diff --git a/docs/reference/project-ordering-mode.md b/docs/reference/project-ordering-mode.md new file mode 100644 index 000000000..4cdc15955 --- /dev/null +++ b/docs/reference/project-ordering-mode.md @@ -0,0 +1,108 @@ +# Project Ordering Mode + +## Problem + +The sidebar can group workspaces by project, but project header order is currently coupled to workspace sorting instead of having its own user choice. + +- `src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx:59` defines one `Sort by` control for workspaces; it has no separate project-order setting. +- `src/renderer/src/store/slices/ui.ts:723` stores only `sortBy`, and `src/shared/types.ts:2543` persists the same workspace sort field. +- `src/renderer/src/components/sidebar/worktree-list-groups.ts:36` derives `ProjectGroupOrdering` from `sortBy`, so project headers follow the highest-ranked visible workspace in Recent/Smart and fall back to manual order otherwise. +- `src/renderer/src/components/sidebar/WorktreeList.tsx:3765` computes the ordered workspace ids, and `src/renderer/src/components/sidebar/WorktreeList.tsx:4060` feeds the derived project ordering into `buildRows(...)`. +- `src/renderer/src/components/sidebar/WorktreeList.tsx:826` only enables project-header drag when project grouping is manual and there are no Project Groups. +- `src/renderer/src/components/sidebar/project-header-drag.ts:1` already implements pointer-based project header dragging for virtualized rows, and `src/renderer/src/store/slices/repos.ts:699`, `src/main/ipc/repos.ts:1029`, and `src/main/persistence.ts:2497` already persist whole-repo manual reorders. + +Result: users cannot choose Manual vs Recent ordering specifically for projects, the default is not Manual, and changing workspace sort can unexpectedly reorder projects. + +## Goal + +Add a project-only ordering preference with two modes: + +- `Manual`, the default. Project headers render in the persisted manual project order, and users can drag project headers to reorder them. +- `Recent`. Project headers render by the most recent visible workspace activity in each project. + +This must affect only project header order in `groupBy: 'repo'`. Worktree/workspace rows inside each project must continue to use the existing workspace `sortBy`, filtering, pinning, lineage, status, and manual-order behavior. + +## Non-goals + +- Do not change `sortBy` semantics or the workspace `Sort by` control. +- Do not change worktree `manualOrder`, `sortOrder`, `lastActivityAt`, or `buildWorktreeComparator(...)`. +- Do not reorder worktrees inside a project when the project ordering mode changes. +- Do not change Project Group header order; groups continue to use `ProjectGroup.tabOrder`. +- Do not add GitHub-specific behavior. Repo/project ordering must remain provider-neutral and work for GitLab, folder projects, local repos, SSH repos, and runtime environments. +- Do not add telemetry in this pass unless a later product requirement asks for it. + +## Design + +1. Add a persisted project-order preference. + - Introduce `ProjectOrderBy = 'manual' | 'recent'` in `src/shared/types.ts` or a concrete sidebar ordering module. + - Add `projectOrderBy: ProjectOrderBy` to `PersistedUIState`, defaulting to `'manual'` in `getDefaultUIState()` (`src/shared/constants.ts:387`). + - Add `projectOrderBy` and `setProjectOrderBy` to the UI slice next to `sortBy` (`src/renderer/src/store/slices/ui.ts:723`). Like `setSortBy`, this setter is a bare `set({...})` — it does not persist on its own. + - Persist through the debounced UI writer, not the setter. `sortBy`/`groupBy` reach disk only via the explicit field list in `App.tsx`'s `window.api.ui.set({...})` effect (`src/renderer/src/App.tsx:1019`) plus its dependency array (`src/renderer/src/App.tsx:1042`). Add `projectOrderBy` to both, or the value lives only in memory and silently resets to `'manual'` every restart. + - Normalize persisted values in `src/main/persistence.ts` the same way `sortBy` is normalized at `src/main/persistence.ts:323`, and wire the normalizer into both `getUI()` (`src/main/persistence.ts:3116`) and `updateUI()` (`src/main/persistence.ts:3147`); invalid or missing values resolve to `'manual'`. + - Hydration in `src/renderer/src/store/slices/ui.ts:1862` should read the normalized value without migrating existing `sortBy`. + +2. Expose the choice in the sidebar options menu. + - In `SidebarWorkspaceOptionsMenu`, keep the existing `Sort by` submenu as workspace sorting. + - Add a second radio submenu labeled `Project order` with `Manual` and `Recent`. + - Show it only when `groupBy === 'repo'`, because project ordering has no visible effect in `none`, `workspace-status`, or `pr-status`. + - Use existing `DropdownMenuRadioGroup` and sidebar/menu styling from `docs/STYLEGUIDE.md`; no new tokens or custom colors. + - Copy should make the scope clear: Manual description `Drag projects to arrange them`; Recent description `Most recent workspace activity`. + +3. Decouple project header ordering from workspace sorting. + - Replace `getProjectGroupOrdering(groupBy, sortBy)` with a project-order resolver that depends on `groupBy` and `projectOrderBy`, not workspace `sortBy`. + - Update `buildRows(...)` so `groupBy !== 'repo'` ignores project order, `projectOrderBy === 'manual'` uses persisted manual ranks, and `projectOrderBy === 'recent'` sorts project header entries by a per-repo recent timestamp. + - Recent must be timestamp-based, not first-encounter. Today's `'visible-worktree-order'` path (`src/renderer/src/components/sidebar/worktree-list-groups.ts:548`) works only because the caller pre-sorts the worktree array by recency when `sortBy` is `recent`/`smart`. Decoupling from `sortBy` removes that guarantee — the incoming array may be name- or manual-sorted — so the Recent resolver must explicitly compute `max(lastActivityAt)` per repo rather than relying on encounter order. + - Recent project rank should be the maximum `lastActivityAt` among that repo's visible, unpinned worktrees passed into `buildRows(...)`. Empty placeholder projects and imported-worktree-card-only projects fall back to `Repo.addedAt`, then manual rank, then label. + - Do not reorder `group.items`; only reorder the project header entries before `appendOrderedGroups(...)`. This preserves the existing `orderMainWorktreeFirst(...)` and workspace row ordering at `src/renderer/src/components/sidebar/worktree-list-groups.ts:632`. + - With Project Groups, keep group headers ordered by `tabOrder`; apply Manual/Recent only to project entries within each group or the ungrouped bucket. + +4. Make Manual drag/drop project-scoped (no-Project-Groups case only in v1). + - Enable project header dragging when `groupBy === 'repo' && projectOrderBy === 'manual'`, regardless of workspace `sortBy`. Today `canReorderRepoHeaders` is additionally gated on `!hasProjectGroups` (`src/renderer/src/components/sidebar/WorktreeList.tsx:827`); the only change here is dropping the `sortBy`-derived `projectGroupOrdering` input in favor of `projectOrderBy === 'manual'`. The `!hasProjectGroups` gate stays. + - Keep using pointer events from `project-header-drag.ts` unchanged; the virtualized-row reasoning at `src/renderer/src/components/sidebar/project-header-drag.ts:3` still applies. For the no-Project-Groups case it already does exactly what we need: a flat whole-list permutation committed through `reorderRepos(...)`, preserving current behavior and IPC rejection semantics. + - Continue to suppress click-to-collapse only after a promoted drag, matching `src/renderer/src/components/sidebar/project-header-drag.ts:142`. + - Defer grouped (within-Project-Group) drag to a follow-up. `useRepoHeaderDrag` is built for a single flat `orderedRepoIds` permutation: `endDrag` (`src/renderer/src/components/sidebar/project-header-drag.ts:130`) computes `fromIndex`/`insertAt` over one array and calls `onCommit` → `reorderRepos`, and `onHandlePointerDown` (`src/renderer/src/components/sidebar/project-header-drag.ts:300`) snapshots every on-screen `[data-repo-header-id]` with no notion of group boundaries. Supporting in-group reorder needs new hook logic — bucket-aware drop targets, rejecting drops outside the source sibling bucket, computing a midpoint between neighbor `projectGroupOrder` values, and a second commit mode that calls `moveProjectToGroup(projectId, sameGroupId, order)` (`src/renderer/src/store/slices/repos.ts:138`) instead of `reorderRepos`. This is its own change and is out of scope for v1. + - In v1, Project-Groups users keep the project actions menu as the move surface (it already calls `moveProjectToGroup`). Manual project order still applies to their headers via the row-builder sort (step 3); only drag-to-reorder is unavailable inside groups. + - Do not turn a project reorder drag into a cross-group move in any version. The existing project actions menu remains the cross-group move surface. + +5. Keep persistence and runtime parity intact. + - Local no-group manual reorder continues through `repos:reorder` (`src/main/ipc/repos.ts:1029`) and `Store.reorderRepos(...)` (`src/main/persistence.ts:2497`). + - Remote no-group manual reorder continues through `repo.reorder` (`src/renderer/src/store/slices/repos.ts:723`). + - Grouped manual reorder via drag is deferred (see step 4); the follow-up would route through `projectGroup.moveProject` / `moveProjectToGroup(...)`, which already handles local and runtime-environment calls. In v1, grouped projects move only through the existing actions menu. + - The project-order preference is renderer UI state, not repo metadata. It persists through the `App.tsx` debounced `window.api.ui.set({...})` writer (step 1), not through any repo record. + +6. Tests. + - Add row-builder tests proving `projectOrderBy: 'manual'` orders project headers by `repoOrder` without changing workspace row order. + - Add row-builder tests proving `projectOrderBy: 'recent'` orders project headers by max visible `worktree.lastActivityAt` while preserving each project's child row order. + - Add Project Group tests for Manual and Recent within groups, plus unchanged `ProjectGroup.tabOrder`. + - Rewrite, do not extend, the existing Recent/`getProjectGroupOrdering` tests. The current cases at `src/renderer/src/components/sidebar/worktree-list-groups.test.ts:639` ("orders repo headers by first encounter…"), `:663` ("…highest-ranked visible child"), and the `getProjectGroupOrdering` block at `:752` all assert the old first-encounter coupling and are semantically incompatible with timestamp-based Recent. Replace them with tests for the new resolver and the `max(lastActivityAt)` ordering. + - Add UI slice/persistence normalization tests for default Manual, invalid value fallback, and hydration. Include a persistence-writer test (or note) that `projectOrderBy` is part of the `App.tsx` `ui.set` payload so it actually round-trips across restart. + - Grouped manual drag persistence tests are deferred with the grouped-drag feature (step 4). + +## Edge cases + +- Existing users with no `projectOrderBy` get Manual, even if their workspace `sortBy` is Recent. +- Changing workspace `sortBy` must not change project header order unless `projectOrderBy === 'recent'` and the visible worktree set/activity data changes. +- Changing `projectOrderBy` must not mutate any worktree `manualOrder` or repo order by itself. +- Manual drag while a project is added or removed can race. Whole-repo reorder (the only v1 drag path) keeps the existing permutation rejection and refetch behavior. (Grouped midpoint writes and their refetch-on-failure handling come with the deferred grouped-drag follow-up.) +- In Recent mode, projects with no visible worktrees should still render when they are placeholders or imported-worktree-card candidates; they sort after projects with activity. +- Pinned worktrees remain in the Pinned section. They should not make their project jump in Recent ordering unless an unpinned visible workspace in that project is also recent. +- Filters and hidden sleeping/default-branch workspaces affect the visible worktree set. Recent project order should reflect the rows the user can currently see. +- Project Group collapse state must not change when the order mode changes. +- Dragging a project inside a collapsed Project Group is impossible because its repo headers are not mounted; no special handling is needed. +- Dragging across Project Group boundaries should not silently move the project; cross-group moves stay on the actions menu. (In v1 there is no in-group drag at all — see step 4 — so this only constrains the deferred grouped-drag follow-up.) +- SSH/runtime projects must use the same store actions as local projects. No local filesystem path assumptions are needed. +- Folder projects have synthetic worktrees and should participate through the same `lastActivityAt` and manual repo order paths. +- Multi-window or external mutations are last-writer-wins through existing persistence. A rejected whole-repo permutation refetches repos (existing behavior); a failed grouped move would do the same once grouped drag lands. +- Behavior change on upgrade: existing users on `sortBy: recent`/`smart` currently see project headers bubble to follow workspace activity. After this change they default to Manual project order, so headers stop bubbling until they pick Recent in the new submenu. This is intended (matches the "no `projectOrderBy` → Manual" edge case) but is a visible change worth calling out in release notes. + +## Rollout + +1. Add `ProjectOrderBy` types/defaults/normalization, the UI slice setter, the `App.tsx` debounced-writer field, and persistence `getUI()`/`updateUI()` wiring. +2. Add the `Project order` submenu in `SidebarWorkspaceOptionsMenu`. +3. Update `WorktreeList` to read `projectOrderBy`, pass it to row construction, and enable project drag only in Manual project order (no-Project-Groups case, keeping the `!hasProjectGroups` gate). +4. Update `worktree-list-groups.ts` to order project headers by Manual or Recent independently from workspace `sortBy`, with Recent using `max(lastActivityAt)` per repo. +5. Add focused row-builder and UI persistence/round-trip tests; rewrite the incompatible first-encounter/`getProjectGroupOrdering` tests. +6. Run targeted Vitest for sidebar row ordering and repo slice tests, then `pnpm typecheck` and `pnpm lint`. +7. Validate in Electron: default startup shows Manual project order, the choice survives restart, project drag/drop reorders project headers only (ungrouped), Recent project order follows workspace activity, and worktree rows inside each project do not change when toggling project order. + +Deferred follow-up (separate change): extend `useRepoHeaderDrag` for grouped sibling buckets — bucket-aware drop targets and a second commit mode using `moveProjectToGroup(...)` midpoint ordering inside Project Groups — plus its grouped-drag persistence tests. diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 10409df03..5d618d8d0 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -606,6 +606,36 @@ describe('Store', () => { expect(store.getUI().groupBy).toBe('workspace-status') }) + it('defaults projectOrderBy to manual when absent, even with recent sortBy', async () => { + writeDataFile({ + schemaVersion: 1, + ui: { sortBy: 'recent' } + }) + const store = await createStore() + expect(store.getUI().projectOrderBy).toBe('manual') + }) + + it('falls back invalid projectOrderBy to manual', async () => { + writeDataFile({ + schemaVersion: 1, + ui: { projectOrderBy: 'bogus' } + }) + const store = await createStore() + expect(store.getUI().projectOrderBy).toBe('manual') + }) + + it('preserves and round-trips an explicit recent projectOrderBy', async () => { + writeDataFile({ + schemaVersion: 1, + ui: { projectOrderBy: 'recent' } + }) + const store = await createStore() + expect(store.getUI().projectOrderBy).toBe('recent') + + store.updateUI({ projectOrderBy: 'manual' }) + expect(store.getUI().projectOrderBy).toBe('manual') + }) + // ── 2. Load from existing valid file ───────────────────────────────── it('reads repos from an existing data file', async () => { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 736675dd0..fdece6281 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -334,6 +334,13 @@ function normalizeSortBy(sortBy: unknown): PersistedState['ui']['sortBy'] { return getDefaultUIState().sortBy } +function normalizeProjectOrderBy(projectOrderBy: unknown): PersistedState['ui']['projectOrderBy'] { + if (projectOrderBy === 'manual' || projectOrderBy === 'recent') { + return projectOrderBy + } + return getDefaultUIState().projectOrderBy +} + function normalizeRightSidebarTab(tab: unknown): PersistedState['ui']['rightSidebarTab'] { if ( tab === 'explorer' || @@ -3116,6 +3123,7 @@ export class Store { ...this.state.ui, groupBy: normalizeGroupBy(this.state.ui?.groupBy), sortBy: normalizeSortBy(this.state.ui?.sortBy), + projectOrderBy: normalizeProjectOrderBy(this.state.ui?.projectOrderBy), rightSidebarTab: normalizeRightSidebarTab(this.state.ui?.rightSidebarTab), worktreeCardProperties: normalizeWorktreeCardProperties( this.state.ui?.worktreeCardProperties @@ -3150,6 +3158,9 @@ export class Store { sortBy: updates.sortBy ? normalizeSortBy(updates.sortBy) : normalizeSortBy(this.state.ui?.sortBy), + projectOrderBy: updates.projectOrderBy + ? normalizeProjectOrderBy(updates.projectOrderBy) + : normalizeProjectOrderBy(this.state.ui?.projectOrderBy), rightSidebarTab: updates.rightSidebarTab !== undefined ? normalizeRightSidebarTab(updates.rightSidebarTab) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 39c938e7d..ad7ac8717 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -440,6 +440,7 @@ function App(): React.JSX.Element { const sidebarOpen = useAppStore((s) => s.sidebarOpen) const groupBy = useAppStore((s) => s.groupBy) const sortBy = useAppStore((s) => s.sortBy) + const projectOrderBy = useAppStore((s) => s.projectOrderBy) const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) const showDotfilesByWorktree = useAppStore((s) => s.showDotfilesByWorktree) @@ -1028,6 +1029,7 @@ function App(): React.JSX.Element { rightSidebarWidth, groupBy, sortBy, + projectOrderBy, showActiveOnly: false, hideSleepingWorkspaces: !showSleepingWorkspaces, showSleepingWorkspaces, @@ -1052,6 +1054,7 @@ function App(): React.JSX.Element { rightSidebarWidth, groupBy, sortBy, + projectOrderBy, showSleepingWorkspaces, hideDefaultBranchWorkspace, showDotfilesByWorktree, diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx index c7bf0a33f..005a9110f 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx @@ -72,6 +72,11 @@ const SORT_OPTIONS = [ } ] as const +const PROJECT_ORDER_OPTIONS = [ + { id: 'manual', label: 'Manual', description: 'Drag projects to arrange them' }, + { id: 'recent', label: 'Recent', description: 'Most recent workspace activity' } +] as const + const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsMenu({ preserveWorkspaceBoardOpen = false, onMenuOpenChange @@ -90,6 +95,8 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM const setSortBy = useAppStore((s) => s.setSortBy) const groupBy = useAppStore((s) => s.groupBy) const setGroupBy = useAppStore((s) => s.setGroupBy) + const projectOrderBy = useAppStore((s) => s.projectOrderBy) + const setProjectOrderBy = useAppStore((s) => s.setProjectOrderBy) const [open, setOpen] = useState(false) @@ -119,6 +126,8 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM (hasSleepingFilter ? 1 : 0) + (hideDefaultBranchWorkspace ? 1 : 0) + selectedCount const activeFilterLabel = `${activeFilterCount} ${activeFilterCount === 1 ? 'filter' : 'filters'}` const sortLabel = SORT_OPTIONS.find((opt) => opt.id === sortBy)?.label ?? 'Sort' + const projectOrderLabel = + PROJECT_ORDER_OPTIONS.find((opt) => opt.id === projectOrderBy)?.label ?? 'Manual' const cardLayout = settings?.experimentalCompactWorktreeCards ? 'compact' : 'detailed' const cardLayoutLabel = CARD_LAYOUT_OPTIONS.find((opt) => opt.id === cardLayout)?.label ?? 'Detailed' @@ -238,6 +247,47 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM + {/* Why: project order only has a visible effect when grouping by + project; hide it in none/status/PR modes to avoid a dead control. */} + {groupBy === 'repo' && ( + + + + Project order + + {projectOrderLabel} + + + + + setProjectOrderBy(v as typeof projectOrderBy)} + > + {PROJECT_ORDER_OPTIONS.map((opt) => ( + + + e.preventDefault()} + > + {opt.label} + + + + {opt.description} + + + ))} + + + + )} + diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 62624e863..da049255f 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -51,6 +51,7 @@ import type { Worktree, Repo, ProjectGroup, + ProjectOrderBy, WorktreeLineage, WorktreeMeta, WorkspaceStatus, @@ -68,14 +69,12 @@ import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { deriveRunningAgentSendTargets } from '@/lib/running-agent-targets' import { rightSidebarShowsPullRequestData } from '@/lib/right-sidebar-visibility' import { - type ProjectGroupOrdering, type Row, type WorktreeGroupBy, ALL_GROUP_KEY, PINNED_GROUP_KEY, buildRows, getGroupKeysForWorktree, - getProjectGroupOrdering, getLineageGroupKey } from './worktree-list-groups' import { @@ -357,7 +356,7 @@ type VirtualizedWorktreeViewportProps = { activeWorktreeId: string | null currentWorktreeId: string | null groupBy: WorktreeGroupBy - projectGroupOrdering: ProjectGroupOrdering + projectOrderBy: ProjectOrderBy toggleGroup: (key: string) => void collapsedGroups: Set handleCreateForRepo: (projectId: string) => void @@ -395,7 +394,6 @@ type VirtualizedWorktreeViewportProps = { // (filtered out / collapsed-only). Visible-only ids would silently drop the // hidden repos on reorder. allRepoIds: string[] - reorderRepos: (orderedIds: string[]) => void prCache: Record | null workspaceStatuses: readonly WorkspaceStatusDefinition[] projectGroups?: readonly ProjectGroup[] @@ -703,7 +701,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp activeWorktreeId, currentWorktreeId, groupBy, - projectGroupOrdering, + projectOrderBy, toggleGroup, collapsedGroups, handleCreateForRepo, @@ -734,7 +732,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp worktreeLineageById, repoOrder, allRepoIds, - reorderRepos, prCache, workspaceStatuses, projectGroups = EMPTY_PROJECT_GROUPS, @@ -824,7 +821,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const suppressWorktreeClickUntilRef = useRef(0) const hasProjectGroups = projectGroups.length > 0 const canReorderRepoHeaders = - groupBy === 'repo' && projectGroupOrdering === 'manual' && !hasProjectGroups + groupBy === 'repo' && projectOrderBy === 'manual' && !hasProjectGroups const lastVisibleRefreshKeyRef = useRef('') const reportVisibleGitHubPRRefreshCandidates = useAppStore( (s) => s.reportVisibleGitHubPRRefreshCandidates @@ -836,6 +833,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const prVisibleRefreshGeneration = useAppStore((s) => s.prVisibleRefreshGeneration) const settings = useAppStore((s) => s.settings) const deleteStateByWorktreeId = useAppStore((s) => s.deleteStateByWorktreeId) + const reorderRepos = useAppStore((s) => s.reorderRepos) useEffect( () => @@ -851,11 +849,27 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp [] ) + // Why: a project reorder relocates a whole group (header + its worktree + // rows) but leaves totalSize unchanged, so the current scrollTop is already + // the visually-stable position. Flag direct scroll input (same refs as + // markDirectScrollInput, defined later) so the scroll-anchor restore effect + // skips re-pinning the old top row — otherwise it chases the moved row and + // yanks the viewport, which is the "jumpy" drop. + const commitRepoReorder = useCallback( + (orderedIds: string[]) => { + const suppressUntil = + window.performance.now() + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS + suppressMeasurementAdjustmentUntilRef.current = suppressUntil + directScrollInputUntilRef.current = suppressUntil + reorderRepos(orderedIds) + }, + [reorderRepos] + ) // Drag is only meaningful when repo headers are using manual order. The // controller is still constructed for hook order stability when inert. const repoDrag = useRepoHeaderDrag({ orderedRepoIds: allRepoIds, - onCommit: reorderRepos, + onCommit: commitRepoReorder, getScrollContainer: () => scrollRef.current }) const worktreeDragGroups = useMemo(() => getWorktreeDragGroups(rows), [rows]) @@ -1418,7 +1432,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp new Set(), repoOrder, workspaceStatuses, - projectGroupOrdering, + projectOrderBy, worktreeLineageById, worktreeMap, true, @@ -1461,7 +1475,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp activeWorktreeId, virtualizer, groupBy, - projectGroupOrdering, + projectOrderBy, worktrees, repoMap, prCache, @@ -2765,7 +2779,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp repoDrag.state.dropIndicatorY !== null ? (
) : null} @@ -2877,6 +2894,16 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp row.repo && 'overflow-hidden' )} style={{ paddingLeft: headerPaddingLeft }} + // Why: arm project-header drag from anywhere on the row, not + // just the icon — users grab the name to reorder. The hook + // ignores presses on nested buttons (+/chevron) and only + // promotes to a drag past a 4px threshold, so a plain click + // still toggles collapse via onClick. + onPointerDown={ + canReorderRepoHeaders && isRepoHeader && projectIdForHeader + ? (e) => repoDrag.onHandlePointerDown(e, projectIdForHeader) + : undefined + } onDragOver={ isPinnedHeader ? handleWorkspacePinDragOver @@ -2906,11 +2933,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp > {row.icon ? (
repoDrag.onHandlePointerDown(e, projectIdForHeader) - : undefined - } className={cn( 'flex size-4 shrink-0 items-center justify-center rounded-[4px]', repoHeaderColor ? 'text-muted-foreground' : row.tone @@ -3616,6 +3638,7 @@ const WorktreeList = React.memo(function WorktreeList({ const workspaceStatuses = useAppStore((s) => s.workspaceStatuses) const sortBy = useAppStore((s) => s.sortBy) const setSortBy = useAppStore((s) => s.setSortBy) + const projectOrderBy = useAppStore((s) => s.projectOrderBy) const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) const filterRepoIds = useAppStore((s) => s.filterRepoIds) @@ -4058,8 +4081,6 @@ const WorktreeList = React.memo(function WorktreeList({ return getEmptyProjectPlaceholderRepoIds({ groupBy, repos, worktreesByRepo, filterRepoIds }) }, [filterRepoIds, groupBy, repos, worktreesByRepo]) const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos]) - const reorderReposAction = useAppStore((s) => s.reorderRepos) - const projectGroupOrdering = getProjectGroupOrdering(groupBy, sortBy) // Build flat row list for rendering const rows: Row[] = useMemo( @@ -4072,7 +4093,7 @@ const WorktreeList = React.memo(function WorktreeList({ effectiveCollapsedGroups, repoOrder, workspaceStatuses, - projectGroupOrdering, + projectOrderBy, worktreeLineageById, worktreeMap, true, @@ -4089,7 +4110,7 @@ const WorktreeList = React.memo(function WorktreeList({ effectiveCollapsedGroups, repoOrder, workspaceStatuses, - projectGroupOrdering, + projectOrderBy, worktreeLineageById, worktreeMap, settings, @@ -4675,7 +4696,7 @@ const WorktreeList = React.memo(function WorktreeList({ activeWorktreeId={selectedSidebarWorktreeId} currentWorktreeId={currentSidebarWorktreeId} groupBy={groupBy} - projectGroupOrdering={projectGroupOrdering} + projectOrderBy={projectOrderBy} toggleGroup={toggleGroup} collapsedGroups={effectiveCollapsedGroups} handleCreateForRepo={handleCreateForRepo} @@ -4706,9 +4727,6 @@ const WorktreeList = React.memo(function WorktreeList({ worktreeLineageById={worktreeLineageById} repoOrder={repoOrder} allRepoIds={allRepoIds} - reorderRepos={(orderedIds) => { - void reorderReposAction(orderedIds) - }} prCache={prCache} workspaceStatuses={workspaceStatuses} projectGroups={projectGroups} diff --git a/src/renderer/src/components/sidebar/project-header-drag.ts b/src/renderer/src/components/sidebar/project-header-drag.ts index 3172f76c2..c02d59171 100644 --- a/src/renderer/src/components/sidebar/project-header-drag.ts +++ b/src/renderer/src/components/sidebar/project-header-drag.ts @@ -118,10 +118,15 @@ export function useRepoHeaderDrag({ // indicator just above the target header keeps it at the visual top of // where the dragged group would land. const INDICATOR_GAP_PX = 4 - const indicatorY = + const rawIndicatorY = insertBefore >= rects.length ? rects.at(-1)!.bottom + INDICATOR_GAP_PX : Math.max(0, rects[insertBefore].top - INDICATOR_GAP_PX) + // Why: while scrolled, the topmost mounted header is pinned flush at the + // container top, so `top - GAP` lands above the overflow clip region and + // the line is painted invisibly. Floor the indicator at the current + // scroll offset so a top-of-list drop stays visible just below the edge. + const indicatorY = Math.max(container.scrollTop, rawIndicatorY) return { dropIndex: insertBefore, dropIndicatorY: indicatorY } }, [] diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index 182f5de0e..f3ee0dbbb 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -9,8 +9,7 @@ import { getGroupKeysForWorktree, getLineageGroupKey, getLineageRenderInfo, - getPRGroupKey, - getProjectGroupOrdering + getPRGroupKey } from './worktree-list-groups' import type { DetectedWorktree, @@ -611,10 +610,36 @@ describe('buildRows project grouping order', () => { [repoB.id, repoB], [repoC.id, repoC] ]) - const wA: Worktree = { ...worktree, id: 'wt-a', repoId: repoA.id, displayName: 'a' } - const wAStale: Worktree = { ...worktree, id: 'wt-a-stale', repoId: repoA.id, displayName: 'a2' } - const wB: Worktree = { ...worktree, id: 'wt-b', repoId: repoB.id, displayName: 'b' } - const wC: Worktree = { ...worktree, id: 'wt-c', repoId: repoC.id, displayName: 'c' } + // Activity: C (300) is freshest, then A (200), then B (100). wAStale (50) is + // an older sibling of A so a repo's rank is its max child, not its first. + const wA: Worktree = { + ...worktree, + id: 'wt-a', + repoId: repoA.id, + displayName: 'a', + lastActivityAt: 200 + } + const wAStale: Worktree = { + ...worktree, + id: 'wt-a-stale', + repoId: repoA.id, + displayName: 'a2', + lastActivityAt: 50 + } + const wB: Worktree = { + ...worktree, + id: 'wt-b', + repoId: repoB.id, + displayName: 'b', + lastActivityAt: 100 + } + const wC: Worktree = { + ...worktree, + id: 'wt-c', + repoId: repoC.id, + displayName: 'c', + lastActivityAt: 300 + } it('orders repo headers by explicit repoOrder, not first-encounter', () => { // Worktree stream encounters in order C, A, B — but repoOrder says B, A, C. @@ -636,11 +661,10 @@ describe('buildRows project grouping order', () => { expect(headerKeys).toEqual(['repo:repo-b', 'repo:repo-a', 'repo:repo-c']) }) - it('orders repo headers by first encounter when caller uses visible worktree order', () => { - // Caller already sorted worktrees by recency: C is freshest, then A, then B. - // Even though repoOrder pins B, A, C, dynamic sorts must follow the freshest - // worktree out of each repo so a just-active worktree's parent group - // bubbles to the top of the sidebar. + it('orders repo headers by max(lastActivityAt) per repo in Recent mode', () => { + // repoOrder pins B, A, C, but Recent ignores it: C (300) > A (200) > B (100). + // The incoming array is name-sorted (not pre-sorted by recency), proving the + // resolver computes the timestamp itself rather than trusting encounter order. const repoOrder = new Map([ [repoB.id, 0], [repoA.id, 1], @@ -648,58 +672,57 @@ describe('buildRows project grouping order', () => { ]) const rows = buildRows( 'repo', - [wC, wA, wB], + [wA, wB, wC], map, null, new Set(), repoOrder, undefined, - 'visible-worktree-order' + 'recent' ) const headerKeys = rows.filter((r) => r.type === 'header').map((r) => r.key) expect(headerKeys).toEqual(['repo:repo-c', 'repo:repo-a', 'repo:repo-b']) }) - it('orders repo headers by each repo highest-ranked visible child', () => { - const repoOrder = new Map([ - [repoB.id, 0], - [repoA.id, 1], - [repoC.id, 2] - ]) + it("uses each repo's freshest visible child, not its first, in Recent mode", () => { + // repo-a has a fresh child (200) and a stale one (50); its rank is the max. const rows = buildRows( 'repo', - [wA, wB, wAStale, wC], + [wAStale, wA, wB, wC], map, null, new Set(), - repoOrder, undefined, - 'visible-worktree-order' + undefined, + 'recent' ) expect(rows).toMatchObject([ - { type: 'header', key: 'repo:repo-a' }, - { type: 'item', worktree: { id: 'wt-a' } }, - { type: 'item', worktree: { id: 'wt-a-stale' } }, - { type: 'header', key: 'repo:repo-b' }, - { type: 'item', worktree: { id: 'wt-b' } }, { type: 'header', key: 'repo:repo-c' }, - { type: 'item', worktree: { id: 'wt-c' } } + { type: 'item', worktree: { id: 'wt-c' } }, + { type: 'header', key: 'repo:repo-a' }, + // Child rows keep their input order; only the header rank uses max activity. + { type: 'item', worktree: { id: 'wt-a-stale' } }, + { type: 'item', worktree: { id: 'wt-a' } }, + { type: 'header', key: 'repo:repo-b' }, + { type: 'item', worktree: { id: 'wt-b' } } ]) }) - it('keeps the main workspace first inside its project group', () => { + it('keeps the main workspace first inside its project group in Recent mode', () => { const main = { ...wA, id: 'wt-a-main', displayName: 'main', - isMainWorktree: true + isMainWorktree: true, + lastActivityAt: 10 } const freshChild = { ...wA, id: 'wt-a-fresh-child', displayName: 'fresh-child', - isMainWorktree: false + isMainWorktree: false, + lastActivityAt: 500 } const rows = buildRows( 'repo', @@ -709,7 +732,7 @@ describe('buildRows project grouping order', () => { new Set(), undefined, undefined, - 'visible-worktree-order' + 'recent' ) expect(rows).toMatchObject([ @@ -721,7 +744,7 @@ describe('buildRows project grouping order', () => { ]) }) - it('keeps repoOrder for manual project group ordering', () => { + it('orders repo headers by repoOrder in Manual mode (default), ignoring activity', () => { const repoOrder = new Map([ [repoB.id, 0], [repoA.id, 1], @@ -749,17 +772,43 @@ describe('buildRows project grouping order', () => { }) }) -describe('getProjectGroupOrdering', () => { - it.each([ - ['repo', 'recent', 'visible-worktree-order'], - ['repo', 'smart', 'visible-worktree-order'], - ['repo', 'name', 'manual'], - ['repo', 'repo', 'manual'], - ['none', 'recent', 'manual'], - ['workspace-status', 'recent', 'manual'], - ['pr-status', 'recent', 'manual'] - ] as const)('uses %s/%s -> %s', (groupBy, sortBy, expected) => { - expect(getProjectGroupOrdering(groupBy, sortBy)).toBe(expected) +describe('buildRows Recent project order fallbacks', () => { + const active: Repo = { ...repo, id: 'repo-active', displayName: 'active', addedAt: 0 } + // Empty project has no visible worktrees, so Recent falls back to addedAt. + const empty: Repo = { ...repo, id: 'repo-empty', displayName: 'empty', addedAt: 999 } + const map = new Map([ + [active.id, active], + [empty.id, empty] + ]) + const activeWorktree: Worktree = { + ...worktree, + id: 'wt-active', + repoId: active.id, + displayName: 'active', + lastActivityAt: 100 + } + + it('sorts placeholder projects after projects with activity', () => { + // empty.addedAt (999) is numerically higher than active's worktree (100), + // but a real activity timestamp must always outrank an addedAt fallback. + const rows = buildRows( + 'repo', + [activeWorktree], + map, + null, + new Set(), + undefined, + undefined, + 'recent', + {}, + undefined, + false, + undefined, + [], + new Set([empty.id]) + ) + const headerKeys = rows.filter((r) => r.type === 'header').map((r) => r.key) + expect(headerKeys).toEqual(['repo:repo-active', 'repo:repo-empty']) }) }) @@ -1134,6 +1183,71 @@ describe('project groups', () => { ]) }) + it('orders repos inside a Project Group by activity in recent mode, keeping tabOrder', () => { + const groupA: ProjectGroup = { + id: 'group-a', + name: 'Platform', + parentPath: '/platform', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 1, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + const groupB: ProjectGroup = { ...groupA, id: 'group-b', name: 'Infra', tabOrder: 0 } + // Inside group A: repoStale ordered first by projectGroupOrder, but repoFresh + // is more recently active so recent mode must lift it above repoStale. + const repoStale: Repo = { + ...repo, + id: 'repo-stale', + displayName: 'stale', + projectGroupId: groupA.id, + projectGroupOrder: 0 + } + const repoFresh: Repo = { + ...repo, + id: 'repo-fresh', + displayName: 'fresh', + projectGroupId: groupA.id, + projectGroupOrder: 1 + } + const groupedMap = new Map([ + [repoStale.id, repoStale], + [repoFresh.id, repoFresh] + ]) + const worktrees = [ + { ...worktree, id: 'wt-stale', repoId: repoStale.id, lastActivityAt: 10 }, + { ...worktree, id: 'wt-fresh', repoId: repoFresh.id, lastActivityAt: 500 } + ] + + const rows = buildRows( + 'repo', + worktrees, + groupedMap, + null, + new Set(), + undefined, + undefined, + 'recent', + {}, + new Map(worktrees.map((entry) => [entry.id, entry])), + false, + undefined, + // Group headers always follow tabOrder (Infra=0 before Platform=1), + // independent of projectOrderBy. + [groupA, groupB] + ) + + expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([ + 'project-group:group-b', + 'project-group:group-a', + 'repo:repo-fresh', + 'repo:repo-stale' + ]) + }) + it('renders nested Project Groups before repos assigned to their leaf group', () => { const rootGroup: ProjectGroup = { id: 'group-root', diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index 930ca53a0..7741cb82f 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -5,6 +5,7 @@ import type { DetectedWorktree, Repo, ProjectGroup, + ProjectOrderBy, Worktree, WorktreeLineage, WorkspaceStatusDefinition @@ -22,7 +23,6 @@ import { ConductorReviewIcon } from './workspace-status-icons' import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-statuses' -import type { SortBy } from './smart-sort' import type { AppState } from '@/store/types' import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '@/store/slices/github-cache-key' import { UNGROUPED_PROJECT_GROUP_KEY } from '../../../../shared/project-groups' @@ -31,16 +31,6 @@ import { getRepoDisplayLabelsByPath } from '@/lib/repo-display-labels' export { branchName } export type WorktreeGroupBy = 'none' | 'workspace-status' | 'repo' | 'pr-status' -export type ProjectGroupOrdering = 'manual' | 'visible-worktree-order' - -export function getProjectGroupOrdering( - groupBy: WorktreeGroupBy, - sortBy: SortBy -): ProjectGroupOrdering { - return groupBy === 'repo' && (sortBy === 'recent' || sortBy === 'smart') - ? 'visible-worktree-order' - : 'manual' -} export type GroupHeaderRow = { type: 'header' @@ -413,6 +403,88 @@ function withRepoSectionDisplayLabels(entries: readonly OrderedGroupEntry[]): Or ]) } +/** + * Recent rank for a project header. `hasActivity` projects (at least one + * visible worktree) always sort before fallback projects, regardless of the + * numeric values — a placeholder's `addedAt` must never outrank real activity. + * Within each tier, higher timestamps come first. + */ +type RecentRank = { hasActivity: boolean; ts: number } + +function recentRankForEntry(entry: OrderedGroupEntry): RecentRank { + let max = Number.NEGATIVE_INFINITY + for (const worktree of entry[1].items) { + if (worktree.lastActivityAt > max) { + max = worktree.lastActivityAt + } + } + if (max !== Number.NEGATIVE_INFINITY) { + // Why: Recent must be timestamp-based, not encounter order — the incoming + // array is no longer pre-sorted by recency once decoupled from sortBy. + return { hasActivity: true, ts: max } + } + const addedAt = entry[1].repo?.addedAt + return { + hasActivity: false, + ts: typeof addedAt === 'number' ? addedAt : Number.NEGATIVE_INFINITY + } +} + +function compareRecentRank(a: RecentRank, b: RecentRank): number { + if (a.hasActivity !== b.hasActivity) { + return a.hasActivity ? -1 : 1 + } + return b.ts - a.ts +} + +function manualRankForEntry( + entry: OrderedGroupEntry, + repoOrder: Map | undefined +): number { + const key = entry[0] + const repoId = key.startsWith('repo:') ? key.slice('repo:'.length) : key + const rank = repoOrder?.get(repoId) + return rank === undefined ? Number.POSITIVE_INFINITY : rank +} + +/** + * Order project header entries by the user's project-order preference. Manual + * follows the canonical repoOrder; Recent follows each project's most recent + * visible workspace activity (descending), with empty/imported-only projects + * sorting after active ones, then by manual rank, then label. + */ +function sortProjectEntries( + entries: OrderedGroupEntry[], + projectOrderBy: ProjectOrderBy, + repoOrder: Map | undefined +): OrderedGroupEntry[] { + if (projectOrderBy === 'recent') { + return [...entries].sort((a, b) => { + const byRecent = compareRecentRank(recentRankForEntry(a), recentRankForEntry(b)) + if (byRecent !== 0) { + return byRecent + } + const ma = manualRankForEntry(a, repoOrder) + const mb = manualRankForEntry(b, repoOrder) + if (ma !== mb) { + return ma - mb + } + return a[1].label.localeCompare(b[1].label) + }) + } + if (!repoOrder) { + return entries + } + return [...entries].sort((a, b) => { + const ra = manualRankForEntry(a, repoOrder) + const rb = manualRankForEntry(b, repoOrder) + if (ra !== rb) { + return ra - rb + } + return a[1].label.localeCompare(b[1].label) + }) +} + /** * Build the flat row list consumed by the virtualizer. * Extracted here to keep WorktreeList.tsx under the line-count lint limit. @@ -425,7 +497,7 @@ export function buildRows( collapsedGroups: Set, repoOrder?: Map, workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(), - projectGroupOrdering: ProjectGroupOrdering = 'manual', + projectOrderBy: ProjectOrderBy = 'manual', lineageById: Record = {}, worktreeMap: Map = new Map( worktrees.map((worktree) => [worktree.id, worktree]) @@ -546,25 +618,10 @@ export function buildRows( } } } else { - // Why: dynamic sorts need repo headers to follow their highest-ranked - // visible child. Manual ordering still uses the canonical state.repos - // order so repo-header drag has a stable source of truth. - const entries = Array.from(grouped.entries()) - if (projectGroupOrdering === 'manual' && repoOrder) { - const rankFor = (key: string): number => { - const repoId = key.startsWith('repo:') ? key.slice('repo:'.length) : key - const rank = repoOrder.get(repoId) - return rank === undefined ? Number.POSITIVE_INFINITY : rank - } - entries.sort((a, b) => { - const ra = rankFor(a[0]) - const rb = rankFor(b[0]) - if (ra !== rb) { - return ra - rb - } - return a[1].label.localeCompare(b[1].label) - }) - } + // Why: project header order is its own user choice (projectOrderBy), + // decoupled from workspace sortBy. Manual uses the canonical repoOrder so + // header drag has a stable source of truth; Recent follows activity. + const entries = sortProjectEntries(Array.from(grouped.entries()), projectOrderBy, repoOrder) // Why: large imported repo sets can have one group per repo; spreading // those entries into push can exceed V8's argument limit. for (const entry of entries) { @@ -656,9 +713,13 @@ export function buildRows( } const sortRepoEntriesWithinGroup = (entries: OrderedGroupEntry[]): OrderedGroupEntry[] => { - if (projectGroupOrdering !== 'manual') { - return entries + if (projectOrderBy === 'recent') { + return [...entries].sort((left, right) => + compareRecentRank(recentRankForEntry(left), recentRankForEntry(right)) + ) } + // Manual: within a Project Group, projects order by their per-group rank + // (projectGroupOrder), not the global repoOrder. return [...entries].sort((left, right) => { const leftOrder = left[1].repo?.projectGroupOrder const rightOrder = right[1].repo?.projectGroupOrder diff --git a/src/renderer/src/lib/startup-ui-hydration.ts b/src/renderer/src/lib/startup-ui-hydration.ts index 92576add6..8735e305c 100644 --- a/src/renderer/src/lib/startup-ui-hydration.ts +++ b/src/renderer/src/lib/startup-ui-hydration.ts @@ -40,6 +40,7 @@ export function getStartupErrorFallbackUI(uiHydrated: boolean): PersistedUIState rightSidebarWidth: 350, groupBy: 'repo', sortBy: 'name', + projectOrderBy: 'manual', showActiveOnly: false, hideSleepingWorkspaces: DEFAULT_HIDE_SLEEPING_WORKSPACES, showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES, diff --git a/src/renderer/src/store/slices/repo-identity-reconcile.test.ts b/src/renderer/src/store/slices/repo-identity-reconcile.test.ts new file mode 100644 index 000000000..716151471 --- /dev/null +++ b/src/renderer/src/store/slices/repo-identity-reconcile.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../../shared/types' +import { reconcileFetchedRepos } from './repo-identity-reconcile' + +function makeRepo(id: string, overrides: Partial = {}): Repo { + return { id, path: `/${id}`, displayName: id, badgeColor: '#000', addedAt: 1, ...overrides } +} + +describe('reconcileFetchedRepos', () => { + it('returns the previous array when the fetched list is field-identical', () => { + const previous = [makeRepo('a'), makeRepo('b')] + const next = [makeRepo('a'), makeRepo('b')] + expect(reconcileFetchedRepos(previous, next)).toBe(previous) + }) + + it('reuses unchanged repo objects while reflecting a reorder', () => { + const previous = [makeRepo('a'), makeRepo('b')] + const next = [makeRepo('b'), makeRepo('a')] + const result = reconcileFetchedRepos(previous, next) + expect(result).not.toBe(previous) + expect(result.map((r) => r.id)).toEqual(['b', 'a']) + // Identity preserved so memos keyed on repo objects don't churn. + expect(result[0]).toBe(previous[1]) + expect(result[1]).toBe(previous[0]) + }) + + it('keeps a new object only for the repo whose fields changed', () => { + const previous = [makeRepo('a'), makeRepo('b')] + const next = [makeRepo('a'), makeRepo('b', { displayName: 'renamed' })] + const result = reconcileFetchedRepos(previous, next) + expect(result[0]).toBe(previous[0]) + expect(result[1]).toBe(next[1]) + }) + + it('keeps fetched data when optional repo keys differ with the same key count', () => { + const previous = [makeRepo('a', { projectGroupId: undefined })] + const next = [makeRepo('a', { projectGroupOrder: 2 })] + const result = reconcileFetchedRepos(previous, next) + expect(result[0]).toBe(next[0]) + }) + + it('returns a rebuilt array when repos are added or removed', () => { + const previous = [makeRepo('a')] + const next = [makeRepo('a'), makeRepo('b')] + const result = reconcileFetchedRepos(previous, next) + expect(result).not.toBe(previous) + expect(result[0]).toBe(previous[0]) + expect(result.map((r) => r.id)).toEqual(['a', 'b']) + }) +}) diff --git a/src/renderer/src/store/slices/repo-identity-reconcile.ts b/src/renderer/src/store/slices/repo-identity-reconcile.ts new file mode 100644 index 000000000..55d313e0c --- /dev/null +++ b/src/renderer/src/store/slices/repo-identity-reconcile.ts @@ -0,0 +1,44 @@ +import type { Repo } from '../../../../shared/types' + +// Why: after a drag-reorder we optimistically set `repos`, persist, and main +// broadcasts `repos:changed`. The renderer's own echo handler refetches, which +// would otherwise hand back field-identical repos as brand-new objects. New +// identities invalidate the repoMap/repoOrder/rows memos and force the +// virtualizer to rebuild + re-measure a tick after the drop — the visible jump. +// Reusing equal objects (and the whole array when nothing moved) makes the echo +// a no-op render. +function areReposEqual(a: Repo, b: Repo): boolean { + if (a === b) { + return true + } + const keys = Object.keys(a) as (keyof Repo)[] + if (keys.length !== Object.keys(b).length) { + return false + } + for (const key of keys) { + if (!Object.prototype.hasOwnProperty.call(b, key)) { + return false + } + if (a[key] !== b[key]) { + return false + } + } + return true +} + +export function reconcileFetchedRepos(previous: readonly Repo[], next: Repo[]): Repo[] { + const previousById = new Map(previous.map((repo) => [repo.id, repo])) + let identical = next.length === previous.length + const reconciled = next.map((repo, index) => { + const existing = previousById.get(repo.id) + if (existing && areReposEqual(existing, repo)) { + if (existing !== previous[index]) { + identical = false + } + return existing + } + identical = false + return repo + }) + return identical ? (previous as Repo[]) : reconciled +} diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 0a5a244a5..fb4422183 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -16,6 +16,7 @@ import { sanitizeRepoIcon } from '../../../../shared/repo-icon' import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color' import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups' import { getRepoIdFromWorktreeId } from './worktree-helpers' +import { reconcileFetchedRepos } from './repo-identity-reconcile' import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' import { buildDismissedOnboardingFolderAgentStartup } from '@/lib/onboarding-folder-agent-startup' @@ -170,8 +171,9 @@ export const createRepoSlice: StateCreator = (set, ).repos set((s) => { const validRepoIds = new Set(repos.map((repo) => repo.id)) + const reconciledRepos = reconcileFetchedRepos(s.repos, repos) return { - repos, + repos: reconciledRepos, activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, filterRepoIds: s.filterRepoIds.filter((projectId) => validRepoIds.has(projectId)), setupScriptPromptDismissedRepoIds: filterSetupScriptPromptDismissalsToValidRepos( diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 44c286b88..c824461ba 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -20,6 +20,7 @@ import type { UpdateStatus, WorkspaceStatusDefinition, AgentActivityDisplayMode, + ProjectOrderBy, WorktreeCardProperty } from '../../../../shared/types' import type { LaunchSource } from '../../../../shared/telemetry-events' @@ -729,6 +730,8 @@ export type UISlice = { setGroupBy: (g: UISlice['groupBy']) => void sortBy: 'name' | 'smart' | 'recent' | 'repo' | 'manual' setSortBy: (s: UISlice['sortBy']) => void + projectOrderBy: ProjectOrderBy + setProjectOrderBy: (p: ProjectOrderBy) => void showActiveOnly: boolean setShowActiveOnly: (v: boolean) => void showSleepingWorkspaces: boolean @@ -1636,6 +1639,11 @@ export const createUISlice: StateCreator = (set, get) sortBy: 'recent', setSortBy: (s) => set({ sortBy: s }), + // Why: like setSortBy, this is a bare set — it persists only via the + // debounced window.api.ui.set writer in App.tsx, not on its own. + projectOrderBy: 'manual', + setProjectOrderBy: (p) => set({ projectOrderBy: p }), + showActiveOnly: false, setShowActiveOnly: (v) => set({ showActiveOnly: v }), @@ -1898,6 +1906,9 @@ export const createUISlice: StateCreator = (set, get) rightSidebarTab: normalizePersistedRightSidebarTab(ui.rightSidebarTab), groupBy: (ui.groupBy as UISlice['groupBy'] | 'parent') === 'parent' ? 'repo' : ui.groupBy, sortBy, + // Why: main-process getUI() already normalized this to a valid value + // (defaulting to 'manual'); read it through without migrating sortBy. + projectOrderBy: ui.projectOrderBy, // Why: Active-only was retired. Force the old persisted flag off so an // old profile cannot invisibly keep narrowing the workspace list. showActiveOnly: false, diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 12029d1fb..d0a5e14f3 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -396,6 +396,7 @@ export function getDefaultUIState(): PersistedUIState { rightSidebarWidth: 350, groupBy: 'repo', sortBy: 'recent', + projectOrderBy: 'manual', showActiveOnly: false, hideSleepingWorkspaces: DEFAULT_HIDE_SLEEPING_WORKSPACES, showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES, diff --git a/src/shared/types.ts b/src/shared/types.ts index 634347261..33850606f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2539,6 +2539,8 @@ export type TaskResumeState = { export type RightSidebarTab = 'explorer' | 'search' | 'source-control' | 'checks' | 'ports' +export type ProjectOrderBy = 'manual' | 'recent' + export type PersistedUIState = { lastActiveRepoId: string | null lastActiveWorktreeId: string | null @@ -2548,6 +2550,11 @@ export type PersistedUIState = { rightSidebarWidth: number groupBy: 'none' | 'workspace-status' | 'repo' | 'pr-status' sortBy: 'name' | 'smart' | 'recent' | 'repo' | 'manual' + /** Project header ordering in `groupBy: 'repo'`, independent of workspace + * `sortBy`. 'manual' (default) uses the persisted repo order and enables + * header drag; 'recent' orders by each project's most recent visible + * workspace activity. */ + projectOrderBy: ProjectOrderBy /** Deprecated; the Active only filter is retired and ignored on hydration. */ showActiveOnly: boolean /** Hide sleeping/inactive workspaces from workspace navigation. Off by default. */