diff --git a/src/__tests__/extension.test.ts b/src/__tests__/extension.test.ts index 2b07dd6..e5cdd70 100644 --- a/src/__tests__/extension.test.ts +++ b/src/__tests__/extension.test.ts @@ -6,9 +6,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; // resolver. Everything it imports is stubbed so activate() runs to completion. const H = vi.hoisted(() => ({ registeredCommands: [] as string[], + commandHandlers: {} as Record unknown>, treeViewsCreated: [] as string[], workspaceFolders: undefined as Array<{ uri: { fsPath: string } }> | undefined, gitPathConfig: null as string | string[] | null, + worktreeList: [] as Array<{ path: string; isMain: boolean }>, })); vi.mock('vscode', () => ({ @@ -35,7 +37,7 @@ vi.mock('vscode', () => ({ activeTextEditor: undefined, }, commands: { - registerCommand: (id: string) => { H.registeredCommands.push(id); return { dispose() {} }; }, + registerCommand: (id: string, cb: (...args: unknown[]) => unknown) => { H.registeredCommands.push(id); H.commandHandlers[id] = cb; return { dispose() {} }; }, executeCommand: vi.fn(), }, extensions: { getExtension: () => undefined }, @@ -57,7 +59,7 @@ vi.mock('../panels/MainPanel', () => ({ }, })); vi.mock('../services/git-content-provider', () => ({ GitContentProvider: class {} })); -vi.mock('../git/git-service', () => ({ GitService: class { setExtraEnv() {} get rootPath() { return '/repo'; } } })); +vi.mock('../git/git-service', () => ({ GitService: class { setExtraEnv() {} get rootPath() { return '/repo'; } worktreeList() { return Promise.resolve(H.worktreeList); } } })); vi.mock('../services/file-watcher', () => ({ FileWatcher: class { enabled = true; suppress() {} dispose() {} } })); const viewStub = () => ({ ViewProvider: undefined }); vi.mock('../views/branches-view', () => ({ BranchesViewProvider: class { refresh() {} dispose() {} setGitService() {} prefetch() { return Promise.resolve(); } getCurrentItem() { return null; } } })); @@ -78,9 +80,11 @@ function makeContext() { beforeEach(() => { H.registeredCommands = []; + H.commandHandlers = {}; H.treeViewsCreated = []; H.workspaceFolders = undefined; H.gitPathConfig = null; + H.worktreeList = []; vi.mocked(existsSync).mockReturnValue(true); }); @@ -137,4 +141,22 @@ describe('activate', () => { 'gitGraphPlus.worktrees', ]); }); + + it('addWorktree defaults beside the main worktree even when active repo is linked worktree', async () => { + H.workspaceFolders = [{ uri: { fsPath: '/repos/project.worktrees/custom.worktrees' } }]; + H.worktreeList = [ + { path: '/repos/project', isMain: true }, + { path: '/repos/project.worktrees/custom.worktrees', isMain: false }, + ]; + const ctx = makeContext(); + activate(ctx); + + await H.commandHandlers['gitGraphPlus.addWorktree'](); + + const { MainPanel } = await import('../panels/MainPanel'); + expect(MainPanel.showModalWithPanel).toHaveBeenCalledWith(ctx.extensionUri, { + modal: 'addWorktree', + defaultPath: '/repos/project.worktrees', + }); + }); }); diff --git a/src/extension.ts b/src/extension.ts index 6e909d5..e359c6a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -476,8 +476,17 @@ export function activate(context: vscode.ExtensionContext) { const index = stashItem?.index ?? 0; MainPanel.showModalWithPanel(context.extensionUri, { modal: 'stashDrop', index, message: stashItem?.stash?.message ?? `stash@{${index}}` }); }), - vscode.commands.registerCommand('gitGraphPlus.addWorktree', () => { - const defaultPath = path.join(path.dirname(activeRepoPath), `${path.basename(activeRepoPath)}-worktree`); + vscode.commands.registerCommand('gitGraphPlus.addWorktree', async () => { + let baseRepoPath = activeRepoPath; + try { + const mainWorktree = (await activeGitService.worktreeList()).find(w => w.isMain); + if (mainWorktree?.path) { + baseRepoPath = mainWorktree.path; + } + } catch (err) { + console.warn('Git Graph+: failed to resolve main worktree path:', err instanceof Error ? err.message : err); + } + const defaultPath = path.join(path.dirname(baseRepoPath), `${path.basename(baseRepoPath)}.worktrees`); MainPanel.showModalWithPanel(context.extensionUri, { modal: 'addWorktree', defaultPath }); }), vscode.commands.registerCommand('gitGraphPlus.pruneWorktrees', () => { diff --git a/webview-ui/src/components/modals/AddWorktreeModal.svelte b/webview-ui/src/components/modals/AddWorktreeModal.svelte index b8f4b73..b0ddf74 100644 --- a/webview-ui/src/components/modals/AddWorktreeModal.svelte +++ b/webview-ui/src/components/modals/AddWorktreeModal.svelte @@ -12,54 +12,115 @@ onAdd: (path: string, branch?: string, newBranch?: string) => void; } + type WorktreeMode = 'existing' | 'new'; + let { defaultPath = '', onClose, onAdd }: Props = $props(); const localBranches = $derived(branchStore.localBranches.map(b => b.name)); + const checkedOutBranches = $derived(new Set(branchStore.worktrees.map(w => w.branch).filter(Boolean))); + const availableExistingBranches = $derived(localBranches.filter(b => !checkedOutBranches.has(b))); + const startAtOptions = $derived(localBranches.length > 0 ? localBranches : ['HEAD']); + let existingBranch = $state(''); // svelte-ignore state_referenced_locally let startAt = $state(branchStore.currentBranch?.name ?? 'HEAD'); + let mode = $state('existing'); let branchName = $state(''); // svelte-ignore state_referenced_locally let location = $state(defaultPath); let branchInput: HTMLInputElement | undefined = $state(); + const sourceRef = $derived(startAt || 'HEAD'); + const folderName = $derived(mode === 'new' ? branchName.trim() : existingBranch); + + function worktreeFolder(basePath: string, name: string): string { + const sanitized = name.replace(/[\\/]+/g, '-'); + if (!basePath) return sanitized; + return basePath.replace(/[\\/]+$/, '') + '/' + sanitized; + } + + $effect(() => { + if (!startAtOptions.includes(startAt)) { + startAt = startAtOptions[0] ?? 'HEAD'; + } + if (!existingBranch || !availableExistingBranches.includes(existingBranch)) { + existingBranch = availableExistingBranches[0] ?? ''; + } + if (mode === 'existing' && availableExistingBranches.length === 0) { + mode = 'new'; + } + }); + $effect(() => { - if (defaultPath && branchName) { - location = defaultPath + branchName.replace(/\//g, '-'); + if (defaultPath && folderName) { + location = worktreeFolder(defaultPath, folderName); } else if (defaultPath) { location = defaultPath; } }); - onMount(() => { branchInput?.focus(); }); - - const refError = $derived(branchName.trim() !== '' ? validateGitRefName(branchName.trim()) : null); - const canSubmit = $derived(branchName.trim() !== '' && location.trim() !== '' && !refError); + onMount(() => { if (mode === 'new') branchInput?.focus(); }); + const refError = $derived(mode === 'new' && branchName.trim() !== '' ? validateGitRefName(branchName.trim()) : null); + const canSubmit = $derived( + location.trim() !== '' + && (mode === 'new' + ? branchName.trim() !== '' && !refError + : existingBranch !== '') + ); function handleSubmit() { if (!canSubmit) return; - onAdd(location.trim(), startAt, branchName.trim()); + if (mode === 'new') { + onAdd(location.trim(), sourceRef, branchName.trim()); + } else { + onAdd(location.trim(), existingBranch); + } } -