From e76b10163938fd4a8de37efd0ea00b5c84a80427 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 24 Oct 2026 02:59:35 +0200 Subject: [PATCH] feat(app): restore manual workspace sorting Keep manual workspace order separate from automatic sorting so surviving pre-beta arrangements and subsequent drags remain available across mode changes. Created stays the default; data already discarded by the beta cannot be reconstructed. --- .../app/e2e/browser/sidebar-reorder.spec.ts | 59 ++++++++- .../browser/sidebar-sort-old-daemon.spec.ts | 3 + .../src/components/sidebar-workspace-list.tsx | 115 ++++++++++++++++-- .../sidebar/display-preferences/menu.tsx | 39 ++++-- .../sidebar-workspaces-view-model.test.ts | 19 ++- .../hooks/sidebar-workspaces-view-model.ts | 42 ++++++- .../src/hooks/use-sidebar-workspaces-list.ts | 11 +- packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 1 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 1 + packages/app/src/i18n/resources/ja.ts | 1 + packages/app/src/i18n/resources/ko.ts | 1 + packages/app/src/i18n/resources/pt-BR.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + .../app/src/projects/workspace-structure.ts | 2 +- .../src/stores/session-store-hooks/index.ts | 6 +- .../session-store-hooks/selectors.test.ts | 24 ++++ .../stores/session-store-hooks/selectors.ts | 17 ++- .../src/stores/sidebar-order-store.test.ts | 45 ++++++- .../app/src/stores/sidebar-order-store.ts | 74 ++++++++++- .../app/src/stores/sidebar-view-store.test.ts | 4 + packages/app/src/stores/sidebar-view-store.ts | 2 +- 24 files changed, 435 insertions(+), 36 deletions(-) diff --git a/packages/app/e2e/browser/sidebar-reorder.spec.ts b/packages/app/e2e/browser/sidebar-reorder.spec.ts index f6bf4d3f1b..03ffe9f7db 100644 --- a/packages/app/e2e/browser/sidebar-reorder.spec.ts +++ b/packages/app/e2e/browser/sidebar-reorder.spec.ts @@ -123,7 +123,7 @@ test("projects and pinned chats reorder while ordinary workspaces stay newest fi } }); -async function selectWorkspaceSort(page: Page, name: "Created" | "Last prompt") { +async function selectWorkspaceSort(page: Page, name: "Manual" | "Created" | "Last prompt") { await page.getByLabel("Display preferences", { exact: true }).click(); await page.getByRole("menuitem", { name: /^Sort/ }).click(); await page.getByRole("menuitem", { name, exact: true }).click(); @@ -362,3 +362,60 @@ test("compact groups retain active overflow and reveal the current ordered rows" await fixture.seed.cleanup(); } }); + +async function restorePreBetaOrder(page: Page, projectKey: string, workspaceKeys: string[]) { + await page.evaluate( + ({ projectKey: scope, workspaceKeys: order }) => { + localStorage.setItem( + "sidebar-project-workspace-order", + JSON.stringify({ + version: 1, + state: { + projectOrder: [scope], + pinnedWorkspaceOrder: [], + workspaceOrderByProject: { [scope]: order }, + }, + }), + ); + }, + { projectKey, workspaceKeys }, + ); + await reconnectSidebar(page); +} + +test("Manual restores pre-beta order and keeps drag order across sorting and reloads", async ({ + page, +}) => { + const seed = await seedWorkspace({ repoPrefix: "sidebar-manual-" }); + try { + const second = await seed.client.createWorkspace({ + source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId }, + title: "New manual workspace", + }); + if (!second.workspace) throw new Error(second.error ?? "Workspace creation failed"); + const keys = [seed.workspaceId, second.workspace.id].map((id) => `${getServerId()}:${id}`); + const ids = keys.map((key) => `sidebar-workspace-row-${key}`); + const rows = page.locator(ids.map((id) => `[data-testid="${id}"]`).join(", ")); + await gotoAppShell(page); + await waitForSidebarHydration(page); + await restorePreBetaOrder(page, projectEquivalenceViewKey(seed.projectKey), keys); + await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]); + await selectWorkspaceSort(page, "Manual"); + await expect.poll(() => rowTestIds(rows)).toEqual(ids); + await selectWorkspaceSort(page, "Created"); + await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]); + await selectWorkspaceSort(page, "Last prompt"); + await selectWorkspaceSort(page, "Manual"); + await expect.poll(() => rowTestIds(rows)).toEqual(ids); + await quickDragFirstRowAfterSecond(rows, pressWorkspaceRow); + await reconnectSidebar(page); + await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]); + await quickDragFirstRowAfterSecond(rows, pressWorkspaceRow); + await selectWorkspaceSort(page, "Created"); + await reconnectSidebar(page); + await selectWorkspaceSort(page, "Manual"); + await expect.poll(() => rowTestIds(rows)).toEqual(ids); + } finally { + await seed.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/sidebar-sort-old-daemon.spec.ts b/packages/app/e2e/browser/sidebar-sort-old-daemon.spec.ts index ba210131db..757b8908c2 100644 --- a/packages/app/e2e/browser/sidebar-sort-old-daemon.spec.ts +++ b/packages/app/e2e/browser/sidebar-sort-old-daemon.spec.ts @@ -72,6 +72,9 @@ async function expectSortingRequiresHostUpdate(page: Page) { ).toBeDisabled(); await expect(page.getByRole("menuitem", { name: "Created", exact: true })).toHaveCount(0); await expect(page.getByRole("menuitem", { name: "Last prompt", exact: true })).toHaveCount(0); + await page.getByRole("menuitem", { name: "Manual", exact: true }).click(); + await page.getByLabel("Display preferences", { exact: true }).click(); + await expect(page.getByRole("menuitem", { name: /^Sort/ })).toContainText("Manual"); await page.keyboard.press("Escape"); } diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index cc248adeff..c3048e6d9a 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -1550,6 +1550,11 @@ function ProjectBlock({ shortcutIndexByWorkspaceKey, onToggleCollapsed, onWorkspacePress, + parentGestureRef, + onWorkspaceReorder, + useNestable, + dragGestureHostActive, + manualSort, onWorktreeCreated, drag, isDragging, @@ -1571,6 +1576,11 @@ function ProjectBlock({ shortcutIndexByWorkspaceKey: Map; onToggleCollapsed: (projectViewKey: string) => void; onWorkspacePress?: () => void; + parentGestureRef?: MutableRefObject; + onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void; + useNestable: boolean; + dragGestureHostActive?: boolean; + manualSort: boolean; onWorktreeCreated?: (workspaceId: string) => void; drag: () => void; isDragging: boolean; @@ -1624,7 +1634,14 @@ function ProjectBlock({ }); const renderWorkspaceRow = useCallback( - (item: SidebarWorkspacePlacement) => { + ( + item: SidebarWorkspacePlacement, + input?: { + drag?: () => void; + isDragging?: boolean; + dragHandleProps?: DraggableListDragHandleProps; + }, + ) => { return ( ); }, @@ -1658,6 +1678,29 @@ function ProjectBlock({ ], ); + const renderWorkspace = useCallback( + ({ + item, + drag: workspaceDrag, + isActive, + dragHandleProps: workspaceDragHandleProps, + }: DraggableRenderItemInfo) => { + return renderWorkspaceRow(item, { + drag: workspaceDrag, + isDragging: isActive, + dragHandleProps: workspaceDragHandleProps, + }); + }, + [renderWorkspaceRow], + ); + + const handleWorkspaceDragEnd = useCallback( + (workspaces: SidebarWorkspacePlacement[]) => { + onWorkspaceReorder(project.viewKey, workspaces); + }, + [onWorkspaceReorder, project.viewKey], + ); + const toast = useToast(); const { t } = useTranslation(); const [isRemovingProject, setIsRemovingProject] = useState(false); @@ -1723,12 +1766,29 @@ function ProjectBlock({ if (project.workspaces.length > 0) { projectChildren = ( <> - - {visibleWorkspaces.map(renderWorkspaceRow)} - + {manualSort ? ( + + ) : ( + + {visibleWorkspaces.map((item) => renderWorkspaceRow(item))} + + )} {canToggleWorkspaces ? ( state.togglePinnedCollapsed, ); + const manualSort = useSidebarViewStore((state) => state.workspaceSort === "manual"); + const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder); + const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder); const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder); const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder); @@ -2211,6 +2279,30 @@ function ProjectModeList({ [getProjectOrder, setProjectOrder], ); + const handleWorkspaceReorder = useCallback( + (projectViewKey: string, reorderedWorkspaces: SidebarWorkspacePlacement[]) => { + const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey); + const currentWorkspaceOrder = getWorkspaceOrder(projectViewKey); + if ( + !hasVisibleOrderChanged({ + currentOrder: currentWorkspaceOrder, + reorderedVisibleKeys: reorderedWorkspaceKeys, + }) + ) { + return; + } + + setWorkspaceOrder( + projectViewKey, + mergeWithRemainder({ + currentOrder: currentWorkspaceOrder, + reorderedVisibleKeys: reorderedWorkspaceKeys, + }), + ); + }, + [getWorkspaceOrder, setWorkspaceOrder], + ); + const handleWorktreeCreated = useCallback((workspaceId: string) => { setCreatingWorkspaceIds((current) => { const next = new Set(current); @@ -2259,6 +2351,11 @@ function ProjectModeList({ shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} onToggleCollapsed={onToggleProjectCollapsed} onWorkspacePress={onWorkspacePress} + manualSort={manualSort} + parentGestureRef={parentGestureRef} + onWorkspaceReorder={handleWorkspaceReorder} + useNestable={platformIsNative} + dragGestureHostActive={dragGestureHostActive} onWorktreeCreated={handleWorktreeCreated} drag={dragState.drag} isDragging={dragState.isDragging} @@ -2276,6 +2373,10 @@ function ProjectModeList({ collapsedProjectKeys, activeWorkspaceSelection, handleWorktreeCreated, + manualSort, + parentGestureRef, + dragGestureHostActive, + handleWorkspaceReorder, hostBadgeByServerId, supportsMultiplicityByServerId, supportsPinningByServerId, diff --git a/packages/app/src/components/sidebar/display-preferences/menu.tsx b/packages/app/src/components/sidebar/display-preferences/menu.tsx index 4fb04ebb67..d6483a5519 100644 --- a/packages/app/src/components/sidebar/display-preferences/menu.tsx +++ b/packages/app/src/components/sidebar/display-preferences/menu.tsx @@ -244,10 +244,8 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { definitions.push({ id: "sort", title: t("sidebar.display.sort.label"), - content: workspaceSortSupported ? ( - - ) : ( - {t("sidebar.display.sort.updateHost")} + content: ( + ), }); } @@ -325,7 +323,7 @@ export function SidebarDisplayPreferencesMenu(): ReactElement { setWorkspaceSort("manual"), [setWorkspaceSort]); const selectCreated = useCallback(() => setWorkspaceSort("created"), [setWorkspaceSort]); const selectLastPrompt = useCallback(() => setWorkspaceSort("lastPrompt"), [setWorkspaceSort]); return ( <> - - {t("sidebar.display.sort.created")} - - - {t("sidebar.display.sort.lastPrompt")} + + {t("sidebar.display.sort.manual")} + {automaticSortSupported ? ( + <> + + {t("sidebar.display.sort.created")} + + + {t("sidebar.display.sort.lastPrompt")} + + + ) : ( + {t("sidebar.display.sort.updateHost")} + )} ); } diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts index 59648e237b..7aa85c1faf 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts @@ -617,7 +617,7 @@ describe("computeSidebarOrderUpdates", () => { persistedProjectOrder: ["stale-project"], }); - expect(updates).toEqual({ projectOrder: null }); + expect(updates).toEqual({ projectOrder: null, workspaceOrders: [] }); }); it("appends unseen projects without changing saved project order", () => { @@ -979,3 +979,20 @@ it("retains unrelated projects and keyed row placements when one project reorder expect(after.projects[0].workspaces[0]).toBe(before.projects[0].workspaces[1]); expect(after.projects[0].workspaces[1]).toBe(before.projects[0].workspaces[0]); }); + +it("prepends newly discovered manual workspaces without dropping filtered saved keys", () => { + const projects = [sidebarProject({ projectKey: "p", workspaceKeys: ["new", "b", "a"] })]; + expect( + computeSidebarOrderUpdates({ + projects, + persistedProjectOrder: ["p"], + getWorkspaceOrder: () => ["srv:hidden", "srv:a", "srv:b"], + }), + ).toEqual({ + projectOrder: null, + workspaceOrders: [{ projectViewKey: "p", order: ["srv:new", "srv:hidden", "srv:a", "srv:b"] }], + }); + expect( + computeSidebarOrderUpdates({ projects, persistedProjectOrder: ["p"] }).workspaceOrders, + ).toEqual([]); +}); diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index 3c4ce4e2da..da1086da45 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -535,21 +535,57 @@ export function appendMissingOrderKeys(input: { return [...input.currentOrder, ...missingKeys]; } +export function prependMissingOrderKeys(input: { + currentOrder: string[]; + visibleKeys: string[]; +}): string[] { + if (input.visibleKeys.length === 0) { + return input.currentOrder; + } + + const existingKeys = new Set(input.currentOrder); + const missingKeys = input.visibleKeys.filter((key) => !existingKeys.has(key)); + if (missingKeys.length === 0) { + return input.currentOrder; + } + + return [...missingKeys, ...input.currentOrder]; +} + export interface SidebarOrderUpdates { projectOrder: string[] | null; + workspaceOrders: Array<{ projectViewKey: string; order: string[] }>; } export function computeSidebarOrderUpdates(input: { projects: SidebarProjectEntry[]; persistedProjectOrder: string[]; + getWorkspaceOrder?: (projectViewKey: string) => string[]; }): SidebarOrderUpdates { + if (input.projects.length === 0) { + return { projectOrder: null, workspaceOrders: [] }; + } + const nextProjectOrder = appendMissingOrderKeys({ currentOrder: input.persistedProjectOrder, visibleKeys: input.projects.map((project) => project.viewKey), }); - return { - projectOrder: nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder, - }; + const projectOrder = nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder; + + const workspaceOrders: Array<{ projectViewKey: string; order: string[] }> = []; + for (const project of input.projects) { + if (!input.getWorkspaceOrder) break; + const persistedWorkspaceOrder = input.getWorkspaceOrder(project.viewKey); + const nextWorkspaceOrder = prependMissingOrderKeys({ + currentOrder: persistedWorkspaceOrder, + visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey), + }); + if (nextWorkspaceOrder !== persistedWorkspaceOrder) { + workspaceOrders.push({ projectViewKey: project.viewKey, order: nextWorkspaceOrder }); + } + } + + return { projectOrder, workspaceOrders }; } export interface SidebarLoadingState { diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index 5665b07e11..378c4b4862 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -144,7 +144,10 @@ export function useSidebarWorkspacesList(options?: { const groupMode = useSidebarViewStore((state) => state.groupMode); const sortSupport = useHostFeatureMap(directoryServerIds, "workspaceSortTimes"); const workspaceSortSupported = [...sortSupport.values()].every(Boolean); - const sort = workspaceSortSupported && groupMode === "project" ? workspaceSort : undefined; + const sort = + groupMode === "project" && (workspaceSort === "manual" || workspaceSortSupported) + ? workspaceSort + : undefined; const { projects: hostProjects } = useWorkspaceStructure(directoryServerIds, sort); const selectPlacements = useMemo(createSidebarWorkspacePlacementSelector, []); @@ -166,12 +169,16 @@ export function useSidebarWorkspacesList(options?: { const updates = computeSidebarOrderUpdates({ projects, persistedProjectOrder, + getWorkspaceOrder: sort === "manual" ? orderStore.getWorkspaceOrder : undefined, }); if (updates.projectOrder) { orderStore.setProjectOrder(updates.projectOrder); } - }, [persistedProjectOrder, projects]); + for (const update of updates.workspaceOrders) { + orderStore.setWorkspaceOrder(update.projectViewKey, update.order); + } + }, [persistedProjectOrder, projects, sort]); const refreshAll = useCallback(() => { if (!isActive) return; diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 16779d95e1..c765f3b835 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1169,6 +1169,7 @@ export const ar: TranslationResources = { sort: { label: "الترتيب", created: "تاريخ الإنشاء", + manual: "يدوي", lastPrompt: "آخر طلب", updateHost: "حدّث المضيفين لترتيب مساحات العمل", }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 6471b03f60..351fb33ac3 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1177,6 +1177,7 @@ export const en = { sort: { label: "Sort", created: "Created", + manual: "Manual", lastPrompt: "Last prompt", updateHost: "Update hosts to sort workspaces", }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 82ab9e6977..fd195ce599 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1205,6 +1205,7 @@ export const es: TranslationResources = { sort: { label: "Orden", created: "Creación", + manual: "Manual", lastPrompt: "Último prompt", updateHost: "Actualiza los hosts para ordenar los espacios de trabajo", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 44aae87628..975d6a3736 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1198,6 +1198,7 @@ export const fr: TranslationResources = { sort: { label: "Tri", created: "Création", + manual: "Manuel", lastPrompt: "Dernier prompt", updateHost: "Mettez les hôtes à jour pour trier les espaces de travail", }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index baa248aa31..60a207e1e1 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1182,6 +1182,7 @@ export const ja: TranslationResources = { sort: { label: "並び順", created: "作成日時", + manual: "手動", lastPrompt: "最後のプロンプト", updateHost: "ワークスペースを並べ替えるにはホストを更新してください", }, diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index a573ef1ffa..4773625327 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1176,6 +1176,7 @@ export const ko: TranslationResources = { sort: { label: "정렬", created: "생성일", + manual: "수동", lastPrompt: "마지막 프롬프트", updateHost: "작업 공간을 정렬하려면 호스트를 업데이트하세요", }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 43c32d2914..766c6d98ed 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1195,6 +1195,7 @@ export const ptBR: TranslationResources = { sort: { label: "Ordenação", created: "Criação", + manual: "Manual", lastPrompt: "Último prompt", updateHost: "Atualize os hosts para ordenar os espaços de trabalho", }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 586ad8595d..23f3d810ee 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1186,6 +1186,7 @@ export const ru: TranslationResources = { sort: { label: "Сортировка", created: "Дата создания", + manual: "Вручную", lastPrompt: "Последний запрос", updateHost: "Обновите хосты для сортировки рабочих пространств", }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 08bb8491cd..43d8ceeef2 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1161,6 +1161,7 @@ export const zhCN: TranslationResources = { sort: { label: "排序", created: "创建时间", + manual: "手动", lastPrompt: "最近提示词", updateHost: "请更新主机以排序工作区", }, diff --git a/packages/app/src/projects/workspace-structure.ts b/packages/app/src/projects/workspace-structure.ts index 92e6c68de4..0dac64b371 100644 --- a/packages/app/src/projects/workspace-structure.ts +++ b/packages/app/src/projects/workspace-structure.ts @@ -30,7 +30,7 @@ interface WorkspaceStructureSession { workspaces: Iterable; } -export type WorkspaceSort = "created" | "lastPrompt"; +export type WorkspaceSort = "manual" | "created" | "lastPrompt"; interface WorkspaceSortItem { workspaceName: string; diff --git a/packages/app/src/stores/session-store-hooks/index.ts b/packages/app/src/stores/session-store-hooks/index.ts index 48c5594f21..3dba13bc80 100644 --- a/packages/app/src/stores/session-store-hooks/index.ts +++ b/packages/app/src/stores/session-store-hooks/index.ts @@ -117,13 +117,17 @@ export function useWorkspaceStructure( (state) => selectProjectOrder(state), workspaceEqualityFns.deep, ); + const workspaceOrderByProject = useSidebarOrderStore((state) => + workspaceSort === "manual" ? state.workspaceOrderByProject : undefined, + ); return useMemo( () => composeWorkspaceStructure({ projects, projectOrder, + workspaceOrderByProject, }), - [projectOrder, projects], + [projectOrder, projects, workspaceOrderByProject], ); } diff --git a/packages/app/src/stores/session-store-hooks/selectors.test.ts b/packages/app/src/stores/session-store-hooks/selectors.test.ts index a9c9d07772..95cc1ba498 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.test.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.test.ts @@ -640,3 +640,27 @@ it("a prompt changes only its project's structure and output preserves both proj expect(output[0]).toBe(after[0]); expect(output[1]).toBe(after[1]); }); + +it("applies manual order only when requested without mutating the automatic baseline", () => { + const project = { + viewKey: "p", + projectKey: "p", + projectName: "P", + projectKind: "git" as const, + iconWorkingDir: "/repo", + hosts: [], + workspaceKeys: ["h:new", "h:b", "h:a"], + }; + const input = { projects: [project], projectOrder: ["p"] }; + expect( + composeWorkspaceStructure({ + ...input, + workspaceOrderByProject: { p: ["h:filtered", "h:a", "h:b"] }, + }).projects[0].workspaceKeys, + ).toEqual(["h:new", "h:a", "h:b"]); + expect(composeWorkspaceStructure(input).projects[0].workspaceKeys).toEqual([ + "h:new", + "h:b", + "h:a", + ]); +}); diff --git a/packages/app/src/stores/session-store-hooks/selectors.ts b/packages/app/src/stores/session-store-hooks/selectors.ts index 6c1100edd6..c131fa7a3a 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.ts @@ -254,6 +254,7 @@ export function selectProjectOrder(state: SidebarOrderSnapshot): string[] { export function composeWorkspaceStructure(input: { projects: WorkspaceStructureProject[]; projectOrder: readonly string[]; + workspaceOrderByProject?: Readonly>; }): WorkspaceStructure { if (input.projects.length === 0) { return EMPTY_WORKSPACE_STRUCTURE; @@ -265,7 +266,21 @@ export function composeWorkspaceStructure(input: { getKey: (project) => project.viewKey, }); - return { projects: orderedProjects }; + return { + // Keep the shared automatic projection immutable when applying a sidebar preference. + // eslint-disable-next-line oxc/no-map-spread + projects: orderedProjects.map((project) => { + const storedOrder = input.workspaceOrderByProject?.[project.viewKey]; + if (!storedOrder) return project; + const orderedProject = { ...project }; + orderedProject.workspaceKeys = applyStoredOrdering({ + items: project.workspaceKeys, + storedOrder, + getKey: (key) => key, + }); + return orderedProject; + }), + }; } export function selectWorkspaceKeys(state: SessionsSnapshot, serverId: string | null): string[] { diff --git a/packages/app/src/stores/sidebar-order-store.test.ts b/packages/app/src/stores/sidebar-order-store.test.ts index b34fb02240..b288089742 100644 --- a/packages/app/src/stores/sidebar-order-store.test.ts +++ b/packages/app/src/stores/sidebar-order-store.test.ts @@ -6,7 +6,7 @@ import { import { migrateSidebarOrderState, useSidebarOrderStore } from "./sidebar-order-store"; describe("migrateSidebarOrderState", () => { - it("discards legacy workspace ordering while retaining project ordering", () => { + it("preserves legacy workspace ordering across hosts", () => { const migrated = migrateSidebarOrderState({ projectOrderByServerId: { "host-a": ["project-a"], @@ -21,6 +21,7 @@ describe("migrateSidebarOrderState", () => { expect(migrated).toEqual({ projectOrder: ["project-a"], pinnedWorkspaceOrder: [], + workspaceOrderByProject: { "project-a": ["host-a:main", "host-a:feature", "host-b:main"] }, }); }); @@ -88,14 +89,18 @@ describe("sidebar order keys that end in whitespace", () => { }); }); -it("preserves project and pinned order while discarding ordinary workspace order", () => { +it("preserves project, pinned and ordinary workspace order", () => { expect( migrateSidebarOrderState({ projectOrder: ["b", "a"], pinnedWorkspaceOrder: ["h:y", "h:x"], workspaceOrderByProject: { a: ["h:old", "h:new"] }, }), - ).toEqual({ projectOrder: ["b", "a"], pinnedWorkspaceOrder: ["h:y", "h:x"] }); + ).toEqual({ + projectOrder: ["b", "a"], + pinnedWorkspaceOrder: ["h:y", "h:x"], + workspaceOrderByProject: { a: ["h:old", "h:new"] }, + }); }); it("preserves whitespace in current project and pin identities during upgrade", () => { @@ -105,5 +110,37 @@ it("preserves whitespace in current project and pin identities during upgrade", pinnedWorkspaceOrder: ["host:workspace "], workspaceOrderByProject: {}, }), - ).toEqual({ projectOrder: ["project "], pinnedWorkspaceOrder: ["host:workspace "] }); + ).toEqual({ + projectOrder: ["project "], + pinnedWorkspaceOrder: ["host:workspace "], + workspaceOrderByProject: {}, + }); +}); + +it("retains a pre-beta manual order when upgrading", () => { + expect( + migrateSidebarOrderState({ + projectOrder: ["project"], + pinnedWorkspaceOrder: [], + workspaceOrderByProject: { project: ["host:b", "host:a"] }, + }), + ).toEqual({ + projectOrder: ["project"], + pinnedWorkspaceOrder: [], + workspaceOrderByProject: { project: ["host:b", "host:a"] }, + }); +}); + +it("starts with an empty manual order for beta users whose order was discarded", () => { + expect(migrateSidebarOrderState({ projectOrder: ["p"], pinnedWorkspaceOrder: ["h:a"] })).toEqual({ + projectOrder: ["p"], + pinnedWorkspaceOrder: ["h:a"], + workspaceOrderByProject: {}, + }); +}); +it("preserves spaces in saved workspace scopes and identities", () => { + expect( + migrateSidebarOrderState({ workspaceOrderByProject: { "project ": ["host:b ", "host:a "] } }) + .workspaceOrderByProject, + ).toEqual({ "project ": ["host:b ", "host:a "] }); }); diff --git a/packages/app/src/stores/sidebar-order-store.ts b/packages/app/src/stores/sidebar-order-store.ts index 54f27e714f..44e3887620 100644 --- a/packages/app/src/stores/sidebar-order-store.ts +++ b/packages/app/src/stores/sidebar-order-store.ts @@ -7,10 +7,13 @@ import { createValidatedPersistStorage } from "@/storage/validated-persist-stora interface SidebarOrderStoreState { projectOrder: string[]; pinnedWorkspaceOrder: string[]; + workspaceOrderByProject: Record; getProjectOrder: () => string[]; setProjectOrder: (keys: string[]) => void; getPinnedWorkspaceOrder: () => string[]; setPinnedWorkspaceOrder: (keys: string[]) => void; + getWorkspaceOrder: (projectViewKey: string) => string[]; + setWorkspaceOrder: (projectViewKey: string, keys: string[]) => void; } interface SidebarOrderPersistedState { @@ -25,12 +28,16 @@ const StringArrayRecordSchema = z.record(z.string(), z.array(z.string())); const SidebarOrderPersistedStateSchema = z.strictObject({ projectOrder: z.array(z.string()).optional(), pinnedWorkspaceOrder: z.array(z.string()).optional(), - // COMPAT(automaticWorkspaceOrder): v0.13.0; accept discarded keys until 2027-04-22 so strict parsing preserves projects/pins. workspaceOrderByProject: StringArrayRecordSchema.optional(), projectOrderByServerId: StringArrayRecordSchema.optional(), workspaceOrderByServerAndProject: StringArrayRecordSchema.optional(), }); +interface SidebarWorkspaceOrderScope { + serverId: string; + projectViewKey: string; +} + // Trims each key. Only for persisted state read at migration time, where a // stray space is an artifact of an older format rather than part of the key. function normalizeKeys(keys: string[]): string[] { @@ -71,13 +78,42 @@ function dedupeKeys(keys: string[]): string[] { return deduped; } +function normalizeWorkspaceOrderByProject( + workspaceOrderByProject: Record | undefined, +): Record { + const normalized: Record = {}; + for (const [projectViewKey, order] of Object.entries(workspaceOrderByProject ?? {})) { + const scope = projectViewKey; + if (!scope.trim()) continue; + normalized[scope] = dedupeKeys(order); + } + return normalized; +} + +function extractWorkspaceOrderScope(scopeKey: string): SidebarWorkspaceOrderScope | null { + const separatorIndex = scopeKey.indexOf("::"); + if (separatorIndex < 0) return null; + const serverId = scopeKey.slice(0, separatorIndex).trim(); + const projectViewKey = scopeKey.slice(separatorIndex + 2).trim(); + if (!serverId || !projectViewKey) return null; + return { serverId, projectViewKey }; +} + +function normalizeLegacyWorkspaceKey(serverId: string, rawWorkspaceKey: string): string | null { + const workspaceKey = rawWorkspaceKey.trim(); + if (!workspaceKey) return null; + const serverPrefix = `${serverId}:`; + return workspaceKey.startsWith(serverPrefix) ? workspaceKey : `${serverPrefix}${workspaceKey}`; +} + export function migrateSidebarOrderState(persistedState: unknown): { projectOrder: string[]; pinnedWorkspaceOrder: string[]; + workspaceOrderByProject: Record; } { const result = SidebarOrderPersistedStateSchema.safeParse(persistedState); if (!result.success) { - return { projectOrder: [], pinnedWorkspaceOrder: [] }; + return { projectOrder: [], pinnedWorkspaceOrder: [], workspaceOrderByProject: {} }; } const state: SidebarOrderPersistedState = result.data; @@ -91,9 +127,26 @@ export function migrateSidebarOrderState(persistedState: unknown): { } } + const workspaceOrderByProject = normalizeWorkspaceOrderByProject(state.workspaceOrderByProject); + for (const [scopeKey, order] of Object.entries(state.workspaceOrderByServerAndProject ?? {})) { + const scope = extractWorkspaceOrderScope(scopeKey); + if (!scope) continue; + const existing = workspaceOrderByProject[scope.projectViewKey] ?? []; + const merged = [...existing]; + const seen = new Set(merged); + for (const key of order) { + const workspaceKey = normalizeLegacyWorkspaceKey(scope.serverId, key); + if (!workspaceKey || seen.has(workspaceKey)) continue; + seen.add(workspaceKey); + merged.push(workspaceKey); + } + workspaceOrderByProject[scope.projectViewKey] = merged; + } + return { projectOrder, pinnedWorkspaceOrder: dedupeKeys(state.pinnedWorkspaceOrder ?? []), + workspaceOrderByProject, }; } @@ -102,6 +155,7 @@ export const useSidebarOrderStore = create()( (set, get) => ({ projectOrder: [], pinnedWorkspaceOrder: [], + workspaceOrderByProject: {}, getProjectOrder: () => get().projectOrder, setProjectOrder: (keys) => { set({ projectOrder: dedupeKeys(keys) }); @@ -110,6 +164,19 @@ export const useSidebarOrderStore = create()( setPinnedWorkspaceOrder: (keys) => { set({ pinnedWorkspaceOrder: dedupeKeys(keys) }); }, + getWorkspaceOrder: (projectViewKey) => { + if (!projectViewKey.trim()) return []; + return get().workspaceOrderByProject[projectViewKey] ?? []; + }, + setWorkspaceOrder: (projectViewKey, keys) => { + if (!projectViewKey.trim()) return; + set((state) => ({ + workspaceOrderByProject: { + ...state.workspaceOrderByProject, + [projectViewKey]: dedupeKeys(keys), + }, + })); + }, }), { name: "sidebar-project-workspace-order", @@ -117,8 +184,9 @@ export const useSidebarOrderStore = create()( partialize: (state) => ({ projectOrder: state.projectOrder, pinnedWorkspaceOrder: state.pinnedWorkspaceOrder, + workspaceOrderByProject: state.workspaceOrderByProject, }), - version: 2, + version: 3, migrate: migrateSidebarOrderState, }, ), diff --git a/packages/app/src/stores/sidebar-view-store.test.ts b/packages/app/src/stores/sidebar-view-store.test.ts index a9e44e146b..4a277ae60d 100644 --- a/packages/app/src/stores/sidebar-view-store.test.ts +++ b/packages/app/src/stores/sidebar-view-store.test.ts @@ -300,3 +300,7 @@ it("defaults to Created and persists the Last prompt choice across migration", ( migrateSidebarViewState({ workspaceSort: "lastPrompt", groupMode: "status" }), ).toMatchObject({ workspaceSort: "lastPrompt", groupMode: "status" }); }); + +it("retains the manual sorting preference on reload", () => { + expect(migrateSidebarViewState({ workspaceSort: "manual" }).workspaceSort).toBe("manual"); +}); diff --git a/packages/app/src/stores/sidebar-view-store.ts b/packages/app/src/stores/sidebar-view-store.ts index 229e95f96b..1b53fdfe9d 100644 --- a/packages/app/src/stores/sidebar-view-store.ts +++ b/packages/app/src/stores/sidebar-view-store.ts @@ -91,7 +91,7 @@ const SidebarLabelFilterSchema = z.object({ labels: z.array(z.string()), }); const SidebarViewPersistedStateSchema = z.strictObject({ - workspaceSort: z.enum(["created", "lastPrompt"]).optional(), + workspaceSort: z.enum(["manual", "created", "lastPrompt"]).optional(), showBackground: z.boolean().optional(), groupMode: PersistedSidebarGroupModeSchema.optional(), hostFilters: z.array(z.string()).optional(),