feat(sessions): confirm dirty-source worktree moves

This commit is contained in:
mattv8
2026-08-27 18:43:37 -06:00
parent 0d4b3f036a
commit 8fc08853b3
13 changed files with 330 additions and 11 deletions
@@ -0,0 +1,106 @@
import React from 'react';
import { describe, expect, mock, test } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nProvider } from '@/lib/i18n';
import type { Session } from '@opencode-ai/sdk/v2';
import type {
SessionTreeMoveIntent,
SessionTreeMoveMessages,
} from '@/lib/worktrees/sessionWorktreeMove';
type MockDialogProps = React.PropsWithChildren<{
open?: boolean;
id?: string;
className?: string;
}>;
mock.module('@/components/ui/dialog', () => ({
Dialog: ({ children, open = true }: MockDialogProps) => (open ? <>{children}</> : null),
DialogContent: ({ children, id, className }: MockDialogProps) => (
<div id={id} className={className}>{children}</div>
),
DialogDescription: ({ children }: MockDialogProps) => <p>{children}</p>,
DialogFooter: ({ children, className }: MockDialogProps) => <div className={className}>{children}</div>,
DialogHeader: ({ children }: MockDialogProps) => <div>{children}</div>,
DialogTitle: ({ children }: MockDialogProps) => <h2>{children}</h2>,
}));
const { SessionWorktreeMoveConfirmDialog } = await import('./SessionWorktreeMoveConfirmDialog');
const makeMoveMessages = (): SessionTreeMoveMessages => ({
success: 'move succeeded',
failure: 'move failed',
sourceVerificationFailed: 'source verification failed',
applyChangesFailed: 'apply changes failed',
});
const makeExistingIntent = (): SessionTreeMoveIntent => ({
kind: 'existing',
root: {
id: 'root',
slug: 'root',
projectID: 'project-1',
directory: '/source',
title: 'Root session',
version: '1',
time: { created: 0, updated: 0 },
} satisfies Session,
descendants: [],
sourceDirectory: '/source',
destination: {
path: '/destination',
projectDirectory: '/repo',
branch: 'feature',
label: 'Destination',
worktreeStatus: 'ready',
worktreeSource: 'existing',
},
messages: makeMoveMessages(),
});
describe('SessionWorktreeMoveConfirmDialog', () => {
test('renders stable semantic hooks, dirty file count, and the staged warning', () => {
const markup = renderToStaticMarkup(
<I18nProvider>
<SessionWorktreeMoveConfirmDialog
value={{
intent: makeExistingIntent(),
dirtyFileCount: 2,
stagedFileCount: 1,
}}
onMoveSessionOnly={() => {}}
onMoveAllChanges={() => {}}
onCancel={() => {}}
/>
</I18nProvider>,
);
expect(markup).toContain('id="session-worktree-move-confirm-dialog"');
expect(markup).toContain('data-session-worktree-move-action="session-only"');
expect(markup).toContain('data-session-worktree-move-action="all-changes"');
expect(markup).toContain('data-session-worktree-move-action="cancel"');
expect(markup).toContain('autofocus=""');
expect(markup).toContain('2');
expect(markup).toContain('data-session-worktree-move-staged-warning="true"');
});
test('omits the staged warning when no staged files are present', () => {
const markup = renderToStaticMarkup(
<I18nProvider>
<SessionWorktreeMoveConfirmDialog
value={{
intent: makeExistingIntent(),
dirtyFileCount: 3,
stagedFileCount: 0,
}}
onMoveSessionOnly={() => {}}
onMoveAllChanges={() => {}}
onCancel={() => {}}
/>
</I18nProvider>,
);
expect(markup).not.toContain('data-session-worktree-move-staged-warning="true"');
});
});
@@ -0,0 +1,81 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { useI18n } from '@/lib/i18n';
import type { SessionTreeMoveConfirmation } from '@/lib/worktrees/sessionWorktreeMove';
export type SessionWorktreeMoveConfirmDialogProps = {
value: SessionTreeMoveConfirmation | null;
onMoveSessionOnly: () => void;
onMoveAllChanges: () => void;
onCancel: () => void;
};
export function SessionWorktreeMoveConfirmDialog(props: SessionWorktreeMoveConfirmDialogProps): React.ReactNode {
const { t } = useI18n();
const { value, onMoveSessionOnly, onMoveAllChanges, onCancel } = props;
return (
<Dialog open={Boolean(value)} onOpenChange={(open) => { if (!open) onCancel(); }}>
<DialogContent
id="session-worktree-move-confirm-dialog"
showCloseButton={false}
className="max-w-md gap-5"
>
<DialogHeader>
<DialogTitle>{t('sessions.sidebar.session.moveToWorktree.confirm.title')}</DialogTitle>
<DialogDescription>
{t('sessions.sidebar.session.moveToWorktree.confirm.changedFiles', {
count: value?.dirtyFileCount ?? 0,
})}{' '}
{t('sessions.sidebar.session.moveToWorktree.confirm.ownership')}
</DialogDescription>
</DialogHeader>
<div className="space-y-2 typography-ui-label text-muted-foreground">
<p>{t('sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp')}</p>
<p>{t('sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp')}</p>
{value && value.stagedFileCount > 0 ? (
<p data-session-worktree-move-staged-warning="true">
{t('sessions.sidebar.session.moveToWorktree.confirm.stagedWarning')}
</p>
) : null}
<p>{t('sessions.sidebar.session.moveToWorktree.confirm.baseWarning')}</p>
</div>
<DialogFooter className="gap-2 sm:justify-end">
<Button
type="button"
variant="neutral"
data-session-worktree-move-action="cancel"
onClick={onCancel}
>
{t('sessions.sidebar.session.moveToWorktree.confirm.cancel')}
</Button>
<Button
type="button"
variant="outline"
data-session-worktree-move-action="all-changes"
onClick={onMoveAllChanges}
>
{t('sessions.sidebar.session.moveToWorktree.confirm.allChanges')}
</Button>
<Button
type="button"
autoFocus
data-session-worktree-move-action="session-only"
onClick={onMoveSessionOnly}
>
{t('sessions.sidebar.session.moveToWorktree.confirm.sessionOnly')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}