Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/worktree-selector.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inkeep/open-knowledge": patch
---

You can now work on several branches of a project at once, each in its own window. The project switcher in the sidebar footer (and the File menu) has a new Worktrees section: pick a branch to open its worktree in a new window — if it doesn't have one yet, OpenKnowledge creates it on demand — or choose "New worktree…" to start a fresh branch. Worktrees are stored inside the project under `.ok/worktrees/` and kept out of git status automatically, so each window stays fully isolated (its own editor and server) without touching your working copy.
76 changes: 76 additions & 0 deletions packages/app/src/components/CommandPalette.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,13 @@ mock.module('@/components/command-palette-tag-search', () => ({
fetchDocsForTag: mock(() => Promise.resolve([])),
}));

let worktreeModelMock: import('@inkeep/open-knowledge-core').WorktreeSelectorModel | null = null;
mock.module('@/hooks/use-worktrees', () => ({
useWorktrees: () => worktreeModelMock,
}));
const refreshWorktreesMock = mock(() => {});
mock.module('@/lib/worktree-store', () => ({ refreshWorktrees: refreshWorktreesMock }));

function recent(name: string, path = `/projects/${name.toLowerCase()}`) {
return { name, path: path.replaceAll(' ', '-') };
}
Expand All @@ -209,6 +216,15 @@ function createBridge() {
navigator: {
open: mock(() => Promise.resolve()),
},
worktree: {
create: mock(() =>
Promise.resolve({
ok: true as const,
path: '/projects/current/.ok/worktrees/feature-x',
created: true,
}),
),
},
};
}

Expand Down Expand Up @@ -247,6 +263,8 @@ describe('CommandPalette DOM behavior', () => {
createProjectDialogProps = [];
commandDialogProps = [];
refreshInstallStatesCalls = 0;
worktreeModelMock = null;
refreshWorktreesMock.mockClear();
window.location.hash = '';
globalThis.fetch = mock(() =>
Promise.resolve(new Response(JSON.stringify({ results: [] }), { status: 200 })),
Expand Down Expand Up @@ -541,6 +559,64 @@ describe('CommandPalette DOM behavior', () => {
expect(screen.queryByTestId('command-palette-search-preparing')).toBeNull(),
);
});

test('surfaces worktrees of the current project — opens an existing one and creates one on demand', async () => {
worktreeModelMock = {
mainRoot: '/projects/current',
currentBranch: 'main',
entries: [
{
branch: 'main',
worktreePath: '/projects/current',
isCurrent: true,
isMain: true,
locked: false,
},
{
branch: 'dev',
worktreePath: '/projects/current/.ok/worktrees/dev',
isCurrent: false,
isMain: false,
locked: false,
},
{
branch: 'feature-x',
worktreePath: null,
isCurrent: false,
isMain: false,
locked: false,
},
],
};
const { bridge } = await renderPalette();

expect(screen.queryByTestId('command-palette-worktree-main')).toBeNull();

fireEvent.click(screen.getByTestId('command-palette-worktree-dev'));
await waitFor(() => {
expect(bridge?.project.open).toHaveBeenCalledWith({
path: '/projects/current/.ok/worktrees/dev',
target: 'new-window',
entryPoint: 'worktree',
});
});

fireEvent.click(screen.getByTestId('command-palette-worktree-feature-x'));
await waitFor(() => {
expect(bridge?.worktree.create).toHaveBeenCalledWith({
branch: 'feature-x',
createBranch: false,
});
});
await waitFor(() => {
expect(bridge?.project.open).toHaveBeenCalledWith({
path: '/projects/current/.ok/worktrees/feature-x',
target: 'new-window',
entryPoint: 'worktree',
});
});
expect(refreshWorktreesMock).toHaveBeenCalled();
});
});

describe('NavigationItem path subtitle', () => {
Expand Down
63 changes: 62 additions & 1 deletion packages/app/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// biome-ignore-all lint/plugin/no-raw-html-interactive-element: pre-rule backlog — file uses raw <button>/<input>/<textarea> awaiting shadcn migration; tracked at https://github.com/inkeep/open-knowledge/blob/main/biome-plugins/README.md#no-raw-html-interactive-elementgrit

import { SHOW_INSTALL_SKILL } from '@inkeep/open-knowledge-core';
import { SHOW_INSTALL_SKILL, type WorktreeSelectorEntry } from '@inkeep/open-knowledge-core';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import {
Box,
Expand All @@ -9,6 +9,7 @@ import {
FileText,
FolderOpen,
FolderPlus,
GitBranch,
Hash,
LayoutGrid,
Loader2,
Expand Down Expand Up @@ -75,6 +76,7 @@ import { useDocumentContext } from '@/editor/DocumentContext';
import type { TagSummaryEntry } from '@/editor/extensions/tag-suggestion';
import { useIsEmbedded } from '@/hooks/use-is-embedded';
import { useSemanticSearchStatus } from '@/hooks/use-semantic-search-status';
import { useWorktrees } from '@/hooks/use-worktrees';
import type { OkDesktopBridge, RecentProjectEntry } from '@/lib/desktop-bridge-types';
import { hashFromDocName } from '@/lib/doc-hash';
import { runWithToast as runWithToastBase } from '@/lib/error-state';
Expand All @@ -84,6 +86,7 @@ import { useSingleFileMode } from '@/lib/single-file-mode';
import { SETTINGS_OPEN_HASH } from '@/lib/use-settings-route';
import { useWorkspace } from '@/lib/use-workspace';
import { cn } from '@/lib/utils.ts';
import { refreshWorktrees } from '@/lib/worktree-store';
import { buildHandoffInput, useHandoffDispatch } from './handoff/useHandoffDispatch';
import { useInstalledAgents } from './handoff/useInstalledAgents';

Expand Down Expand Up @@ -310,6 +313,11 @@ export function CommandPalette({ bridge = null, open, onOpenChange }: CommandPal
const visibleRecents = filterOmnibarRecents(recentNavigation, validRecentKeys);
const currentPath = bridge?.config.projectPath ?? null;
const switchableProjects = bridge ? projectRecents.filter((row) => row.path !== currentPath) : [];
const worktreeModel = useWorktrees();
const switchableWorktrees =
bridge && worktreeModel
? worktreeModel.entries.filter((entry) => entry.branch !== null && !entry.isCurrent)
: [];
const initialCreateDir = resolveCreateInitialDir(activeTarget, activeDocName);
const fallbackSearchResults =
trimmedDeferredQuery === ''
Expand Down Expand Up @@ -521,6 +529,31 @@ export function CommandPalette({ bridge = null, open, onOpenChange }: CommandPal
}, fallback);
};

const openWorktreeEntry = (entry: WorktreeSelectorEntry) => {
if (!bridge) return;
const existingPath = entry.worktreePath;
if (existingPath !== null) {
runAction(
() =>
bridge.project.open({ path: existingPath, target: 'new-window', entryPoint: 'worktree' }),
t`Failed to open worktree.`,
);
return;
}
const branch = entry.branch;
if (branch === null) return;
runAction(async () => {
const result = await bridge.worktree.create({ branch, createBranch: false });
if (!result.ok) throw new Error(result.reason);
refreshWorktrees();
await bridge.project.open({
path: result.path,
target: 'new-window',
entryPoint: 'worktree',
});
}, t`Failed to open worktree.`);
};

function rememberNavigation(entry: WorkspaceEntry | OmnibarRecentEntry) {
const nextEntry = {
kind: entry.kind,
Expand Down Expand Up @@ -604,6 +637,12 @@ export function CommandPalette({ bridge = null, open, onOpenChange }: CommandPal
switchableProjects.some((row) =>
matchesCommandQuery(`${row.name} ${row.path}`, deferredQuery, ['open recent project']),
));
const matchedWorktrees = switchableWorktrees.filter(
(entry) =>
trimmedDeferredQuery === '' ||
matchesCommandQuery(entry.branch ?? '', deferredQuery, ['worktree branch']),
);
const showWorktrees = !inExclusiveMode && bridge !== null && matchedWorktrees.length > 0;
const isEmbedded = useIsEmbedded();
const showAgentGroup =
!inExclusiveMode &&
Expand Down Expand Up @@ -1296,6 +1335,28 @@ export function CommandPalette({ bridge = null, open, onOpenChange }: CommandPal
</CommandGroup>
) : null}

{showWorktrees && bridge ? (
<CommandGroup heading={t`Worktrees`}>
{matchedWorktrees.slice(0, 10).map((entry) => (
<CommandItem
key={entry.branch}
value={`${entry.branch} worktree branch`}
onSelect={() => openWorktreeEntry(entry)}
data-testid={`command-palette-worktree-${entry.branch}`}
className="items-start"
>
<GitBranch className="mt-0.5" />
<div className="flex min-w-0 flex-col gap-1">
<span className="truncate font-medium">{entry.branch}</span>
<span className="truncate text-muted-foreground text-xs">
{entry.worktreePath ?? t`Create worktree`}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
) : null}

{showNavigation ? (
<CommandGroup heading={t`Search`}>
{visibleSearchResults.map((entry) => (
Expand Down
145 changes: 145 additions & 0 deletions packages/app/src/components/NewWorktreeDialog.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { NewWorktreeDialog } from './NewWorktreeDialog';

const refreshWorktrees = mock(() => {});
mock.module('@/lib/worktree-store', () => ({ refreshWorktrees }));

function createBridge(createResult: unknown) {
return {
worktree: { create: mock(() => Promise.resolve(createResult)) },
project: { open: mock(() => Promise.resolve()) },
};
}

const noop = () => {};

describe('NewWorktreeDialog', () => {
beforeEach(() => {
cleanup();
refreshWorktrees.mockClear();
});

test('creates a new branch worktree and opens it (entryPoint worktree)', async () => {
const bridge = createBridge({
ok: true,
path: '/repo/.ok/worktrees/my-feature',
created: true,
});
render(
<NewWorktreeDialog
open={true}
onOpenChange={noop}
bridge={bridge as never}
currentBranch="main"
/>,
);
const input = await screen.findByTestId('new-worktree-branch');
fireEvent.change(input, { target: { value: 'my-feature' } });
fireEvent.click(screen.getByTestId('new-worktree-create'));

await waitFor(() => expect(bridge.worktree.create).toHaveBeenCalledTimes(1));
expect(bridge.worktree.create).toHaveBeenCalledWith({
branch: 'my-feature',
createBranch: true,
baseBranch: 'main',
});
await waitFor(() =>
expect(bridge.project.open).toHaveBeenCalledWith({
path: '/repo/.ok/worktrees/my-feature',
target: 'new-window',
entryPoint: 'worktree',
}),
);
});

test('surfaces a branch-exists failure inline without opening a window', async () => {
const bridge = createBridge({ ok: false, reason: 'branch-exists' });
render(
<NewWorktreeDialog
open={true}
onOpenChange={noop}
bridge={bridge as never}
currentBranch="main"
/>,
);
fireEvent.change(await screen.findByTestId('new-worktree-branch'), {
target: { value: 'dev' },
});
fireEvent.click(screen.getByTestId('new-worktree-create'));
const err = await screen.findByTestId('new-worktree-error');
expect(err.textContent).toContain('already exists');
expect(bridge.project.open).not.toHaveBeenCalled();
});

test('checks out an existing branch (createBranch false, no base) and refreshes the cache', async () => {
const bridge = createBridge({
ok: true,
path: '/repo/.ok/worktrees/dev',
created: false,
});
render(
<NewWorktreeDialog
open={true}
onOpenChange={noop}
bridge={bridge as never}
currentBranch="main"
branches={['main', 'dev', 'release']}
/>,
);
const input = await screen.findByTestId('new-worktree-branch');
fireEvent.change(input, { target: { value: 'dev' } });

expect(screen.getByTestId('new-worktree-create').textContent).toContain('Check out');

fireEvent.click(screen.getByTestId('new-worktree-create'));
await waitFor(() => expect(bridge.worktree.create).toHaveBeenCalledTimes(1));
expect(bridge.worktree.create).toHaveBeenCalledWith({
branch: 'dev',
createBranch: false,
baseBranch: undefined,
});
await waitFor(() =>
expect(bridge.project.open).toHaveBeenCalledWith({
path: '/repo/.ok/worktrees/dev',
target: 'new-window',
entryPoint: 'worktree',
}),
);
expect(refreshWorktrees).toHaveBeenCalled();
});

test('offers existing branches as datalist options for selection', async () => {
const bridge = createBridge({ ok: true, path: '/x', created: true });
render(
<NewWorktreeDialog
open={true}
onOpenChange={noop}
bridge={bridge as never}
currentBranch="main"
branches={['main', 'dev']}
/>,
);
const list = await screen.findByTestId('new-worktree-branch-list');
const options = Array.from(list.querySelectorAll('option')).map((o) => o.getAttribute('value'));
expect(options).toEqual(['main', 'dev']);
const input = screen.getByTestId('new-worktree-branch');
expect(input.getAttribute('list')).toBe(list.getAttribute('id'));
});

test('the create button is disabled until a branch name is entered', async () => {
const bridge = createBridge({ ok: true, path: '/x', created: true });
render(
<NewWorktreeDialog
open={true}
onOpenChange={noop}
bridge={bridge as never}
currentBranch={null}
/>,
);
const button = (await screen.findByTestId('new-worktree-create')) as HTMLButtonElement;
expect(button.disabled).toBe(true);
fireEvent.change(screen.getByTestId('new-worktree-branch'), { target: { value: 'x' } });
await waitFor(() => expect(button.disabled).toBe(false));
});
});
Loading