diff --git a/apps/web/src/components/AppRouteModal.tsx b/apps/web/src/components/AppRouteModal.tsx index 79e5510265f8e..5625281f069be 100644 --- a/apps/web/src/components/AppRouteModal.tsx +++ b/apps/web/src/components/AppRouteModal.tsx @@ -69,6 +69,16 @@ export function AppRouteModal({ label, children }: { label: string; children: Re aria-label={label} data-slot="dialog-popup" data-app-modal="" + onKeyDown={(event) => { + if ( + (event.key === "ArrowUp" || event.key === "ArrowDown") && + event.target instanceof HTMLElement && + event.target.closest('[aria-roledescription="sortable"][aria-pressed="true"]') + ) { + // dnd-kit's keyboard sensor listens on document, beyond the dialog's key guard. + event.preventBaseUIHandler(); + } + }} className="relative flex size-full min-h-0 min-w-0 flex-col overflow-hidden bg-background max-sm:pt-(--app-modal-top-inset) max-sm:pb-safe text-foreground outline-none transition-[opacity,scale] duration-150 ease-out [-webkit-app-region:no-drag] data-ending-style:scale-[0.985] data-ending-style:opacity-0 data-starting-style:scale-[0.985] data-starting-style:opacity-0 sm:max-h-[60rem] sm:max-w-[80rem] sm:rounded-2xl sm:border sm:shadow-2xl/40" > {/* The emptied strip still moves the window, as the titlebar it replaces does. */} diff --git a/apps/web/src/components/settings/ProjectOrderList.browser.test.tsx b/apps/web/src/components/settings/ProjectOrderList.browser.test.tsx new file mode 100644 index 0000000000000..081b3f128197f --- /dev/null +++ b/apps/web/src/components/settings/ProjectOrderList.browser.test.tsx @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import { render } from "../../../test/render"; +import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping"; +import { useUiStateStore } from "../../uiStateStore"; +import { AppRouteModal } from "../AppRouteModal"; +import { ProjectOrderList } from "./ProjectOrderList"; + +const state = vi.hoisted(() => ({ + groups: [] as SidebarProjectSnapshot[], +})); + +vi.mock(import("../../hooks/useSidebarProjectGroups"), () => ({ + useSidebarProjectGroups: () => state.groups, +})); +vi.mock(import("../../state/entities"), async (original) => ({ + ...(await original()), + useProjects: () => [], +})); +vi.mock(import("./SettingsScopeContext"), async (original) => ({ + ...(await original()), + useSettingsScope: () => ({ selectScope: vi.fn() }) as never, +})); +vi.mock(import("../ProjectFavicon"), () => ({ ProjectFavicon: () => })); + +function group(name: string, memberKeys: readonly string[], pinned = false) { + return { + projectKey: name, + displayName: name, + workspaceRoot: `/code/${name}`, + pinned, + archived: false, + memberProjects: memberKeys.map((physicalProjectKey) => ({ physicalProjectKey })), + } as unknown as SidebarProjectSnapshot; +} + +function setup(groups: SidebarProjectSnapshot[]) { + state.groups = groups; + useUiStateStore.setState({ projectOrder: [] }); +} + +async function moveDown(name: string) { + const handle = page.getByRole("button", { name: new RegExp(`^Reorder ${name},`) }); + await expect.element(handle).toBeVisible(); + handle.element().focus(); + await userEvent.keyboard(" "); + await expect.element(handle).toHaveAttribute("aria-pressed", "true"); + await userEvent.keyboard("{ArrowDown}"); + await userEvent.keyboard(" "); + await expect.element(handle).not.toHaveAttribute("aria-pressed", "true"); +} + +describe("ProjectOrderList", () => { + it("saves a dragged project's checkouts together", async () => { + setup([group("alpha", ["a-local", "a-remote"]), group("beta", ["b"]), group("gamma", ["g"])]); + await using _screen = await render( + + + , + ); + + await moveDown("alpha"); + + expect(useUiStateStore.getState().projectOrder).toEqual(["b", "a-local", "a-remote", "g"]); + }); + + it("leaves the order alone when a drop crosses the pinned line", async () => { + setup([group("pinned", ["p"], true), group("beta", ["b"])]); + await using _screen = await render(); + + await moveDown("pinned"); + + expect(useUiStateStore.getState().projectOrder).toEqual([]); + }); +}); diff --git a/apps/web/src/components/settings/ProjectOrderList.tsx b/apps/web/src/components/settings/ProjectOrderList.tsx new file mode 100644 index 0000000000000..45c7619e85fc5 --- /dev/null +++ b/apps/web/src/components/settings/ProjectOrderList.tsx @@ -0,0 +1,199 @@ +import { + type Announcements, + DndContext, + type DragEndEvent, + KeyboardSensor, + PointerSensor, + type UniqueIdentifier, + closestCenter, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { + pickableProjectGroups, + selectProjectGroupingSettings, +} from "@t3tools/client-runtime/state/project-grouping"; +import { ChevronRightIcon, GripVerticalIcon, PinIcon } from "lucide-react"; +import { Fragment } from "react"; + +import { useClientSettings } from "../../hooks/useSettings"; +import { useSidebarProjectGroups } from "../../hooks/useSidebarProjectGroups"; +import { cn } from "../../lib/utils"; +import { + getSidebarProjectSettingsKey, + type SidebarProjectSnapshot, +} from "../../sidebarProjectGrouping"; +import { useProjects } from "../../state/entities"; +import { useUiStateStore } from "../../uiStateStore"; +import { ProjectFavicon } from "../ProjectFavicon"; +import { ITEM_ROW_CLASSNAME } from "./itemRows"; +import { SettingsGroup } from "./SettingsGroup"; +import { useSettingsScope } from "./SettingsScopeContext"; + +const memberKeys = (group: SidebarProjectSnapshot) => + group.memberProjects.map((member) => member.physicalProjectKey); + +export function ProjectOrderList() { + const groups = useSidebarProjectGroups(); + const projects = useProjects(); + const groupingSettings = useClientSettings(selectProjectGroupingSettings); + const reorderProjects = useUiStateStore((store) => store.reorderProjects); + const { selectScope } = useSettingsScope(); + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + const shown = pickableProjectGroups(groups); + + // dnd-kit announces raw ids by default, and these ids are internal project keys. + const findGroup = (id: UniqueIdentifier | undefined) => + shown.find((group) => group.projectKey === id); + const nameOf = (id: UniqueIdentifier) => findGroup(id)?.displayName ?? String(id); + const announcements: Announcements = { + onDragStart: ({ active }) => `Picked up ${nameOf(active.id)}.`, + onDragOver: ({ active, over }) => + over ? `${nameOf(active.id)} is over ${nameOf(over.id)}.` : undefined, + onDragEnd: ({ active, over }) => { + if (!over) return `${nameOf(active.id)} was dropped.`; + return findGroup(active.id)?.pinned === findGroup(over.id)?.pinned + ? `${nameOf(active.id)} was dropped on ${nameOf(over.id)}.` + : `${nameOf(active.id)} did not move. Pinned projects stay above unpinned projects.`; + }, + onDragCancel: ({ active }) => `Moving ${nameOf(active.id)} was cancelled.`, + }; + + const handleDragEnd = ({ active, over }: DragEndEvent) => { + const dragged = findGroup(active.id); + const target = findGroup(over?.id); + // Pinned projects always sort first, so a drop across that line could not stick. + if (!dragged || !target || dragged === target || dragged.pinned !== target.pinned) return; + reorderProjects(groups.flatMap(memberKeys), memberKeys(dragged), memberKeys(target)); + }; + + return ( +
+

+ Choose a project to manage its name, icon, checkouts and actions. Drag projects to fix their + order in the sidebar. +

+ + + group.projectKey)} + strategy={verticalListSortingStrategy} + > +
    + {shown.map((group, index) => ( + + {shown[index - 1]?.pinned && !group.pinned ? ( +
  1. + ) : null} + + selectScope({ + project: getSidebarProjectSettingsKey(group, groupingSettings, projects), + }) + } + /> + + ))} +
+
+
+
+
+ ); +} + +function SortableProjectRow({ + group, + position, + onOpen, +}: { + readonly group: SidebarProjectSnapshot; + readonly position: number; + readonly onOpen: () => void; +}) { + const { + attributes, + listeners, + setNodeRef, + setActivatorNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: group.projectKey }); + + return ( +
  • + + + {position} + + + {group.pinned ? ( + + + Pinned + + ) : null} + +
  • + ); +} diff --git a/apps/web/src/components/settings/ProjectsSettings.tsx b/apps/web/src/components/settings/ProjectsSettings.tsx index 826c92bca26e3..a24edaceb6d03 100644 --- a/apps/web/src/components/settings/ProjectsSettings.tsx +++ b/apps/web/src/components/settings/ProjectsSettings.tsx @@ -1,7 +1,9 @@ import { EnvironmentId } from "@t3tools/contracts"; import { LayersIcon } from "lucide-react"; +import { useClientSettings } from "../../hooks/useSettings"; import { Button } from "../ui/button"; +import { ProjectOrderList } from "./ProjectOrderList"; import { ProjectSettingsPanel } from "./ProjectSettingsPanel"; import { useSettingsScope } from "./SettingsScopeContext"; import { SettingsScopeNotice } from "./SettingsScopeNotice"; @@ -10,6 +12,9 @@ import { ALL_ENVIRONMENTS_VALUE, selectEnvironmentAxis } from "./settingsScopeAx export function ProjectsSettings() { const { search: value, scope, selectScope } = useSettingsScope(); + const manualOrder = useClientSettings( + (settings) => settings.sidebarProjectSortOrder === "manual", + ); const unavailable = scope.kind === "unavailable" ? ( @@ -45,6 +50,10 @@ export function ProjectsSettings() { /> ) : unavailable ? ( unavailable + ) : manualOrder ? ( + + + ) : ( Choose a project to manage its name, icon, checkouts and actions. diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index b20295847f934..a5d4a926a0c04 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -844,7 +844,12 @@ export function GeneralSettingsPanel() { /> Order of projects in the sidebar project picker and command palette.} + description={ + + Order of projects in the sidebar project picker and command palette. Set a manual + order by dragging projects in Settings → Projects. + + } resetAction={ settings.sidebarProjectSortOrder !== DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder ? ( diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index dbfb60d15bd20..8f8e618029b05 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -91,6 +91,14 @@ describe("uiStateStore pure functions", () => { const next = reorderProjects(makeUiState(), currentOrder, [keyALocal, keyARemote], [keyC]); expect(next.projectOrder).toEqual([keyB, keyC, keyALocal, keyARemote]); + expect( + reorderProjects( + makeUiState(), + [keyB, keyALocal, keyARemote, keyC], + [keyB], + [keyALocal, keyARemote], + ).projectOrder, + ).toEqual([keyALocal, keyARemote, keyB, keyC]); }); }); diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 328e1a6477132..5993da8666af9 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -420,7 +420,10 @@ export function reorderProjects( return state; } - const insertIndex = originalTargetIndex - Math.max(0, draggedBeforeTarget - 1); + const insertIndex = + draggedBeforeTarget > 0 + ? projectOrder.findLastIndex((id) => targetSet.has(id)) + 1 + : originalTargetIndex; projectOrder.splice(insertIndex, 0, ...removed); return { ...state, diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 09ce63e2a3e65..f847154230857 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -150,7 +150,9 @@ cannot drag or move threads within it. Your saved order returns when you turn it ### Group threads by project (beta) Turn on **Settings → Labs → Boring sidebar (group by project)** on web and desktop to put active -threads in a folder for each project. Pinned projects come first. A project with no active +threads in a folder for each project. Pinned projects come first. To fix the folder order, set +**Settings → General → Project order** to **Manual** and drag projects into place in **Settings → +Projects**; agent activity won't move them then. A project with no active threads has no folder unless it is pinned or you pick it in the project filter. An archived project's folder comes last and shows only while it has active threads. Click a folder to open or close it. A closed folder shows how many threads it holds, and a dot when one of them