mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
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:
+13
-6
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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> =>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user