feat: move sessions to new worktrees
Add a root-session action that creates a generated worktree from the session directory's current branch, transfers uncommitted changes, and moves the parent session plus its descendants through OpenCode's control-plane API. Reuse existing project/worktree topology and quick-create behavior, keep the UI non-blocking, reconcile live and global session state across directories, and roll back partial moves and failed worktree creation safely. Split worktree bootstrap readiness into directory-created, git-ready, and setup-ready phases across web and VS Code. Session moves wait for Git readiness while existing setup-aware flows continue waiting for full setup completion, and worktree removal is serialized with active bootstrap tasks. Expose the move only for idle root sessions, show localized progress and explanatory tooltips in the sidebar, and keep pending/ready worktree metadata synchronized with authoritative session attachments to avoid stale setup indicators. Add coverage for control-plane payloads, session-state migration, bootstrap phase ordering and compatibility, removal races, progress metadata, and fast-ready attachment races.
This commit is contained in:
@@ -112,6 +112,7 @@ export const iconSpriteData = {
|
||||
"folder-open-fill": `<path d="M3 21C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H10.4142L12.4142 5H20C20.5523 5 21 5.44772 21 6V9H4V18.996L6 11H22.5L20.1894 20.2425C20.0781 20.6877 19.6781 21 19.2192 21H3Z" fill="currentColor"/>`,
|
||||
"folder-open": `<path d="M3 21C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H10.4142L12.4142 5H20C20.5523 5 21 5.44772 21 6V9H19V7H11.5858L9.58579 5H4V16.998L5.5 11H22.5L20.1894 20.2425C20.0781 20.6877 19.6781 21 19.2192 21H3ZM19.9384 13H7.06155L5.56155 19H18.4384L19.9384 13Z" fill="currentColor"/>`,
|
||||
"folder-received": `<path d="M22 13H20V7H11.5858L9.58579 5H4V19H13V21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H10.4142L12.4142 5H21C21.5523 5 22 5.44772 22 6V13ZM20 17H23V19H20V22.5L15 18L20 13.5V17Z" fill="currentColor"/>`,
|
||||
"folder-shared": `<path d="M22 13H20V7H11.5858L9.58579 5H4V19H13V21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H10.4142L12.4142 5H21C21.5523 5 22 5.44772 22 6V13ZM18 17V13.5L23 18L18 22.5V19H15V17H18Z" fill="currentColor"/>`,
|
||||
"folders": `<path d="M6 7V4C6 3.44772 6.44772 3 7 3H13.4142L15.4142 5H21C21.5523 5 22 5.44772 22 6V16C22 16.5523 21.5523 17 21 17H18V20C18 20.5523 17.5523 21 17 21H3C2.44772 21 2 20.5523 2 20V8C2 7.44772 2.44772 7 3 7H6ZM6 9H4V19H16V17H6V9ZM8 5V15H20V7H14.5858L12.5858 5H8Z" fill="currentColor"/>`,
|
||||
"fullscreen-exit": `<path d="M18 7H22V9H16V3H18V7ZM8 9H2V7H6V3H8V9ZM18 17V21H16V15H22V17H18ZM8 15V21H6V17H2V15H8Z" fill="currentColor"/>`,
|
||||
"fullscreen": `<path d="M8 3V5H4V9H2V3H8ZM2 21V15H4V19H8V21H2ZM22 21H16V19H20V15H22V21ZM22 9H20V5H16V3H22V9Z" fill="currentColor"/>`,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
- Active/hover row styling is text-first; selected sessions use primary text instead of background fills.
|
||||
- Archived groups are collapsed by default and support bulk deletion at group/folder level.
|
||||
- Session rows support compact inline dates in minimal mode and simplified metadata in default mode.
|
||||
- Root session menus can quickly create a worktree from the session directory's current branch and move the full session subtree there while idle.
|
||||
- New extractions in latest pass reduced local effect/callback bulk further:
|
||||
- project session list builders
|
||||
- folder cleanup sync
|
||||
|
||||
@@ -42,6 +42,7 @@ 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';
|
||||
|
||||
type Folder = { id: string; name: string; sessionIds: string[] };
|
||||
|
||||
@@ -346,6 +347,18 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
return out;
|
||||
}, []);
|
||||
|
||||
const collectNodeDescendantSessions = React.useCallback((root: SessionNode): Session[] => {
|
||||
const out: Session[] = [];
|
||||
const walk = (current: SessionNode) => {
|
||||
current.children.forEach((child) => {
|
||||
out.push(child.session);
|
||||
walk(child);
|
||||
});
|
||||
};
|
||||
walk(root);
|
||||
return out;
|
||||
}, []);
|
||||
|
||||
const [exportDialogOpen, setExportDialogOpen] = React.useState(false);
|
||||
const [exportIncludeSubtasks, setExportIncludeSubtasks] = React.useState(true);
|
||||
|
||||
@@ -354,6 +367,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
React.useCallback((state) => Boolean(state.sessionMemoryState.get(viewportSessionKey(session.id))?.isZombie), [session.id]),
|
||||
);
|
||||
const sessionStatus = useGlobalSessionStatus(session.id);
|
||||
const isMovingToWorktree = useIsSessionWorktreeMovePending(session.id);
|
||||
const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined);
|
||||
const sessionGoal = getSessionGoal(resolvedSession);
|
||||
const sessionGoalGlyph = sessionGoal ? (
|
||||
@@ -578,7 +592,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
const statusType = sessionStatus?.type ?? 'idle';
|
||||
const isStreaming = statusType === 'busy' || statusType === 'retry';
|
||||
const pendingPermissionCount = sessionPermissions.length;
|
||||
const showUnreadStatus = !isStreaming && needsAttention && !isActive;
|
||||
const showUnreadStatus = !isMovingToWorktree && !isStreaming && needsAttention && !isActive;
|
||||
const showStatusMarker = isStreaming || showUnreadStatus;
|
||||
const statusMarkerContent = isStreaming
|
||||
? (
|
||||
@@ -595,8 +609,8 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
title={t('sessions.sidebar.session.status.unread')}
|
||||
/>
|
||||
);
|
||||
const hideLeadingIndicatorOnHover = !alwaysShowActions && hasChildren && (showStatusMarker || isPinnedSession);
|
||||
const showPinnedMarker = isPinnedSession && !showStatusMarker;
|
||||
const hideLeadingIndicatorOnHover = !alwaysShowActions && hasChildren && (isMovingToWorktree || showStatusMarker || isPinnedSession);
|
||||
const showPinnedMarker = isPinnedSession && !isMovingToWorktree && !showStatusMarker;
|
||||
const pinnedMarkerContent = (
|
||||
<Icon
|
||||
name="pushpin"
|
||||
@@ -604,7 +618,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
aria-label={t('sessions.sidebar.session.status.pinned')}
|
||||
/>
|
||||
);
|
||||
const leadingIndicators = showStatusMarker || showPinnedMarker ? (
|
||||
const leadingIndicators = isMovingToWorktree || showStatusMarker || showPinnedMarker ? (
|
||||
<span
|
||||
className={cn(
|
||||
'pointer-events-none absolute left-0.5 inline-flex h-3.5 w-3.5 items-center justify-center transition-opacity',
|
||||
@@ -612,11 +626,16 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
hideLeadingIndicatorOnHover ? 'opacity-100 group-hover:opacity-0 group-focus-within:opacity-0' : '',
|
||||
)}
|
||||
>
|
||||
{showStatusMarker ? statusMarkerContent : null}
|
||||
{showPinnedMarker ? pinnedMarkerContent : null}
|
||||
{isMovingToWorktree ? (
|
||||
<Icon
|
||||
name="loader-4"
|
||||
className="h-3 w-3 animate-spin text-primary"
|
||||
aria-label={t('sessions.sidebar.session.status.movingToWorktree')}
|
||||
/>
|
||||
) : showStatusMarker ? statusMarkerContent : showPinnedMarker ? pinnedMarkerContent : null}
|
||||
</span>
|
||||
) : null;
|
||||
const hideChevronUntilHover = hasChildren && !alwaysShowActions && (showStatusMarker || isPinnedSession);
|
||||
const hideChevronUntilHover = hasChildren && !alwaysShowActions && (isMovingToWorktree || showStatusMarker || isPinnedSession);
|
||||
const subsessionChevron = hasChildren ? (
|
||||
<span
|
||||
role="button"
|
||||
@@ -839,6 +858,38 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
<Icon name="download" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.session.menu.exportMarkdown')}
|
||||
</Item>
|
||||
{!isSubtaskSession && !archivedBucket && !isVSCode ? (
|
||||
<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}
|
||||
{isMultiRunLikeSession ? (
|
||||
<Item onClick={() => setFusionDialogOpen(true)} className="[&>svg]:mr-1">
|
||||
<FusionIcon className="mr-1 h-4 w-4" />
|
||||
|
||||
@@ -357,6 +357,7 @@ export interface GitWorktreeValidationResult {
|
||||
|
||||
export interface GitWorktreeBootstrapStatus {
|
||||
status: 'pending' | 'ready' | 'failed';
|
||||
phase?: 'directory-created' | 'git-ready' | 'setup-ready';
|
||||
error: string | null;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
@@ -456,6 +456,12 @@ export const dict = {
|
||||
'sessions.sidebar.session.menu.copyLink': 'Copy link',
|
||||
'sessions.sidebar.session.menu.unshare': 'Unshare',
|
||||
'sessions.sidebar.session.menu.exportMarkdown': 'Export Markdown',
|
||||
'sessions.sidebar.session.menu.moveToWorktree': 'Move to new worktree',
|
||||
'sessions.sidebar.session.moveToWorktree.success': 'Session moved to a new worktree',
|
||||
'sessions.sidebar.session.moveToWorktree.failed': 'Failed to move session to a new worktree',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltip': 'Creates a new worktree from the current branch, transfers uncommitted changes, and moves this session and its sub-sessions there.',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'Available when the session is idle. Stop or wait for the current activity to finish.',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'This session is already being moved to a new worktree.',
|
||||
'sessions.sidebar.session.menu.runFusion': 'Run fusion',
|
||||
'sessions.sidebar.session.menu.openInSidePanel': 'Open in Side Panel',
|
||||
'sessions.sidebar.session.actions.openInEditor': 'Open in Editor',
|
||||
@@ -476,6 +482,7 @@ export const dict = {
|
||||
'sessions.sidebar.session.status.active': 'Session active',
|
||||
'sessions.sidebar.session.status.unread': 'Unread updates',
|
||||
'sessions.sidebar.session.status.pinned': 'Pinned session',
|
||||
'sessions.sidebar.session.status.movingToWorktree': 'Moving session to a new worktree',
|
||||
'sessions.sidebar.session.status.permissionRequired': 'Permission required',
|
||||
'sessions.sidebar.session.subsessions.collapse': 'Collapse subsessions',
|
||||
'sessions.sidebar.session.subsessions.expand': 'Expand subsessions',
|
||||
|
||||
@@ -457,6 +457,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.sidebar.session.menu.copyLink": "Copiar enlace",
|
||||
"sessions.sidebar.session.menu.unshare": "Dejar de compartir",
|
||||
"sessions.sidebar.session.menu.exportMarkdown": "Exportar Markdown",
|
||||
"sessions.sidebar.session.menu.moveToWorktree": "Mover a un worktree nuevo",
|
||||
"sessions.sidebar.session.moveToWorktree.success": "Sesión movida a un worktree nuevo",
|
||||
"sessions.sidebar.session.moveToWorktree.failed": "No se pudo mover la sesión a un worktree nuevo",
|
||||
"sessions.sidebar.session.moveToWorktree.tooltip": "Crea un worktree nuevo desde la rama actual, transfiere los cambios sin confirmar y mueve allí esta sesión y sus subsesiones.",
|
||||
"sessions.sidebar.session.moveToWorktree.tooltipBusy": "Disponible cuando la sesión está inactiva. Detén la actividad actual o espera a que termine.",
|
||||
"sessions.sidebar.session.moveToWorktree.tooltipMoving": "Esta sesión ya se está moviendo a un worktree nuevo.",
|
||||
"sessions.sidebar.session.menu.runFusion": "Ejecutar fusion",
|
||||
"sessions.sidebar.session.menu.openInSidePanel": "Abrir en panel lateral",
|
||||
"sessions.sidebar.session.actions.openInEditor": "Abrir en el editor",
|
||||
@@ -477,6 +483,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.sidebar.session.status.active": "Sesión activa",
|
||||
"sessions.sidebar.session.status.unread": "Actualizaciones no leídas",
|
||||
"sessions.sidebar.session.status.pinned": "Sesión anclada",
|
||||
"sessions.sidebar.session.status.movingToWorktree": "Moviendo la sesión a un worktree nuevo",
|
||||
"sessions.sidebar.session.status.permissionRequired": "Permiso requerido",
|
||||
"sessions.sidebar.session.subsessions.collapse": "Colapsar subsesiones",
|
||||
"sessions.sidebar.session.subsessions.expand": "Expandir subsesiones",
|
||||
|
||||
@@ -298,6 +298,12 @@ export const dict = {
|
||||
'sessions.sidebar.session.menu.copyLink': 'Copier le lien',
|
||||
'sessions.sidebar.session.menu.unshare': 'Annuler le partage',
|
||||
'sessions.sidebar.session.menu.exportMarkdown': 'Exporter le Markdown',
|
||||
'sessions.sidebar.session.menu.moveToWorktree': 'Déplacer vers un nouveau worktree',
|
||||
'sessions.sidebar.session.moveToWorktree.success': 'Session déplacée vers un nouveau worktree',
|
||||
'sessions.sidebar.session.moveToWorktree.failed': 'Impossible de déplacer la session vers un nouveau worktree',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltip': 'Crée un nouveau worktree depuis la branche actuelle, transfère les modifications non validées et y déplace cette session et ses sous-sessions.',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'Disponible lorsque la session est inactive. Arrêtez l’activité en cours ou attendez sa fin.',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'Cette session est déjà en cours de déplacement vers un nouveau worktree.',
|
||||
'sessions.sidebar.session.menu.runFusion': 'Exécuter la fusion',
|
||||
'sessions.sidebar.session.menu.openInSidePanel': 'Ouvrir dans le panneau latéral',
|
||||
'sessions.sidebar.session.actions.openInEditor': 'Ouvrir dans l\'éditeur',
|
||||
@@ -318,6 +324,7 @@ export const dict = {
|
||||
'sessions.sidebar.session.status.active': 'Session active',
|
||||
'sessions.sidebar.session.status.unread': 'Mises à jour non lues',
|
||||
'sessions.sidebar.session.status.pinned': 'Session épinglée',
|
||||
'sessions.sidebar.session.status.movingToWorktree': 'Déplacement de la session vers un nouveau worktree',
|
||||
'sessions.sidebar.session.status.permissionRequired': 'Autorisation requise',
|
||||
'sessions.sidebar.session.subsessions.collapse': 'Réduire les sous-sessions',
|
||||
'sessions.sidebar.session.subsessions.expand': 'Développer les sous-sessions',
|
||||
|
||||
@@ -457,6 +457,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.session.menu.copyLink': 'リンクをコピー',
|
||||
'sessions.sidebar.session.menu.unshare': '共有解除',
|
||||
'sessions.sidebar.session.menu.exportMarkdown': 'Markdownでエクスポート',
|
||||
'sessions.sidebar.session.menu.moveToWorktree': '新しいworktreeへ移動',
|
||||
'sessions.sidebar.session.moveToWorktree.success': 'セッションを新しいworktreeへ移動しました',
|
||||
'sessions.sidebar.session.moveToWorktree.failed': 'セッションを新しいworktreeへ移動できませんでした',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltip': '現在のブランチから新しいworktreeを作成し、未コミットの変更とこのセッションおよびサブセッションを移動します。',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'セッションがアイドル状態のときに利用できます。現在の処理を停止するか、完了するまでお待ちください。',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'このセッションはすでに新しいworktreeへ移動中です。',
|
||||
'sessions.sidebar.session.menu.runFusion': 'フュージョンを実行',
|
||||
'sessions.sidebar.session.menu.openInSidePanel': 'サイドパネルで開く',
|
||||
'sessions.sidebar.session.actions.openInEditor': 'エディターで開く',
|
||||
@@ -477,6 +483,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.session.status.active': 'セッションアクティブ',
|
||||
'sessions.sidebar.session.status.unread': '未読の更新',
|
||||
'sessions.sidebar.session.status.pinned': 'ピン留めされたセッション',
|
||||
'sessions.sidebar.session.status.movingToWorktree': 'セッションを新しいworktreeへ移動中',
|
||||
'sessions.sidebar.session.status.permissionRequired': '権限が必要です',
|
||||
'sessions.sidebar.session.subsessions.collapse': 'サブセッションを折りたたむ',
|
||||
'sessions.sidebar.session.subsessions.expand': 'サブセッションを展開',
|
||||
|
||||
@@ -457,6 +457,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.session.menu.copyLink': '링크 복사',
|
||||
'sessions.sidebar.session.menu.unshare': '공유 해제',
|
||||
'sessions.sidebar.session.menu.exportMarkdown': 'Markdown 내보내기',
|
||||
'sessions.sidebar.session.menu.moveToWorktree': '새 worktree로 이동',
|
||||
'sessions.sidebar.session.moveToWorktree.success': '세션을 새 worktree로 이동했습니다',
|
||||
'sessions.sidebar.session.moveToWorktree.failed': '세션을 새 worktree로 이동하지 못했습니다',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltip': '현재 브랜치에서 새 worktree를 만들고 커밋되지 않은 변경 사항과 이 세션 및 하위 세션을 이동합니다.',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipBusy': '세션이 유휴 상태일 때 사용할 수 있습니다. 현재 작업을 중지하거나 완료될 때까지 기다리세요.',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipMoving': '이 세션은 이미 새 worktree로 이동 중입니다.',
|
||||
'sessions.sidebar.session.menu.runFusion': 'fusion 실행',
|
||||
'sessions.sidebar.session.menu.openInSidePanel': '사이드 패널에서 열기',
|
||||
'sessions.sidebar.session.actions.openInEditor': '편집기에서 열기',
|
||||
@@ -477,6 +483,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.session.status.active': '활성 세션',
|
||||
'sessions.sidebar.session.status.unread': '읽지 않은 업데이트',
|
||||
'sessions.sidebar.session.status.pinned': '고정된 세션',
|
||||
'sessions.sidebar.session.status.movingToWorktree': '세션을 새 worktree로 이동하는 중',
|
||||
'sessions.sidebar.session.status.permissionRequired': '권한 필요',
|
||||
'sessions.sidebar.session.subsessions.collapse': '하위 세션 접기',
|
||||
'sessions.sidebar.session.subsessions.expand': '하위 세션 펼치기',
|
||||
|
||||
@@ -262,6 +262,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.session.menu.copyLink': 'Kopiuj link',
|
||||
'sessions.sidebar.session.menu.unshare': 'Cofnij udostępnienie',
|
||||
'sessions.sidebar.session.menu.exportMarkdown': 'Eksportuj Markdown',
|
||||
'sessions.sidebar.session.menu.moveToWorktree': 'Przenieś do nowego worktree',
|
||||
'sessions.sidebar.session.moveToWorktree.success': 'Sesja została przeniesiona do nowego worktree',
|
||||
'sessions.sidebar.session.moveToWorktree.failed': 'Nie udało się przenieść sesji do nowego worktree',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltip': 'Tworzy nowy worktree z bieżącej gałęzi, przenosi niezacommitowane zmiany oraz tę sesję i jej podsesje.',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'Dostępne, gdy sesja jest bezczynna. Zatrzymaj bieżącą aktywność lub poczekaj na jej zakończenie.',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'Ta sesja jest już przenoszona do nowego worktree.',
|
||||
'sessions.sidebar.session.menu.runFusion': 'Uruchom fusion',
|
||||
'sessions.sidebar.session.menu.openInSidePanel': 'Otwórz w panelu bocznym',
|
||||
'sessions.sidebar.session.actions.openInEditor': 'Otwórz w edytorze',
|
||||
@@ -477,6 +483,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.session.status.active': 'Sesja aktywna',
|
||||
'sessions.sidebar.session.status.unread': 'Nieprzeczytane aktualizacje',
|
||||
'sessions.sidebar.session.status.pinned': 'Przypięta sesja',
|
||||
'sessions.sidebar.session.status.movingToWorktree': 'Przenoszenie sesji do nowego worktree',
|
||||
'sessions.sidebar.session.status.permissionRequired': 'Wymagane uprawnienie',
|
||||
'sessions.sidebar.session.subsessions.collapse': 'Zwiń pod-sesje',
|
||||
'sessions.sidebar.session.subsessions.expand': 'Rozwiń pod-sesje',
|
||||
|
||||
@@ -457,6 +457,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.sidebar.session.menu.copyLink": "Copiar link",
|
||||
"sessions.sidebar.session.menu.unshare": "Parar de compartilhar",
|
||||
"sessions.sidebar.session.menu.exportMarkdown": "Exportar Markdown",
|
||||
"sessions.sidebar.session.menu.moveToWorktree": "Mover para um novo worktree",
|
||||
"sessions.sidebar.session.moveToWorktree.success": "Sessão movida para um novo worktree",
|
||||
"sessions.sidebar.session.moveToWorktree.failed": "Não foi possível mover a sessão para um novo worktree",
|
||||
"sessions.sidebar.session.moveToWorktree.tooltip": "Cria um novo worktree a partir da branch atual, transfere alterações não commitadas e move esta sessão e suas subsessões para lá.",
|
||||
"sessions.sidebar.session.moveToWorktree.tooltipBusy": "Disponível quando a sessão está ociosa. Interrompa a atividade atual ou aguarde sua conclusão.",
|
||||
"sessions.sidebar.session.moveToWorktree.tooltipMoving": "Esta sessão já está sendo movida para um novo worktree.",
|
||||
"sessions.sidebar.session.menu.runFusion": "Executar fusion",
|
||||
"sessions.sidebar.session.menu.openInSidePanel": "Abrir no painel lateral",
|
||||
"sessions.sidebar.session.actions.openInEditor": "Abrir no editor",
|
||||
@@ -477,6 +483,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.sidebar.session.status.active": "Sessão ativa",
|
||||
"sessions.sidebar.session.status.unread": "Atualizações não lidas",
|
||||
"sessions.sidebar.session.status.pinned": "Sessão fixada",
|
||||
"sessions.sidebar.session.status.movingToWorktree": "Movendo a sessão para um novo worktree",
|
||||
"sessions.sidebar.session.status.permissionRequired": "Permissão obrigatória",
|
||||
"sessions.sidebar.session.subsessions.collapse": "Recolher subsessões",
|
||||
"sessions.sidebar.session.subsessions.expand": "Expandir subsessões",
|
||||
|
||||
@@ -457,6 +457,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.sidebar.session.menu.copyLink": "Копіювати посилання",
|
||||
"sessions.sidebar.session.menu.unshare": "Скасувати спільний доступ",
|
||||
"sessions.sidebar.session.menu.exportMarkdown": "Експорт Markdown",
|
||||
"sessions.sidebar.session.menu.moveToWorktree": "Перенести в новий worktree",
|
||||
"sessions.sidebar.session.moveToWorktree.success": "Сесію перенесено в новий worktree",
|
||||
"sessions.sidebar.session.moveToWorktree.failed": "Не вдалося перенести сесію в новий worktree",
|
||||
"sessions.sidebar.session.moveToWorktree.tooltip": "Створює новий worktree з поточної гілки, переносить незакомічені зміни та переміщує туди цю сесію і її підсесії.",
|
||||
"sessions.sidebar.session.moveToWorktree.tooltipBusy": "Доступно, коли сесія неактивна. Зупиніть поточну активність або дочекайтеся її завершення.",
|
||||
"sessions.sidebar.session.moveToWorktree.tooltipMoving": "Ця сесія вже переноситься в новий worktree.",
|
||||
"sessions.sidebar.session.menu.runFusion": "Запустити fusion",
|
||||
"sessions.sidebar.session.menu.openInSidePanel": "Відкрити на бічній панелі",
|
||||
"sessions.sidebar.session.actions.openInEditor": "Відкрити в редакторі",
|
||||
@@ -477,6 +483,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.sidebar.session.status.active": "Сесія активний",
|
||||
"sessions.sidebar.session.status.unread": "Непрочитані оновлення",
|
||||
"sessions.sidebar.session.status.pinned": "Закріплений сесія",
|
||||
"sessions.sidebar.session.status.movingToWorktree": "Перенесення сесії в новий worktree",
|
||||
"sessions.sidebar.session.status.permissionRequired": "Потрібен дозвіл",
|
||||
"sessions.sidebar.session.subsessions.collapse": "Згорнути підсесії",
|
||||
"sessions.sidebar.session.subsessions.expand": "Розгорнути підсесії",
|
||||
|
||||
@@ -457,6 +457,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.session.menu.copyLink': '复制链接',
|
||||
'sessions.sidebar.session.menu.unshare': '取消分享',
|
||||
'sessions.sidebar.session.menu.exportMarkdown': '导出 Markdown',
|
||||
'sessions.sidebar.session.menu.moveToWorktree': '移至新工作树',
|
||||
'sessions.sidebar.session.moveToWorktree.success': '会话已移至新工作树',
|
||||
'sessions.sidebar.session.moveToWorktree.failed': '无法将会话移至新工作树',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltip': '从当前分支创建新工作树,转移未提交的更改,并将此会话及其子会话移至其中。',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipBusy': '仅在会话空闲时可用。请停止当前活动或等待其完成。',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipMoving': '此会话已在移至新工作树。',
|
||||
'sessions.sidebar.session.menu.runFusion': '运行融合',
|
||||
'sessions.sidebar.session.menu.openInSidePanel': '在侧边面板中打开',
|
||||
'sessions.sidebar.session.actions.openInEditor': '在编辑器中打开',
|
||||
@@ -477,6 +483,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.session.status.active': '会话活跃中',
|
||||
'sessions.sidebar.session.status.unread': '有未读更新',
|
||||
'sessions.sidebar.session.status.pinned': '已置顶会话',
|
||||
'sessions.sidebar.session.status.movingToWorktree': '正在将会话移至新工作树',
|
||||
'sessions.sidebar.session.status.permissionRequired': '需要权限',
|
||||
'sessions.sidebar.session.subsessions.collapse': '折叠子会话',
|
||||
'sessions.sidebar.session.subsessions.expand': '展开子会话',
|
||||
|
||||
@@ -470,6 +470,12 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.session.menu.copyLink': '複製連結',
|
||||
'sessions.sidebar.session.menu.unshare': '取消分享',
|
||||
'sessions.sidebar.session.menu.exportMarkdown': '匯出 Markdown',
|
||||
'sessions.sidebar.session.menu.moveToWorktree': '移至新工作樹',
|
||||
'sessions.sidebar.session.moveToWorktree.success': '工作階段已移至新工作樹',
|
||||
'sessions.sidebar.session.moveToWorktree.failed': '無法將工作階段移至新工作樹',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltip': '從目前分支建立新工作樹,轉移未提交的變更,並將此工作階段及其子工作階段移至其中。',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipBusy': '僅在工作階段閒置時可用。請停止目前活動或等待其完成。',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipMoving': '此工作階段已在移至新工作樹。',
|
||||
'sessions.sidebar.session.menu.runFusion': '執行 fusion',
|
||||
'sessions.sidebar.session.menu.openInSidePanel': '在側邊面板中開啟',
|
||||
'sessions.sidebar.session.actions.openInEditor': '在編輯器中開啟',
|
||||
@@ -490,6 +496,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.session.status.active': '會話活躍中',
|
||||
'sessions.sidebar.session.status.unread': '有未讀更新',
|
||||
'sessions.sidebar.session.status.pinned': '已釘選會話',
|
||||
'sessions.sidebar.session.status.movingToWorktree': '正在將會話移至新工作樹',
|
||||
'sessions.sidebar.session.status.permissionRequired': '需要權限',
|
||||
'sessions.sidebar.session.subsessions.collapse': '摺疊子會話',
|
||||
'sessions.sidebar.session.subsessions.expand': '展開子會話',
|
||||
|
||||
@@ -26,8 +26,8 @@ import {
|
||||
resolvePendingDraftWorktreeRequest,
|
||||
} from '@/lib/worktrees/pendingDraftWorktree';
|
||||
import { waitForWorktreeBootstrap } from '@/lib/worktrees/worktreeBootstrap';
|
||||
|
||||
const normalizePath = (value: string): string => value.replace(/\\/g, '/').replace(/\/+$/, '') || value;
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
import { resolveProjectForDirectory } from '@/lib/projectResolution';
|
||||
|
||||
const waitForWorktreeBootstrapIfEnabled = async (project: ProjectRef, directory: string): Promise<void> => {
|
||||
if (await getWorktreeSetupWaitEnabled(project)) {
|
||||
@@ -35,29 +35,51 @@ const waitForWorktreeBootstrapIfEnabled = async (project: ProjectRef, directory:
|
||||
}
|
||||
};
|
||||
|
||||
const resolveProjectRef = (directory: string): ProjectRef | null => {
|
||||
const normalized = normalizePath(directory);
|
||||
export const resolveProjectRef = (directory: string): ProjectRef | null => {
|
||||
const projects = useProjectsStore.getState().projects;
|
||||
if (projects.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const normalizedDirectory = normalizePath(directory);
|
||||
if (!normalizedDirectory) return null;
|
||||
|
||||
const activeProject = useProjectsStore.getState().getActiveProject();
|
||||
if (activeProject?.path) {
|
||||
const activePath = normalizePath(activeProject.path);
|
||||
if (normalized === activePath || normalized.startsWith(`${activePath}/`)) {
|
||||
return { id: activeProject.id, path: activeProject.path };
|
||||
let project: (typeof projects)[number] | null = null;
|
||||
let matchedWorktreePathLength = -1;
|
||||
for (const [projectPath, worktrees] of useSessionUIStore.getState().availableWorktreesByProject) {
|
||||
for (const worktree of worktrees) {
|
||||
const worktreePath = normalizePath(worktree.path);
|
||||
if (!worktreePath) continue;
|
||||
if (normalizedDirectory !== worktreePath && !normalizedDirectory.startsWith(`${worktreePath}/`)) continue;
|
||||
if (worktreePath.length <= matchedWorktreePathLength) continue;
|
||||
|
||||
const ownerPaths = [worktree.projectDirectory, projectPath];
|
||||
for (const ownerPath of ownerPaths) {
|
||||
const owner = projects.find((candidate) => normalizePath(candidate.path) === normalizePath(ownerPath))
|
||||
?? resolveProjectForDirectory(projects, ownerPath);
|
||||
if (!owner) continue;
|
||||
project = owner;
|
||||
matchedWorktreePathLength = worktreePath.length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const matches = projects.filter((project) => {
|
||||
const projectPath = normalizePath(project.path);
|
||||
return normalized === projectPath || normalized.startsWith(`${projectPath}/`);
|
||||
project ??= resolveProjectForDirectory(projects, normalizedDirectory);
|
||||
return project ? { id: project.id, path: project.path } : null;
|
||||
};
|
||||
|
||||
export const createQuickWorktree = async (
|
||||
project: ProjectRef,
|
||||
options: { preferredName?: string; startRef?: string } = {},
|
||||
) => {
|
||||
const preferredName = options.preferredName ?? generateBranchName();
|
||||
const setupCommands = await getWorktreeSetupCommands(project);
|
||||
return createWorktreeWithDefaults(project, {
|
||||
preferredName,
|
||||
mode: 'new',
|
||||
branchName: preferredName,
|
||||
worktreeName: preferredName,
|
||||
startRef: options.startRef,
|
||||
setupCommands,
|
||||
returnAfterDirectoryCreated: true,
|
||||
});
|
||||
|
||||
const match = matches.sort((a, b) => normalizePath(b.path).length - normalizePath(a.path).length)[0];
|
||||
|
||||
return match ? { id: match.id, path: match.path } : null;
|
||||
};
|
||||
|
||||
// Track if a worktree creation flow is already running
|
||||
@@ -233,15 +255,7 @@ const createInstantWorktreeDraft = async (options?: {
|
||||
useDirectoryStore.getState().setDirectory(preview.path, { showOverlay: false });
|
||||
}
|
||||
|
||||
const setupCommands = await getWorktreeSetupCommands(projectRef);
|
||||
const metadata = await createWorktreeWithDefaults(projectRef, {
|
||||
preferredName,
|
||||
mode: 'new',
|
||||
branchName: preferredName,
|
||||
worktreeName: preferredName,
|
||||
setupCommands,
|
||||
returnAfterDirectoryCreated: true,
|
||||
});
|
||||
const metadata = await createQuickWorktree(projectRef, { preferredName });
|
||||
|
||||
resolvePendingDraftWorktreeRequest(pendingRequestId, metadata.path);
|
||||
useSessionUIStore.getState().overrideNewSessionDraftTarget({
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { toast } from '@/components/ui';
|
||||
import { getGitStatus } from '@/lib/gitApi';
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
import { createQuickWorktree, resolveProjectRef } from '@/lib/worktreeSessionCreator';
|
||||
import { getLatestWorktreeMetadata, removeProjectWorktree, type ProjectRef } from '@/lib/worktrees/worktreeManager';
|
||||
import { refreshGlobalSessionsForDirectories } from '@/stores/useGlobalSessionsStore';
|
||||
import { moveSessionToDirectory } from '@/sync/session-actions';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { getDirectoryState } from '@/sync/sync-refs';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { waitForWorktreeGitReady } from '@/lib/worktrees/worktreeBootstrap';
|
||||
import { create } from 'zustand';
|
||||
|
||||
const useSessionMoveState = create<{ pendingSessionIds: Set<string> }>(() => ({
|
||||
pendingSessionIds: new Set(),
|
||||
}));
|
||||
|
||||
export const useIsSessionWorktreeMovePending = (sessionId: string): boolean =>
|
||||
useSessionMoveState((state) => state.pendingSessionIds.has(sessionId));
|
||||
|
||||
const setSessionMovePending = (sessionId: string, pending: boolean): void => {
|
||||
useSessionMoveState.setState((state) => {
|
||||
if (state.pendingSessionIds.has(sessionId) === pending) return state;
|
||||
const pendingSessionIds = new Set(state.pendingSessionIds);
|
||||
if (pending) pendingSessionIds.add(sessionId);
|
||||
else pendingSessionIds.delete(sessionId);
|
||||
return { pendingSessionIds };
|
||||
});
|
||||
};
|
||||
|
||||
const resolveSourceBranch = async (directory: string, projectDirectory: string): Promise<string> => {
|
||||
try {
|
||||
const status = await getGitStatus(directory, { mode: 'light' });
|
||||
const currentBranch = status.current?.trim();
|
||||
if (currentBranch) return currentBranch;
|
||||
} catch {
|
||||
// Fall back to discovered worktree metadata below.
|
||||
}
|
||||
|
||||
const normalizedDirectory = normalizePath(directory);
|
||||
const normalizedProjectDirectory = normalizePath(projectDirectory) ?? projectDirectory;
|
||||
const worktrees = useSessionUIStore.getState().availableWorktreesByProject;
|
||||
const metadata = (worktrees.get(normalizedProjectDirectory) ?? worktrees.get(projectDirectory) ?? [])
|
||||
.find((worktree) => normalizePath(worktree.path) === normalizedDirectory);
|
||||
const mappedBranch = metadata?.branch?.trim();
|
||||
if (mappedBranch) return mappedBranch;
|
||||
|
||||
throw new Error('Unable to determine the current branch');
|
||||
};
|
||||
|
||||
const assertSessionsIdle = (sessions: Session[], sourceDirectory: string): void => {
|
||||
const directoryState = getDirectoryState(sourceDirectory);
|
||||
if (!directoryState) throw new Error('Session status is unavailable');
|
||||
|
||||
const statuses = directoryState.session_status;
|
||||
const hasActiveSession = sessions.some((session) => {
|
||||
const status = statuses[session.id]?.type;
|
||||
return status === 'busy' || status === 'retry';
|
||||
});
|
||||
if (hasActiveSession) throw new Error('Session is not idle');
|
||||
};
|
||||
|
||||
const rollbackMovedSessions = async (
|
||||
sessions: Session[],
|
||||
rootSessionId: string,
|
||||
sourceDirectory: string,
|
||||
worktreeDirectory: string,
|
||||
previousMetadata: ReadonlyMap<string, WorktreeMetadata | undefined>,
|
||||
): Promise<unknown[]> => {
|
||||
const failures: unknown[] = [];
|
||||
for (const session of [...sessions].reverse()) {
|
||||
try {
|
||||
await moveSessionToDirectory(
|
||||
session,
|
||||
worktreeDirectory,
|
||||
sourceDirectory,
|
||||
session.id === rootSessionId,
|
||||
);
|
||||
useSessionUIStore.getState().setWorktreeMetadata(session.id, previousMetadata.get(session.id) ?? null);
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
};
|
||||
|
||||
const removeFailedWorktree = async (
|
||||
project: ProjectRef,
|
||||
worktree: WorktreeMetadata,
|
||||
moveError: unknown,
|
||||
): Promise<never> => {
|
||||
try {
|
||||
await removeProjectWorktree(project, worktree, { deleteLocalBranch: true });
|
||||
} catch {
|
||||
const message = moveError instanceof Error ? moveError.message : String(moveError);
|
||||
throw new Error(`Session move failed and the new worktree could not be removed: ${message}`);
|
||||
}
|
||||
throw moveError;
|
||||
};
|
||||
|
||||
const moveSessionTreeToQuickWorktree = async (input: {
|
||||
root: Session;
|
||||
descendants: Session[];
|
||||
sourceDirectory: string;
|
||||
}): Promise<string> => {
|
||||
if (useSessionMoveState.getState().pendingSessionIds.has(input.root.id)) {
|
||||
throw new Error('Session move already in progress');
|
||||
}
|
||||
setSessionMovePending(input.root.id, true);
|
||||
|
||||
try {
|
||||
const project = resolveProjectRef(input.sourceDirectory);
|
||||
if (!project) throw new Error('Unable to find the project for this session');
|
||||
|
||||
const sessions = [input.root, ...input.descendants];
|
||||
const previousMetadata = new Map(
|
||||
sessions.map((session) => [
|
||||
session.id,
|
||||
useSessionUIStore.getState().getWorktreeMetadata(session.id),
|
||||
]),
|
||||
);
|
||||
assertSessionsIdle(sessions, input.sourceDirectory);
|
||||
|
||||
const sourceBranch = await resolveSourceBranch(input.sourceDirectory, project.path);
|
||||
const worktree = await createQuickWorktree(project, { startRef: sourceBranch });
|
||||
|
||||
const moved: Session[] = [];
|
||||
try {
|
||||
await waitForWorktreeGitReady(worktree.path);
|
||||
// Branch/status discovery and worktree creation can take long enough for a
|
||||
// session to start running, so verify the whole tree again before moving.
|
||||
assertSessionsIdle(sessions, input.sourceDirectory);
|
||||
for (const [index, session] of sessions.entries()) {
|
||||
// Transfer the checkout changes once with the root. Descendants only
|
||||
// need their execution location updated.
|
||||
await moveSessionToDirectory(session, input.sourceDirectory, worktree.path, index === 0);
|
||||
moved.push(session);
|
||||
useSessionUIStore.getState().setWorktreeMetadata(session.id, getLatestWorktreeMetadata(worktree));
|
||||
}
|
||||
} catch (error) {
|
||||
const rollbackFailures = await rollbackMovedSessions(
|
||||
moved,
|
||||
input.root.id,
|
||||
input.sourceDirectory,
|
||||
worktree.path,
|
||||
previousMetadata,
|
||||
);
|
||||
if (rollbackFailures.length > 0) {
|
||||
throw new Error(`Session move partially failed and could not be fully rolled back: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
return removeFailedWorktree(project, worktree, error);
|
||||
}
|
||||
|
||||
try {
|
||||
await refreshGlobalSessionsForDirectories([input.sourceDirectory, worktree.path]);
|
||||
} catch (error) {
|
||||
// Direct action updates already reconciled both stores. Keep the move
|
||||
// successful if this best-effort authoritative refresh is unavailable.
|
||||
console.warn('[session-worktree-move] Failed to refresh moved sessions', error);
|
||||
}
|
||||
return worktree.path;
|
||||
} finally {
|
||||
setSessionMovePending(input.root.id, false);
|
||||
}
|
||||
};
|
||||
|
||||
export const startSessionTreeWorktreeMove = (input: {
|
||||
root: Session;
|
||||
descendants: Session[];
|
||||
sourceDirectory: string;
|
||||
successMessage: string;
|
||||
failureMessage: string;
|
||||
}): void => {
|
||||
void moveSessionTreeToQuickWorktree(input)
|
||||
.then(() => toast.success(input.successMessage))
|
||||
.catch((error) => toast.error(input.failureMessage, {
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
};
|
||||
@@ -1,11 +1,13 @@
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
import type { GitWorktreeBootstrapStatus } from '@/lib/api/types';
|
||||
|
||||
const bootstrapStatusCalls: string[] = [];
|
||||
let bootstrapStatusResult: { status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number } = {
|
||||
let bootstrapStatusResult: GitWorktreeBootstrapStatus = {
|
||||
status: 'ready',
|
||||
error: null,
|
||||
updatedAt: 1,
|
||||
};
|
||||
let getBootstrapStatus = (): Promise<GitWorktreeBootstrapStatus> => Promise.resolve(bootstrapStatusResult);
|
||||
const toastErrors: Array<{ title: string; description?: string }> = [];
|
||||
|
||||
mock.module('@/components/ui', () => ({
|
||||
@@ -29,7 +31,7 @@ mock.module('@/contexts/runtimeAPIRegistry', () => ({
|
||||
worktree: {
|
||||
bootstrapStatus: (directory: string) => {
|
||||
bootstrapStatusCalls.push(directory);
|
||||
return Promise.resolve(bootstrapStatusResult);
|
||||
return getBootstrapStatus();
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -39,7 +41,7 @@ mock.module('@/contexts/runtimeAPIRegistry', () => ({
|
||||
mock.module('@/lib/gitApiHttp', () => ({
|
||||
getGitWorktreeBootstrapStatus: (directory: string) => {
|
||||
bootstrapStatusCalls.push(directory);
|
||||
return Promise.resolve(bootstrapStatusResult);
|
||||
return getBootstrapStatus();
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -47,8 +49,10 @@ const {
|
||||
clearWorktreeBootstrapState,
|
||||
getWorktreeBootstrapState,
|
||||
markWorktreeBootstrapPending,
|
||||
setWorktreeBootstrapState,
|
||||
startWorktreeBootstrapWatcher,
|
||||
waitForWorktreeBootstrap,
|
||||
waitForWorktreeGitReady,
|
||||
} = await import('./worktreeBootstrap');
|
||||
|
||||
const waitFor = async (predicate: () => boolean): Promise<void> => {
|
||||
@@ -64,6 +68,7 @@ describe('worktreeBootstrap.waitForWorktreeBootstrap', () => {
|
||||
bootstrapStatusCalls.length = 0;
|
||||
toastErrors.length = 0;
|
||||
bootstrapStatusResult = { status: 'ready', error: null, updatedAt: 1 };
|
||||
getBootstrapStatus = () => Promise.resolve(bootstrapStatusResult);
|
||||
clearWorktreeBootstrapState('/repo');
|
||||
clearWorktreeBootstrapState('/repo-wt');
|
||||
});
|
||||
@@ -82,6 +87,108 @@ describe('worktreeBootstrap.waitForWorktreeBootstrap', () => {
|
||||
expect(bootstrapStatusCalls).toEqual(['/repo-wt']);
|
||||
});
|
||||
|
||||
test('git-ready wait does not wait for setup-ready', async () => {
|
||||
setWorktreeBootstrapState('/repo-wt', {
|
||||
status: 'pending',
|
||||
phase: 'git-ready',
|
||||
error: null,
|
||||
updatedAt: 1,
|
||||
});
|
||||
|
||||
await waitForWorktreeGitReady('/repo-wt');
|
||||
|
||||
expect(bootstrapStatusCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test('git-ready wait remains compatible with ready responses that omit phase', async () => {
|
||||
markWorktreeBootstrapPending('/repo-wt');
|
||||
|
||||
await waitForWorktreeGitReady('/repo-wt');
|
||||
|
||||
expect(bootstrapStatusCalls).toEqual(['/repo-wt']);
|
||||
expect(getWorktreeBootstrapState('/repo-wt')).toEqual(bootstrapStatusResult);
|
||||
});
|
||||
|
||||
test('dedupes concurrent waiters for the same phase', async () => {
|
||||
let resolveStatus!: (status: GitWorktreeBootstrapStatus) => void;
|
||||
getBootstrapStatus = () => new Promise((resolve) => {
|
||||
resolveStatus = resolve;
|
||||
});
|
||||
markWorktreeBootstrapPending('/repo-wt');
|
||||
|
||||
const first = waitForWorktreeGitReady('/repo-wt');
|
||||
const second = waitForWorktreeGitReady('/repo-wt');
|
||||
await waitFor(() => bootstrapStatusCalls.length === 1);
|
||||
resolveStatus({ status: 'pending', phase: 'git-ready', error: null, updatedAt: 2 });
|
||||
|
||||
await Promise.all([first, second]);
|
||||
expect(bootstrapStatusCalls).toEqual(['/repo-wt']);
|
||||
});
|
||||
|
||||
test('does not let a cleared waiter restore stale state or remove a replacement waiter', async () => {
|
||||
const statusResolvers: Array<(status: GitWorktreeBootstrapStatus) => void> = [];
|
||||
getBootstrapStatus = () => new Promise((resolve) => {
|
||||
statusResolvers.push(resolve);
|
||||
});
|
||||
markWorktreeBootstrapPending('/repo-wt');
|
||||
|
||||
const staleWaiter = waitForWorktreeGitReady('/repo-wt');
|
||||
await waitFor(() => statusResolvers.length === 1);
|
||||
clearWorktreeBootstrapState('/repo-wt');
|
||||
markWorktreeBootstrapPending('/repo-wt');
|
||||
const replacementWaiter = waitForWorktreeGitReady('/repo-wt');
|
||||
await waitFor(() => statusResolvers.length === 2);
|
||||
|
||||
statusResolvers[0]({ status: 'pending', phase: 'git-ready', error: null, updatedAt: 2 });
|
||||
await expect(staleWaiter).rejects.toThrow('cancelled');
|
||||
expect(getWorktreeBootstrapState('/repo-wt')?.phase).toBe('directory-created');
|
||||
|
||||
statusResolvers[1]({ status: 'pending', phase: 'git-ready', error: null, updatedAt: 3 });
|
||||
await replacementWaiter;
|
||||
expect(getWorktreeBootstrapState('/repo-wt')?.phase).toBe('git-ready');
|
||||
expect(bootstrapStatusCalls).toEqual(['/repo-wt', '/repo-wt']);
|
||||
});
|
||||
|
||||
test('does not regress a newer phase when concurrent polls resolve out of order', async () => {
|
||||
const statusResolvers: Array<(status: GitWorktreeBootstrapStatus) => void> = [];
|
||||
getBootstrapStatus = () => new Promise((resolve) => {
|
||||
statusResolvers.push(resolve);
|
||||
});
|
||||
markWorktreeBootstrapPending('/repo-wt');
|
||||
|
||||
startWorktreeBootstrapWatcher('/repo-wt', { pollIntervalMs: 1000 });
|
||||
await waitFor(() => statusResolvers.length === 1);
|
||||
const gitReadyWaiter = waitForWorktreeGitReady('/repo-wt');
|
||||
await waitFor(() => statusResolvers.length === 2);
|
||||
|
||||
statusResolvers[1]({ status: 'pending', phase: 'git-ready', error: null, updatedAt: 3 });
|
||||
await gitReadyWaiter;
|
||||
statusResolvers[0]({ status: 'pending', phase: 'directory-created', error: null, updatedAt: 2 });
|
||||
await Promise.resolve();
|
||||
|
||||
expect(getWorktreeBootstrapState('/repo-wt')?.phase).toBe('git-ready');
|
||||
clearWorktreeBootstrapState('/repo-wt');
|
||||
});
|
||||
|
||||
test('full bootstrap wait continues through git-ready until setup-ready', async () => {
|
||||
setWorktreeBootstrapState('/repo-wt', {
|
||||
status: 'pending',
|
||||
phase: 'git-ready',
|
||||
error: null,
|
||||
updatedAt: 1,
|
||||
});
|
||||
bootstrapStatusResult = {
|
||||
status: 'ready',
|
||||
phase: 'setup-ready',
|
||||
error: null,
|
||||
updatedAt: 2,
|
||||
};
|
||||
|
||||
await waitForWorktreeBootstrap('/repo-wt');
|
||||
|
||||
expect(bootstrapStatusCalls).toEqual(['/repo-wt']);
|
||||
});
|
||||
|
||||
test('background watcher polls pending worktrees without blocking', async () => {
|
||||
markWorktreeBootstrapPending('/repo-wt');
|
||||
const readyStatuses: Array<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> = [];
|
||||
|
||||
@@ -14,10 +14,66 @@ const POLL_INTERVAL_MS = 250;
|
||||
const normalizePath = (value: string): string => value.replace(/\\/g, '/').replace(/\/+$/, '') || value;
|
||||
|
||||
const state = new Map<string, WorktreeBootstrapState>();
|
||||
type WorktreeBootstrapTarget = 'git-ready' | 'setup-ready';
|
||||
|
||||
const waiters = new Map<string, Promise<void>>();
|
||||
const watchers = new Map<string, { cancelled: boolean; promise: Promise<void> }>();
|
||||
const lifecycleVersions = new Map<string, number>();
|
||||
let nextLifecycleVersion = 0;
|
||||
const watchers = new Map<string, { cancelled: boolean; lifecycleVersion: number }>();
|
||||
|
||||
const getKey = (directory: string): string => normalizePath(directory);
|
||||
const getWaiterKey = (key: string, target: WorktreeBootstrapTarget): string => `${key}\n${target}`;
|
||||
|
||||
const startLifecycle = (key: string): void => {
|
||||
const watcher = watchers.get(key);
|
||||
if (watcher) {
|
||||
watcher.cancelled = true;
|
||||
watchers.delete(key);
|
||||
}
|
||||
|
||||
waiters.delete(getWaiterKey(key, 'git-ready'));
|
||||
waiters.delete(getWaiterKey(key, 'setup-ready'));
|
||||
|
||||
const version = ++nextLifecycleVersion;
|
||||
lifecycleVersions.set(key, version);
|
||||
};
|
||||
|
||||
const isCurrentLifecycle = (key: string, version: number): boolean => lifecycleVersions.get(key) === version;
|
||||
|
||||
const phaseRank = (phase: GitWorktreeBootstrapStatus['phase']): number => {
|
||||
switch (phase) {
|
||||
case 'setup-ready':
|
||||
return 2;
|
||||
case 'git-ready':
|
||||
return 1;
|
||||
case 'directory-created':
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const storePolledState = (
|
||||
key: string,
|
||||
next: WorktreeBootstrapState,
|
||||
lifecycleVersion: number,
|
||||
): WorktreeBootstrapState | null => {
|
||||
if (!isCurrentLifecycle(key, lifecycleVersion)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = state.get(key);
|
||||
const wouldRegressReadyState = current?.status === 'ready' && next.status === 'pending';
|
||||
const wouldRegressPendingPhase = current?.status === 'pending'
|
||||
&& next.status === 'pending'
|
||||
&& phaseRank(next.phase) < phaseRank(current.phase);
|
||||
|
||||
if (wouldRegressReadyState || wouldRegressPendingPhase) {
|
||||
return current;
|
||||
}
|
||||
|
||||
state.set(key, next);
|
||||
return next;
|
||||
};
|
||||
|
||||
const getGitWorktreeBootstrapStatus = async (directory: string): Promise<GitWorktreeBootstrapStatus> => {
|
||||
const runtimeGit = getRegisteredRuntimeAPIs()?.git;
|
||||
@@ -35,8 +91,10 @@ export const markWorktreeBootstrapPending = (directory: string): void => {
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
startLifecycle(key);
|
||||
state.set(key, {
|
||||
status: 'pending',
|
||||
phase: 'directory-created',
|
||||
error: null,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
@@ -47,13 +105,9 @@ export const clearWorktreeBootstrapState = (directory: string): void => {
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
const watcher = watchers.get(key);
|
||||
if (watcher) {
|
||||
watcher.cancelled = true;
|
||||
watchers.delete(key);
|
||||
}
|
||||
startLifecycle(key);
|
||||
state.delete(key);
|
||||
waiters.delete(key);
|
||||
lifecycleVersions.delete(key);
|
||||
};
|
||||
|
||||
export const setWorktreeBootstrapState = (directory: string, next: WorktreeBootstrapState): void => {
|
||||
@@ -61,10 +115,8 @@ export const setWorktreeBootstrapState = (directory: string, next: WorktreeBoots
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
startLifecycle(key);
|
||||
state.set(key, next);
|
||||
if (next.status !== 'pending') {
|
||||
waiters.delete(key);
|
||||
}
|
||||
};
|
||||
|
||||
export const getWorktreeBootstrapState = (directory: string): WorktreeBootstrapState | null => {
|
||||
@@ -97,19 +149,34 @@ const markBootstrapFailed = (
|
||||
return failed;
|
||||
};
|
||||
|
||||
const pollWorktreeBootstrapUntilSettled = async (directory: string, timeoutMs: number): Promise<void> => {
|
||||
const hasReachedTarget = (status: GitWorktreeBootstrapStatus, target: WorktreeBootstrapTarget): boolean => {
|
||||
if (status.status === 'ready') return true;
|
||||
if (target === 'git-ready' && (status.phase === 'git-ready' || status.phase === 'setup-ready')) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const pollWorktreeBootstrapUntilSettled = async (
|
||||
directory: string,
|
||||
key: string,
|
||||
lifecycleVersion: number,
|
||||
timeoutMs: number,
|
||||
target: WorktreeBootstrapTarget,
|
||||
): Promise<void> => {
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const result = await getGitWorktreeBootstrapStatus(directory);
|
||||
setWorktreeBootstrapState(directory, result);
|
||||
const current = storePolledState(key, result, lifecycleVersion);
|
||||
if (!current) {
|
||||
throw new Error('Worktree bootstrap wait was cancelled');
|
||||
}
|
||||
|
||||
if (result.status === 'ready') {
|
||||
if (hasReachedTarget(current, target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === 'failed') {
|
||||
throw new Error(result.error || 'Worktree bootstrap failed');
|
||||
if (current.status === 'failed') {
|
||||
throw new Error(current.error || 'Worktree bootstrap failed');
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
@@ -121,7 +188,8 @@ const pollWorktreeBootstrapUntilSettled = async (directory: string, timeoutMs: n
|
||||
|
||||
const pollWorktreeBootstrapInBackground = async (
|
||||
directory: string,
|
||||
watcher: { cancelled: boolean },
|
||||
key: string,
|
||||
watcher: { cancelled: boolean; lifecycleVersion: number },
|
||||
timeoutMs: number,
|
||||
pollIntervalMs: number,
|
||||
onFailed?: WorktreeBootstrapFailureHandler,
|
||||
@@ -134,17 +202,20 @@ const pollWorktreeBootstrapInBackground = async (
|
||||
if (watcher.cancelled) {
|
||||
return;
|
||||
}
|
||||
setWorktreeBootstrapState(directory, result);
|
||||
|
||||
if (result.status === 'ready') {
|
||||
onReady?.(result);
|
||||
const current = storePolledState(key, result, watcher.lifecycleVersion);
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === 'failed') {
|
||||
onFailed?.(result);
|
||||
if (current.status === 'ready') {
|
||||
onReady?.(current);
|
||||
return;
|
||||
}
|
||||
|
||||
if (current.status === 'failed') {
|
||||
onFailed?.(current);
|
||||
toast.error(t('worktree.bootstrap.toast.failed'), {
|
||||
description: result.error || t('worktree.bootstrap.toast.failedDescription'),
|
||||
description: current.error || t('worktree.bootstrap.toast.failedDescription'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -183,9 +254,13 @@ export const startWorktreeBootstrapWatcher = (
|
||||
return;
|
||||
}
|
||||
|
||||
const watcher = { cancelled: false, promise: Promise.resolve() };
|
||||
watcher.promise = pollWorktreeBootstrapInBackground(
|
||||
const watcher = {
|
||||
cancelled: false,
|
||||
lifecycleVersion: lifecycleVersions.get(key) ?? 0,
|
||||
};
|
||||
void pollWorktreeBootstrapInBackground(
|
||||
directory,
|
||||
key,
|
||||
watcher,
|
||||
options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
options?.pollIntervalMs ?? POLL_INTERVAL_MS,
|
||||
@@ -211,7 +286,11 @@ export const startWorktreeBootstrapWatcher = (
|
||||
watchers.set(key, watcher);
|
||||
};
|
||||
|
||||
export const waitForWorktreeBootstrap = async (directory: string, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<void> => {
|
||||
const waitForWorktreePhase = async (
|
||||
directory: string,
|
||||
target: WorktreeBootstrapTarget,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
): Promise<void> => {
|
||||
const key = getKey(directory);
|
||||
if (!key) {
|
||||
return;
|
||||
@@ -222,21 +301,31 @@ export const waitForWorktreeBootstrap = async (directory: string, timeoutMs = DE
|
||||
return;
|
||||
}
|
||||
|
||||
if (current?.status === 'ready') {
|
||||
if (hasReachedTarget(current, target)) {
|
||||
return;
|
||||
}
|
||||
if (current?.status === 'failed') {
|
||||
throw new Error(current.error || 'Worktree bootstrap failed');
|
||||
}
|
||||
|
||||
const existing = waiters.get(key);
|
||||
const waiterKey = getWaiterKey(key, target);
|
||||
const existing = waiters.get(waiterKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const pending = pollWorktreeBootstrapUntilSettled(directory, timeoutMs).finally(() => {
|
||||
waiters.delete(key);
|
||||
const lifecycleVersion = lifecycleVersions.get(key) ?? 0;
|
||||
const pending = pollWorktreeBootstrapUntilSettled(directory, key, lifecycleVersion, timeoutMs, target).finally(() => {
|
||||
if (waiters.get(waiterKey) === pending) {
|
||||
waiters.delete(waiterKey);
|
||||
}
|
||||
});
|
||||
waiters.set(key, pending);
|
||||
waiters.set(waiterKey, pending);
|
||||
return pending;
|
||||
};
|
||||
|
||||
export const waitForWorktreeGitReady = (directory: string, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<void> =>
|
||||
waitForWorktreePhase(directory, 'git-ready', timeoutMs);
|
||||
|
||||
export const waitForWorktreeBootstrap = (directory: string, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<void> =>
|
||||
waitForWorktreePhase(directory, 'setup-ready', timeoutMs);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
import type { GitWorktreeCreateResult } from '@/lib/api/types';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
|
||||
type WorktreeListEntry = {
|
||||
@@ -11,16 +12,24 @@ type WorktreeListEntry = {
|
||||
const listCalls: string[] = [];
|
||||
const listResolvers: Array<(value: WorktreeListEntry[]) => void> = [];
|
||||
const createdWorktree = {
|
||||
head: 'abc123',
|
||||
name: 'feature',
|
||||
branch: 'feature',
|
||||
path: '/repo-feature',
|
||||
directoryCreated: true as const,
|
||||
bootstrapStatus: { status: 'pending' as const, error: null, updatedAt: 1 },
|
||||
};
|
||||
let createdWorktreeResult: GitWorktreeCreateResult = createdWorktree;
|
||||
const bootstrapWatcherCalls: string[] = [];
|
||||
const bootstrapWatcherOptions: Array<{ onReady?: () => void }> = [];
|
||||
|
||||
const sessionState = {
|
||||
availableWorktreesByProject: new Map<string, WorktreeMetadata[]>(),
|
||||
availableWorktrees: [] as WorktreeMetadata[],
|
||||
worktreeMetadata: new Map<string, WorktreeMetadata>(),
|
||||
};
|
||||
const attachmentState = {
|
||||
attachments: new Map<string, { worktreeStatus: 'pending' | 'ready'; worktreeRoot: string }>(),
|
||||
};
|
||||
|
||||
mock.module('@/lib/openchamberConfig', () => ({
|
||||
@@ -31,7 +40,19 @@ mock.module('@/lib/worktrees/worktreeBootstrap', () => ({
|
||||
clearWorktreeBootstrapState: mock(),
|
||||
markWorktreeBootstrapPending: mock(),
|
||||
setWorktreeBootstrapState: mock(),
|
||||
startWorktreeBootstrapWatcher: mock(),
|
||||
startWorktreeBootstrapWatcher: (directory: string, options?: { onReady?: () => void }) => {
|
||||
bootstrapWatcherCalls.push(directory);
|
||||
bootstrapWatcherOptions.push(options ?? {});
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('@/sync/session-worktree-store', () => ({
|
||||
useSessionWorktreeStore: {
|
||||
setState: (patch: Partial<typeof attachmentState> | ((state: typeof attachmentState) => Partial<typeof attachmentState>)) => {
|
||||
const next = typeof patch === 'function' ? patch(attachmentState) : patch;
|
||||
Object.assign(attachmentState, next);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
mock.module('@/lib/worktrees/worktreeStatus', () => ({
|
||||
@@ -59,13 +80,13 @@ mock.module('@/lib/gitApi', () => ({
|
||||
listResolvers.push(resolve);
|
||||
});
|
||||
},
|
||||
create: mock(() => Promise.resolve(createdWorktree)),
|
||||
create: mock(() => Promise.resolve(createdWorktreeResult)),
|
||||
remove: mock(() => Promise.resolve({ success: true })),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { createWorktree, listProjectWorktrees, worktreeMapsEqual } = await import('./worktreeManager');
|
||||
const { createWorktree, getLatestWorktreeMetadata, listProjectWorktrees, worktreeMapsEqual } = await import('./worktreeManager');
|
||||
|
||||
const waitForListCallCount = async (count: number): Promise<void> => {
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
@@ -81,8 +102,13 @@ describe('worktreeManager list invalidation', () => {
|
||||
beforeEach(() => {
|
||||
listCalls.length = 0;
|
||||
listResolvers.length = 0;
|
||||
bootstrapWatcherCalls.length = 0;
|
||||
bootstrapWatcherOptions.length = 0;
|
||||
createdWorktreeResult = createdWorktree;
|
||||
sessionState.availableWorktreesByProject = new Map();
|
||||
sessionState.availableWorktrees = [];
|
||||
sessionState.worktreeMetadata = new Map();
|
||||
attachmentState.attachments = new Map();
|
||||
});
|
||||
|
||||
test('retries an in-flight list when a worktree is created before it resolves', async () => {
|
||||
@@ -119,6 +145,62 @@ describe('worktreeManager list invalidation', () => {
|
||||
|
||||
expect(metadata.worktreeStatus).toBe('pending');
|
||||
expect(sessionState.availableWorktrees[0]?.worktreeStatus).toBe('pending');
|
||||
expect(bootstrapWatcherCalls).toEqual(['/repo-feature']);
|
||||
});
|
||||
|
||||
test('treats legacy create responses without bootstrap state as fully ready', async () => {
|
||||
createdWorktreeResult = {
|
||||
head: '',
|
||||
name: 'legacy-feature',
|
||||
branch: 'legacy-feature',
|
||||
path: '/repo-legacy-feature',
|
||||
};
|
||||
|
||||
const metadata = await createWorktree({ id: 'project-1', path: '/repo' }, {
|
||||
preferredName: 'legacy-feature',
|
||||
mode: 'new',
|
||||
branchName: 'legacy-feature',
|
||||
worktreeName: 'legacy-feature',
|
||||
returnAfterDirectoryCreated: true,
|
||||
});
|
||||
|
||||
expect(metadata.worktreeStatus).toBe('ready');
|
||||
expect(bootstrapWatcherCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test('reconciles session attachments when bootstrap becomes ready', async () => {
|
||||
const metadata = await createWorktree({ id: 'project-1', path: '/repo' }, {
|
||||
preferredName: 'feature',
|
||||
mode: 'new',
|
||||
branchName: 'feature',
|
||||
worktreeName: 'feature',
|
||||
returnAfterDirectoryCreated: true,
|
||||
});
|
||||
sessionState.worktreeMetadata.set('session-1', metadata);
|
||||
attachmentState.attachments.set('session-1', {
|
||||
worktreeRoot: metadata.path,
|
||||
worktreeStatus: 'pending',
|
||||
});
|
||||
|
||||
bootstrapWatcherOptions[0]?.onReady?.();
|
||||
|
||||
expect(sessionState.worktreeMetadata.get('session-1')?.worktreeStatus).toBe('ready');
|
||||
expect(attachmentState.attachments.get('session-1')?.worktreeStatus).toBe('ready');
|
||||
});
|
||||
|
||||
test('resolves ready metadata when bootstrap settles before the session is attached', async () => {
|
||||
const metadata = await createWorktree({ id: 'project-1', path: '/repo' }, {
|
||||
preferredName: 'feature',
|
||||
mode: 'new',
|
||||
branchName: 'feature',
|
||||
worktreeName: 'feature',
|
||||
returnAfterDirectoryCreated: true,
|
||||
});
|
||||
|
||||
bootstrapWatcherOptions[0]?.onReady?.();
|
||||
|
||||
expect(metadata.worktreeStatus).toBe('pending');
|
||||
expect(getLatestWorktreeMetadata(metadata).worktreeStatus).toBe('ready');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
GitWorktreeValidationResult,
|
||||
} from '@/lib/api/types';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSessionWorktreeStore } from '@/sync/session-worktree-store';
|
||||
|
||||
type WorktreeListEntry = {
|
||||
path?: string;
|
||||
@@ -57,6 +58,18 @@ const normalizePath = (value: string): string => {
|
||||
return replaced.length > 1 ? replaced.replace(/\/+$/, '') : replaced;
|
||||
};
|
||||
|
||||
export const getLatestWorktreeMetadata = (metadata: WorktreeMetadata): WorktreeMetadata => {
|
||||
const target = normalizePath(metadata.path);
|
||||
const state = useSessionUIStore.getState();
|
||||
const available = state.availableWorktrees.find((candidate) => normalizePath(candidate.path) === target);
|
||||
if (available) return available;
|
||||
for (const worktrees of state.availableWorktreesByProject.values()) {
|
||||
const candidate = worktrees.find((worktree) => normalizePath(worktree.path) === target);
|
||||
if (candidate) return candidate;
|
||||
}
|
||||
return metadata;
|
||||
};
|
||||
|
||||
const slugifyWorktreeName = (value: string): string => {
|
||||
return value
|
||||
.trim()
|
||||
@@ -86,6 +99,7 @@ const setStoredWorktreeStatus = (directory: string, status: NonNullable<Worktree
|
||||
return;
|
||||
}
|
||||
|
||||
const changedSessionIds: string[] = [];
|
||||
useSessionUIStore.setState((state) => {
|
||||
let changed = false;
|
||||
|
||||
@@ -131,6 +145,7 @@ const setStoredWorktreeStatus = (directory: string, status: NonNullable<Worktree
|
||||
for (const [sessionId, metadata] of state.worktreeMetadata) {
|
||||
const next = applyStatus(metadata);
|
||||
if (next !== metadata) {
|
||||
changedSessionIds.push(sessionId);
|
||||
if (worktreeMetadata === state.worktreeMetadata) {
|
||||
worktreeMetadata = new Map(state.worktreeMetadata);
|
||||
}
|
||||
@@ -148,6 +163,19 @@ const setStoredWorktreeStatus = (directory: string, status: NonNullable<Worktree
|
||||
worktreeMetadata,
|
||||
};
|
||||
});
|
||||
|
||||
if (changedSessionIds.length > 0) {
|
||||
useSessionWorktreeStore.setState((state) => {
|
||||
let attachments = state.attachments;
|
||||
for (const sessionId of changedSessionIds) {
|
||||
const attachment = attachments.get(sessionId);
|
||||
if (!attachment || attachment.worktreeStatus === status) continue;
|
||||
if (attachments === state.attachments) attachments = new Map(state.attachments);
|
||||
attachments.set(sessionId, { ...attachment, worktreeStatus: status });
|
||||
}
|
||||
return attachments === state.attachments ? state : { attachments };
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getWorktreeStatusFromBootstrap = (status?: GitWorktreeBootstrapStatus): WorktreeMetadata['worktreeStatus'] => {
|
||||
@@ -404,13 +432,17 @@ export async function createWorktree(project: ProjectRef, args: CreateWorktreeAr
|
||||
|
||||
if (created?.bootstrapStatus) {
|
||||
setWorktreeBootstrapState(metadata.path, created.bootstrapStatus);
|
||||
} else {
|
||||
} else if (created?.directoryCreated) {
|
||||
markWorktreeBootstrapPending(metadata.path);
|
||||
}
|
||||
startWorktreeBootstrapWatcher(metadata.path, {
|
||||
onFailed: () => setStoredWorktreeStatus(metadata.path, 'invalid'),
|
||||
onReady: () => setStoredWorktreeStatus(metadata.path, 'ready'),
|
||||
});
|
||||
const shouldWatchBootstrap = created?.bootstrapStatus?.status === 'pending'
|
||||
|| (!created?.bootstrapStatus && created?.directoryCreated === true);
|
||||
if (shouldWatchBootstrap) {
|
||||
startWorktreeBootstrapWatcher(metadata.path, {
|
||||
onFailed: () => setStoredWorktreeStatus(metadata.path, 'invalid'),
|
||||
onReady: () => setStoredWorktreeStatus(metadata.path, 'ready'),
|
||||
});
|
||||
}
|
||||
|
||||
invalidateWorktreeList(projectDirectory);
|
||||
// The new worktree changes the repo's worktree topology; drop cached root
|
||||
|
||||
@@ -100,8 +100,9 @@ VS Code does not run the server permission-auto-accept runtime. The extension ho
|
||||
- title update
|
||||
- share
|
||||
- unshare
|
||||
- archive
|
||||
- delete
|
||||
- archive
|
||||
- delete
|
||||
- move to another worktree directory
|
||||
- retention cleanup batch archive/delete
|
||||
|
||||
This keeps cold/global lists responsive without requiring a refetch after every change.
|
||||
@@ -125,6 +126,7 @@ Examples of global-store updates performed in `session-actions.ts`:
|
||||
- `shareSession()` / `unshareSession()` -> `upsertSession(result.data)`
|
||||
- `archiveSession()` -> `archiveSessions([id], archivedAt)`
|
||||
- `deleteSession()` -> `removeSessions([id])`
|
||||
- `moveSessionToDirectory()` -> move the session between directory stores and update the global directory index
|
||||
|
||||
## The golden rule
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ let sessionShareResult: { data?: unknown; error?: unknown; response?: { status?:
|
||||
let sessionUpdateResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {}
|
||||
let sessionMessagesResult: { data?: unknown; error?: unknown; response?: { status?: number } } = { data: [] }
|
||||
const globalUpsertedSessions: unknown[] = []
|
||||
const movedSessionDirectories: Array<{ sessionID: string; directory: string }> = []
|
||||
|
||||
const mockScopedClient = {
|
||||
permission: {
|
||||
@@ -40,6 +41,14 @@ const mockScopedClient = {
|
||||
}
|
||||
|
||||
const mockSdk = {
|
||||
experimental: {
|
||||
controlPlane: {
|
||||
moveSession: mock((params: Record<string, unknown>) => {
|
||||
replyCalls.push({ method: "controlPlane.moveSession", params })
|
||||
return Promise.resolve({})
|
||||
}),
|
||||
},
|
||||
},
|
||||
session: {
|
||||
messages: mock((params: Record<string, unknown>) => {
|
||||
replyCalls.push({ method: "session.messages", params })
|
||||
@@ -102,6 +111,7 @@ mock.module("@/lib/opencode/client", () => ({
|
||||
return mockScopedClient
|
||||
},
|
||||
getDirectory: () => "/test/project",
|
||||
getSdkClient: () => mockSdk,
|
||||
replyToPermission: mock((requestId: string, reply: string, options?: { directory?: string | null }) => {
|
||||
replyCalls.push({ method: "permission.reply", params: { requestID: requestId, reply, directory: options?.directory } })
|
||||
return Promise.resolve(true)
|
||||
@@ -147,6 +157,9 @@ mock.module("./session-ui-store", () => ({
|
||||
if (sessionId === "session-b") return "/other/project"
|
||||
return null
|
||||
},
|
||||
setSessionDirectory: (sessionID: string, directory: string) => {
|
||||
movedSessionDirectories.push({ sessionID, directory })
|
||||
},
|
||||
}),
|
||||
},
|
||||
}))
|
||||
@@ -234,6 +247,87 @@ function createChildStores(entries: Array<[string, StoreApi<DirectoryStore>]>) {
|
||||
} as unknown as import("./child-store").ChildStoreManager
|
||||
}
|
||||
|
||||
describe("moveSessionToDirectory", () => {
|
||||
beforeEach(() => {
|
||||
replyCalls.length = 0
|
||||
registeredSessionDirectories.length = 0
|
||||
movedSessionDirectories.length = 0
|
||||
globalUpsertedSessions.length = 0
|
||||
})
|
||||
|
||||
test("moves through the control plane and reconciles directory stores", async () => {
|
||||
const message = {
|
||||
id: "message-a",
|
||||
sessionID: "session-a",
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
} as Message
|
||||
const part = {
|
||||
id: "part-a",
|
||||
messageID: "message-a",
|
||||
type: "text",
|
||||
text: "hello",
|
||||
} as Part
|
||||
const source = createStore({ "session-a": [{ id: "permission-a" }] as never }, {
|
||||
session: [{ id: "session-a", title: "Move me", directory: "/source" } as Session],
|
||||
sessionTotal: 1,
|
||||
session_status: { "session-a": { type: "idle" } },
|
||||
session_diff: { "session-a": [{ file: "changed.ts", additions: 1, deletions: 0 }] },
|
||||
todo: { "session-a": [{ id: "todo-a", content: "Check move", status: "pending", priority: "medium" }] as never },
|
||||
question: { "session-a": [{ id: "question-a" }] as never },
|
||||
message: { "session-a": [message] },
|
||||
part: { "message-a": [part] },
|
||||
})
|
||||
const destination = createStore({})
|
||||
const childStores = createChildStores([["/source", source], ["/destination", destination]])
|
||||
const { moveSessionToDirectory, setActionRefs } = await import("./session-actions")
|
||||
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/source")
|
||||
|
||||
await moveSessionToDirectory(source.getState().session[0], "/source", "/destination", true)
|
||||
|
||||
expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")).toEqual([{
|
||||
method: "controlPlane.moveSession",
|
||||
params: {
|
||||
sessionID: "session-a",
|
||||
destination: { directory: "/destination" },
|
||||
moveChanges: true,
|
||||
},
|
||||
}])
|
||||
expect(source.getState().session).toHaveLength(0)
|
||||
expect(source.getState().sessionTotal).toBe(0)
|
||||
expect(source.getState().session_status["session-a"]).toBe(undefined)
|
||||
expect(source.getState().session_diff["session-a"]).toBe(undefined)
|
||||
expect(source.getState().todo["session-a"]).toBe(undefined)
|
||||
expect(source.getState().permission["session-a"]).toBe(undefined)
|
||||
expect(source.getState().question["session-a"]).toBe(undefined)
|
||||
expect(source.getState().message["session-a"]).toBe(undefined)
|
||||
expect(source.getState().part["message-a"]).toBe(undefined)
|
||||
expect(destination.getState().session[0]?.id).toBe("session-a")
|
||||
expect(destination.getState().sessionTotal).toBe(1)
|
||||
expect((destination.getState().session[0] as SessionWithDirectory)?.directory).toBe("/destination")
|
||||
expect(destination.getState().session_status["session-a"]?.type).toBe("idle")
|
||||
expect(destination.getState().session_diff["session-a"]?.[0]?.file).toBe("changed.ts")
|
||||
expect(destination.getState().todo["session-a"]?.[0]?.content).toBe("Check move")
|
||||
expect(destination.getState().permission["session-a"]?.[0]?.id).toBe("permission-a")
|
||||
expect(destination.getState().question["session-a"]?.[0]?.id).toBe("question-a")
|
||||
expect(destination.getState().message["session-a"]?.[0]?.id).toBe("message-a")
|
||||
expect(destination.getState().part["message-a"]?.[0]?.id).toBe("part-a")
|
||||
expect(registeredSessionDirectories).toEqual([{ sessionID: "session-a", directory: "/destination" }])
|
||||
expect(movedSessionDirectories).toEqual([{ sessionID: "session-a", directory: "/destination" }])
|
||||
expect((globalUpsertedSessions[0] as SessionWithDirectory).directory).toBe("/destination")
|
||||
|
||||
await moveSessionToDirectory(destination.getState().session[0], "/destination", "/source", true)
|
||||
|
||||
expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")[1]?.params.moveChanges).toBe(true)
|
||||
expect(source.getState().session[0]?.id).toBe("session-a")
|
||||
expect(source.getState().message["session-a"]?.[0]?.id).toBe("message-a")
|
||||
expect(source.getState().part["message-a"]?.[0]?.id).toBe("part-a")
|
||||
expect(destination.getState().session).toHaveLength(0)
|
||||
expect(destination.getState().message["session-a"]).toBe(undefined)
|
||||
expect(destination.getState().part["message-a"]).toBe(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetchMessagesForSession startup race", () => {
|
||||
test("does not reject before sync action refs are initialized", async () => {
|
||||
const { fetchMessagesForSession } = await import("./session-actions")
|
||||
|
||||
@@ -192,6 +192,115 @@ export function mirrorSessionIntoLiveStores(session: Session, directory?: string
|
||||
updateLiveSession(session)
|
||||
}
|
||||
|
||||
function moveRecordEntries<T>(
|
||||
source: Record<string, T>,
|
||||
destination: Record<string, T>,
|
||||
keys: Iterable<string>,
|
||||
): { source: Record<string, T>; destination: Record<string, T> } {
|
||||
let nextSource = source
|
||||
let nextDestination = destination
|
||||
|
||||
for (const key of keys) {
|
||||
if (!Object.prototype.hasOwnProperty.call(source, key)) continue
|
||||
if (nextSource === source) nextSource = { ...source }
|
||||
if (nextDestination === destination) nextDestination = { ...destination }
|
||||
nextDestination[key] = source[key]
|
||||
delete nextSource[key]
|
||||
}
|
||||
|
||||
return { source: nextSource, destination: nextDestination }
|
||||
}
|
||||
|
||||
function reconcileSessionMove(
|
||||
session: Session,
|
||||
sourceDirectory: string,
|
||||
destinationDirectory: string,
|
||||
): Session {
|
||||
const stores = _childStores
|
||||
const sourceStore = stores?.getChild(sourceDirectory)
|
||||
const destinationStore = stores?.ensureChild(destinationDirectory, { bootstrap: false })
|
||||
const sourceState = sourceStore?.getState()
|
||||
const destinationState = destinationStore?.getState()
|
||||
const liveSession = sourceState?.session.find((candidate) => candidate.id === session.id) ?? session
|
||||
const movedSession = { ...liveSession, directory: destinationDirectory } as Session
|
||||
|
||||
if (!destinationStore || !destinationState || sourceStore === destinationStore) {
|
||||
return movedSession
|
||||
}
|
||||
|
||||
const destinationSessionIndex = destinationState.session.findIndex((candidate) => candidate.id === session.id)
|
||||
const destinationSessions = [...destinationState.session]
|
||||
if (destinationSessionIndex === -1) destinationSessions.push(movedSession)
|
||||
else destinationSessions[destinationSessionIndex] = movedSession
|
||||
|
||||
if (!sourceStore || !sourceState) {
|
||||
destinationStore.setState({
|
||||
session: destinationSessions,
|
||||
sessionTotal: destinationSessionIndex === -1
|
||||
? destinationState.sessionTotal + 1
|
||||
: destinationState.sessionTotal,
|
||||
})
|
||||
return movedSession
|
||||
}
|
||||
|
||||
const sourceContainsSession = sourceState.session.some((candidate) => candidate.id === session.id)
|
||||
const status = moveRecordEntries(sourceState.session_status, destinationState.session_status, [session.id])
|
||||
const diffs = moveRecordEntries(sourceState.session_diff, destinationState.session_diff, [session.id])
|
||||
const todos = moveRecordEntries(sourceState.todo, destinationState.todo, [session.id])
|
||||
const permissions = moveRecordEntries(sourceState.permission, destinationState.permission, [session.id])
|
||||
const questions = moveRecordEntries(sourceState.question, destinationState.question, [session.id])
|
||||
const messages = moveRecordEntries(sourceState.message, destinationState.message, [session.id])
|
||||
const messageIds = sourceState.message[session.id]?.map((message) => message.id) ?? []
|
||||
const parts = moveRecordEntries(sourceState.part, destinationState.part, messageIds)
|
||||
|
||||
sourceStore.setState({
|
||||
session: sourceState.session.filter((candidate) => candidate.id !== session.id),
|
||||
sessionTotal: sourceContainsSession ? Math.max(0, sourceState.sessionTotal - 1) : sourceState.sessionTotal,
|
||||
session_status: status.source,
|
||||
session_diff: diffs.source,
|
||||
todo: todos.source,
|
||||
permission: permissions.source,
|
||||
question: questions.source,
|
||||
message: messages.source,
|
||||
part: parts.source,
|
||||
})
|
||||
destinationStore.setState({
|
||||
session: destinationSessions,
|
||||
sessionTotal: destinationSessionIndex === -1
|
||||
? destinationState.sessionTotal + 1
|
||||
: destinationState.sessionTotal,
|
||||
session_status: status.destination,
|
||||
session_diff: diffs.destination,
|
||||
todo: todos.destination,
|
||||
permission: permissions.destination,
|
||||
question: questions.destination,
|
||||
message: messages.destination,
|
||||
part: parts.destination,
|
||||
})
|
||||
|
||||
return movedSession
|
||||
}
|
||||
|
||||
export async function moveSessionToDirectory(
|
||||
session: Session,
|
||||
sourceDirectory: string,
|
||||
destinationDirectory: string,
|
||||
moveChanges = true,
|
||||
): Promise<void> {
|
||||
const result = await opencodeClient.getSdkClient().experimental.controlPlane.moveSession({
|
||||
sessionID: session.id,
|
||||
destination: { directory: destinationDirectory },
|
||||
moveChanges,
|
||||
})
|
||||
assertSdkSuccess(result, "Move session")
|
||||
|
||||
const moved = reconcileSessionMove(session, sourceDirectory, destinationDirectory)
|
||||
|
||||
registerSessionDirectory(session.id, destinationDirectory)
|
||||
useGlobalSessionsStore.getState().upsertSession(moved)
|
||||
useSessionUIStore.getState().setSessionDirectory(session.id, destinationDirectory)
|
||||
}
|
||||
|
||||
function dir() {
|
||||
return _getDirectory() || undefined
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user