diff --git a/.changeset/worktree-selector.md b/.changeset/worktree-selector.md new file mode 100644 index 000000000..3873d141d --- /dev/null +++ b/.changeset/worktree-selector.md @@ -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. diff --git a/packages/app/src/components/CommandPalette.dom.test.tsx b/packages/app/src/components/CommandPalette.dom.test.tsx index 3bf433a15..d2992e635 100644 --- a/packages/app/src/components/CommandPalette.dom.test.tsx +++ b/packages/app/src/components/CommandPalette.dom.test.tsx @@ -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(' ', '-') }; } @@ -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, + }), + ), + }, }; } @@ -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 })), @@ -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', () => { diff --git a/packages/app/src/components/CommandPalette.tsx b/packages/app/src/components/CommandPalette.tsx index b2b1b8cee..add9bc080 100644 --- a/packages/app/src/components/CommandPalette.tsx +++ b/packages/app/src/components/CommandPalette.tsx @@ -1,6 +1,6 @@ // biome-ignore-all lint/plugin/no-raw-html-interactive-element: pre-rule backlog — file uses raw