diff --git a/apps/desktop/plans/20260306-2140-mixed-project-children-ordering.md b/apps/desktop/plans/20260306-2140-mixed-project-children-ordering.md new file mode 100644 index 00000000000..6b4088885af --- /dev/null +++ b/apps/desktop/plans/20260306-2140-mixed-project-children-ordering.md @@ -0,0 +1,214 @@ +# Mixed project-child ordering for workspace sidebar + +This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. + +Reference: This plan follows conventions from `AGENTS.md` (root), `apps/desktop/AGENTS.md`, and the ExecPlan template at `.agents/commands/create-plan.md`. + + +## Purpose / Big Picture + +The desktop workspace sidebar currently models a project's top-level children as two separate ordered lanes: + +1. ungrouped workspaces +2. sections + +That means users can reorder workspaces against workspaces and sections against sections, but they cannot reorder a section against an ungrouped workspace. The UI exposes this limitation as a drag-and-drop dead-end, but the real constraint is deeper: the server stores and queries these as distinct ordered collections and the sidebar always renders them in a fixed `workspaces -> sections` sequence. + +After this change, a project's top-level children are treated as one ordered list consisting of: + +1. ungrouped workspaces +2. sections + +The important difference is that these items can now be interleaved arbitrarily by `tabOrder`. Section-internal workspaces remain separately ordered within each section. + + +## Assumptions + +1. We can use the existing `tabOrder` columns on `workspaces` and `workspace_sections` as the persisted top-level ordering source of truth. +2. Section-contained workspaces should keep their current intra-section ordering behavior and continue using `workspaces.tabOrder`. +3. We should avoid a database migration unless the existing schema proves insufficient. +4. Keyboard shortcuts and next/previous workspace navigation must follow the same visual order the sidebar renders. + + +## Open Questions + +1. Should the tRPC query shape returned by `getAllGrouped` gain a `topLevelItems` array, or should the renderer derive it from `workspaces` + `sections` during the transition? + Decision Log: [DL-1](#dl-1-query-shape-for-top-level-items) +2. Should we replace `reorderSections` with a new mixed top-level reorder mutation immediately, or keep `reorderSections` as a compatibility wrapper during the refactor? + Decision Log: [DL-2](#dl-2-mutation-transition-strategy) + + +## Progress + +- [x] (2026-03-07 05:40Z) Draft ExecPlan +- [x] (2026-03-07 05:43Z) Inventory ordering assumptions across `getAllGrouped`, `computeVisualOrder`, sidebar DnD, and workspace shortcuts +- [x] (2026-03-07 05:45Z) Add shared server helper for mixed top-level project child ordering +- [x] (2026-03-07 05:45Z) Add backend tests for mixed workspace/section ordering +- [x] (2026-03-07 05:45Z) Route new top-level workspace/section creation through shared top-level child ordering +- [x] (2026-03-07 05:47Z) Move `computeVisualOrder` onto the shared mixed top-level ordering helper +- [x] (2026-03-07 06:19Z) Add `topLevelItems` to `getAllGrouped` and update keyboard shortcuts to use mixed top-level ordering +- [x] (2026-03-07 06:19Z) Replace sidebar rendering with one ordered top-level list per project +- [x] (2026-03-07 06:19Z) Replace section-only reorder mutation/UI flow with mixed top-level reorder flow +- [x] (2026-03-07 06:19Z) Update keyboard shortcut and next/previous workspace ordering +- [ ] Audit create/move paths so new items get correct top-level `tabOrder` +- [ ] Run `bun run typecheck`, targeted desktop tests, and manual sidebar DnD QA + + +## Surprises & Discoveries + +- `getAllGrouped` currently returns separate `group.workspaces` and `group.sections`, and many renderer callsites assume the visual order is always “ungrouped workspaces first, then all sections”. +- `getWorkspacesInVisualOrder` in `apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts` also hardcodes that same two-lane ordering, so keyboard navigation and sidebar DnD semantics are coupled to the query shape. +- The existing creation paths were already inconsistent with the desired model: new sections appended relative only to sections, while new top-level workspaces appended relative only to workspaces. That had to be unified before mixed reordering could behave predictably. +- Desktop typecheck surfaced an unrelated-but-live issue in the collapsed section drag-handle refs. That wiring needed a callback-ref fix before the package would typecheck cleanly. +- Once the sidebar started rendering from `topLevelItems`, several optimistic cache writers (`close`, `delete`) needed to remove entries from both `sections[*].workspaces` and `topLevelItems`. Those optimistic paths were previously incomplete for section-contained workspaces too. + + +## Decision Log + +### DL-1 Query shape for top-level items + +TBD. The cleanest long-term shape is likely a `topLevelItems` array on each project group, but that is a broader API change. A staged migration can keep `workspaces` and `sections` in the query while deriving a mixed array in the renderer first. + + +### DL-2 Mutation transition strategy + +TBD. A new mixed reorder mutation is the right end state. We may temporarily keep `reorderSections` as a wrapper or stop using it from the sidebar as soon as the new mutation exists. + + +## Outcomes & Retrospective + +TBD after implementation. + + +## Context and Orientation + +Relevant files: + +- `apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts` builds the grouped sidebar data and the visual order used for next/previous workspace navigation. +- `apps/desktop/src/lib/trpc/routers/workspaces/procedures/sections.ts` owns section creation, section reordering, and moving workspaces in/out of sections. +- `apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts` assigns `tabOrder` when new workspaces are created. +- `apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx` currently renders ungrouped workspaces first and sections second. +- `apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSection/WorkspaceSection.tsx` implements section drag-and-drop against other sections only. +- `apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/useWorkspaceDnD.ts` implements workspace drag-and-drop within ungrouped and in-section lanes. +- `apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts` flattens workspaces using the old grouped ordering assumptions. + +Current persisted ordering model: + +- Top-level ungrouped workspaces use `workspaces.tabOrder` +- Sections use `workspace_sections.tabOrder` +- In-section workspaces also use `workspaces.tabOrder`, but only relative to other workspaces in the same section + +Current rendering model: + + project + - group.workspaces sorted by tabOrder + - group.sections sorted by tabOrder + - section.workspaces sorted by tabOrder + +Target rendering model: + + project + - topLevelItems sorted by tabOrder + - workspace item + - section item + - section.workspaces sorted by tabOrder + + +## Plan of Work + +### Milestone 1: Lock down a mixed top-level ordering helper + +Add a server-side helper that can load a project's top-level children as one list, reorder them, and persist normalized `tabOrder` values back to both `workspaces` and `workspace_sections`. + +Scope: + +1. Add a utility under `apps/desktop/src/lib/trpc/routers/workspaces/utils/` for: + - collecting ungrouped workspaces and sections for one project + - sorting them by `tabOrder` + - moving one item to a target position + - writing sequential `tabOrder` values back to the correct tables +2. Keep section-internal workspace ordering out of this helper. +3. Add tests that prove a section can be moved before/after an ungrouped workspace and that ordering remains normalized. + +Acceptance: + + bun test apps/desktop/src/lib/trpc/routers/workspaces/utils + + +### Milestone 2: Introduce a mixed top-level reorder mutation + +Add a new mutation for project-level child reordering and move the sidebar off `reorderSections` for top-level ordering. + +Scope: + +1. Add a tRPC mutation that accepts: + - `projectId` + - dragged item kind/id + - destination index within the combined top-level list +2. Reuse the helper from Milestone 1. +3. Keep `reorderWorkspacesInSection` unchanged for section contents. + +Acceptance: + +Backend tests cover: + +1. workspace before section +2. section before workspace +3. no-op reorders +4. invalid item IDs / invalid indices + + +### Milestone 3: Make query and shortcut ordering match the new model + +Refactor server query helpers and shortcut consumers so the visual order is based on mixed top-level project children, not a fixed workspaces-then-sections sequence. + +Scope: + +1. Update `getWorkspacesInVisualOrder` and `computeVisualOrder` usage to iterate projects by mixed top-level order. +2. Update `getAllGrouped` or its renderer consumers so top-level order can be rendered without assuming `group.workspaces` comes first. +3. Update `useWorkspaceShortcuts` to flatten workspaces in true visual order. + +Acceptance: + +1. Keyboard shortcuts match sidebar order. +2. Previous/next workspace navigation matches sidebar order. + + +### Milestone 4: Refactor sidebar rendering and DnD + +Render one mixed top-level list per project and allow section/workspace drag-and-drop across that list. + +Scope: + +1. In `ProjectSection.tsx`, replace the fixed “ungrouped workspaces then sections” rendering with a mixed ordered list. +2. Add a shared top-level DnD path for project children. +3. Preserve existing section-internal workspace DnD behavior. +4. Update optimistic cache writes to reorder the mixed top-level list correctly. + +Acceptance: + +Manual QA: + +1. Drag section above an ungrouped workspace. +2. Drag section below an ungrouped workspace. +3. Drag ungrouped workspace around sections. +4. Reorder workspaces inside a section unchanged. + + +### Milestone 5: Audit creation and move semantics + +Ensure all mutations that create or relocate top-level items produce stable mixed ordering. + +Scope: + +1. New top-level workspaces should append to the mixed top-level list, not just the ungrouped workspace lane. +2. New sections should append to the mixed top-level list, not just the sections lane. +3. Moving a workspace into or out of a section should preserve sane ordering for both the top-level list and the destination section. + +Acceptance: + +Targeted tests cover: + +1. creating a section after top-level workspaces exist +2. creating a workspace after sections exist +3. moving a workspace out of a section into top-level placement diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts index ba10e28bcc3..0bdcc1f82f6 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts @@ -13,7 +13,7 @@ import { findOrphanedWorktreeByBranch, findWorktreeWorkspaceByBranch, getBranchWorkspace, - getMaxWorkspaceTabOrder, + getMaxProjectChildTabOrder, getProject, getWorktree, setLastActiveWorkspace, @@ -53,7 +53,7 @@ function createWorkspaceFromWorktree({ branch, name, }: CreateWorkspaceFromWorktreeParams) { - const maxTabOrder = getMaxWorkspaceTabOrder(projectId); + const maxTabOrder = getMaxProjectChildTabOrder(projectId); const workspace = localDb .insert(workspaces) @@ -469,7 +469,7 @@ export const createCreateProcedures = () => { .returning() .get(); - const maxTabOrder = getMaxWorkspaceTabOrder(input.projectId); + const maxTabOrder = getMaxProjectChildTabOrder(input.projectId); const workspace = localDb .insert(workspaces) @@ -687,7 +687,7 @@ export const createCreateProcedures = () => { throw new Error("Worktree no longer exists on disk"); } - const maxTabOrder = getMaxWorkspaceTabOrder(worktree.projectId); + const maxTabOrder = getMaxProjectChildTabOrder(worktree.projectId); const workspace = localDb .insert(workspaces) @@ -800,7 +800,7 @@ export const createCreateProcedures = () => { }; } - const maxTabOrder = getMaxWorkspaceTabOrder(input.projectId); + const maxTabOrder = getMaxProjectChildTabOrder(input.projectId); const workspace = localDb .insert(workspaces) .values({ @@ -868,7 +868,7 @@ export const createCreateProcedures = () => { .returning() .get(); - const maxTabOrder = getMaxWorkspaceTabOrder(input.projectId); + const maxTabOrder = getMaxProjectChildTabOrder(input.projectId); const workspace = localDb .insert(workspaces) .values({ @@ -1013,7 +1013,7 @@ export const createCreateProcedures = () => { const exists = await worktreeExists(project.mainRepoPath, wt.path); if (!exists) continue; - const maxTabOrder = getMaxWorkspaceTabOrder(input.projectId); + const maxTabOrder = getMaxProjectChildTabOrder(input.projectId); localDb .insert(workspaces) .values({ @@ -1067,7 +1067,7 @@ export const createCreateProcedures = () => { .returning() .get(); - const maxTabOrder = getMaxWorkspaceTabOrder(input.projectId); + const maxTabOrder = getMaxProjectChildTabOrder(input.projectId); localDb .insert(workspaces) .values({ diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts index 2871d5ffd98..fe676f1018b 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts @@ -10,6 +10,7 @@ import { localDb } from "main/lib/local-db"; import { z } from "zod"; import { publicProcedure, router } from "../../.."; import { getWorkspace } from "../utils/db-helpers"; +import { getProjectChildItems } from "../utils/project-children-order"; import { computeVisualOrder } from "../utils/visual-order"; import { getWorkspacePath } from "../utils/worktree"; @@ -112,6 +113,7 @@ export const createQueryProcedures = () => { type SectionItem = { id: string; + projectId: string; name: string; tabOrder: number; isCollapsed: boolean; @@ -119,6 +121,12 @@ export const createQueryProcedures = () => { workspaces: WorkspaceItem[]; }; + type TopLevelItem = { + id: string; + kind: "workspace" | "section"; + tabOrder: number; + }; + const activeProjects = localDb .select() .from(projects) @@ -147,6 +155,7 @@ export const createQueryProcedures = () => { }; workspaces: WorkspaceItem[]; sections: SectionItem[]; + topLevelItems: TopLevelItem[]; } >(); @@ -156,6 +165,7 @@ export const createQueryProcedures = () => { .sort((a, b) => a.tabOrder - b.tabOrder) .map((s) => ({ id: s.id, + projectId: s.projectId, name: s.name, tabOrder: s.tabOrder, isCollapsed: s.isCollapsed ?? false, @@ -177,6 +187,7 @@ export const createQueryProcedures = () => { }, workspaces: [], sections: projectSections, + topLevelItems: [], }); } @@ -222,9 +233,27 @@ export const createQueryProcedures = () => { } } - return Array.from(groupsMap.values()).sort( - (a, b) => a.project.tabOrder - b.project.tabOrder, - ); + return Array.from(groupsMap.values()) + .map((group) => { + const projectWorkspaces = [ + ...group.workspaces, + ...group.sections.flatMap((section) => section.workspaces), + ]; + + return { + ...group, + topLevelItems: getProjectChildItems( + group.project.id, + projectWorkspaces, + group.sections, + ).map((item) => ({ + id: item.id, + kind: item.kind, + tabOrder: item.tabOrder, + })), + }; + }) + .sort((a, b) => a.project.tabOrder - b.project.tabOrder); }), getPreviousWorkspace: publicProcedure diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/sections.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/sections.ts index 6b2cd67d05d..6329a340338 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/sections.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/sections.ts @@ -7,7 +7,8 @@ import { } from "shared/constants/project-colors"; import { z } from "zod"; import { publicProcedure, router } from "../../.."; -import { computeNextTabOrder, reorderItems } from "../utils/reorder"; +import { getMaxProjectChildTabOrder } from "../utils/db-helpers"; +import { reorderItems } from "../utils/reorder"; const SECTION_COLORS = PROJECT_COLORS.filter( (c) => c.value !== PROJECT_COLOR_DEFAULT, @@ -28,15 +29,7 @@ export const createSectionsProcedures = () => { }), ) .mutation(({ input }) => { - const existing = localDb - .select() - .from(workspaceSections) - .where(eq(workspaceSections.projectId, input.projectId)) - .all(); - - const nextTabOrder = computeNextTabOrder( - existing.map((s) => s.tabOrder), - ); + const nextTabOrder = getMaxProjectChildTabOrder(input.projectId) + 1; const section = localDb .insert(workspaceSections) diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/status.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/status.ts index 230db02f0a7..4e0ab490faf 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/status.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/status.ts @@ -1,4 +1,4 @@ -import { workspaces, worktrees } from "@superset/local-db"; +import { workspaceSections, workspaces, worktrees } from "@superset/local-db"; import { and, eq, isNull, not } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; import { z } from "zod"; @@ -8,6 +8,10 @@ import { setLastActiveWorkspace, touchWorkspace, } from "../utils/db-helpers"; +import { + getProjectChildItems, + reorderProjectChildItems, +} from "../utils/project-children-order"; export const createStatusProcedures = () => { return router({ @@ -57,6 +61,61 @@ export const createStatusProcedures = () => { return { success: true }; }), + reorderProjectChildren: publicProcedure + .input( + z.object({ + projectId: z.string(), + fromIndex: z.number(), + toIndex: z.number(), + }), + ) + .mutation(({ input }) => { + const { projectId, fromIndex, toIndex } = input; + + const projectWorkspaces = localDb + .select() + .from(workspaces) + .where( + and( + eq(workspaces.projectId, projectId), + isNull(workspaces.deletingAt), + ), + ) + .all(); + const projectSections = localDb + .select() + .from(workspaceSections) + .where(eq(workspaceSections.projectId, projectId)) + .all(); + + const items = getProjectChildItems( + projectId, + projectWorkspaces, + projectSections, + ); + + reorderProjectChildItems(items, fromIndex, toIndex); + + for (const item of items) { + if (item.kind === "workspace") { + localDb + .update(workspaces) + .set({ tabOrder: item.tabOrder }) + .where(eq(workspaces.id, item.id)) + .run(); + continue; + } + + localDb + .update(workspaceSections) + .set({ tabOrder: item.tabOrder }) + .where(eq(workspaceSections.id, item.id)) + .run(); + } + + return { success: true }; + }), + update: publicProcedure .input( z.object({ diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/db-helpers.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/db-helpers.ts index 069bb474938..785ec815871 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/db-helpers.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/db-helpers.ts @@ -4,12 +4,14 @@ import { type SelectWorkspace, type SelectWorktree, settings, + workspaceSections, workspaces, worktrees, } from "@superset/local-db"; import { and, desc, eq, isNotNull, isNull } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; +import { computeNextProjectChildTabOrder } from "./project-children-order"; /** * Set the last active workspace in settings. @@ -27,10 +29,11 @@ export function setLastActiveWorkspace(workspaceId: string | null): void { } /** - * Get the maximum tab order for workspaces in a project (excluding those being deleted). - * Returns -1 if no workspaces exist. + * Get the maximum tab order for top-level project children in a project. + * Top-level children are ungrouped workspaces plus sections. + * Returns -1 if no top-level children exist. */ -export function getMaxWorkspaceTabOrder(projectId: string): number { +export function getMaxProjectChildTabOrder(projectId: string): number { const projectWorkspaces = localDb .select() .from(workspaces) @@ -38,9 +41,18 @@ export function getMaxWorkspaceTabOrder(projectId: string): number { and(eq(workspaces.projectId, projectId), isNull(workspaces.deletingAt)), ) .all(); - return projectWorkspaces.length > 0 - ? Math.max(...projectWorkspaces.map((w) => w.tabOrder)) - : -1; + const projectSections = localDb + .select() + .from(workspaceSections) + .where(eq(workspaceSections.projectId, projectId)) + .all(); + return ( + computeNextProjectChildTabOrder( + projectId, + projectWorkspaces, + projectSections, + ) - 1 + ); } /** diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/project-children-order.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/project-children-order.test.ts new file mode 100644 index 00000000000..a9ded0b1315 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/project-children-order.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "bun:test"; +import { + computeNextProjectChildTabOrder, + getProjectChildItems, + reorderProjectChildItems, +} from "./project-children-order"; + +describe("getProjectChildItems", () => { + test("returns mixed top-level items ordered by shared tabOrder", () => { + const items = getProjectChildItems( + "p1", + [ + { id: "w1", projectId: "p1", sectionId: null, tabOrder: 2 }, + { id: "w2", projectId: "p1", sectionId: "s1", tabOrder: 0 }, + { id: "w3", projectId: "p1", sectionId: null, tabOrder: 0 }, + ], + [{ id: "s1", projectId: "p1", tabOrder: 1 }], + ); + + expect(items).toEqual([ + { id: "w3", kind: "workspace", projectId: "p1", tabOrder: 0 }, + { id: "s1", kind: "section", projectId: "p1", tabOrder: 1 }, + { id: "w1", kind: "workspace", projectId: "p1", tabOrder: 2 }, + ]); + }); + + test("treats orphaned section workspaces as top-level", () => { + const items = getProjectChildItems( + "p1", + [{ id: "w1", projectId: "p1", sectionId: "missing", tabOrder: 3 }], + [], + ); + + expect(items).toEqual([ + { id: "w1", kind: "workspace", projectId: "p1", tabOrder: 3 }, + ]); + }); +}); + +describe("computeNextProjectChildTabOrder", () => { + test("uses both top-level workspaces and sections", () => { + const nextTabOrder = computeNextProjectChildTabOrder( + "p1", + [ + { id: "w1", projectId: "p1", sectionId: null, tabOrder: 1 }, + { id: "w2", projectId: "p1", sectionId: "s1", tabOrder: 8 }, + ], + [{ id: "s1", projectId: "p1", tabOrder: 4 }], + ); + + expect(nextTabOrder).toBe(5); + }); + + test("returns 0 when the project has no top-level children", () => { + const nextTabOrder = computeNextProjectChildTabOrder("p1", [], []); + expect(nextTabOrder).toBe(0); + }); +}); + +describe("reorderProjectChildItems", () => { + test("reorders sections against workspaces and normalizes tabOrder", () => { + const reordered = reorderProjectChildItems( + [ + { id: "w1", kind: "workspace", projectId: "p1", tabOrder: 2 }, + { id: "s1", kind: "section", projectId: "p1", tabOrder: 7 }, + { id: "w2", kind: "workspace", projectId: "p1", tabOrder: 9 }, + ], + 1, + 0, + ); + + expect(reordered).toEqual([ + { id: "s1", kind: "section", projectId: "p1", tabOrder: 0 }, + { id: "w1", kind: "workspace", projectId: "p1", tabOrder: 1 }, + { id: "w2", kind: "workspace", projectId: "p1", tabOrder: 2 }, + ]); + }); +}); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/project-children-order.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/project-children-order.ts new file mode 100644 index 00000000000..d4cf4a1f04f --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/project-children-order.ts @@ -0,0 +1,87 @@ +import { computeNextTabOrder, reorderItems } from "./reorder"; + +interface WorkspaceLike { + id: string; + projectId: string; + sectionId: string | null; + tabOrder: number; +} + +interface SectionLike { + id: string; + projectId: string; + tabOrder: number; +} + +export type ProjectChildItem = + | { + id: string; + kind: "workspace"; + projectId: string; + tabOrder: number; + } + | { + id: string; + kind: "section"; + projectId: string; + tabOrder: number; + }; + +function isTopLevelWorkspace( + workspace: WorkspaceLike, + projectSectionIds: Set, +): boolean { + return ( + workspace.sectionId === null || !projectSectionIds.has(workspace.sectionId) + ); +} + +export function getProjectChildItems( + projectId: string, + workspaces: WorkspaceLike[], + sections: SectionLike[], +): ProjectChildItem[] { + const projectSections = sections.filter( + (section) => section.projectId === projectId, + ); + const projectSectionIds = new Set( + projectSections.map((section) => section.id), + ); + const topLevelWorkspaces = workspaces.filter( + (workspace) => + workspace.projectId === projectId && + isTopLevelWorkspace(workspace, projectSectionIds), + ); + + return [ + ...topLevelWorkspaces.map((workspace) => ({ + id: workspace.id, + kind: "workspace" as const, + projectId: workspace.projectId, + tabOrder: workspace.tabOrder, + })), + ...projectSections.map((section) => ({ + id: section.id, + kind: "section" as const, + projectId: section.projectId, + tabOrder: section.tabOrder, + })), + ].sort((a, b) => a.tabOrder - b.tabOrder); +} + +export function computeNextProjectChildTabOrder( + projectId: string, + workspaces: WorkspaceLike[], + sections: SectionLike[], +): number { + const items = getProjectChildItems(projectId, workspaces, sections); + return computeNextTabOrder(items.map((item) => item.tabOrder)); +} + +export function reorderProjectChildItems( + items: ProjectChildItem[], + fromIndex: number, + toIndex: number, +): ProjectChildItem[] { + return reorderItems(items, fromIndex, toIndex); +} diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/visual-order.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/visual-order.test.ts index 43b402d8b83..6eb64780715 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/visual-order.test.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/visual-order.test.ts @@ -15,32 +15,34 @@ describe("computeVisualOrder", () => { expect(computeVisualOrder(projects, workspaces, [])).toEqual(["w2", "w1"]); }); - test("single project with one section — ungrouped first, then section", () => { + test("single project with one section uses mixed top-level tabOrder", () => { const projects = [{ id: "p1", tabOrder: 0 }]; const workspaces = [ - { id: "w1", projectId: "p1", sectionId: null, tabOrder: 0 }, + { id: "w1", projectId: "p1", sectionId: null, tabOrder: 1 }, { id: "w2", projectId: "p1", sectionId: "s1", tabOrder: 0 }, ]; const sections = [{ id: "s1", projectId: "p1", tabOrder: 0 }]; expect(computeVisualOrder(projects, workspaces, sections)).toEqual([ - "w1", "w2", + "w1", ]); }); - test("multiple sections ordered by tabOrder", () => { + test("multiple sections ordered by shared top-level tabOrder", () => { const projects = [{ id: "p1", tabOrder: 0 }]; const workspaces = [ { id: "w1", projectId: "p1", sectionId: "s2", tabOrder: 0 }, { id: "w2", projectId: "p1", sectionId: "s1", tabOrder: 0 }, + { id: "w3", projectId: "p1", sectionId: null, tabOrder: 1 }, ]; const sections = [ - { id: "s1", projectId: "p1", tabOrder: 0 }, - { id: "s2", projectId: "p1", tabOrder: 1 }, + { id: "s1", projectId: "p1", tabOrder: 2 }, + { id: "s2", projectId: "p1", tabOrder: 0 }, ]; expect(computeVisualOrder(projects, workspaces, sections)).toEqual([ - "w2", "w1", + "w3", + "w2", ]); }); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/visual-order.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/visual-order.ts index fcb3b29d18c..91cfa88a4f8 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/visual-order.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/visual-order.ts @@ -1,3 +1,5 @@ +import { getProjectChildItems } from "./project-children-order"; + interface ProjectLike { id: string; tabOrder: number | null; @@ -19,8 +21,8 @@ interface SectionLike { /** * Computes the visual sidebar order of workspace IDs: * projects sorted by tabOrder, then within each project: - * 1. ungrouped workspaces (sectionId === null) sorted by tabOrder - * 2. sections sorted by tabOrder, each containing its workspaces sorted by tabOrder + * 1. top-level project children (ungrouped workspaces + sections) sorted by shared tabOrder + * 2. section workspaces sorted by tabOrder within each section */ export function computeVisualOrder( projects: ProjectLike[], @@ -37,25 +39,21 @@ export function computeVisualOrder( const projectWorkspaces = workspaces .filter((w) => w.projectId === project.id) .sort((a, b) => a.tabOrder - b.tabOrder); - - const projectSections = sections - .filter((s) => s.projectId === project.id) - .sort((a, b) => a.tabOrder - b.tabOrder); - - const sectionIds = new Set(projectSections.map((s) => s.id)); - - // Ungrouped workspaces: null sectionId OR orphaned (sectionId not in project) - for (const ws of projectWorkspaces.filter( - (w) => w.sectionId === null || !sectionIds.has(w.sectionId), - )) { - orderedIds.push(ws.id); - } - - for (const section of projectSections) { - for (const ws of projectWorkspaces.filter( - (w) => w.sectionId === section.id, + const topLevelItems = getProjectChildItems( + project.id, + projectWorkspaces, + sections, + ); + + for (const item of topLevelItems) { + if (item.kind === "workspace") { + orderedIds.push(item.id); + continue; + } + for (const workspace of projectWorkspaces.filter( + (w) => w.sectionId === item.id, )) { - orderedIds.push(ws.id); + orderedIds.push(workspace.id); } } } diff --git a/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts b/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts index 973ce30121e..76d7ffbd86c 100644 --- a/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts +++ b/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts @@ -15,11 +15,23 @@ export function useWorkspaceShortcuts() { electronTrpc.workspaces.getAllGrouped.useQuery(); const navigate = useNavigate(); - // Flatten workspaces for keyboard navigation - const allWorkspaces = groups.flatMap((group) => [ - ...group.workspaces, - ...(group.sections ?? []).flatMap((s) => s.workspaces), - ]); + const allWorkspaces = groups.flatMap((group) => { + const topLevelWorkspacesById = new Map( + group.workspaces.map((workspace) => [workspace.id, workspace]), + ); + const sectionsById = new Map( + (group.sections ?? []).map((section) => [section.id, section]), + ); + + return group.topLevelItems.flatMap((item) => { + if (item.kind === "workspace") { + const workspace = topLevelWorkspacesById.get(item.id); + return workspace ? [workspace] : []; + } + + return sectionsById.get(item.id)?.workspaces ?? []; + }); + }); const switchToWorkspace = useCallback( (index: number) => { diff --git a/apps/desktop/src/renderer/react-query/workspaces/index.ts b/apps/desktop/src/renderer/react-query/workspaces/index.ts index 75ca52da65e..276c977aab7 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/index.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/index.ts @@ -9,6 +9,7 @@ export { useImportAllWorktrees } from "./useImportAllWorktrees"; export { useMoveWorkspacesToSection } from "./useMoveWorkspacesToSection"; export { useMoveWorkspaceToSection } from "./useMoveWorkspaceToSection"; export { useOpenExternalWorktree } from "./useOpenExternalWorktree"; +export { useReorderProjectChildren } from "./useReorderProjectChildren"; export { useReorderSections } from "./useReorderSections"; export { useReorderWorkspaces } from "./useReorderWorkspaces"; export { useReorderWorkspacesInSection } from "./useReorderWorkspacesInSection"; diff --git a/apps/desktop/src/renderer/react-query/workspaces/useCloseWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useCloseWorkspace.ts index c6aea3bd525..de8e33ee071 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useCloseWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useCloseWorkspace.ts @@ -46,11 +46,34 @@ export function useCloseWorkspace( utils.workspaces.getAllGrouped.setData( undefined, previousGrouped - .map((group) => ({ - ...group, - workspaces: group.workspaces.filter((w) => w.id !== id), - })) - .filter((group) => group.workspaces.length > 0), + .map((group) => { + const isTopLevelWorkspace = group.workspaces.some( + (w) => w.id === id, + ); + const workspaces = group.workspaces.filter((w) => w.id !== id); + const sections = group.sections.map((section) => ({ + ...section, + workspaces: section.workspaces.filter((w) => w.id !== id), + })); + + return { + ...group, + workspaces, + sections, + topLevelItems: isTopLevelWorkspace + ? group.topLevelItems.filter((item) => item.id !== id) + : group.topLevelItems, + }; + }) + .filter( + (group) => + group.workspaces.length + + group.sections.reduce( + (sum, section) => sum + section.workspaces.length, + 0, + ) > + 0, + ), ); } diff --git a/apps/desktop/src/renderer/react-query/workspaces/useDeleteWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useDeleteWorkspace.ts index 19cb469db28..a557cc18ee0 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useDeleteWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useDeleteWorkspace.ts @@ -59,11 +59,34 @@ export function useDeleteWorkspace( utils.workspaces.getAllGrouped.setData( undefined, previousGrouped - .map((group) => ({ - ...group, - workspaces: group.workspaces.filter((w) => w.id !== id), - })) - .filter((group) => group.workspaces.length > 0), + .map((group) => { + const isTopLevelWorkspace = group.workspaces.some( + (w) => w.id === id, + ); + const workspaces = group.workspaces.filter((w) => w.id !== id); + const sections = group.sections.map((section) => ({ + ...section, + workspaces: section.workspaces.filter((w) => w.id !== id), + })); + + return { + ...group, + workspaces, + sections, + topLevelItems: isTopLevelWorkspace + ? group.topLevelItems.filter((item) => item.id !== id) + : group.topLevelItems, + }; + }) + .filter( + (group) => + group.workspaces.length + + group.sections.reduce( + (sum, section) => sum + section.workspaces.length, + 0, + ) > + 0, + ), ); } diff --git a/apps/desktop/src/renderer/react-query/workspaces/useReorderProjectChildren.ts b/apps/desktop/src/renderer/react-query/workspaces/useReorderProjectChildren.ts new file mode 100644 index 00000000000..a8c102eaa83 --- /dev/null +++ b/apps/desktop/src/renderer/react-query/workspaces/useReorderProjectChildren.ts @@ -0,0 +1,18 @@ +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { invalidateWorkspaceQueries } from "./invalidateWorkspaceQueries"; + +export function useReorderProjectChildren( + options?: Parameters< + typeof electronTrpc.workspaces.reorderProjectChildren.useMutation + >[0], +) { + const utils = electronTrpc.useUtils(); + + return electronTrpc.workspaces.reorderProjectChildren.useMutation({ + ...options, + onSuccess: async (...args) => { + await invalidateWorkspaceQueries(utils); + await options?.onSuccess?.(...args); + }, + }); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx index 366ce5e6f6d..8dd19f2100b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx @@ -9,12 +9,26 @@ import { useWorkspaceSidebarStore } from "renderer/stores"; import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; import { useSectionDropZone } from "../hooks"; import type { SidebarSection, SidebarWorkspace } from "../types"; -import { WorkspaceList } from "../WorkspaceList"; +import { WorkspaceListItem } from "../WorkspaceListItem"; import { WorkspaceSection } from "../WorkspaceSection"; import { ProjectHeader } from "./ProjectHeader"; const PROJECT_TYPE = "PROJECT"; +type TopLevelChild = + | { + kind: "workspace"; + workspace: SidebarWorkspace; + topLevelIndex: number; + shortcutIndex: number; + } + | { + kind: "section"; + section: SidebarSection; + topLevelIndex: number; + shortcutBaseIndex: number; + }; + interface ProjectSectionProps { projectId: string; projectName: string; @@ -25,6 +39,11 @@ interface ProjectSectionProps { iconUrl: string | null; workspaces: SidebarWorkspace[]; sections: SidebarSection[]; + topLevelItems: { + id: string; + kind: "workspace" | "section"; + tabOrder: number; + }[]; /** Base index for keyboard shortcuts (0-based) */ shortcutBaseIndex: number; /** Index for drag-and-drop reordering */ @@ -43,6 +62,7 @@ export function ProjectSection({ iconUrl, workspaces, sections, + topLevelItems, shortcutBaseIndex, index, isCollapsed: isSidebarCollapsed = false, @@ -58,25 +78,52 @@ export function ProjectSection({ workspaces.length + sections.reduce((sum, s) => sum + s.workspaces.length, 0); - const sectionBaseIndices = useMemo(() => { - const map = new Map(); - let offset = shortcutBaseIndex + workspaces.length; - for (const section of sections) { - map.set(section.id, offset); - offset += section.workspaces.length; - } - return map; - }, [shortcutBaseIndex, workspaces.length, sections]); + const { orderedWorkspaceIds, topLevelChildren } = useMemo(() => { + const topLevelWorkspacesById = new Map( + workspaces.map((workspace) => [workspace.id, workspace]), + ); + const sectionsById = new Map( + sections.map((section) => [section.id, section]), + ); + const ids: string[] = []; + let shortcutOffset = shortcutBaseIndex; + const renderables: TopLevelChild[] = []; + + for (const [topLevelIndex, item] of topLevelItems.entries()) { + if (item.kind === "workspace") { + const workspace = topLevelWorkspacesById.get(item.id); + if (!workspace) continue; + ids.push(workspace.id); + const shortcutIndex = shortcutOffset; + shortcutOffset += 1; + renderables.push({ + kind: "workspace", + workspace, + topLevelIndex, + shortcutIndex, + }); + continue; + } - const orderedWorkspaceIds = useMemo(() => { - const ids = workspaces.map((w) => w.id); - for (const section of sections) { - for (const w of section.workspaces) { - ids.push(w.id); + const section = sectionsById.get(item.id); + if (!section) continue; + for (const workspace of section.workspaces) { + ids.push(workspace.id); } + renderables.push({ + kind: "section", + section, + topLevelIndex, + shortcutBaseIndex: shortcutOffset, + }); + shortcutOffset += section.workspaces.length; } - return ids; - }, [workspaces, sections]); + + return { + orderedWorkspaceIds: ids, + topLevelChildren: renderables, + }; + }, [shortcutBaseIndex, sections, topLevelItems, workspaces]); const ungroupedDropZone = useSectionDropZone({ canAccept: (item) => @@ -186,29 +233,41 @@ export function ProjectSection({ className="overflow-hidden w-full" >
- - {sections.map((section, sectionIndex) => ( - - ))} + {topLevelChildren.map((item) => + item.kind === "workspace" ? ( + + ) : ( + + ), + )}
)} @@ -253,44 +312,52 @@ export function ProjectSection({ className="overflow-hidden" >
-
- -
- {sections.map((section, sectionIndex) => ( - - ))} + )} + {topLevelChildren.map((item) => + item.kind === "workspace" ? ( + + ) : ( + + ), + )}
)} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/useWorkspaceDnD.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/useWorkspaceDnD.ts index 8a70cdf3278..c1be6d1ccf4 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/useWorkspaceDnD.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/useWorkspaceDnD.ts @@ -5,13 +5,15 @@ import { electronTrpc } from "renderer/lib/electron-trpc"; import { useMoveWorkspacesToSection, useMoveWorkspaceToSection, - useReorderWorkspaces, + useReorderProjectChildren, useReorderWorkspacesInSection, } from "renderer/react-query/workspaces"; import { invalidateWorkspaceQueries } from "renderer/react-query/workspaces/invalidateWorkspaceQueries"; import { useActiveDragItemStore } from "renderer/stores/active-drag-item"; import { useWorkspaceSelectionStore } from "renderer/stores/workspace-selection"; -import type { DragItem } from "../types"; +import { SECTION_DND_TYPE } from "../constants"; +import type { DragItem, SectionDragItem } from "../types"; +import { reorderProjectChildrenInCache } from "../utils/reorderProjectChildrenInCache"; import { WORKSPACE_DND_TYPE } from "./constants"; interface UseWorkspaceDnDOptions { @@ -28,7 +30,7 @@ export function useWorkspaceDnD({ index, }: UseWorkspaceDnDOptions) { const utils = electronTrpc.useUtils(); - const reorderWorkspaces = useReorderWorkspaces(); + const reorderProjectChildren = useReorderProjectChildren(); const reorderWorkspacesInSection = useReorderWorkspacesInSection(); const moveToSection = useMoveWorkspaceToSection(); const bulkMoveToSection = useMoveWorkspacesToSection(); @@ -54,7 +56,7 @@ export function useWorkspaceDnD({ callbacks, ); } else { - reorderWorkspaces.mutate( + reorderProjectChildren.mutate( { projectId: item.projectId, fromIndex: item.originalIndex, @@ -64,7 +66,7 @@ export function useWorkspaceDnD({ ); } }, - [reorderWorkspaces, reorderWorkspacesInSection, utils], + [reorderProjectChildren, reorderWorkspacesInSection, utils], ); const [{ isDragging }, drag] = useDrag( @@ -81,6 +83,7 @@ export function useWorkspaceDnD({ ? [...selection.selectedIds] : undefined; const dragItem: DragItem = { + kind: "workspace", id, projectId, sectionId, @@ -104,8 +107,25 @@ export function useWorkspaceDnD({ ); const [, drop] = useDrop({ - accept: WORKSPACE_DND_TYPE, - hover: (item: DragItem) => { + accept: + sectionId === null + ? [WORKSPACE_DND_TYPE, SECTION_DND_TYPE] + : WORKSPACE_DND_TYPE, + hover: (item: DragItem | SectionDragItem) => { + if (item.kind === "section") { + if ( + sectionId !== null || + item.projectId !== projectId || + item.index === index + ) { + return; + } + utils.workspaces.getAllGrouped.setData(undefined, (oldData) => + reorderProjectChildrenInCache(oldData, projectId, item.index, index), + ); + item.index = index; + return; + } if (item.selectedIds && item.selectedIds.length > 1) return; if ( item.projectId !== projectId || @@ -113,29 +133,47 @@ export function useWorkspaceDnD({ item.index === index ) return; - utils.workspaces.getAllGrouped.setData(undefined, (oldData) => { - if (!oldData) return oldData; - return oldData.map((group) => { - if (group.project.id !== projectId) return group; - if (sectionId === null) { - const workspaces = [...group.workspaces]; - const [moved] = workspaces.splice(item.index, 1); - workspaces.splice(index, 0, moved); - return { ...group, workspaces }; - } - const sections = group.sections.map((section) => { - if (section.id !== sectionId) return section; - const workspaces = [...section.workspaces]; - const [moved] = workspaces.splice(item.index, 1); - workspaces.splice(index, 0, moved); - return { ...section, workspaces }; + if (sectionId === null) { + utils.workspaces.getAllGrouped.setData(undefined, (oldData) => + reorderProjectChildrenInCache(oldData, projectId, item.index, index), + ); + } else { + utils.workspaces.getAllGrouped.setData(undefined, (oldData) => { + if (!oldData) return oldData; + return oldData.map((group) => { + if (group.project.id !== projectId) return group; + const sections = group.sections.map((section) => { + if (section.id !== sectionId) return section; + const workspaces = [...section.workspaces]; + const [moved] = workspaces.splice(item.index, 1); + workspaces.splice(index, 0, moved); + return { ...section, workspaces }; + }); + return { ...group, sections }; }); - return { ...group, sections }; }); - }); + } item.index = index; }, - drop: (item: DragItem) => { + drop: (item: DragItem | SectionDragItem) => { + if (item.kind === "section") { + if (sectionId !== null || item.projectId !== projectId) return; + reorderProjectChildren.mutate( + { + projectId, + fromIndex: item.originalIndex, + toIndex: item.index, + }, + { + onError: (error: { message: string }) => { + void invalidateWorkspaceQueries(utils); + toast.error(`Failed to reorder project items: ${error.message}`); + }, + }, + ); + if (item.originalIndex !== item.index) return { reordered: true }; + return; + } if (item.projectId !== projectId) return; if (item.sectionId === sectionId) { handleReorder(item); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSection/WorkspaceSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSection/WorkspaceSection.tsx index 99b1ca4381f..f38ec03ce57 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSection/WorkspaceSection.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSection/WorkspaceSection.tsx @@ -16,20 +16,19 @@ import { useDrag, useDrop } from "react-dnd"; import { HiChevronRight } from "react-icons/hi2"; import { LuPalette, LuPencil, LuTrash2 } from "react-icons/lu"; import { electronTrpc } from "renderer/lib/electron-trpc"; -import { useReorderSections } from "renderer/react-query/workspaces"; +import { useReorderProjectChildren } from "renderer/react-query/workspaces"; import { PROJECT_COLOR_DEFAULT, PROJECT_COLORS, } from "shared/constants/project-colors"; -import { STROKE_WIDTH } from "../constants"; +import { SECTION_DND_TYPE, STROKE_WIDTH } from "../constants"; import { useSectionDropZone } from "../hooks"; import { RenameInput } from "../RenameInput"; import type { SectionDragItem, SidebarWorkspace } from "../types"; +import { reorderProjectChildrenInCache } from "../utils/reorderProjectChildrenInCache"; import { WorkspaceList } from "../WorkspaceList"; import { useSectionMutations } from "./useSectionMutations"; -export const SECTION_DND_TYPE = "SECTION"; - interface WorkspaceSectionProps { sectionId: string; projectId: string; @@ -63,6 +62,11 @@ export function WorkspaceSection({ const mutations = useSectionMutations(sectionId); const hasColor = color && color !== PROJECT_COLOR_DEFAULT; + const sectionBorderStyle = { + borderLeft: hasColor + ? `2px solid ${color}` + : "2px solid var(--color-border)", + }; const dropZone = useSectionDropZone({ canAccept: (item) => @@ -71,11 +75,11 @@ export function WorkspaceSection({ onAutoExpand: isCollapsed ? () => mutations.toggle() : undefined, }); - const reorderSections = useReorderSections(); + const reorderProjectChildren = useReorderProjectChildren(); const commitSectionReorder = (item: SectionDragItem) => { if (item.originalIndex === item.index) return; - reorderSections.mutate( + reorderProjectChildren.mutate( { projectId: item.projectId, fromIndex: item.originalIndex, @@ -84,7 +88,7 @@ export function WorkspaceSection({ { onError: (error) => { void utils.workspaces.getAllGrouped.invalidate(); - toast.error(`Failed to reorder sections: ${error.message}`); + toast.error(`Failed to reorder project items: ${error.message}`); }, }, ); @@ -94,6 +98,7 @@ export function WorkspaceSection({ () => ({ type: SECTION_DND_TYPE, item: (): SectionDragItem => ({ + kind: "section", sectionId, projectId, index, @@ -106,23 +111,16 @@ export function WorkspaceSection({ }, collect: (monitor) => ({ isSectionDragging: monitor.isDragging() }), }), - [sectionId, projectId, index, reorderSections], + [sectionId, projectId, index, reorderProjectChildren], ); const [, sectionDrop] = useDrop({ accept: SECTION_DND_TYPE, hover: (item: SectionDragItem) => { if (item.projectId !== projectId || item.index === index) return; - utils.workspaces.getAllGrouped.setData(undefined, (oldData) => { - if (!oldData) return oldData; - return oldData.map((group) => { - if (group.project.id !== projectId) return group; - const sections = [...group.sections]; - const [moved] = sections.splice(item.index, 1); - sections.splice(index, 0, moved); - return { ...group, sections }; - }); - }); + utils.workspaces.getAllGrouped.setData(undefined, (oldData) => + reorderProjectChildrenInCache(oldData, projectId, item.index, index), + ); item.index = index; }, drop: (item: SectionDragItem) => { @@ -170,26 +168,40 @@ export function WorkspaceSection({ if (isSidebarCollapsed) { return ( - +
{ + sectionDrop(node); + }} + {...dropZone.handlers} + className={cn( + "relative flex flex-col -ml-0.5", + isSectionDragging && "opacity-30", + )} + style={sectionBorderStyle} + > +
{ + sectionDrag(node); + }} + className="absolute inset-y-0 -left-1 w-2 cursor-grab" + /> + +
); } return (
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx index 3c9fe0a3776..4198ed8636c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -90,6 +90,7 @@ export function WorkspaceSidebar({ iconUrl={group.project.iconUrl} workspaces={group.workspaces} sections={group.sections ?? []} + topLevelItems={group.topLevelItems} shortcutBaseIndex={projectShortcutIndices[index]} index={index} isCollapsed={isCollapsed} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/constants.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/constants.ts index fd30a967c27..64d49dfeed2 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/constants.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/constants.ts @@ -6,3 +6,5 @@ export const STROKE_WIDTH_THICK = 2; /** Thin stroke width for subtle icons (e.g., toggle icons) */ export const STROKE_WIDTH_THIN = 1; + +export const SECTION_DND_TYPE = "SECTION"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/types.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/types.ts index 4f254d045f4..bdb6ef2a4e4 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/types.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/types.ts @@ -10,6 +10,7 @@ export interface SidebarWorkspace { } export interface DragItem { + kind: "workspace"; id: string; projectId: string; sectionId: string | null; @@ -22,6 +23,7 @@ export interface DragItem { } export interface SectionDragItem { + kind: "section"; sectionId: string; projectId: string; index: number; @@ -30,6 +32,7 @@ export interface SectionDragItem { export interface SidebarSection { id: string; + projectId?: string; name: string; tabOrder: number; isCollapsed: boolean; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/utils/reorderProjectChildrenInCache.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/utils/reorderProjectChildrenInCache.test.ts new file mode 100644 index 00000000000..64211203212 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/utils/reorderProjectChildrenInCache.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test"; +import { reorderProjectChildrenInCache } from "./reorderProjectChildrenInCache"; + +describe("reorderProjectChildrenInCache", () => { + test("reorders a section ahead of a top-level workspace and normalizes tabOrder", () => { + const result = reorderProjectChildrenInCache( + [ + { + project: { id: "p1" }, + workspaces: [ + { id: "w1", tabOrder: 0 }, + { id: "w2", tabOrder: 2 }, + ], + sections: [ + { + id: "s1", + tabOrder: 1, + workspaces: [{ id: "w3", tabOrder: 0 }], + }, + ], + topLevelItems: [ + { id: "w1", kind: "workspace" as const, tabOrder: 0 }, + { id: "s1", kind: "section" as const, tabOrder: 1 }, + { id: "w2", kind: "workspace" as const, tabOrder: 2 }, + ], + }, + ], + "p1", + 1, + 0, + ); + + expect(result).toEqual([ + { + project: { id: "p1" }, + workspaces: [ + { id: "w1", tabOrder: 1 }, + { id: "w2", tabOrder: 2 }, + ], + sections: [ + { + id: "s1", + tabOrder: 0, + workspaces: [{ id: "w3", tabOrder: 0 }], + }, + ], + topLevelItems: [ + { id: "s1", kind: "section", tabOrder: 0 }, + { id: "w1", kind: "workspace", tabOrder: 1 }, + { id: "w2", kind: "workspace", tabOrder: 2 }, + ], + }, + ]); + }); + + test("does not change unrelated projects", () => { + const data = [ + { + project: { id: "p1" }, + workspaces: [{ id: "w1", tabOrder: 0 }], + sections: [], + topLevelItems: [{ id: "w1", kind: "workspace" as const, tabOrder: 0 }], + }, + { + project: { id: "p2" }, + workspaces: [{ id: "w2", tabOrder: 0 }], + sections: [], + topLevelItems: [{ id: "w2", kind: "workspace" as const, tabOrder: 0 }], + }, + ]; + + expect(reorderProjectChildrenInCache(data, "p2", 0, 0)).toEqual(data); + }); +}); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/utils/reorderProjectChildrenInCache.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/utils/reorderProjectChildrenInCache.ts new file mode 100644 index 00000000000..e9e4f2bf781 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/utils/reorderProjectChildrenInCache.ts @@ -0,0 +1,81 @@ +type SidebarWorkspaceLike = { + id: string; + tabOrder: number; +}; + +type SidebarSectionLike = { + id: string; + tabOrder: number; + workspaces: SidebarWorkspaceLike[]; +}; + +type TopLevelItemLike = { + id: string; + kind: "workspace" | "section"; + tabOrder: number; +}; + +type SidebarGroupLike = { + project: { id: string }; + workspaces: SidebarWorkspaceLike[]; + sections: SidebarSectionLike[]; + topLevelItems: TopLevelItemLike[]; +}; + +export function reorderProjectChildrenInCache( + oldData: T[] | undefined, + projectId: string, + fromIndex: number, + toIndex: number, +): T[] | undefined { + if (!oldData) return oldData; + + return oldData.map((group) => { + if (group.project.id !== projectId) return group; + if ( + fromIndex < 0 || + fromIndex >= group.topLevelItems.length || + toIndex < 0 || + toIndex >= group.topLevelItems.length + ) { + return group; + } + + const topLevelItems = [...group.topLevelItems]; + const [moved] = topLevelItems.splice(fromIndex, 1); + topLevelItems.splice(toIndex, 0, moved); + + const normalizedTopLevelItems = topLevelItems.map((item, index) => ({ + ...item, + tabOrder: index, + })); + + const workspaceTabOrders = new Map( + normalizedTopLevelItems + .filter((item) => item.kind === "workspace") + .map((item) => [item.id, item.tabOrder]), + ); + const sectionTabOrders = new Map( + normalizedTopLevelItems + .filter((item) => item.kind === "section") + .map((item) => [item.id, item.tabOrder]), + ); + + return { + ...group, + topLevelItems: normalizedTopLevelItems, + workspaces: group.workspaces + .map((workspace) => ({ + ...workspace, + tabOrder: workspaceTabOrders.get(workspace.id) ?? workspace.tabOrder, + })) + .sort((a, b) => a.tabOrder - b.tabOrder), + sections: group.sections + .map((section) => ({ + ...section, + tabOrder: sectionTabOrders.get(section.id) ?? section.tabOrder, + })) + .sort((a, b) => a.tabOrder - b.tabOrder), + }; + }); +} diff --git a/apps/desktop/src/renderer/screens/main/hooks/useBranchSyncInvalidation/useBranchSyncInvalidation.ts b/apps/desktop/src/renderer/screens/main/hooks/useBranchSyncInvalidation/useBranchSyncInvalidation.ts index ef597ceabcd..a45a739e1af 100644 --- a/apps/desktop/src/renderer/screens/main/hooks/useBranchSyncInvalidation/useBranchSyncInvalidation.ts +++ b/apps/desktop/src/renderer/screens/main/hooks/useBranchSyncInvalidation/useBranchSyncInvalidation.ts @@ -32,6 +32,12 @@ export function useBranchSyncInvalidation({ workspaces: group.workspaces.map((ws) => ws.id === workspaceId ? { ...ws, branch } : ws, ), + sections: group.sections.map((section) => ({ + ...section, + workspaces: section.workspaces.map((ws) => + ws.id === workspaceId ? { ...ws, branch } : ws, + ), + })), })); }); diff --git a/apps/desktop/src/renderer/stores/active-drag-item.test.ts b/apps/desktop/src/renderer/stores/active-drag-item.test.ts index 7ccd4d375bb..a9b5e14020e 100644 --- a/apps/desktop/src/renderer/stores/active-drag-item.test.ts +++ b/apps/desktop/src/renderer/stores/active-drag-item.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test } from "bun:test"; import { getActiveDragItem, useActiveDragItemStore } from "./active-drag-item"; const testItem = { + kind: "workspace" as const, id: "ws-1", projectId: "p-1", sectionId: null,