fix(sessions): harden worktree move recovery
This commit is contained in:
@@ -564,6 +564,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
: (args.sourceDirectory ? resolveProjectRef(args.sourceDirectory) : null);
|
||||
return startSessionWorktreeMenuLoad(args, {
|
||||
projects,
|
||||
getCurrentProjects: () => useProjectsStore.getState().projects,
|
||||
rawWorktreesByProjectRef,
|
||||
getPublishedWorktreesByProject: () => useSessionUIStore.getState().availableWorktreesByProject,
|
||||
resolveProject: (directory) => resolveProjectRef(directory),
|
||||
|
||||
@@ -197,6 +197,10 @@ describe('startSessionWorktreeMenuLoad', () => {
|
||||
{ id: 'linked', path: '/repo-linked' },
|
||||
{ id: 'other', path: '/repo-other' },
|
||||
],
|
||||
getCurrentProjects: () => [
|
||||
{ id: 'linked', path: '/repo-linked' },
|
||||
{ id: 'other', path: '/repo-other' },
|
||||
],
|
||||
rawWorktreesByProjectRef: rawRef,
|
||||
getPublishedWorktreesByProject: () => new Map(),
|
||||
resolveProject: () => null,
|
||||
@@ -259,6 +263,7 @@ describe('startSessionWorktreeMenuLoad', () => {
|
||||
},
|
||||
{
|
||||
projects: [{ id: 'linked', path: '/repo-linked' }],
|
||||
getCurrentProjects: () => [{ id: 'linked', path: '/repo-linked' }],
|
||||
rawWorktreesByProjectRef: rawRef,
|
||||
getPublishedWorktreesByProject: () => new Map([['/repo-linked', [existing]]]),
|
||||
resolveProject: () => null,
|
||||
@@ -303,6 +308,7 @@ describe('startSessionWorktreeMenuLoad', () => {
|
||||
},
|
||||
{
|
||||
projects: [{ id: 'linked', path: '/repo-linked' }],
|
||||
getCurrentProjects: () => [{ id: 'linked', path: '/repo-linked' }],
|
||||
rawWorktreesByProjectRef: rawRef,
|
||||
getPublishedWorktreesByProject: () => publishedTopology,
|
||||
resolveProject: () => null,
|
||||
@@ -352,6 +358,10 @@ describe('startSessionWorktreeMenuLoad', () => {
|
||||
{ id: 'owner', path: '/repo' },
|
||||
{ id: 'linked', path: '/repo-linked' },
|
||||
],
|
||||
getCurrentProjects: () => [
|
||||
{ id: 'owner', path: '/repo' },
|
||||
{ id: 'linked', path: '/repo-linked' },
|
||||
],
|
||||
rawWorktreesByProjectRef: rawRef,
|
||||
getPublishedWorktreesByProject: () => new Map([['/repo', [ownerExisting]]]),
|
||||
resolveProject: () => null,
|
||||
@@ -398,6 +408,7 @@ describe('startSessionWorktreeMenuLoad', () => {
|
||||
},
|
||||
{
|
||||
projects: [{ id: 'linked', path: '/repo-linked' }],
|
||||
getCurrentProjects: () => [{ id: 'linked', path: '/repo-linked' }],
|
||||
rawWorktreesByProjectRef: rawRef,
|
||||
getPublishedWorktreesByProject: () => publishedCurrentRuntime,
|
||||
resolveProject: () => null,
|
||||
@@ -433,6 +444,53 @@ describe('startSessionWorktreeMenuLoad', () => {
|
||||
expect(published).toEqual([]);
|
||||
});
|
||||
|
||||
test('rejects a deferred refresh when the owning project is removed before commit', async () => {
|
||||
const refreshDeferred = createDeferred<WorktreeMetadata[]>();
|
||||
const existing = worktree({ path: '/repo-existing', branch: 'existing', label: 'existing', name: 'existing' });
|
||||
const published: Array<{ availableWorktreesByProject: Map<string, WorktreeMetadata[]> }> = [];
|
||||
const rawRef = rawScope('runtime-1', [
|
||||
['/repo-linked', [existing]],
|
||||
]);
|
||||
let currentProjects = [{ id: 'linked', path: '/repo-linked' }];
|
||||
|
||||
const load = startSessionWorktreeMenuLoad(
|
||||
{
|
||||
projectId: 'linked',
|
||||
sourceDirectory: '/repo-current',
|
||||
currentWorktree: worktree({ path: '/repo-current', branch: 'current', label: 'current' }),
|
||||
},
|
||||
{
|
||||
projects: currentProjects,
|
||||
rawWorktreesByProjectRef: rawRef,
|
||||
getPublishedWorktreesByProject: () => new Map([['/repo-linked', [existing]]]),
|
||||
resolveProject: () => null,
|
||||
listProjectWorktrees: async () => refreshDeferred.promise,
|
||||
partitionWorktreesByRegisteredProject: (_projects, worktreesByProject) => new Map(worktreesByProject),
|
||||
worktreeMapsEqual: () => false,
|
||||
recordWorktreesSeen: () => {},
|
||||
publishTopology: (next) => {
|
||||
published.push({ availableWorktreesByProject: next.availableWorktreesByProject });
|
||||
},
|
||||
getCurrentProjects: () => currentProjects,
|
||||
getRuntimeKey: () => 'runtime-1',
|
||||
now: () => 123,
|
||||
projectRootBranch: null,
|
||||
},
|
||||
);
|
||||
|
||||
currentProjects = [];
|
||||
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('Project removed during worktree refresh');
|
||||
expect(rawRef.current.worktreesByProject.get('/repo-linked')).toEqual([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(
|
||||
@@ -443,6 +501,7 @@ describe('startSessionWorktreeMenuLoad', () => {
|
||||
},
|
||||
{
|
||||
projects: [{ id: 'owner', path: '/repo' }],
|
||||
getCurrentProjects: () => [{ id: 'owner', path: '/repo' }],
|
||||
rawWorktreesByProjectRef: rawScope('runtime-1', []),
|
||||
getPublishedWorktreesByProject: () => new Map(),
|
||||
resolveProject: (directory) => {
|
||||
|
||||
@@ -26,6 +26,7 @@ type SessionWorktreeMenuState = {
|
||||
|
||||
type StartSessionWorktreeMenuLoadDependencies = {
|
||||
projects: ReadonlyArray<ProjectRef>;
|
||||
getCurrentProjects: () => ReadonlyArray<ProjectRef>;
|
||||
rawWorktreesByProjectRef: { current: RawWorktreesByProjectScope };
|
||||
getPublishedWorktreesByProject: () => Map<string, WorktreeMetadata[]>;
|
||||
resolveProject: (directory: string) => ProjectRef | null;
|
||||
@@ -285,6 +286,12 @@ export const startSessionWorktreeMenuLoad = (
|
||||
throw new Error('Runtime changed during worktree refresh');
|
||||
}
|
||||
|
||||
const currentProjects = deps.getCurrentProjects();
|
||||
const currentProject = currentProjects.find((candidate) => candidate.id === project.id) ?? null;
|
||||
if (!currentProject || normalizePath(currentProject.path ?? null) !== normalizedProjectPath) {
|
||||
throw new Error('Project removed during worktree refresh');
|
||||
}
|
||||
|
||||
const currentRawScope = ensureRawWorktreesByProjectScope({
|
||||
rawWorktreesByProjectRef: deps.rawWorktreesByProjectRef,
|
||||
publishedWorktreesByProject: deps.getPublishedWorktreesByProject(),
|
||||
@@ -327,7 +334,7 @@ export const startSessionWorktreeMenuLoad = (
|
||||
worktreesByProject: nextRawTopology,
|
||||
};
|
||||
|
||||
const partitionedWorktreesByProject = deps.partitionWorktreesByRegisteredProject(deps.projects, nextRawTopology);
|
||||
const partitionedWorktreesByProject = deps.partitionWorktreesByRegisteredProject(currentProjects, nextRawTopology);
|
||||
const allWorktrees = [...partitionedWorktreesByProject.values()].flat();
|
||||
deps.recordWorktreesSeen(allWorktrees.map((worktree) => worktree.path), deps.now());
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import { useGlobalSessionStatus, useSessionPermissions, useSessionQuestionCount
|
||||
import { useSessionMessageRecordsForExport } from '@/sync/use-sync';
|
||||
import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store';
|
||||
import { DraggableSessionRow } from '../folders/sessionFolderDnd';
|
||||
import { nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils';
|
||||
import { getSessionWorktreeMenuDisabled, nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils';
|
||||
import type { SessionNode } from '../types';
|
||||
import { formatProjectLabel, formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from '../utils';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
@@ -993,102 +993,106 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
|
||||
{t('sessions.sidebar.session.menu.exportMarkdown')}
|
||||
</Item>
|
||||
{!isSubtaskSession && !archivedBucket && !isVSCode ? (() => {
|
||||
const isWorktreeMenuDisabled = getSessionWorktreeMenuDisabled({
|
||||
sessionDirectory,
|
||||
isStreaming,
|
||||
isMovingToWorktree,
|
||||
});
|
||||
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';
|
||||
<Sub onOpenChange={handleWorktreeSubmenuOpenChange}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SubTrigger
|
||||
disabled={isWorktreeMenuDisabled}
|
||||
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>
|
||||
</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>
|
||||
<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>
|
||||
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
|
||||
disabled={isWorktreeMenuDisabled}
|
||||
data-session-worktree-new-action="true"
|
||||
onClick={() => {
|
||||
if (isWorktreeMenuDisabled || !sessionDirectory) 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>
|
||||
);
|
||||
})() : null}
|
||||
{isMultiRunLikeSession ? (
|
||||
|
||||
@@ -2,7 +2,13 @@ import { describe, expect, test } from 'bun:test';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { getPinnedSessionKey } from '@/stores/useSessionPinnedStore';
|
||||
import { computeNodeStructureKey, nodeHasPinnedMembershipChange, selectFolderRootNodes, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils';
|
||||
import {
|
||||
computeNodeStructureKey,
|
||||
getSessionWorktreeMenuDisabled,
|
||||
nodeHasPinnedMembershipChange,
|
||||
selectFolderRootNodes,
|
||||
selectQuestionBadgeSessionScopes,
|
||||
} from './sessionNodeItemUtils';
|
||||
import type { SessionNode } from '../types';
|
||||
|
||||
const session = (id: string, title: string): Session => ({
|
||||
@@ -158,3 +164,31 @@ describe('selectFolderRootNodes', () => {
|
||||
expect(selectFolderRootNodes(['missing-root', 'child'], new Map([['child', child]]))).toEqual([child]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionWorktreeMenuDisabled', () => {
|
||||
test('shares the parent trigger disabled contract with the new worktree action', () => {
|
||||
expect(getSessionWorktreeMenuDisabled({
|
||||
sessionDirectory: '/repo-feature',
|
||||
isStreaming: false,
|
||||
isMovingToWorktree: false,
|
||||
})).toBe(false);
|
||||
|
||||
expect(getSessionWorktreeMenuDisabled({
|
||||
sessionDirectory: null,
|
||||
isStreaming: false,
|
||||
isMovingToWorktree: false,
|
||||
})).toBe(true);
|
||||
|
||||
expect(getSessionWorktreeMenuDisabled({
|
||||
sessionDirectory: '/repo-feature',
|
||||
isStreaming: true,
|
||||
isMovingToWorktree: false,
|
||||
})).toBe(true);
|
||||
|
||||
expect(getSessionWorktreeMenuDisabled({
|
||||
sessionDirectory: '/repo-feature',
|
||||
isStreaming: false,
|
||||
isMovingToWorktree: true,
|
||||
})).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,6 +78,16 @@ export type QuestionBadgeSessionScope = {
|
||||
sessionIDs: string[];
|
||||
};
|
||||
|
||||
export const getSessionWorktreeMenuDisabled = ({
|
||||
sessionDirectory,
|
||||
isStreaming,
|
||||
isMovingToWorktree,
|
||||
}: {
|
||||
sessionDirectory: string | null;
|
||||
isStreaming: boolean;
|
||||
isMovingToWorktree: boolean;
|
||||
}): boolean => !sessionDirectory || isStreaming || isMovingToWorktree;
|
||||
|
||||
/**
|
||||
* Choose which (directory, sessionIDs) scopes a sidebar row's pending-question
|
||||
* badge should count. An expanded row counts only its own session; a collapsed
|
||||
|
||||
Reference in New Issue
Block a user