From e2a7b5df806a9ae113b35ab8857313729fa4cb02 Mon Sep 17 00:00:00 2001 From: Vitor Ribeiro Date: Mon, 13 Jul 2026 10:01:01 -0300 Subject: [PATCH] fix: open markdown file shortcut does nothing in main window Cmd/Ctrl+Shift+O only had a handler inside the Floating Terminal panel's own keydown listener, so it did nothing when pressed with a normal worktree tab focused. Add a main-window picker (IPC handler, store action, keydown branch) that mirrors the existing tab.newMarkdown wiring. Fixes #8532 Co-Authored-By: Claude Sonnet 5 --- src/main/ipc/app.test.ts | 45 +++++++++ src/main/ipc/app.ts | 24 ++++- src/preload/api-types.ts | 3 + src/preload/index.ts | 2 + src/renderer/src/components/Terminal.tsx | 35 +++++++ src/renderer/src/i18n/locales/en.json | 3 +- .../lib/floating-workspace-tab-creation.ts | 25 +++++ .../floating-workspace-terminal-actions.ts | 3 +- src/renderer/src/store/slices/editor.ts | 32 ++++++ .../store/slices/open-markdown-file.test.ts | 99 +++++++++++++++++++ src/renderer/src/web/web-preload-api.ts | 1 + 11 files changed, 267 insertions(+), 5 deletions(-) create mode 100644 src/renderer/src/store/slices/open-markdown-file.test.ts diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts index 1eb9bbb17a2..6d552ba6bfd 100644 --- a/src/main/ipc/app.test.ts +++ b/src/main/ipc/app.test.ts @@ -356,4 +356,49 @@ describe('registerAppHandlers', () => { }) expect(grantFloatingWorkspaceDirectoryMock).toHaveBeenCalledWith(store, '/Users/kaylee/notes') }) + + it('picks a markdown file rooted at the given worktree cwd', async () => { + showOpenDialogMock.mockResolvedValue({ + canceled: false, + filePaths: ['/Users/kaylee/repo/docs/notes.md'] + }) + registerAppHandlers({} as never) + + await expect( + handlers.get('app:pickWorktreeMarkdownDocument')?.({ sender: {} }, '/Users/kaylee/repo') + ).resolves.toMatchObject({ + filePath: '/Users/kaylee/repo/docs/notes.md', + relativePath: 'docs/notes.md', + basename: 'notes.md', + name: 'notes' + }) + expect(showOpenDialogMock).toHaveBeenCalledWith( + expect.objectContaining({ + defaultPath: '/Users/kaylee/repo', + properties: ['openFile'], + filters: [{ name: 'Markdown', extensions: ['md', 'mdx', 'markdown'] }] + }) + ) + }) + + it('returns null when the markdown file picker is cancelled', async () => { + showOpenDialogMock.mockResolvedValue({ canceled: true, filePaths: [] }) + registerAppHandlers({} as never) + + await expect( + handlers.get('app:pickWorktreeMarkdownDocument')?.({ sender: {} }, '/Users/kaylee/repo') + ).resolves.toBeNull() + }) + + it('rejects a picked file that is not a markdown document', async () => { + showOpenDialogMock.mockResolvedValue({ + canceled: false, + filePaths: ['/Users/kaylee/repo/notes.txt'] + }) + registerAppHandlers({} as never) + + await expect( + handlers.get('app:pickWorktreeMarkdownDocument')?.({ sender: {} }, '/Users/kaylee/repo') + ).rejects.toThrow('Selected file is not a markdown document.') + }) }) diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 2dc3c81f91b..0726fdd75cd 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -37,10 +37,10 @@ type RegisterAppHandlersOptions = { onBeforeRelaunch?: () => void | Promise } -async function pickFloatingMarkdownDocument( - event: IpcMainInvokeEvent +async function pickMarkdownDocumentAt( + event: IpcMainInvokeEvent, + cwd: string ): Promise { - const cwd = await ensureDefaultFloatingWorkspacePath() const options = { defaultPath: cwd, properties: ['openFile'], @@ -61,6 +61,20 @@ async function pickFloatingMarkdownDocument( return markdownDocumentFromFilePath(cwd, filePath, { outsideRootRelativePath: 'basename' }) } +async function pickFloatingMarkdownDocument( + event: IpcMainInvokeEvent +): Promise { + const cwd = await ensureDefaultFloatingWorkspacePath() + return pickMarkdownDocumentAt(event, cwd) +} + +async function pickWorktreeMarkdownDocument( + event: IpcMainInvokeEvent, + cwd: string +): Promise { + return pickMarkdownDocumentAt(event, cwd) +} + async function pickFloatingWorkspaceDirectory( event: IpcMainInvokeEvent, store: Store @@ -342,6 +356,10 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp ipcMain.handle('app:pickFloatingMarkdownDocument', (event) => pickFloatingMarkdownDocument(event)) + ipcMain.handle('app:pickWorktreeMarkdownDocument', (event, cwd: string) => + pickWorktreeMarkdownDocument(event, cwd) + ) + ipcMain.handle('app:pickFloatingWorkspaceDirectory', (event) => pickFloatingWorkspaceDirectory(event, store) ) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index a3db4bbbc55..2c74f2ac829 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -913,6 +913,9 @@ export type AppApi = { /** Opens a native picker for markdown documents, rooted in the floating * workspace, and authorizes the selected file for editor reads/writes. */ pickFloatingMarkdownDocument: () => Promise + /** Opens a native picker for markdown documents, rooted in the given + * worktree path, and authorizes the selected file for editor reads/writes. */ + pickWorktreeMarkdownDocument: (cwd: string) => Promise /** Opens a native directory picker and authorizes the selected directory * for Floating Workspace markdown file creation. */ pickFloatingWorkspaceDirectory: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 4aa685d7b56..7dd6c64759f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -503,6 +503,8 @@ const api = { ipcRenderer.invoke('app:getFloatingMarkdownDirectory'), pickFloatingMarkdownDocument: (): Promise => ipcRenderer.invoke('app:pickFloatingMarkdownDocument'), + pickWorktreeMarkdownDocument: (cwd: string): Promise => + ipcRenderer.invoke('app:pickWorktreeMarkdownDocument', cwd), pickFloatingWorkspaceDirectory: (): Promise => ipcRenderer.invoke('app:pickFloatingWorkspaceDirectory') }, diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index f75bbaf951e..287a151e952 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -119,6 +119,7 @@ import { createFloatingWorkspaceTerminalTab, handleEmptyFloatingWorkspacePanelCloseShortcut, isFloatingWorkspacePanelFocused, + openFloatingWorkspaceMarkdownTab, switchFloatingWorkspaceTab } from '@/lib/floating-workspace-terminal-actions' import { @@ -308,6 +309,7 @@ function Terminal(): React.JSX.Element | null { (s) => s.openNewBrowserTabInActiveWorkspace ) const openNewMarkdownInActiveWorkspace = useAppStore((s) => s.openNewMarkdownInActiveWorkspace) + const openMarkdownFileInActiveWorkspace = useAppStore((s) => s.openMarkdownFileInActiveWorkspace) const openNewTerminalTabInActiveWorkspace = useAppStore( (s) => s.openNewTerminalTabInActiveWorkspace ) @@ -1444,6 +1446,19 @@ 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 + } + await openMarkdownFileInActiveWorkspace(targetGroupId) + }, [activeWorktreeId, openMarkdownFileInActiveWorkspace]) + const handleCloseTab = useCallback((tabId: string) => { closeTerminalTab(tabId) }, []) @@ -1899,6 +1914,24 @@ function Terminal(): React.JSX.Element | null { return } + // Cmd/Ctrl+Shift+O - open existing markdown file + if (!e.repeat && matchShortcut('tab.openMarkdown')) { + e.preventDefault() + notifyTerminalCapture('tab.openMarkdown') + if (floatingWorkspaceFocused) { + void openFloatingWorkspaceMarkdownTab(useAppStore.getState()).catch((err) => { + toast.error( + err instanceof Error + ? err.message + : translate('auto.components.Terminal.3a460c3322', 'Failed to open markdown file.') + ) + }) + return + } + void handleOpenFile() + return + } + if (handleEmptyFloatingWorkspacePanelCloseShortcut(e, shortcutPlatform, keybindings)) { return } @@ -2040,6 +2073,7 @@ function Terminal(): React.JSX.Element | null { handleNewBrowserTab, handleNewSimulatorTab, handleNewFile, + handleOpenFile, handleNewTab, handleNewAgentTab, handleCloseTab, @@ -2199,6 +2233,7 @@ function Terminal(): React.JSX.Element | null { onNewSimulatorTab={mobileEmulatorEnabled ? handleNewSimulatorTab : undefined} onOpenEntry={handleOpenEntry} onNewFileTab={handleNewFile} + onOpenFileTab={handleOpenFile} onSetCustomTitle={setTabCustomTitle} onSetTabColor={setTabColor} expandedPaneByTabId={expandedPaneByTabId} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 45f8b3fb8bc..9452370e636 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1776,7 +1776,8 @@ "61ed600d29": "\"{{value0}}\" has unsaved changes. Do you want to save before closing?", "cdc9ac4b2d": "editor", "e57db40c11": "Could not build launch command for {{value0}}.", - "5b2c1a9e44": "No agent CLI detected — install one or pick a default agent in Settings." + "5b2c1a9e44": "No agent CLI detected — install one or pick a default agent in Settings.", + "3a460c3322": "Failed to open markdown file." }, "TerminalSearch": { "db234b7519": "Close", diff --git a/src/renderer/src/lib/floating-workspace-tab-creation.ts b/src/renderer/src/lib/floating-workspace-tab-creation.ts index d9b6e51b69b..e7143fb7c10 100644 --- a/src/renderer/src/lib/floating-workspace-tab-creation.ts +++ b/src/renderer/src/lib/floating-workspace-tab-creation.ts @@ -82,3 +82,28 @@ export async function createFloatingWorkspaceMarkdownTab( } ) } + +export async function openFloatingWorkspaceMarkdownTab( + store: FloatingWorkspaceMarkdownStore +): Promise { + const targetGroupId = store.activeGroupIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID] + const document = await window.api.app.pickFloatingMarkdownDocument() + if (!document) { + return + } + store.openFile( + { + filePath: document.filePath, + relativePath: document.relativePath, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + language: detectLanguage(document.relativePath), + mode: 'edit', + runtimeEnvironmentId: null + }, + { + preview: false, + targetGroupId, + suppressActiveRuntimeFallback: true + } + ) +} diff --git a/src/renderer/src/lib/floating-workspace-terminal-actions.ts b/src/renderer/src/lib/floating-workspace-terminal-actions.ts index 35c312b0b93..18eb0ece66c 100644 --- a/src/renderer/src/lib/floating-workspace-terminal-actions.ts +++ b/src/renderer/src/lib/floating-workspace-terminal-actions.ts @@ -14,7 +14,8 @@ import { keybindingMatchesAction, type KeybindingOverrides } from '../../../shar export { createFloatingWorkspaceBrowserTab, createFloatingWorkspaceMarkdownTab, - createFloatingWorkspaceTerminalTab + createFloatingWorkspaceTerminalTab, + openFloatingWorkspaceMarkdownTab } from './floating-workspace-tab-creation' export { isFloatingWorkspacePanelShortcut, diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index bd2bc1fddeb..e4814a61a7a 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -493,6 +493,7 @@ export type EditorSlice = { } ) => void openNewMarkdownInActiveWorkspace: (groupId: string) => Promise + openMarkdownFileInActiveWorkspace: (groupId: string) => Promise // Why: dispatcher for markdown link activation. Lives on the slice because it // sequences openFile, setMarkdownViewMode, and setPendingEditorReveal around // an async Monaco remount — all reading/writing state in this slice. See @@ -1890,6 +1891,37 @@ export const createEditorSlice: StateCreator = (s } }, + openMarkdownFileInActiveWorkspace: async (groupId) => { + const state = get() + const worktreeId = state.activeWorktreeId + if (!worktreeId) { + return + } + const worktree = state.getKnownWorktreeById(worktreeId) + if (!worktree) { + return + } + try { + const document = await window.api.app.pickWorktreeMarkdownDocument(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/store/slices/open-markdown-file.test.ts b/src/renderer/src/store/slices/open-markdown-file.test.ts new file mode 100644 index 00000000000..855c85c16ed --- /dev/null +++ b/src/renderer/src/store/slices/open-markdown-file.test.ts @@ -0,0 +1,99 @@ +// @vitest-environment happy-dom + +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { toast } from 'sonner' +import { createTestStore, makeWorktree, TEST_REPO } from './store-test-helpers' + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warning: vi.fn() + } +})) + +const wt1 = makeWorktree({ id: 'wt-1', repoId: TEST_REPO.id, path: '/repo1/wt1' }) + +function seedActiveWorktree(store: ReturnType): void { + store.setState({ + repos: [TEST_REPO], + worktreesByRepo: { [TEST_REPO.id]: [wt1] }, + activeWorktreeId: wt1.id + }) +} + +describe('openMarkdownFileInActiveWorkspace', () => { + beforeEach(() => { + vi.mocked(toast.error).mockClear() + }) + + it('opens the picked file rooted at the active worktree path', async () => { + const pickWorktreeMarkdownDocument = vi.fn().mockResolvedValue({ + filePath: '/repo1/wt1/notes.md', + relativePath: 'notes.md' + }) + globalThis.window.api = { + app: { pickWorktreeMarkdownDocument } + } as unknown as Window['api'] + + const store = createTestStore() + seedActiveWorktree(store) + + await store.getState().openMarkdownFileInActiveWorkspace('group-1') + + expect(pickWorktreeMarkdownDocument).toHaveBeenCalledWith(wt1.path) + expect(store.getState().openFiles).toHaveLength(1) + expect(store.getState().openFiles[0]).toMatchObject({ + filePath: '/repo1/wt1/notes.md', + relativePath: 'notes.md', + worktreeId: wt1.id, + language: 'markdown', + mode: 'edit' + }) + }) + + it('does nothing when the picker is cancelled', async () => { + const pickWorktreeMarkdownDocument = vi.fn().mockResolvedValue(null) + globalThis.window.api = { + app: { pickWorktreeMarkdownDocument } + } as unknown as Window['api'] + + const store = createTestStore() + seedActiveWorktree(store) + + await store.getState().openMarkdownFileInActiveWorkspace('group-1') + + expect(store.getState().openFiles).toHaveLength(0) + }) + + it('does nothing when there is no active worktree', async () => { + const pickWorktreeMarkdownDocument = vi.fn() + globalThis.window.api = { + app: { pickWorktreeMarkdownDocument } + } as unknown as Window['api'] + + const store = createTestStore() + store.setState({ repos: [TEST_REPO], activeWorktreeId: null }) + + await store.getState().openMarkdownFileInActiveWorkspace('group-1') + + expect(pickWorktreeMarkdownDocument).not.toHaveBeenCalled() + expect(store.getState().openFiles).toHaveLength(0) + }) + + it('shows a toast error when the picker rejects', async () => { + const pickWorktreeMarkdownDocument = vi.fn().mockRejectedValue(new Error('boom')) + globalThis.window.api = { + app: { pickWorktreeMarkdownDocument } + } as unknown as Window['api'] + + const store = createTestStore() + seedActiveWorktree(store) + + await store.getState().openMarkdownFileInActiveWorkspace('group-1') + + expect(toast.error).toHaveBeenCalledWith('boom') + expect(store.getState().openFiles).toHaveLength(0) + }) +}) diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index d6f6d6e5830..b188b05f521 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -499,6 +499,7 @@ function createWebPreloadApi(): Partial { getFloatingTerminalCwd: () => Promise.resolve(''), getFloatingMarkdownDirectory: () => Promise.resolve(''), pickFloatingMarkdownDocument: () => Promise.resolve(null), + pickWorktreeMarkdownDocument: () => Promise.resolve(null), pickFloatingWorkspaceDirectory: () => Promise.resolve(null) }, starNag: {