fix(sessions): preflight every worktree move
This commit is contained in:
@@ -71,7 +71,7 @@ import { copyTextToClipboard } from '@/lib/clipboard';
|
|||||||
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
|
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { startSessionTreeWorktreeMove, useIsSessionWorktreeMovePending } from '@/lib/worktrees/sessionWorktreeMove';
|
import { requestSessionTreeMove, useIsSessionWorktreeMovePending } from '@/lib/worktrees/sessionWorktreeMove';
|
||||||
|
|
||||||
const DESKTOP_HEADER_ICON_BUTTON_CLASS = 'app-region-no-drag inline-flex h-8 w-8 items-center justify-center gap-2 rounded-md typography-ui-label font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:bg-interactive-hover transition-colors';
|
const DESKTOP_HEADER_ICON_BUTTON_CLASS = 'app-region-no-drag inline-flex h-8 w-8 items-center justify-center gap-2 rounded-md typography-ui-label font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:bg-interactive-hover transition-colors';
|
||||||
|
|
||||||
@@ -1059,12 +1059,17 @@ export const Header: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
startSessionTreeWorktreeMove({
|
requestSessionTreeMove({
|
||||||
|
kind: 'quick',
|
||||||
root,
|
root,
|
||||||
descendants,
|
descendants,
|
||||||
sourceDirectory: sessionDirectory,
|
sourceDirectory: sessionDirectory,
|
||||||
successMessage: t('sessions.sidebar.session.moveToWorktree.success'),
|
messages: {
|
||||||
failureMessage: t('sessions.sidebar.session.moveToWorktree.failed'),
|
success: t('sessions.sidebar.session.moveToWorktree.success'),
|
||||||
|
failure: t('sessions.sidebar.session.moveToWorktree.failed'),
|
||||||
|
sourceVerificationFailed: t('sessions.sidebar.session.moveToWorktree.sourceVerificationFailed'),
|
||||||
|
applyChangesFailed: t('sessions.sidebar.session.moveToWorktree.applyChangesFailed'),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}, [currentSessionId, isCurrentSessionActive, isCurrentSessionMovingToWorktree, sessionDirectory, t]);
|
}, [currentSessionId, isCurrentSessionActive, isCurrentSessionMovingToWorktree, sessionDirectory, t]);
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { HelpDialog } from '../ui/HelpDialog';
|
|||||||
import { OpenCodeStatusDialog } from '../ui/OpenCodeStatusDialog';
|
import { OpenCodeStatusDialog } from '../ui/OpenCodeStatusDialog';
|
||||||
import { SessionSidebar } from '@/components/session/SessionSidebar';
|
import { SessionSidebar } from '@/components/session/SessionSidebar';
|
||||||
import { SessionDialogs } from '@/components/session/SessionDialogs';
|
import { SessionDialogs } from '@/components/session/SessionDialogs';
|
||||||
|
import { SessionWorktreeMoveConfirmDialog } from '@/components/session/sidebar/SessionWorktreeMoveConfirmDialog';
|
||||||
import { ScheduledTasksDialog } from '@/components/session/ScheduledTasksDialog';
|
import { ScheduledTasksDialog } from '@/components/session/ScheduledTasksDialog';
|
||||||
import { ArchiveView } from '@/components/views/ArchiveView';
|
import { ArchiveView } from '@/components/views/ArchiveView';
|
||||||
import { WorktreesView } from '@/components/views/WorktreesView';
|
import { WorktreesView } from '@/components/views/WorktreesView';
|
||||||
@@ -19,6 +20,11 @@ import { MultiRunLauncher } from '@/components/multirun';
|
|||||||
|
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import {
|
||||||
|
cancelSessionTreeMove,
|
||||||
|
confirmSessionTreeMove,
|
||||||
|
useSessionTreeMoveConfirmation,
|
||||||
|
} from '@/lib/worktrees/sessionWorktreeMove';
|
||||||
import { useUpdatePolling } from '@/hooks/useUpdatePolling';
|
import { useUpdatePolling } from '@/hooks/useUpdatePolling';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -80,6 +86,8 @@ export const MainLayout: React.FC = () => {
|
|||||||
|
|
||||||
useUpdatePolling();
|
useUpdatePolling();
|
||||||
|
|
||||||
|
const sessionTreeMoveConfirmation = useSessionTreeMoveConfirmation();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const previous = useUIStore.getState().isMobile;
|
const previous = useUIStore.getState().isMobile;
|
||||||
if (previous !== isMobile) {
|
if (previous !== isMobile) {
|
||||||
@@ -97,6 +105,12 @@ export const MainLayout: React.FC = () => {
|
|||||||
<HelpDialog />
|
<HelpDialog />
|
||||||
<OpenCodeStatusDialog />
|
<OpenCodeStatusDialog />
|
||||||
<SessionDialogs />
|
<SessionDialogs />
|
||||||
|
<SessionWorktreeMoveConfirmDialog
|
||||||
|
value={sessionTreeMoveConfirmation}
|
||||||
|
onMoveSessionOnly={() => confirmSessionTreeMove(false)}
|
||||||
|
onMoveAllChanges={() => confirmSessionTreeMove(true)}
|
||||||
|
onCancel={cancelSessionTreeMove}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Persistent top-left controls (toggle + project actions) that
|
{/* Persistent top-left controls (toggle + project actions) that
|
||||||
stay put while the sidebar/header animate beneath them. */}
|
stay put while the sidebar/header animate beneath them. */}
|
||||||
|
|||||||
@@ -48,8 +48,7 @@ import { MultiRunFusionDialog } from '@/components/multirun/MultiRunFusionDialog
|
|||||||
import { FusionIcon } from '@/components/icons/FusionIcon';
|
import { FusionIcon } from '@/components/icons/FusionIcon';
|
||||||
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
||||||
import {
|
import {
|
||||||
startSessionTreeExistingWorktreeMove,
|
requestSessionTreeMove,
|
||||||
startSessionTreeWorktreeMove,
|
|
||||||
useIsSessionWorktreeMovePending,
|
useIsSessionWorktreeMovePending,
|
||||||
} from '@/lib/worktrees/sessionWorktreeMove';
|
} from '@/lib/worktrees/sessionWorktreeMove';
|
||||||
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
||||||
@@ -1042,13 +1041,18 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
|
|||||||
if (isDisabled || !sessionDirectory) {
|
if (isDisabled || !sessionDirectory) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
startSessionTreeExistingWorktreeMove({
|
requestSessionTreeMove({
|
||||||
|
kind: 'existing',
|
||||||
root: resolvedSession,
|
root: resolvedSession,
|
||||||
descendants: collectNodeDescendantSessions(node),
|
descendants: collectNodeDescendantSessions(node),
|
||||||
sourceDirectory: sessionDirectory,
|
sourceDirectory: sessionDirectory,
|
||||||
destination: target.metadata,
|
destination: target.metadata,
|
||||||
successMessage: t('sessions.sidebar.session.moveToWorktree.existingSuccess'),
|
messages: {
|
||||||
failureMessage: t('sessions.sidebar.session.moveToWorktree.existingFailed'),
|
success: t('sessions.sidebar.session.moveToWorktree.existingSuccess'),
|
||||||
|
failure: t('sessions.sidebar.session.moveToWorktree.existingFailed'),
|
||||||
|
sourceVerificationFailed: t('sessions.sidebar.session.moveToWorktree.sourceVerificationFailed'),
|
||||||
|
applyChangesFailed: t('sessions.sidebar.session.moveToWorktree.applyChangesFailed'),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1077,12 +1081,17 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
|
|||||||
data-session-worktree-new-action="true"
|
data-session-worktree-new-action="true"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isWorktreeMenuDisabled || !sessionDirectory) return;
|
if (isWorktreeMenuDisabled || !sessionDirectory) return;
|
||||||
startSessionTreeWorktreeMove({
|
requestSessionTreeMove({
|
||||||
|
kind: 'quick',
|
||||||
root: resolvedSession,
|
root: resolvedSession,
|
||||||
descendants: collectNodeDescendantSessions(node),
|
descendants: collectNodeDescendantSessions(node),
|
||||||
sourceDirectory: sessionDirectory,
|
sourceDirectory: sessionDirectory,
|
||||||
successMessage: t('sessions.sidebar.session.moveToWorktree.success'),
|
messages: {
|
||||||
failureMessage: t('sessions.sidebar.session.moveToWorktree.failed'),
|
success: t('sessions.sidebar.session.moveToWorktree.success'),
|
||||||
|
failure: t('sessions.sidebar.session.moveToWorktree.failed'),
|
||||||
|
sourceVerificationFailed: t('sessions.sidebar.session.moveToWorktree.sourceVerificationFailed'),
|
||||||
|
applyChangesFailed: t('sessions.sidebar.session.moveToWorktree.applyChangesFailed'),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="[&>svg]:mr-1"
|
className="[&>svg]:mr-1"
|
||||||
|
|||||||
@@ -204,7 +204,6 @@ const {
|
|||||||
cancelSessionTreeMove,
|
cancelSessionTreeMove,
|
||||||
useSessionTreeMoveConfirmation,
|
useSessionTreeMoveConfirmation,
|
||||||
getSessionTreeMoveConfirmation,
|
getSessionTreeMoveConfirmation,
|
||||||
startSessionTreeWorktreeMove,
|
|
||||||
} = await import('./sessionWorktreeMove');
|
} = await import('./sessionWorktreeMove');
|
||||||
|
|
||||||
const makeSession = (id: string, directory = '/source'): Session => ({
|
const makeSession = (id: string, directory = '/source'): Session => ({
|
||||||
@@ -659,16 +658,10 @@ describe('moveSessionTreeToExistingWorktree', () => {
|
|||||||
throw new Error('git-ready failed');
|
throw new Error('git-ready failed');
|
||||||
};
|
};
|
||||||
|
|
||||||
startSessionTreeWorktreeMove({
|
requestSessionTreeMove(makeQuickIntent());
|
||||||
root: makeSession('root'),
|
|
||||||
descendants: [],
|
|
||||||
sourceDirectory: '/source',
|
|
||||||
successMessage: 'success',
|
|
||||||
failureMessage: 'failed',
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => toastErrors.length === 1);
|
await waitFor(() => toastErrors.length === 1);
|
||||||
expect(toastErrors).toEqual([{ title: 'failed', description: 'git-ready failed' }]);
|
expect(toastErrors).toEqual([{ title: 'move failed', description: 'git-ready failed' }]);
|
||||||
expect(removeWorktreeCalls).toEqual([{
|
expect(removeWorktreeCalls).toEqual([{
|
||||||
projectDirectory: '/repo',
|
projectDirectory: '/repo',
|
||||||
directory: '/created-worktree',
|
directory: '/created-worktree',
|
||||||
@@ -683,13 +676,7 @@ describe('moveSessionTreeToExistingWorktree', () => {
|
|||||||
setStatuses('/source', { root: 'busy' });
|
setStatuses('/source', { root: 'busy' });
|
||||||
};
|
};
|
||||||
|
|
||||||
startSessionTreeWorktreeMove({
|
requestSessionTreeMove(makeQuickIntent());
|
||||||
root: makeSession('root'),
|
|
||||||
descendants: [],
|
|
||||||
sourceDirectory: '/source',
|
|
||||||
successMessage: 'success',
|
|
||||||
failureMessage: 'failed',
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => toastErrors.length === 1);
|
await waitFor(() => toastErrors.length === 1);
|
||||||
expect(removeWorktreeCalls).toEqual([{
|
expect(removeWorktreeCalls).toEqual([{
|
||||||
@@ -1020,16 +1007,10 @@ describe('moveSessionTreeToExistingWorktree', () => {
|
|||||||
setStatuses('/source', { root: 'idle' });
|
setStatuses('/source', { root: 'idle' });
|
||||||
resolveProjectRefImplementation = () => null;
|
resolveProjectRefImplementation = () => null;
|
||||||
|
|
||||||
startSessionTreeWorktreeMove({
|
requestSessionTreeMove(makeQuickIntent());
|
||||||
root: makeSession('root'),
|
|
||||||
descendants: [],
|
|
||||||
sourceDirectory: '/source',
|
|
||||||
successMessage: 'success',
|
|
||||||
failureMessage: 'failed',
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => toastErrors.length === 1);
|
await waitFor(() => toastErrors.length === 1);
|
||||||
expect(toastErrors).toEqual([{ title: 'failed', description: 'Unable to find the project for this session' }]);
|
expect(toastErrors).toEqual([{ title: 'move failed', description: 'Unable to find the project for this session' }]);
|
||||||
expect(removeWorktreeCalls).toEqual([]);
|
expect(removeWorktreeCalls).toEqual([]);
|
||||||
expect(moveCalls).toEqual([]);
|
expect(moveCalls).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -405,47 +405,3 @@ export const requestSessionTreeMove = (intent: SessionTreeMoveIntent): void => {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const startSessionTreeExistingWorktreeMove = (input: {
|
|
||||||
root: Session;
|
|
||||||
descendants: Session[];
|
|
||||||
sourceDirectory: string;
|
|
||||||
destination: WorktreeMetadata;
|
|
||||||
successMessage: string;
|
|
||||||
failureMessage: string;
|
|
||||||
}): void => {
|
|
||||||
requestSessionTreeMove({
|
|
||||||
kind: 'existing',
|
|
||||||
root: input.root,
|
|
||||||
descendants: input.descendants,
|
|
||||||
sourceDirectory: input.sourceDirectory,
|
|
||||||
destination: input.destination,
|
|
||||||
messages: {
|
|
||||||
success: input.successMessage,
|
|
||||||
failure: input.failureMessage,
|
|
||||||
sourceVerificationFailed: input.failureMessage,
|
|
||||||
applyChangesFailed: input.failureMessage,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const startSessionTreeWorktreeMove = (input: {
|
|
||||||
root: Session;
|
|
||||||
descendants: Session[];
|
|
||||||
sourceDirectory: string;
|
|
||||||
successMessage: string;
|
|
||||||
failureMessage: string;
|
|
||||||
}): void => {
|
|
||||||
requestSessionTreeMove({
|
|
||||||
kind: 'quick',
|
|
||||||
root: input.root,
|
|
||||||
descendants: input.descendants,
|
|
||||||
sourceDirectory: input.sourceDirectory,
|
|
||||||
messages: {
|
|
||||||
success: input.successMessage,
|
|
||||||
failure: input.failureMessage,
|
|
||||||
sourceVerificationFailed: input.failureMessage,
|
|
||||||
applyChangesFailed: input.failureMessage,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|||||||
Reference in New Issue
Block a user