Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions src/__tests__/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, (...args: unknown[]) => 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', () => ({
Expand All @@ -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 },
Expand All @@ -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; } } }));
Expand All @@ -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);
});

Expand Down Expand Up @@ -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',
});
});
});
13 changes: 11 additions & 2 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
111 changes: 89 additions & 22 deletions webview-ui/src/components/modals/AddWorktreeModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<WorktreeMode>('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);
}
}
</script>

<Modal title={t('worktree.addTitle')} {onClose}>
<div class="modal-form-group">
<div class="modal-field-row">
<div class="modal-field-label">{t('worktree.startAt')}</div>
<ColorSelect
options={localBranches.map(b => ({ value: b, label: b, color: '' }))}
value={startAt}
onChange={(v) => { startAt = v; }}
showDot={false}
/>
</div>
<div class="modal-form-group mode-options" role="radiogroup" aria-label={t('worktree.mode')}>
<label class="modal-radio">
<input type="radio" name="worktree-mode" value="existing" bind:group={mode} disabled={availableExistingBranches.length === 0} />
<span>{t('worktree.useExisting')}</span>
</label>
<label class="modal-radio">
<input type="radio" name="worktree-mode" value="new" bind:group={mode} />
<span>{t('worktree.createNewBranch')}</span>
</label>
</div>

<div class="modal-form-group">
<div class="modal-field-row">
<label class="modal-field-label" for="wt-branch">{t('worktree.branchName')}</label>
<input id="wt-branch" class="modal-input" type="text" bind:value={branchName} bind:this={branchInput} placeholder={t('worktree.branchPlaceholder')} onkeydown={(e) => { if (e.key === 'Enter' && canSubmit) handleSubmit(); }} />
{#if mode === 'existing'}
<div class="modal-form-group">
<div class="modal-field-row">
<div class="modal-field-label">{t('worktree.existingBranch')}</div>
<ColorSelect
options={availableExistingBranches.map(b => ({ value: b, label: b, color: '' }))}
value={existingBranch}
onChange={(v) => { existingBranch = v; }}
showDot={false}
/>
</div>
</div>
</div>
{:else}
<div class="modal-form-group">
<div class="modal-field-row">
<div class="modal-field-label">{t('worktree.startAt')}</div>
<ColorSelect
options={startAtOptions.map(b => ({ value: b, label: b, color: '' }))}
value={startAt}
onChange={(v) => { startAt = v; }}
showDot={false}
/>
</div>
</div>

<div class="modal-form-group">
<div class="modal-field-row">
<label class="modal-field-label" for="wt-branch">{t('worktree.branchName')}</label>
<input id="wt-branch" class="modal-input" type="text" bind:value={branchName} bind:this={branchInput} placeholder={t('worktree.branchPlaceholder')} onkeydown={(e) => { if (e.key === 'Enter' && canSubmit) handleSubmit(); }} />
</div>
</div>
{/if}

<div class="modal-form-group">
<div class="modal-field-row">
Expand All @@ -83,4 +144,10 @@
font-size: 11px;
color: var(--text-secondary);
}
.mode-options {
display: flex;
flex-direction: column;
gap: 8px;
}
</style>
Loading
Loading