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
7 changes: 7 additions & 0 deletions .changeset/recent-projects-remove-stale.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@inkeep/open-knowledge": patch
---

Recent projects can now be removed from the desktop app, matching VS Code's "Open Recent" behavior. Recents in the sidebar "Switch project" dropdown and the command palette gain a hover "×" and a right-click "Remove from recent projects" action; the Project Navigator keeps its per-row "×" and now offers the same right-click action. Removing an entry also clears its saved editor session and window position, not just the list row.

Opening a recent whose folder was deleted or moved outside the app no longer leaves a dead entry behind. The app detects the gone folder on open, drops the stale entry from the list, and shows a brief notice instead of failing to open a window. Missing recents are no longer greyed out or tagged; they stay openable and self-clean on open. Only a folder that is genuinely gone is pruned this way: a folder that is merely unreadable for the moment (a permission-restricted parent) stays in the list.
2 changes: 2 additions & 0 deletions docs/content/features/editor.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ Turn it off in **Settings → Terminal → “Let agents use OpenKnowledge witho

`Cmd+K` opens the command palette in both the desktop app and the browser. Type to jump to any file or folder, run create commands, open the graph or settings, or dispatch to an agent; recently opened entries surface first. In the desktop app it also switches projects and worktrees.

Remove a recent project from the list by hovering its row and clicking the ×, or right-clicking for a **Remove from recent projects** menu — the same controls are on the sidebar's **Switch project** dropdown and in the Project Navigator. A recent whose folder was deleted or moved off disk isn't greyed out; it's cleared automatically the next time you open it, with a brief notice, instead of opening a broken window.

A `tag:` prefix searches by tag: `tag:` alone lists every tag, `tag:fr` narrows the tag list, and `tag:frontend` lists the docs tagged `#frontend` (including its hierarchy children). When semantic search is configured for the project, a **By meaning** pill switches the palette to meaning-based search — press Enter to run it; typing alone never fires a query.

## Sidebars and window width
Expand Down
24 changes: 24 additions & 0 deletions packages/app/src/components/CommandPalette.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ function createBridge() {
),
open: mock(() => Promise.resolve()),
openFile: mock(() => Promise.resolve()),
removeRecent: mock(() => Promise.resolve()),
},
dialog: {
openFolder: mock(() => Promise.resolve('/chosen/folder')),
Expand Down Expand Up @@ -397,6 +398,29 @@ describe('CommandPalette DOM behavior', () => {
expect(screen.queryByTestId('command-palette-switch-project')).toBeNull();
});

test('the per-row × prunes a recent, keeps the palette open, and does not open it', async () => {
const bridge = createBridge();
const { onOpenChange } = await renderPalette({ bridge });
await waitFor(() => expect(bridge.project.listRecent).toHaveBeenCalledTimes(1));

// The × is a sibling of the recent's option row. Clicking it must prune via
// removeRecent and NOT fall through to the row's open onSelect (the
// stopPropagation contract) — unlike clicking the row, which opens + closes.
fireEvent.click(screen.getByTestId('command-palette-recent-remove-/projects/alpha'));
await waitFor(() =>
expect(bridge.project.removeRecent).toHaveBeenCalledWith('/projects/alpha'),
);
expect(bridge.project.open).not.toHaveBeenCalled();

// Optimistic drop of that row, and the palette stays open (runWithToast, not
// runAction) so the user can prune several in a row.
await waitFor(() =>
expect(screen.queryByTestId('command-palette-recent-/projects/alpha')).toBeNull(),
);
expect(screen.queryByTestId('command-palette-recent-/archive/omega-project')).not.toBeNull();
expect(onOpenChange).not.toHaveBeenCalledWith(false);
});

test('new-folder shortcut is desktop-only while new-file shortcut is always visible', async () => {
await renderPalette({ bridge: null });

Expand Down
88 changes: 54 additions & 34 deletions packages/app/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ import { refreshWorktrees } from '@/lib/worktree-store';
import { buildHandoffInput, useHandoffDispatch } from './handoff/useHandoffDispatch';
import { useInstalledAgents } from './handoff/useInstalledAgents';
import { basenameOf } from './project-switcher-recents';
import { RecentItemContextMenu, RecentRemoveButton } from './recent-remove-controls';

const COMMAND_PALETTE_SEARCH_TIMEOUT_MS = 3000;
// Re-poll cadence while the server reports the search index is still warming
Expand Down Expand Up @@ -727,6 +728,18 @@ export function CommandPalette({ bridge = null, open, onOpenChange }: CommandPal
}, fallback);
};

// Remove a single recent (VS Code Open Recent per-row remove). Unlike
// `runAction`, this keeps the palette OPEN so the user can prune several in a
// row; the bridge call also clears the entry's session / window-bounds /
// last-opened keys, and we optimistically drop the row here.
const onRemoveRecent = (path: string) => {
if (!bridge) return;
void runWithToast(async () => {
await bridge.project.removeRecent(path);
setProjectRecents((cur) => cur.filter((r) => r.path !== path));
}, t`Failed to remove project.`);
};

// Open a worktree from the palette. An existing
// worktree opens its window directly; a branch without one is created on
// demand, then opened — mirroring the ProjectSwitcher submenu. `refresh` after
Expand Down Expand Up @@ -1465,43 +1478,50 @@ export function CommandPalette({ bridge = null, open, onOpenChange }: CommandPal
const worktreeOf =
isWorktree && row.mainRoot !== undefined ? basenameOf(row.mainRoot) : null;
return (
<CommandItem
<RecentItemContextMenu
key={row.path}
value={`${row.name} ${row.path} recent project`}
disabled={row.missing}
onSelect={() =>
runAction(
() =>
bridge.project.open({
path: row.path,
target: 'new-window',
entryPoint: 'recents',
}),
t`Failed to open project.`,
)
}
data-testid={`command-palette-recent-${row.path}`}
className="items-start"
path={row.path}
onRemoveRecent={onRemoveRecent}
testIdPrefix="command-palette-recent"
>
<RowIcon className="mt-0.5" />
<div className="flex min-w-0 flex-col gap-1">
<span className="truncate font-medium">{row.name}</span>
{worktreeOf !== null ? (
<span className="truncate text-muted-foreground text-xs">
<Trans>worktree of {worktreeOf}</Trans>
</span>
) : null}
<span className="truncate text-muted-foreground text-xs">
{row.path}
{row.missing ? (
<>
{' '}
<Trans>(missing)</Trans>
</>
) : null}
</span>
<div className="group/recent relative flex items-center">
<CommandItem
value={`${row.name} ${row.path} recent project`}
onSelect={() =>
runAction(
() =>
bridge.project.open({
path: row.path,
target: 'new-window',
entryPoint: 'recents',
}),
t`Failed to open project.`,
)
}
data-testid={`command-palette-recent-${row.path}`}
className="flex-1 items-start pr-8"
>
<RowIcon className="mt-0.5" />
<div className="flex min-w-0 flex-col gap-1">
<span className="truncate font-medium">{row.name}</span>
{worktreeOf !== null ? (
<span className="truncate text-muted-foreground text-xs">
<Trans>worktree of {worktreeOf}</Trans>
</span>
) : null}
<span className="truncate text-muted-foreground text-xs">
{row.path}
</span>
</div>
</CommandItem>
<RecentRemoveButton
path={row.path}
name={row.name}
onRemoveRecent={onRemoveRecent}
testIdPrefix="command-palette-recent"
/>
</div>
</CommandItem>
</RecentItemContextMenu>
);
})}
</CommandGroup>
Expand Down
27 changes: 27 additions & 0 deletions packages/app/src/components/NavigatorApp.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ function createBridge() {
return {
appVersion: '0.4.0-beta.1',
onMenuAction: mock(() => () => {}),
onRecentRemovedMissing: mock(
(_cb: (info: { path: string; projectName: string }) => void) => () => {},
),
config: {
collabUrl: '',
apiOrigin: '',
Expand Down Expand Up @@ -311,4 +314,28 @@ describe('NavigatorApp launcher runtime behavior', () => {
expect(plainRow.textContent).toContain('/Users/x/plain-notes');
expect(plainRow.textContent).not.toContain('worktree');
});

test('drops only the matching row when main pushes recent-removed-missing', async () => {
const bridge = createBridge();
bridge.project.listRecent = mock(() =>
Promise.resolve([
{ path: '/projects/keep', name: 'Keep Project' },
{ path: '/projects/gone', name: 'Gone Project' },
]),
);
await renderNavigator(bridge);
await screen.findByText('Keep Project');
await screen.findByText('Gone Project');

// The lazy-remove-on-open push is a one-way main→renderer event; grab the
// callback the effect registered and fire it as main would for the pruned
// window. The module-init listener owns the toast; this effect owns the row.
expect(bridge.onRecentRemovedMissing).toHaveBeenCalledTimes(1);
const onRemovedMissing = bridge.onRecentRemovedMissing.mock.calls[0]?.[0];
expect(onRemovedMissing).toBeDefined();
act(() => onRemovedMissing?.({ path: '/projects/gone', projectName: 'Gone Project' }));

await waitFor(() => expect(screen.queryByText('Gone Project')).toBeNull());
expect(screen.getByText('Keep Project')).not.toBeNull();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ function createBridge(recents: unknown[]) {
return {
appVersion: '0.4.0-beta.1',
onMenuAction: mock(() => () => {}),
onRecentRemovedMissing: mock(() => () => {}),
config: { mode: 'navigator' },
project: {
listRecent: mock(() => Promise.resolve(recents)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ function makeNavigatorBridge(): NavigatorBridgeStub {
mode: 'navigator',
},
onMenuAction: () => () => {},
onRecentRemovedMissing: () => () => {},
project: {
listRecent: async () => [],
removeRecent: async () => undefined,
Expand Down
129 changes: 69 additions & 60 deletions packages/app/src/components/NavigatorApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ import { createCloneController } from '@/lib/share/clone-controller';
import { ipcAuthQueryTransport } from '@/lib/transports/auth-query-transport';
import { ipcAuthTransport } from '@/lib/transports/auth-transport';
import { ipcCloneTransport } from '@/lib/transports/clone-transport';
import { cn } from '@/lib/utils';
import { AuthModal } from './AuthModal';
import { BetaBadge } from './BetaBadge';
import { CloneDialog } from './CloneDialog';
Expand All @@ -61,6 +60,7 @@ import { McpConsentDialog } from './McpConsentDialog';
import { PackCardGrid } from './PackCardGrid';
import { basenameOf } from './project-switcher-recents';
import { ReportBugDialog } from './ReportBugDialog';
import { RecentItemContextMenu } from './recent-remove-controls';
import { Badge } from './ui/badge';
import { Button } from './ui/button';

Expand Down Expand Up @@ -231,6 +231,21 @@ export function NavigatorApp({ bridge }: { bridge: OkDesktopBridge }) {
});
}, []);

// Main prunes a recents entry whose folder vanished when the user opens it
// (VS Code "Open Recent" parity). Drop the row from our list so it doesn't
// linger; the module-init listener shows the toast, so we don't re-notify.
useEffect(() => {
return bridge.onRecentRemovedMissing(({ path }) => {
setRecents((current) => removeRecentFromList(current, path));
setRecentBranches((current) => {
if (!current.has(path)) return current;
const next = new Map(current);
next.delete(path);
return next;
});
});
}, [bridge]);

/**
* Wrap any bridge call in a visible error state. Without this the IPC
* rejection (utility failed to boot, bad folder, dialog rejected) lands as
Expand Down Expand Up @@ -763,71 +778,65 @@ function RecentRow({
// Branch chip shows the worktree's own branch, else the project's current branch.
const rowBranch = isWorktree ? (project.branch ?? branch) : branch;
return (
<li className="group flex items-center justify-between rounded-lg hover:bg-accent">
<Button
type="button"
variant="ghost"
onClick={onOpen}
disabled={project.missing}
className={cn(
'h-auto min-w-0 flex-1 justify-between gap-3 py-3.5 pl-4 pr-2 text-left hover:bg-transparent',
project.missing && 'opacity-50',
)}
>
<div className="flex min-w-0 items-center gap-3">
{/* Uniform folder icon for every row — worktree vs project is conveyed
<RecentItemContextMenu path={project.path} onRemoveRecent={onRemove} testIdPrefix="nav-recent">
<li className="group flex items-center justify-between rounded-lg hover:bg-accent">
<Button
type="button"
variant="ghost"
onClick={onOpen}
className="h-auto min-w-0 flex-1 justify-between gap-3 py-3.5 pl-4 pr-2 text-left hover:bg-transparent"
>
<div className="flex min-w-0 items-center gap-3">
{/* Uniform folder icon for every row — worktree vs project is conveyed
by the worktree pill + the branch chip, not by the icon. */}
<Folder aria-hidden="true" className="size-[18px] shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-col gap-1 truncate">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium text-sm text-gray-700 dark:text-foreground">
{project.name}
<Folder aria-hidden="true" className="size-[18px] shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-col gap-1 truncate">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium text-sm text-gray-700 dark:text-foreground">
{project.name}
</span>
{isWorktree ? (
<Badge
variant="secondary"
className="shrink-0 gap-1 rounded-full border-transparent bg-green-600/10 px-2 py-0 font-medium text-2xs text-green-800 dark:bg-green-400/10 dark:text-green-400"
>
<GitBranch aria-hidden="true" className="size-2.5" />
<Trans>worktree</Trans>
</Badge>
) : null}
</div>
<span
className="truncate w-full text-muted-foreground text-xs"
title={isWorktree ? (project.mainRoot ?? '') : project.path}
>
{isWorktree ? <Trans>of {basenameOf(project.mainRoot ?? '')}</Trans> : project.path}
</span>
{isWorktree ? (
<Badge
variant="secondary"
className="shrink-0 gap-1 rounded-full border-transparent bg-green-600/10 px-2 py-0 font-medium text-2xs text-green-800 dark:bg-green-400/10 dark:text-green-400"
>
<GitBranch aria-hidden="true" className="size-2.5" />
<Trans>worktree</Trans>
</Badge>
) : null}
</div>
</div>
{rowBranch != null ? (
<span
className="truncate w-full text-muted-foreground text-xs"
title={isWorktree ? (project.mainRoot ?? '') : project.path}
className="flex max-w-[40%] items-center gap-1 text-muted-foreground text-xs"
data-testid={`nav-recent-branch-${project.path}`}
>
{isWorktree ? <Trans>of {basenameOf(project.mainRoot ?? '')}</Trans> : project.path}
<GitBranch aria-hidden="true" className="size-3 shrink-0" />
<span className="truncate font-mono">{rowBranch}</span>
</span>
</div>
</div>
{project.missing ? (
<Badge className="text-2xs rounded-sm" variant="warning">
<Trans>Missing</Trans>
</Badge>
) : rowBranch != null ? (
<span
className="flex max-w-[40%] items-center gap-1 text-muted-foreground text-xs"
data-testid={`nav-recent-branch-${project.path}`}
>
<GitBranch aria-hidden="true" className="size-3 shrink-0" />
<span className="truncate font-mono">{rowBranch}</span>
</span>
) : null}
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={onRemove}
aria-label={t`Remove ${projectName} from recent projects`}
title={t`Remove from recent projects`}
className="pointer-events-none mr-2 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100"
data-testid={`nav-recent-remove-${project.path}`}
>
<XIcon aria-hidden="true" />
</Button>
</li>
) : null}
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={onRemove}
aria-label={t`Remove ${projectName} from recent projects`}
title={t`Remove from recent projects`}
className="pointer-events-none mr-2 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100"
data-testid={`nav-recent-remove-${project.path}`}
>
<XIcon aria-hidden="true" />
</Button>
</li>
</RecentItemContextMenu>
);
}

Expand Down
Loading