diff --git a/.changeset/recent-projects-remove-stale.md b/.changeset/recent-projects-remove-stale.md new file mode 100644 index 00000000..2704e64f --- /dev/null +++ b/.changeset/recent-projects-remove-stale.md @@ -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. diff --git a/docs/content/features/editor.mdx b/docs/content/features/editor.mdx index 8c121ec8..950f6264 100644 --- a/docs/content/features/editor.mdx +++ b/docs/content/features/editor.mdx @@ -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 diff --git a/packages/app/src/components/CommandPalette.dom.test.tsx b/packages/app/src/components/CommandPalette.dom.test.tsx index db1e83d8..21a169ad 100644 --- a/packages/app/src/components/CommandPalette.dom.test.tsx +++ b/packages/app/src/components/CommandPalette.dom.test.tsx @@ -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')), @@ -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 }); diff --git a/packages/app/src/components/CommandPalette.tsx b/packages/app/src/components/CommandPalette.tsx index 1c268a24..62441519 100644 --- a/packages/app/src/components/CommandPalette.tsx +++ b/packages/app/src/components/CommandPalette.tsx @@ -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 @@ -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 @@ -1465,43 +1478,50 @@ export function CommandPalette({ bridge = null, open, onOpenChange }: CommandPal const worktreeOf = isWorktree && row.mainRoot !== undefined ? basenameOf(row.mainRoot) : null; return ( - - 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" > - -
- {row.name} - {worktreeOf !== null ? ( - - worktree of {worktreeOf} - - ) : null} - - {row.path} - {row.missing ? ( - <> - {' '} - (missing) - - ) : null} - +
+ + 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" + > + +
+ {row.name} + {worktreeOf !== null ? ( + + worktree of {worktreeOf} + + ) : null} + + {row.path} + +
+
+
- + ); })} diff --git a/packages/app/src/components/NavigatorApp.dom.test.tsx b/packages/app/src/components/NavigatorApp.dom.test.tsx index 0e18aed2..09835d51 100644 --- a/packages/app/src/components/NavigatorApp.dom.test.tsx +++ b/packages/app/src/components/NavigatorApp.dom.test.tsx @@ -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: '', @@ -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(); + }); }); diff --git a/packages/app/src/components/NavigatorApp.first-run.dom.test.tsx b/packages/app/src/components/NavigatorApp.first-run.dom.test.tsx index 74c7a50f..5b6402c3 100644 --- a/packages/app/src/components/NavigatorApp.first-run.dom.test.tsx +++ b/packages/app/src/components/NavigatorApp.first-run.dom.test.tsx @@ -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)), diff --git a/packages/app/src/components/NavigatorApp.menu-action.dom.test.tsx b/packages/app/src/components/NavigatorApp.menu-action.dom.test.tsx index 517f2924..b48fca6b 100644 --- a/packages/app/src/components/NavigatorApp.menu-action.dom.test.tsx +++ b/packages/app/src/components/NavigatorApp.menu-action.dom.test.tsx @@ -90,6 +90,7 @@ function makeNavigatorBridge(): NavigatorBridgeStub { mode: 'navigator', }, onMenuAction: () => () => {}, + onRecentRemovedMissing: () => () => {}, project: { listRecent: async () => [], removeRecent: async () => undefined, diff --git a/packages/app/src/components/NavigatorApp.tsx b/packages/app/src/components/NavigatorApp.tsx index 05054c44..0036c6c4 100644 --- a/packages/app/src/components/NavigatorApp.tsx +++ b/packages/app/src/components/NavigatorApp.tsx @@ -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'; @@ -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'; @@ -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 @@ -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 ( -
  • - - -
  • + ) : null} + + + + ); } diff --git a/packages/app/src/components/ProjectSwitcher.dom.test.tsx b/packages/app/src/components/ProjectSwitcher.dom.test.tsx index 004072cd..8c6fe13b 100644 --- a/packages/app/src/components/ProjectSwitcher.dom.test.tsx +++ b/packages/app/src/components/ProjectSwitcher.dom.test.tsx @@ -338,6 +338,26 @@ describe('ProjectSwitcher dropdown behavior', () => { expect(createDialogProps.at(-1)?.bridge).toBe(bridge); }); + test('the per-row × removes a recent from the list without opening it', async () => { + const bridge = createBridge(); + bridge.project.removeRecent = mock(() => Promise.resolve()); + render(); + await openMenu(); + + // Project 1 has no opened worktrees, so it renders as a flat recent row that + // carries the trailing "×". Clicking it must prune the entry, NOT open it — + // the × is a sibling of the menu item, so the item's open `onSelect` never + // fires (VS Code Open Recent per-row remove). + fireEvent.click(screen.getByTestId('project-switcher-recent-remove-/projects/project-1')); + await waitFor(() => { + expect(bridge.project.removeRecent).toHaveBeenCalledWith('/projects/project-1'); + }); + expect(bridge.project.open).not.toHaveBeenCalled(); + await waitFor(() => { + expect(screen.queryByTestId('project-switcher-recent-/projects/project-1')).toBeNull(); + }); + }); + test('search matches projects only (not worktrees/branches), announces empty results, stops typeahead bubbling, and clears on close', async () => { const bridge = createBridge(); render(); diff --git a/packages/app/src/components/ProjectSwitcher.tsx b/packages/app/src/components/ProjectSwitcher.tsx index af07c2fb..1420c816 100644 --- a/packages/app/src/components/ProjectSwitcher.tsx +++ b/packages/app/src/components/ProjectSwitcher.tsx @@ -195,6 +195,16 @@ export function ProjectSwitcher({ bridge }: ProjectSwitcherProps) { void runWithToast(() => bridge.navigator.open(), t`Failed to open Project Navigator.`); }; + // Remove a single recent (VS Code "Open Recent" per-row remove). Prunes the + // one canonical recents list via the bridge (which also clears the entry's + // session / window-bounds / last-opened keys) and optimistically drops the + // row here; the menu stays open so the user can remove several in a row. + const onRemoveRecent = (path: string) => + runWithToast(async () => { + await bridge.project.removeRecent(path); + setRecents((cur) => (cur === null ? cur : cur.filter((r) => r.path !== path))); + }, t`Failed to remove project.`); + // Close the dropdown before opening the dialog — two stacked Radix overlays // (menu + dialog) would otherwise fight over focus return on dismiss. The // dialog drives the full scaffold + open-in-new-window flow itself. @@ -359,6 +369,7 @@ export function ProjectSwitcher({ bridge }: ProjectSwitcherProps) { worktreeModel={menuWorktreeModel} closeMenu={() => handleOpenChange(false)} guardStaleSelect={guardStaleSelect} + onRemoveRecent={onRemoveRecent} flyoutPath={flyoutPath} setFlyoutPath={setFlyoutPath} openNewWorktreeWith={openNewWorktreeWith} diff --git a/packages/app/src/components/RecentProjectsMenu.tsx b/packages/app/src/components/RecentProjectsMenu.tsx index cb291e77..f0a0f53e 100644 --- a/packages/app/src/components/RecentProjectsMenu.tsx +++ b/packages/app/src/components/RecentProjectsMenu.tsx @@ -55,6 +55,7 @@ import { type RecentRepoGroup, type WorktreeFlyoutEntry, } from './project-switcher-recents'; +import { RecentItemContextMenu, RecentRemoveButton } from './recent-remove-controls'; interface RecentProjectsMenuProps { bridge: OkDesktopBridge; @@ -68,6 +69,8 @@ interface RecentProjectsMenuProps { closeMenu: () => void; /** Swallows the Electron open-click fall-through (see ProjectSwitcher). */ guardStaleSelect: (event: Event) => boolean; + /** Remove a recent project from the (single) recents list. */ + onRemoveRecent: (path: string) => void; /** * Hoisted "which project row's worktree flyout is open" (its `project.path`), * or null. Lives in ProjectSwitcher so only one flyout is open at a time and @@ -92,6 +95,7 @@ export function RecentProjectsMenu({ worktreeModel, closeMenu, guardStaleSelect, + onRemoveRecent, flyoutPath, setFlyoutPath, openNewWorktreeWith, @@ -162,6 +166,7 @@ export function RecentProjectsMenu({ void createAndOpenBranch(branch); }} guardStaleSelect={guardStaleSelect} + onRemoveRecent={onRemoveRecent} /> ); } @@ -193,6 +198,7 @@ export function RecentProjectsMenu({ }} onPickFlyoutEntry={onPickFlyoutEntry} guardStaleSelect={guardStaleSelect} + onRemoveRecent={onRemoveRecent} openNewWorktreeWith={openNewWorktreeWith} /> ))} @@ -209,6 +215,7 @@ function GroupRow({ onPickProject, onPickFlyoutEntry, guardStaleSelect, + onRemoveRecent, openNewWorktreeWith, }: { group: RecentRepoGroup; @@ -219,6 +226,7 @@ function GroupRow({ onPickProject: () => void; onPickFlyoutEntry: (entry: WorktreeFlyoutEntry) => void; guardStaleSelect: (event: Event) => boolean; + onRemoveRecent: (path: string) => void; openNewWorktreeWith: (name: string) => void; }) { const projectIsCurrent = group.project.path === currentPath; @@ -236,21 +244,35 @@ function GroupRow({ if (openedWorktreeCount === 0) { return ( - { - if (guardStaleSelect(e)) return; - onPickProject(); - }} - className="flex w-full min-w-0 flex-col items-start gap-0.5" - data-testid={`project-switcher-recent-${group.project.path}`} - data-current={projectIsCurrent ? 'true' : undefined} + - - +
    + { + if (guardStaleSelect(e)) return; + onPickProject(); + }} + className="flex w-full min-w-0 flex-col items-start gap-0.5 pr-8" + data-testid={`project-switcher-recent-${group.project.path}`} + data-current={projectIsCurrent ? 'true' : undefined} + > + + + +
    + ); } @@ -663,6 +685,7 @@ function SearchResults({ onPickEntry, onPickBranch, guardStaleSelect, + onRemoveRecent, }: { recents: readonly RecentProjectEntry[]; currentPath: string; @@ -671,6 +694,7 @@ function SearchResults({ onPickEntry: (entry: RecentProjectEntry) => void; onPickBranch: (branch: string) => void; guardStaleSelect: (event: Event) => boolean; + onRemoveRecent: (path: string) => void; }) { const { t } = useLingui(); const matches = (text: string): boolean => text.toLowerCase().includes(query); @@ -711,17 +735,31 @@ function SearchResults({ return ( <> {projectMatches.map((r) => ( - { - if (guardStaleSelect(e)) return; - onPickEntry(r); - }} - className="flex w-full min-w-0 flex-col items-start gap-0.5" - data-testid={`project-switcher-recent-${r.path}`} + path={r.path} + onRemoveRecent={onRemoveRecent} + testIdPrefix="project-switcher-recent" > - - +
    + { + if (guardStaleSelect(e)) return; + onPickEntry(r); + }} + className="flex w-full min-w-0 flex-col items-start gap-0.5 pr-8" + data-testid={`project-switcher-recent-${r.path}`} + > + + + +
    + ))} {openedWorktreeMatches.map((r) => ( { + test('clicking the × removes exactly that path', () => { + const onRemoveRecent = mock((_path: string) => {}); + render( + , + ); + const button = screen.getByTestId('project-switcher-recent-remove-/projects/one'); + // Mouse-only affordance: kept out of the menu's tab order (keyboard users + // remove via the context menu). See recent-remove-controls.tsx. + expect(button.getAttribute('tabindex')).toBe('-1'); + expect(button.getAttribute('aria-label')).toBe('Remove One from recent projects'); + fireEvent.click(button); + expect(onRemoveRecent).toHaveBeenCalledWith('/projects/one'); + }); +}); + +describe('RecentItemContextMenu', () => { + test('right-click surfaces a Remove item that removes that path', () => { + const onRemoveRecent = mock((_path: string) => {}); + render( + +
    Two
    +
    , + ); + // No context content until the row is right-clicked. + expect(screen.queryByTestId('command-palette-recent-context-remove-/projects/two')).toBeNull(); + + fireEvent.contextMenu(screen.getByTestId('recent-row')); + const item = screen.getByTestId('command-palette-recent-context-remove-/projects/two'); + fireEvent.click(item); + expect(onRemoveRecent).toHaveBeenCalledWith('/projects/two'); + }); +}); diff --git a/packages/app/src/components/recent-remove-controls.tsx b/packages/app/src/components/recent-remove-controls.tsx new file mode 100644 index 00000000..91c0386c --- /dev/null +++ b/packages/app/src/components/recent-remove-controls.tsx @@ -0,0 +1,97 @@ +/** + * Shared per-recent removal affordances used by the project switcher and the + * command palette — the VS Code "Open Recent" remove controls: + * - `RecentRemoveButton`: a trailing "×" revealed on row hover. Rendered as a + * SIBLING of the row's menu/option item (never nested inside it) so its click + * can't fall through to the item's open action. `tabIndex={-1}` keeps it out + * of the menu's Tab order (Radix/cmdk trap Tab), and `aria-hidden` keeps this + * mouse-only control out of the accessibility tree so it isn't a non-menuitem + * control owned by the enclosing `role="menu"`/`role="listbox"` (an axe + * `aria-required-children` violation). `aria-hidden` + `tabIndex={-1}` is the + * documented-safe pairing — no `aria-hidden-focus` regression. Keyboard / AT + * users remove via the context menu. + * - `RecentItemContextMenu`: wraps a row so right-click (or the context-menu + * key on the focused row) offers "Remove from recent projects" — the + * Welcome-page affordance and the keyboard-reachable path the mouse-only × + * can't provide. + * + * Removal itself (the `bridge.project.removeRecent` call + optimistic list + * update) lives in each surface's owner; these controls only wire the gesture + * to the passed `onRemoveRecent`. `testIdPrefix` namespaces the emitted + * `data-testid`s per surface (`project-switcher-recent` / `command-palette-recent`). + */ + +import { Trans, useLingui } from '@lingui/react/macro'; +import { X } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { Button } from '@/components/ui/button'; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from '@/components/ui/context-menu'; + +export function RecentRemoveButton({ + path, + name, + onRemoveRecent, + testIdPrefix, +}: { + path: string; + name: string; + onRemoveRecent: (path: string) => void; + testIdPrefix: string; +}) { + const { t } = useLingui(); + return ( + + ); +} + +export function RecentItemContextMenu({ + path, + onRemoveRecent, + testIdPrefix, + children, +}: { + path: string; + onRemoveRecent: (path: string) => void; + testIdPrefix: string; + children: ReactNode; +}) { + return ( + + {children} + + onRemoveRecent(path)} + data-testid={`${testIdPrefix}-context-remove-${path}`} + > + + + + ); +} diff --git a/packages/app/src/lib/desktop-bridge-types.ts b/packages/app/src/lib/desktop-bridge-types.ts index a57c1f70..55e28bae 100644 --- a/packages/app/src/lib/desktop-bridge-types.ts +++ b/packages/app/src/lib/desktop-bridge-types.ts @@ -958,6 +958,10 @@ export interface OkDesktopBridge { onServerVersionDrift(cb: (info: OkServerVersionDriftInfo) => void): OkUnsubscribe; /** Subscribe to `ok:server-restarted`. Canonical JSDoc in `bridge-contract.ts`. */ onServerRestarted(cb: (info: { readonly appRuntime: string }) => void): OkUnsubscribe; + /** Subscribe to `ok:project:recent-removed-missing`. Canonical JSDoc in `bridge-contract.ts`. */ + onRecentRemovedMissing( + cb: (info: { readonly path: string; readonly projectName: string }) => void, + ): OkUnsubscribe; /** Restart the project's server to match this app's version. Canonical JSDoc in `bridge-contract.ts`. */ restartServer(projectPath: string): Promise; /** diff --git a/packages/app/src/lib/install-recent-removed-listener.test.ts b/packages/app/src/lib/install-recent-removed-listener.test.ts new file mode 100644 index 00000000..9beb5893 --- /dev/null +++ b/packages/app/src/lib/install-recent-removed-listener.test.ts @@ -0,0 +1,69 @@ +import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'; +import * as actualSonner from 'sonner'; +import type { OkDesktopBridge } from '@/lib/desktop-bridge-types'; + +// Capture the plain `toast(...)` call so we can assert the wiring without a DOM. +const toastPlain = mock((_msg: string) => 'toast-id'); +mock.module('sonner', () => ({ + ...actualSonner, + toast: Object.assign(toastPlain, { + warning: mock(() => 'w'), + success: mock(() => 's'), + error: mock(() => 'e'), + dismiss: mock(() => {}), + custom: mock(() => 'c'), + loading: mock(() => 'l'), + }), +})); + +// Bind the SUT after the sonner mock is registered so its `toast` import is the +// captured stub (the mock facade only rewrites imports resolved after doMock). +type Mod = typeof import('@/lib/install-recent-removed-listener'); +let installRecentRemovedListener: Mod['installRecentRemovedListener']; +let recentRemovedMissingMessage: Mod['recentRemovedMissingMessage']; +beforeAll(async () => { + ({ installRecentRemovedListener, recentRemovedMissingMessage } = await import( + '@/lib/install-recent-removed-listener' + )); +}); + +beforeEach(() => { + toastPlain.mockClear(); +}); + +describe('recentRemovedMissingMessage', () => { + test('names the project that was pruned', () => { + expect(recentRemovedMissingMessage('Fishing Notes')).toContain('Fishing Notes'); + }); +}); + +describe('installRecentRemovedListener', () => { + test('no-ops (no throw, no toast) without a desktop bridge', () => { + expect(installRecentRemovedListener({ bridge: undefined })).toBeUndefined(); + expect(toastPlain).not.toHaveBeenCalled(); + }); + + test('subscribes once and toasts the project name when the event fires', () => { + let captured: ((info: { path: string; projectName: string }) => void) | null = null; + const unsubscribe = mock(() => {}); + const bridge = { + onRecentRemovedMissing: mock((cb: (info: { path: string; projectName: string }) => void) => { + captured = cb; + return unsubscribe; + }), + } as unknown as OkDesktopBridge; + + const dispose = installRecentRemovedListener({ bridge }); + expect(bridge.onRecentRemovedMissing).toHaveBeenCalledTimes(1); + expect(captured).not.toBeNull(); + + captured?.({ path: '/gone', projectName: 'Ghost Project' }); + expect(toastPlain).toHaveBeenCalledTimes(1); + const msg = toastPlain.mock.calls[0]?.[0]; + expect(typeof msg).toBe('string'); + expect(msg).toContain('Ghost Project'); + + dispose?.(); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/app/src/lib/install-recent-removed-listener.ts b/packages/app/src/lib/install-recent-removed-listener.ts new file mode 100644 index 00000000..c3a8a051 --- /dev/null +++ b/packages/app/src/lib/install-recent-removed-listener.ts @@ -0,0 +1,36 @@ +/** + * Install the subscriber for `ok:project:recent-removed-missing`. Main fires + * this at the window that initiated a recents open of a folder that no longer + * exists on disk — VS Code "Open Recent" parity: opening a vanished recent + * prunes it from the (single) recents list rather than spawning a broken + * window. Surface a lightweight toast so the user learns the stale entry was + * cleaned up. The Navigator additionally drops the row from its own list — that + * list-mutation lives in NavigatorApp, which owns the React state; this module + * only owns the toast, so both window kinds get identical feedback. + * + * Registered imperatively during `main.tsx` module init so the listener is in + * place before the event can fire. No-op in web / CLI distribution + * (`window.okDesktop` undefined). Copy is wrapped in the Lingui `t` macro + * inside the callback so it resolves against the active locale at fire time + * (this module has no React context; it relies on the global `i18n` singleton + * activated by `@/lib/i18n`, imported before this listener in `main.tsx`). + */ + +import { t } from '@lingui/core/macro'; +import { toast } from 'sonner'; +import type { OkDesktopBridge } from '@/lib/desktop-bridge-types'; + +/** Toast copy for a lazily-pruned missing recent. Pure — exported for tests. */ +export function recentRemovedMissingMessage(projectName: string): string { + return t`Removed "${projectName}" from recent projects. Its folder no longer exists.`; +} + +export function installRecentRemovedListener(opts: { + bridge: OkDesktopBridge | undefined; +}): (() => void) | undefined { + const bridge = opts.bridge; + if (!bridge) return undefined; + return bridge.onRecentRemovedMissing(({ projectName }) => { + toast(recentRemovedMissingMessage(projectName)); + }); +} diff --git a/packages/app/src/locales/en/messages.json b/packages/app/src/locales/en/messages.json index 71a7c5bf..00990a79 100644 --- a/packages/app/src/locales/en/messages.json +++ b/packages/app/src/locales/en/messages.json @@ -1523,7 +1523,6 @@ "VM7H66": ["This file is binary and can't be shown as text."], "VNgKZz": ["Advanced settings"], "VTulZj": ["Open in worktree"], - "VU3Nrn": ["Missing"], "VUijjF": ["Embed an image with optional alt text."], "VYd_YA": ["Open an existing project folder from disk."], "VZGh8G": ["Fold, unfold, fold all, or unfold all source ranges."], @@ -1651,6 +1650,7 @@ " because it contains a <2>.git folder (one .ok/ per git repo). <3>Content directory defaults to <4>. (the whole repo); type a sub-folder to narrow it." ], "Y2tU6I": ["Delete ", ["0"]], + "Y6PKd1": ["Remove ", ["name"], " from recent projects"], "Y6SK9K": ["Show source"], "Y8ViJx": ["A newer version may have been deployed since this tab opened."], "YBt9YP": ["Beta"], @@ -2341,7 +2341,6 @@ ], "lond3p": ["Shows above the code body. Round-trips as `title=\"...\"` in markdown."], "lrhetp": ["+ ", ["overflow"], " more"], - "ls1N1w": ["(missing)"], "lyWa43": ["Project-level disconnected pages"], "lzmtKp": ["Failed to load folder properties: ", ["message"]], "lzwo7p": ["Could not open GitHub sign-in. Please try again."], @@ -2834,6 +2833,11 @@ "wTcjov": ["No workspace"], "wVa83H": ["Couldn't create the report"], "wXO4Tg": [["hrs"], "h ago"], + "wXw19Y": [ + "Removed \"", + ["projectName"], + "\" from recent projects. Its folder no longer exists." + ], "w_Sphq": ["Attachments"], "waFx9W": ["Managed"], "wbv9xO": ["Create new project"], diff --git a/packages/app/src/locales/en/messages.po b/packages/app/src/locales/en/messages.po index 09d00eb4..c5cb4ceb 100644 --- a/packages/app/src/locales/en/messages.po +++ b/packages/app/src/locales/en/messages.po @@ -82,10 +82,6 @@ msgstr "(detached)" msgid "(inline)" msgstr "(inline)" -#: src/components/CommandPalette.tsx -msgid "(missing)" -msgstr "(missing)" - #: src/editor/components/MirrorSource.tsx msgid "(no id)" msgstr "(no id)" @@ -3728,7 +3724,9 @@ msgstr "Failed to parse backlinks error response (HTTP {status})" msgid "Failed to parse forward-links error response (HTTP {status})" msgstr "Failed to parse forward-links error response (HTTP {status})" +#: src/components/CommandPalette.tsx #: src/components/NavigatorApp.tsx +#: src/components/ProjectSwitcher.tsx msgid "Failed to remove project." msgstr "Failed to remove project." @@ -5137,10 +5135,6 @@ msgstr "Mirror source <0>{label}" msgid "Mirror source <0>api-spec" msgstr "Mirror source <0>api-spec" -#: src/components/NavigatorApp.tsx -msgid "Missing" -msgstr "Missing" - #: src/components/FeedbackForm.tsx msgid "Missing a feature" msgstr "Missing a feature" @@ -6910,6 +6904,10 @@ msgstr "Remove {keyName}" msgid "Remove {label} from context" msgstr "Remove {label} from context" +#: src/components/recent-remove-controls.tsx +msgid "Remove {name} from recent projects" +msgstr "Remove {name} from recent projects" + #: src/components/settings/OkignoreSection.tsx msgid "Remove {patternText}" msgstr "Remove {patternText}" @@ -6927,6 +6925,7 @@ msgid "Remove failed: {error}" msgstr "Remove failed: {error}" #: src/components/NavigatorApp.tsx +#: src/components/recent-remove-controls.tsx msgid "Remove from recent projects" msgstr "Remove from recent projects" @@ -6951,6 +6950,10 @@ msgstr "Remove selection" msgid "Remove the parent <0>.git folder" msgstr "Remove the parent <0>.git folder" +#: src/lib/install-recent-removed-listener.ts +msgid "Removed \"{projectName}\" from recent projects. Its folder no longer exists." +msgstr "Removed \"{projectName}\" from recent projects. Its folder no longer exists." + #. Warning shown when the user unchecks an already-installed skill #: src/components/McpConsentDialogBody.tsx msgid "Removes this skill from your editors." diff --git a/packages/app/src/locales/pseudo/messages.json b/packages/app/src/locales/pseudo/messages.json index 946c9366..13540564 100644 --- a/packages/app/src/locales/pseudo/messages.json +++ b/packages/app/src/locales/pseudo/messages.json @@ -1523,7 +1523,6 @@ "VM7H66": ["Ţĥĩś ƒĩĺē ĩś ƀĩńàŕŷ àńď ćàń'ţ ƀē śĥōŵń àś ţēxţ."], "VNgKZz": ["Àďvàńćēď śēţţĩńĝś"], "VTulZj": ["Ōƥēń ĩń ŵōŕķţŕēē"], - "VU3Nrn": ["Ḿĩśśĩńĝ"], "VUijjF": ["Ēḿƀēď àń ĩḿàĝē ŵĩţĥ ōƥţĩōńàĺ àĺţ ţēxţ."], "VYd_YA": ["Ōƥēń àń ēxĩśţĩńĝ ƥŕōĴēćţ ƒōĺďēŕ ƒŕōḿ ďĩśķ."], "VZGh8G": ["Ƒōĺď, ũńƒōĺď, ƒōĺď àĺĺ, ōŕ ũńƒōĺď àĺĺ śōũŕćē ŕàńĝēś."], @@ -1651,6 +1650,7 @@ " ƀēćàũśē ĩţ ćōńţàĩńś à <2>.ĝĩţ ƒōĺďēŕ (ōńē .ōķ/ ƥēŕ ĝĩţ ŕēƥō). <3>Ćōńţēńţ ďĩŕēćţōŕŷ ďēƒàũĺţś ţō <4>. (ţĥē ŵĥōĺē ŕēƥō); ţŷƥē à śũƀ-ƒōĺďēŕ ţō ńàŕŕōŵ ĩţ." ], "Y2tU6I": ["Ďēĺēţē ", ["0"]], + "Y6PKd1": ["Ŕēḿōvē ", ["name"], " ƒŕōḿ ŕēćēńţ ƥŕōĴēćţś"], "Y6SK9K": ["Śĥōŵ śōũŕćē"], "Y8ViJx": ["À ńēŵēŕ vēŕśĩōń ḿàŷ ĥàvē ƀēēń ďēƥĺōŷēď śĩńćē ţĥĩś ţàƀ ōƥēńēď."], "YBt9YP": ["ßēţà"], @@ -2341,7 +2341,6 @@ ], "lond3p": ["Śĥōŵś àƀōvē ţĥē ćōďē ƀōďŷ. Ŕōũńď-ţŕĩƥś àś `ţĩţĺē=\"...\"` ĩń ḿàŕķďōŵń."], "lrhetp": ["+ ", ["overflow"], " ḿōŕē"], - "ls1N1w": ["(ḿĩśśĩńĝ)"], "lyWa43": ["ƤŕōĴēćţ-ĺēvēĺ ďĩśćōńńēćţēď ƥàĝēś"], "lzmtKp": ["Ƒàĩĺēď ţō ĺōàď ƒōĺďēŕ ƥŕōƥēŕţĩēś: ", ["message"]], "lzwo7p": ["Ćōũĺď ńōţ ōƥēń ĜĩţĤũƀ śĩĝń-ĩń. Ƥĺēàśē ţŕŷ àĝàĩń."], @@ -2834,6 +2833,11 @@ "wTcjov": ["Ńō ŵōŕķśƥàćē"], "wVa83H": ["Ćōũĺďń'ţ ćŕēàţē ţĥē ŕēƥōŕţ"], "wXO4Tg": [["hrs"], "ĥ àĝō"], + "wXw19Y": [ + "Ŕēḿōvēď \"", + ["projectName"], + "\" ƒŕōḿ ŕēćēńţ ƥŕōĴēćţś. Ĩţś ƒōĺďēŕ ńō ĺōńĝēŕ ēxĩśţś." + ], "w_Sphq": ["Àţţàćĥḿēńţś"], "waFx9W": ["Ḿàńàĝēď"], "wbv9xO": ["Ćŕēàţē ńēŵ ƥŕōĴēćţ"], diff --git a/packages/app/src/locales/pseudo/messages.po b/packages/app/src/locales/pseudo/messages.po index 451039bc..719dc285 100644 --- a/packages/app/src/locales/pseudo/messages.po +++ b/packages/app/src/locales/pseudo/messages.po @@ -82,10 +82,6 @@ msgstr "" msgid "(inline)" msgstr "" -#: src/components/CommandPalette.tsx -msgid "(missing)" -msgstr "" - #: src/editor/components/MirrorSource.tsx msgid "(no id)" msgstr "" @@ -3724,7 +3720,9 @@ msgstr "" msgid "Failed to parse forward-links error response (HTTP {status})" msgstr "" +#: src/components/CommandPalette.tsx #: src/components/NavigatorApp.tsx +#: src/components/ProjectSwitcher.tsx msgid "Failed to remove project." msgstr "" @@ -5133,10 +5131,6 @@ msgstr "" msgid "Mirror source <0>api-spec" msgstr "" -#: src/components/NavigatorApp.tsx -msgid "Missing" -msgstr "" - #: src/components/FeedbackForm.tsx msgid "Missing a feature" msgstr "" @@ -6906,6 +6900,10 @@ msgstr "" msgid "Remove {label} from context" msgstr "" +#: src/components/recent-remove-controls.tsx +msgid "Remove {name} from recent projects" +msgstr "" + #: src/components/settings/OkignoreSection.tsx msgid "Remove {patternText}" msgstr "" @@ -6923,6 +6921,7 @@ msgid "Remove failed: {error}" msgstr "" #: src/components/NavigatorApp.tsx +#: src/components/recent-remove-controls.tsx msgid "Remove from recent projects" msgstr "" @@ -6947,6 +6946,10 @@ msgstr "" msgid "Remove the parent <0>.git folder" msgstr "" +#: src/lib/install-recent-removed-listener.ts +msgid "Removed \"{projectName}\" from recent projects. Its folder no longer exists." +msgstr "" + #. Warning shown when the user unchecks an already-installed skill #: src/components/McpConsentDialogBody.tsx msgid "Removes this skill from your editors." diff --git a/packages/app/src/main.tsx b/packages/app/src/main.tsx index 12ce3508..d0165db0 100644 --- a/packages/app/src/main.tsx +++ b/packages/app/src/main.tsx @@ -42,6 +42,7 @@ import { i18n } from '@/lib/i18n'; import { installClientLogForwarder } from '@/lib/install-client-log-forwarder'; import { installDeepLinkListener } from '@/lib/install-deep-link-listener'; import { installOnboardingToastListener } from '@/lib/install-onboarding-toast'; +import { installRecentRemovedListener } from '@/lib/install-recent-removed-listener'; import { installServerDriftListener } from '@/lib/install-server-drift-listener'; import { installMcpConsentListener } from '@/lib/mcp-consent-store'; import { installOnboardingCardStore } from '@/lib/onboarding-card-store'; @@ -145,6 +146,13 @@ if (typeof window !== 'undefined') { installServerDriftListener({ bridge: window.okDesktop }); } +// Desktop-only: `ok:project:recent-removed-missing` — main prunes a recents +// entry whose folder vanished on open and notifies the originating window. +// Module-init so the toast isn't dropped if the event beats React's mount. +if (typeof window !== 'undefined') { + installRecentRemovedListener({ bridge: window.okDesktop }); +} + // Desktop-only: subscribe to the first-launch MCP consent bridge event // and call `mcpWiring.signalReady()` so main's whenRendererReady dispatch // knows this renderer is attached. Same module-init pattern — listeners must diff --git a/packages/app/tests/stress/file-tree-create.e2e.ts b/packages/app/tests/stress/file-tree-create.e2e.ts index 48a76af4..5031140c 100644 --- a/packages/app/tests/stress/file-tree-create.e2e.ts +++ b/packages/app/tests/stress/file-tree-create.e2e.ts @@ -234,6 +234,7 @@ async function installDelayedDesktopSessionBridge( onShareReceived: () => unsubscribe, onServerVersionDrift: () => unsubscribe, onServerRestarted: () => unsubscribe, + onRecentRemovedMissing: () => unsubscribe, restartServer: async () => ({ ok: true }), dialog: { openFolder: async () => null, diff --git a/packages/app/tests/stress/fixtures/handoff-mocks.ts b/packages/app/tests/stress/fixtures/handoff-mocks.ts index 410a7786..44bfb70e 100644 --- a/packages/app/tests/stress/fixtures/handoff-mocks.ts +++ b/packages/app/tests/stress/fixtures/handoff-mocks.ts @@ -376,6 +376,7 @@ export async function installHandoffMocks(page: Page, cfg: HandoffMockConfig): P onShareReceived: () => () => {}, onServerVersionDrift: () => () => {}, onServerRestarted: () => () => {}, + onRecentRemovedMissing: () => () => {}, restartServer: async () => ({ ok: true as const }), // Theme bridge is invoked on first ConfigProvider render via // useThemeBridge — must not throw or the ConfigProvider subtree diff --git a/packages/core/src/desktop-bridge.ts b/packages/core/src/desktop-bridge.ts index ece70464..c32bb2f8 100644 --- a/packages/core/src/desktop-bridge.ts +++ b/packages/core/src/desktop-bridge.ts @@ -919,6 +919,15 @@ export interface OkServerRestartedInfo { readonly appRuntime: string; } +/** + * Payload for `onRecentRemovedMissing` — fired on the window that initiated a + * recents open when the target folder was gone and its stale entry was pruned. + */ +export interface OkRecentRemovedMissingInfo { + readonly path: string; + readonly projectName: string; +} + /** * Result of `restartServer`. Only the failure case reaches the originating * renderer — on success the window is recreated, so its invoke promise never @@ -1086,6 +1095,13 @@ export interface OkDesktopBridge { * server now matches the app. */ onServerRestarted(cb: (info: OkServerRestartedInfo) => void): OkUnsubscribe; + /** + * Subscribe to `ok:project:recent-removed-missing` — fired on the window that + * initiated a recents open of a folder that no longer exists. The stale entry + * has already been pruned from the recents list; the renderer surfaces a + * lightweight toast (and, in the Navigator, drops the row from its list). + */ + onRecentRemovedMissing(cb: (info: OkRecentRemovedMissingInfo) => void): OkUnsubscribe; /** * Restart the project's server to match this app's version: terminate the * attached (not-owned) server and recreate the window against a fresh diff --git a/packages/desktop/src/main/check-target-exists.test.ts b/packages/desktop/src/main/check-target-exists.test.ts index 724e7f96..5a01c991 100644 --- a/packages/desktop/src/main/check-target-exists.test.ts +++ b/packages/desktop/src/main/check-target-exists.test.ts @@ -3,7 +3,11 @@ import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { checkTargetExists, computeShareTargetMissing } from './check-target-exists.ts'; +import { + checkProjectDirExists, + checkTargetExists, + computeShareTargetMissing, +} from './check-target-exists.ts'; describe('checkTargetExists', () => { function makeProject(): string { @@ -385,3 +389,72 @@ describe('computeShareTargetMissing', () => { ).toBe(false); }); }); + +describe('checkProjectDirExists', () => { + function makeTmp(): string { + return mkdtempSync(join(tmpdir(), 'ok-check-project-dir-')); + } + function cleanup(path: string): void { + // Restore permissions first so a chmod'd parent can be removed. + try { + chmodSync(path, 0o755); + } catch { + // best-effort + } + rmSync(path, { recursive: true, force: true }); + } + const isRoot = typeof process.getuid === 'function' && process.getuid() === 0; + + test('returns exists for a present directory', () => { + const dir = makeTmp(); + try { + expect(checkProjectDirExists(dir)).toEqual('exists'); + } finally { + cleanup(dir); + } + }); + + test('returns missing for a genuinely absent path (ENOENT)', () => { + const parent = makeTmp(); + try { + // Never created — the deleted/moved-folder case the lazy prune targets. + expect(checkProjectDirExists(join(parent, 'gone'))).toEqual('missing'); + } finally { + cleanup(parent); + } + }); + + test('returns missing when the path is a file, not a directory', () => { + const parent = makeTmp(); + try { + const filePath = join(parent, 'proj'); + writeFileSync(filePath, 'not a dir\n'); + expect(checkProjectDirExists(filePath)).toEqual('missing'); + } finally { + cleanup(parent); + } + }); + + // An EACCES / unreadable folder (unmounted volume, permission-restricted + // parent) must classify as `'unreadable'`, NOT `'missing'` — otherwise the + // recents lazy-prune would drop a project that is still present. `existsSync` + // (the pre-fix gate) collapses EACCES to `false` and would misclassify it as a + // miss; this is the case the fix guards. + test('returns unreadable when a present folder has an unreadable parent (EACCES)', () => { + if (isRoot) return; // root bypasses the permission check + const parent = makeTmp(); + const child = join(parent, 'proj'); + mkdirSync(child); + try { + chmodSync(parent, 0o000); + expect(checkProjectDirExists(child)).toEqual('unreadable'); + } finally { + chmodSync(parent, 0o755); + cleanup(parent); + } + }); + + test('returns unreadable for an unsafe (non-absolute) project path', () => { + expect(checkProjectDirExists('relative/project')).toEqual('unreadable'); + }); +}); diff --git a/packages/desktop/src/main/check-target-exists.ts b/packages/desktop/src/main/check-target-exists.ts index 4e43e68e..245cfb56 100644 --- a/packages/desktop/src/main/check-target-exists.ts +++ b/packages/desktop/src/main/check-target-exists.ts @@ -176,6 +176,38 @@ export function checkTargetExists( return 'exists'; } +/** + * Classify a bare project directory's on-disk state for the recents + * lazy-remove-on-open gate. Distinguishes a genuine `ENOENT` miss + * (`'missing'` — the folder was deleted/moved, safe to drop the stale recent) + * from an `EACCES` / I-O failure or an unsafe path (`'unreadable'` — a + * temporarily-unmounted volume or a permission-restricted parent, which must + * NOT be pruned). Mirrors {@link checkTargetExists}'s errno split, but probes + * the project root itself rather than a contained sub-target — `existsSync` + * cannot make this distinction because it collapses every error, including + * `EACCES`, to `false`. A path that exists but is not a directory (the folder + * was replaced by a file) is `'missing'`, matching the wrong-kind semantics + * above. + */ +export function checkProjectDirExists(projectPath: string): CheckTargetExistsResult { + if (!isSafeProjectPath(projectPath)) return 'unreadable'; + let stat: ReturnType; + try { + stat = statSync(projectPath); + } catch (err) { + if ( + typeof err === 'object' && + err !== null && + 'code' in err && + (err as { code?: unknown }).code === 'ENOENT' + ) { + return 'missing'; + } + return 'unreadable'; + } + return stat.isDirectory() ? 'exists' : 'missing'; +} + /** * Decide whether a share-receive deep-link target is absent on the receiver's * checked-out working tree, so the caller can flag the window it is about to diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 74545893..34d50b1c 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -168,6 +168,7 @@ import { } from './bundle-replace-detector.ts'; import { cascadePosition } from './cascade-position.ts'; import { + checkProjectDirExists, checkTargetExists as checkTargetExistsImpl, computeShareTargetMissing, } from './check-target-exists.ts'; @@ -2057,6 +2058,50 @@ async function openProject( refreshApplicationMenu(); } +/** + * Strict VS Code "Open Recent" parity: when a recents entry's folder no longer + * exists on disk, drop it from the single canonical recents list (and its + * associated session / window-bounds / last-opened keys, via + * `removeRecentProject`) and refresh the File → Open Recent menu. Returns + * whether an entry was actually removed plus the display name for the notice. + * A no-op (`removed: false`) when the path is present, was never a recent, or is + * only `'unreadable'` (an EACCES / I-O error where the folder may still exist), + * so a plain pick-existing of a vanished folder is left to the normal error path. + */ +function pruneRecentIfMissing(projectPath: string): { removed: boolean; name: string } { + const entry = appState.recentProjects.find((p) => p.path === projectPath); + if (entry === undefined) return { removed: false, name: basename(projectPath) }; + // Strict VS Code parity: prune only on a genuine not-exist miss. `existsSync` + // collapses EACCES / I-O errors to `false`, so gating on it would wrongly drop + // a recent whose folder is still present but momentarily unreadable (a + // permission-restricted parent, a transient I/O error). Classify via the + // errno-aware probe so only a definitive `'missing'` prunes; `'exists'` and + // `'unreadable'` fall through to the normal open path. + const dirState = checkProjectDirExists(projectPath); + if (dirState !== 'missing') { + // A folder that exists but can't be read (EACCES / I-O) never self-cleans on + // open, so leave a breadcrumb — otherwise a "this dead recent won't go away" + // report is indistinguishable from an intentional keep. + if (dirState === 'unreadable') { + console.warn('[main] recents entry left intact: project folder is unreadable', { + projectPath, + }); + } + return { removed: false, name: entry.name }; + } + appState = removeRecentProject(appState, projectPath); + saveAppState(appState); + refreshApplicationMenu(); + // This deletes persisted state (recents row + saved session + window bounds + + // lastOpenedProject); leave a trace so a "my project vanished from recents" + // report has a main-process signal, as the sibling openProject-catch fallback + // does for its failure. + console.warn('[main] pruned stale recents entry: project folder no longer exists', { + projectPath, + }); + return { removed: true, name: entry.name }; +} + async function openProjectOrFallbackToNavigator( projectPath: string, entryPoint: EntryPoint, @@ -2066,6 +2111,23 @@ async function openProjectOrFallbackToNavigator( pendingShareBranchSwitch?: ShareDeepLinkBranchSwitchPayload, pendingTargetMissing?: boolean, ) { + // Prune-and-fall-back for internal callers that have no originating renderer + // to notify (native File → Open Recent, boot restore of a vanished + // lastOpenedProject): drop the stale recent and land on the Navigator rather + // than spawning a server for a gone path. Renderer opens instead prune (and + // toast) up front in the `ok:project:open` handler and return there on a + // genuine miss, so any renderer open that reaches here has a present folder — + // making this an extra, harmless existence probe for that path. Share opens + // carry a deep-link / branch-switch target and render their own honest verdict + // panel, so they skip this prune and proceed to `openProject` below. + if ( + pendingDeepLinkTarget === undefined && + pendingShareBranchSwitch === undefined && + pruneRecentIfMissing(projectPath).removed + ) { + openNavigator(); + return; + } try { await openProject( projectPath, @@ -4556,7 +4618,7 @@ function registerIpcHandlers() { return undefined; }); - handle('ok:project:open', async (_event, request) => { + handle('ok:project:open', async (event, request) => { // Route through the wrapper so boot failures (lock collision, git-init // error, generic crash) surface as the standard Electron error dialog // + Navigator fall-back instead of escaping to the renderer as a raw @@ -4566,6 +4628,25 @@ function registerIpcHandlers() { `ok:project:open rejected: invalid entryPoint '${String(request.entryPoint)}'`, ); } + // Strict VS Code "Open Recent" parity for renderer-initiated opens: a plain + // (non-share) open of a recents entry whose folder is gone prunes the stale + // entry from the single recents list and notifies the originating window + // with a lightweight toast — instead of spawning a broken window or bouncing + // to the Navigator. The user stays where they are. Share opens (which carry + // a deep-link / branch-switch target) are handled by the probe below. + if ( + request.pendingDeepLinkTarget === undefined && + request.pendingShareBranchSwitch === undefined + ) { + const pruned = pruneRecentIfMissing(request.path); + if (pruned.removed) { + sendToRenderer(event.sender, 'ok:project:recent-removed-missing', { + path: request.path, + projectName: pruned.name, + }); + return undefined; + } + } // Renderer-initiated share-receive opens (fresh clone, multi-worktree pivot) // reach window-open here instead of through the URL-scheme dispatcher, which // is where `dispatchResolvedShare` probes the target. Run the same probe so a diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index f2e03c27..504dbdbc 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -46,6 +46,7 @@ import type { OkOnboardingShowPayload, OkPtyData, OkPtyExit, + OkRecentRemovedMissingInfo, OkServerRestartedInfo, OkServerVersionDriftInfo, OkShareReceivedPayload, @@ -373,6 +374,13 @@ const bridge: OkDesktopBridge = { return () => ipcRenderer.removeListener('ok:server-restarted', listener); }, + onRecentRemovedMissing(cb: (info: OkRecentRemovedMissingInfo) => void) { + const listener = (_event: IpcRendererEvent, info: OkRecentRemovedMissingInfo) => cb(info); + // biome-ignore lint/plugin/no-loosely-typed-webcontents-ipc: preload-side subscription wrapper (precedent #14) + ipcRenderer.on('ok:project:recent-removed-missing', listener); + return () => ipcRenderer.removeListener('ok:project:recent-removed-missing', listener); + }, + restartServer: (projectPath: string) => invoke('ok:project:restart-server', projectPath), setThemeSource: (source: OkThemeSource) => invoke('ok:theme:set-source', { source }), diff --git a/packages/desktop/src/shared/bridge-contract.ts b/packages/desktop/src/shared/bridge-contract.ts index 74302e43..ea57bca6 100644 --- a/packages/desktop/src/shared/bridge-contract.ts +++ b/packages/desktop/src/shared/bridge-contract.ts @@ -925,6 +925,15 @@ export interface OkServerRestartedInfo { readonly appRuntime: string; } +/** + * Payload for `onRecentRemovedMissing` — fired on the window that initiated a + * recents open when the target folder was gone and its stale entry was pruned. + */ +export interface OkRecentRemovedMissingInfo { + readonly path: string; + readonly projectName: string; +} + /** * Result of `restartServer`. Only the failure case reaches the originating * renderer — on success the window is recreated, so its invoke promise never @@ -1132,6 +1141,13 @@ export interface OkDesktopBridge { * server now matches the app. */ onServerRestarted(cb: (info: OkServerRestartedInfo) => void): OkUnsubscribe; + /** + * Subscribe to `ok:project:recent-removed-missing` — fired on the window that + * initiated a recents open of a folder that no longer exists. The stale entry + * has already been pruned from the recents list; the renderer surfaces a + * lightweight toast (and, in the Navigator, drops the row from its list). + */ + onRecentRemovedMissing(cb: (info: OkRecentRemovedMissingInfo) => void): OkUnsubscribe; /** * Restart the project's server to match this app's version: terminate the * attached (not-owned) server and recreate the window against a fresh diff --git a/packages/desktop/src/shared/ipc-events.ts b/packages/desktop/src/shared/ipc-events.ts index 8f81b0d2..ea3b1f11 100644 --- a/packages/desktop/src/shared/ipc-events.ts +++ b/packages/desktop/src/shared/ipc-events.ts @@ -22,6 +22,7 @@ import type { OkMenuAction, OkPtyData, OkPtyExit, + OkRecentRemovedMissingInfo, OkServerRestartedInfo, OkServerVersionDriftInfo, OkShareReceivedPayload, @@ -38,6 +39,14 @@ export interface EventChannels { 'ok:project:switching': { payload: { projectPath: string } }; /** After a project switch: renderer re-exposes `window.okDesktop.config` + fires `onProjectSwitched` subscribers. */ 'ok:project:switched': { payload: OkDesktopConfig }; + /** + * A renderer-initiated open of a recents entry whose folder was gone: after + * the stale entry is lazy-pruned from the single canonical recents list, the + * originating window gets a lightweight "removed because missing" toast. + * One-way, fire-and-forget; internal callers with no originating window + * (boot restore, native File → Open Recent) prune silently and never send. + */ + 'ok:project:recent-removed-missing': { payload: OkRecentRemovedMissingInfo }; /** Main → renderer menu-action dispatch (File → New Doc, Edit → Toggle Sidebar, etc.). */ 'ok:menu-action': { payload: OkMenuAction }; /**