Merge pull request #2998 from mattv8/feat/move-session-existing-worktree
feat(sessions): move sessions to existing worktrees
This commit is contained in:
@@ -71,7 +71,7 @@ import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
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';
|
||||
|
||||
@@ -1059,12 +1059,17 @@ export const Header: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
startSessionTreeWorktreeMove({
|
||||
requestSessionTreeMove({
|
||||
kind: 'quick',
|
||||
root,
|
||||
descendants,
|
||||
sourceDirectory: sessionDirectory,
|
||||
successMessage: t('sessions.sidebar.session.moveToWorktree.success'),
|
||||
failureMessage: t('sessions.sidebar.session.moveToWorktree.failed'),
|
||||
messages: {
|
||||
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]);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { HelpDialog } from '../ui/HelpDialog';
|
||||
import { OpenCodeStatusDialog } from '../ui/OpenCodeStatusDialog';
|
||||
import { SessionSidebar } from '@/components/session/SessionSidebar';
|
||||
import { SessionDialogs } from '@/components/session/SessionDialogs';
|
||||
import { SessionWorktreeMoveConfirmDialog } from '@/components/session/sidebar/SessionWorktreeMoveConfirmDialog';
|
||||
import { ScheduledTasksDialog } from '@/components/session/ScheduledTasksDialog';
|
||||
import { ArchiveView } from '@/components/views/ArchiveView';
|
||||
import { WorktreesView } from '@/components/views/WorktreesView';
|
||||
@@ -19,6 +20,11 @@ import { MultiRunLauncher } from '@/components/multirun';
|
||||
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import {
|
||||
cancelSessionTreeMove,
|
||||
confirmSessionTreeMove,
|
||||
useSessionTreeMoveConfirmation,
|
||||
} from '@/lib/worktrees/sessionWorktreeMove';
|
||||
import { useUpdatePolling } from '@/hooks/useUpdatePolling';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -80,6 +86,8 @@ export const MainLayout: React.FC = () => {
|
||||
|
||||
useUpdatePolling();
|
||||
|
||||
const sessionTreeMoveConfirmation = useSessionTreeMoveConfirmation();
|
||||
|
||||
React.useEffect(() => {
|
||||
const previous = useUIStore.getState().isMobile;
|
||||
if (previous !== isMobile) {
|
||||
@@ -97,6 +105,12 @@ export const MainLayout: React.FC = () => {
|
||||
<HelpDialog />
|
||||
<OpenCodeStatusDialog />
|
||||
<SessionDialogs />
|
||||
<SessionWorktreeMoveConfirmDialog
|
||||
value={sessionTreeMoveConfirmation}
|
||||
onMoveSessionOnly={() => confirmSessionTreeMove(false)}
|
||||
onMoveAllChanges={() => confirmSessionTreeMove(true)}
|
||||
onCancel={cancelSessionTreeMove}
|
||||
/>
|
||||
|
||||
{/* Persistent top-left controls (toggle + project actions) that
|
||||
stay put while the sidebar/header animate beneath them. */}
|
||||
|
||||
@@ -40,6 +40,14 @@ import { runBackgroundNetworkTask } from '@/lib/background-network';
|
||||
import { buildKnownSessionDirectories } from './sidebar/list/sessionListDirectories';
|
||||
import { z } from 'zod';
|
||||
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
|
||||
import {
|
||||
commitDiscoveredRawWorktreesByProject,
|
||||
ensureRawWorktreesByProjectScope,
|
||||
startSessionWorktreeMenuLoad,
|
||||
type RawWorktreesByProjectScope,
|
||||
type StartSessionWorktreeMenuLoadArgs,
|
||||
} from './sidebar/sessionWorktreeMenu';
|
||||
import { resolveProjectRef } from '@/lib/worktreeSessionCreator';
|
||||
|
||||
const PROJECT_ACTIVE_SESSION_STORAGE_KEY = 'oc.sessions.activeSessionByProject';
|
||||
const EMPTY_STRING_ARRAY: string[] = [];
|
||||
@@ -189,6 +197,11 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
const [worktreeDiscoveryRevision, requestWorktreeDiscovery] = React.useReducer((revision) => revision + 1, 0);
|
||||
const isWorktreeTopologyLoading = !isVSCode && resolvedWorktreeTopologyKey !== projectWorktreeDiscoveryKey;
|
||||
const [unresolvedWorktreeProjectPaths, setUnresolvedWorktreeProjectPaths] = React.useState<ReadonlySet<string>>(new Set());
|
||||
const rawWorktreesByProjectRef = React.useRef<RawWorktreesByProjectScope>({
|
||||
runtimeKey: null,
|
||||
revision: 0,
|
||||
worktreesByProject: new Map(),
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -198,14 +211,25 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
const projectEntries = useProjectsStore.getState().projects;
|
||||
if (projectEntries.length === 0 || isVSCode) {
|
||||
if (!cancelled) {
|
||||
rawWorktreesByProjectRef.current = {
|
||||
runtimeKey: null,
|
||||
revision: 0,
|
||||
worktreesByProject: new Map(),
|
||||
};
|
||||
setUnresolvedWorktreeProjectPaths(new Set());
|
||||
setResolvedWorktreeTopologyKey(projectWorktreeDiscoveryKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const knownWorktreesByProject = useSessionUIStore.getState().availableWorktreesByProject;
|
||||
const worktreesByProject = new Map(knownWorktreesByProject);
|
||||
const knownPublishedWorktreesByProject = useSessionUIStore.getState().availableWorktreesByProject;
|
||||
const seededRawScope = ensureRawWorktreesByProjectScope({
|
||||
rawWorktreesByProjectRef,
|
||||
publishedWorktreesByProject: knownPublishedWorktreesByProject,
|
||||
runtimeKey: discoveryRuntimeKey,
|
||||
});
|
||||
const capturedRawRevision = seededRawScope.revision;
|
||||
const worktreesByProject = new Map(seededRawScope.worktreesByProject);
|
||||
const unresolvedProjectPaths = new Set<string>();
|
||||
|
||||
// Constrain fanout: previously `Promise.all(projects.map(...))` could
|
||||
@@ -258,18 +282,26 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
worktreesByProject.delete(projectPath);
|
||||
}
|
||||
}
|
||||
const partitionedWorktreesByProject = partitionWorktreesByRegisteredProject(projectEntries, worktreesByProject);
|
||||
const allWorktrees = [...partitionedWorktreesByProject.values()].flat();
|
||||
// Newly appearing worktrees sort to the top of their project's
|
||||
// worktree list (see worktreeFirstSeen.ts).
|
||||
recordWorktreesSeen(allWorktrees.map((worktree) => worktree.path), Date.now());
|
||||
|
||||
// Skip update if nothing changed — see worktreeMapsEqual JSDoc.
|
||||
if (!worktreeMapsEqual(partitionedWorktreesByProject, knownWorktreesByProject)) {
|
||||
useSessionUIStore.setState({
|
||||
availableWorktrees: allWorktrees,
|
||||
availableWorktreesByProject: partitionedWorktreesByProject,
|
||||
});
|
||||
const committed = commitDiscoveredRawWorktreesByProject({
|
||||
rawWorktreesByProjectRef,
|
||||
runtimeKey: discoveryRuntimeKey,
|
||||
capturedRevision: capturedRawRevision,
|
||||
nextRawWorktreesByProject: worktreesByProject,
|
||||
publishedWorktreesByProject: knownPublishedWorktreesByProject,
|
||||
partitionWorktreesByRegisteredProject,
|
||||
projects: projectEntries,
|
||||
worktreeMapsEqual,
|
||||
recordWorktreesSeen,
|
||||
publishTopology: (next) => {
|
||||
useSessionUIStore.setState(next);
|
||||
},
|
||||
requestRediscovery: () => {
|
||||
requestWorktreeDiscovery();
|
||||
},
|
||||
now: () => Date.now(),
|
||||
});
|
||||
if (!committed) {
|
||||
return;
|
||||
}
|
||||
setUnresolvedWorktreeProjectPaths(unresolvedProjectPaths);
|
||||
setResolvedWorktreeTopologyKey(projectWorktreeDiscoveryKey);
|
||||
@@ -367,7 +399,6 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
const normalizedProjects = React.useMemo(() => {
|
||||
return projects.flatMap((project) => {
|
||||
const normalizedPath = normalizePath(project.path);
|
||||
@@ -527,6 +558,29 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
openMultiRunLauncher();
|
||||
}, [mobileVariant, openMultiRunLauncher, setSessionSwitcherOpen]);
|
||||
|
||||
const handleSessionWorktreeMenuLoad = React.useCallback((args: StartSessionWorktreeMenuLoadArgs) => {
|
||||
const resolvedProject = args.projectId
|
||||
? (projects.find((candidate) => candidate.id === args.projectId) ?? null)
|
||||
: (args.sourceDirectory ? resolveProjectRef(args.sourceDirectory) : null);
|
||||
return startSessionWorktreeMenuLoad(args, {
|
||||
projects,
|
||||
getCurrentProjects: () => useProjectsStore.getState().projects,
|
||||
rawWorktreesByProjectRef,
|
||||
getPublishedWorktreesByProject: () => useSessionUIStore.getState().availableWorktreesByProject,
|
||||
resolveProject: (directory) => resolveProjectRef(directory),
|
||||
listProjectWorktrees,
|
||||
partitionWorktreesByRegisteredProject,
|
||||
worktreeMapsEqual,
|
||||
recordWorktreesSeen,
|
||||
publishTopology: (next) => {
|
||||
useSessionUIStore.setState(next);
|
||||
},
|
||||
getRuntimeKey,
|
||||
now: () => Date.now(),
|
||||
projectRootBranch: resolvedProject ? (projectRootBranches.get(resolvedProject.id) ?? null) : null,
|
||||
});
|
||||
}, [projectRootBranches, projects]);
|
||||
|
||||
const handleOpenNewSessionDraftFromHeader = React.useCallback(() => {
|
||||
useUIStore.getState().closeMainSurfaces();
|
||||
if (mobileVariant) {
|
||||
@@ -637,6 +691,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
openProjectEditDialog: setEditingProjectDialogId,
|
||||
removeProject,
|
||||
reorderProjects,
|
||||
startSessionWorktreeMenuLoad: handleSessionWorktreeMenuLoad,
|
||||
initialActiveSessionByProject,
|
||||
persistActiveSessionByProject,
|
||||
projectViewActions: projectView.actions,
|
||||
|
||||
@@ -11,6 +11,15 @@ kept at this root in `types.ts` and `utils.tsx`.
|
||||
- `sessions/` owns session rows, row actions, expansion, ownership, and activity indicators.
|
||||
- `recent/` owns Recent and managed Chats activity projections.
|
||||
- `folders/` owns folder DnD, bulk actions, archived folders, and folder UI.
|
||||
- Root session right-click and overflow menus expose `Move to worktree`: a submenu
|
||||
listing the canonical primary and linked worktree destinations, with the current
|
||||
target disabled and a separate `New worktree...` action. Opening the submenu
|
||||
refreshes the worktree topology. Moving transfers the full idle subtree. Clean
|
||||
and non-Git sources move session-only; a dirty Git source prompts to move only
|
||||
the session, move all source changes, or cancel. Descendants move first without
|
||||
changes and roll back session-only if a later descendant fails. The root moves
|
||||
last and carries source changes once, which prevents rollback from replaying the
|
||||
transferred patch into the source.
|
||||
|
||||
`MainLayout` and `VSCodeLayout` call `useSessionListSync({ isVSCode })`
|
||||
unconditionally. The hook publishes complete directory bootstrap demand,
|
||||
@@ -35,3 +44,24 @@ Directory demand always includes known project roots and worktrees. Visibility
|
||||
only changes priority. Row mounts must not start bootstrap work. Selection and
|
||||
activity subscriptions stay session-scoped so a structural list update does not
|
||||
make every row observe unrelated streaming updates.
|
||||
|
||||
## Loading rules
|
||||
|
||||
- Always publish every known project root and worktree directory. Collapse/visibility changes priority only; they do not opt a directory out of authoritative refresh.
|
||||
- Current directory and selected-session directory are `selected` demand and therefore run first.
|
||||
- Expanded projects/worktrees outrank merely visible and background groups.
|
||||
- The sync scheduler deduplicates, promotes, retries, and limits work. Sidebar components must not reproduce that lifecycle with mount effects.
|
||||
- Hide speculative work when the sidebar/chat surface is hidden: message prefetch, Git/PR enrichment and subscriptions, search listeners, sticky-header observation, and archived-folder derivation stop. The session row tree unmounts so row-owned status, permission, unseen, and viewport subscriptions do no background work. The outer sidebar remains mounted, preserving UI state and authoritative directory refresh for an immediate reopen; deferred derived work reruns from current state when visibility returns.
|
||||
- The sidebar does not subscribe its whole tree to the cross-directory live-session aggregate. Global create/structural/lifecycle snapshots drive rendered session metadata; the cached sync index only fills sessions not yet present globally and provides refresh fallback data. Row activity continues to come from the session-keyed live status index.
|
||||
- Session selection does not invalidate the sidebar orchestration component. Each mounted row selects only whether its own session ID is active, while parent expansion, project selection memory, and neighbor prefetch run in small effect-only subscribers.
|
||||
- Parent expansion is exclusively manual. Selecting or navigating to a subsession never expands its parent automatically. Project/worktree and `recent` trees use independent persisted context keys and receive separate stable projections, so expansion changes in one context neither invalidate nor change the other. The persisted storage key remains `v3`; older state mixed contexts and is not migrated into this contract.
|
||||
- Folder membership may contain both a parent session and its descendants. Rendering treats only the highest assigned ancestors as folder roots because their normal session trees already include assigned descendants; persisted membership remains unchanged for cleanup and move semantics.
|
||||
- Sidebar selection holds the clicked row's viewport position across navigation-driven sidebar updates. Wheel or touch input cancels the hold immediately, so programmatic compensation never fights intentional scrolling.
|
||||
- Global session subscriptions are structural: create/delete, title, share, archive, directory, parent, and slug changes invalidate the tree. Recency-only `time.updated` changes do not trigger a rebuild. The separate lifecycle rank invalidates ordering only on `settled ↔ active` transitions, with root sessions ranked among roots and child sessions only among siblings of the same parent.
|
||||
- Opening the root-session `Move to worktree` submenu force-refreshes the owning project's worktree topology so externally created worktrees appear without a full reload. While that refresh runs, the menu keeps the last known primary/linked topology visible; if the refresh fails, the stale topology remains and the load failure state stays explicit. Failure cleanup never removes or manages an existing destination worktree.
|
||||
- CLI/server-created sessions use the low-frequency OpenChamber control event stream to refresh only the created session directory. The same event retriggers bounded worktree discovery so a newly created external worktree gains ownership without a view reload; it does not re-enable broad session or streaming subscriptions.
|
||||
- Recent membership includes active root sessions immediately even when their last committed `time.updated` falls outside the 48-hour window. Children and archived sessions remain excluded, and inactive roots remain timestamp-based. The active-ID subscription is disabled while the sidebar is hidden and ignores retry/status detail changes, avoiding streaming-frequency rerenders.
|
||||
- Structural updates rebuild grouped nodes only for projects whose local sessions, worktrees, repository state, or branch changed; unchanged project sections preserve references so memoized group/session descendants skip the update wave.
|
||||
- Empty successful lists, unresolved loads, and failed loads are separate UI states. Failed groups expose Retry and retain prior data.
|
||||
- Directory permission failures remain visible even when stale sessions are retained. Flat groups inspect every represented root/worktree directory; local Desktop may open the native picker for the exact failed directory, while other runtimes keep the ordinary Retry action.
|
||||
- Pins and folder assignments are not pruned from the first startup snapshot or from optimistic mutations. Confirmed local deletion and routed external deletion clean immediately; a later authoritative omission after an established baseline covers missed external delete events.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { getGitHubPrStatusKey, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import type { SessionTreeItemProps } from '../sessions/SessionTreeItem';
|
||||
import { useArchivedAutoFolders } from '../folders/useArchivedAutoFolders';
|
||||
import { ProjectSessionSelectionEffect } from '../projects/useProjectSessionSelection';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
@@ -103,6 +104,7 @@ type SessionProjectCollectionProps = {
|
||||
openProjectEditDialog: (id: string) => void;
|
||||
removeProject: (id: string) => void;
|
||||
reorderProjects: (fromIndex: number, toIndex: number) => void;
|
||||
startSessionWorktreeMenuLoad: SessionTreeItemProps['startSessionWorktreeMenuLoad'];
|
||||
renderProjectStatusIndicator?: (projectId: string, groups: SessionGroup[]) => React.ReactNode;
|
||||
initialActiveSessionByProject: Map<string, string>;
|
||||
persistActiveSessionByProject: (value: Map<string, string>) => void;
|
||||
@@ -330,6 +332,7 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
||||
setDeleteSessionConfirm,
|
||||
startFolderRename,
|
||||
setCopiedSessionId,
|
||||
startSessionWorktreeMenuLoad: actions.startSessionWorktreeMenuLoad,
|
||||
folderRename,
|
||||
setFolderRenameDraft,
|
||||
clearFolderRename,
|
||||
@@ -350,6 +353,7 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
||||
deleteSessionConfirm,
|
||||
copiedSessionId,
|
||||
setCopiedSessionId,
|
||||
actions.startSessionWorktreeMenuLoad,
|
||||
rowActions,
|
||||
toggleParent,
|
||||
view.hideDirectoryControls,
|
||||
@@ -457,12 +461,14 @@ const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topol
|
||||
setDeleteSessionConfirm={setDeleteSessionConfirm}
|
||||
startFolderRename={startFolderRename}
|
||||
setCopiedSessionId={setCopiedSessionId}
|
||||
startSessionWorktreeMenuLoad={actions.startSessionWorktreeMenuLoad}
|
||||
chatSessions={collection.chatSessions}
|
||||
renderChatsSection={renderChatsSection}
|
||||
onNewChat={handleOpenNewChat}
|
||||
showRecentSection={showRecentSection && !singleProjectMode}
|
||||
/> : null
|
||||
), [
|
||||
actions.startSessionWorktreeMenuLoad,
|
||||
alwaysShowActions,
|
||||
collection.childrenMap,
|
||||
collection.pinnedSessionIds,
|
||||
|
||||
+4
@@ -138,6 +138,10 @@ const createProps = (): SessionGroupSectionProps => ({
|
||||
deleteSessionConfirm: null,
|
||||
setDeleteSessionConfirm: () => undefined,
|
||||
setCopiedSessionId: () => undefined,
|
||||
startSessionWorktreeMenuLoad: () => ({
|
||||
cachedTargets: [],
|
||||
refreshTargets: Promise.resolve([]),
|
||||
}),
|
||||
onToggleCollapsedGroup: () => undefined,
|
||||
folderRename: null,
|
||||
setFolderRenameDraft: () => undefined,
|
||||
|
||||
@@ -108,6 +108,7 @@ export type SessionGroupSectionProps = {
|
||||
| 'setDeleteSessionConfirm'
|
||||
| 'startFolderRename'
|
||||
| 'setCopiedSessionId'
|
||||
| 'startSessionWorktreeMenuLoad'
|
||||
>;
|
||||
|
||||
const CollapsedFolderActivity: React.FC<{
|
||||
@@ -253,6 +254,7 @@ const areGroupPropsEqual = (prev: SessionGroupSectionProps, next: SessionGroupSe
|
||||
&& prev.setDeleteSessionConfirm === next.setDeleteSessionConfirm
|
||||
&& prev.startFolderRename === next.startFolderRename
|
||||
&& prev.setCopiedSessionId === next.setCopiedSessionId
|
||||
&& prev.startSessionWorktreeMenuLoad === next.startSessionWorktreeMenuLoad
|
||||
&& prev.setFolderRenameDraft === next.setFolderRenameDraft
|
||||
&& prev.clearFolderRename === next.clearFolderRename
|
||||
);
|
||||
@@ -852,10 +854,11 @@ function SessionGroupSectionBase(props: SessionGroupSectionProps): React.ReactNo
|
||||
setSessionSearchQuery={props.setSessionSearchQuery}
|
||||
setIsSessionSearchOpen={props.setIsSessionSearchOpen}
|
||||
deleteSessionConfirm={props.deleteSessionConfirm}
|
||||
setDeleteSessionConfirm={props.setDeleteSessionConfirm}
|
||||
startFolderRename={props.startFolderRename}
|
||||
setCopiedSessionId={props.setCopiedSessionId}
|
||||
/>)}
|
||||
setDeleteSessionConfirm={props.setDeleteSessionConfirm}
|
||||
startFolderRename={props.startFolderRename}
|
||||
setCopiedSessionId={props.setCopiedSessionId}
|
||||
startSessionWorktreeMenuLoad={props.startSessionWorktreeMenuLoad}
|
||||
/>)}
|
||||
</SessionFolderItem>
|
||||
)}
|
||||
</DroppableFolderWrapper>
|
||||
@@ -962,7 +965,8 @@ function SessionGroupSectionBase(props: SessionGroupSectionProps): React.ReactNo
|
||||
setDeleteSessionConfirm={props.setDeleteSessionConfirm}
|
||||
startFolderRename={props.startFolderRename}
|
||||
setCopiedSessionId={props.setCopiedSessionId}
|
||||
/>;
|
||||
startSessionWorktreeMenuLoad={props.startSessionWorktreeMenuLoad}
|
||||
/>;
|
||||
|
||||
const body = (
|
||||
<SessionFolderDndScope
|
||||
|
||||
@@ -58,6 +58,7 @@ type SessionProjectScrollerGroupProps = Pick<SessionGroupSectionProps,
|
||||
| 'setDeleteSessionConfirm'
|
||||
| 'startFolderRename'
|
||||
| 'setCopiedSessionId'
|
||||
| 'startSessionWorktreeMenuLoad'
|
||||
> & {
|
||||
pinnedSessionIds: Set<string>;
|
||||
sessionOrderIndex: Map<string, number>;
|
||||
|
||||
@@ -49,6 +49,7 @@ type Props = {
|
||||
| 'setDeleteSessionConfirm'
|
||||
| 'startFolderRename'
|
||||
| 'setCopiedSessionId'
|
||||
| 'startSessionWorktreeMenuLoad'
|
||||
>;
|
||||
|
||||
export const RecentSessionSection: React.FC<Props> = (props) => {
|
||||
@@ -162,6 +163,7 @@ export const RecentSessionSection: React.FC<Props> = (props) => {
|
||||
setDeleteSessionConfirm={props.setDeleteSessionConfirm}
|
||||
startFolderRename={props.startFolderRename}
|
||||
setCopiedSessionId={props.setCopiedSessionId}
|
||||
startSessionWorktreeMenuLoad={props.startSessionWorktreeMenuLoad}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -63,6 +63,7 @@ type Props = {
|
||||
| 'setDeleteSessionConfirm'
|
||||
| 'startFolderRename'
|
||||
| 'setCopiedSessionId'
|
||||
| 'startSessionWorktreeMenuLoad'
|
||||
>;
|
||||
|
||||
type RenderExtras = SessionNodeRenderExtras;
|
||||
@@ -198,6 +199,7 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
setDeleteSessionConfirm={props.setDeleteSessionConfirm}
|
||||
startFolderRename={props.startFolderRename}
|
||||
setCopiedSessionId={props.setCopiedSessionId}
|
||||
startSessionWorktreeMenuLoad={props.startSessionWorktreeMenuLoad}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,549 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import {
|
||||
buildSessionWorktreeMenuTargets,
|
||||
commitDiscoveredRawWorktreesByProject,
|
||||
getSessionWorktreeMenuState,
|
||||
markRawWorktreesByProjectMutation,
|
||||
startSessionWorktreeMenuLoad,
|
||||
} from './sessionWorktreeMenu';
|
||||
|
||||
const rawScope = (runtimeKey: string | null, entries: Array<[string, WorktreeMetadata[]]>) => ({
|
||||
current: {
|
||||
runtimeKey,
|
||||
revision: 0,
|
||||
worktreesByProject: new Map<string, WorktreeMetadata[]>(entries),
|
||||
},
|
||||
});
|
||||
|
||||
const worktree = (overrides: Partial<WorktreeMetadata> = {}): WorktreeMetadata => ({
|
||||
path: '/repo-feature',
|
||||
projectDirectory: '/repo',
|
||||
branch: 'feature',
|
||||
label: 'feature',
|
||||
name: 'feature',
|
||||
worktreeStatus: 'ready',
|
||||
worktreeSource: 'existing',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createDeferred = <T>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
describe('buildSessionWorktreeMenuTargets', () => {
|
||||
test('adds the canonical main worktree, includes the current source, dedupes by path, and sorts linked targets', () => {
|
||||
const targets = buildSessionWorktreeMenuTargets({
|
||||
projectPath: '/repo-linked',
|
||||
discoveredWorktrees: [
|
||||
worktree({ path: '/repo-zebra', branch: 'zebra', label: 'zebra', name: 'zebra' }),
|
||||
worktree({ path: '/repo-alpha', branch: 'alpha', label: 'alpha', name: 'alpha' }),
|
||||
worktree({ path: '/repo-current', branch: 'current', label: 'current', name: 'current' }),
|
||||
worktree({ path: '/repo-alpha/', branch: 'alpha', label: 'alpha duplicate', name: 'alpha-duplicate' }),
|
||||
],
|
||||
sourceDirectory: '/repo-current/',
|
||||
currentWorktree: worktree({
|
||||
path: '/repo-current',
|
||||
projectDirectory: '/repo',
|
||||
branch: 'current',
|
||||
label: 'Current branch',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(targets.map((target) => ({
|
||||
path: target.metadata.path,
|
||||
isPrimary: target.isPrimary,
|
||||
isCurrent: target.isCurrent,
|
||||
}))).toEqual([
|
||||
{ path: '/repo', isPrimary: true, isCurrent: false },
|
||||
{ path: '/repo-alpha', isPrimary: false, isCurrent: false },
|
||||
{ path: '/repo-current', isPrimary: false, isCurrent: true },
|
||||
{ path: '/repo-zebra', isPrimary: false, isCurrent: false },
|
||||
]);
|
||||
expect(targets[0]?.metadata.worktreeStatus).toBe('ready');
|
||||
expect(targets[0]?.metadata.worktreeSource).toBe('existing');
|
||||
});
|
||||
|
||||
test('prefers discovered primary metadata instead of synthetic fallback metadata', () => {
|
||||
const targets = buildSessionWorktreeMenuTargets({
|
||||
projectPath: '/repo-linked',
|
||||
discoveredWorktrees: [
|
||||
worktree({
|
||||
path: '/repo',
|
||||
projectDirectory: '/repo',
|
||||
branch: 'main',
|
||||
label: 'main',
|
||||
name: 'repo-primary',
|
||||
headState: 'branch',
|
||||
}),
|
||||
],
|
||||
sourceDirectory: '/repo-linked',
|
||||
currentWorktree: worktree({
|
||||
path: '/repo-linked',
|
||||
projectDirectory: '/repo',
|
||||
branch: 'feature',
|
||||
label: 'feature',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(targets[0]?.isPrimary).toBe(true);
|
||||
expect(targets[0]?.metadata.path).toBe('/repo');
|
||||
expect(targets[0]?.metadata.branch).toBe('main');
|
||||
expect(targets[0]?.metadata.label).toBe('main');
|
||||
expect(targets[0]?.metadata.name).toBe('repo-primary');
|
||||
expect(targets[0]?.metadata.headState).toBe('branch');
|
||||
});
|
||||
|
||||
test('sorts linked targets by effective compact label when branch is missing', () => {
|
||||
const targets = buildSessionWorktreeMenuTargets({
|
||||
projectPath: '/repo',
|
||||
discoveredWorktrees: [
|
||||
worktree({ path: '/repo-zed', branch: '', label: '', name: 'zed' }),
|
||||
worktree({ path: '/repo-alpha', branch: '', label: '', name: 'alpha' }),
|
||||
worktree({ path: '/repo-beta', branch: 'beta', label: 'beta', name: 'beta' }),
|
||||
],
|
||||
sourceDirectory: '/repo-current',
|
||||
currentWorktree: worktree({ path: '/repo-current', projectDirectory: '/repo', branch: '', label: '', name: 'current' }),
|
||||
});
|
||||
|
||||
expect(targets.map((target) => target.metadata.path)).toEqual([
|
||||
'/repo',
|
||||
'/repo-alpha',
|
||||
'/repo-beta',
|
||||
'/repo-current',
|
||||
'/repo-zed',
|
||||
]);
|
||||
});
|
||||
|
||||
test('uses the owning project root branch for a synthetic primary when git omits the queried checkout', () => {
|
||||
const targets = buildSessionWorktreeMenuTargets({
|
||||
projectPath: '/repo',
|
||||
discoveredWorktrees: [
|
||||
worktree({ path: '/repo-feature', projectDirectory: '/repo', branch: 'feature', label: 'feature' }),
|
||||
],
|
||||
sourceDirectory: '/repo-feature',
|
||||
currentWorktree: worktree({ path: '/repo-feature', projectDirectory: '/repo', branch: 'feature', label: 'feature' }),
|
||||
projectRootBranch: 'main',
|
||||
});
|
||||
|
||||
expect(targets[0]?.isPrimary).toBe(true);
|
||||
expect(targets[0]?.metadata.path).toBe('/repo');
|
||||
expect(targets[0]?.metadata.branch).toBe('main');
|
||||
expect(targets[0]?.metadata.label).toBe('main');
|
||||
expect(targets[0]?.metadata.headState).toBe('branch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('commitDiscoveredRawWorktreesByProject', () => {
|
||||
test('rejects an older aggregate commit after a newer targeted mutation and requests one bounded rediscovery', () => {
|
||||
const rawRef = rawScope('runtime-1', [
|
||||
['/repo', [worktree({ path: '/repo-old', projectDirectory: '/repo', branch: 'old', label: 'old' })]],
|
||||
]);
|
||||
const reruns: string[] = [];
|
||||
const published: Array<unknown> = [];
|
||||
const capturedRevision = rawRef.current.revision;
|
||||
|
||||
markRawWorktreesByProjectMutation(rawRef, 'runtime-1');
|
||||
|
||||
const committed = commitDiscoveredRawWorktreesByProject({
|
||||
rawWorktreesByProjectRef: rawRef,
|
||||
runtimeKey: 'runtime-1',
|
||||
capturedRevision,
|
||||
nextRawWorktreesByProject: new Map([
|
||||
['/repo', [worktree({ path: '/repo-stale', projectDirectory: '/repo', branch: 'stale', label: 'stale' })]],
|
||||
]),
|
||||
publishedWorktreesByProject: new Map(),
|
||||
partitionWorktreesByRegisteredProject: (_projects, worktreesByProject) => new Map(worktreesByProject),
|
||||
projects: [{ id: 'owner', path: '/repo' }],
|
||||
worktreeMapsEqual: () => false,
|
||||
recordWorktreesSeen: () => {},
|
||||
publishTopology: (next) => {
|
||||
published.push(next);
|
||||
},
|
||||
requestRediscovery: () => {
|
||||
reruns.push('rerun');
|
||||
},
|
||||
now: () => 123,
|
||||
});
|
||||
|
||||
expect(committed).toBe(false);
|
||||
expect(reruns).toEqual(['rerun']);
|
||||
expect(rawRef.current.worktreesByProject.get('/repo')?.map((entry) => entry.path)).toEqual(['/repo-old']);
|
||||
expect(published).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startSessionWorktreeMenuLoad', () => {
|
||||
test('returns cached targets immediately, forces only the owning project refresh, and publishes refreshed topology', async () => {
|
||||
const calls: Array<{ projectId: string; force: boolean }> = [];
|
||||
const published: Array<{ availableWorktrees: WorktreeMetadata[]; availableWorktreesByProject: Map<string, WorktreeMetadata[]> }> = [];
|
||||
const rawRef = rawScope('runtime-1', [
|
||||
['/repo-linked', [worktree({ path: '/repo-existing', branch: 'existing', label: 'existing', name: 'existing' })]],
|
||||
['/repo-other', [worktree({ path: '/other-worktree', projectDirectory: '/repo-other', branch: 'other', label: 'other', name: 'other' })]],
|
||||
]);
|
||||
|
||||
const load = startSessionWorktreeMenuLoad(
|
||||
{
|
||||
projectId: 'linked',
|
||||
sourceDirectory: '/repo-current',
|
||||
currentWorktree: worktree({ path: '/repo-current', branch: 'current', label: 'current' }),
|
||||
},
|
||||
{
|
||||
projects: [
|
||||
{ id: 'linked', path: '/repo-linked' },
|
||||
{ id: 'other', path: '/repo-other' },
|
||||
],
|
||||
getCurrentProjects: () => [
|
||||
{ id: 'linked', path: '/repo-linked' },
|
||||
{ id: 'other', path: '/repo-other' },
|
||||
],
|
||||
rawWorktreesByProjectRef: rawRef,
|
||||
getPublishedWorktreesByProject: () => new Map(),
|
||||
resolveProject: () => null,
|
||||
listProjectWorktrees: async (project, options) => {
|
||||
calls.push({ projectId: project.id, force: options.force });
|
||||
return [
|
||||
worktree({ path: '/repo-new', branch: 'aaa', label: 'aaa', name: 'aaa' }),
|
||||
worktree({ path: '/repo-current', branch: 'current', label: 'current', name: 'current' }),
|
||||
];
|
||||
},
|
||||
partitionWorktreesByRegisteredProject: (_projects, worktreesByProject) => new Map(worktreesByProject),
|
||||
worktreeMapsEqual: () => false,
|
||||
recordWorktreesSeen: () => {},
|
||||
publishTopology: (next) => {
|
||||
published.push(next);
|
||||
},
|
||||
getRuntimeKey: () => 'runtime-1',
|
||||
now: () => 123,
|
||||
projectRootBranch: null,
|
||||
},
|
||||
);
|
||||
|
||||
expect(load.cachedTargets.map((target) => target.metadata.path)).toEqual([
|
||||
'/repo',
|
||||
'/repo-current',
|
||||
'/repo-existing',
|
||||
]);
|
||||
|
||||
const freshTargets = await load.refreshTargets;
|
||||
|
||||
expect(calls).toEqual([{ projectId: 'linked', force: true }]);
|
||||
expect(freshTargets.map((target) => target.metadata.path)).toEqual([
|
||||
'/repo',
|
||||
'/repo-new',
|
||||
'/repo-current',
|
||||
]);
|
||||
expect(rawRef.current.worktreesByProject.get('/repo-linked')?.map((entry) => entry.path)).toEqual([
|
||||
'/repo-new',
|
||||
'/repo-current',
|
||||
]);
|
||||
expect(published).toHaveLength(1);
|
||||
expect(published[0]?.availableWorktreesByProject.get('/repo-linked')?.map((entry) => entry.path)).toEqual([
|
||||
'/repo-new',
|
||||
'/repo-current',
|
||||
]);
|
||||
});
|
||||
|
||||
test('rejects refresh failures without mutating topology and keeps cached targets available for the menu', async () => {
|
||||
const published: Array<unknown> = [];
|
||||
const existing = worktree({ path: '/repo-existing', branch: 'existing', label: 'existing', name: 'existing' });
|
||||
const rawRef = rawScope('runtime-1', [
|
||||
['/repo-linked', [existing]],
|
||||
]);
|
||||
|
||||
const load = startSessionWorktreeMenuLoad(
|
||||
{
|
||||
projectId: 'linked',
|
||||
sourceDirectory: '/repo-current',
|
||||
currentWorktree: worktree({ path: '/repo-current', branch: 'current', label: 'current' }),
|
||||
},
|
||||
{
|
||||
projects: [{ id: 'linked', path: '/repo-linked' }],
|
||||
getCurrentProjects: () => [{ id: 'linked', path: '/repo-linked' }],
|
||||
rawWorktreesByProjectRef: rawRef,
|
||||
getPublishedWorktreesByProject: () => new Map([['/repo-linked', [existing]]]),
|
||||
resolveProject: () => null,
|
||||
listProjectWorktrees: async () => {
|
||||
throw new Error('git failed');
|
||||
},
|
||||
partitionWorktreesByRegisteredProject: (_projects, worktreesByProject) => new Map(worktreesByProject),
|
||||
worktreeMapsEqual: () => false,
|
||||
recordWorktreesSeen: () => {},
|
||||
publishTopology: (next) => {
|
||||
published.push(next);
|
||||
},
|
||||
getRuntimeKey: () => 'runtime-1',
|
||||
now: () => 123,
|
||||
projectRootBranch: null,
|
||||
},
|
||||
);
|
||||
|
||||
expect(load.cachedTargets.map((target) => target.metadata.path)).toEqual([
|
||||
'/repo',
|
||||
'/repo-current',
|
||||
'/repo-existing',
|
||||
]);
|
||||
const refreshError = await load.refreshTargets.catch((error) => error);
|
||||
expect(refreshError).toBeInstanceOf(Error);
|
||||
expect(refreshError.message).toBe('git failed');
|
||||
expect(rawRef.current.worktreesByProject.get('/repo-linked')).toEqual([existing]);
|
||||
expect(published).toEqual([]);
|
||||
});
|
||||
|
||||
test('seeds an empty raw scope from published topology so a failed first refresh preserves prior topology', async () => {
|
||||
const publishedTopology = new Map<string, WorktreeMetadata[]>([
|
||||
['/repo-linked', [worktree({ path: '/repo-existing', branch: 'existing', label: 'existing', name: 'existing' })]],
|
||||
]);
|
||||
const rawRef = rawScope(null, []);
|
||||
|
||||
const load = startSessionWorktreeMenuLoad(
|
||||
{
|
||||
projectId: 'linked',
|
||||
sourceDirectory: '/repo-current',
|
||||
currentWorktree: worktree({ path: '/repo-current', branch: 'current', label: 'current' }),
|
||||
},
|
||||
{
|
||||
projects: [{ id: 'linked', path: '/repo-linked' }],
|
||||
getCurrentProjects: () => [{ id: 'linked', path: '/repo-linked' }],
|
||||
rawWorktreesByProjectRef: rawRef,
|
||||
getPublishedWorktreesByProject: () => publishedTopology,
|
||||
resolveProject: () => null,
|
||||
listProjectWorktrees: async () => {
|
||||
throw new Error('git failed');
|
||||
},
|
||||
partitionWorktreesByRegisteredProject: (_projects, worktreesByProject) => new Map(worktreesByProject),
|
||||
worktreeMapsEqual: () => true,
|
||||
recordWorktreesSeen: () => {},
|
||||
publishTopology: () => {
|
||||
throw new Error('should not publish on failed refresh');
|
||||
},
|
||||
getRuntimeKey: () => 'runtime-1',
|
||||
now: () => 123,
|
||||
projectRootBranch: null,
|
||||
},
|
||||
);
|
||||
|
||||
expect(load.cachedTargets.map((target) => target.metadata.path)).toEqual([
|
||||
'/repo',
|
||||
'/repo-current',
|
||||
'/repo-existing',
|
||||
]);
|
||||
const refreshError = await load.refreshTargets.catch((error) => error);
|
||||
expect(refreshError).toBeInstanceOf(Error);
|
||||
expect(refreshError.message).toBe('git failed');
|
||||
expect(rawRef.current.runtimeKey).toBe('runtime-1');
|
||||
expect(rawRef.current.worktreesByProject.get('/repo-linked')?.map((entry) => entry.path)).toEqual(['/repo-existing']);
|
||||
});
|
||||
|
||||
test('applies a non-owner shared-repository refresh to the owner raw and published topology', async () => {
|
||||
const published: Array<{ availableWorktreesByProject: Map<string, WorktreeMetadata[]> }> = [];
|
||||
const ownerExisting = worktree({ path: '/repo-old', branch: 'old', label: 'old', name: 'old' });
|
||||
const rawRef = rawScope('runtime-1', [
|
||||
['/repo', [ownerExisting]],
|
||||
['/repo-linked', [worktree({ path: '/repo-other-stale', branch: 'stale', label: 'stale', name: 'stale' })]],
|
||||
]);
|
||||
|
||||
const load = startSessionWorktreeMenuLoad(
|
||||
{
|
||||
projectId: 'linked',
|
||||
sourceDirectory: '/repo-linked',
|
||||
currentWorktree: worktree({ path: '/repo-linked', projectDirectory: '/repo', branch: 'feature', label: 'feature' }),
|
||||
},
|
||||
{
|
||||
projects: [
|
||||
{ id: 'owner', path: '/repo' },
|
||||
{ id: 'linked', path: '/repo-linked' },
|
||||
],
|
||||
getCurrentProjects: () => [
|
||||
{ id: 'owner', path: '/repo' },
|
||||
{ id: 'linked', path: '/repo-linked' },
|
||||
],
|
||||
rawWorktreesByProjectRef: rawRef,
|
||||
getPublishedWorktreesByProject: () => new Map([['/repo', [ownerExisting]]]),
|
||||
resolveProject: () => null,
|
||||
listProjectWorktrees: async () => [
|
||||
worktree({ path: '/repo-new', projectDirectory: '/repo', branch: 'new', label: 'new', name: 'new' }),
|
||||
],
|
||||
partitionWorktreesByRegisteredProject: (projects, worktreesByProject) => {
|
||||
const ownerPath = projects[0]!.path;
|
||||
return new Map([[ownerPath, worktreesByProject.get(ownerPath) ?? []]]);
|
||||
},
|
||||
worktreeMapsEqual: () => false,
|
||||
recordWorktreesSeen: () => {},
|
||||
publishTopology: (next) => {
|
||||
published.push({ availableWorktreesByProject: next.availableWorktreesByProject });
|
||||
},
|
||||
getRuntimeKey: () => 'runtime-1',
|
||||
now: () => 123,
|
||||
projectRootBranch: null,
|
||||
},
|
||||
);
|
||||
|
||||
await load.refreshTargets;
|
||||
|
||||
expect(rawRef.current.worktreesByProject.get('/repo')?.map((entry) => entry.path)).toEqual(['/repo-new']);
|
||||
expect(published[0]?.availableWorktreesByProject.get('/repo')?.map((entry) => entry.path)).toEqual(['/repo-new']);
|
||||
});
|
||||
|
||||
test('re-seeds raw topology on runtime change and ignores stale completions', async () => {
|
||||
let runtimeKey = 'runtime-2';
|
||||
const refreshDeferred = createDeferred<WorktreeMetadata[]>();
|
||||
const published: Array<unknown> = [];
|
||||
const rawRef = rawScope('runtime-1', [
|
||||
['/old-runtime-repo', [worktree({ path: '/old-runtime-worktree', projectDirectory: '/old-runtime-repo' })]],
|
||||
]);
|
||||
const publishedCurrentRuntime = new Map<string, WorktreeMetadata[]>([
|
||||
['/repo-linked', [worktree({ path: '/repo-existing', branch: 'existing', label: 'existing', name: 'existing' })]],
|
||||
]);
|
||||
|
||||
const load = startSessionWorktreeMenuLoad(
|
||||
{
|
||||
projectId: 'linked',
|
||||
sourceDirectory: '/repo-current',
|
||||
currentWorktree: worktree({ path: '/repo-current', branch: 'current', label: 'current' }),
|
||||
},
|
||||
{
|
||||
projects: [{ id: 'linked', path: '/repo-linked' }],
|
||||
getCurrentProjects: () => [{ id: 'linked', path: '/repo-linked' }],
|
||||
rawWorktreesByProjectRef: rawRef,
|
||||
getPublishedWorktreesByProject: () => publishedCurrentRuntime,
|
||||
resolveProject: () => null,
|
||||
listProjectWorktrees: async () => refreshDeferred.promise,
|
||||
partitionWorktreesByRegisteredProject: (_projects, worktreesByProject) => new Map(worktreesByProject),
|
||||
worktreeMapsEqual: () => false,
|
||||
recordWorktreesSeen: () => {},
|
||||
publishTopology: (next) => {
|
||||
published.push(next);
|
||||
},
|
||||
getRuntimeKey: () => runtimeKey,
|
||||
now: () => 123,
|
||||
projectRootBranch: null,
|
||||
},
|
||||
);
|
||||
|
||||
expect(load.cachedTargets.map((target) => target.metadata.path)).toEqual([
|
||||
'/repo',
|
||||
'/repo-current',
|
||||
'/repo-existing',
|
||||
]);
|
||||
expect(rawRef.current.runtimeKey).toBe('runtime-2');
|
||||
expect(rawRef.current.worktreesByProject.get('/repo-linked')?.map((entry) => entry.path)).toEqual(['/repo-existing']);
|
||||
|
||||
runtimeKey = 'runtime-3';
|
||||
refreshDeferred.resolve([worktree({ path: '/repo-new', projectDirectory: '/repo', branch: 'new', label: 'new', name: 'new' })]);
|
||||
|
||||
const refreshError = await load.refreshTargets.catch((error) => error);
|
||||
expect(refreshError).toBeInstanceOf(Error);
|
||||
expect(refreshError.message).toBe('Runtime changed during worktree refresh');
|
||||
expect(rawRef.current.runtimeKey).toBe('runtime-2');
|
||||
expect(rawRef.current.worktreesByProject.get('/repo-linked')?.map((entry) => entry.path)).toEqual(['/repo-existing']);
|
||||
expect(published).toEqual([]);
|
||||
});
|
||||
|
||||
test('rejects a deferred refresh when the owning project is removed before commit', async () => {
|
||||
const refreshDeferred = createDeferred<WorktreeMetadata[]>();
|
||||
const existing = worktree({ path: '/repo-existing', branch: 'existing', label: 'existing', name: 'existing' });
|
||||
const published: Array<{ availableWorktreesByProject: Map<string, WorktreeMetadata[]> }> = [];
|
||||
const rawRef = rawScope('runtime-1', [
|
||||
['/repo-linked', [existing]],
|
||||
]);
|
||||
let currentProjects = [{ id: 'linked', path: '/repo-linked' }];
|
||||
|
||||
const load = startSessionWorktreeMenuLoad(
|
||||
{
|
||||
projectId: 'linked',
|
||||
sourceDirectory: '/repo-current',
|
||||
currentWorktree: worktree({ path: '/repo-current', branch: 'current', label: 'current' }),
|
||||
},
|
||||
{
|
||||
projects: currentProjects,
|
||||
rawWorktreesByProjectRef: rawRef,
|
||||
getPublishedWorktreesByProject: () => new Map([['/repo-linked', [existing]]]),
|
||||
resolveProject: () => null,
|
||||
listProjectWorktrees: async () => refreshDeferred.promise,
|
||||
partitionWorktreesByRegisteredProject: (_projects, worktreesByProject) => new Map(worktreesByProject),
|
||||
worktreeMapsEqual: () => false,
|
||||
recordWorktreesSeen: () => {},
|
||||
publishTopology: (next) => {
|
||||
published.push({ availableWorktreesByProject: next.availableWorktreesByProject });
|
||||
},
|
||||
getCurrentProjects: () => currentProjects,
|
||||
getRuntimeKey: () => 'runtime-1',
|
||||
now: () => 123,
|
||||
projectRootBranch: null,
|
||||
},
|
||||
);
|
||||
|
||||
currentProjects = [];
|
||||
refreshDeferred.resolve([
|
||||
worktree({ path: '/repo-new', projectDirectory: '/repo', branch: 'new', label: 'new', name: 'new' }),
|
||||
]);
|
||||
|
||||
const refreshError = await load.refreshTargets.catch((error) => error);
|
||||
|
||||
expect(refreshError).toBeInstanceOf(Error);
|
||||
expect(refreshError.message).toBe('Project removed during worktree refresh');
|
||||
expect(rawRef.current.worktreesByProject.get('/repo-linked')).toEqual([existing]);
|
||||
expect(published).toEqual([]);
|
||||
});
|
||||
|
||||
test('falls back to resolving the owning configured project from the source directory when projectId is missing', async () => {
|
||||
const calls: string[] = [];
|
||||
const load = startSessionWorktreeMenuLoad(
|
||||
{
|
||||
projectId: null,
|
||||
sourceDirectory: '/repo-feature',
|
||||
currentWorktree: worktree({ path: '/repo-feature', projectDirectory: '/repo', branch: 'feature', label: 'feature' }),
|
||||
},
|
||||
{
|
||||
projects: [{ id: 'owner', path: '/repo' }],
|
||||
getCurrentProjects: () => [{ id: 'owner', path: '/repo' }],
|
||||
rawWorktreesByProjectRef: rawScope('runtime-1', []),
|
||||
getPublishedWorktreesByProject: () => new Map(),
|
||||
resolveProject: (directory) => {
|
||||
calls.push(directory);
|
||||
return { id: 'owner', path: '/repo' };
|
||||
},
|
||||
listProjectWorktrees: async (project) => [
|
||||
worktree({ path: '/repo-another', projectDirectory: project.path, branch: 'another', label: 'another', name: 'another' }),
|
||||
],
|
||||
partitionWorktreesByRegisteredProject: (_projects, worktreesByProject) => new Map(worktreesByProject),
|
||||
worktreeMapsEqual: () => false,
|
||||
recordWorktreesSeen: () => {},
|
||||
publishTopology: () => {},
|
||||
getRuntimeKey: () => 'runtime-1',
|
||||
now: () => 123,
|
||||
projectRootBranch: 'main',
|
||||
},
|
||||
);
|
||||
|
||||
expect(calls).toEqual(['/repo-feature']);
|
||||
expect(load.cachedTargets.map((target) => target.metadata.path)).toEqual(['/repo', '/repo-feature']);
|
||||
const refreshTargets = await load.refreshTargets;
|
||||
expect(refreshTargets.map((target) => ({
|
||||
path: target.metadata.path,
|
||||
branch: target.metadata.branch,
|
||||
}))).toEqual([
|
||||
{ path: '/repo', branch: 'main' },
|
||||
{ path: '/repo-another', branch: 'another' },
|
||||
{ path: '/repo-feature', branch: 'feature' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionWorktreeMenuState', () => {
|
||||
test('keeps the new worktree action available when refresh fails without cached targets', () => {
|
||||
expect(getSessionWorktreeMenuState({
|
||||
targets: [],
|
||||
isRefreshing: false,
|
||||
loadFailed: true,
|
||||
})).toEqual({
|
||||
refreshState: 'error',
|
||||
showNewWorktreeAction: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,409 @@
|
||||
import type { ProjectRef } from '@/lib/worktrees/worktreeManager';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
|
||||
export type SessionWorktreeMenuTarget = {
|
||||
metadata: WorktreeMetadata;
|
||||
isPrimary: boolean;
|
||||
isCurrent: boolean;
|
||||
};
|
||||
|
||||
export type StartSessionWorktreeMenuLoadArgs = {
|
||||
projectId: string | null;
|
||||
sourceDirectory: string | null;
|
||||
currentWorktree: WorktreeMetadata | null;
|
||||
};
|
||||
|
||||
export type StartSessionWorktreeMenuLoadResult = {
|
||||
cachedTargets: SessionWorktreeMenuTarget[];
|
||||
refreshTargets: Promise<SessionWorktreeMenuTarget[]>;
|
||||
};
|
||||
|
||||
type SessionWorktreeMenuState = {
|
||||
refreshState: 'loading' | 'error' | null;
|
||||
showNewWorktreeAction: boolean;
|
||||
};
|
||||
|
||||
type StartSessionWorktreeMenuLoadDependencies = {
|
||||
projects: ReadonlyArray<ProjectRef>;
|
||||
getCurrentProjects: () => ReadonlyArray<ProjectRef>;
|
||||
rawWorktreesByProjectRef: { current: RawWorktreesByProjectScope };
|
||||
getPublishedWorktreesByProject: () => Map<string, WorktreeMetadata[]>;
|
||||
resolveProject: (directory: string) => ProjectRef | null;
|
||||
listProjectWorktrees: (project: ProjectRef, options: { force: true }) => Promise<WorktreeMetadata[]>;
|
||||
partitionWorktreesByRegisteredProject: (
|
||||
projects: ReadonlyArray<Pick<ProjectRef, 'path'>>,
|
||||
worktreesByProject: ReadonlyMap<string, WorktreeMetadata[]>,
|
||||
) => Map<string, WorktreeMetadata[]>;
|
||||
worktreeMapsEqual: (
|
||||
a: Map<string, WorktreeMetadata[]>,
|
||||
b: Map<string, WorktreeMetadata[]>,
|
||||
) => boolean;
|
||||
recordWorktreesSeen: (paths: Iterable<string | null | undefined>, seenAt: number) => void;
|
||||
publishTopology: (next: {
|
||||
availableWorktrees: WorktreeMetadata[];
|
||||
availableWorktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||
}) => void;
|
||||
getRuntimeKey: () => string;
|
||||
now: () => number;
|
||||
projectRootBranch: string | null;
|
||||
};
|
||||
|
||||
type RequestRediscovery = () => void;
|
||||
|
||||
export type RawWorktreesByProjectScope = {
|
||||
runtimeKey: string | null;
|
||||
revision: number;
|
||||
worktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||
};
|
||||
|
||||
export const markRawWorktreesByProjectMutation = (
|
||||
rawWorktreesByProjectRef: { current: RawWorktreesByProjectScope },
|
||||
runtimeKey: string,
|
||||
): number => {
|
||||
if (rawWorktreesByProjectRef.current.runtimeKey !== runtimeKey) {
|
||||
return rawWorktreesByProjectRef.current.revision;
|
||||
}
|
||||
rawWorktreesByProjectRef.current = {
|
||||
...rawWorktreesByProjectRef.current,
|
||||
revision: rawWorktreesByProjectRef.current.revision + 1,
|
||||
};
|
||||
return rawWorktreesByProjectRef.current.revision;
|
||||
};
|
||||
|
||||
const cloneWorktreesByProject = (
|
||||
worktreesByProject: ReadonlyMap<string, WorktreeMetadata[]>,
|
||||
): Map<string, WorktreeMetadata[]> => {
|
||||
return new Map(
|
||||
[...worktreesByProject.entries()].map(([projectPath, worktrees]) => [projectPath, worktrees.map((worktree) => cloneMetadata(worktree))]),
|
||||
);
|
||||
};
|
||||
|
||||
export const ensureRawWorktreesByProjectScope = (args: {
|
||||
rawWorktreesByProjectRef: { current: RawWorktreesByProjectScope };
|
||||
publishedWorktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||
runtimeKey: string;
|
||||
}): RawWorktreesByProjectScope => {
|
||||
const shouldReseed = args.rawWorktreesByProjectRef.current.runtimeKey !== args.runtimeKey
|
||||
|| (args.rawWorktreesByProjectRef.current.worktreesByProject.size === 0 && args.publishedWorktreesByProject.size > 0);
|
||||
|
||||
if (shouldReseed) {
|
||||
args.rawWorktreesByProjectRef.current = {
|
||||
runtimeKey: args.runtimeKey,
|
||||
revision: args.rawWorktreesByProjectRef.current.runtimeKey === args.runtimeKey
|
||||
? args.rawWorktreesByProjectRef.current.revision
|
||||
: 0,
|
||||
worktreesByProject: cloneWorktreesByProject(args.publishedWorktreesByProject),
|
||||
};
|
||||
}
|
||||
|
||||
return args.rawWorktreesByProjectRef.current;
|
||||
};
|
||||
|
||||
const compareLinkedTargets = (a: SessionWorktreeMenuTarget, b: SessionWorktreeMenuTarget): number => {
|
||||
const aLabel = a.metadata.branch || a.metadata.name || a.metadata.label || a.metadata.path;
|
||||
const bLabel = b.metadata.branch || b.metadata.name || b.metadata.label || b.metadata.path;
|
||||
const labelCompare = aLabel.localeCompare(bLabel, undefined, { sensitivity: 'base' });
|
||||
if (labelCompare !== 0) {
|
||||
return labelCompare;
|
||||
}
|
||||
|
||||
return a.metadata.path.localeCompare(b.metadata.path, undefined, { sensitivity: 'base' });
|
||||
};
|
||||
|
||||
const buildFallbackLabel = (path: string): string => {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
return parts[parts.length - 1] ?? path;
|
||||
};
|
||||
|
||||
const cloneMetadata = (metadata: WorktreeMetadata): WorktreeMetadata => ({
|
||||
...metadata,
|
||||
path: normalizePath(metadata.path) ?? metadata.path,
|
||||
projectDirectory: normalizePath(metadata.projectDirectory) ?? metadata.projectDirectory,
|
||||
worktreeRoot: normalizePath(metadata.worktreeRoot ?? metadata.path) ?? metadata.worktreeRoot,
|
||||
});
|
||||
|
||||
const buildSyntheticWorktreeMetadata = (args: {
|
||||
path: string;
|
||||
projectDirectory: string;
|
||||
currentWorktree: WorktreeMetadata | null;
|
||||
projectRootBranch?: string | null;
|
||||
}): WorktreeMetadata => {
|
||||
const { currentWorktree, path, projectDirectory, projectRootBranch } = args;
|
||||
const currentPath = normalizePath(currentWorktree?.path ?? null);
|
||||
const isCurrentPath = currentPath === path;
|
||||
const syntheticBranch = isCurrentPath ? (currentWorktree?.branch ?? '') : (projectRootBranch ?? '');
|
||||
|
||||
const syntheticMetadata: WorktreeMetadata = {
|
||||
path,
|
||||
projectDirectory,
|
||||
branch: syntheticBranch,
|
||||
label: isCurrentPath
|
||||
? (currentWorktree?.label || currentWorktree?.branch || currentWorktree?.name || buildFallbackLabel(path))
|
||||
: (projectRootBranch || buildFallbackLabel(path)),
|
||||
name: isCurrentPath ? currentWorktree?.name : undefined,
|
||||
worktreeRoot: isCurrentPath
|
||||
? (normalizePath(currentWorktree?.worktreeRoot ?? path) ?? path)
|
||||
: path,
|
||||
worktreeStatus: isCurrentPath
|
||||
? (currentWorktree?.worktreeStatus ?? 'ready')
|
||||
: 'ready',
|
||||
worktreeSource: isCurrentPath
|
||||
? (currentWorktree?.worktreeSource ?? 'existing')
|
||||
: 'existing',
|
||||
headState: isCurrentPath ? currentWorktree?.headState : (projectRootBranch ? 'branch' : undefined),
|
||||
};
|
||||
|
||||
return isCurrentPath && currentWorktree
|
||||
? { ...currentWorktree, ...syntheticMetadata }
|
||||
: syntheticMetadata;
|
||||
};
|
||||
|
||||
export const buildSessionWorktreeMenuTargets = (args: {
|
||||
projectPath: string | null;
|
||||
discoveredWorktrees: ReadonlyArray<WorktreeMetadata>;
|
||||
sourceDirectory: string | null;
|
||||
currentWorktree: WorktreeMetadata | null;
|
||||
projectRootBranch?: string | null;
|
||||
}): SessionWorktreeMenuTarget[] => {
|
||||
const normalizedProjectPath = normalizePath(args.projectPath ?? null);
|
||||
const normalizedSourceDirectory = normalizePath(args.sourceDirectory ?? null)
|
||||
?? normalizePath(args.currentWorktree?.path ?? null);
|
||||
const discoveredPrimaryPath = normalizePath(
|
||||
args.discoveredWorktrees.find((worktree) => normalizePath(worktree.projectDirectory ?? null))?.projectDirectory ?? null,
|
||||
);
|
||||
const currentPrimaryPath = normalizePath(args.currentWorktree?.projectDirectory ?? null);
|
||||
const primaryPath = discoveredPrimaryPath ?? currentPrimaryPath ?? normalizedProjectPath;
|
||||
|
||||
const targetsByPath = new Map<string, SessionWorktreeMenuTarget>();
|
||||
const pushTarget = (target: SessionWorktreeMenuTarget): void => {
|
||||
const normalizedPath = normalizePath(target.metadata.path ?? null);
|
||||
if (!normalizedPath || targetsByPath.has(normalizedPath)) {
|
||||
return;
|
||||
}
|
||||
targetsByPath.set(normalizedPath, {
|
||||
...target,
|
||||
metadata: cloneMetadata({
|
||||
...target.metadata,
|
||||
path: normalizedPath,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
for (const worktree of args.discoveredWorktrees) {
|
||||
const normalizedPath = normalizePath(worktree.path ?? null);
|
||||
if (!normalizedPath) {
|
||||
continue;
|
||||
}
|
||||
pushTarget({
|
||||
metadata: cloneMetadata({
|
||||
...worktree,
|
||||
path: normalizedPath,
|
||||
projectDirectory: normalizePath(worktree.projectDirectory ?? null) ?? primaryPath ?? normalizedProjectPath ?? normalizedPath,
|
||||
}),
|
||||
isPrimary: primaryPath === normalizedPath,
|
||||
isCurrent: normalizedSourceDirectory === normalizedPath,
|
||||
});
|
||||
}
|
||||
|
||||
if (primaryPath && !targetsByPath.has(primaryPath)) {
|
||||
pushTarget({
|
||||
metadata: buildSyntheticWorktreeMetadata({
|
||||
path: primaryPath,
|
||||
projectDirectory: primaryPath,
|
||||
currentWorktree: args.currentWorktree,
|
||||
projectRootBranch: args.projectRootBranch,
|
||||
}),
|
||||
isPrimary: true,
|
||||
isCurrent: normalizedSourceDirectory === primaryPath,
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedSourceDirectory && !targetsByPath.has(normalizedSourceDirectory)) {
|
||||
pushTarget({
|
||||
metadata: buildSyntheticWorktreeMetadata({
|
||||
path: normalizedSourceDirectory,
|
||||
projectDirectory: primaryPath ?? normalizedProjectPath ?? normalizedSourceDirectory,
|
||||
currentWorktree: args.currentWorktree,
|
||||
projectRootBranch: args.projectRootBranch,
|
||||
}),
|
||||
isPrimary: primaryPath === normalizedSourceDirectory,
|
||||
isCurrent: true,
|
||||
});
|
||||
}
|
||||
|
||||
const primaryTargets: SessionWorktreeMenuTarget[] = [];
|
||||
const linkedTargets: SessionWorktreeMenuTarget[] = [];
|
||||
for (const target of targetsByPath.values()) {
|
||||
if (target.isPrimary) {
|
||||
primaryTargets.push(target);
|
||||
continue;
|
||||
}
|
||||
linkedTargets.push(target);
|
||||
}
|
||||
|
||||
primaryTargets.sort((a, b) => a.metadata.path.localeCompare(b.metadata.path, undefined, { sensitivity: 'base' }));
|
||||
linkedTargets.sort(compareLinkedTargets);
|
||||
return [...primaryTargets, ...linkedTargets];
|
||||
};
|
||||
|
||||
export const startSessionWorktreeMenuLoad = (
|
||||
args: StartSessionWorktreeMenuLoadArgs,
|
||||
deps: StartSessionWorktreeMenuLoadDependencies,
|
||||
): StartSessionWorktreeMenuLoadResult => {
|
||||
const runtimeKey = deps.getRuntimeKey();
|
||||
const publishedWorktreesByProject = deps.getPublishedWorktreesByProject();
|
||||
const rawScope = ensureRawWorktreesByProjectScope({
|
||||
rawWorktreesByProjectRef: deps.rawWorktreesByProjectRef,
|
||||
publishedWorktreesByProject,
|
||||
runtimeKey,
|
||||
});
|
||||
const projectById = args.projectId
|
||||
? deps.projects.find((candidate) => candidate.id === args.projectId) ?? null
|
||||
: null;
|
||||
const project = projectById ?? (args.sourceDirectory ? deps.resolveProject(args.sourceDirectory) : null);
|
||||
const normalizedProjectPath = normalizePath(project?.path ?? null);
|
||||
const cachedTargets = buildSessionWorktreeMenuTargets({
|
||||
projectPath: normalizedProjectPath,
|
||||
discoveredWorktrees: normalizedProjectPath
|
||||
? (rawScope.worktreesByProject.get(normalizedProjectPath) ?? [])
|
||||
: [],
|
||||
sourceDirectory: args.sourceDirectory,
|
||||
currentWorktree: args.currentWorktree,
|
||||
projectRootBranch: deps.projectRootBranch,
|
||||
});
|
||||
|
||||
return {
|
||||
cachedTargets,
|
||||
refreshTargets: (async () => {
|
||||
if (!project || !normalizedProjectPath) {
|
||||
throw new Error('Unable to resolve worktree project');
|
||||
}
|
||||
|
||||
const refreshedWorktrees = await deps.listProjectWorktrees(project, { force: true });
|
||||
|
||||
if (deps.getRuntimeKey() !== runtimeKey) {
|
||||
throw new Error('Runtime changed during worktree refresh');
|
||||
}
|
||||
|
||||
const currentProjects = deps.getCurrentProjects();
|
||||
const currentProject = currentProjects.find((candidate) => candidate.id === project.id) ?? null;
|
||||
if (!currentProject || normalizePath(currentProject.path ?? null) !== normalizedProjectPath) {
|
||||
throw new Error('Project removed during worktree refresh');
|
||||
}
|
||||
|
||||
const currentRawScope = ensureRawWorktreesByProjectScope({
|
||||
rawWorktreesByProjectRef: deps.rawWorktreesByProjectRef,
|
||||
publishedWorktreesByProject: deps.getPublishedWorktreesByProject(),
|
||||
runtimeKey,
|
||||
});
|
||||
const nextRawTopology = cloneWorktreesByProject(currentRawScope.worktreesByProject);
|
||||
const nextProjectWorktrees = [...refreshedWorktrees]
|
||||
.map((worktree) => cloneMetadata(worktree))
|
||||
.sort((a, b) => compareLinkedTargets(
|
||||
{ metadata: a, isPrimary: false, isCurrent: false },
|
||||
{ metadata: b, isPrimary: false, isCurrent: false },
|
||||
));
|
||||
|
||||
const refreshedRepositoryRoot = normalizePath(
|
||||
nextProjectWorktrees.find((worktree) => normalizePath(worktree.projectDirectory ?? null))?.projectDirectory
|
||||
?? args.currentWorktree?.projectDirectory
|
||||
?? project.path,
|
||||
);
|
||||
const matchingProjectPaths = new Set<string>([normalizedProjectPath]);
|
||||
for (const [projectPath, worktrees] of nextRawTopology.entries()) {
|
||||
const repositoryRoot = normalizePath(
|
||||
worktrees.find((worktree) => normalizePath(worktree.projectDirectory ?? null))?.projectDirectory ?? projectPath,
|
||||
);
|
||||
if (repositoryRoot && repositoryRoot === refreshedRepositoryRoot) {
|
||||
matchingProjectPaths.add(projectPath);
|
||||
}
|
||||
}
|
||||
for (const projectPath of matchingProjectPaths) {
|
||||
if (nextProjectWorktrees.length === 0) {
|
||||
nextRawTopology.delete(projectPath);
|
||||
continue;
|
||||
}
|
||||
nextRawTopology.set(projectPath, nextProjectWorktrees.map((worktree) => cloneMetadata(worktree)));
|
||||
}
|
||||
|
||||
markRawWorktreesByProjectMutation(deps.rawWorktreesByProjectRef, runtimeKey);
|
||||
deps.rawWorktreesByProjectRef.current = {
|
||||
runtimeKey,
|
||||
revision: deps.rawWorktreesByProjectRef.current.revision,
|
||||
worktreesByProject: nextRawTopology,
|
||||
};
|
||||
|
||||
const partitionedWorktreesByProject = deps.partitionWorktreesByRegisteredProject(currentProjects, nextRawTopology);
|
||||
const allWorktrees = [...partitionedWorktreesByProject.values()].flat();
|
||||
deps.recordWorktreesSeen(allWorktrees.map((worktree) => worktree.path), deps.now());
|
||||
|
||||
const latestPublishedWorktreesByProject = deps.getPublishedWorktreesByProject();
|
||||
if (!deps.worktreeMapsEqual(partitionedWorktreesByProject, latestPublishedWorktreesByProject)) {
|
||||
deps.publishTopology({
|
||||
availableWorktrees: allWorktrees,
|
||||
availableWorktreesByProject: partitionedWorktreesByProject,
|
||||
});
|
||||
}
|
||||
|
||||
return buildSessionWorktreeMenuTargets({
|
||||
projectPath: normalizedProjectPath,
|
||||
discoveredWorktrees: nextProjectWorktrees,
|
||||
sourceDirectory: args.sourceDirectory,
|
||||
currentWorktree: args.currentWorktree,
|
||||
projectRootBranch: deps.projectRootBranch,
|
||||
});
|
||||
})(),
|
||||
};
|
||||
};
|
||||
|
||||
export const commitDiscoveredRawWorktreesByProject = (args: {
|
||||
rawWorktreesByProjectRef: { current: RawWorktreesByProjectScope };
|
||||
runtimeKey: string;
|
||||
capturedRevision: number;
|
||||
nextRawWorktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||
publishedWorktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||
partitionWorktreesByRegisteredProject: StartSessionWorktreeMenuLoadDependencies['partitionWorktreesByRegisteredProject'];
|
||||
projects: ReadonlyArray<Pick<ProjectRef, 'id' | 'path'>>;
|
||||
worktreeMapsEqual: StartSessionWorktreeMenuLoadDependencies['worktreeMapsEqual'];
|
||||
recordWorktreesSeen: StartSessionWorktreeMenuLoadDependencies['recordWorktreesSeen'];
|
||||
publishTopology: StartSessionWorktreeMenuLoadDependencies['publishTopology'];
|
||||
requestRediscovery: RequestRediscovery;
|
||||
now: () => number;
|
||||
}): boolean => {
|
||||
if (args.rawWorktreesByProjectRef.current.runtimeKey !== args.runtimeKey) {
|
||||
return false;
|
||||
}
|
||||
if (args.rawWorktreesByProjectRef.current.revision !== args.capturedRevision) {
|
||||
args.requestRediscovery();
|
||||
return false;
|
||||
}
|
||||
const partitionedWorktreesByProject = args.partitionWorktreesByRegisteredProject(args.projects, args.nextRawWorktreesByProject);
|
||||
const allWorktrees = [...partitionedWorktreesByProject.values()].flat();
|
||||
args.recordWorktreesSeen(allWorktrees.map((worktree) => worktree.path), args.now());
|
||||
args.rawWorktreesByProjectRef.current = {
|
||||
runtimeKey: args.runtimeKey,
|
||||
revision: args.capturedRevision,
|
||||
worktreesByProject: new Map(args.nextRawWorktreesByProject),
|
||||
};
|
||||
if (!args.worktreeMapsEqual(partitionedWorktreesByProject, args.publishedWorktreesByProject)) {
|
||||
args.publishTopology({
|
||||
availableWorktrees: allWorktrees,
|
||||
availableWorktreesByProject: partitionedWorktreesByProject,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getSessionWorktreeMenuState = (args: {
|
||||
targets: ReadonlyArray<SessionWorktreeMenuTarget>;
|
||||
isRefreshing: boolean;
|
||||
loadFailed: boolean;
|
||||
}): SessionWorktreeMenuState => {
|
||||
return {
|
||||
refreshState: args.isRefreshing
|
||||
? 'loading'
|
||||
: (args.loadFailed && args.targets.length === 0 ? 'error' : null),
|
||||
showNewWorktreeAction: true,
|
||||
};
|
||||
};
|
||||
@@ -26,7 +26,7 @@ import { useGlobalSessionStatus, useSessionPermissions, useSessionQuestionCount
|
||||
import { useSessionMessageRecordsForExport } from '@/sync/use-sync';
|
||||
import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store';
|
||||
import { DraggableSessionRow } from '../folders/sessionFolderDnd';
|
||||
import { nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils';
|
||||
import { canShowSessionWorktreeMenu, getSessionWorktreeMenuDisabled, nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils';
|
||||
import type { SessionNode } from '../types';
|
||||
import { formatProjectLabel, formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from '../utils';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
@@ -42,16 +42,25 @@ import { getSessionGoal } from '@/lib/sessionGoalMetadata';
|
||||
import { sessionGoalStatusColor, sessionGoalStatusLabelKey } from '@/lib/sessionGoalPresentation';
|
||||
import { getRuntimeBearerTokenSync } from '@/lib/runtime-auth';
|
||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
import { getChatsRootFromDirectory, isChatDirectoryPath } from '@/lib/chatDirectories';
|
||||
import { getChatsRootFromDirectory } from '@/lib/chatDirectories';
|
||||
import { parseMultiRunSessionTitle } from '@/lib/multirun/title';
|
||||
import { MultiRunFusionDialog } from '@/components/multirun/MultiRunFusionDialog';
|
||||
import { FusionIcon } from '@/components/icons/FusionIcon';
|
||||
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
||||
import { startSessionTreeWorktreeMove, useIsSessionWorktreeMovePending } from '@/lib/worktrees/sessionWorktreeMove';
|
||||
import {
|
||||
requestSessionTreeMove,
|
||||
useIsSessionWorktreeMovePending,
|
||||
} from '@/lib/worktrees/sessionWorktreeMove';
|
||||
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import {
|
||||
getSessionWorktreeMenuState,
|
||||
type SessionWorktreeMenuTarget,
|
||||
type StartSessionWorktreeMenuLoadResult,
|
||||
} from '../sessionWorktreeMenu';
|
||||
|
||||
type SecondaryMeta = {
|
||||
projectLabel?: string | null;
|
||||
@@ -88,6 +97,11 @@ export type SessionNodeItemProps = {
|
||||
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
|
||||
handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean; hardDelete?: boolean; skipConfirm?: boolean }) => void;
|
||||
handleRestoreSession: (session: Session) => void;
|
||||
startSessionWorktreeMenuLoad: (args: {
|
||||
projectId: string | null;
|
||||
sourceDirectory: string | null;
|
||||
currentWorktree: WorktreeMetadata | null;
|
||||
}) => StartSessionWorktreeMenuLoadResult;
|
||||
mobileVariant: boolean;
|
||||
alwaysShowActions: boolean;
|
||||
secondaryMeta?: SecondaryMeta | null;
|
||||
@@ -271,6 +285,7 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
|
||||
createFolderAndStartRename,
|
||||
handleDeleteSession,
|
||||
handleRestoreSession,
|
||||
startSessionWorktreeMenuLoad,
|
||||
mobileVariant,
|
||||
alwaysShowActions,
|
||||
secondaryMeta,
|
||||
@@ -430,6 +445,12 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
|
||||
// tick of the counter it only decides to mount.
|
||||
const hasActivityDuration = useHasSessionActivityDuration(session.id, isStreaming);
|
||||
const isMovingToWorktree = useIsSessionWorktreeMovePending(session.id);
|
||||
const currentWorktreeMetadata = node.worktree ?? useSessionUIStore.getState().getWorktreeMetadata(session.id) ?? null;
|
||||
const [worktreeTargets, setWorktreeTargets] = React.useState<SessionWorktreeMenuTarget[]>([]);
|
||||
const [worktreeTargetsLoading, setWorktreeTargetsLoading] = React.useState(false);
|
||||
const [worktreeTargetsLoadFailed, setWorktreeTargetsLoadFailed] = React.useState(false);
|
||||
const worktreeSubmenuOpenRef = React.useRef(false);
|
||||
const worktreeLoadSequenceRef = React.useRef(0);
|
||||
const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined, { bootstrap: false });
|
||||
const sessionGoal = getSessionGoal(resolvedSession);
|
||||
const sessionGoalGlyph = sessionGoal ? (
|
||||
@@ -879,6 +900,41 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
|
||||
}
|
||||
};
|
||||
|
||||
const handleWorktreeSubmenuOpenChange = (open: boolean) => {
|
||||
worktreeSubmenuOpenRef.current = open;
|
||||
worktreeLoadSequenceRef.current += 1;
|
||||
const loadSequence = worktreeLoadSequenceRef.current;
|
||||
if (!open) {
|
||||
setWorktreeTargetsLoading(false);
|
||||
setWorktreeTargetsLoadFailed(false);
|
||||
return;
|
||||
}
|
||||
const load = startSessionWorktreeMenuLoad({
|
||||
projectId: projectId ?? null,
|
||||
sourceDirectory: sessionDirectory,
|
||||
currentWorktree: currentWorktreeMetadata,
|
||||
});
|
||||
setWorktreeTargets(load.cachedTargets);
|
||||
setWorktreeTargetsLoading(true);
|
||||
setWorktreeTargetsLoadFailed(false);
|
||||
void load.refreshTargets
|
||||
.then((freshTargets) => {
|
||||
if (!worktreeSubmenuOpenRef.current || worktreeLoadSequenceRef.current !== loadSequence) {
|
||||
return;
|
||||
}
|
||||
setWorktreeTargets(freshTargets);
|
||||
setWorktreeTargetsLoading(false);
|
||||
setWorktreeTargetsLoadFailed(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!worktreeSubmenuOpenRef.current || worktreeLoadSequenceRef.current !== loadSequence) {
|
||||
return;
|
||||
}
|
||||
setWorktreeTargetsLoading(false);
|
||||
setWorktreeTargetsLoadFailed(true);
|
||||
});
|
||||
};
|
||||
|
||||
const renderSessionMenuItems = ({
|
||||
Item,
|
||||
Separator,
|
||||
@@ -935,38 +991,119 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode
|
||||
<Icon name="download" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.session.menu.exportMarkdown')}
|
||||
</Item>
|
||||
{!isSubtaskSession && !archivedBucket && !isVSCode && !isChatDirectoryPath(sessionDirectory) ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block">
|
||||
<Item
|
||||
disabled={!sessionDirectory || isStreaming || isMovingToWorktree}
|
||||
onClick={() => {
|
||||
if (!sessionDirectory || isStreaming || isMovingToWorktree) return;
|
||||
startSessionTreeWorktreeMove({
|
||||
root: resolvedSession,
|
||||
descendants: collectNodeDescendantSessions(node),
|
||||
sourceDirectory: sessionDirectory,
|
||||
successMessage: t('sessions.sidebar.session.moveToWorktree.success'),
|
||||
failureMessage: t('sessions.sidebar.session.moveToWorktree.failed'),
|
||||
});
|
||||
}}
|
||||
className="w-full [&>svg]:mr-1"
|
||||
>
|
||||
<Icon name="folder-shared" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.session.menu.moveToWorktree')}
|
||||
</Item>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-72">
|
||||
{isMovingToWorktree
|
||||
? t('sessions.sidebar.session.moveToWorktree.tooltipMoving')
|
||||
: isStreaming
|
||||
? t('sessions.sidebar.session.moveToWorktree.tooltipBusy')
|
||||
: t('sessions.sidebar.session.moveToWorktree.tooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{canShowSessionWorktreeMenu({ isSubtaskSession, archivedBucket: Boolean(archivedBucket), isVSCode, sessionDirectory }) ? (() => {
|
||||
const isWorktreeMenuDisabled = getSessionWorktreeMenuDisabled({
|
||||
sessionDirectory,
|
||||
isStreaming,
|
||||
isMovingToWorktree,
|
||||
});
|
||||
const worktreeMenuState = getSessionWorktreeMenuState({
|
||||
targets: worktreeTargets,
|
||||
isRefreshing: worktreeTargetsLoading,
|
||||
loadFailed: worktreeTargetsLoadFailed,
|
||||
});
|
||||
return (
|
||||
<Sub onOpenChange={handleWorktreeSubmenuOpenChange}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SubTrigger
|
||||
disabled={isWorktreeMenuDisabled}
|
||||
className="w-full [&>svg]:mr-1"
|
||||
data-session-worktree-submenu-trigger={session.id}
|
||||
>
|
||||
<Icon name="folder-shared" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.session.menu.moveToWorktreeTargets')}
|
||||
</SubTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-72">
|
||||
{isMovingToWorktree
|
||||
? t('sessions.sidebar.session.moveToWorktree.tooltipMoving')
|
||||
: isStreaming
|
||||
? t('sessions.sidebar.session.moveToWorktree.tooltipBusy')
|
||||
: t('sessions.sidebar.session.moveToWorktree.tooltipTargets')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<SubContent className="min-w-[220px]" data-session-worktree-submenu={session.id}>
|
||||
{worktreeTargets.map((target) => {
|
||||
const targetPath = normalizePath(target.metadata.path ?? null) ?? target.metadata.path;
|
||||
const itemLabel = target.isPrimary
|
||||
? t('sessions.sidebar.session.moveToWorktree.main')
|
||||
: (target.metadata.label || target.metadata.branch || target.metadata.name || target.metadata.path);
|
||||
const isDisabled = target.isCurrent || target.metadata.worktreeStatus !== 'ready';
|
||||
|
||||
return (
|
||||
<Item
|
||||
key={targetPath}
|
||||
disabled={isDisabled}
|
||||
title={target.metadata.path}
|
||||
data-session-worktree-target={targetPath}
|
||||
onClick={() => {
|
||||
if (isDisabled || !sessionDirectory) {
|
||||
return;
|
||||
}
|
||||
requestSessionTreeMove({
|
||||
kind: 'existing',
|
||||
root: resolvedSession,
|
||||
descendants: collectNodeDescendantSessions(node),
|
||||
sourceDirectory: sessionDirectory,
|
||||
destination: target.metadata,
|
||||
messages: {
|
||||
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'),
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1 truncate">
|
||||
<span className="truncate">{itemLabel}</span>
|
||||
{target.isCurrent ? <span className="sr-only">{t('sessions.sidebar.session.moveToWorktree.current')}</span> : null}
|
||||
</span>
|
||||
{target.isCurrent ? <Icon name="check" className="ml-2 h-3.5 w-3.5 flex-shrink-0 text-primary" aria-hidden="true" /> : null}
|
||||
</Item>
|
||||
);
|
||||
})}
|
||||
{worktreeMenuState.refreshState === 'loading' ? (
|
||||
<Item disabled data-session-worktree-refresh-state="loading" className="py-0.5 text-muted-foreground typography-micro">
|
||||
{t('sessions.sidebar.session.moveToWorktree.refreshing')}
|
||||
</Item>
|
||||
) : null}
|
||||
{worktreeMenuState.refreshState === 'error' ? (
|
||||
<Item disabled data-session-worktree-refresh-state="error" className="py-0.5 text-muted-foreground typography-micro">
|
||||
{t('sessions.sidebar.session.moveToWorktree.loadFailed')}
|
||||
</Item>
|
||||
) : null}
|
||||
<Separator />
|
||||
{worktreeMenuState.showNewWorktreeAction ? (
|
||||
<Item
|
||||
disabled={isWorktreeMenuDisabled}
|
||||
data-session-worktree-new-action="true"
|
||||
onClick={() => {
|
||||
if (isWorktreeMenuDisabled || !sessionDirectory) return;
|
||||
requestSessionTreeMove({
|
||||
kind: 'quick',
|
||||
root: resolvedSession,
|
||||
descendants: collectNodeDescendantSessions(node),
|
||||
sourceDirectory: sessionDirectory,
|
||||
messages: {
|
||||
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"
|
||||
>
|
||||
<Icon name="add" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.session.menu.newWorktree')}
|
||||
</Item>
|
||||
) : null}
|
||||
</SubContent>
|
||||
</Sub>
|
||||
);
|
||||
})() : null}
|
||||
{isMultiRunLikeSession ? (
|
||||
<Item onClick={() => setFusionDialogOpen(true)} className="[&>svg]:mr-1">
|
||||
<FusionIcon className="mr-1 h-4 w-4" />
|
||||
@@ -1628,6 +1765,7 @@ const areSessionNodeItemPropsEqual = (prev: SessionNodeItemProps, next: SessionN
|
||||
&& prev.createFolderAndStartRename === next.createFolderAndStartRename
|
||||
&& prev.handleDeleteSession === next.handleDeleteSession
|
||||
&& prev.handleRestoreSession === next.handleRestoreSession
|
||||
&& prev.startSessionWorktreeMenuLoad === next.startSessionWorktreeMenuLoad
|
||||
&& prev.children === next.children;
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { SessionNodeItemProps } from './SessionNodeItem';
|
||||
import type { SessionTreeItemProps } from './SessionTreeItem';
|
||||
import { installHookTestDom } from '../test-utils/testDom';
|
||||
import { I18nProvider } from '@/lib/i18n';
|
||||
|
||||
@@ -39,6 +40,11 @@ mock.module('./hooks/useSessionActions', () => ({
|
||||
|
||||
const { SessionTreeItem } = await import('./SessionTreeItem');
|
||||
|
||||
const noopStartSessionWorktreeMenuLoad: SessionTreeItemProps['startSessionWorktreeMenuLoad'] = () => ({
|
||||
cachedTargets: [],
|
||||
refreshTargets: Promise.resolve([]),
|
||||
});
|
||||
|
||||
const session = (id: string): Session => ({
|
||||
id,
|
||||
slug: id,
|
||||
@@ -91,6 +97,7 @@ describe('SessionTreeItem public behavior', () => {
|
||||
setDeleteSessionConfirm={noop}
|
||||
startFolderRename={noop}
|
||||
setCopiedSessionId={setCopiedSessionId}
|
||||
startSessionWorktreeMenuLoad={noopStartSessionWorktreeMenuLoad}
|
||||
mobileVariant={false}
|
||||
alwaysShowActions={false}
|
||||
{...context}
|
||||
|
||||
@@ -39,6 +39,7 @@ export type SessionTreeItemProps = SessionTreeItemRenderProps & Pick<SessionNode
|
||||
| 'setEditTitle'
|
||||
| 'toggleParent'
|
||||
| 'setOpenSidebarMenuKey'
|
||||
| 'startSessionWorktreeMenuLoad'
|
||||
> & {
|
||||
allowReselect: boolean;
|
||||
onSessionSelected?: (sessionId: string) => void;
|
||||
@@ -88,6 +89,7 @@ export function SessionTreeItem({
|
||||
startFolderRename,
|
||||
copiedSessionId,
|
||||
setCopiedSessionId,
|
||||
startSessionWorktreeMenuLoad,
|
||||
mobileVariant,
|
||||
alwaysShowActions,
|
||||
}: SessionTreeItemProps): React.ReactNode {
|
||||
@@ -160,11 +162,12 @@ export function SessionTreeItem({
|
||||
openSidebarMenuKey={openSidebarMenuKey}
|
||||
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
|
||||
createFolderAndStartRename={createFolderAndStartRename}
|
||||
handleDeleteSession={sessionActions.handleDeleteSession}
|
||||
handleRestoreSession={sessionActions.handleRestoreSession}
|
||||
mobileVariant={mobileVariant}
|
||||
alwaysShowActions={alwaysShowActions}
|
||||
pinnedSessionIds={pinnedSessionIds}
|
||||
handleDeleteSession={sessionActions.handleDeleteSession}
|
||||
handleRestoreSession={sessionActions.handleRestoreSession}
|
||||
startSessionWorktreeMenuLoad={startSessionWorktreeMenuLoad}
|
||||
mobileVariant={mobileVariant}
|
||||
alwaysShowActions={alwaysShowActions}
|
||||
pinnedSessionIds={pinnedSessionIds}
|
||||
node={node}
|
||||
depth={depth}
|
||||
groupDirectory={groupDirectory}
|
||||
@@ -201,11 +204,12 @@ export function SessionTreeItem({
|
||||
setIsSessionSearchOpen={setIsSessionSearchOpen}
|
||||
deleteSessionConfirm={deleteSessionConfirm}
|
||||
setDeleteSessionConfirm={setDeleteSessionConfirm}
|
||||
startFolderRename={startFolderRename}
|
||||
setCopiedSessionId={setCopiedSessionId}
|
||||
mobileVariant={mobileVariant}
|
||||
alwaysShowActions={alwaysShowActions}
|
||||
depth={depth + 1}
|
||||
startFolderRename={startFolderRename}
|
||||
setCopiedSessionId={setCopiedSessionId}
|
||||
startSessionWorktreeMenuLoad={startSessionWorktreeMenuLoad}
|
||||
mobileVariant={mobileVariant}
|
||||
alwaysShowActions={alwaysShowActions}
|
||||
depth={depth + 1}
|
||||
{...childContext}
|
||||
renderExtras={childRenderExtrasFor?.(child)}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,14 @@ import { describe, expect, test } from 'bun:test';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { getPinnedSessionKey } from '@/stores/useSessionPinnedStore';
|
||||
import { computeNodeStructureKey, nodeHasPinnedMembershipChange, selectFolderRootNodes, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils';
|
||||
import {
|
||||
computeNodeStructureKey,
|
||||
canShowSessionWorktreeMenu,
|
||||
getSessionWorktreeMenuDisabled,
|
||||
nodeHasPinnedMembershipChange,
|
||||
selectFolderRootNodes,
|
||||
selectQuestionBadgeSessionScopes,
|
||||
} from './sessionNodeItemUtils';
|
||||
import type { SessionNode } from '../types';
|
||||
|
||||
const session = (id: string, title: string): Session => ({
|
||||
@@ -158,3 +165,49 @@ describe('selectFolderRootNodes', () => {
|
||||
expect(selectFolderRootNodes(['missing-root', 'child'], new Map([['child', child]]))).toEqual([child]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionWorktreeMenuDisabled', () => {
|
||||
test('shares the parent trigger disabled contract with the new worktree action', () => {
|
||||
expect(getSessionWorktreeMenuDisabled({
|
||||
sessionDirectory: '/repo-feature',
|
||||
isStreaming: false,
|
||||
isMovingToWorktree: false,
|
||||
})).toBe(false);
|
||||
|
||||
expect(getSessionWorktreeMenuDisabled({
|
||||
sessionDirectory: null,
|
||||
isStreaming: false,
|
||||
isMovingToWorktree: false,
|
||||
})).toBe(true);
|
||||
|
||||
expect(getSessionWorktreeMenuDisabled({
|
||||
sessionDirectory: '/repo-feature',
|
||||
isStreaming: true,
|
||||
isMovingToWorktree: false,
|
||||
})).toBe(true);
|
||||
|
||||
expect(getSessionWorktreeMenuDisabled({
|
||||
sessionDirectory: '/repo-feature',
|
||||
isStreaming: false,
|
||||
isMovingToWorktree: true,
|
||||
})).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canShowSessionWorktreeMenu', () => {
|
||||
test('hides worktree moves for managed Chat directories', () => {
|
||||
expect(canShowSessionWorktreeMenu({
|
||||
isSubtaskSession: false,
|
||||
archivedBucket: false,
|
||||
isVSCode: false,
|
||||
sessionDirectory: '/home/test/.config/openchamber/chats/2026-08-25/session-1',
|
||||
})).toBe(false);
|
||||
|
||||
expect(canShowSessionWorktreeMenu({
|
||||
isSubtaskSession: false,
|
||||
archivedBucket: false,
|
||||
isVSCode: false,
|
||||
sessionDirectory: '/repo',
|
||||
})).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
import { isChatDirectoryPath } from '@/lib/chatDirectories';
|
||||
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||
import { getPinnedSessionKey } from '@/stores/useSessionPinnedStore';
|
||||
import type { SessionNode } from '../types';
|
||||
@@ -78,6 +79,31 @@ export type QuestionBadgeSessionScope = {
|
||||
sessionIDs: string[];
|
||||
};
|
||||
|
||||
export const canShowSessionWorktreeMenu = ({
|
||||
isSubtaskSession,
|
||||
archivedBucket,
|
||||
isVSCode,
|
||||
sessionDirectory,
|
||||
}: {
|
||||
isSubtaskSession: boolean;
|
||||
archivedBucket: boolean;
|
||||
isVSCode: boolean;
|
||||
sessionDirectory: string | null;
|
||||
}): boolean => !isSubtaskSession
|
||||
&& !archivedBucket
|
||||
&& !isVSCode
|
||||
&& !isChatDirectoryPath(sessionDirectory);
|
||||
|
||||
export const getSessionWorktreeMenuDisabled = ({
|
||||
sessionDirectory,
|
||||
isStreaming,
|
||||
isMovingToWorktree,
|
||||
}: {
|
||||
sessionDirectory: string | null;
|
||||
isStreaming: boolean;
|
||||
isMovingToWorktree: boolean;
|
||||
}): boolean => !sessionDirectory || isStreaming || isMovingToWorktree;
|
||||
|
||||
/**
|
||||
* Choose which (directory, sessionIDs) scopes a sidebar row's pending-question
|
||||
* badge should count. An expanded row counts only its own session; a collapsed
|
||||
|
||||
@@ -2989,11 +2989,32 @@ export const dict = {
|
||||
'sessions.sidebar.session.copyId.success': 'Sitzungs-ID kopiert',
|
||||
'sessions.sidebar.session.copyId.error': 'Sitzungs-ID konnte nicht kopiert werden',
|
||||
'sessions.sidebar.session.menu.moveToWorktree': 'In neuen Worktree verschieben',
|
||||
'sessions.sidebar.session.menu.moveToWorktreeTargets': 'In Worktree verschieben',
|
||||
'sessions.sidebar.session.menu.newWorktree': 'Neuer Worktree...',
|
||||
'sessions.sidebar.session.moveToWorktree.success': 'Sitzung in einen neuen Worktree verschoben',
|
||||
'sessions.sidebar.session.moveToWorktree.failed': 'Sitzung konnte nicht in einen neuen Worktree verschoben werden',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltip': 'Erstellt einen neuen Worktree aus dem aktuellen Branch, überträgt nicht gespeicherte Änderungen und verschiebt diese Sitzung samt Untersitzungen dorthin.',
|
||||
'sessions.sidebar.session.moveToWorktree.main': 'Haupt-Worktree',
|
||||
'sessions.sidebar.session.moveToWorktree.refreshing': 'Worktrees werden aktualisiert...',
|
||||
'sessions.sidebar.session.moveToWorktree.loadFailed': 'Worktrees konnten nicht geladen werden',
|
||||
'sessions.sidebar.session.moveToWorktree.current': 'Aktueller Worktree',
|
||||
'sessions.sidebar.session.moveToWorktree.existingSuccess': 'Sitzung in Worktree verschoben',
|
||||
'sessions.sidebar.session.moveToWorktree.existingFailed': 'Sitzung konnte nicht in Worktree verschoben werden',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipTargets': 'Zeigt vorhandene Worktrees und die Option, für diese Sitzung einen neuen zu erstellen.',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltip': 'Erstellt einen neuen Worktree aus dem aktuellen Branch und verschiebt diese Sitzung samt Untersitzungen dorthin. Bei ungespeicherten Änderungen in der Quelle wählst du, ob sie mit verschoben werden.',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'Verfügbar, wenn die Sitzung inaktiv ist. Warten Sie oder beenden Sie die aktuelle Aktivität.',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'Diese Sitzung wird bereits in einen neuen Worktree verschoben.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.title': 'Die Quelle hat ungespeicherte Änderungen',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': 'Geänderte Dateien in diesem Worktree: {count}.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCode verfolgt diese Änderungen nach Verzeichnis, nicht nach Sitzung.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': 'Verschiebt diese Sitzung und ihre Untersitzungen, ohne die Quelldateien zu verändern.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': 'Überträgt die Änderungen im Sitzungsverzeichnis. Nicht committete und unversionierte Dateien verlassen die Quelle nach Erfolg.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': 'Gemappte (staged) Änderungen bleiben in der Quelle und werden ans Ziel kopiert.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': 'Die Übertragung kann fehlschlagen, wenn das Ziel eine andere Git-Basis verwendet.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': 'Nur Sitzung verschieben',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.allChanges': 'Alle Quelländerungen verschieben',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'Abbrechen',
|
||||
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'Die Änderungen in der Quelle konnten nicht geprüft werden. Es wurde kein Worktree und keine Sitzung geändert.',
|
||||
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': 'Das Ziel konnte die Änderungen der Quelle nicht übernehmen. Sitzung und Änderungen wurden nicht verschoben. Versuche es erneut und wähle Nur Sitzung verschieben.',
|
||||
'sessions.sidebar.session.export.failedLoadHistory': 'Die vollständige Sitzungshistorie konnte nicht geladen werden',
|
||||
'sessions.sidebar.session.status.movingToWorktree': 'Sitzung wird in einen neuen Worktree verschoben',
|
||||
'gitView.header.updateBranch': 'Branch aktualisieren',
|
||||
|
||||
@@ -537,11 +537,32 @@ export const dict = {
|
||||
'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.menu.moveToWorktreeTargets': 'Move to worktree',
|
||||
'sessions.sidebar.session.menu.newWorktree': '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.main': 'Main worktree',
|
||||
'sessions.sidebar.session.moveToWorktree.refreshing': 'Refreshing worktrees...',
|
||||
'sessions.sidebar.session.moveToWorktree.loadFailed': 'Worktrees could not be loaded',
|
||||
'sessions.sidebar.session.moveToWorktree.current': 'Current worktree',
|
||||
'sessions.sidebar.session.moveToWorktree.existingSuccess': 'Session moved to worktree',
|
||||
'sessions.sidebar.session.moveToWorktree.existingFailed': 'Failed to move session to worktree',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipTargets': 'Shows existing worktrees and the option to create a new one for this session.',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltip': 'Creates a new worktree from the current branch and moves this session and its sub-sessions there. When the source has uncommitted changes, you choose whether to move them.',
|
||||
'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.moveToWorktree.confirm.title': 'Source has uncommitted changes',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': 'Changed files in this worktree: {count}.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCode tracks these changes by directory, not by session.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': 'Move this session and its sub-sessions while leaving every source file unchanged.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': 'Transfer changes under the session directory. Unstaged and untracked files leave the source after success.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': 'Staged changes remain in the source and are copied to the destination.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': 'The transfer can fail when the destination uses a different Git base.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': 'Move session only',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.allChanges': 'Move all source changes',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'Cancel',
|
||||
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'Source changes could not be verified. No worktree or session was changed.',
|
||||
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': 'The destination could not accept the source changes. The session and source changes were not moved. Retry and choose Move session only.',
|
||||
'sessions.sidebar.session.menu.runFusion': 'Run fusion',
|
||||
'sessions.sidebar.session.menu.openInSidePanel': 'Open in Side Panel',
|
||||
'sessions.sidebar.session.actions.openInEditor': 'Open in Editor',
|
||||
|
||||
@@ -538,11 +538,32 @@ export const dict: Record<I18nKey, string> = {
|
||||
"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.menu.moveToWorktreeTargets": "Mover a worktree",
|
||||
"sessions.sidebar.session.menu.newWorktree": "Nuevo worktree...",
|
||||
"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.main": "Worktree principal",
|
||||
"sessions.sidebar.session.moveToWorktree.refreshing": "Actualizando worktrees...",
|
||||
"sessions.sidebar.session.moveToWorktree.loadFailed": "No se pudieron cargar los worktrees",
|
||||
"sessions.sidebar.session.moveToWorktree.current": "Worktree actual",
|
||||
"sessions.sidebar.session.moveToWorktree.existingSuccess": "Sesión movida al worktree",
|
||||
"sessions.sidebar.session.moveToWorktree.existingFailed": "No se pudo mover la sesión al worktree",
|
||||
"sessions.sidebar.session.moveToWorktree.tooltipTargets": "Muestra los worktrees existentes y la opción de crear uno nuevo para esta sesión.",
|
||||
"sessions.sidebar.session.moveToWorktree.tooltip": "Crea un worktree nuevo desde la rama actual y mueve allí esta sesión y sus subsesiones. Si la fuente tiene cambios sin confirmar, decides si se transfieren.",
|
||||
"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.moveToWorktree.confirm.title": "La fuente tiene cambios sin confirmar",
|
||||
"sessions.sidebar.session.moveToWorktree.confirm.changedFiles": "Archivos modificados en este worktree: {count}.",
|
||||
"sessions.sidebar.session.moveToWorktree.confirm.ownership": "OpenCode rastrea estos cambios por directorio, no por sesión.",
|
||||
"sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp": "Mueve esta sesión y sus subsesiones dejando intacto cada archivo de la fuente.",
|
||||
"sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp": "Transfiere los cambios del directorio de la sesión. Los archivos sin confirmar y sin rastrear salen de la fuente tras el éxito.",
|
||||
"sessions.sidebar.session.moveToWorktree.confirm.stagedWarning": "Los cambios en el índice permanecen en la fuente y se copian al destino.",
|
||||
"sessions.sidebar.session.moveToWorktree.confirm.baseWarning": "La transferencia puede fallar si el destino usa una base de Git distinta.",
|
||||
"sessions.sidebar.session.moveToWorktree.confirm.sessionOnly": "Mover solo la sesión",
|
||||
"sessions.sidebar.session.moveToWorktree.confirm.allChanges": "Mover todos los cambios de la fuente",
|
||||
"sessions.sidebar.session.moveToWorktree.confirm.cancel": "Cancelar",
|
||||
"sessions.sidebar.session.moveToWorktree.sourceVerificationFailed": "No se pudieron verificar los cambios de la fuente. No se modificó ningún worktree ni sesión.",
|
||||
"sessions.sidebar.session.moveToWorktree.applyChangesFailed": "El destino no pudo aceptar los cambios de la fuente. No se movieron la sesión ni los cambios. Reintenta y elige Mover solo la sesión.",
|
||||
"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",
|
||||
|
||||
@@ -368,11 +368,32 @@ export const dict = {
|
||||
'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.menu.moveToWorktreeTargets': 'Déplacer vers un worktree',
|
||||
'sessions.sidebar.session.menu.newWorktree': '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.main': 'Worktree principal',
|
||||
'sessions.sidebar.session.moveToWorktree.refreshing': 'Actualisation des worktrees...',
|
||||
'sessions.sidebar.session.moveToWorktree.loadFailed': 'Impossible de charger les worktrees',
|
||||
'sessions.sidebar.session.moveToWorktree.current': 'Worktree actuel',
|
||||
'sessions.sidebar.session.moveToWorktree.existingSuccess': 'Session déplacée vers le worktree',
|
||||
'sessions.sidebar.session.moveToWorktree.existingFailed': 'Impossible de déplacer la session vers le worktree',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipTargets': 'Affiche les worktrees existants et l’option d’en créer un nouveau pour cette session.',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltip': 'Crée un nouveau worktree depuis la branche actuelle et y déplace cette session et ses sous-sessions. Si la source contient des modifications non validées, vous choisissez de les transférer ou non.',
|
||||
'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.moveToWorktree.confirm.title': 'La source contient des modifications non validées',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': 'Fichiers modifiés dans ce worktree : {count}.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCode suit ces modifications par répertoire, pas par session.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': 'Déplace cette session et ses sous-sessions en laissant chaque fichier source inchangé.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': 'Transfère les modifications du répertoire de la session. Les fichiers non indexés et non suivis quittent la source après succès.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': 'Les modifications indexées restent dans la source et sont copiées vers la destination.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': 'Le transfert peut échouer si la destination utilise une base Git différente.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': 'Déplacer la session uniquement',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.allChanges': 'Déplacer toutes les modifications de la source',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'Annuler',
|
||||
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'Les modifications de la source n’ont pas pu être vérifiées. Aucun worktree ni session n’a été modifié.',
|
||||
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': 'La destination n’a pas pu accepter les modifications de la source. La session et les modifications n’ont pas été déplacées. Réessayez et choisissez Déplacer la session uniquement.',
|
||||
'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',
|
||||
|
||||
@@ -538,11 +538,32 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.session.menu.unshare': '共有解除',
|
||||
'sessions.sidebar.session.menu.exportMarkdown': 'Markdownでエクスポート',
|
||||
'sessions.sidebar.session.menu.moveToWorktree': '新しいworktreeへ移動',
|
||||
'sessions.sidebar.session.menu.moveToWorktreeTargets': 'worktreeへ移動',
|
||||
'sessions.sidebar.session.menu.newWorktree': '新しいworktree...',
|
||||
'sessions.sidebar.session.moveToWorktree.success': 'セッションを新しいworktreeへ移動しました',
|
||||
'sessions.sidebar.session.moveToWorktree.failed': 'セッションを新しいworktreeへ移動できませんでした',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltip': '現在のブランチから新しいworktreeを作成し、未コミットの変更とこのセッションおよびサブセッションを移動します。',
|
||||
'sessions.sidebar.session.moveToWorktree.main': 'メインworktree',
|
||||
'sessions.sidebar.session.moveToWorktree.refreshing': 'worktreeを更新しています...',
|
||||
'sessions.sidebar.session.moveToWorktree.loadFailed': 'worktreeを読み込めませんでした',
|
||||
'sessions.sidebar.session.moveToWorktree.current': '現在のworktree',
|
||||
'sessions.sidebar.session.moveToWorktree.existingSuccess': 'セッションをworktreeへ移動しました',
|
||||
'sessions.sidebar.session.moveToWorktree.existingFailed': 'セッションをworktreeへ移動できませんでした',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipTargets': '既存のworktreeと、このセッション用に新しいworktreeを作成するオプションを表示します。',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltip': '現在のブランチから新しいworktreeを作成し、このセッションとサブセッションをそこへ移動します。ソースに未コミットの変更がある場合は、移動するかどうかを選択します。',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipBusy': 'セッションがアイドル状態のときに利用できます。現在の処理を停止するか、完了するまでお待ちください。',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipMoving': 'このセッションはすでに新しいworktreeへ移動中です。',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.title': 'ソースに未コミットの変更があります',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': 'このworktree内の変更されたファイル: {count}。',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCodeはこれらの変更をセッションではなくディレクトリ単位で追跡します。',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': 'ソースファイルを一切変更せずに、このセッションとサブセッションを移動します。',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': 'セッションディレクトリ配下の変更を転送します。ステージされていない・追跡されていないファイルは成功後にソースを離れます。',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': 'ステージ済みの変更はソースに残り、宛先へコピーされます。',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': '宛先が異なるGitベースを使用している場合、転送に失敗することがあります。',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': 'セッションのみ移動',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.allChanges': 'ソースの変更をすべて移動',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'キャンセル',
|
||||
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'ソースの変更を検証できませんでした。worktreeもセッションも変更されませんでした。',
|
||||
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': '宛先がソースの変更を受け付けられませんでした。セッションもソースの変更も移動されていません。再試行して「セッションのみ移動」を選んでください。',
|
||||
'sessions.sidebar.session.menu.runFusion': 'フュージョンを実行',
|
||||
'sessions.sidebar.session.menu.openInSidePanel': 'サイドパネルで開く',
|
||||
'sessions.sidebar.session.actions.openInEditor': 'エディターで開く',
|
||||
|
||||
@@ -538,11 +538,32 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.session.menu.unshare': '공유 해제',
|
||||
'sessions.sidebar.session.menu.exportMarkdown': 'Markdown 내보내기',
|
||||
'sessions.sidebar.session.menu.moveToWorktree': '새 worktree로 이동',
|
||||
'sessions.sidebar.session.menu.moveToWorktreeTargets': 'worktree로 이동',
|
||||
'sessions.sidebar.session.menu.newWorktree': '새 worktree...',
|
||||
'sessions.sidebar.session.moveToWorktree.success': '세션을 새 worktree로 이동했습니다',
|
||||
'sessions.sidebar.session.moveToWorktree.failed': '세션을 새 worktree로 이동하지 못했습니다',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltip': '현재 브랜치에서 새 worktree를 만들고 커밋되지 않은 변경 사항과 이 세션 및 하위 세션을 이동합니다.',
|
||||
'sessions.sidebar.session.moveToWorktree.main': '메인 worktree',
|
||||
'sessions.sidebar.session.moveToWorktree.refreshing': 'worktree 새로 고침 중...',
|
||||
'sessions.sidebar.session.moveToWorktree.loadFailed': 'worktree를 불러오지 못했습니다',
|
||||
'sessions.sidebar.session.moveToWorktree.current': '현재 worktree',
|
||||
'sessions.sidebar.session.moveToWorktree.existingSuccess': '세션을 worktree로 이동했습니다',
|
||||
'sessions.sidebar.session.moveToWorktree.existingFailed': '세션을 worktree로 이동하지 못했습니다',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipTargets': '기존 worktree와 이 세션용 새 worktree를 만드는 옵션을 표시합니다.',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltip': '현재 브랜치에서 새 worktree를 만들어 이 세션과 하위 세션을 그곳으로 이동합니다. 원본에 커밋되지 않은 변경 사항이 있으면 이동 여부를 선택합니다.',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipBusy': '세션이 유휴 상태일 때 사용할 수 있습니다. 현재 작업을 중지하거나 완료될 때까지 기다리세요.',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipMoving': '이 세션은 이미 새 worktree로 이동 중입니다.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.title': '원본에 커밋되지 않은 변경 사항이 있습니다',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': '이 worktree에서 변경된 파일: {count}개.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCode는 이 변경 사항을 세션이 아닌 디렉터리 기준으로 추적합니다.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': '원본 파일은 그대로 둔 채 이 세션과 하위 세션을 이동합니다.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': '세션 디렉터리 아래의 변경 사항을 전송합니다. 스테이지되지 않거나 추적되지 않은 파일은 성공 후 원본을 떠납니다.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': '스테이지된 변경 사항은 원본에 남고 목적지로 복사됩니다.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': '목적지가 다른 Git 베이스를 사용하면 전송이 실패할 수 있습니다.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': '세션만 이동',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.allChanges': '원본 변경 사항 모두 이동',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.cancel': '취소',
|
||||
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': '원본 변경 사항을 확인하지 못했습니다. worktree와 세션 모두 변경되지 않았습니다.',
|
||||
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': '목적지가 원본 변경 사항을 받아들이지 못했습니다. 세션과 원본 변경 사항이 이동되지 않았습니다. 다시 시도해 세션만 이동을 선택하세요.',
|
||||
'sessions.sidebar.session.menu.runFusion': 'fusion 실행',
|
||||
'sessions.sidebar.session.menu.openInSidePanel': '사이드 패널에서 열기',
|
||||
'sessions.sidebar.session.actions.openInEditor': '편집기에서 열기',
|
||||
|
||||
@@ -331,11 +331,32 @@ export const dict: Record<I18nKey, string> = {
|
||||
'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.menu.moveToWorktreeTargets': 'Przenieś do worktree',
|
||||
'sessions.sidebar.session.menu.newWorktree': 'Nowy 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.main': 'Główny worktree',
|
||||
'sessions.sidebar.session.moveToWorktree.refreshing': 'Odświeżanie worktree...',
|
||||
'sessions.sidebar.session.moveToWorktree.loadFailed': 'Nie udało się wczytać worktree',
|
||||
'sessions.sidebar.session.moveToWorktree.current': 'Bieżący worktree',
|
||||
'sessions.sidebar.session.moveToWorktree.existingSuccess': 'Sesję przeniesiono do worktree',
|
||||
'sessions.sidebar.session.moveToWorktree.existingFailed': 'Nie udało się przenieść sesji do worktree',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipTargets': 'Pokazuje istniejące worktree i opcję utworzenia nowego dla tej sesji.',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltip': 'Tworzy nowy worktree z bieżącej gałęzi i przenosi tam tę sesję wraz z podsesjami. Jeśli w źródle są niezacommitowane zmiany, decydujesz, czy je przenieść.',
|
||||
'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.moveToWorktree.confirm.title': 'Źródło ma niezacommitowane zmiany',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': 'Zmienione pliki w tym worktree: {count}.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCode śledzi te zmiany według katalogu, a nie sesji.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': 'Przenosi tę sesję i jej podsesje, pozostawiając każdy plik źródłowy bez zmian.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': 'Przenosi zmiany w katalogu sesji. Pliki niezacommitowane i nieśledzone opuszczają źródło po sukcesie.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': 'Zmiany w indeksie pozostają w źródle i są kopiowane do miejsca docelowego.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': 'Przeniesienie może się nie udać, gdy cel używa innej bazy Git.',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': 'Przenieś tylko sesję',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.allChanges': 'Przenieś wszystkie zmiany ze źródła',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.cancel': 'Anuluj',
|
||||
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': 'Nie udało się zweryfikować zmian w źródle. Żaden worktree ani sesja nie został zmieniony.',
|
||||
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': 'Cel nie mógł przyjąć zmian ze źródła. Sesja i zmiany w źródle nie zostały przeniesione. Spróbuj ponownie i wybierz Przenieś tylko sesję.',
|
||||
'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',
|
||||
|
||||
@@ -538,11 +538,32 @@ export const dict: Record<I18nKey, string> = {
|
||||
"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.menu.moveToWorktreeTargets": "Mover para worktree",
|
||||
"sessions.sidebar.session.menu.newWorktree": "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.main": "Worktree principal",
|
||||
"sessions.sidebar.session.moveToWorktree.refreshing": "Atualizando worktrees...",
|
||||
"sessions.sidebar.session.moveToWorktree.loadFailed": "Não foi possível carregar os worktrees",
|
||||
"sessions.sidebar.session.moveToWorktree.current": "Worktree atual",
|
||||
"sessions.sidebar.session.moveToWorktree.existingSuccess": "Sessão movida para o worktree",
|
||||
"sessions.sidebar.session.moveToWorktree.existingFailed": "Não foi possível mover a sessão para o worktree",
|
||||
"sessions.sidebar.session.moveToWorktree.tooltipTargets": "Mostra os worktrees existentes e a opção de criar um novo para esta sessão.",
|
||||
"sessions.sidebar.session.moveToWorktree.tooltip": "Cria um novo worktree a partir da branch atual e move esta sessão e suas subsessões para lá. Quando a fonte tem alterações não commitadas, você escolhe se as transfere.",
|
||||
"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.moveToWorktree.confirm.title": "A fonte tem alterações não commitadas",
|
||||
"sessions.sidebar.session.moveToWorktree.confirm.changedFiles": "Arquivos alterados neste worktree: {count}.",
|
||||
"sessions.sidebar.session.moveToWorktree.confirm.ownership": "O OpenCode rastreia essas alterações por diretório, não por sessão.",
|
||||
"sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp": "Move esta sessão e suas subsessões deixando todos os arquivos da fonte inalterados.",
|
||||
"sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp": "Transfere as alterações no diretório da sessão. Arquivos não adicionados ao stage e não rastreados saem da fonte após o sucesso.",
|
||||
"sessions.sidebar.session.moveToWorktree.confirm.stagedWarning": "Alterações já no stage permanecem na fonte e são copiadas para o destino.",
|
||||
"sessions.sidebar.session.moveToWorktree.confirm.baseWarning": "A transferência pode falhar quando o destino usa uma base do Git diferente.",
|
||||
"sessions.sidebar.session.moveToWorktree.confirm.sessionOnly": "Mover apenas a sessão",
|
||||
"sessions.sidebar.session.moveToWorktree.confirm.allChanges": "Mover todas as alterações da fonte",
|
||||
"sessions.sidebar.session.moveToWorktree.confirm.cancel": "Cancelar",
|
||||
"sessions.sidebar.session.moveToWorktree.sourceVerificationFailed": "As alterações da fonte não puderam ser verificadas. Nenhum worktree ou sessão foi alterado.",
|
||||
"sessions.sidebar.session.moveToWorktree.applyChangesFailed": "O destino não pôde aceitar as alterações da fonte. A sessão e as alterações da fonte não foram movidas. Tente novamente e escolha Mover apenas a sessão.",
|
||||
"sessions.sidebar.session.menu.runFusion": "Executar fusion",
|
||||
"sessions.sidebar.session.menu.openInSidePanel": "Abrir no painel lateral",
|
||||
"sessions.sidebar.session.actions.openInEditor": "Abrir no editor",
|
||||
|
||||
@@ -538,11 +538,32 @@ export const dict: Record<I18nKey, string> = {
|
||||
"sessions.sidebar.session.menu.unshare": "Скасувати спільний доступ",
|
||||
"sessions.sidebar.session.menu.exportMarkdown": "Експорт Markdown",
|
||||
"sessions.sidebar.session.menu.moveToWorktree": "Перенести в новий worktree",
|
||||
"sessions.sidebar.session.menu.moveToWorktreeTargets": "Перенести в worktree",
|
||||
"sessions.sidebar.session.menu.newWorktree": "Новий worktree...",
|
||||
"sessions.sidebar.session.moveToWorktree.success": "Сесію перенесено в новий worktree",
|
||||
"sessions.sidebar.session.moveToWorktree.failed": "Не вдалося перенести сесію в новий worktree",
|
||||
"sessions.sidebar.session.moveToWorktree.tooltip": "Створює новий worktree з поточної гілки, переносить незакомічені зміни та переміщує туди цю сесію і її підсесії.",
|
||||
"sessions.sidebar.session.moveToWorktree.main": "Основний worktree",
|
||||
"sessions.sidebar.session.moveToWorktree.refreshing": "Оновлення worktree...",
|
||||
"sessions.sidebar.session.moveToWorktree.loadFailed": "Не вдалося завантажити worktree",
|
||||
"sessions.sidebar.session.moveToWorktree.current": "Поточний worktree",
|
||||
"sessions.sidebar.session.moveToWorktree.existingSuccess": "Сесію перенесено в worktree",
|
||||
"sessions.sidebar.session.moveToWorktree.existingFailed": "Не вдалося перенести сесію в worktree",
|
||||
"sessions.sidebar.session.moveToWorktree.tooltipTargets": "Показує наявні worktree і можливість створити новий для цієї сесії.",
|
||||
"sessions.sidebar.session.moveToWorktree.tooltip": "Створює новий worktree з поточної гілки та переміщує туди цю сесію і її підсесії. Якщо у джерелі є незакомічені зміни, ви обираєте, переносити їх чи ні.",
|
||||
"sessions.sidebar.session.moveToWorktree.tooltipBusy": "Доступно, коли сесія неактивна. Зупиніть поточну активність або дочекайтеся її завершення.",
|
||||
"sessions.sidebar.session.moveToWorktree.tooltipMoving": "Ця сесія вже переноситься в новий worktree.",
|
||||
"sessions.sidebar.session.moveToWorktree.confirm.title": "У джерелі є незакомічені зміни",
|
||||
"sessions.sidebar.session.moveToWorktree.confirm.changedFiles": "Змінені файли у цьому worktree: {count}.",
|
||||
"sessions.sidebar.session.moveToWorktree.confirm.ownership": "OpenCode відстежує ці зміни за каталогом, а не за сесією.",
|
||||
"sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp": "Переносить цю сесію та її підсесії, не змінюючи жодного файла джерела.",
|
||||
"sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp": "Переносить зміни з каталогу сесії. Незакомічені та невідстежувані файли залишають джерело після успіху.",
|
||||
"sessions.sidebar.session.moveToWorktree.confirm.stagedWarning": "Закомічені в індекс зміни залишаються в джерелі та копіюються до призначення.",
|
||||
"sessions.sidebar.session.moveToWorktree.confirm.baseWarning": "Перенесення може не вдатися, якщо призначення використовує іншу базу Git.",
|
||||
"sessions.sidebar.session.moveToWorktree.confirm.sessionOnly": "Перенести лише сесію",
|
||||
"sessions.sidebar.session.moveToWorktree.confirm.allChanges": "Перенести всі зміни з джерела",
|
||||
"sessions.sidebar.session.moveToWorktree.confirm.cancel": "Скасувати",
|
||||
"sessions.sidebar.session.moveToWorktree.sourceVerificationFailed": "Не вдалося перевірити зміни в джерелі. Жоден worktree чи сесію не змінено.",
|
||||
"sessions.sidebar.session.moveToWorktree.applyChangesFailed": "Призначення не змогло прийняти зміни з джерела. Сесію та зміни в джерелі не перенесено. Спробуйте знову й оберіть Перенести лише сесію.",
|
||||
"sessions.sidebar.session.menu.runFusion": "Запустити fusion",
|
||||
"sessions.sidebar.session.menu.openInSidePanel": "Відкрити на бічній панелі",
|
||||
"sessions.sidebar.session.actions.openInEditor": "Відкрити в редакторі",
|
||||
|
||||
@@ -538,11 +538,32 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.session.menu.unshare': '取消分享',
|
||||
'sessions.sidebar.session.menu.exportMarkdown': '导出 Markdown',
|
||||
'sessions.sidebar.session.menu.moveToWorktree': '移至新工作树',
|
||||
'sessions.sidebar.session.menu.moveToWorktreeTargets': '移至工作树',
|
||||
'sessions.sidebar.session.menu.newWorktree': '新建工作树...',
|
||||
'sessions.sidebar.session.moveToWorktree.success': '会话已移至新工作树',
|
||||
'sessions.sidebar.session.moveToWorktree.failed': '无法将会话移至新工作树',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltip': '从当前分支创建新工作树,转移未提交的更改,并将此会话及其子会话移至其中。',
|
||||
'sessions.sidebar.session.moveToWorktree.main': '主工作树',
|
||||
'sessions.sidebar.session.moveToWorktree.refreshing': '正在刷新工作树...',
|
||||
'sessions.sidebar.session.moveToWorktree.loadFailed': '无法加载工作树',
|
||||
'sessions.sidebar.session.moveToWorktree.current': '当前工作树',
|
||||
'sessions.sidebar.session.moveToWorktree.existingSuccess': '会话已移至工作树',
|
||||
'sessions.sidebar.session.moveToWorktree.existingFailed': '无法将会话移至工作树',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipTargets': '显示现有工作树,以及为此会话创建新工作树的选项。',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltip': '从当前分支创建新工作树,并将此会话及其子会话移至其中。当源有未提交的更改时,由你选择是否一并转移。',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipBusy': '仅在会话空闲时可用。请停止当前活动或等待其完成。',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipMoving': '此会话已在移至新工作树。',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.title': '源有未提交的更改',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': '此工作树中已更改的文件:{count}。',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCode 按目录而非会话跟踪这些更改。',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': '移动此会话及其子会话,同时保持每个源文件不变。',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': '转移会话目录下的更改。未暂存和未跟踪的文件在成功后离开源。',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': '已暂存的更改保留在源中,并复制到目的地。',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': '当目的地使用不同的 Git 基准时,转移可能失败。',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': '仅移动会话',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.allChanges': '移动全部源更改',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.cancel': '取消',
|
||||
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': '无法验证源的更改。未更改任何工作树或会话。',
|
||||
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': '目的地无法接受源的更改。会话和源更改均未移动。请重试并选择“仅移动会话”。',
|
||||
'sessions.sidebar.session.menu.runFusion': '运行融合',
|
||||
'sessions.sidebar.session.menu.openInSidePanel': '在侧边面板中打开',
|
||||
'sessions.sidebar.session.actions.openInEditor': '在编辑器中打开',
|
||||
|
||||
@@ -551,11 +551,32 @@ export const dict: Record<I18nKey, string> = {
|
||||
'sessions.sidebar.session.menu.unshare': '取消分享',
|
||||
'sessions.sidebar.session.menu.exportMarkdown': '匯出 Markdown',
|
||||
'sessions.sidebar.session.menu.moveToWorktree': '移至新工作樹',
|
||||
'sessions.sidebar.session.menu.moveToWorktreeTargets': '移至工作樹',
|
||||
'sessions.sidebar.session.menu.newWorktree': '新增工作樹...',
|
||||
'sessions.sidebar.session.moveToWorktree.success': '工作階段已移至新工作樹',
|
||||
'sessions.sidebar.session.moveToWorktree.failed': '無法將工作階段移至新工作樹',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltip': '從目前分支建立新工作樹,轉移未提交的變更,並將此工作階段及其子工作階段移至其中。',
|
||||
'sessions.sidebar.session.moveToWorktree.main': '主要工作樹',
|
||||
'sessions.sidebar.session.moveToWorktree.refreshing': '正在重新整理工作樹...',
|
||||
'sessions.sidebar.session.moveToWorktree.loadFailed': '無法載入工作樹',
|
||||
'sessions.sidebar.session.moveToWorktree.current': '目前的工作樹',
|
||||
'sessions.sidebar.session.moveToWorktree.existingSuccess': '工作階段已移至工作樹',
|
||||
'sessions.sidebar.session.moveToWorktree.existingFailed': '無法將工作階段移至工作樹',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipTargets': '顯示現有工作樹,以及為此工作階段建立新工作樹的選項。',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltip': '從目前分支建立新工作樹,並將此工作階段及其子工作階段移至其中。當來源有未提交的變更時,由你選擇是否一併轉移。',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipBusy': '僅在工作階段閒置時可用。請停止目前活動或等待其完成。',
|
||||
'sessions.sidebar.session.moveToWorktree.tooltipMoving': '此工作階段已在移至新工作樹。',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.title': '來源有未提交的變更',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.changedFiles': '此工作樹中已變更的檔案:{count}。',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.ownership': 'OpenCode 依目錄而非工作階段追蹤這些變更。',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnlyHelp': '移動此工作階段及其子工作階段,同時保持每個來源檔案不變。',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.allChangesHelp': '轉移工作階段目錄下的變更。未暫存與未追蹤的檔案在成功後離開來源。',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.stagedWarning': '已暫存的變更保留在來源中,並複製到目的地。',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.baseWarning': '當目的地使用不同的 Git 基礎時,轉移可能失敗。',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.sessionOnly': '僅移動工作階段',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.allChanges': '移動全部來源變更',
|
||||
'sessions.sidebar.session.moveToWorktree.confirm.cancel': '取消',
|
||||
'sessions.sidebar.session.moveToWorktree.sourceVerificationFailed': '無法驗證來源的變更。未變更任何工作樹或工作階段。',
|
||||
'sessions.sidebar.session.moveToWorktree.applyChangesFailed': '目的地無法接受來源的變更。工作階段與來源變更均未移動。請重試並選擇「僅移動工作階段」。',
|
||||
'sessions.sidebar.session.menu.runFusion': '執行 fusion',
|
||||
'sessions.sidebar.session.menu.openInSidePanel': '在側邊面板中開啟',
|
||||
'sessions.sidebar.session.actions.openInEditor': '在編輯器中開啟',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { toast } from '@/components/ui';
|
||||
import { getGitStatus } from '@/lib/gitApi';
|
||||
import { checkIsGitRepository, getGitStatus } from '@/lib/gitApi';
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
import { createQuickWorktree, resolveProjectRef } from '@/lib/worktreeSessionCreator';
|
||||
import { getLatestWorktreeMetadata, removeProjectWorktree, type ProjectRef } from '@/lib/worktrees/worktreeManager';
|
||||
@@ -12,12 +12,60 @@ import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { waitForWorktreeGitReady } from '@/lib/worktrees/worktreeBootstrap';
|
||||
import { create } from 'zustand';
|
||||
|
||||
const useSessionMoveState = create<{ pendingSessionIds: Set<string> }>(() => ({
|
||||
export type SessionTreeMoveMessages = {
|
||||
success: string;
|
||||
failure: string;
|
||||
sourceVerificationFailed: string;
|
||||
applyChangesFailed: string;
|
||||
};
|
||||
|
||||
export type SessionTreeMoveIntent =
|
||||
| {
|
||||
kind: 'existing';
|
||||
root: Session;
|
||||
descendants: Session[];
|
||||
sourceDirectory: string;
|
||||
destination: WorktreeMetadata;
|
||||
messages: SessionTreeMoveMessages;
|
||||
}
|
||||
| {
|
||||
kind: 'quick';
|
||||
root: Session;
|
||||
descendants: Session[];
|
||||
sourceDirectory: string;
|
||||
messages: SessionTreeMoveMessages;
|
||||
};
|
||||
|
||||
export type SessionTreeMoveConfirmation = {
|
||||
intent: SessionTreeMoveIntent;
|
||||
dirtyFileCount: number;
|
||||
stagedFileCount: number;
|
||||
};
|
||||
|
||||
type SessionMoveState = {
|
||||
pendingSessionIds: Set<string>;
|
||||
requestingSessionIds: Set<string>;
|
||||
confirmation: SessionTreeMoveConfirmation | null;
|
||||
};
|
||||
|
||||
const useSessionMoveState = create<SessionMoveState>(() => ({
|
||||
pendingSessionIds: new Set(),
|
||||
requestingSessionIds: new Set(),
|
||||
confirmation: null,
|
||||
}));
|
||||
|
||||
export const useIsSessionWorktreeMovePending = (sessionId: string): boolean =>
|
||||
useSessionMoveState((state) => state.pendingSessionIds.has(sessionId));
|
||||
useSessionMoveState((state) => state.pendingSessionIds.has(sessionId) || state.requestingSessionIds.has(sessionId));
|
||||
|
||||
export const useSessionTreeMoveConfirmation = (): SessionTreeMoveConfirmation | null =>
|
||||
useSessionMoveState((state) => state.confirmation);
|
||||
|
||||
export const getSessionTreeMoveConfirmation = (): SessionTreeMoveConfirmation | null =>
|
||||
useSessionMoveState.getState().confirmation;
|
||||
|
||||
const setSessionMoveConfirmation = (confirmation: SessionTreeMoveConfirmation | null): void => {
|
||||
useSessionMoveState.setState((state) => (state.confirmation === confirmation ? state : { ...state, confirmation }));
|
||||
};
|
||||
|
||||
const setSessionMovePending = (sessionId: string, pending: boolean): void => {
|
||||
useSessionMoveState.setState((state) => {
|
||||
@@ -25,10 +73,29 @@ const setSessionMovePending = (sessionId: string, pending: boolean): void => {
|
||||
const pendingSessionIds = new Set(state.pendingSessionIds);
|
||||
if (pending) pendingSessionIds.add(sessionId);
|
||||
else pendingSessionIds.delete(sessionId);
|
||||
return { pendingSessionIds };
|
||||
return { ...state, pendingSessionIds };
|
||||
});
|
||||
};
|
||||
|
||||
const setSessionMoveRequesting = (sessionId: string, requesting: boolean): void => {
|
||||
useSessionMoveState.setState((state) => {
|
||||
if (state.requestingSessionIds.has(sessionId) === requesting) return state;
|
||||
const requestingSessionIds = new Set(state.requestingSessionIds);
|
||||
if (requesting) requestingSessionIds.add(sessionId);
|
||||
else requestingSessionIds.delete(sessionId);
|
||||
return { ...state, requestingSessionIds };
|
||||
});
|
||||
};
|
||||
|
||||
const APPLY_CHANGES_MESSAGE = 'Unable to apply your changes in the destination directory';
|
||||
|
||||
const isApplyChangesError = (error: Error): boolean => {
|
||||
// SAFETY: move failures originate from our own SDK/runtime layer, which may
|
||||
// attach an optional numeric HTTP status to an Error instance.
|
||||
const errorWithStatus = error as Error & { status?: number };
|
||||
return errorWithStatus.status === 400 && error.message.includes(APPLY_CHANGES_MESSAGE);
|
||||
};
|
||||
|
||||
const resolveSourceBranch = async (directory: string, projectDirectory: string): Promise<string> => {
|
||||
try {
|
||||
const status = await getGitStatus(directory, { mode: 'light' });
|
||||
@@ -61,25 +128,51 @@ const assertSessionsIdle = (sessions: Session[], sourceDirectory: string): void
|
||||
if (hasActiveSession) throw new Error('Session is not idle');
|
||||
};
|
||||
|
||||
type RollbackFailure = {
|
||||
sessionId: string;
|
||||
error: Error;
|
||||
};
|
||||
|
||||
const createIncompleteRollbackError = (moveError: Error, rollbackFailures: RollbackFailure[]): Error => {
|
||||
const rollbackSummary = rollbackFailures
|
||||
.map(({ sessionId, error }) => `${sessionId}: ${error.message}`)
|
||||
.join(', ');
|
||||
return new Error(
|
||||
`Session move partially failed and could not be fully rolled back: ${moveError.message}. Rollback failures: ${rollbackSummary}`,
|
||||
{ cause: { moveError, rollbackFailures } },
|
||||
);
|
||||
};
|
||||
|
||||
const isSessionBusyOrRetrying = (session: Session, directory: string): boolean => {
|
||||
const status = getDirectoryState(directory)?.session_status[session.id]?.type;
|
||||
return status === 'busy' || status === 'retry';
|
||||
};
|
||||
|
||||
const rollbackMovedSessions = async (
|
||||
sessions: Session[],
|
||||
rootSessionId: string,
|
||||
sourceDirectory: string,
|
||||
worktreeDirectory: string,
|
||||
previousMetadata: ReadonlyMap<string, WorktreeMetadata | undefined>,
|
||||
): Promise<unknown[]> => {
|
||||
const failures: unknown[] = [];
|
||||
): Promise<RollbackFailure[]> => {
|
||||
const failures: RollbackFailure[] = [];
|
||||
for (const session of [...sessions].reverse()) {
|
||||
if (isSessionBusyOrRetrying(session, worktreeDirectory)) {
|
||||
failures.push({ sessionId: session.id, error: new Error('Session is not idle') });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await moveSessionToDirectory(
|
||||
session,
|
||||
worktreeDirectory,
|
||||
sourceDirectory,
|
||||
session.id === rootSessionId,
|
||||
false,
|
||||
);
|
||||
useSessionUIStore.getState().setWorktreeMetadata(session.id, previousMetadata.get(session.id) ?? null);
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
failures.push({
|
||||
sessionId: session.id,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
});
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
@@ -88,32 +181,36 @@ const rollbackMovedSessions = async (
|
||||
const removeFailedWorktree = async (
|
||||
project: ProjectRef,
|
||||
worktree: WorktreeMetadata,
|
||||
moveError: unknown,
|
||||
moveError: Error,
|
||||
): 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 new Error(`Session move failed and the new worktree could not be removed: ${moveError.message}`);
|
||||
}
|
||||
throw moveError;
|
||||
};
|
||||
|
||||
const moveSessionTreeToQuickWorktree = async (input: {
|
||||
root: Session;
|
||||
descendants: Session[];
|
||||
sourceDirectory: string;
|
||||
}): Promise<string> => {
|
||||
const moveSessionTreeTransaction = async (
|
||||
input: {
|
||||
root: Session;
|
||||
descendants: Session[];
|
||||
sourceDirectory: string;
|
||||
moveChanges: boolean;
|
||||
},
|
||||
prepareDestination: () => Promise<{
|
||||
directory: string;
|
||||
metadata: WorktreeMetadata;
|
||||
onMoveFailure?: (error: Error) => Promise<never>;
|
||||
}>,
|
||||
): 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 sessions = [...input.descendants, input.root];
|
||||
const previousMetadata = new Map(
|
||||
sessions.map((session) => [
|
||||
session.id,
|
||||
@@ -122,59 +219,188 @@ const moveSessionTreeToQuickWorktree = async (input: {
|
||||
);
|
||||
assertSessionsIdle(sessions, input.sourceDirectory);
|
||||
|
||||
const sourceBranch = await resolveSourceBranch(input.sourceDirectory, project.path);
|
||||
const worktree = await createQuickWorktree(project, { startRef: sourceBranch });
|
||||
|
||||
let destination: Awaited<ReturnType<typeof prepareDestination>> | null = null;
|
||||
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);
|
||||
destination = await prepareDestination();
|
||||
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);
|
||||
// Setup and earlier moves can take long enough for a not-yet-moved
|
||||
// session to start running, so re-check the remaining source tree
|
||||
// immediately before each move. The root moves last so no later
|
||||
// descendant failure can require replaying a transferred patch.
|
||||
assertSessionsIdle(sessions.slice(index), input.sourceDirectory);
|
||||
await moveSessionToDirectory(
|
||||
session,
|
||||
input.sourceDirectory,
|
||||
destination.directory,
|
||||
session.id === input.root.id && input.moveChanges,
|
||||
);
|
||||
moved.push(session);
|
||||
useSessionUIStore.getState().setWorktreeMetadata(session.id, getLatestWorktreeMetadata(worktree));
|
||||
if (session.id === input.root.id) continue;
|
||||
useSessionUIStore.getState().setWorktreeMetadata(session.id, getLatestWorktreeMetadata(destination.metadata));
|
||||
}
|
||||
} catch (error) {
|
||||
const moveError = error instanceof Error ? error : new Error(String(error));
|
||||
const rollbackFailures = await rollbackMovedSessions(
|
||||
moved,
|
||||
input.root.id,
|
||||
input.sourceDirectory,
|
||||
worktree.path,
|
||||
destination?.directory ?? input.sourceDirectory,
|
||||
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)}`);
|
||||
throw createIncompleteRollbackError(moveError, rollbackFailures);
|
||||
}
|
||||
return removeFailedWorktree(project, worktree, error);
|
||||
if (destination?.onMoveFailure) {
|
||||
return destination.onMoveFailure(moveError);
|
||||
}
|
||||
throw moveError;
|
||||
}
|
||||
useSessionUIStore.getState().setWorktreeMetadata(input.root.id, getLatestWorktreeMetadata(destination.metadata));
|
||||
|
||||
try {
|
||||
await refreshGlobalSessionsForDirectories([input.sourceDirectory, worktree.path]);
|
||||
await refreshGlobalSessionsForDirectories([input.sourceDirectory, destination.directory]);
|
||||
} 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;
|
||||
return destination.directory;
|
||||
} finally {
|
||||
setSessionMovePending(input.root.id, false);
|
||||
}
|
||||
};
|
||||
|
||||
export const startSessionTreeWorktreeMove = (input: {
|
||||
export const moveSessionTreeToExistingWorktree = async (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),
|
||||
}));
|
||||
destination: WorktreeMetadata;
|
||||
moveChanges: boolean;
|
||||
}): Promise<string> => {
|
||||
const normalizedSourceDirectory = normalizePath(input.sourceDirectory) ?? input.sourceDirectory;
|
||||
const normalizedDestinationDirectory = normalizePath(input.destination.path) ?? input.destination.path;
|
||||
if (normalizedSourceDirectory === normalizedDestinationDirectory) {
|
||||
throw new Error('Source and destination are the same');
|
||||
}
|
||||
if (input.destination.worktreeStatus !== 'ready') {
|
||||
throw new Error('Destination worktree is not ready');
|
||||
}
|
||||
|
||||
return moveSessionTreeTransaction(input, async () => ({
|
||||
directory: input.destination.path,
|
||||
metadata: input.destination,
|
||||
}));
|
||||
};
|
||||
|
||||
const moveSessionTreeToQuickWorktree = async (input: {
|
||||
root: Session;
|
||||
descendants: Session[];
|
||||
sourceDirectory: string;
|
||||
moveChanges: boolean;
|
||||
}): Promise<string> => {
|
||||
return moveSessionTreeTransaction(input, async () => {
|
||||
const project = resolveProjectRef(input.sourceDirectory);
|
||||
if (!project) throw new Error('Unable to find the project for this session');
|
||||
|
||||
const sourceBranch = await checkIsGitRepository(input.sourceDirectory)
|
||||
? await resolveSourceBranch(input.sourceDirectory, project.path)
|
||||
: null;
|
||||
const worktree = await createQuickWorktree(project, sourceBranch ? { startRef: sourceBranch } : {});
|
||||
try {
|
||||
await waitForWorktreeGitReady(worktree.path);
|
||||
} catch (error) {
|
||||
const setupError = error instanceof Error ? error : new Error(String(error));
|
||||
return removeFailedWorktree(project, worktree, setupError);
|
||||
}
|
||||
return {
|
||||
directory: worktree.path,
|
||||
metadata: worktree,
|
||||
onMoveFailure: (error) => removeFailedWorktree(project, worktree, error),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const executeSessionTreeMove = (intent: SessionTreeMoveIntent, moveChanges: boolean): void => {
|
||||
const movePromise = intent.kind === 'existing'
|
||||
? moveSessionTreeToExistingWorktree({
|
||||
root: intent.root,
|
||||
descendants: intent.descendants,
|
||||
sourceDirectory: intent.sourceDirectory,
|
||||
destination: intent.destination,
|
||||
moveChanges,
|
||||
})
|
||||
: moveSessionTreeToQuickWorktree({
|
||||
root: intent.root,
|
||||
descendants: intent.descendants,
|
||||
sourceDirectory: intent.sourceDirectory,
|
||||
moveChanges,
|
||||
});
|
||||
|
||||
void movePromise
|
||||
.then(() => toast.success(intent.messages.success))
|
||||
.catch((error) => {
|
||||
const failure = error instanceof Error ? error : new Error(String(error));
|
||||
toast.error(intent.messages.failure, {
|
||||
description: moveChanges && isApplyChangesError(failure)
|
||||
? intent.messages.applyChangesFailed
|
||||
: failure.message,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const cancelSessionTreeMove = (): void => {
|
||||
const confirmation = getSessionTreeMoveConfirmation();
|
||||
if (!confirmation) return;
|
||||
setSessionMoveRequesting(confirmation.intent.root.id, false);
|
||||
setSessionMoveConfirmation(null);
|
||||
};
|
||||
|
||||
export const confirmSessionTreeMove = (moveChanges: boolean): void => {
|
||||
const confirmation = getSessionTreeMoveConfirmation();
|
||||
if (!confirmation) return;
|
||||
const { intent } = confirmation;
|
||||
setSessionMoveConfirmation(null);
|
||||
setSessionMoveRequesting(intent.root.id, false);
|
||||
executeSessionTreeMove(intent, moveChanges);
|
||||
};
|
||||
|
||||
export const requestSessionTreeMove = (intent: SessionTreeMoveIntent): void => {
|
||||
const state = useSessionMoveState.getState();
|
||||
if (state.confirmation) return;
|
||||
if (state.pendingSessionIds.has(intent.root.id) || state.requestingSessionIds.has(intent.root.id)) return;
|
||||
|
||||
setSessionMoveRequesting(intent.root.id, true);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const isGitRepository = await checkIsGitRepository(intent.sourceDirectory);
|
||||
if (!isGitRepository) {
|
||||
setSessionMoveRequesting(intent.root.id, false);
|
||||
executeSessionTreeMove(intent, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await getGitStatus(intent.sourceDirectory);
|
||||
if (status.isClean) {
|
||||
setSessionMoveRequesting(intent.root.id, false);
|
||||
executeSessionTreeMove(intent, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const stagedFileCount = status.files.filter((file) => {
|
||||
const indexStatus = file.index.trim();
|
||||
return indexStatus !== '' && indexStatus !== '?';
|
||||
}).length;
|
||||
setSessionMoveConfirmation({
|
||||
intent,
|
||||
dirtyFileCount: status.files.length,
|
||||
stagedFileCount,
|
||||
});
|
||||
} catch {
|
||||
toast.error(intent.messages.failure, {
|
||||
description: intent.messages.sourceVerificationFailed,
|
||||
});
|
||||
setSessionMoveRequesting(intent.root.id, false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
@@ -11,6 +11,8 @@ type WorktreeListEntry = {
|
||||
|
||||
const listCalls: string[] = [];
|
||||
const listResolvers: Array<(value: WorktreeListEntry[]) => void> = [];
|
||||
const listRejecters: Array<(reason: Error) => void> = [];
|
||||
let listImplementation: ((directory: string) => Promise<WorktreeListEntry[]>) | undefined;
|
||||
const createPayloads: unknown[] = [];
|
||||
const validatePayloads: unknown[] = [];
|
||||
const createdWorktree = {
|
||||
@@ -78,8 +80,12 @@ mock.module('@/lib/gitApi', () => ({
|
||||
worktree: {
|
||||
list: (directory: string) => {
|
||||
listCalls.push(directory);
|
||||
return new Promise<WorktreeListEntry[]>((resolve) => {
|
||||
if (listImplementation) {
|
||||
return listImplementation(directory);
|
||||
}
|
||||
return new Promise<WorktreeListEntry[]>((resolve, reject) => {
|
||||
listResolvers.push(resolve);
|
||||
listRejecters.push((reason: Error) => reject(reason));
|
||||
});
|
||||
},
|
||||
create: mock((_directory: string, payload: unknown) => {
|
||||
@@ -118,6 +124,8 @@ describe('worktreeManager list invalidation', () => {
|
||||
beforeEach(() => {
|
||||
listCalls.length = 0;
|
||||
listResolvers.length = 0;
|
||||
listRejecters.length = 0;
|
||||
listImplementation = undefined;
|
||||
createPayloads.length = 0;
|
||||
validatePayloads.length = 0;
|
||||
bootstrapWatcherCalls.length = 0;
|
||||
@@ -152,6 +160,143 @@ describe('worktreeManager list invalidation', () => {
|
||||
expect(result.map((entry) => entry.path)).toEqual(['/repo-feature']);
|
||||
});
|
||||
|
||||
test('forced refresh bypasses a fresh cached result', async () => {
|
||||
const project = { id: 'project-force-cache', path: '/repo-force-cache' };
|
||||
|
||||
const initialListing = listProjectWorktrees(project);
|
||||
await waitForListCallCount(1);
|
||||
listResolvers[0]([]);
|
||||
const initialResult = await initialListing;
|
||||
expect(initialResult).toEqual([]);
|
||||
|
||||
const cachedResult = await listProjectWorktrees(project);
|
||||
expect(cachedResult).toEqual([]);
|
||||
expect(listCalls).toEqual(['/repo-force-cache']);
|
||||
|
||||
const forcedListing = listProjectWorktrees(project, { force: true });
|
||||
await waitForListCallCount(2);
|
||||
listResolvers[1]([createdWorktree]);
|
||||
|
||||
const forcedResult = await forcedListing;
|
||||
expect(forcedResult.map((entry) => entry.path)).toEqual(['/repo-feature']);
|
||||
expect(listCalls).toEqual(['/repo-force-cache', '/repo-force-cache']);
|
||||
const refreshedCachedResult = await listProjectWorktrees(project);
|
||||
expect(refreshedCachedResult.map((entry) => entry.path)).toEqual(['/repo-feature']);
|
||||
expect(listCalls).toEqual(['/repo-force-cache', '/repo-force-cache']);
|
||||
});
|
||||
|
||||
test('forced refresh starts a new request instead of joining an older in-flight list', async () => {
|
||||
const project = { id: 'project-force-inflight', path: '/repo-force-inflight' };
|
||||
|
||||
const initialListing = listProjectWorktrees(project);
|
||||
await waitForListCallCount(1);
|
||||
|
||||
const forcedListing = listProjectWorktrees(project, { force: true });
|
||||
await waitForListCallCount(2);
|
||||
|
||||
listResolvers[1]([createdWorktree]);
|
||||
const forcedResult = await forcedListing;
|
||||
expect(forcedResult.map((entry) => entry.path)).toEqual(['/repo-feature']);
|
||||
|
||||
listResolvers[0]([]);
|
||||
await waitForListCallCount(3);
|
||||
listResolvers[2]([createdWorktree]);
|
||||
const initialResult = await initialListing;
|
||||
expect(initialResult.map((entry) => entry.path)).toEqual(['/repo-feature']);
|
||||
expect(listCalls).toEqual([
|
||||
'/repo-force-inflight',
|
||||
'/repo-force-inflight',
|
||||
'/repo-force-inflight',
|
||||
]);
|
||||
});
|
||||
|
||||
test('older completions do not replace a forced refresh result with stale topology', async () => {
|
||||
const project = { id: 'project-force-stale', path: '/repo-force-stale' };
|
||||
|
||||
void listProjectWorktrees(project);
|
||||
await waitForListCallCount(1);
|
||||
|
||||
const forcedListing = listProjectWorktrees(project, { force: true });
|
||||
await waitForListCallCount(2);
|
||||
listResolvers[1]([createdWorktree]);
|
||||
const forcedResult = await forcedListing;
|
||||
expect(forcedResult.map((entry) => entry.path)).toEqual(['/repo-feature']);
|
||||
|
||||
listResolvers[0]([{ path: '/repo-stale', branch: 'stale', name: 'stale' }]);
|
||||
await waitForListCallCount(3);
|
||||
|
||||
const cachedResult = await listProjectWorktrees(project);
|
||||
expect(cachedResult.map((entry) => entry.path)).toEqual(['/repo-feature']);
|
||||
});
|
||||
|
||||
test('rejects when git worktree listing fails', async () => {
|
||||
const project = { id: 'project-force-failure', path: '/repo-force-failure' };
|
||||
|
||||
const listing = listProjectWorktrees(project);
|
||||
await waitForListCallCount(1);
|
||||
listRejecters[0](new Error('git failed'));
|
||||
|
||||
await expect(listing).rejects.toThrow('git failed');
|
||||
});
|
||||
|
||||
test('rejects sustained invalidation explicitly, preserves the last cached result, and allows a later retry', async () => {
|
||||
const project = { id: 'project-force-convergence', path: '/repo-force-convergence' };
|
||||
const oldWorktree = [{ path: '/repo-old', branch: 'old', name: 'old' } satisfies WorktreeListEntry];
|
||||
const scriptedResolvers = new Map<number, (value: WorktreeListEntry[]) => void>();
|
||||
let recoveryReadsAllowed = false;
|
||||
|
||||
listImplementation = () => {
|
||||
const callNumber = listCalls.length;
|
||||
if (callNumber === 8 && !recoveryReadsAllowed) {
|
||||
return Promise.reject(new Error('unexpected extra read'));
|
||||
}
|
||||
return new Promise<WorktreeListEntry[]>((resolve) => {
|
||||
scriptedResolvers.set(callNumber, resolve);
|
||||
});
|
||||
};
|
||||
|
||||
const seededListing = listProjectWorktrees(project);
|
||||
await waitForListCallCount(1);
|
||||
scriptedResolvers.get(1)?.(oldWorktree);
|
||||
expect((await seededListing).map((entry) => entry.path)).toEqual(['/repo-old']);
|
||||
|
||||
const unstableListing = listProjectWorktrees(project, { force: true });
|
||||
await waitForListCallCount(2);
|
||||
|
||||
const forcedRefreshA = listProjectWorktrees(project, { force: true });
|
||||
await waitForListCallCount(3);
|
||||
scriptedResolvers.get(3)?.([createdWorktree]);
|
||||
expect((await forcedRefreshA).map((entry) => entry.path)).toEqual(['/repo-feature']);
|
||||
scriptedResolvers.get(2)?.([{ path: '/repo-stale-a', branch: 'stale-a', name: 'stale-a' }]);
|
||||
await waitForListCallCount(4);
|
||||
|
||||
const forcedRefreshB = listProjectWorktrees(project, { force: true });
|
||||
await waitForListCallCount(5);
|
||||
scriptedResolvers.get(5)?.([createdWorktree]);
|
||||
expect((await forcedRefreshB).map((entry) => entry.path)).toEqual(['/repo-feature']);
|
||||
scriptedResolvers.get(4)?.([{ path: '/repo-stale-b', branch: 'stale-b', name: 'stale-b' }]);
|
||||
await waitForListCallCount(6);
|
||||
|
||||
const forcedRefreshC = listProjectWorktrees(project, { force: true });
|
||||
await waitForListCallCount(7);
|
||||
scriptedResolvers.get(7)?.([createdWorktree]);
|
||||
expect((await forcedRefreshC).map((entry) => entry.path)).toEqual(['/repo-feature']);
|
||||
scriptedResolvers.get(6)?.([{ path: '/repo-stale-c', branch: 'stale-c', name: 'stale-c' }]);
|
||||
|
||||
await expect(unstableListing).rejects.toThrow('Worktree list did not converge');
|
||||
expect(listCalls).toHaveLength(7);
|
||||
|
||||
const cachedResult = await listProjectWorktrees(project);
|
||||
expect(cachedResult.map((entry) => entry.path)).toEqual(['/repo-feature']);
|
||||
expect(listCalls).toHaveLength(7);
|
||||
|
||||
recoveryReadsAllowed = true;
|
||||
const recoveredListing = listProjectWorktrees(project, { force: true });
|
||||
await waitForListCallCount(8);
|
||||
scriptedResolvers.get(8)?.([createdWorktree]);
|
||||
expect((await recoveredListing).map((entry) => entry.path)).toEqual(['/repo-feature']);
|
||||
});
|
||||
|
||||
test('marks fast-created worktrees pending until bootstrap settles', async () => {
|
||||
const metadata = await createWorktree({ id: 'project-1', path: '/repo' }, {
|
||||
preferredName: 'feature',
|
||||
@@ -389,6 +534,7 @@ describe('worktreeManager fork remote payload wiring', () => {
|
||||
beforeEach(() => {
|
||||
listCalls.length = 0;
|
||||
listResolvers.length = 0;
|
||||
listRejecters.length = 0;
|
||||
createPayloads.length = 0;
|
||||
validatePayloads.length = 0;
|
||||
bootstrapWatcherCalls.length = 0;
|
||||
|
||||
@@ -374,9 +374,10 @@ export const partitionWorktreesByRegisteredProject = (
|
||||
|
||||
// Cache worktree listings to avoid repeated git worktree list + rev-parse calls
|
||||
const _worktreeListCache = new Map<string, { value: WorktreeMetadata[]; at: number }>();
|
||||
const _worktreeListInflight = new Map<string, Promise<WorktreeMetadata[]>>();
|
||||
const _worktreeListInflight = new Map<string, { generation: number; promise: Promise<WorktreeMetadata[]> }>();
|
||||
const _worktreeListGeneration = new Map<string, number>();
|
||||
const WORKTREE_LIST_CACHE_TTL = 30_000; // 30 seconds
|
||||
const WORKTREE_LIST_MAX_CONVERGENCE_ATTEMPTS = 3;
|
||||
|
||||
const getWorktreeListGeneration = (projectDirectory: string): number => {
|
||||
return _worktreeListGeneration.get(projectDirectory) ?? 0;
|
||||
@@ -391,7 +392,7 @@ const readProjectWorktrees = async (projectDirectory: string): Promise<WorktreeM
|
||||
const metadataProjectDirectory = await resolveProjectRoot(projectDirectory).catch(() => projectDirectory);
|
||||
const normalizedProjectDirectory = normalizePath(projectDirectory);
|
||||
|
||||
const worktrees = await git.worktree.list(projectDirectory).catch(() => []);
|
||||
const worktrees = await git.worktree.list(projectDirectory);
|
||||
const results: WorktreeMetadata[] = worktrees
|
||||
.filter((entry) => typeof entry.path === 'string' && entry.path.trim().length > 0)
|
||||
.map((entry) => {
|
||||
@@ -424,38 +425,64 @@ const readProjectWorktrees = async (projectDirectory: string): Promise<WorktreeM
|
||||
});
|
||||
};
|
||||
|
||||
const readStableProjectWorktrees = async (projectDirectory: string): Promise<WorktreeMetadata[]> => {
|
||||
while (true) {
|
||||
const readStableProjectWorktrees = async (
|
||||
projectDirectory: string,
|
||||
minimumGeneration = getWorktreeListGeneration(projectDirectory),
|
||||
): Promise<WorktreeMetadata[]> => {
|
||||
for (let attempt = 0; attempt < WORKTREE_LIST_MAX_CONVERGENCE_ATTEMPTS; attempt += 1) {
|
||||
const generation = getWorktreeListGeneration(projectDirectory);
|
||||
const worktrees = await readProjectWorktrees(projectDirectory);
|
||||
|
||||
if (generation === getWorktreeListGeneration(projectDirectory)) {
|
||||
if (generation >= minimumGeneration && generation === getWorktreeListGeneration(projectDirectory)) {
|
||||
_worktreeListCache.set(projectDirectory, { value: worktrees, at: Date.now() });
|
||||
return worktrees;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Worktree list did not converge after ${WORKTREE_LIST_MAX_CONVERGENCE_ATTEMPTS} attempts`
|
||||
);
|
||||
};
|
||||
|
||||
export async function listProjectWorktrees(project: ProjectRef): Promise<WorktreeMetadata[]> {
|
||||
export async function listProjectWorktrees(project: ProjectRef, options?: { force?: boolean }): Promise<WorktreeMetadata[]> {
|
||||
const projectDirectory = normalizePath(project.path);
|
||||
const force = options?.force === true;
|
||||
const previousCache = force ? _worktreeListCache.get(projectDirectory) : undefined;
|
||||
|
||||
if (force) {
|
||||
invalidateWorktreeList(projectDirectory);
|
||||
}
|
||||
|
||||
const generation = getWorktreeListGeneration(projectDirectory);
|
||||
|
||||
// Return cached if fresh
|
||||
const cached = _worktreeListCache.get(projectDirectory);
|
||||
if (cached && Date.now() - cached.at < WORKTREE_LIST_CACHE_TTL) {
|
||||
if (!force && cached && Date.now() - cached.at < WORKTREE_LIST_CACHE_TTL) {
|
||||
return cached.value;
|
||||
}
|
||||
|
||||
// Dedup in-flight requests
|
||||
const inflight = _worktreeListInflight.get(projectDirectory);
|
||||
if (inflight) return inflight;
|
||||
if (inflight && inflight.generation === generation) return inflight.promise;
|
||||
|
||||
const promise = readStableProjectWorktrees(projectDirectory).finally(() => {
|
||||
if (_worktreeListInflight.get(projectDirectory) === promise) {
|
||||
_worktreeListInflight.delete(projectDirectory);
|
||||
}
|
||||
});
|
||||
const promise = readStableProjectWorktrees(projectDirectory, generation)
|
||||
.catch((error) => {
|
||||
if (
|
||||
previousCache
|
||||
&& !_worktreeListCache.has(projectDirectory)
|
||||
&& getWorktreeListGeneration(projectDirectory) === generation
|
||||
) {
|
||||
_worktreeListCache.set(projectDirectory, previousCache);
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
if (_worktreeListInflight.get(projectDirectory)?.promise === promise) {
|
||||
_worktreeListInflight.delete(projectDirectory);
|
||||
}
|
||||
});
|
||||
|
||||
_worktreeListInflight.set(projectDirectory, promise);
|
||||
_worktreeListInflight.set(projectDirectory, { generation, promise });
|
||||
return promise;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user