fix(editor): make Cmd+Shift+O (Open Markdown) work in the main window

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-07-20 19:54:25 -07:00
co-authored by Orca
parent 0e7b0ac220
commit de7be8d836
9 changed files with 196 additions and 9 deletions
+13 -6
View File
@@ -36,12 +36,14 @@ type RegisterAppHandlersOptions = {
onBeforeRelaunch?: () => void | Promise<void>
}
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<MarkdownDocument | null> {
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)
+4
View File
@@ -942,6 +942,10 @@ export type AppApi = {
/** Resolves Orca's app-owned directory for auto-created Floating Workspace
* markdown notes. */
getFloatingMarkdownDirectory: () => Promise<string>
/** 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<MarkdownDocument | null>
/** Opens a native picker for markdown documents, rooted in the floating
* workspace, and authorizes the selected file for editor reads/writes. */
pickFloatingMarkdownDocument: () => Promise<MarkdownDocument | null>
+2
View File
@@ -458,6 +458,8 @@ const api = {
ipcRenderer.invoke('app:getFloatingTerminalCwd', args),
getFloatingMarkdownDirectory: (): Promise<string> =>
ipcRenderer.invoke('app:getFloatingMarkdownDirectory'),
pickMarkdownDocument: (rootPath?: string): Promise<MarkdownDocument | null> =>
ipcRenderer.invoke('app:pickMarkdownDocument', rootPath),
pickFloatingMarkdownDocument: (): Promise<MarkdownDocument | null> =>
ipcRenderer.invoke('app:pickFloatingMarkdownDocument'),
pickFloatingWorkspaceDirectory: (): Promise<string | null> =>
+42 -1
View File
@@ -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}
@@ -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}
@@ -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)
},
@@ -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<string, any[]>
}): StoreApi<AppState> {
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<any>()((...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<typeof createEditorSlice>))
})) as unknown as StoreApi<AppState>
}
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)
})
})
+43 -1
View File
@@ -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<void>
/** 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<void>
// 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<AppState, [], [], EditorSlice> = (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 =
+1
View File
@@ -511,6 +511,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
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.