From de7be8d8361f60b2b074b5fcf86d0ef20def13b1 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:44:19 -0700 Subject: [PATCH] fix(editor): make Cmd+Shift+O (Open Markdown) work in the main window Co-authored-by: Orca --- src/main/ipc/app.ts | 19 +++-- src/preload/api-types.ts | 4 ++ src/preload/index.ts | 2 + src/renderer/src/components/Terminal.tsx | 43 ++++++++++- .../components/tab-group/TabGroupPanel.tsx | 1 + .../tab-group/useTabGroupWorkspaceModel.ts | 19 ++++- src/renderer/src/store/slices/editor.test.ts | 72 +++++++++++++++++++ src/renderer/src/store/slices/editor.ts | 44 +++++++++++- src/renderer/src/web/web-preload-api.ts | 1 + 9 files changed, 196 insertions(+), 9 deletions(-) diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 7485c03b5f0..a16c66df775 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -36,12 +36,14 @@ type RegisterAppHandlersOptions = { onBeforeRelaunch?: () => void | Promise } -async function pickFloatingMarkdownDocument( - event: IpcMainInvokeEvent +// Why: the dialog runs on the LOCAL machine; callers must only pass a rootPath for LOCAL worktrees (remote/SSH roots would mis-root the result). See issue #8532. +async function pickMarkdownDocument( + event: IpcMainInvokeEvent, + rootPath?: string ): Promise { - const cwd = await ensureDefaultFloatingWorkspacePath() + const root = rootPath?.trim() ? rootPath : await ensureDefaultFloatingWorkspacePath() const options = { - defaultPath: cwd, + defaultPath: root, properties: ['openFile'], filters: [{ name: 'Markdown', extensions: ['md', 'mdx', 'markdown'] }] } satisfies Electron.OpenDialogOptions @@ -57,7 +59,7 @@ async function pickFloatingMarkdownDocument( throw new Error('Selected file is not a markdown document.') } authorizeExternalPath(filePath) - return markdownDocumentFromFilePath(cwd, filePath, { outsideRootRelativePath: 'basename' }) + return markdownDocumentFromFilePath(root, filePath, { outsideRootRelativePath: 'basename' }) } async function pickFloatingWorkspaceDirectory( @@ -309,7 +311,12 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp ipcMain.handle('app:getFloatingMarkdownDirectory', () => ensureDefaultFloatingWorkspacePath()) - ipcMain.handle('app:pickFloatingMarkdownDocument', (event) => pickFloatingMarkdownDocument(event)) + ipcMain.handle('app:pickMarkdownDocument', (event, rootPath?: string) => + pickMarkdownDocument(event, rootPath) + ) + + // Why: kept as an alias so the Floating Terminal picker keeps working unchanged. + ipcMain.handle('app:pickFloatingMarkdownDocument', (event) => pickMarkdownDocument(event)) ipcMain.handle('app:pickFloatingWorkspaceDirectory', (event) => pickFloatingWorkspaceDirectory(event, store) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index f10ba519bd9..d7a149dc38a 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -942,6 +942,10 @@ export type AppApi = { /** Resolves Orca's app-owned directory for auto-created Floating Workspace * markdown notes. */ getFloatingMarkdownDirectory: () => Promise + /** Opens a native picker for markdown documents rooted at `rootPath` (a LOCAL + * worktree root; omit for the floating workspace) and authorizes the selected + * file for editor reads/writes. */ + pickMarkdownDocument: (rootPath?: string) => Promise /** Opens a native picker for markdown documents, rooted in the floating * workspace, and authorizes the selected file for editor reads/writes. */ pickFloatingMarkdownDocument: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 0021f55dfa7..a69de4ed1e2 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -458,6 +458,8 @@ const api = { ipcRenderer.invoke('app:getFloatingTerminalCwd', args), getFloatingMarkdownDirectory: (): Promise => ipcRenderer.invoke('app:getFloatingMarkdownDirectory'), + pickMarkdownDocument: (rootPath?: string): Promise => + ipcRenderer.invoke('app:pickMarkdownDocument', rootPath), pickFloatingMarkdownDocument: (): Promise => ipcRenderer.invoke('app:pickFloatingMarkdownDocument'), pickFloatingWorkspaceDirectory: (): Promise => diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index f093cdcf95b..96c52166125 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -133,7 +133,11 @@ import { useContextualTour } from './contextual-tours/use-contextual-tour' import { openTabBarEntry, type TabCreateEntryArgs } from './tab-bar/tab-create-entry-action' import { closeTerminalTab } from './terminal/terminal-tab-actions' import { translate } from '@/i18n/i18n' -import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { + getExecutionHostIdForWorktree, + getRuntimeEnvironmentIdForWorktree +} from '@/lib/worktree-runtime-owner' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import { getResolvedExecutionHostIdForWorktree } from '@/lib/resolved-worktree-execution-host' import { browserWorkspaceHasRemoteOwner } from '@/runtime/remote-browser-tab-ownership' @@ -305,6 +309,11 @@ function Terminal(): React.JSX.Element | null { (s) => s.openNewBrowserTabInActiveWorkspace ) const openNewMarkdownInActiveWorkspace = useAppStore((s) => s.openNewMarkdownInActiveWorkspace) + const openMarkdownFileInActiveWorkspace = useAppStore((s) => s.openMarkdownFileInActiveWorkspace) + // Why: the native open picker runs locally, so the "Open Markdown" action is only offered for LOCAL worktrees (issue #8532). + const activeWorktreeIsLocal = useAppStore( + (s) => getExecutionHostIdForWorktree(s, s.activeWorktreeId) === LOCAL_EXECUTION_HOST_ID + ) const openNewTerminalTabInActiveWorkspace = useAppStore( (s) => s.openNewTerminalTabInActiveWorkspace ) @@ -1329,6 +1338,20 @@ function Terminal(): React.JSX.Element | null { await openNewMarkdownInActiveWorkspace(targetGroupId) }, [activeWorktreeId, openNewMarkdownInActiveWorkspace]) + const handleOpenFile = useCallback(async () => { + if (!activeWorktreeId) { + return + } + const targetGroupId = + useAppStore.getState().activeGroupIdByWorktree[activeWorktreeId] ?? + useAppStore.getState().groupsByWorktree[activeWorktreeId]?.[0]?.id + if (!targetGroupId) { + return + } + // Why: the store action re-checks locality, so a rebound shortcut on a remote worktree safely no-ops. + await openMarkdownFileInActiveWorkspace(targetGroupId) + }, [activeWorktreeId, openMarkdownFileInActiveWorkspace]) + const handleCloseTab = useCallback((tabId: string) => { closeTerminalTab(tabId) }, []) @@ -1767,6 +1790,21 @@ function Terminal(): React.JSX.Element | null { return } + // Cmd/Ctrl+Shift+O - open an existing markdown file (issue #8532). + // Why: the floating panel's own handler calls stopImmediatePropagation, so this window + // listener can't double-fire when the floating panel owns focus; guard anyway. + // Why: gate on locality so we don't swallow/notify the key on remote worktrees where the + // native picker no-ops — keeps the shortcut consistent with the (also local-only) menu item. + if (!e.repeat && activeWorktreeIsLocal && matchShortcut('tab.openMarkdown')) { + e.preventDefault() + notifyTerminalCapture('tab.openMarkdown') + if (floatingWorkspaceFocused) { + return + } + void handleOpenFile() + return + } + if (handleEmptyFloatingWorkspacePanelCloseShortcut(e, shortcutPlatform, keybindings)) { return } @@ -1875,9 +1913,11 @@ function Terminal(): React.JSX.Element | null { return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) }, [ activeWorktreeId, + activeWorktreeIsLocal, handleNewBrowserTab, handleNewSimulatorTab, handleNewFile, + handleOpenFile, handleNewTab, handleNewAgentTab, handleCloseTab, @@ -2019,6 +2059,7 @@ function Terminal(): React.JSX.Element | null { onNewSimulatorTab={mobileEmulatorEnabled ? handleNewSimulatorTab : undefined} onOpenEntry={handleOpenEntry} onNewFileTab={handleNewFile} + onOpenFileTab={activeWorktreeIsLocal ? handleOpenFile : undefined} onSetCustomTitle={setTabCustomTitle} onSetTabColor={setTabColor} expandedPaneByTabId={expandedPaneByTabId} diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx index a9f7f2ac507..305db17d8df 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -111,6 +111,7 @@ export default function TabGroupPanel({ onNewSimulatorTab={commands.newSimulatorTab} onOpenEntry={commands.openEntry} onNewFileTab={commands.newFileTab} + onOpenFileTab={commands.openFileTab} onSetCustomTitle={commands.setTabCustomTitle} onSetTabColor={commands.setTabColor} onTogglePaneExpand={commands.toggleTerminalPaneExpand} diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts index d775d0347c4..0254f01cf22 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -26,7 +26,11 @@ import { openTabBarEntry, type TabCreateEntryArgs } from '../tab-bar/tab-create- import { openMobileEmulatorTab } from '@/lib/open-mobile-emulator-tab' import { ensureSimulatorTab, getSimulatorTabForWorktree } from '@/lib/ensure-simulator-tab' import { buildDuplicatedBrowserTabOptions } from '@/lib/duplicate-browser-tab-options' -import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { + getExecutionHostIdForWorktree, + getRuntimeEnvironmentIdForWorktree +} from '@/lib/worktree-runtime-owner' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { browserWorkspaceHasRemoteOwner } from '@/runtime/remote-browser-tab-ownership' export function recordTerminalTabGroupSplit(createdTerminal: TerminalTab | null | undefined): void { @@ -87,6 +91,13 @@ export function useTabGroupWorkspaceModel({ const openNewMarkdownInActiveWorkspace = useAppStore( (state) => state.openNewMarkdownInActiveWorkspace ) + const openMarkdownFileInActiveWorkspace = useAppStore( + (state) => state.openMarkdownFileInActiveWorkspace + ) + // Why: the native open picker runs locally, so "Open Markdown" is only offered for LOCAL worktrees (issue #8532). + const worktreeIsLocal = useAppStore( + (state) => getExecutionHostIdForWorktree(state, worktreeId) === LOCAL_EXECUTION_HOST_ID + ) const openNewTerminalTabInActiveWorkspace = useAppStore( (state) => state.openNewTerminalTabInActiveWorkspace ) @@ -609,6 +620,12 @@ export function useTabGroupWorkspaceModel({ newFileTab: async () => { await openNewMarkdownInActiveWorkspace(groupId) }, + // Why: undefined for remote worktrees hides the "Open Markdown" menu item (local-only native picker, issue #8532). + openFileTab: worktreeIsLocal + ? async () => { + await openMarkdownFileInActiveWorkspace(groupId) + } + : undefined, newTerminalTab: () => { void openNewTerminalTabInActiveWorkspace(groupId) }, diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index 42ec67db17f..c585cba7b5b 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -4870,3 +4870,75 @@ describe('read-only editor tabs (AI Vault View Log)', () => { expect(store.getState().editorDrafts[LOG_PATH]).toBeUndefined() }) }) + +describe('openMarkdownFileInActiveWorkspace — main-window open markdown (issue #8532)', () => { + const pickMarkdownDocumentMock = vi.fn() + + function createOpenMarkdownStore(options?: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + repos?: any[] + // eslint-disable-next-line @typescript-eslint/no-explicit-any + worktreesByRepo?: Record + }): StoreApi { + const repos = options?.repos ?? [{ id: 'repo-1', path: '/repo' }] + const worktreesByRepo = options?.worktreesByRepo ?? { + 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo' }] + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return createStore()((...args: any[]) => ({ + activeWorktreeId: 'wt-1', + repos, + worktreesByRepo, + folderWorkspaces: [], + projectGroups: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getKnownWorktreeById: (id: string) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Object.values(worktreesByRepo) + .flat() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .find((wt: any) => wt.id === id) ?? null, + recordFeatureInteraction: vi.fn(), + ...createEditorSlice(...(args as Parameters)) + })) as unknown as StoreApi + } + + beforeEach(() => { + pickMarkdownDocumentMock.mockReset() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(globalThis as any).window = (globalThis as any).window ?? {} + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(globalThis as any).window.api = { app: { pickMarkdownDocument: pickMarkdownDocumentMock } } + }) + + it('opens a picked markdown file into the active LOCAL worktree, rooted at the worktree path', async () => { + const store = createOpenMarkdownStore() + pickMarkdownDocumentMock.mockResolvedValue({ + filePath: '/repo/docs/notes.md', + relativePath: 'docs/notes.md', + basename: 'notes.md', + name: 'notes' + }) + + await store.getState().openMarkdownFileInActiveWorkspace('wt-1:group') + + // Native picker must be rooted at the worktree path, not the floating workspace. + expect(pickMarkdownDocumentMock).toHaveBeenCalledWith('/repo') + const opened = store.getState().openFiles.find((f) => f.filePath === '/repo/docs/notes.md') + expect(opened).toBeDefined() + expect(opened?.worktreeId).toBe('wt-1') + expect(opened?.mode).toBe('edit') + }) + + it('no-ops for a remote/SSH worktree without invoking the local picker', async () => { + const store = createOpenMarkdownStore({ + repos: [{ id: 'repo-1', path: '/repo', connectionId: 'ssh-host-1' }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/remote/repo' }] } + }) + + await store.getState().openMarkdownFileInActiveWorkspace('wt-1:group') + + expect(pickMarkdownDocumentMock).not.toHaveBeenCalled() + expect(store.getState().openFiles).toHaveLength(0) + }) +}) diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index fcfffeb641e..c76475c9ea5 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -64,7 +64,11 @@ import { import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import { notifyHostOfMirroredEditorClose } from '@/runtime/close-mirrored-editor-tab' import { findWorktreeById, getRepoIdFromWorktreeId } from './worktree-helpers' -import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { + getExecutionHostIdForWorktree, + getExplicitRuntimeEnvironmentIdForWorktree +} from '@/lib/worktree-runtime-owner' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import { addAdditionalValidWorkspaceKeys, type WorkspaceSessionHydrationOptions @@ -431,6 +435,9 @@ export type EditorSlice = { } ) => void openNewMarkdownInActiveWorkspace: (groupId: string) => Promise + /** Opens a markdown file via the native picker into the active worktree. + * No-ops for remote/SSH/runtime worktrees (the local dialog can't reach them). */ + openMarkdownFileInActiveWorkspace: (groupId: string) => Promise // Why: sequences openFile/setMarkdownViewMode/reveal around an async Monaco remount. See docs/markdown-internal-link-opening-design.md. activateMarkdownLink: ( rawHref: string | undefined, @@ -1778,6 +1785,41 @@ export const createEditorSlice: StateCreator = (s } }, + openMarkdownFileInActiveWorkspace: async (groupId) => { + const state = get() + const worktreeId = state.activeWorktreeId + if (!worktreeId) { + return + } + // Why: the native open dialog runs on the LOCAL machine, so a remote/SSH/runtime worktree must not receive a local file path (issue #8532). + if (getExecutionHostIdForWorktree(state, worktreeId) !== LOCAL_EXECUTION_HOST_ID) { + return + } + const worktree = state.getKnownWorktreeById(worktreeId) + if (!worktree) { + return + } + try { + const document = await window.api.app.pickMarkdownDocument(worktree.path) + if (!document) { + return + } + get().openFile( + { + filePath: document.filePath, + relativePath: document.relativePath, + worktreeId, + language: detectLanguage(document.relativePath), + mode: 'edit', + runtimeEnvironmentId: null + }, + { preview: false, targetGroupId: groupId } + ) + } catch (err) { + toast.error(extractIpcErrorMessage(err, 'Failed to open markdown file.')) + } + }, + openMarkdownPreview: (file, options) => { const initialState = get() const resolvedRuntimeEnvironmentId = diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index ba97c10f127..fc5c0d74e5c 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -511,6 +511,7 @@ function createWebPreloadApi(): Partial { setUnreadDockBadgeCount: () => Promise.resolve(), getFloatingTerminalCwd: () => Promise.resolve(''), getFloatingMarkdownDirectory: () => Promise.resolve(''), + pickMarkdownDocument: () => Promise.resolve(null), pickFloatingMarkdownDocument: () => Promise.resolve(null), pickFloatingWorkspaceDirectory: () => Promise.resolve(null), // Browser fallback has no app-owned userData dir; reject so the sentinel can't claim sensitive evidence was persisted.