feat: projects → workspaces → tabs

This commit is contained in:
Mohamed Boudra
2026-03-01 18:22:11 +07:00
parent 2e201cab09
commit 080fb74642
43 changed files with 4054 additions and 2654 deletions
+6 -9
View File
@@ -18,7 +18,7 @@ interface DownloadProgress {
export interface Download {
id: string;
serverId: string;
agentId: string;
scopeId: string;
fileName: string;
status: "downloading" | "complete" | "error";
message?: string;
@@ -32,14 +32,11 @@ interface DownloadState {
startDownload: (params: {
serverId: string;
agentId: string;
scopeId: string;
fileName: string;
path: string;
daemonProfile: HostProfile | undefined;
requestFileDownloadToken: (
agentId: string,
path: string
) => Promise<{
requestFileDownloadToken: (path: string) => Promise<{
token: string | null;
fileName: string | null;
mimeType: string | null;
@@ -64,7 +61,7 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
startDownload: async ({
serverId,
agentId,
scopeId,
fileName,
path,
daemonProfile,
@@ -74,7 +71,7 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
const download: Download = {
id,
serverId,
agentId,
scopeId,
fileName,
status: "downloading",
startedAt: Date.now(),
@@ -86,7 +83,7 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
}));
try {
const tokenResponse = await requestFileDownloadToken(agentId, path);
const tokenResponse = await requestFileDownloadToken(path);
if (tokenResponse.error || !tokenResponse.token) {
throw new Error(tokenResponse.error ?? "Failed to request download token.");
}
@@ -1,7 +1,7 @@
export type ExplorerTab = "changes" | "files" | "terminals";
export type ExplorerTab = "changes" | "files";
export function isExplorerTab(value: unknown): value is ExplorerTab {
return value === "changes" || value === "files" || value === "terminals";
return value === "changes" || value === "files";
}
export function buildExplorerCheckoutKey(serverId: string, cwd: string): string | null {
+3 -3
View File
@@ -44,7 +44,7 @@ describe("panel-store explorer tab resolution", () => {
).toBe("files");
});
it("restores a stored terminals tab for git checkouts", () => {
it("falls back to default when stored tab is invalid", () => {
const key = buildExplorerCheckoutKey(serverId, cwd)!;
expect(
resolveExplorerTabForCheckout({
@@ -52,10 +52,10 @@ describe("panel-store explorer tab resolution", () => {
cwd,
isGit: true,
explorerTabByCheckout: {
[key]: "terminals",
[key]: "terminals" as any,
},
})
).toBe("terminals");
).toBe("changes");
});
it("coerces stored changes to files for non-git checkouts", () => {
+5 -1
View File
@@ -336,7 +336,7 @@ export const usePanelStore = create<PanelState>()(
}),
{
name: "panel-state",
version: 4,
version: 5,
storage: createJSONStorage(() => AsyncStorage),
migrate: (persistedState, version) => {
const state = persistedState as Partial<PanelState> & Record<string, unknown>;
@@ -369,6 +369,10 @@ export const usePanelStore = create<PanelState>()(
}
}
if (!isExplorerTab(state.explorerTab)) {
state.explorerTab = "changes";
}
if (version < 4 || typeof state.explorerTabByCheckout !== "object" || !state.explorerTabByCheckout) {
state.explorerTabByCheckout = {};
} else {
+57 -63
View File
@@ -1,99 +1,93 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import AsyncStorage from '@react-native-async-storage/async-storage'
import { create } from 'zustand'
import { createJSONStorage, persist } from 'zustand/middleware'
interface SidebarOrderStoreState {
orderByServerId: Record<string, string[]>;
getOrder: (serverId: string) => string[];
setOrder: (serverId: string, keys: string[]) => void;
insertAtTop: (serverId: string, key: string) => void;
remove: (serverId: string, key: string) => void;
projectOrderByServerId: Record<string, string[]>
workspaceOrderByServerAndProject: Record<string, string[]>
getProjectOrder: (serverId: string) => string[]
setProjectOrder: (serverId: string, keys: string[]) => void
getWorkspaceOrder: (serverId: string, projectKey: string) => string[]
setWorkspaceOrder: (serverId: string, projectKey: string, keys: string[]) => void
}
function normalizeKeys(keys: string[]): string[] {
const seen = new Set<string>();
const normalized: string[] = [];
const seen = new Set<string>()
const normalized: string[] = []
for (const rawKey of keys) {
const key = rawKey.trim();
const key = rawKey.trim()
if (!key || seen.has(key)) {
continue;
continue
}
seen.add(key);
normalized.push(key);
seen.add(key)
normalized.push(key)
}
return normalized;
return normalized
}
function buildWorkspaceScopeKey(serverId: string, projectKey: string): string {
return `${serverId.trim()}::${projectKey.trim()}`
}
export const useSidebarOrderStore = create<SidebarOrderStoreState>()(
persist(
(set, get) => ({
orderByServerId: {},
getOrder: (serverId) => {
const key = serverId.trim();
projectOrderByServerId: {},
workspaceOrderByServerAndProject: {},
getProjectOrder: (serverId) => {
const key = serverId.trim()
if (!key) {
return [];
return []
}
return get().orderByServerId[key] ?? [];
return get().projectOrderByServerId[key] ?? []
},
setOrder: (serverId, keys) => {
const key = serverId.trim();
setProjectOrder: (serverId, keys) => {
const key = serverId.trim()
if (!key) {
return;
return
}
const normalized = normalizeKeys(keys);
const normalized = normalizeKeys(keys)
set((state) => ({
orderByServerId: {
...state.orderByServerId,
projectOrderByServerId: {
...state.projectOrderByServerId,
[key]: normalized,
},
}));
}))
},
insertAtTop: (serverId, rawKey) => {
const serverKey = serverId.trim();
const entryKey = rawKey.trim();
if (!serverKey || !entryKey) {
return;
getWorkspaceOrder: (serverId, projectKey) => {
const serverKey = serverId.trim()
const projectScope = projectKey.trim()
if (!serverKey || !projectScope) {
return []
}
set((state) => {
const current = state.orderByServerId[serverKey] ?? [];
const next = [entryKey, ...current.filter((key) => key !== entryKey)];
return {
orderByServerId: {
...state.orderByServerId,
[serverKey]: next,
},
};
});
const scopeKey = buildWorkspaceScopeKey(serverKey, projectScope)
return get().workspaceOrderByServerAndProject[scopeKey] ?? []
},
remove: (serverId, rawKey) => {
const serverKey = serverId.trim();
const entryKey = rawKey.trim();
if (!serverKey || !entryKey) {
return;
setWorkspaceOrder: (serverId, projectKey, keys) => {
const serverKey = serverId.trim()
const projectScope = projectKey.trim()
if (!serverKey || !projectScope) {
return
}
set((state) => {
const current = state.orderByServerId[serverKey] ?? [];
const next = current.filter((key) => key !== entryKey);
if (next.length === current.length) {
return state;
}
return {
orderByServerId: {
...state.orderByServerId,
[serverKey]: next,
},
};
});
const scopeKey = buildWorkspaceScopeKey(serverKey, projectScope)
const normalized = normalizeKeys(keys)
set((state) => ({
workspaceOrderByServerAndProject: {
...state.workspaceOrderByServerAndProject,
[scopeKey]: normalized,
},
}))
},
}),
{
name: "sidebar-agent-order",
name: 'sidebar-project-workspace-order',
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({
orderByServerId: state.orderByServerId,
projectOrderByServerId: state.projectOrderByServerId,
workspaceOrderByServerAndProject: state.workspaceOrderByServerAndProject,
}),
}
)
);
)
@@ -0,0 +1,136 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
export type WorkspaceTabTarget =
| { kind: "agent"; agentId: string }
| { kind: "terminal"; terminalId: string };
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function normalizeWorkspaceTab(
value: WorkspaceTabTarget | null | undefined
): WorkspaceTabTarget | null {
if (!value || typeof value !== "object" || typeof value.kind !== "string") {
return null;
}
if (value.kind === "agent") {
const agentId = trimNonEmpty(value.agentId);
if (!agentId) {
return null;
}
return { kind: "agent", agentId };
}
if (value.kind === "terminal") {
const terminalId = trimNonEmpty(value.terminalId);
if (!terminalId) {
return null;
}
return { kind: "terminal", terminalId };
}
return null;
}
export function buildWorkspaceTabPersistenceKey(input: {
serverId: string;
workspaceId: string;
}): string | null {
const serverId = trimNonEmpty(input.serverId);
const workspaceId = trimNonEmpty(input.workspaceId);
if (!serverId || !workspaceId) {
return null;
}
return `${serverId}:${workspaceId}`;
}
type WorkspaceTabsState = {
lastFocusedTabByWorkspace: Record<string, WorkspaceTabTarget>;
setLastFocusedTab: (input: {
serverId: string;
workspaceId: string;
tab: WorkspaceTabTarget;
}) => void;
getLastFocusedTab: (input: {
serverId: string;
workspaceId: string;
}) => WorkspaceTabTarget | null;
};
export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
persist(
(set, get) => ({
lastFocusedTabByWorkspace: {},
setLastFocusedTab: ({ serverId, workspaceId, tab }) => {
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
const normalizedTab = normalizeWorkspaceTab(tab);
if (!key || !normalizedTab) {
return;
}
set((state) => {
const current = state.lastFocusedTabByWorkspace[key];
if (
current &&
current.kind === normalizedTab.kind &&
((current.kind === "agent" && normalizedTab.kind === "agent"
? current.agentId === normalizedTab.agentId
: current.kind === "terminal" && normalizedTab.kind === "terminal"
? current.terminalId === normalizedTab.terminalId
: false))
) {
return state;
}
return {
lastFocusedTabByWorkspace: {
...state.lastFocusedTabByWorkspace,
[key]: normalizedTab,
},
};
});
},
getLastFocusedTab: ({ serverId, workspaceId }) => {
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
if (!key) {
return null;
}
const value = get().lastFocusedTabByWorkspace[key];
return normalizeWorkspaceTab(value);
},
}),
{
name: "workspace-tabs-state",
version: 1,
storage: createJSONStorage(() => AsyncStorage),
migrate: (persistedState) => {
const state = persistedState as
| {
lastFocusedTabByWorkspace?: Record<string, WorkspaceTabTarget>;
}
| undefined;
const raw = state?.lastFocusedTabByWorkspace ?? {};
const next: Record<string, WorkspaceTabTarget> = {};
for (const key in raw) {
const value = raw[key];
const normalized = normalizeWorkspaceTab(value);
if (normalized) {
next[key] = normalized;
}
}
return {
...state,
lastFocusedTabByWorkspace: next,
};
},
}
)
);