diff --git a/CHANGELOG.md b/CHANGELOG.md index 720e1db6..8f3325cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,7 @@ All notable changes to this project will be documented in this file. - Usage/Command Code: Command Code plan limits now appear in the Usage page and work status panel. - Git: the pull request panel now follows the branch's current open PR, and an open PR always wins over an older merged or closed one. After a PR is merged or closed the panel keeps showing it as the branch's last PR and offers creating the next one right below it (thanks to @makeittech). - Git/Worktrees: creating a worktree from a pull request now falls back to GitHub's pull-request reference when the source fork was deleted or cannot be reached, instead of failing before creating the worktree (thanks to @makeittech). +- Git/Worktrees: session menus can now move an idle session and its sub-sessions into an existing worktree, and opening the target list discovers worktrees created outside OpenChamber without a restart (thanks to @mattv8). - Chat: new chats no longer start against a deleted last worktree directory; they fall back to the active project instead of saving the first message and never starting. - Chat: typing with Chinese, Japanese, or Korean input methods no longer interrupts composition or jumps the cursor to the end of the composer (thanks to @makeittech). - Chat: opening a busy subagent in the context panel now shows its history instead of only the working-status line (thanks to @makeittech). diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 9a7361bd..f3fc8c15 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -40,6 +40,14 @@ import { runBackgroundNetworkTask } from '@/lib/background-network'; import { buildKnownSessionDirectories } from './sidebar/list/sessionListDirectories'; import { z } from 'zod'; import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents'; +import { + commitDiscoveredRawWorktreesByProject, + ensureRawWorktreesByProjectScope, + startSessionWorktreeMenuLoad, + type RawWorktreesByProjectScope, + type StartSessionWorktreeMenuLoadArgs, +} from './sidebar/sessionWorktreeMenu'; +import { resolveProjectRef } from '@/lib/worktreeSessionCreator'; const PROJECT_ACTIVE_SESSION_STORAGE_KEY = 'oc.sessions.activeSessionByProject'; const EMPTY_STRING_ARRAY: string[] = []; @@ -189,6 +197,11 @@ const SessionSidebarComponent: React.FC = ({ const [worktreeDiscoveryRevision, requestWorktreeDiscovery] = React.useReducer((revision) => revision + 1, 0); const isWorktreeTopologyLoading = !isVSCode && resolvedWorktreeTopologyKey !== projectWorktreeDiscoveryKey; const [unresolvedWorktreeProjectPaths, setUnresolvedWorktreeProjectPaths] = React.useState>(new Set()); + const rawWorktreesByProjectRef = React.useRef({ + runtimeKey: null, + revision: 0, + worktreesByProject: new Map(), + }); React.useEffect(() => { let cancelled = false; @@ -198,14 +211,25 @@ const SessionSidebarComponent: React.FC = ({ const projectEntries = useProjectsStore.getState().projects; if (projectEntries.length === 0 || isVSCode) { if (!cancelled) { + rawWorktreesByProjectRef.current = { + runtimeKey: null, + revision: 0, + worktreesByProject: new Map(), + }; setUnresolvedWorktreeProjectPaths(new Set()); setResolvedWorktreeTopologyKey(projectWorktreeDiscoveryKey); } return; } - const knownWorktreesByProject = useSessionUIStore.getState().availableWorktreesByProject; - const worktreesByProject = new Map(knownWorktreesByProject); + const knownPublishedWorktreesByProject = useSessionUIStore.getState().availableWorktreesByProject; + const seededRawScope = ensureRawWorktreesByProjectScope({ + rawWorktreesByProjectRef, + publishedWorktreesByProject: knownPublishedWorktreesByProject, + runtimeKey: discoveryRuntimeKey, + }); + const capturedRawRevision = seededRawScope.revision; + const worktreesByProject = new Map(seededRawScope.worktreesByProject); const unresolvedProjectPaths = new Set(); // Constrain fanout: previously `Promise.all(projects.map(...))` could @@ -258,18 +282,26 @@ const SessionSidebarComponent: React.FC = ({ worktreesByProject.delete(projectPath); } } - const partitionedWorktreesByProject = partitionWorktreesByRegisteredProject(projectEntries, worktreesByProject); - const allWorktrees = [...partitionedWorktreesByProject.values()].flat(); - // Newly appearing worktrees sort to the top of their project's - // worktree list (see worktreeFirstSeen.ts). - recordWorktreesSeen(allWorktrees.map((worktree) => worktree.path), Date.now()); - - // Skip update if nothing changed — see worktreeMapsEqual JSDoc. - if (!worktreeMapsEqual(partitionedWorktreesByProject, knownWorktreesByProject)) { - useSessionUIStore.setState({ - availableWorktrees: allWorktrees, - availableWorktreesByProject: partitionedWorktreesByProject, - }); + const committed = commitDiscoveredRawWorktreesByProject({ + rawWorktreesByProjectRef, + runtimeKey: discoveryRuntimeKey, + capturedRevision: capturedRawRevision, + nextRawWorktreesByProject: worktreesByProject, + publishedWorktreesByProject: knownPublishedWorktreesByProject, + partitionWorktreesByRegisteredProject, + projects: projectEntries, + worktreeMapsEqual, + recordWorktreesSeen, + publishTopology: (next) => { + useSessionUIStore.setState(next); + }, + requestRediscovery: () => { + requestWorktreeDiscovery(); + }, + now: () => Date.now(), + }); + if (!committed) { + return; } setUnresolvedWorktreeProjectPaths(unresolvedProjectPaths); setResolvedWorktreeTopologyKey(projectWorktreeDiscoveryKey); @@ -367,7 +399,6 @@ const SessionSidebarComponent: React.FC = ({ }, []); - const normalizedProjects = React.useMemo(() => { return projects.flatMap((project) => { const normalizedPath = normalizePath(project.path); @@ -527,6 +558,28 @@ const SessionSidebarComponent: React.FC = ({ openMultiRunLauncher(); }, [mobileVariant, openMultiRunLauncher, setSessionSwitcherOpen]); + const handleSessionWorktreeMenuLoad = React.useCallback((args: StartSessionWorktreeMenuLoadArgs) => { + const resolvedProject = args.projectId + ? (projects.find((candidate) => candidate.id === args.projectId) ?? null) + : (args.sourceDirectory ? resolveProjectRef(args.sourceDirectory) : null); + return startSessionWorktreeMenuLoad(args, { + projects, + rawWorktreesByProjectRef, + getPublishedWorktreesByProject: () => useSessionUIStore.getState().availableWorktreesByProject, + resolveProject: (directory) => resolveProjectRef(directory), + listProjectWorktrees, + partitionWorktreesByRegisteredProject, + worktreeMapsEqual, + recordWorktreesSeen, + publishTopology: (next) => { + useSessionUIStore.setState(next); + }, + getRuntimeKey, + now: () => Date.now(), + projectRootBranch: resolvedProject ? (projectRootBranches.get(resolvedProject.id) ?? null) : null, + }); + }, [projectRootBranches, projects]); + const handleOpenNewSessionDraftFromHeader = React.useCallback(() => { useUIStore.getState().closeMainSurfaces(); if (mobileVariant) { @@ -637,6 +690,7 @@ const SessionSidebarComponent: React.FC = ({ openProjectEditDialog: setEditingProjectDialogId, removeProject, reorderProjects, + startSessionWorktreeMenuLoad: handleSessionWorktreeMenuLoad, initialActiveSessionByProject, persistActiveSessionByProject, projectViewActions: projectView.actions, diff --git a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md index 15a94964..0cc1692e 100644 --- a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md +++ b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md @@ -11,6 +11,11 @@ kept at this root in `types.ts` and `utils.tsx`. - `sessions/` owns session rows, row actions, expansion, ownership, and activity indicators. - `recent/` owns Recent and managed Chats activity projections. - `folders/` owns folder DnD, bulk actions, archived folders, and folder UI. +- Root session right-click and overflow menus expose `Move to worktree`: a submenu + listing existing primary and linked worktree destinations, the current target + greyed and disabled, plus a `New worktree...` action. Moving to an existing or + new destination transfers the full idle subtree; only the root session carries + uncommitted changes. `MainLayout` and `VSCodeLayout` call `useSessionListSync({ isVSCode })` unconditionally. The hook publishes complete directory bootstrap demand, @@ -35,3 +40,24 @@ Directory demand always includes known project roots and worktrees. Visibility only changes priority. Row mounts must not start bootstrap work. Selection and activity subscriptions stay session-scoped so a structural list update does not make every row observe unrelated streaming updates. + +## Loading rules + +- Always publish every known project root and worktree directory. Collapse/visibility changes priority only; they do not opt a directory out of authoritative refresh. +- Current directory and selected-session directory are `selected` demand and therefore run first. +- Expanded projects/worktrees outrank merely visible and background groups. +- The sync scheduler deduplicates, promotes, retries, and limits work. Sidebar components must not reproduce that lifecycle with mount effects. +- Hide speculative work when the sidebar/chat surface is hidden: message prefetch, Git/PR enrichment and subscriptions, search listeners, sticky-header observation, and archived-folder derivation stop. The session row tree unmounts so row-owned status, permission, unseen, and viewport subscriptions do no background work. The outer sidebar remains mounted, preserving UI state and authoritative directory refresh for an immediate reopen; deferred derived work reruns from current state when visibility returns. +- The sidebar does not subscribe its whole tree to the cross-directory live-session aggregate. Global create/structural/lifecycle snapshots drive rendered session metadata; the cached sync index only fills sessions not yet present globally and provides refresh fallback data. Row activity continues to come from the session-keyed live status index. +- Session selection does not invalidate the sidebar orchestration component. Each mounted row selects only whether its own session ID is active, while parent expansion, project selection memory, and neighbor prefetch run in small effect-only subscribers. +- Parent expansion is exclusively manual. Selecting or navigating to a subsession never expands its parent automatically. Project/worktree and `recent` trees use independent persisted context keys and receive separate stable projections, so expansion changes in one context neither invalidate nor change the other. The persisted storage key remains `v3`; older state mixed contexts and is not migrated into this contract. +- Folder membership may contain both a parent session and its descendants. Rendering treats only the highest assigned ancestors as folder roots because their normal session trees already include assigned descendants; persisted membership remains unchanged for cleanup and move semantics. +- Sidebar selection holds the clicked row's viewport position across navigation-driven sidebar updates. Wheel or touch input cancels the hold immediately, so programmatic compensation never fights intentional scrolling. +- Global session subscriptions are structural: create/delete, title, share, archive, directory, parent, and slug changes invalidate the tree. Recency-only `time.updated` changes do not trigger a rebuild. The separate lifecycle rank invalidates ordering only on `settled ↔ active` transitions, with root sessions ranked among roots and child sessions only among siblings of the same parent. +- Opening the root-session `Move to worktree` submenu force-refreshes the owning project's worktree topology so externally created worktrees appear without a full reload. While that refresh runs, the menu keeps the last known primary/linked topology visible; if the refresh fails, the stale topology remains and the load failure state stays explicit. Failure cleanup never removes or manages an existing destination worktree. +- CLI/server-created sessions use the low-frequency OpenChamber control event stream to refresh only the created session directory. The same event retriggers bounded worktree discovery so a newly created external worktree gains ownership without a view reload; it does not re-enable broad session or streaming subscriptions. +- Recent membership includes active root sessions immediately even when their last committed `time.updated` falls outside the 48-hour window. Children and archived sessions remain excluded, and inactive roots remain timestamp-based. The active-ID subscription is disabled while the sidebar is hidden and ignores retry/status detail changes, avoiding streaming-frequency rerenders. +- Structural updates rebuild grouped nodes only for projects whose local sessions, worktrees, repository state, or branch changed; unchanged project sections preserve references so memoized group/session descendants skip the update wave. +- Empty successful lists, unresolved loads, and failed loads are separate UI states. Failed groups expose Retry and retain prior data. +- Directory permission failures remain visible even when stale sessions are retained. Flat groups inspect every represented root/worktree directory; local Desktop may open the native picker for the exact failed directory, while other runtimes keep the ordinary Retry action. +- Pins and folder assignments are not pruned from the first startup snapshot or from optimistic mutations. Confirmed local deletion and routed external deletion clean immediately; a later authoritative omission after an established baseline covers missed external delete events. diff --git a/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx b/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx index 9ed3e620..4bd79c52 100644 --- a/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx +++ b/packages/ui/src/components/session/sidebar/list/SessionProjectCollection.tsx @@ -103,6 +103,7 @@ type SessionProjectCollectionProps = { openProjectEditDialog: (id: string) => void; removeProject: (id: string) => void; reorderProjects: (fromIndex: number, toIndex: number) => void; + startSessionWorktreeMenuLoad: SessionTreeItemProps['startSessionWorktreeMenuLoad']; renderProjectStatusIndicator?: (projectId: string, groups: SessionGroup[]) => React.ReactNode; initialActiveSessionByProject: Map; persistActiveSessionByProject: (value: Map) => void; @@ -330,6 +331,7 @@ const VisibleSessionProjects: React.FC = ({ topol setDeleteSessionConfirm, startFolderRename, setCopiedSessionId, + startSessionWorktreeMenuLoad: actions.startSessionWorktreeMenuLoad, folderRename, setFolderRenameDraft, clearFolderRename, @@ -350,6 +352,7 @@ const VisibleSessionProjects: React.FC = ({ topol deleteSessionConfirm, copiedSessionId, setCopiedSessionId, + actions.startSessionWorktreeMenuLoad, rowActions, toggleParent, view.hideDirectoryControls, @@ -457,6 +460,7 @@ const VisibleSessionProjects: React.FC = ({ topol setDeleteSessionConfirm={setDeleteSessionConfirm} startFolderRename={startFolderRename} setCopiedSessionId={setCopiedSessionId} + startSessionWorktreeMenuLoad={actions.startSessionWorktreeMenuLoad} chatSessions={collection.chatSessions} renderChatsSection={renderChatsSection} onNewChat={handleOpenNewChat} diff --git a/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.behavior.test.tsx b/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.behavior.test.tsx index 7665a74b..83882dc1 100644 --- a/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.behavior.test.tsx +++ b/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.behavior.test.tsx @@ -138,6 +138,10 @@ const createProps = (): SessionGroupSectionProps => ({ deleteSessionConfirm: null, setDeleteSessionConfirm: () => undefined, setCopiedSessionId: () => undefined, + startSessionWorktreeMenuLoad: () => ({ + cachedTargets: [], + refreshTargets: Promise.resolve([]), + }), onToggleCollapsedGroup: () => undefined, folderRename: null, setFolderRenameDraft: () => undefined, diff --git a/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.tsx b/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.tsx index 7a571e30..42a4b474 100644 --- a/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.tsx +++ b/packages/ui/src/components/session/sidebar/projects/SessionGroupSection.tsx @@ -108,6 +108,7 @@ export type SessionGroupSectionProps = { | 'setDeleteSessionConfirm' | 'startFolderRename' | 'setCopiedSessionId' + | 'startSessionWorktreeMenuLoad' >; const CollapsedFolderActivity: React.FC<{ @@ -253,6 +254,7 @@ const areGroupPropsEqual = (prev: SessionGroupSectionProps, next: SessionGroupSe && prev.setDeleteSessionConfirm === next.setDeleteSessionConfirm && prev.startFolderRename === next.startFolderRename && prev.setCopiedSessionId === next.setCopiedSessionId + && prev.startSessionWorktreeMenuLoad === next.startSessionWorktreeMenuLoad && prev.setFolderRenameDraft === next.setFolderRenameDraft && prev.clearFolderRename === next.clearFolderRename ); @@ -852,10 +854,11 @@ function SessionGroupSectionBase(props: SessionGroupSectionProps): React.ReactNo setSessionSearchQuery={props.setSessionSearchQuery} setIsSessionSearchOpen={props.setIsSessionSearchOpen} deleteSessionConfirm={props.deleteSessionConfirm} - setDeleteSessionConfirm={props.setDeleteSessionConfirm} - startFolderRename={props.startFolderRename} - setCopiedSessionId={props.setCopiedSessionId} - />)} + setDeleteSessionConfirm={props.setDeleteSessionConfirm} + startFolderRename={props.startFolderRename} + setCopiedSessionId={props.setCopiedSessionId} + startSessionWorktreeMenuLoad={props.startSessionWorktreeMenuLoad} + />)} )} @@ -962,7 +965,8 @@ function SessionGroupSectionBase(props: SessionGroupSectionProps): React.ReactNo setDeleteSessionConfirm={props.setDeleteSessionConfirm} startFolderRename={props.startFolderRename} setCopiedSessionId={props.setCopiedSessionId} - />; + startSessionWorktreeMenuLoad={props.startSessionWorktreeMenuLoad} + />; const body = ( & { pinnedSessionIds: Set; sessionOrderIndex: Map; diff --git a/packages/ui/src/components/session/sidebar/recent/RecentSessionSection.tsx b/packages/ui/src/components/session/sidebar/recent/RecentSessionSection.tsx index b3abac77..b17a9424 100644 --- a/packages/ui/src/components/session/sidebar/recent/RecentSessionSection.tsx +++ b/packages/ui/src/components/session/sidebar/recent/RecentSessionSection.tsx @@ -49,6 +49,7 @@ type Props = { | 'setDeleteSessionConfirm' | 'startFolderRename' | 'setCopiedSessionId' + | 'startSessionWorktreeMenuLoad' >; export const RecentSessionSection: React.FC = (props) => { @@ -162,6 +163,7 @@ export const RecentSessionSection: React.FC = (props) => { setDeleteSessionConfirm={props.setDeleteSessionConfirm} startFolderRename={props.startFolderRename} setCopiedSessionId={props.setCopiedSessionId} + startSessionWorktreeMenuLoad={props.startSessionWorktreeMenuLoad} /> ); }; diff --git a/packages/ui/src/components/session/sidebar/recent/SidebarActivitySections.tsx b/packages/ui/src/components/session/sidebar/recent/SidebarActivitySections.tsx index 1cd444d0..9e44e178 100644 --- a/packages/ui/src/components/session/sidebar/recent/SidebarActivitySections.tsx +++ b/packages/ui/src/components/session/sidebar/recent/SidebarActivitySections.tsx @@ -63,6 +63,7 @@ type Props = { | 'setDeleteSessionConfirm' | 'startFolderRename' | 'setCopiedSessionId' + | 'startSessionWorktreeMenuLoad' >; type RenderExtras = SessionNodeRenderExtras; @@ -198,6 +199,7 @@ export function SidebarActivitySections(props: Props): React.ReactNode { setDeleteSessionConfirm={props.setDeleteSessionConfirm} startFolderRename={props.startFolderRename} setCopiedSessionId={props.setCopiedSessionId} + startSessionWorktreeMenuLoad={props.startSessionWorktreeMenuLoad} /> ); diff --git a/packages/ui/src/components/session/sidebar/sessionWorktreeMenu.test.ts b/packages/ui/src/components/session/sidebar/sessionWorktreeMenu.test.ts new file mode 100644 index 00000000..4b87fbea --- /dev/null +++ b/packages/ui/src/components/session/sidebar/sessionWorktreeMenu.test.ts @@ -0,0 +1,490 @@ +import { describe, expect, test } from 'bun:test'; +import type { WorktreeMetadata } from '@/types/worktree'; +import { + buildSessionWorktreeMenuTargets, + commitDiscoveredRawWorktreesByProject, + getSessionWorktreeMenuState, + markRawWorktreesByProjectMutation, + startSessionWorktreeMenuLoad, +} from './sessionWorktreeMenu'; + +const rawScope = (runtimeKey: string | null, entries: Array<[string, WorktreeMetadata[]]>) => ({ + current: { + runtimeKey, + revision: 0, + worktreesByProject: new Map(entries), + }, +}); + +const worktree = (overrides: Partial = {}): WorktreeMetadata => ({ + path: '/repo-feature', + projectDirectory: '/repo', + branch: 'feature', + label: 'feature', + name: 'feature', + worktreeStatus: 'ready', + worktreeSource: 'existing', + ...overrides, +}); + +const createDeferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +}; + +describe('buildSessionWorktreeMenuTargets', () => { + test('adds the canonical main worktree, includes the current source, dedupes by path, and sorts linked targets', () => { + const targets = buildSessionWorktreeMenuTargets({ + projectPath: '/repo-linked', + discoveredWorktrees: [ + worktree({ path: '/repo-zebra', branch: 'zebra', label: 'zebra', name: 'zebra' }), + worktree({ path: '/repo-alpha', branch: 'alpha', label: 'alpha', name: 'alpha' }), + worktree({ path: '/repo-current', branch: 'current', label: 'current', name: 'current' }), + worktree({ path: '/repo-alpha/', branch: 'alpha', label: 'alpha duplicate', name: 'alpha-duplicate' }), + ], + sourceDirectory: '/repo-current/', + currentWorktree: worktree({ + path: '/repo-current', + projectDirectory: '/repo', + branch: 'current', + label: 'Current branch', + }), + }); + + expect(targets.map((target) => ({ + path: target.metadata.path, + isPrimary: target.isPrimary, + isCurrent: target.isCurrent, + }))).toEqual([ + { path: '/repo', isPrimary: true, isCurrent: false }, + { path: '/repo-alpha', isPrimary: false, isCurrent: false }, + { path: '/repo-current', isPrimary: false, isCurrent: true }, + { path: '/repo-zebra', isPrimary: false, isCurrent: false }, + ]); + expect(targets[0]?.metadata.worktreeStatus).toBe('ready'); + expect(targets[0]?.metadata.worktreeSource).toBe('existing'); + }); + + test('prefers discovered primary metadata instead of synthetic fallback metadata', () => { + const targets = buildSessionWorktreeMenuTargets({ + projectPath: '/repo-linked', + discoveredWorktrees: [ + worktree({ + path: '/repo', + projectDirectory: '/repo', + branch: 'main', + label: 'main', + name: 'repo-primary', + headState: 'branch', + }), + ], + sourceDirectory: '/repo-linked', + currentWorktree: worktree({ + path: '/repo-linked', + projectDirectory: '/repo', + branch: 'feature', + label: 'feature', + }), + }); + + expect(targets[0]?.isPrimary).toBe(true); + expect(targets[0]?.metadata.path).toBe('/repo'); + expect(targets[0]?.metadata.branch).toBe('main'); + expect(targets[0]?.metadata.label).toBe('main'); + expect(targets[0]?.metadata.name).toBe('repo-primary'); + expect(targets[0]?.metadata.headState).toBe('branch'); + }); + + test('sorts linked targets by effective compact label when branch is missing', () => { + const targets = buildSessionWorktreeMenuTargets({ + projectPath: '/repo', + discoveredWorktrees: [ + worktree({ path: '/repo-zed', branch: '', label: '', name: 'zed' }), + worktree({ path: '/repo-alpha', branch: '', label: '', name: 'alpha' }), + worktree({ path: '/repo-beta', branch: 'beta', label: 'beta', name: 'beta' }), + ], + sourceDirectory: '/repo-current', + currentWorktree: worktree({ path: '/repo-current', projectDirectory: '/repo', branch: '', label: '', name: 'current' }), + }); + + expect(targets.map((target) => target.metadata.path)).toEqual([ + '/repo', + '/repo-alpha', + '/repo-beta', + '/repo-current', + '/repo-zed', + ]); + }); + + test('uses the owning project root branch for a synthetic primary when git omits the queried checkout', () => { + const targets = buildSessionWorktreeMenuTargets({ + projectPath: '/repo', + discoveredWorktrees: [ + worktree({ path: '/repo-feature', projectDirectory: '/repo', branch: 'feature', label: 'feature' }), + ], + sourceDirectory: '/repo-feature', + currentWorktree: worktree({ path: '/repo-feature', projectDirectory: '/repo', branch: 'feature', label: 'feature' }), + projectRootBranch: 'main', + }); + + expect(targets[0]?.isPrimary).toBe(true); + expect(targets[0]?.metadata.path).toBe('/repo'); + expect(targets[0]?.metadata.branch).toBe('main'); + expect(targets[0]?.metadata.label).toBe('main'); + expect(targets[0]?.metadata.headState).toBe('branch'); + }); +}); + +describe('commitDiscoveredRawWorktreesByProject', () => { + test('rejects an older aggregate commit after a newer targeted mutation and requests one bounded rediscovery', () => { + const rawRef = rawScope('runtime-1', [ + ['/repo', [worktree({ path: '/repo-old', projectDirectory: '/repo', branch: 'old', label: 'old' })]], + ]); + const reruns: string[] = []; + const published: Array = []; + const capturedRevision = rawRef.current.revision; + + markRawWorktreesByProjectMutation(rawRef, 'runtime-1'); + + const committed = commitDiscoveredRawWorktreesByProject({ + rawWorktreesByProjectRef: rawRef, + runtimeKey: 'runtime-1', + capturedRevision, + nextRawWorktreesByProject: new Map([ + ['/repo', [worktree({ path: '/repo-stale', projectDirectory: '/repo', branch: 'stale', label: 'stale' })]], + ]), + publishedWorktreesByProject: new Map(), + partitionWorktreesByRegisteredProject: (_projects, worktreesByProject) => new Map(worktreesByProject), + projects: [{ id: 'owner', path: '/repo' }], + worktreeMapsEqual: () => false, + recordWorktreesSeen: () => {}, + publishTopology: (next) => { + published.push(next); + }, + requestRediscovery: () => { + reruns.push('rerun'); + }, + now: () => 123, + }); + + expect(committed).toBe(false); + expect(reruns).toEqual(['rerun']); + expect(rawRef.current.worktreesByProject.get('/repo')?.map((entry) => entry.path)).toEqual(['/repo-old']); + expect(published).toEqual([]); + }); +}); + +describe('startSessionWorktreeMenuLoad', () => { + test('returns cached targets immediately, forces only the owning project refresh, and publishes refreshed topology', async () => { + const calls: Array<{ projectId: string; force: boolean }> = []; + const published: Array<{ availableWorktrees: WorktreeMetadata[]; availableWorktreesByProject: Map }> = []; + const rawRef = rawScope('runtime-1', [ + ['/repo-linked', [worktree({ path: '/repo-existing', branch: 'existing', label: 'existing', name: 'existing' })]], + ['/repo-other', [worktree({ path: '/other-worktree', projectDirectory: '/repo-other', branch: 'other', label: 'other', name: 'other' })]], + ]); + + const load = startSessionWorktreeMenuLoad( + { + projectId: 'linked', + sourceDirectory: '/repo-current', + currentWorktree: worktree({ path: '/repo-current', branch: 'current', label: 'current' }), + }, + { + projects: [ + { id: 'linked', path: '/repo-linked' }, + { id: 'other', path: '/repo-other' }, + ], + rawWorktreesByProjectRef: rawRef, + getPublishedWorktreesByProject: () => new Map(), + resolveProject: () => null, + listProjectWorktrees: async (project, options) => { + calls.push({ projectId: project.id, force: options.force }); + return [ + worktree({ path: '/repo-new', branch: 'aaa', label: 'aaa', name: 'aaa' }), + worktree({ path: '/repo-current', branch: 'current', label: 'current', name: 'current' }), + ]; + }, + partitionWorktreesByRegisteredProject: (_projects, worktreesByProject) => new Map(worktreesByProject), + worktreeMapsEqual: () => false, + recordWorktreesSeen: () => {}, + publishTopology: (next) => { + published.push(next); + }, + getRuntimeKey: () => 'runtime-1', + now: () => 123, + projectRootBranch: null, + }, + ); + + expect(load.cachedTargets.map((target) => target.metadata.path)).toEqual([ + '/repo', + '/repo-current', + '/repo-existing', + ]); + + const freshTargets = await load.refreshTargets; + + expect(calls).toEqual([{ projectId: 'linked', force: true }]); + expect(freshTargets.map((target) => target.metadata.path)).toEqual([ + '/repo', + '/repo-new', + '/repo-current', + ]); + expect(rawRef.current.worktreesByProject.get('/repo-linked')?.map((entry) => entry.path)).toEqual([ + '/repo-new', + '/repo-current', + ]); + expect(published).toHaveLength(1); + expect(published[0]?.availableWorktreesByProject.get('/repo-linked')?.map((entry) => entry.path)).toEqual([ + '/repo-new', + '/repo-current', + ]); + }); + + test('rejects refresh failures without mutating topology and keeps cached targets available for the menu', async () => { + const published: Array = []; + const existing = worktree({ path: '/repo-existing', branch: 'existing', label: 'existing', name: 'existing' }); + const rawRef = rawScope('runtime-1', [ + ['/repo-linked', [existing]], + ]); + + const load = startSessionWorktreeMenuLoad( + { + projectId: 'linked', + sourceDirectory: '/repo-current', + currentWorktree: worktree({ path: '/repo-current', branch: 'current', label: 'current' }), + }, + { + projects: [{ id: 'linked', path: '/repo-linked' }], + rawWorktreesByProjectRef: rawRef, + getPublishedWorktreesByProject: () => new Map([['/repo-linked', [existing]]]), + resolveProject: () => null, + listProjectWorktrees: async () => { + throw new Error('git failed'); + }, + partitionWorktreesByRegisteredProject: (_projects, worktreesByProject) => new Map(worktreesByProject), + worktreeMapsEqual: () => false, + recordWorktreesSeen: () => {}, + publishTopology: (next) => { + published.push(next); + }, + getRuntimeKey: () => 'runtime-1', + now: () => 123, + projectRootBranch: null, + }, + ); + + expect(load.cachedTargets.map((target) => target.metadata.path)).toEqual([ + '/repo', + '/repo-current', + '/repo-existing', + ]); + const refreshError = await load.refreshTargets.catch((error) => error); + expect(refreshError).toBeInstanceOf(Error); + expect(refreshError.message).toBe('git failed'); + expect(rawRef.current.worktreesByProject.get('/repo-linked')).toEqual([existing]); + expect(published).toEqual([]); + }); + + test('seeds an empty raw scope from published topology so a failed first refresh preserves prior topology', async () => { + const publishedTopology = new Map([ + ['/repo-linked', [worktree({ path: '/repo-existing', branch: 'existing', label: 'existing', name: 'existing' })]], + ]); + const rawRef = rawScope(null, []); + + const load = startSessionWorktreeMenuLoad( + { + projectId: 'linked', + sourceDirectory: '/repo-current', + currentWorktree: worktree({ path: '/repo-current', branch: 'current', label: 'current' }), + }, + { + projects: [{ id: 'linked', path: '/repo-linked' }], + rawWorktreesByProjectRef: rawRef, + getPublishedWorktreesByProject: () => publishedTopology, + resolveProject: () => null, + listProjectWorktrees: async () => { + throw new Error('git failed'); + }, + partitionWorktreesByRegisteredProject: (_projects, worktreesByProject) => new Map(worktreesByProject), + worktreeMapsEqual: () => true, + recordWorktreesSeen: () => {}, + publishTopology: () => { + throw new Error('should not publish on failed refresh'); + }, + getRuntimeKey: () => 'runtime-1', + now: () => 123, + projectRootBranch: null, + }, + ); + + expect(load.cachedTargets.map((target) => target.metadata.path)).toEqual([ + '/repo', + '/repo-current', + '/repo-existing', + ]); + const refreshError = await load.refreshTargets.catch((error) => error); + expect(refreshError).toBeInstanceOf(Error); + expect(refreshError.message).toBe('git failed'); + expect(rawRef.current.runtimeKey).toBe('runtime-1'); + expect(rawRef.current.worktreesByProject.get('/repo-linked')?.map((entry) => entry.path)).toEqual(['/repo-existing']); + }); + + test('applies a non-owner shared-repository refresh to the owner raw and published topology', async () => { + const published: Array<{ availableWorktreesByProject: Map }> = []; + const ownerExisting = worktree({ path: '/repo-old', branch: 'old', label: 'old', name: 'old' }); + const rawRef = rawScope('runtime-1', [ + ['/repo', [ownerExisting]], + ['/repo-linked', [worktree({ path: '/repo-other-stale', branch: 'stale', label: 'stale', name: 'stale' })]], + ]); + + const load = startSessionWorktreeMenuLoad( + { + projectId: 'linked', + sourceDirectory: '/repo-linked', + currentWorktree: worktree({ path: '/repo-linked', projectDirectory: '/repo', branch: 'feature', label: 'feature' }), + }, + { + projects: [ + { id: 'owner', path: '/repo' }, + { id: 'linked', path: '/repo-linked' }, + ], + rawWorktreesByProjectRef: rawRef, + getPublishedWorktreesByProject: () => new Map([['/repo', [ownerExisting]]]), + resolveProject: () => null, + listProjectWorktrees: async () => [ + worktree({ path: '/repo-new', projectDirectory: '/repo', branch: 'new', label: 'new', name: 'new' }), + ], + partitionWorktreesByRegisteredProject: (projects, worktreesByProject) => { + const ownerPath = projects[0]!.path; + return new Map([[ownerPath, worktreesByProject.get(ownerPath) ?? []]]); + }, + worktreeMapsEqual: () => false, + recordWorktreesSeen: () => {}, + publishTopology: (next) => { + published.push({ availableWorktreesByProject: next.availableWorktreesByProject }); + }, + getRuntimeKey: () => 'runtime-1', + now: () => 123, + projectRootBranch: null, + }, + ); + + await load.refreshTargets; + + expect(rawRef.current.worktreesByProject.get('/repo')?.map((entry) => entry.path)).toEqual(['/repo-new']); + expect(published[0]?.availableWorktreesByProject.get('/repo')?.map((entry) => entry.path)).toEqual(['/repo-new']); + }); + + test('re-seeds raw topology on runtime change and ignores stale completions', async () => { + let runtimeKey = 'runtime-2'; + const refreshDeferred = createDeferred(); + const published: Array = []; + const rawRef = rawScope('runtime-1', [ + ['/old-runtime-repo', [worktree({ path: '/old-runtime-worktree', projectDirectory: '/old-runtime-repo' })]], + ]); + const publishedCurrentRuntime = new Map([ + ['/repo-linked', [worktree({ path: '/repo-existing', branch: 'existing', label: 'existing', name: 'existing' })]], + ]); + + const load = startSessionWorktreeMenuLoad( + { + projectId: 'linked', + sourceDirectory: '/repo-current', + currentWorktree: worktree({ path: '/repo-current', branch: 'current', label: 'current' }), + }, + { + projects: [{ id: 'linked', path: '/repo-linked' }], + rawWorktreesByProjectRef: rawRef, + getPublishedWorktreesByProject: () => publishedCurrentRuntime, + resolveProject: () => null, + listProjectWorktrees: async () => refreshDeferred.promise, + partitionWorktreesByRegisteredProject: (_projects, worktreesByProject) => new Map(worktreesByProject), + worktreeMapsEqual: () => false, + recordWorktreesSeen: () => {}, + publishTopology: (next) => { + published.push(next); + }, + getRuntimeKey: () => runtimeKey, + now: () => 123, + projectRootBranch: null, + }, + ); + + expect(load.cachedTargets.map((target) => target.metadata.path)).toEqual([ + '/repo', + '/repo-current', + '/repo-existing', + ]); + expect(rawRef.current.runtimeKey).toBe('runtime-2'); + expect(rawRef.current.worktreesByProject.get('/repo-linked')?.map((entry) => entry.path)).toEqual(['/repo-existing']); + + runtimeKey = 'runtime-3'; + refreshDeferred.resolve([worktree({ path: '/repo-new', projectDirectory: '/repo', branch: 'new', label: 'new', name: 'new' })]); + + const refreshError = await load.refreshTargets.catch((error) => error); + expect(refreshError).toBeInstanceOf(Error); + expect(refreshError.message).toBe('Runtime changed during worktree refresh'); + expect(rawRef.current.runtimeKey).toBe('runtime-2'); + expect(rawRef.current.worktreesByProject.get('/repo-linked')?.map((entry) => entry.path)).toEqual(['/repo-existing']); + expect(published).toEqual([]); + }); + + test('falls back to resolving the owning configured project from the source directory when projectId is missing', async () => { + const calls: string[] = []; + const load = startSessionWorktreeMenuLoad( + { + projectId: null, + sourceDirectory: '/repo-feature', + currentWorktree: worktree({ path: '/repo-feature', projectDirectory: '/repo', branch: 'feature', label: 'feature' }), + }, + { + projects: [{ id: 'owner', path: '/repo' }], + rawWorktreesByProjectRef: rawScope('runtime-1', []), + getPublishedWorktreesByProject: () => new Map(), + resolveProject: (directory) => { + calls.push(directory); + return { id: 'owner', path: '/repo' }; + }, + listProjectWorktrees: async (project) => [ + worktree({ path: '/repo-another', projectDirectory: project.path, branch: 'another', label: 'another', name: 'another' }), + ], + partitionWorktreesByRegisteredProject: (_projects, worktreesByProject) => new Map(worktreesByProject), + worktreeMapsEqual: () => false, + recordWorktreesSeen: () => {}, + publishTopology: () => {}, + getRuntimeKey: () => 'runtime-1', + now: () => 123, + projectRootBranch: 'main', + }, + ); + + expect(calls).toEqual(['/repo-feature']); + expect(load.cachedTargets.map((target) => target.metadata.path)).toEqual(['/repo', '/repo-feature']); + const refreshTargets = await load.refreshTargets; + expect(refreshTargets.map((target) => ({ + path: target.metadata.path, + branch: target.metadata.branch, + }))).toEqual([ + { path: '/repo', branch: 'main' }, + { path: '/repo-another', branch: 'another' }, + { path: '/repo-feature', branch: 'feature' }, + ]); + }); +}); + +describe('getSessionWorktreeMenuState', () => { + test('keeps the new worktree action available when refresh fails without cached targets', () => { + expect(getSessionWorktreeMenuState({ + targets: [], + isRefreshing: false, + loadFailed: true, + })).toEqual({ + refreshState: 'error', + showNewWorktreeAction: true, + }); + }); +}); diff --git a/packages/ui/src/components/session/sidebar/sessionWorktreeMenu.ts b/packages/ui/src/components/session/sidebar/sessionWorktreeMenu.ts new file mode 100644 index 00000000..081cd376 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/sessionWorktreeMenu.ts @@ -0,0 +1,402 @@ +import type { ProjectRef } from '@/lib/worktrees/worktreeManager'; +import type { WorktreeMetadata } from '@/types/worktree'; +import { normalizePath } from '@/lib/pathNormalization'; + +export type SessionWorktreeMenuTarget = { + metadata: WorktreeMetadata; + isPrimary: boolean; + isCurrent: boolean; +}; + +export type StartSessionWorktreeMenuLoadArgs = { + projectId: string | null; + sourceDirectory: string | null; + currentWorktree: WorktreeMetadata | null; +}; + +export type StartSessionWorktreeMenuLoadResult = { + cachedTargets: SessionWorktreeMenuTarget[]; + refreshTargets: Promise; +}; + +type SessionWorktreeMenuState = { + refreshState: 'loading' | 'error' | null; + showNewWorktreeAction: boolean; +}; + +type StartSessionWorktreeMenuLoadDependencies = { + projects: ReadonlyArray; + rawWorktreesByProjectRef: { current: RawWorktreesByProjectScope }; + getPublishedWorktreesByProject: () => Map; + resolveProject: (directory: string) => ProjectRef | null; + listProjectWorktrees: (project: ProjectRef, options: { force: true }) => Promise; + partitionWorktreesByRegisteredProject: ( + projects: ReadonlyArray>, + worktreesByProject: ReadonlyMap, + ) => Map; + worktreeMapsEqual: ( + a: Map, + b: Map, + ) => boolean; + recordWorktreesSeen: (paths: Iterable, seenAt: number) => void; + publishTopology: (next: { + availableWorktrees: WorktreeMetadata[]; + availableWorktreesByProject: Map; + }) => void; + getRuntimeKey: () => string; + now: () => number; + projectRootBranch: string | null; +}; + +type RequestRediscovery = () => void; + +export type RawWorktreesByProjectScope = { + runtimeKey: string | null; + revision: number; + worktreesByProject: Map; +}; + +export const markRawWorktreesByProjectMutation = ( + rawWorktreesByProjectRef: { current: RawWorktreesByProjectScope }, + runtimeKey: string, +): number => { + if (rawWorktreesByProjectRef.current.runtimeKey !== runtimeKey) { + return rawWorktreesByProjectRef.current.revision; + } + rawWorktreesByProjectRef.current = { + ...rawWorktreesByProjectRef.current, + revision: rawWorktreesByProjectRef.current.revision + 1, + }; + return rawWorktreesByProjectRef.current.revision; +}; + +const cloneWorktreesByProject = ( + worktreesByProject: ReadonlyMap, +): Map => { + return new Map( + [...worktreesByProject.entries()].map(([projectPath, worktrees]) => [projectPath, worktrees.map((worktree) => cloneMetadata(worktree))]), + ); +}; + +export const ensureRawWorktreesByProjectScope = (args: { + rawWorktreesByProjectRef: { current: RawWorktreesByProjectScope }; + publishedWorktreesByProject: Map; + runtimeKey: string; +}): RawWorktreesByProjectScope => { + const shouldReseed = args.rawWorktreesByProjectRef.current.runtimeKey !== args.runtimeKey + || (args.rawWorktreesByProjectRef.current.worktreesByProject.size === 0 && args.publishedWorktreesByProject.size > 0); + + if (shouldReseed) { + args.rawWorktreesByProjectRef.current = { + runtimeKey: args.runtimeKey, + revision: args.rawWorktreesByProjectRef.current.runtimeKey === args.runtimeKey + ? args.rawWorktreesByProjectRef.current.revision + : 0, + worktreesByProject: cloneWorktreesByProject(args.publishedWorktreesByProject), + }; + } + + return args.rawWorktreesByProjectRef.current; +}; + +const compareLinkedTargets = (a: SessionWorktreeMenuTarget, b: SessionWorktreeMenuTarget): number => { + const aLabel = a.metadata.branch || a.metadata.name || a.metadata.label || a.metadata.path; + const bLabel = b.metadata.branch || b.metadata.name || b.metadata.label || b.metadata.path; + const labelCompare = aLabel.localeCompare(bLabel, undefined, { sensitivity: 'base' }); + if (labelCompare !== 0) { + return labelCompare; + } + + return a.metadata.path.localeCompare(b.metadata.path, undefined, { sensitivity: 'base' }); +}; + +const buildFallbackLabel = (path: string): string => { + const parts = path.split('/').filter(Boolean); + return parts[parts.length - 1] ?? path; +}; + +const cloneMetadata = (metadata: WorktreeMetadata): WorktreeMetadata => ({ + ...metadata, + path: normalizePath(metadata.path) ?? metadata.path, + projectDirectory: normalizePath(metadata.projectDirectory) ?? metadata.projectDirectory, + worktreeRoot: normalizePath(metadata.worktreeRoot ?? metadata.path) ?? metadata.worktreeRoot, +}); + +const buildSyntheticWorktreeMetadata = (args: { + path: string; + projectDirectory: string; + currentWorktree: WorktreeMetadata | null; + projectRootBranch?: string | null; +}): WorktreeMetadata => { + const { currentWorktree, path, projectDirectory, projectRootBranch } = args; + const currentPath = normalizePath(currentWorktree?.path ?? null); + const isCurrentPath = currentPath === path; + const syntheticBranch = isCurrentPath ? (currentWorktree?.branch ?? '') : (projectRootBranch ?? ''); + + const syntheticMetadata: WorktreeMetadata = { + path, + projectDirectory, + branch: syntheticBranch, + label: isCurrentPath + ? (currentWorktree?.label || currentWorktree?.branch || currentWorktree?.name || buildFallbackLabel(path)) + : (projectRootBranch || buildFallbackLabel(path)), + name: isCurrentPath ? currentWorktree?.name : undefined, + worktreeRoot: isCurrentPath + ? (normalizePath(currentWorktree?.worktreeRoot ?? path) ?? path) + : path, + worktreeStatus: isCurrentPath + ? (currentWorktree?.worktreeStatus ?? 'ready') + : 'ready', + worktreeSource: isCurrentPath + ? (currentWorktree?.worktreeSource ?? 'existing') + : 'existing', + headState: isCurrentPath ? currentWorktree?.headState : (projectRootBranch ? 'branch' : undefined), + }; + + return isCurrentPath && currentWorktree + ? { ...currentWorktree, ...syntheticMetadata } + : syntheticMetadata; +}; + +export const buildSessionWorktreeMenuTargets = (args: { + projectPath: string | null; + discoveredWorktrees: ReadonlyArray; + sourceDirectory: string | null; + currentWorktree: WorktreeMetadata | null; + projectRootBranch?: string | null; +}): SessionWorktreeMenuTarget[] => { + const normalizedProjectPath = normalizePath(args.projectPath ?? null); + const normalizedSourceDirectory = normalizePath(args.sourceDirectory ?? null) + ?? normalizePath(args.currentWorktree?.path ?? null); + const discoveredPrimaryPath = normalizePath( + args.discoveredWorktrees.find((worktree) => normalizePath(worktree.projectDirectory ?? null))?.projectDirectory ?? null, + ); + const currentPrimaryPath = normalizePath(args.currentWorktree?.projectDirectory ?? null); + const primaryPath = discoveredPrimaryPath ?? currentPrimaryPath ?? normalizedProjectPath; + + const targetsByPath = new Map(); + const pushTarget = (target: SessionWorktreeMenuTarget): void => { + const normalizedPath = normalizePath(target.metadata.path ?? null); + if (!normalizedPath || targetsByPath.has(normalizedPath)) { + return; + } + targetsByPath.set(normalizedPath, { + ...target, + metadata: cloneMetadata({ + ...target.metadata, + path: normalizedPath, + }), + }); + }; + + for (const worktree of args.discoveredWorktrees) { + const normalizedPath = normalizePath(worktree.path ?? null); + if (!normalizedPath) { + continue; + } + pushTarget({ + metadata: cloneMetadata({ + ...worktree, + path: normalizedPath, + projectDirectory: normalizePath(worktree.projectDirectory ?? null) ?? primaryPath ?? normalizedProjectPath ?? normalizedPath, + }), + isPrimary: primaryPath === normalizedPath, + isCurrent: normalizedSourceDirectory === normalizedPath, + }); + } + + if (primaryPath && !targetsByPath.has(primaryPath)) { + pushTarget({ + metadata: buildSyntheticWorktreeMetadata({ + path: primaryPath, + projectDirectory: primaryPath, + currentWorktree: args.currentWorktree, + projectRootBranch: args.projectRootBranch, + }), + isPrimary: true, + isCurrent: normalizedSourceDirectory === primaryPath, + }); + } + + if (normalizedSourceDirectory && !targetsByPath.has(normalizedSourceDirectory)) { + pushTarget({ + metadata: buildSyntheticWorktreeMetadata({ + path: normalizedSourceDirectory, + projectDirectory: primaryPath ?? normalizedProjectPath ?? normalizedSourceDirectory, + currentWorktree: args.currentWorktree, + projectRootBranch: args.projectRootBranch, + }), + isPrimary: primaryPath === normalizedSourceDirectory, + isCurrent: true, + }); + } + + const primaryTargets: SessionWorktreeMenuTarget[] = []; + const linkedTargets: SessionWorktreeMenuTarget[] = []; + for (const target of targetsByPath.values()) { + if (target.isPrimary) { + primaryTargets.push(target); + continue; + } + linkedTargets.push(target); + } + + primaryTargets.sort((a, b) => a.metadata.path.localeCompare(b.metadata.path, undefined, { sensitivity: 'base' })); + linkedTargets.sort(compareLinkedTargets); + return [...primaryTargets, ...linkedTargets]; +}; + +export const startSessionWorktreeMenuLoad = ( + args: StartSessionWorktreeMenuLoadArgs, + deps: StartSessionWorktreeMenuLoadDependencies, +): StartSessionWorktreeMenuLoadResult => { + const runtimeKey = deps.getRuntimeKey(); + const publishedWorktreesByProject = deps.getPublishedWorktreesByProject(); + const rawScope = ensureRawWorktreesByProjectScope({ + rawWorktreesByProjectRef: deps.rawWorktreesByProjectRef, + publishedWorktreesByProject, + runtimeKey, + }); + const projectById = args.projectId + ? deps.projects.find((candidate) => candidate.id === args.projectId) ?? null + : null; + const project = projectById ?? (args.sourceDirectory ? deps.resolveProject(args.sourceDirectory) : null); + const normalizedProjectPath = normalizePath(project?.path ?? null); + const cachedTargets = buildSessionWorktreeMenuTargets({ + projectPath: normalizedProjectPath, + discoveredWorktrees: normalizedProjectPath + ? (rawScope.worktreesByProject.get(normalizedProjectPath) ?? []) + : [], + sourceDirectory: args.sourceDirectory, + currentWorktree: args.currentWorktree, + projectRootBranch: deps.projectRootBranch, + }); + + return { + cachedTargets, + refreshTargets: (async () => { + if (!project || !normalizedProjectPath) { + throw new Error('Unable to resolve worktree project'); + } + + const refreshedWorktrees = await deps.listProjectWorktrees(project, { force: true }); + + if (deps.getRuntimeKey() !== runtimeKey) { + throw new Error('Runtime changed during worktree refresh'); + } + + const currentRawScope = ensureRawWorktreesByProjectScope({ + rawWorktreesByProjectRef: deps.rawWorktreesByProjectRef, + publishedWorktreesByProject: deps.getPublishedWorktreesByProject(), + runtimeKey, + }); + const nextRawTopology = cloneWorktreesByProject(currentRawScope.worktreesByProject); + const nextProjectWorktrees = [...refreshedWorktrees] + .map((worktree) => cloneMetadata(worktree)) + .sort((a, b) => compareLinkedTargets( + { metadata: a, isPrimary: false, isCurrent: false }, + { metadata: b, isPrimary: false, isCurrent: false }, + )); + + const refreshedRepositoryRoot = normalizePath( + nextProjectWorktrees.find((worktree) => normalizePath(worktree.projectDirectory ?? null))?.projectDirectory + ?? args.currentWorktree?.projectDirectory + ?? project.path, + ); + const matchingProjectPaths = new Set([normalizedProjectPath]); + for (const [projectPath, worktrees] of nextRawTopology.entries()) { + const repositoryRoot = normalizePath( + worktrees.find((worktree) => normalizePath(worktree.projectDirectory ?? null))?.projectDirectory ?? projectPath, + ); + if (repositoryRoot && repositoryRoot === refreshedRepositoryRoot) { + matchingProjectPaths.add(projectPath); + } + } + for (const projectPath of matchingProjectPaths) { + if (nextProjectWorktrees.length === 0) { + nextRawTopology.delete(projectPath); + continue; + } + nextRawTopology.set(projectPath, nextProjectWorktrees.map((worktree) => cloneMetadata(worktree))); + } + + markRawWorktreesByProjectMutation(deps.rawWorktreesByProjectRef, runtimeKey); + deps.rawWorktreesByProjectRef.current = { + runtimeKey, + revision: deps.rawWorktreesByProjectRef.current.revision, + worktreesByProject: nextRawTopology, + }; + + const partitionedWorktreesByProject = deps.partitionWorktreesByRegisteredProject(deps.projects, nextRawTopology); + const allWorktrees = [...partitionedWorktreesByProject.values()].flat(); + deps.recordWorktreesSeen(allWorktrees.map((worktree) => worktree.path), deps.now()); + + const latestPublishedWorktreesByProject = deps.getPublishedWorktreesByProject(); + if (!deps.worktreeMapsEqual(partitionedWorktreesByProject, latestPublishedWorktreesByProject)) { + deps.publishTopology({ + availableWorktrees: allWorktrees, + availableWorktreesByProject: partitionedWorktreesByProject, + }); + } + + return buildSessionWorktreeMenuTargets({ + projectPath: normalizedProjectPath, + discoveredWorktrees: nextProjectWorktrees, + sourceDirectory: args.sourceDirectory, + currentWorktree: args.currentWorktree, + projectRootBranch: deps.projectRootBranch, + }); + })(), + }; +}; + +export const commitDiscoveredRawWorktreesByProject = (args: { + rawWorktreesByProjectRef: { current: RawWorktreesByProjectScope }; + runtimeKey: string; + capturedRevision: number; + nextRawWorktreesByProject: Map; + publishedWorktreesByProject: Map; + partitionWorktreesByRegisteredProject: StartSessionWorktreeMenuLoadDependencies['partitionWorktreesByRegisteredProject']; + projects: ReadonlyArray>; + worktreeMapsEqual: StartSessionWorktreeMenuLoadDependencies['worktreeMapsEqual']; + recordWorktreesSeen: StartSessionWorktreeMenuLoadDependencies['recordWorktreesSeen']; + publishTopology: StartSessionWorktreeMenuLoadDependencies['publishTopology']; + requestRediscovery: RequestRediscovery; + now: () => number; +}): boolean => { + if (args.rawWorktreesByProjectRef.current.runtimeKey !== args.runtimeKey) { + return false; + } + if (args.rawWorktreesByProjectRef.current.revision !== args.capturedRevision) { + args.requestRediscovery(); + return false; + } + const partitionedWorktreesByProject = args.partitionWorktreesByRegisteredProject(args.projects, args.nextRawWorktreesByProject); + const allWorktrees = [...partitionedWorktreesByProject.values()].flat(); + args.recordWorktreesSeen(allWorktrees.map((worktree) => worktree.path), args.now()); + args.rawWorktreesByProjectRef.current = { + runtimeKey: args.runtimeKey, + revision: args.capturedRevision, + worktreesByProject: new Map(args.nextRawWorktreesByProject), + }; + if (!args.worktreeMapsEqual(partitionedWorktreesByProject, args.publishedWorktreesByProject)) { + args.publishTopology({ + availableWorktrees: allWorktrees, + availableWorktreesByProject: partitionedWorktreesByProject, + }); + } + return true; +}; + +export const getSessionWorktreeMenuState = (args: { + targets: ReadonlyArray; + isRefreshing: boolean; + loadFailed: boolean; +}): SessionWorktreeMenuState => { + return { + refreshState: args.isRefreshing + ? 'loading' + : (args.loadFailed && args.targets.length === 0 ? 'error' : null), + showNewWorktreeAction: true, + }; +}; diff --git a/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx index 3c9d40c5..9272f8b4 100644 --- a/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx @@ -47,11 +47,21 @@ import { parseMultiRunSessionTitle } from '@/lib/multirun/title'; import { MultiRunFusionDialog } from '@/components/multirun/MultiRunFusionDialog'; import { FusionIcon } from '@/components/icons/FusionIcon'; import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; -import { startSessionTreeWorktreeMove, useIsSessionWorktreeMovePending } from '@/lib/worktrees/sessionWorktreeMove'; +import { + startSessionTreeExistingWorktreeMove, + startSessionTreeWorktreeMove, + useIsSessionWorktreeMovePending, +} from '@/lib/worktrees/sessionWorktreeMove'; import { streamPerfCount } from '@/stores/utils/streamDebug'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore'; import { useUIStore } from '@/stores/useUIStore'; +import type { WorktreeMetadata } from '@/types/worktree'; +import { + getSessionWorktreeMenuState, + type SessionWorktreeMenuTarget, + type StartSessionWorktreeMenuLoadResult, +} from '../sessionWorktreeMenu'; type SecondaryMeta = { projectLabel?: string | null; @@ -88,6 +98,11 @@ export type SessionNodeItemProps = { createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null; handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean; hardDelete?: boolean; skipConfirm?: boolean }) => void; handleRestoreSession: (session: Session) => void; + startSessionWorktreeMenuLoad: (args: { + projectId: string | null; + sourceDirectory: string | null; + currentWorktree: WorktreeMetadata | null; + }) => StartSessionWorktreeMenuLoadResult; mobileVariant: boolean; alwaysShowActions: boolean; secondaryMeta?: SecondaryMeta | null; @@ -271,6 +286,7 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode createFolderAndStartRename, handleDeleteSession, handleRestoreSession, + startSessionWorktreeMenuLoad, mobileVariant, alwaysShowActions, secondaryMeta, @@ -430,6 +446,12 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode // tick of the counter it only decides to mount. const hasActivityDuration = useHasSessionActivityDuration(session.id, isStreaming); const isMovingToWorktree = useIsSessionWorktreeMovePending(session.id); + const currentWorktreeMetadata = node.worktree ?? useSessionUIStore.getState().getWorktreeMetadata(session.id) ?? null; + const [worktreeTargets, setWorktreeTargets] = React.useState([]); + const [worktreeTargetsLoading, setWorktreeTargetsLoading] = React.useState(false); + const [worktreeTargetsLoadFailed, setWorktreeTargetsLoadFailed] = React.useState(false); + const worktreeSubmenuOpenRef = React.useRef(false); + const worktreeLoadSequenceRef = React.useRef(0); const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined, { bootstrap: false }); const sessionGoal = getSessionGoal(resolvedSession); const sessionGoalGlyph = sessionGoal ? ( @@ -879,6 +901,41 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode } }; + const handleWorktreeSubmenuOpenChange = React.useCallback((open: boolean) => { + worktreeSubmenuOpenRef.current = open; + worktreeLoadSequenceRef.current += 1; + const loadSequence = worktreeLoadSequenceRef.current; + if (!open) { + setWorktreeTargetsLoading(false); + setWorktreeTargetsLoadFailed(false); + return; + } + const load = startSessionWorktreeMenuLoad({ + projectId: projectId ?? null, + sourceDirectory: sessionDirectory, + currentWorktree: currentWorktreeMetadata, + }); + setWorktreeTargets(load.cachedTargets); + setWorktreeTargetsLoading(true); + setWorktreeTargetsLoadFailed(false); + void load.refreshTargets + .then((freshTargets) => { + if (!worktreeSubmenuOpenRef.current || worktreeLoadSequenceRef.current !== loadSequence) { + return; + } + setWorktreeTargets(freshTargets); + setWorktreeTargetsLoading(false); + setWorktreeTargetsLoadFailed(false); + }) + .catch(() => { + if (!worktreeSubmenuOpenRef.current || worktreeLoadSequenceRef.current !== loadSequence) { + return; + } + setWorktreeTargetsLoading(false); + setWorktreeTargetsLoadFailed(true); + }); + }, [currentWorktreeMetadata, projectId, sessionDirectory, startSessionWorktreeMenuLoad]); + const renderSessionMenuItems = ({ Item, Separator, @@ -935,38 +992,105 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode {t('sessions.sidebar.session.menu.exportMarkdown')} - {!isSubtaskSession && !archivedBucket && !isVSCode && !isChatDirectoryPath(sessionDirectory) ? ( - - - - { - if (!sessionDirectory || isStreaming || isMovingToWorktree) return; - startSessionTreeWorktreeMove({ - root: resolvedSession, - descendants: collectNodeDescendantSessions(node), - sourceDirectory: sessionDirectory, - successMessage: t('sessions.sidebar.session.moveToWorktree.success'), - failureMessage: t('sessions.sidebar.session.moveToWorktree.failed'), - }); - }} - className="w-full [&>svg]:mr-1" - > - - {t('sessions.sidebar.session.menu.moveToWorktree')} - - - - - {isMovingToWorktree - ? t('sessions.sidebar.session.moveToWorktree.tooltipMoving') - : isStreaming - ? t('sessions.sidebar.session.moveToWorktree.tooltipBusy') - : t('sessions.sidebar.session.moveToWorktree.tooltip')} - - - ) : null} +{!isSubtaskSession && !archivedBucket && !isVSCode ? (() => { + const worktreeMenuState = getSessionWorktreeMenuState({ + targets: worktreeTargets, + isRefreshing: worktreeTargetsLoading, + loadFailed: worktreeTargetsLoadFailed, + }); + return ( + + + + + + + {t('sessions.sidebar.session.menu.moveToWorktreeTargets')} + + + {worktreeTargets.map((target) => { + const targetPath = normalizePath(target.metadata.path ?? null) ?? target.metadata.path; + const itemLabel = target.isPrimary + ? t('sessions.sidebar.session.moveToWorktree.main') + : (target.metadata.label || target.metadata.branch || target.metadata.name || target.metadata.path); + const isDisabled = target.isCurrent || target.metadata.worktreeStatus !== 'ready'; + + return ( + { + if (isDisabled || !sessionDirectory) { + return; + } + startSessionTreeExistingWorktreeMove({ + root: resolvedSession, + descendants: collectNodeDescendantSessions(node), + sourceDirectory: sessionDirectory, + destination: target.metadata, + successMessage: t('sessions.sidebar.session.moveToWorktree.existingSuccess'), + failureMessage: t('sessions.sidebar.session.moveToWorktree.existingFailed'), + }); + }} + > + + {itemLabel} + {target.isCurrent ? {t('sessions.sidebar.session.moveToWorktree.current')} : null} + + {target.isCurrent ? + ); + })} + {worktreeMenuState.refreshState === 'loading' ? ( + + {t('sessions.sidebar.session.moveToWorktree.refreshing')} + + ) : null} + {worktreeMenuState.refreshState === 'error' ? ( + + {t('sessions.sidebar.session.moveToWorktree.loadFailed')} + + ) : null} + + {worktreeMenuState.showNewWorktreeAction ? ( + { + if (!sessionDirectory || isStreaming || isMovingToWorktree) return; + startSessionTreeWorktreeMove({ + root: resolvedSession, + descendants: collectNodeDescendantSessions(node), + sourceDirectory: sessionDirectory, + successMessage: t('sessions.sidebar.session.moveToWorktree.success'), + failureMessage: t('sessions.sidebar.session.moveToWorktree.failed'), + }); + }} + className="[&>svg]:mr-1" + > + + {t('sessions.sidebar.session.menu.newWorktree')} + + ) : null} + + + + + + {isMovingToWorktree + ? t('sessions.sidebar.session.moveToWorktree.tooltipMoving') + : isStreaming + ? t('sessions.sidebar.session.moveToWorktree.tooltipBusy') + : t('sessions.sidebar.session.moveToWorktree.tooltipTargets')} + + + ); + })() : null} {isMultiRunLikeSession ? ( setFusionDialogOpen(true)} className="[&>svg]:mr-1"> @@ -1628,6 +1752,7 @@ const areSessionNodeItemPropsEqual = (prev: SessionNodeItemProps, next: SessionN && prev.createFolderAndStartRename === next.createFolderAndStartRename && prev.handleDeleteSession === next.handleDeleteSession && prev.handleRestoreSession === next.handleRestoreSession + && prev.startSessionWorktreeMenuLoad === next.startSessionWorktreeMenuLoad && prev.children === next.children; }; diff --git a/packages/ui/src/components/session/sidebar/sessions/SessionTreeItem.behavior.test.tsx b/packages/ui/src/components/session/sidebar/sessions/SessionTreeItem.behavior.test.tsx index e524c50e..87f3a4c8 100644 --- a/packages/ui/src/components/session/sidebar/sessions/SessionTreeItem.behavior.test.tsx +++ b/packages/ui/src/components/session/sidebar/sessions/SessionTreeItem.behavior.test.tsx @@ -3,6 +3,7 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import type { Session } from '@opencode-ai/sdk/v2'; import type { SessionNodeItemProps } from './SessionNodeItem'; +import type { SessionTreeItemProps } from './SessionTreeItem'; import { installHookTestDom } from '../test-utils/testDom'; import { I18nProvider } from '@/lib/i18n'; @@ -39,6 +40,11 @@ mock.module('./hooks/useSessionActions', () => ({ const { SessionTreeItem } = await import('./SessionTreeItem'); +const noopStartSessionWorktreeMenuLoad: SessionTreeItemProps['startSessionWorktreeMenuLoad'] = () => ({ + cachedTargets: [], + refreshTargets: Promise.resolve([]), +}); + const session = (id: string): Session => ({ id, slug: id, @@ -91,6 +97,7 @@ describe('SessionTreeItem public behavior', () => { setDeleteSessionConfirm={noop} startFolderRename={noop} setCopiedSessionId={setCopiedSessionId} + startSessionWorktreeMenuLoad={noopStartSessionWorktreeMenuLoad} mobileVariant={false} alwaysShowActions={false} {...context} diff --git a/packages/ui/src/components/session/sidebar/sessions/SessionTreeItem.tsx b/packages/ui/src/components/session/sidebar/sessions/SessionTreeItem.tsx index d3519e26..9bd265f0 100644 --- a/packages/ui/src/components/session/sidebar/sessions/SessionTreeItem.tsx +++ b/packages/ui/src/components/session/sidebar/sessions/SessionTreeItem.tsx @@ -39,6 +39,7 @@ export type SessionTreeItemProps = SessionTreeItemRenderProps & Pick & { allowReselect: boolean; onSessionSelected?: (sessionId: string) => void; @@ -88,6 +89,7 @@ export function SessionTreeItem({ startFolderRename, copiedSessionId, setCopiedSessionId, + startSessionWorktreeMenuLoad, mobileVariant, alwaysShowActions, }: SessionTreeItemProps): React.ReactNode { @@ -160,11 +162,12 @@ export function SessionTreeItem({ openSidebarMenuKey={openSidebarMenuKey} setOpenSidebarMenuKey={setOpenSidebarMenuKey} createFolderAndStartRename={createFolderAndStartRename} - handleDeleteSession={sessionActions.handleDeleteSession} - handleRestoreSession={sessionActions.handleRestoreSession} - mobileVariant={mobileVariant} - alwaysShowActions={alwaysShowActions} - pinnedSessionIds={pinnedSessionIds} + handleDeleteSession={sessionActions.handleDeleteSession} + handleRestoreSession={sessionActions.handleRestoreSession} + startSessionWorktreeMenuLoad={startSessionWorktreeMenuLoad} + mobileVariant={mobileVariant} + alwaysShowActions={alwaysShowActions} + pinnedSessionIds={pinnedSessionIds} node={node} depth={depth} groupDirectory={groupDirectory} @@ -201,11 +204,12 @@ export function SessionTreeItem({ setIsSessionSearchOpen={setIsSessionSearchOpen} deleteSessionConfirm={deleteSessionConfirm} setDeleteSessionConfirm={setDeleteSessionConfirm} - startFolderRename={startFolderRename} - setCopiedSessionId={setCopiedSessionId} - mobileVariant={mobileVariant} - alwaysShowActions={alwaysShowActions} - depth={depth + 1} + startFolderRename={startFolderRename} + setCopiedSessionId={setCopiedSessionId} + startSessionWorktreeMenuLoad={startSessionWorktreeMenuLoad} + mobileVariant={mobileVariant} + alwaysShowActions={alwaysShowActions} + depth={depth + 1} {...childContext} renderExtras={childRenderExtrasFor?.(child)} /> diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 62c0cfaa..caab5042 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -2988,8 +2988,17 @@ export const dict = { 'sessions.sidebar.session.copyId.success': 'Sitzungs-ID kopiert', 'sessions.sidebar.session.copyId.error': 'Sitzungs-ID konnte nicht kopiert werden', 'sessions.sidebar.session.menu.moveToWorktree': 'In neuen Worktree verschieben', + 'sessions.sidebar.session.menu.moveToWorktreeTargets': 'In Worktree verschieben', + 'sessions.sidebar.session.menu.newWorktree': 'Neuer Worktree...', 'sessions.sidebar.session.moveToWorktree.success': 'Sitzung in einen neuen Worktree verschoben', 'sessions.sidebar.session.moveToWorktree.failed': 'Sitzung konnte nicht in einen neuen Worktree verschoben werden', + 'sessions.sidebar.session.moveToWorktree.main': 'Haupt-Worktree', + 'sessions.sidebar.session.moveToWorktree.refreshing': 'Worktrees werden aktualisiert...', + 'sessions.sidebar.session.moveToWorktree.loadFailed': 'Worktrees konnten nicht geladen werden', + 'sessions.sidebar.session.moveToWorktree.current': 'Aktueller Worktree', + 'sessions.sidebar.session.moveToWorktree.existingSuccess': 'Sitzung in Worktree verschoben', + 'sessions.sidebar.session.moveToWorktree.existingFailed': 'Sitzung konnte nicht in Worktree verschoben werden', + 'sessions.sidebar.session.moveToWorktree.tooltipTargets': 'Zeigt vorhandene Worktrees und die Option, für diese Sitzung einen neuen zu erstellen.', 'sessions.sidebar.session.moveToWorktree.tooltip': 'Erstellt einen neuen Worktree aus dem aktuellen Branch, überträgt nicht gespeicherte Änderungen und verschiebt diese Sitzung samt Untersitzungen dorthin.', 'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'Verfügbar, wenn die Sitzung inaktiv ist. Warten Sie oder beenden Sie die aktuelle Aktivität.', 'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'Diese Sitzung wird bereits in einen neuen Worktree verschoben.', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index e8e697aa..2d820570 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -536,8 +536,17 @@ export const dict = { 'sessions.sidebar.session.menu.unshare': 'Unshare', 'sessions.sidebar.session.menu.exportMarkdown': 'Export Markdown', 'sessions.sidebar.session.menu.moveToWorktree': 'Move to new worktree', + 'sessions.sidebar.session.menu.moveToWorktreeTargets': 'Move to worktree', + 'sessions.sidebar.session.menu.newWorktree': 'New worktree...', 'sessions.sidebar.session.moveToWorktree.success': 'Session moved to a new worktree', 'sessions.sidebar.session.moveToWorktree.failed': 'Failed to move session to a new worktree', + 'sessions.sidebar.session.moveToWorktree.main': 'Main worktree', + 'sessions.sidebar.session.moveToWorktree.refreshing': 'Refreshing worktrees...', + 'sessions.sidebar.session.moveToWorktree.loadFailed': 'Worktrees could not be loaded', + 'sessions.sidebar.session.moveToWorktree.current': 'Current worktree', + 'sessions.sidebar.session.moveToWorktree.existingSuccess': 'Session moved to worktree', + 'sessions.sidebar.session.moveToWorktree.existingFailed': 'Failed to move session to worktree', + 'sessions.sidebar.session.moveToWorktree.tooltipTargets': 'Shows existing worktrees and the option to create a new one for this session.', 'sessions.sidebar.session.moveToWorktree.tooltip': 'Creates a new worktree from the current branch, transfers uncommitted changes, and moves this session and its sub-sessions there.', 'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'Available when the session is idle. Stop or wait for the current activity to finish.', 'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'This session is already being moved to a new worktree.', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index ebc2a2ad..8b8b656b 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -537,8 +537,17 @@ export const dict: Record = { "sessions.sidebar.session.menu.unshare": "Dejar de compartir", "sessions.sidebar.session.menu.exportMarkdown": "Exportar Markdown", "sessions.sidebar.session.menu.moveToWorktree": "Mover a un worktree nuevo", + "sessions.sidebar.session.menu.moveToWorktreeTargets": "Mover a worktree", + "sessions.sidebar.session.menu.newWorktree": "Nuevo worktree...", "sessions.sidebar.session.moveToWorktree.success": "Sesión movida a un worktree nuevo", "sessions.sidebar.session.moveToWorktree.failed": "No se pudo mover la sesión a un worktree nuevo", + "sessions.sidebar.session.moveToWorktree.main": "Worktree principal", + "sessions.sidebar.session.moveToWorktree.refreshing": "Actualizando worktrees...", + "sessions.sidebar.session.moveToWorktree.loadFailed": "No se pudieron cargar los worktrees", + "sessions.sidebar.session.moveToWorktree.current": "Worktree actual", + "sessions.sidebar.session.moveToWorktree.existingSuccess": "Sesión movida al worktree", + "sessions.sidebar.session.moveToWorktree.existingFailed": "No se pudo mover la sesión al worktree", + "sessions.sidebar.session.moveToWorktree.tooltipTargets": "Muestra los worktrees existentes y la opción de crear uno nuevo para esta sesión.", "sessions.sidebar.session.moveToWorktree.tooltip": "Crea un worktree nuevo desde la rama actual, transfiere los cambios sin confirmar y mueve allí esta sesión y sus subsesiones.", "sessions.sidebar.session.moveToWorktree.tooltipBusy": "Disponible cuando la sesión está inactiva. Detén la actividad actual o espera a que termine.", "sessions.sidebar.session.moveToWorktree.tooltipMoving": "Esta sesión ya se está moviendo a un worktree nuevo.", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 46d97256..76e05ad3 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -367,8 +367,17 @@ export const dict = { 'sessions.sidebar.session.menu.unshare': 'Annuler le partage', 'sessions.sidebar.session.menu.exportMarkdown': 'Exporter le Markdown', 'sessions.sidebar.session.menu.moveToWorktree': 'Déplacer vers un nouveau worktree', + 'sessions.sidebar.session.menu.moveToWorktreeTargets': 'Déplacer vers un worktree', + 'sessions.sidebar.session.menu.newWorktree': 'Nouveau worktree...', 'sessions.sidebar.session.moveToWorktree.success': 'Session déplacée vers un nouveau worktree', 'sessions.sidebar.session.moveToWorktree.failed': 'Impossible de déplacer la session vers un nouveau worktree', + 'sessions.sidebar.session.moveToWorktree.main': 'Worktree principal', + 'sessions.sidebar.session.moveToWorktree.refreshing': 'Actualisation des worktrees...', + 'sessions.sidebar.session.moveToWorktree.loadFailed': 'Impossible de charger les worktrees', + 'sessions.sidebar.session.moveToWorktree.current': 'Worktree actuel', + 'sessions.sidebar.session.moveToWorktree.existingSuccess': 'Session déplacée vers le worktree', + 'sessions.sidebar.session.moveToWorktree.existingFailed': 'Impossible de déplacer la session vers le worktree', + 'sessions.sidebar.session.moveToWorktree.tooltipTargets': 'Affiche les worktrees existants et l’option d’en créer un nouveau pour cette session.', 'sessions.sidebar.session.moveToWorktree.tooltip': 'Crée un nouveau worktree depuis la branche actuelle, transfère les modifications non validées et y déplace cette session et ses sous-sessions.', 'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'Disponible lorsque la session est inactive. Arrêtez l’activité en cours ou attendez sa fin.', 'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'Cette session est déjà en cours de déplacement vers un nouveau worktree.', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 845bc896..cadc381f 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -537,8 +537,17 @@ export const dict: Record = { 'sessions.sidebar.session.menu.unshare': '共有解除', 'sessions.sidebar.session.menu.exportMarkdown': 'Markdownでエクスポート', 'sessions.sidebar.session.menu.moveToWorktree': '新しいworktreeへ移動', + 'sessions.sidebar.session.menu.moveToWorktreeTargets': 'worktreeへ移動', + 'sessions.sidebar.session.menu.newWorktree': '新しいworktree...', 'sessions.sidebar.session.moveToWorktree.success': 'セッションを新しいworktreeへ移動しました', 'sessions.sidebar.session.moveToWorktree.failed': 'セッションを新しいworktreeへ移動できませんでした', + 'sessions.sidebar.session.moveToWorktree.main': 'メインworktree', + 'sessions.sidebar.session.moveToWorktree.refreshing': 'worktreeを更新しています...', + 'sessions.sidebar.session.moveToWorktree.loadFailed': 'worktreeを読み込めませんでした', + 'sessions.sidebar.session.moveToWorktree.current': '現在のworktree', + 'sessions.sidebar.session.moveToWorktree.existingSuccess': 'セッションをworktreeへ移動しました', + 'sessions.sidebar.session.moveToWorktree.existingFailed': 'セッションをworktreeへ移動できませんでした', + 'sessions.sidebar.session.moveToWorktree.tooltipTargets': '既存のworktreeと、このセッション用に新しいworktreeを作成するオプションを表示します。', 'sessions.sidebar.session.moveToWorktree.tooltip': '現在のブランチから新しいworktreeを作成し、未コミットの変更とこのセッションおよびサブセッションを移動します。', 'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'セッションがアイドル状態のときに利用できます。現在の処理を停止するか、完了するまでお待ちください。', 'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'このセッションはすでに新しいworktreeへ移動中です。', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 18ebfa49..d8389704 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -537,8 +537,17 @@ export const dict: Record = { 'sessions.sidebar.session.menu.unshare': '공유 해제', 'sessions.sidebar.session.menu.exportMarkdown': 'Markdown 내보내기', 'sessions.sidebar.session.menu.moveToWorktree': '새 worktree로 이동', + 'sessions.sidebar.session.menu.moveToWorktreeTargets': 'worktree로 이동', + 'sessions.sidebar.session.menu.newWorktree': '새 worktree...', 'sessions.sidebar.session.moveToWorktree.success': '세션을 새 worktree로 이동했습니다', 'sessions.sidebar.session.moveToWorktree.failed': '세션을 새 worktree로 이동하지 못했습니다', + 'sessions.sidebar.session.moveToWorktree.main': '메인 worktree', + 'sessions.sidebar.session.moveToWorktree.refreshing': 'worktree 새로 고침 중...', + 'sessions.sidebar.session.moveToWorktree.loadFailed': 'worktree를 불러오지 못했습니다', + 'sessions.sidebar.session.moveToWorktree.current': '현재 worktree', + 'sessions.sidebar.session.moveToWorktree.existingSuccess': '세션을 worktree로 이동했습니다', + 'sessions.sidebar.session.moveToWorktree.existingFailed': '세션을 worktree로 이동하지 못했습니다', + 'sessions.sidebar.session.moveToWorktree.tooltipTargets': '기존 worktree와 이 세션용 새 worktree를 만드는 옵션을 표시합니다.', 'sessions.sidebar.session.moveToWorktree.tooltip': '현재 브랜치에서 새 worktree를 만들고 커밋되지 않은 변경 사항과 이 세션 및 하위 세션을 이동합니다.', 'sessions.sidebar.session.moveToWorktree.tooltipBusy': '세션이 유휴 상태일 때 사용할 수 있습니다. 현재 작업을 중지하거나 완료될 때까지 기다리세요.', 'sessions.sidebar.session.moveToWorktree.tooltipMoving': '이 세션은 이미 새 worktree로 이동 중입니다.', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 9938ac81..c95038bd 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -330,8 +330,17 @@ export const dict: Record = { 'sessions.sidebar.session.menu.unshare': 'Cofnij udostępnienie', 'sessions.sidebar.session.menu.exportMarkdown': 'Eksportuj Markdown', 'sessions.sidebar.session.menu.moveToWorktree': 'Przenieś do nowego worktree', + 'sessions.sidebar.session.menu.moveToWorktreeTargets': 'Przenieś do worktree', + 'sessions.sidebar.session.menu.newWorktree': 'Nowy worktree...', 'sessions.sidebar.session.moveToWorktree.success': 'Sesja została przeniesiona do nowego worktree', 'sessions.sidebar.session.moveToWorktree.failed': 'Nie udało się przenieść sesji do nowego worktree', + 'sessions.sidebar.session.moveToWorktree.main': 'Główny worktree', + 'sessions.sidebar.session.moveToWorktree.refreshing': 'Odświeżanie worktree...', + 'sessions.sidebar.session.moveToWorktree.loadFailed': 'Nie udało się wczytać worktree', + 'sessions.sidebar.session.moveToWorktree.current': 'Bieżący worktree', + 'sessions.sidebar.session.moveToWorktree.existingSuccess': 'Sesję przeniesiono do worktree', + 'sessions.sidebar.session.moveToWorktree.existingFailed': 'Nie udało się przenieść sesji do worktree', + 'sessions.sidebar.session.moveToWorktree.tooltipTargets': 'Pokazuje istniejące worktree i opcję utworzenia nowego dla tej sesji.', 'sessions.sidebar.session.moveToWorktree.tooltip': 'Tworzy nowy worktree z bieżącej gałęzi, przenosi niezacommitowane zmiany oraz tę sesję i jej podsesje.', 'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'Dostępne, gdy sesja jest bezczynna. Zatrzymaj bieżącą aktywność lub poczekaj na jej zakończenie.', 'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'Ta sesja jest już przenoszona do nowego worktree.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 73c923ec..8ab01425 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -537,8 +537,17 @@ export const dict: Record = { "sessions.sidebar.session.menu.unshare": "Parar de compartilhar", "sessions.sidebar.session.menu.exportMarkdown": "Exportar Markdown", "sessions.sidebar.session.menu.moveToWorktree": "Mover para um novo worktree", + "sessions.sidebar.session.menu.moveToWorktreeTargets": "Mover para worktree", + "sessions.sidebar.session.menu.newWorktree": "Novo worktree...", "sessions.sidebar.session.moveToWorktree.success": "Sessão movida para um novo worktree", "sessions.sidebar.session.moveToWorktree.failed": "Não foi possível mover a sessão para um novo worktree", + "sessions.sidebar.session.moveToWorktree.main": "Worktree principal", + "sessions.sidebar.session.moveToWorktree.refreshing": "Atualizando worktrees...", + "sessions.sidebar.session.moveToWorktree.loadFailed": "Não foi possível carregar os worktrees", + "sessions.sidebar.session.moveToWorktree.current": "Worktree atual", + "sessions.sidebar.session.moveToWorktree.existingSuccess": "Sessão movida para o worktree", + "sessions.sidebar.session.moveToWorktree.existingFailed": "Não foi possível mover a sessão para o worktree", + "sessions.sidebar.session.moveToWorktree.tooltipTargets": "Mostra os worktrees existentes e a opção de criar um novo para esta sessão.", "sessions.sidebar.session.moveToWorktree.tooltip": "Cria um novo worktree a partir da branch atual, transfere alterações não commitadas e move esta sessão e suas subsessões para lá.", "sessions.sidebar.session.moveToWorktree.tooltipBusy": "Disponível quando a sessão está ociosa. Interrompa a atividade atual ou aguarde sua conclusão.", "sessions.sidebar.session.moveToWorktree.tooltipMoving": "Esta sessão já está sendo movida para um novo worktree.", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 08a5949c..5c46ddb0 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -537,8 +537,17 @@ export const dict: Record = { "sessions.sidebar.session.menu.unshare": "Скасувати спільний доступ", "sessions.sidebar.session.menu.exportMarkdown": "Експорт Markdown", "sessions.sidebar.session.menu.moveToWorktree": "Перенести в новий worktree", + "sessions.sidebar.session.menu.moveToWorktreeTargets": "Перенести в worktree", + "sessions.sidebar.session.menu.newWorktree": "Новий worktree...", "sessions.sidebar.session.moveToWorktree.success": "Сесію перенесено в новий worktree", "sessions.sidebar.session.moveToWorktree.failed": "Не вдалося перенести сесію в новий worktree", + "sessions.sidebar.session.moveToWorktree.main": "Основний worktree", + "sessions.sidebar.session.moveToWorktree.refreshing": "Оновлення worktree...", + "sessions.sidebar.session.moveToWorktree.loadFailed": "Не вдалося завантажити worktree", + "sessions.sidebar.session.moveToWorktree.current": "Поточний worktree", + "sessions.sidebar.session.moveToWorktree.existingSuccess": "Сесію перенесено в worktree", + "sessions.sidebar.session.moveToWorktree.existingFailed": "Не вдалося перенести сесію в worktree", + "sessions.sidebar.session.moveToWorktree.tooltipTargets": "Показує наявні worktree і можливість створити новий для цієї сесії.", "sessions.sidebar.session.moveToWorktree.tooltip": "Створює новий worktree з поточної гілки, переносить незакомічені зміни та переміщує туди цю сесію і її підсесії.", "sessions.sidebar.session.moveToWorktree.tooltipBusy": "Доступно, коли сесія неактивна. Зупиніть поточну активність або дочекайтеся її завершення.", "sessions.sidebar.session.moveToWorktree.tooltipMoving": "Ця сесія вже переноситься в новий worktree.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 191110b9..34504816 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -537,8 +537,17 @@ export const dict: Record = { 'sessions.sidebar.session.menu.unshare': '取消分享', 'sessions.sidebar.session.menu.exportMarkdown': '导出 Markdown', 'sessions.sidebar.session.menu.moveToWorktree': '移至新工作树', + 'sessions.sidebar.session.menu.moveToWorktreeTargets': '移至工作树', + 'sessions.sidebar.session.menu.newWorktree': '新建工作树...', 'sessions.sidebar.session.moveToWorktree.success': '会话已移至新工作树', 'sessions.sidebar.session.moveToWorktree.failed': '无法将会话移至新工作树', + 'sessions.sidebar.session.moveToWorktree.main': '主工作树', + 'sessions.sidebar.session.moveToWorktree.refreshing': '正在刷新工作树...', + 'sessions.sidebar.session.moveToWorktree.loadFailed': '无法加载工作树', + 'sessions.sidebar.session.moveToWorktree.current': '当前工作树', + 'sessions.sidebar.session.moveToWorktree.existingSuccess': '会话已移至工作树', + 'sessions.sidebar.session.moveToWorktree.existingFailed': '无法将会话移至工作树', + 'sessions.sidebar.session.moveToWorktree.tooltipTargets': '显示现有工作树,以及为此会话创建新工作树的选项。', 'sessions.sidebar.session.moveToWorktree.tooltip': '从当前分支创建新工作树,转移未提交的更改,并将此会话及其子会话移至其中。', 'sessions.sidebar.session.moveToWorktree.tooltipBusy': '仅在会话空闲时可用。请停止当前活动或等待其完成。', 'sessions.sidebar.session.moveToWorktree.tooltipMoving': '此会话已在移至新工作树。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 7338818a..aefcb5e0 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -550,8 +550,17 @@ export const dict: Record = { 'sessions.sidebar.session.menu.unshare': '取消分享', 'sessions.sidebar.session.menu.exportMarkdown': '匯出 Markdown', 'sessions.sidebar.session.menu.moveToWorktree': '移至新工作樹', + 'sessions.sidebar.session.menu.moveToWorktreeTargets': '移至工作樹', + 'sessions.sidebar.session.menu.newWorktree': '新增工作樹...', 'sessions.sidebar.session.moveToWorktree.success': '工作階段已移至新工作樹', 'sessions.sidebar.session.moveToWorktree.failed': '無法將工作階段移至新工作樹', + 'sessions.sidebar.session.moveToWorktree.main': '主要工作樹', + 'sessions.sidebar.session.moveToWorktree.refreshing': '正在重新整理工作樹...', + 'sessions.sidebar.session.moveToWorktree.loadFailed': '無法載入工作樹', + 'sessions.sidebar.session.moveToWorktree.current': '目前的工作樹', + 'sessions.sidebar.session.moveToWorktree.existingSuccess': '工作階段已移至工作樹', + 'sessions.sidebar.session.moveToWorktree.existingFailed': '無法將工作階段移至工作樹', + 'sessions.sidebar.session.moveToWorktree.tooltipTargets': '顯示現有工作樹,以及為此工作階段建立新工作樹的選項。', 'sessions.sidebar.session.moveToWorktree.tooltip': '從目前分支建立新工作樹,轉移未提交的變更,並將此工作階段及其子工作階段移至其中。', 'sessions.sidebar.session.moveToWorktree.tooltipBusy': '僅在工作階段閒置時可用。請停止目前活動或等待其完成。', 'sessions.sidebar.session.moveToWorktree.tooltipMoving': '此工作階段已在移至新工作樹。', diff --git a/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts b/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts new file mode 100644 index 00000000..14abe32e --- /dev/null +++ b/packages/ui/src/lib/worktrees/sessionWorktreeMove.test.ts @@ -0,0 +1,485 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; +import type { Session, SessionStatus } from '@opencode-ai/sdk/v2'; +import type { State } from '@/sync/types'; +import type { WorktreeMetadata } from '@/types/worktree'; +import type { ProjectRef } from '@/lib/worktrees/worktreeManager'; + +const moveCalls: Array<{ + sessionId: string; + sourceDirectory: string; + destinationDirectory: string; + moveChanges: boolean; +}> = []; +const refreshCalls: string[][] = []; +type RemoveProjectWorktreeOptions = { deleteLocalBranch: boolean }; +type RemoveProjectWorktreeCall = { + project: ProjectRef; + worktree: WorktreeMetadata; + options: RemoveProjectWorktreeOptions; +}; +type MoveSessionImplementation = ( + session: Session, + sourceDirectory: string, + destinationDirectory: string, + moveChanges: boolean, +) => Promise; +type RefreshImplementation = (directories: string[]) => Promise; +type CreateQuickWorktreeOptions = { preferredName?: string; startRef?: string }; +type CreateQuickWorktreeImplementation = ( + project: ProjectRef, + options: CreateQuickWorktreeOptions, +) => Promise; +type ResolveProjectRefImplementation = (directory: string) => ProjectRef | null; +type WaitForWorktreeGitReadyImplementation = (directory: string) => Promise; +type DirectoryState = Pick; +type DeferredVoid = { + promise: Promise; + resolve: () => void; + reject: (error: Error) => void; +}; + +const removeWorktreeCalls: RemoveProjectWorktreeCall[] = []; +const metadataWrites: Array<{ sessionId: string; metadata: WorktreeMetadata | null }> = []; +const latestMetadataInputs: WorktreeMetadata[] = []; +const toastSuccesses: string[] = []; +const toastErrors: Array<{ title: string; description?: string }> = []; +const directoryStates = new Map(); +const storedMetadata = new Map(); +const originalConsoleWarn = console.warn; + +let moveSessionImplementation: MoveSessionImplementation = async () => {}; +let refreshImplementation: RefreshImplementation = async () => {}; +let latestMetadataResult: WorktreeMetadata; +let createQuickWorktreeImplementation: CreateQuickWorktreeImplementation = async () => ({ + path: '/created-worktree', + projectDirectory: '/repo', + branch: 'feature', + label: 'Created worktree', + worktreeStatus: 'ready', + worktreeSource: 'created-for-session', +}); +let resolveProjectRefImplementation: ResolveProjectRefImplementation = () => ({ id: 'project-1', path: '/repo' }); +let waitForWorktreeGitReadyImplementation: WaitForWorktreeGitReadyImplementation = async () => {}; + +mock.module('@/components/ui', () => ({ + toast: { + success: (message: string) => { + toastSuccesses.push(message); + }, + error: (title: string, options?: { description?: string }) => { + toastErrors.push({ title, description: options?.description }); + }, + }, +})); + +mock.module('@/lib/gitApi', () => ({ + getGitStatus: mock(() => Promise.resolve({ current: 'feature' })), +})); + +mock.module('@/lib/worktreeSessionCreator', () => ({ + createQuickWorktree: mock((project: ProjectRef, options: CreateQuickWorktreeOptions) => createQuickWorktreeImplementation(project, options)), + resolveProjectRef: mock((directory: string) => resolveProjectRefImplementation(directory)), +})); + +mock.module('@/lib/worktrees/worktreeBootstrap', () => ({ + waitForWorktreeGitReady: mock((directory: string) => waitForWorktreeGitReadyImplementation(directory)), +})); + +mock.module('@/lib/worktrees/worktreeManager', () => ({ + getLatestWorktreeMetadata: (metadata: WorktreeMetadata) => { + latestMetadataInputs.push(metadata); + return latestMetadataResult; + }, + removeProjectWorktree: (project: ProjectRef, worktree: WorktreeMetadata, options: RemoveProjectWorktreeOptions) => { + removeWorktreeCalls.push({ project, worktree, options }); + return Promise.resolve(); + }, +})); + +mock.module('@/stores/useGlobalSessionsStore', () => ({ + refreshGlobalSessionsForDirectories: (directories: string[]) => { + refreshCalls.push(directories); + return refreshImplementation(directories); + }, +})); + +mock.module('@/sync/session-actions', () => ({ + moveSessionToDirectory: (session: Session, sourceDirectory: string, destinationDirectory: string, moveChanges = true) => { + moveCalls.push({ sessionId: session.id, sourceDirectory, destinationDirectory, moveChanges }); + return moveSessionImplementation(session, sourceDirectory, destinationDirectory, moveChanges); + }, +})); + +mock.module('@/sync/session-ui-store', () => ({ + useSessionUIStore: { + getState: () => ({ + availableWorktrees: [], + availableWorktreesByProject: new Map(), + getWorktreeMetadata: (sessionId: string) => storedMetadata.get(sessionId) ?? null, + setWorktreeMetadata: (sessionId: string, metadata: WorktreeMetadata | null) => { + storedMetadata.set(sessionId, metadata); + metadataWrites.push({ sessionId, metadata }); + }, + }), + }, +})); + +mock.module('@/sync/sync-refs', () => ({ + getDirectoryState: (directory: string) => directoryStates.get(directory), +})); + +const { + moveSessionTreeToExistingWorktree, + startSessionTreeWorktreeMove, +} = await import('./sessionWorktreeMove'); + +const makeSession = (id: string, directory = '/source'): Session => ({ + id, + slug: id, + projectID: 'project-1', + directory, + title: id, + version: '1', + time: { + created: 0, + updated: 0, + }, +}); + +const makeWorktreeMetadata = (overrides: Partial = {}): WorktreeMetadata => ({ + path: '/destination', + projectDirectory: '/repo', + branch: 'feature', + label: 'Destination', + worktreeStatus: 'ready', + worktreeSource: 'existing', + ...overrides, +}); + +const makeSessionStatus = (type: SessionStatus['type']): SessionStatus => { + switch (type) { + case 'busy': + return { type: 'busy' }; + case 'idle': + return { type: 'idle' }; + case 'retry': + return { type: 'retry', attempt: 1, message: 'retry', next: 0 }; + } +}; + +const setStatuses = (directory: string, statuses: Record): void => { + directoryStates.set(directory, { + session_status: Object.fromEntries( + Object.entries(statuses).map(([sessionId, type]) => [sessionId, makeSessionStatus(type)]), + ), + }); +}; + +const waitFor = async (predicate: () => boolean): Promise => { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (predicate()) return; + await Promise.resolve(); + } + throw new Error('Timed out waiting for condition'); +}; + +const deferred = (): DeferredVoid => { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + +describe('moveSessionTreeToExistingWorktree', () => { + beforeEach(() => { + moveCalls.length = 0; + refreshCalls.length = 0; + removeWorktreeCalls.length = 0; + metadataWrites.length = 0; + latestMetadataInputs.length = 0; + toastSuccesses.length = 0; + toastErrors.length = 0; + directoryStates.clear(); + storedMetadata.clear(); + latestMetadataResult = makeWorktreeMetadata({ label: 'Latest destination' }); + moveSessionImplementation = async () => {}; + refreshImplementation = async () => {}; + createQuickWorktreeImplementation = async () => makeWorktreeMetadata({ path: '/created-worktree', worktreeSource: 'created-for-session' }); + resolveProjectRefImplementation = () => ({ id: 'project-1', path: '/repo' }); + waitForWorktreeGitReadyImplementation = async () => {}; + console.warn = () => {}; + }); + + afterEach(() => { + console.warn = originalConsoleWarn; + }); + + test('moves the root before descendants, only transfers changes once, and refreshes both directories', async () => { + const root = makeSession('root'); + const child = makeSession('child'); + const previousRootMetadata = makeWorktreeMetadata({ path: '/old-root', label: 'Old root' }); + const previousChildMetadata = makeWorktreeMetadata({ path: '/old-child', label: 'Old child' }); + const destination = makeWorktreeMetadata(); + setStatuses('/source', { root: 'idle', child: 'idle' }); + storedMetadata.set(root.id, previousRootMetadata); + storedMetadata.set(child.id, previousChildMetadata); + + const result = await moveSessionTreeToExistingWorktree({ + root, + descendants: [child], + sourceDirectory: '/source', + destination, + }); + + expect(result).toBe('/destination'); + expect(moveCalls).toEqual([ + { sessionId: 'root', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: true }, + { sessionId: 'child', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false }, + ]); + expect(metadataWrites).toEqual([ + { sessionId: 'root', metadata: latestMetadataResult }, + { sessionId: 'child', metadata: latestMetadataResult }, + ]); + expect(latestMetadataInputs).toEqual([destination, destination]); + expect(refreshCalls).toEqual([['/source', '/destination']]); + expect(removeWorktreeCalls).toEqual([]); + }); + + test('rejects a destination that normalizes to the source directory', async () => { + setStatuses('/source', { root: 'idle' }); + + await expect(moveSessionTreeToExistingWorktree({ + root: makeSession('root'), + descendants: [], + sourceDirectory: '/source/', + destination: makeWorktreeMetadata({ path: '/source' }), + })).rejects.toThrow('Source and destination are the same'); + + expect(moveCalls).toEqual([]); + expect(refreshCalls).toEqual([]); + }); + + test('rejects a destination worktree that is not ready', async () => { + setStatuses('/source', { root: 'idle' }); + + await expect(moveSessionTreeToExistingWorktree({ + root: makeSession('root'), + descendants: [], + sourceDirectory: '/source', + destination: makeWorktreeMetadata({ worktreeStatus: 'pending' }), + })).rejects.toThrow('Destination worktree is not ready'); + + expect(moveCalls).toEqual([]); + }); + + test('rejects when the root session is busy before setup', async () => { + setStatuses('/source', { root: 'busy' }); + + await expect(moveSessionTreeToExistingWorktree({ + root: makeSession('root'), + descendants: [], + sourceDirectory: '/source', + destination: makeWorktreeMetadata(), + })).rejects.toThrow('Session is not idle'); + + expect(moveCalls).toEqual([]); + }); + + test('rejects when any descendant is busy before setup', async () => { + const root = makeSession('root'); + const child = makeSession('child'); + setStatuses('/source', { root: 'idle', child: 'retry' }); + + await expect(moveSessionTreeToExistingWorktree({ + root, + descendants: [child], + sourceDirectory: '/source', + destination: makeWorktreeMetadata(), + })).rejects.toThrow('Session is not idle'); + + expect(moveCalls).toEqual([]); + }); + + test('rejects a duplicate move request while the root move is pending', async () => { + const root = makeSession('root'); + const rootMove = deferred(); + setStatuses('/source', { root: 'idle' }); + moveSessionImplementation = async (session, sourceDirectory) => { + if (session.id === 'root' && sourceDirectory === '/source') { + return rootMove.promise; + } + }; + + const firstMove = moveSessionTreeToExistingWorktree({ + root, + descendants: [], + sourceDirectory: '/source', + destination: makeWorktreeMetadata(), + }); + await waitFor(() => moveCalls.length === 1); + + await expect(moveSessionTreeToExistingWorktree({ + root, + descendants: [], + sourceDirectory: '/source', + destination: makeWorktreeMetadata(), + })).rejects.toThrow('Session move already in progress'); + + rootMove.resolve(); + await firstMove; + expect(moveCalls).toHaveLength(1); + }); + + test('rolls back completed moves in reverse order, restores previous metadata, and never removes an existing destination', async () => { + const root = makeSession('root'); + const childA = makeSession('child-a'); + const childB = makeSession('child-b'); + const previousRootMetadata = makeWorktreeMetadata({ path: '/old-root', label: 'Old root' }); + const previousChildAMetadata = makeWorktreeMetadata({ path: '/old-child-a', label: 'Old child A' }); + const previousChildBMetadata = makeWorktreeMetadata({ path: '/old-child-b', label: 'Old child B' }); + setStatuses('/source', { root: 'idle', 'child-a': 'idle', 'child-b': 'idle' }); + storedMetadata.set(root.id, previousRootMetadata); + storedMetadata.set(childA.id, previousChildAMetadata); + storedMetadata.set(childB.id, previousChildBMetadata); + moveSessionImplementation = async (session, sourceDirectory) => { + if (session.id === 'child-b' && sourceDirectory === '/source') { + throw new Error('child-b failed'); + } + }; + + await expect(moveSessionTreeToExistingWorktree({ + root, + descendants: [childA, childB], + sourceDirectory: '/source', + destination: makeWorktreeMetadata(), + })).rejects.toThrow('child-b failed'); + + expect(moveCalls).toEqual([ + { sessionId: 'root', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: true }, + { sessionId: 'child-a', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false }, + { sessionId: 'child-b', sourceDirectory: '/source', destinationDirectory: '/destination', moveChanges: false }, + { sessionId: 'child-a', sourceDirectory: '/destination', destinationDirectory: '/source', moveChanges: false }, + { sessionId: 'root', sourceDirectory: '/destination', destinationDirectory: '/source', moveChanges: true }, + ]); + expect(metadataWrites).toEqual([ + { sessionId: 'root', metadata: latestMetadataResult }, + { sessionId: 'child-a', metadata: latestMetadataResult }, + { sessionId: 'child-a', metadata: previousChildAMetadata }, + { sessionId: 'root', metadata: previousRootMetadata }, + ]); + expect(storedMetadata.get(root.id)).toBe(previousRootMetadata); + expect(storedMetadata.get(childA.id)).toBe(previousChildAMetadata); + expect(storedMetadata.get(childB.id)).toBe(previousChildBMetadata); + expect(removeWorktreeCalls).toEqual([]); + expect(refreshCalls).toEqual([]); + }); + + test('reports an incomplete rollback explicitly and still does not remove the existing destination', async () => { + const root = makeSession('root'); + const child = makeSession('child'); + setStatuses('/source', { root: 'idle', child: 'idle' }); + moveSessionImplementation = async (session, sourceDirectory) => { + if (session.id === 'child' && sourceDirectory === '/source') { + throw new Error('child failed'); + } + if (session.id === 'root' && sourceDirectory === '/destination') { + throw new Error('rollback failed'); + } + }; + + await expect(moveSessionTreeToExistingWorktree({ + root, + descendants: [child], + sourceDirectory: '/source', + destination: makeWorktreeMetadata(), + })).rejects.toThrow('could not be fully rolled back'); + + expect(removeWorktreeCalls).toEqual([]); + }); + + test('keeps the move successful when the post-move refresh fails', async () => { + const root = makeSession('root'); + setStatuses('/source', { root: 'idle' }); + refreshImplementation = async () => { + throw new Error('refresh failed'); + }; + + const result = await moveSessionTreeToExistingWorktree({ + root, + descendants: [], + sourceDirectory: '/source', + destination: makeWorktreeMetadata(), + }); + + expect(result).toBe('/destination'); + expect(refreshCalls).toEqual([['/source', '/destination']]); + }); + + test('removes a newly created worktree when git-ready setup fails', async () => { + setStatuses('/source', { root: 'idle' }); + waitForWorktreeGitReadyImplementation = async () => { + throw new Error('git-ready failed'); + }; + + startSessionTreeWorktreeMove({ + root: makeSession('root'), + descendants: [], + sourceDirectory: '/source', + successMessage: 'success', + failureMessage: 'failed', + }); + + await waitFor(() => toastErrors.length === 1); + expect(toastErrors).toEqual([{ title: 'failed', description: 'git-ready failed' }]); + expect(removeWorktreeCalls).toEqual([{ + project: { id: 'project-1', path: '/repo' }, + worktree: makeWorktreeMetadata({ path: '/created-worktree', worktreeSource: 'created-for-session', label: 'Destination' }), + options: { deleteLocalBranch: true }, + }]); + expect(moveCalls).toEqual([]); + }); + + test('removes a newly created worktree when a session becomes busy before the first move', async () => { + setStatuses('/source', { root: 'idle' }); + waitForWorktreeGitReadyImplementation = async () => { + setStatuses('/source', { root: 'busy' }); + }; + + startSessionTreeWorktreeMove({ + root: makeSession('root'), + descendants: [], + sourceDirectory: '/source', + successMessage: 'success', + failureMessage: 'failed', + }); + + await waitFor(() => toastErrors.length === 1); + expect(removeWorktreeCalls).toEqual([{ + project: { id: 'project-1', path: '/repo' }, + worktree: makeWorktreeMetadata({ path: '/created-worktree', worktreeSource: 'created-for-session', label: 'Destination' }), + options: { deleteLocalBranch: true }, + }]); + expect(moveCalls).toEqual([]); + }); + + test('surfaces a pre-destination preparation failure without attempting removal', async () => { + setStatuses('/source', { root: 'idle' }); + resolveProjectRefImplementation = () => null; + + startSessionTreeWorktreeMove({ + root: makeSession('root'), + descendants: [], + sourceDirectory: '/source', + successMessage: 'success', + failureMessage: 'failed', + }); + + await waitFor(() => toastErrors.length === 1); + expect(toastErrors).toEqual([{ title: 'failed', description: 'Unable to find the project for this session' }]); + expect(removeWorktreeCalls).toEqual([]); + expect(moveCalls).toEqual([]); + }); +}); diff --git a/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts b/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts index 9ddb5e57..e0b679b1 100644 --- a/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts +++ b/packages/ui/src/lib/worktrees/sessionWorktreeMove.ts @@ -88,31 +88,34 @@ const rollbackMovedSessions = async ( const removeFailedWorktree = async ( project: ProjectRef, worktree: WorktreeMetadata, - moveError: unknown, + moveError: Error, ): Promise => { try { await removeProjectWorktree(project, worktree, { deleteLocalBranch: true }); } catch { - const message = moveError instanceof Error ? moveError.message : String(moveError); - throw new Error(`Session move failed and the new worktree could not be removed: ${message}`); + throw new Error(`Session move failed and the new worktree could not be removed: ${moveError.message}`); } throw moveError; }; -const moveSessionTreeToQuickWorktree = async (input: { - root: Session; - descendants: Session[]; - sourceDirectory: string; -}): Promise => { +const moveSessionTreeTransaction = async ( + input: { + root: Session; + descendants: Session[]; + sourceDirectory: string; + }, + prepareDestination: () => Promise<{ + directory: string; + metadata: WorktreeMetadata; + onMoveFailure?: (error: Error) => Promise; + }>, +): Promise => { if (useSessionMoveState.getState().pendingSessionIds.has(input.root.id)) { throw new Error('Session move already in progress'); } setSessionMovePending(input.root.id, true); try { - const project = resolveProjectRef(input.sourceDirectory); - if (!project) throw new Error('Unable to find the project for this session'); - const sessions = [input.root, ...input.descendants]; const previousMetadata = new Map( sessions.map((session) => [ @@ -122,49 +125,112 @@ const moveSessionTreeToQuickWorktree = async (input: { ); assertSessionsIdle(sessions, input.sourceDirectory); - const sourceBranch = await resolveSourceBranch(input.sourceDirectory, project.path); - const worktree = await createQuickWorktree(project, { startRef: sourceBranch }); - + let destination: Awaited> | null = null; const moved: Session[] = []; try { - await waitForWorktreeGitReady(worktree.path); - // Branch/status discovery and worktree creation can take long enough for a - // session to start running, so verify the whole tree again before moving. + destination = await prepareDestination(); + // Setup can take long enough for one of the sessions to start running, so + // verify the whole tree again immediately before the first move. assertSessionsIdle(sessions, input.sourceDirectory); for (const [index, session] of sessions.entries()) { // Transfer the checkout changes once with the root. Descendants only // need their execution location updated. - await moveSessionToDirectory(session, input.sourceDirectory, worktree.path, index === 0); + await moveSessionToDirectory(session, input.sourceDirectory, destination.directory, index === 0); moved.push(session); - useSessionUIStore.getState().setWorktreeMetadata(session.id, getLatestWorktreeMetadata(worktree)); + useSessionUIStore.getState().setWorktreeMetadata(session.id, getLatestWorktreeMetadata(destination.metadata)); } } catch (error) { + const moveError = error instanceof Error ? error : new Error(String(error)); const rollbackFailures = await rollbackMovedSessions( moved, input.root.id, input.sourceDirectory, - worktree.path, + destination?.directory ?? input.sourceDirectory, previousMetadata, ); if (rollbackFailures.length > 0) { - throw new Error(`Session move partially failed and could not be fully rolled back: ${error instanceof Error ? error.message : String(error)}`); + throw new Error(`Session move partially failed and could not be fully rolled back: ${moveError.message}`); } - return removeFailedWorktree(project, worktree, error); + if (destination?.onMoveFailure) { + return destination.onMoveFailure(moveError); + } + throw moveError; } try { - await refreshGlobalSessionsForDirectories([input.sourceDirectory, worktree.path]); + await refreshGlobalSessionsForDirectories([input.sourceDirectory, destination.directory]); } catch (error) { // Direct action updates already reconciled both stores. Keep the move // successful if this best-effort authoritative refresh is unavailable. console.warn('[session-worktree-move] Failed to refresh moved sessions', error); } - return worktree.path; + return destination.directory; } finally { setSessionMovePending(input.root.id, false); } }; +export const moveSessionTreeToExistingWorktree = async (input: { + root: Session; + descendants: Session[]; + sourceDirectory: string; + destination: WorktreeMetadata; +}): Promise => { + const normalizedSourceDirectory = normalizePath(input.sourceDirectory) ?? input.sourceDirectory; + const normalizedDestinationDirectory = normalizePath(input.destination.path) ?? input.destination.path; + if (normalizedSourceDirectory === normalizedDestinationDirectory) { + throw new Error('Source and destination are the same'); + } + if (input.destination.worktreeStatus !== 'ready') { + throw new Error('Destination worktree is not ready'); + } + + return moveSessionTreeTransaction(input, async () => ({ + directory: input.destination.path, + metadata: input.destination, + })); +}; + +const moveSessionTreeToQuickWorktree = async (input: { + root: Session; + descendants: Session[]; + sourceDirectory: string; +}): Promise => { + return moveSessionTreeTransaction(input, async () => { + const project = resolveProjectRef(input.sourceDirectory); + if (!project) throw new Error('Unable to find the project for this session'); + + const sourceBranch = await resolveSourceBranch(input.sourceDirectory, project.path); + const worktree = await createQuickWorktree(project, { startRef: sourceBranch }); + try { + await waitForWorktreeGitReady(worktree.path); + } catch (error) { + const setupError = error instanceof Error ? error : new Error(String(error)); + return removeFailedWorktree(project, worktree, setupError); + } + return { + directory: worktree.path, + metadata: worktree, + onMoveFailure: (error) => removeFailedWorktree(project, worktree, error), + }; + }); +}; + +export const startSessionTreeExistingWorktreeMove = (input: { + root: Session; + descendants: Session[]; + sourceDirectory: string; + destination: WorktreeMetadata; + successMessage: string; + failureMessage: string; +}): void => { + void moveSessionTreeToExistingWorktree(input) + .then(() => toast.success(input.successMessage)) + .catch((error) => toast.error(input.failureMessage, { + description: error instanceof Error ? error.message : String(error), + })); +}; + export const startSessionTreeWorktreeMove = (input: { root: Session; descendants: Session[]; diff --git a/packages/ui/src/lib/worktrees/worktreeManager.test.ts b/packages/ui/src/lib/worktrees/worktreeManager.test.ts index 772db94b..eeed16c9 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.test.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.test.ts @@ -11,6 +11,7 @@ type WorktreeListEntry = { const listCalls: string[] = []; const listResolvers: Array<(value: WorktreeListEntry[]) => void> = []; +const listRejecters: Array<(reason: Error) => void> = []; const createPayloads: unknown[] = []; const validatePayloads: unknown[] = []; const createdWorktree = { @@ -78,8 +79,9 @@ mock.module('@/lib/gitApi', () => ({ worktree: { list: (directory: string) => { listCalls.push(directory); - return new Promise((resolve) => { + return new Promise((resolve, reject) => { listResolvers.push(resolve); + listRejecters.push((reason: Error) => reject(reason)); }); }, create: mock((_directory: string, payload: unknown) => { @@ -118,6 +120,7 @@ describe('worktreeManager list invalidation', () => { beforeEach(() => { listCalls.length = 0; listResolvers.length = 0; + listRejecters.length = 0; createPayloads.length = 0; validatePayloads.length = 0; bootstrapWatcherCalls.length = 0; @@ -152,6 +155,85 @@ describe('worktreeManager list invalidation', () => { expect(result.map((entry) => entry.path)).toEqual(['/repo-feature']); }); + test('forced refresh bypasses a fresh cached result', async () => { + const project = { id: 'project-force-cache', path: '/repo-force-cache' }; + + const initialListing = listProjectWorktrees(project); + await waitForListCallCount(1); + listResolvers[0]([]); + const initialResult = await initialListing; + expect(initialResult).toEqual([]); + + const cachedResult = await listProjectWorktrees(project); + expect(cachedResult).toEqual([]); + expect(listCalls).toEqual(['/repo-force-cache']); + + const forcedListing = listProjectWorktrees(project, { force: true }); + await waitForListCallCount(2); + listResolvers[1]([createdWorktree]); + + const forcedResult = await forcedListing; + expect(forcedResult.map((entry) => entry.path)).toEqual(['/repo-feature']); + expect(listCalls).toEqual(['/repo-force-cache', '/repo-force-cache']); + const refreshedCachedResult = await listProjectWorktrees(project); + expect(refreshedCachedResult.map((entry) => entry.path)).toEqual(['/repo-feature']); + expect(listCalls).toEqual(['/repo-force-cache', '/repo-force-cache']); + }); + + test('forced refresh starts a new request instead of joining an older in-flight list', async () => { + const project = { id: 'project-force-inflight', path: '/repo-force-inflight' }; + + const initialListing = listProjectWorktrees(project); + await waitForListCallCount(1); + + const forcedListing = listProjectWorktrees(project, { force: true }); + await waitForListCallCount(2); + + listResolvers[1]([createdWorktree]); + const forcedResult = await forcedListing; + expect(forcedResult.map((entry) => entry.path)).toEqual(['/repo-feature']); + + listResolvers[0]([]); + await waitForListCallCount(3); + listResolvers[2]([createdWorktree]); + const initialResult = await initialListing; + expect(initialResult.map((entry) => entry.path)).toEqual(['/repo-feature']); + expect(listCalls).toEqual([ + '/repo-force-inflight', + '/repo-force-inflight', + '/repo-force-inflight', + ]); + }); + + test('older completions do not replace a forced refresh result with stale topology', async () => { + const project = { id: 'project-force-stale', path: '/repo-force-stale' }; + + void listProjectWorktrees(project); + await waitForListCallCount(1); + + const forcedListing = listProjectWorktrees(project, { force: true }); + await waitForListCallCount(2); + listResolvers[1]([createdWorktree]); + const forcedResult = await forcedListing; + expect(forcedResult.map((entry) => entry.path)).toEqual(['/repo-feature']); + + listResolvers[0]([{ path: '/repo-stale', branch: 'stale', name: 'stale' }]); + await waitForListCallCount(3); + + const cachedResult = await listProjectWorktrees(project); + expect(cachedResult.map((entry) => entry.path)).toEqual(['/repo-feature']); + }); + + test('rejects when git worktree listing fails', async () => { + const project = { id: 'project-force-failure', path: '/repo-force-failure' }; + + const listing = listProjectWorktrees(project); + await waitForListCallCount(1); + listRejecters[0](new Error('git failed')); + + await expect(listing).rejects.toThrow('git failed'); + }); + test('marks fast-created worktrees pending until bootstrap settles', async () => { const metadata = await createWorktree({ id: 'project-1', path: '/repo' }, { preferredName: 'feature', @@ -389,6 +471,7 @@ describe('worktreeManager fork remote payload wiring', () => { beforeEach(() => { listCalls.length = 0; listResolvers.length = 0; + listRejecters.length = 0; createPayloads.length = 0; validatePayloads.length = 0; bootstrapWatcherCalls.length = 0; diff --git a/packages/ui/src/lib/worktrees/worktreeManager.ts b/packages/ui/src/lib/worktrees/worktreeManager.ts index 543c1d41..50016065 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.ts @@ -374,7 +374,7 @@ export const partitionWorktreesByRegisteredProject = ( // Cache worktree listings to avoid repeated git worktree list + rev-parse calls const _worktreeListCache = new Map(); -const _worktreeListInflight = new Map>(); +const _worktreeListInflight = new Map }>(); const _worktreeListGeneration = new Map(); const WORKTREE_LIST_CACHE_TTL = 30_000; // 30 seconds @@ -391,7 +391,7 @@ const readProjectWorktrees = async (projectDirectory: string): Promise projectDirectory); const normalizedProjectDirectory = normalizePath(projectDirectory); - const worktrees = await git.worktree.list(projectDirectory).catch(() => []); + const worktrees = await git.worktree.list(projectDirectory); const results: WorktreeMetadata[] = worktrees .filter((entry) => typeof entry.path === 'string' && entry.path.trim().length > 0) .map((entry) => { @@ -424,38 +424,48 @@ const readProjectWorktrees = async (projectDirectory: string): Promise => { +const readStableProjectWorktrees = async ( + projectDirectory: string, + minimumGeneration = getWorktreeListGeneration(projectDirectory), +): Promise => { while (true) { const generation = getWorktreeListGeneration(projectDirectory); const worktrees = await readProjectWorktrees(projectDirectory); - if (generation === getWorktreeListGeneration(projectDirectory)) { + if (generation >= minimumGeneration && generation === getWorktreeListGeneration(projectDirectory)) { _worktreeListCache.set(projectDirectory, { value: worktrees, at: Date.now() }); return worktrees; } } }; -export async function listProjectWorktrees(project: ProjectRef): Promise { +export async function listProjectWorktrees(project: ProjectRef, options?: { force?: boolean }): Promise { const projectDirectory = normalizePath(project.path); + const force = options?.force === true; + + if (force) { + invalidateWorktreeList(projectDirectory); + } + + const generation = getWorktreeListGeneration(projectDirectory); // Return cached if fresh const cached = _worktreeListCache.get(projectDirectory); - if (cached && Date.now() - cached.at < WORKTREE_LIST_CACHE_TTL) { + if (!force && cached && Date.now() - cached.at < WORKTREE_LIST_CACHE_TTL) { return cached.value; } // Dedup in-flight requests const inflight = _worktreeListInflight.get(projectDirectory); - if (inflight) return inflight; + if (inflight && inflight.generation === generation) return inflight.promise; - const promise = readStableProjectWorktrees(projectDirectory).finally(() => { - if (_worktreeListInflight.get(projectDirectory) === promise) { + const promise = readStableProjectWorktrees(projectDirectory, generation).finally(() => { + if (_worktreeListInflight.get(projectDirectory)?.promise === promise) { _worktreeListInflight.delete(projectDirectory); } }); - _worktreeListInflight.set(projectDirectory, promise); + _worktreeListInflight.set(projectDirectory, { generation, promise }); return promise; }