feat(sessions): move sessions to existing worktrees
This commit is contained in:
@@ -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<SessionSidebarProps> = ({
|
||||
const [worktreeDiscoveryRevision, requestWorktreeDiscovery] = React.useReducer((revision) => revision + 1, 0);
|
||||
const isWorktreeTopologyLoading = !isVSCode && resolvedWorktreeTopologyKey !== projectWorktreeDiscoveryKey;
|
||||
const [unresolvedWorktreeProjectPaths, setUnresolvedWorktreeProjectPaths] = React.useState<ReadonlySet<string>>(new Set());
|
||||
const rawWorktreesByProjectRef = React.useRef<RawWorktreesByProjectScope>({
|
||||
runtimeKey: null,
|
||||
revision: 0,
|
||||
worktreesByProject: new Map(),
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -198,14 +211,25 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
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<string>();
|
||||
|
||||
// Constrain fanout: previously `Promise.all(projects.map(...))` could
|
||||
@@ -258,18 +282,26 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
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<SessionSidebarProps> = ({
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
const normalizedProjects = React.useMemo(() => {
|
||||
return projects.flatMap((project) => {
|
||||
const normalizedPath = normalizePath(project.path);
|
||||
@@ -527,6 +558,28 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
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<SessionSidebarProps> = ({
|
||||
openProjectEditDialog: setEditingProjectDialogId,
|
||||
removeProject,
|
||||
reorderProjects,
|
||||
startSessionWorktreeMenuLoad: handleSessionWorktreeMenuLoad,
|
||||
initialActiveSessionByProject,
|
||||
persistActiveSessionByProject,
|
||||
projectViewActions: projectView.actions,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string, string>;
|
||||
persistActiveSessionByProject: (value: Map<string, string>) => void;
|
||||
@@ -330,6 +331,7 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
||||
setDeleteSessionConfirm,
|
||||
startFolderRename,
|
||||
setCopiedSessionId,
|
||||
startSessionWorktreeMenuLoad: actions.startSessionWorktreeMenuLoad,
|
||||
folderRename,
|
||||
setFolderRenameDraft,
|
||||
clearFolderRename,
|
||||
@@ -350,6 +352,7 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
||||
deleteSessionConfirm,
|
||||
copiedSessionId,
|
||||
setCopiedSessionId,
|
||||
actions.startSessionWorktreeMenuLoad,
|
||||
rowActions,
|
||||
toggleParent,
|
||||
view.hideDirectoryControls,
|
||||
@@ -457,6 +460,7 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
||||
setDeleteSessionConfirm={setDeleteSessionConfirm}
|
||||
startFolderRename={startFolderRename}
|
||||
setCopiedSessionId={setCopiedSessionId}
|
||||
startSessionWorktreeMenuLoad={actions.startSessionWorktreeMenuLoad}
|
||||
chatSessions={collection.chatSessions}
|
||||
renderChatsSection={renderChatsSection}
|
||||
onNewChat={handleOpenNewChat}
|
||||
|
||||
+4
@@ -138,6 +138,10 @@ const createProps = (): SessionGroupSectionProps => ({
|
||||
deleteSessionConfirm: null,
|
||||
setDeleteSessionConfirm: () => undefined,
|
||||
setCopiedSessionId: () => undefined,
|
||||
startSessionWorktreeMenuLoad: () => ({
|
||||
cachedTargets: [],
|
||||
refreshTargets: Promise.resolve([]),
|
||||
}),
|
||||
onToggleCollapsedGroup: () => undefined,
|
||||
folderRename: null,
|
||||
setFolderRenameDraft: () => undefined,
|
||||
|
||||
@@ -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}
|
||||
/>)}
|
||||
</SessionFolderItem>
|
||||
)}
|
||||
</DroppableFolderWrapper>
|
||||
@@ -962,7 +965,8 @@ function SessionGroupSectionBase(props: SessionGroupSectionProps): React.ReactNo
|
||||
setDeleteSessionConfirm={props.setDeleteSessionConfirm}
|
||||
startFolderRename={props.startFolderRename}
|
||||
setCopiedSessionId={props.setCopiedSessionId}
|
||||
/>;
|
||||
startSessionWorktreeMenuLoad={props.startSessionWorktreeMenuLoad}
|
||||
/>;
|
||||
|
||||
const body = (
|
||||
<SessionFolderDndScope
|
||||
|
||||
@@ -58,6 +58,7 @@ type SessionProjectScrollerGroupProps = Pick<SessionGroupSectionProps,
|
||||
| 'setDeleteSessionConfirm'
|
||||
| 'startFolderRename'
|
||||
| 'setCopiedSessionId'
|
||||
| 'startSessionWorktreeMenuLoad'
|
||||
> & {
|
||||
pinnedSessionIds: Set<string>;
|
||||
sessionOrderIndex: Map<string, number>;
|
||||
|
||||
@@ -49,6 +49,7 @@ type Props = {
|
||||
| 'setDeleteSessionConfirm'
|
||||
| 'startFolderRename'
|
||||
| 'setCopiedSessionId'
|
||||
| 'startSessionWorktreeMenuLoad'
|
||||
>;
|
||||
|
||||
export const RecentSessionSection: React.FC<Props> = (props) => {
|
||||
@@ -162,6 +163,7 @@ export const RecentSessionSection: React.FC<Props> = (props) => {
|
||||
setDeleteSessionConfirm={props.setDeleteSessionConfirm}
|
||||
startFolderRename={props.startFolderRename}
|
||||
setCopiedSessionId={props.setCopiedSessionId}
|
||||
startSessionWorktreeMenuLoad={props.startSessionWorktreeMenuLoad}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -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<string, WorktreeMetadata[]>(entries),
|
||||
},
|
||||
});
|
||||
|
||||
const worktree = (overrides: Partial<WorktreeMetadata> = {}): WorktreeMetadata => ({
|
||||
path: '/repo-feature',
|
||||
projectDirectory: '/repo',
|
||||
branch: 'feature',
|
||||
label: 'feature',
|
||||
name: 'feature',
|
||||
worktreeStatus: 'ready',
|
||||
worktreeSource: 'existing',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createDeferred = <T>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((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<unknown> = [];
|
||||
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<string, WorktreeMetadata[]> }> = [];
|
||||
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<unknown> = [];
|
||||
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<string, WorktreeMetadata[]>([
|
||||
['/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<string, WorktreeMetadata[]> }> = [];
|
||||
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<WorktreeMetadata[]>();
|
||||
const published: Array<unknown> = [];
|
||||
const rawRef = rawScope('runtime-1', [
|
||||
['/old-runtime-repo', [worktree({ path: '/old-runtime-worktree', projectDirectory: '/old-runtime-repo' })]],
|
||||
]);
|
||||
const publishedCurrentRuntime = new Map<string, WorktreeMetadata[]>([
|
||||
['/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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<SessionWorktreeMenuTarget[]>;
|
||||
};
|
||||
|
||||
type SessionWorktreeMenuState = {
|
||||
refreshState: 'loading' | 'error' | null;
|
||||
showNewWorktreeAction: boolean;
|
||||
};
|
||||
|
||||
type StartSessionWorktreeMenuLoadDependencies = {
|
||||
projects: ReadonlyArray<ProjectRef>;
|
||||
rawWorktreesByProjectRef: { current: RawWorktreesByProjectScope };
|
||||
getPublishedWorktreesByProject: () => Map<string, WorktreeMetadata[]>;
|
||||
resolveProject: (directory: string) => ProjectRef | null;
|
||||
listProjectWorktrees: (project: ProjectRef, options: { force: true }) => Promise<WorktreeMetadata[]>;
|
||||
partitionWorktreesByRegisteredProject: (
|
||||
projects: ReadonlyArray<Pick<ProjectRef, 'path'>>,
|
||||
worktreesByProject: ReadonlyMap<string, WorktreeMetadata[]>,
|
||||
) => Map<string, WorktreeMetadata[]>;
|
||||
worktreeMapsEqual: (
|
||||
a: Map<string, WorktreeMetadata[]>,
|
||||
b: Map<string, WorktreeMetadata[]>,
|
||||
) => boolean;
|
||||
recordWorktreesSeen: (paths: Iterable<string | null | undefined>, seenAt: number) => void;
|
||||
publishTopology: (next: {
|
||||
availableWorktrees: WorktreeMetadata[];
|
||||
availableWorktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||
}) => void;
|
||||
getRuntimeKey: () => string;
|
||||
now: () => number;
|
||||
projectRootBranch: string | null;
|
||||
};
|
||||
|
||||
type RequestRediscovery = () => void;
|
||||
|
||||
export type RawWorktreesByProjectScope = {
|
||||
runtimeKey: string | null;
|
||||
revision: number;
|
||||
worktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||
};
|
||||
|
||||
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<string, WorktreeMetadata[]>,
|
||||
): Map<string, WorktreeMetadata[]> => {
|
||||
return new Map(
|
||||
[...worktreesByProject.entries()].map(([projectPath, worktrees]) => [projectPath, worktrees.map((worktree) => cloneMetadata(worktree))]),
|
||||
);
|
||||
};
|
||||
|
||||
export const ensureRawWorktreesByProjectScope = (args: {
|
||||
rawWorktreesByProjectRef: { current: RawWorktreesByProjectScope };
|
||||
publishedWorktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||
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<WorktreeMetadata>;
|
||||
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<string, SessionWorktreeMenuTarget>();
|
||||
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<string>([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<string, WorktreeMetadata[]>;
|
||||
publishedWorktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||
partitionWorktreesByRegisteredProject: StartSessionWorktreeMenuLoadDependencies['partitionWorktreesByRegisteredProject'];
|
||||
projects: ReadonlyArray<Pick<ProjectRef, 'id' | 'path'>>;
|
||||
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<SessionWorktreeMenuTarget>;
|
||||
isRefreshing: boolean;
|
||||
loadFailed: boolean;
|
||||
}): SessionWorktreeMenuState => {
|
||||
return {
|
||||
refreshState: args.isRefreshing
|
||||
? 'loading'
|
||||
: (args.loadFailed && args.targets.length === 0 ? 'error' : null),
|
||||
showNewWorktreeAction: true,
|
||||
};
|
||||
};
|
||||
@@ -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<SessionWorktreeMenuTarget[]>([]);
|
||||
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
|
||||
<Icon name="download" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.session.menu.exportMarkdown')}
|
||||
</Item>
|
||||
{!isSubtaskSession && !archivedBucket && !isVSCode && !isChatDirectoryPath(sessionDirectory) ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block">
|
||||
<Item
|
||||
disabled={!sessionDirectory || isStreaming || isMovingToWorktree}
|
||||
onClick={() => {
|
||||
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"
|
||||
>
|
||||
<Icon name="folder-shared" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.session.menu.moveToWorktree')}
|
||||
</Item>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-72">
|
||||
{isMovingToWorktree
|
||||
? t('sessions.sidebar.session.moveToWorktree.tooltipMoving')
|
||||
: isStreaming
|
||||
? t('sessions.sidebar.session.moveToWorktree.tooltipBusy')
|
||||
: t('sessions.sidebar.session.moveToWorktree.tooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{!isSubtaskSession && !archivedBucket && !isVSCode ? (() => {
|
||||
const worktreeMenuState = getSessionWorktreeMenuState({
|
||||
targets: worktreeTargets,
|
||||
isRefreshing: worktreeTargetsLoading,
|
||||
loadFailed: worktreeTargetsLoadFailed,
|
||||
});
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block">
|
||||
<Sub onOpenChange={handleWorktreeSubmenuOpenChange}>
|
||||
<SubTrigger
|
||||
disabled={!sessionDirectory || isStreaming || isMovingToWorktree}
|
||||
className="w-full [&>svg]:mr-1"
|
||||
data-session-worktree-submenu-trigger={session.id}
|
||||
>
|
||||
<Icon name="folder-shared" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.session.menu.moveToWorktreeTargets')}
|
||||
</SubTrigger>
|
||||
<SubContent className="min-w-[220px]" data-session-worktree-submenu={session.id}>
|
||||
{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 (
|
||||
<Item
|
||||
key={targetPath}
|
||||
disabled={isDisabled}
|
||||
title={target.metadata.path}
|
||||
data-session-worktree-target={targetPath}
|
||||
onClick={() => {
|
||||
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'),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1 truncate">
|
||||
<span className="truncate">{itemLabel}</span>
|
||||
{target.isCurrent ? <span className="sr-only">{t('sessions.sidebar.session.moveToWorktree.current')}</span> : null}
|
||||
</span>
|
||||
{target.isCurrent ? <Icon name="check" className="ml-2 h-3.5 w-3.5 flex-shrink-0 text-primary" aria-hidden="true" /> : null}
|
||||
</Item>
|
||||
);
|
||||
})}
|
||||
{worktreeMenuState.refreshState === 'loading' ? (
|
||||
<Item disabled data-session-worktree-refresh-state="loading" className="py-0.5 text-muted-foreground typography-micro">
|
||||
{t('sessions.sidebar.session.moveToWorktree.refreshing')}
|
||||
</Item>
|
||||
) : null}
|
||||
{worktreeMenuState.refreshState === 'error' ? (
|
||||
<Item disabled data-session-worktree-refresh-state="error" className="py-0.5 text-muted-foreground typography-micro">
|
||||
{t('sessions.sidebar.session.moveToWorktree.loadFailed')}
|
||||
</Item>
|
||||
) : null}
|
||||
<Separator />
|
||||
{worktreeMenuState.showNewWorktreeAction ? (
|
||||
<Item
|
||||
data-session-worktree-new-action="true"
|
||||
onClick={() => {
|
||||
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"
|
||||
>
|
||||
<Icon name="add" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.session.menu.newWorktree')}
|
||||
</Item>
|
||||
) : null}
|
||||
</SubContent>
|
||||
</Sub>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-72">
|
||||
{isMovingToWorktree
|
||||
? t('sessions.sidebar.session.moveToWorktree.tooltipMoving')
|
||||
: isStreaming
|
||||
? t('sessions.sidebar.session.moveToWorktree.tooltipBusy')
|
||||
: t('sessions.sidebar.session.moveToWorktree.tooltipTargets')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})() : null}
|
||||
{isMultiRunLikeSession ? (
|
||||
<Item onClick={() => setFusionDialogOpen(true)} className="[&>svg]:mr-1">
|
||||
<FusionIcon className="mr-1 h-4 w-4" />
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -39,6 +39,7 @@ export type SessionTreeItemProps = SessionTreeItemRenderProps & Pick<SessionNode
|
||||
| 'setEditTitle'
|
||||
| 'toggleParent'
|
||||
| 'setOpenSidebarMenuKey'
|
||||
| 'startSessionWorktreeMenuLoad'
|
||||
> & {
|
||||
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)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user