feat(app): open Markdown files from the OS in the floating workspace

Registers Orca as a Markdown handler on macOS, Windows and Linux, and opens
an OS-handed .md/.markdown/.mdx file as a floating-workspace editor tab —
the one editor surface that needs no project. Works cold-start and when
Orca is already running.

Main buffers the paths and both pushes to a live renderer and answers a
pull on renderer mount, mirroring SkillShareDeepLinkState. The buffer is
only released once delivery is possible: the renderer's pull is what proves
its ui:openMarkdownFiles listener is attached, because a push into a window
whose renderer has not subscribed is dropped by Electron with no error. Both
the push and the pull restore an undelivered batch, and a renderer reload
clears the latch so the fresh renderer re-proves itself. Paths are stat'd and
proven to be files before authorizeExternalPath sees them.

Windows association is registered by hand in the NSIS include rather than
through electron-builder's `fileAssociations`: app-builder-lib emits
APP_ASSOCIATE, whose first line overwrites Software\Classes\.md's default
value with no backup — silently taking .md from whichever editor owns it,
for every existing user on their next update — and APP_UNASSOCIATE never
restores it. The hand-rolled registration is additive (ProgID +
OpenWithProgids + SupportedTypes) and leaves the user's default alone;
verified end to end on a real Windows 11 host.

Co-authored-by: Wooseong Kim <innocarpe@users.noreply.github.com>
Co-authored-by: Jaydev <java-jaydev@users.noreply.github.com>

Closes #10138
This commit is contained in:
Neil
2026-09-01 17:11:00 -07:00
co-authored by Wooseong Kim Jaydev
parent c8937936eb
commit 3fbbfc2b26
23 changed files with 1376 additions and 44 deletions
+27 -4
View File
@@ -105,6 +105,11 @@ const winSpeechNativeResource = {
to: 'node_modules/sherpa-onnx-win-x64'
}
// Why mirrored, not imported: this config is CJS loaded by electron-builder outside the TS build.
// Keep in sync with isMarkdownDocumentName() in src/main/ipc/markdown-documents.ts and with
// config/nsis/orca-installer-hooks.nsh, which registers the same set on Windows.
const MARKDOWN_FILE_EXTENSIONS = ['md', 'markdown', 'mdx']
/** @type {import('electron-builder').Configuration} */
module.exports = {
appId,
@@ -376,12 +381,24 @@ module.exports = {
shortcutName: '${productName}',
uninstallDisplayName: '${productName}',
createDesktopShortcut: 'always',
// Why: on a real uninstall, stop and remove the relocated terminal daemon
// (which lives outside the install dir under LOCALAPPDATA by design). Guarded
// by ${isUpdated} inside so it never runs during an update's uninstallOldVersion.
include: resolve(__dirname, 'nsis', 'daemon-host-uninstall.nsh')
// Why: electron-builder allows one include, so both Windows installer hooks live in it -
// the relocated-daemon uninstall sweep (guarded by ${isUpdated} so it never runs during an
// update's uninstallOldVersion) and the additive markdown "Open with" registration.
// Windows markdown association is deliberately NOT done via `fileAssociations`; see the
// header comment in that file for why that would steal the user's default .md handler.
include: resolve(__dirname, 'nsis', 'orca-installer-hooks.nsh')
},
mac: {
// Why rank Alternate: Orca joins Finder's "Open With" list for Markdown without claiming
// LSHandlerRank ownership, so whichever editor the user already prefers stays the default.
// Why one entry per extension: app-builder-lib globs `*.${ext}`, which an array would break.
fileAssociations: MARKDOWN_FILE_EXTENSIONS.map((ext) => ({
ext,
name: 'Markdown Document',
description: 'Markdown Document',
role: 'Editor',
rank: 'Alternate'
})),
icon: 'resources/build/icon.icns',
entitlements: 'resources/build/entitlements.mac.plist',
entitlementsInherit: 'resources/build/entitlements.mac.plist',
@@ -468,6 +485,12 @@ module.exports = {
artifactName: 'orca-macos-${arch}.${ext}'
},
linux: {
// Why mimeTypes and not fileAssociations: shared-mime-info already maps *.md/*.markdown to
// text/markdown, so reusing that type puts Orca in the Open With list without shipping a glob
// override. A desktop entry's MimeType only adds a handler - mimeapps.list still owns the
// default. .mdx is deliberately absent: Ubuntu 24.04's mime database maps it to
// application/x-genesis-32x-rom, so claiming it here would need a glob override.
mimeTypes: ['text/markdown'],
// Why: Ubuntu desktop ships GNOME Orca as the `orca` package and /usr/bin/orca.
// The Linux installer should not claim those system package/file names.
executableName: 'orca-ide',
-23
View File
@@ -1,23 +0,0 @@
; Clean up the relocated terminal daemon on a REAL uninstall.
;
; Why: the daemon host is deliberately copied to a distinct image name
; (orca-terminal-daemon.exe) under %LOCALAPPDATA%\Orca\daemon-host so that app
; UPDATES cannot kill it — that relocation is what keeps terminals alive across
; updates. The same design means a normal uninstall's process sweep and file
; removal both miss it, leaving an orphaned daemon plus its runtime copy behind.
;
; The ${isUpdated} guard is essential: electron-builder runs this uninstaller as
; part of uninstallOldVersion on EVERY update, and killing the daemon there would
; defeat the whole feature. Only clean up on a genuine uninstall.
;
; The image name and the LOCALAPPDATA folder name must stay in sync with
; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in
; src/main/daemon/daemon-host-relocation.ts.
!macro customUnInstall
${ifNot} ${isUpdated}
nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe'
; Give the OS a moment to release the image lock before removing the tree.
Sleep 500
RMDir /r "$LOCALAPPDATA\Orca\daemon-host"
${endIf}
!macroend
+79
View File
@@ -0,0 +1,79 @@
; electron-builder NSIS hooks for the Orca Windows installer.
;
; electron-builder accepts exactly ONE `nsis.include` file, so every customInstall /
; customUnInstall hook Orca needs lives here.
; ---------------------------------------------------------------------------
; Markdown "Open with Orca" (issue #10138)
;
; Why hand-rolled instead of electron-builder's `fileAssociations` on Windows:
; app-builder-lib emits !insertmacro APP_ASSOCIATE, whose first line is
; WriteRegStr SHELL_CONTEXT "Software\Classes\.md" "" "<ProgID>"
; That overwrites whichever editor currently owns .md, with no backup, for every
; existing user on their next UPDATE - and APP_UNASSOCIATE never restores it, so
; uninstalling Orca would leave .md pointing at a deleted ProgID.
;
; These writes are additive only. Registering a ProgID plus an OpenWithProgids
; hint and an Applications\<exe>\SupportedTypes entry puts Orca in Explorer's
; "Open with" list and in "Choose another app", while the default handler stays
; exactly where the user left it. Never add a `Software\Classes\.<ext>` default
; value here.
;
; MARKDOWN_PROGID must stay in sync with the extension list handled by
; isMarkdownDocumentName() in src/main/ipc/markdown-documents.ts.
; ---------------------------------------------------------------------------
!define MARKDOWN_PROGID "Orca.Markdown"
!macro ORCA_REGISTER_MARKDOWN_OPEN_WITH EXT
WriteRegNone SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${MARKDOWN_PROGID}"
WriteRegStr SHELL_CONTEXT "Software\Classes\Applications\${APP_EXECUTABLE_FILENAME}\SupportedTypes" "${EXT}" ""
!macroend
!macro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH EXT
DeleteRegValue SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${MARKDOWN_PROGID}"
DeleteRegValue SHELL_CONTEXT "Software\Classes\Applications\${APP_EXECUTABLE_FILENAME}\SupportedTypes" "${EXT}"
!macroend
!macro customInstall
WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}" "" "Markdown Document"
WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\DefaultIcon" "" "$appExe,0"
WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\shell\open" "" "Open with ${PRODUCT_NAME}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}\shell\open\command" "" '"$appExe" "%1"'
!insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".md"
!insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".markdown"
!insertmacro ORCA_REGISTER_MARKDOWN_OPEN_WITH ".mdx"
; Why: Explorer caches the association list until told otherwise.
System::Call "shell32::SHChangeNotify(i,i,i,i) (0x08000000, 0x1000, 0, 0)"
!macroend
; ---------------------------------------------------------------------------
; Clean up the relocated terminal daemon on a REAL uninstall.
;
; Why: the daemon host is deliberately copied to a distinct image name
; (orca-terminal-daemon.exe) under %LOCALAPPDATA%\Orca\daemon-host so that app
; UPDATES cannot kill it — that relocation is what keeps terminals alive across
; updates. The same design means a normal uninstall's process sweep and file
; removal both miss it, leaving an orphaned daemon plus its runtime copy behind.
;
; The ${isUpdated} guard is essential: electron-builder runs this uninstaller as
; part of uninstallOldVersion on EVERY update, and killing the daemon there would
; defeat the whole feature. Only clean up on a genuine uninstall.
;
; The image name and the LOCALAPPDATA folder name must stay in sync with
; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in
; src/main/daemon/daemon-host-relocation.ts.
!macro customUnInstall
${ifNot} ${isUpdated}
nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe'
; Give the OS a moment to release the image lock before removing the tree.
Sleep 500
RMDir /r "$LOCALAPPDATA\Orca\daemon-host"
${endIf}
; Why outside the ${isUpdated} guard: customInstall rewrites these on every update, so
; dropping them during uninstallOldVersion is correct and keeps the pair symmetric.
DeleteRegKey SHELL_CONTEXT "Software\Classes\${MARKDOWN_PROGID}"
!insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".md"
!insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".markdown"
!insertmacro ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".mdx"
System::Call "shell32::SHChangeNotify(i,i,i,i) (0x08000000, 0x1000, 0, 0)"
!macroend
@@ -0,0 +1,116 @@
import { existsSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { basename } from 'node:path'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const electronBuilderConfig = require('../electron-builder.config.cjs')
const MARKDOWN_EXTENSIONS = ['md', 'markdown', 'mdx']
// The exact shape app-builder-lib's APP_ASSOCIATE emits: a write to the DEFAULT ("")
// value of Software\Classes\.<ext>. Additive `WriteRegNone ...\OpenWithProgids` must not
// match, or the guard below would be unfalsifiable.
const DEFAULT_HANDLER_WRITE = /WriteRegStr\s+SHELL_CONTEXT\s+"Software\\Classes\\\.[a-z]+"\s+""/i
// The hooks file documents the forbidden line in prose, so match executable script only.
const stripNsisCommentLines = (source) =>
source
.split('\n')
.filter((line) => !/^\s*[;#]/.test(line))
.join('\n')
const readInstallerHooks = () => readFile(electronBuilderConfig.nsis.include, 'utf8')
describe('electron-builder markdown file associations', () => {
// Why: any top-level (or `win.`) fileAssociations entry makes app-builder-lib's NSIS
// packager emit `!insertmacro APP_ASSOCIATE`, whose first line writes that DEFAULT value
// — silently taking .md from whichever editor owns it, for every existing user on their
// next UPDATE, with APP_UNASSOCIATE never restoring it. `rank: 'Alternate'` cannot
// prevent this; it is LSHandlerRank and applies to macOS only. So the mac block must
// stay under `mac.` — hoisting it up "to share it with Windows" is what this test blocks.
it('never claims the Windows default markdown handler', () => {
expect(electronBuilderConfig.fileAssociations).toBeUndefined()
expect(electronBuilderConfig.win?.fileAssociations).toBeUndefined()
})
it('joins the macOS Open With list for every markdown extension without owning it', () => {
const associations = electronBuilderConfig.mac.fileAssociations
// One entry per extension: an array `ext` would break the Linux packager's `*.${ext}` glob.
expect([...associations].map((association) => association.ext).sort()).toEqual(
[...MARKDOWN_EXTENSIONS].sort()
)
for (const association of associations) {
expect(association).toMatchObject({ role: 'Editor', rank: 'Alternate' })
}
})
// Why mimeTypes and not linux.fileAssociations: shared-mime-info already maps markdown to
// text/markdown, so the desktop entry only adds a handler and mimeapps.list keeps owning
// the default. A fileAssociations entry would ship a redundant glob override instead.
it('reuses the existing shared-mime-info markdown type on Linux', () => {
expect(electronBuilderConfig.linux.mimeTypes).toContain('text/markdown')
expect(electronBuilderConfig.linux.fileAssociations).toBeUndefined()
})
it('points the single NSIS include at the installer hooks file on disk', () => {
const includePath = electronBuilderConfig.nsis.include
expect(existsSync(includePath)).toBe(true)
expect(basename(includePath)).toBe('orca-installer-hooks.nsh')
})
// Guard for the guard: proves DEFAULT_HANDLER_WRITE really matches a takeover line, so
// the assertion below is a live check rather than a regex that can never fire.
it('recognizes an APP_ASSOCIATE-style default-handler write', () => {
for (const takeover of [
' WriteRegStr SHELL_CONTEXT "Software\\Classes\\.md" "" "Orca.Markdown"',
'WriteRegStr SHELL_CONTEXT "Software\\Classes\\.markdown" "" "$0"'
]) {
expect(takeover).toMatch(DEFAULT_HANDLER_WRITE)
}
expect(
'WriteRegNone SHELL_CONTEXT "Software\\Classes\\.md\\OpenWithProgids" "Orca.Markdown"'
).not.toMatch(DEFAULT_HANDLER_WRITE)
// Comment stripping must drop prose that quotes the bad line without swallowing a real
// one that happens to carry a trailing comment.
const stripped = stripNsisCommentLines(
[
'; WriteRegStr SHELL_CONTEXT "Software\\Classes\\.md" "" "<ProgID>"',
' WriteRegStr SHELL_CONTEXT "Software\\Classes\\.md" "" "$0" ; oops'
].join('\n')
)
expect(stripped.split('\n')).toHaveLength(1)
expect(stripped).toMatch(DEFAULT_HANDLER_WRITE)
})
it('registers Windows markdown Open With additively, never as the default', async () => {
const hooks = await readInstallerHooks()
expect(stripNsisCommentLines(hooks)).not.toMatch(DEFAULT_HANDLER_WRITE)
// The additive hint that puts Orca in Explorer's "Open with" list.
expect(hooks).toMatch(
/WriteRegNone\s+SHELL_CONTEXT\s+"Software\\Classes\\\$\{EXT\}\\OpenWithProgids"/
)
expect(hooks).toMatch(/!macro\s+ORCA_REGISTER_MARKDOWN_OPEN_WITH\s+EXT/)
for (const ext of MARKDOWN_EXTENSIONS) {
expect(hooks).toContain(`ORCA_REGISTER_MARKDOWN_OPEN_WITH ".${ext}"`)
expect(hooks).toContain(`ORCA_UNREGISTER_MARKDOWN_OPEN_WITH ".${ext}"`)
}
expect(hooks).toMatch(/!macro\s+customInstall\b/)
expect(hooks).toMatch(/!macro\s+customUnInstall\b/)
})
// Why: this include was renamed from daemon-host-uninstall.nsh to carry the markdown
// hooks too. electron-builder allows only one include, so a merge that drops the daemon
// sweep would silently orphan a running orca-terminal-daemon.exe on every uninstall.
it('keeps the daemon-host uninstall sweep across the include rename', async () => {
const hooks = await readInstallerHooks()
expect(hooks).toContain('orca-terminal-daemon.exe')
expect(hooks).toContain('$LOCALAPPDATA\\Orca\\daemon-host')
// Without this guard, uninstallOldVersion would kill the daemon on every update —
// defeating the relocation that keeps terminals alive across updates.
expect(hooks).toMatch(/\$\{ifNot\}\s+\$\{isUpdated\}/)
})
})
+1 -1
View File
@@ -34,7 +34,7 @@ export type RelocatedDaemonHost = {
const HOST_SUBDIR = 'daemon-host'
const MARKER_NAME = '.materialized.json'
// LOCAL appData (not roaming) so OneDrive/roaming never syncs this ~260MB runtime. Shared with NSIS uninstall (config/nsis/daemon-host-uninstall.nsh) — keep in sync.
// LOCAL appData (not roaming) so OneDrive/roaming never syncs this ~260MB runtime. Shared with NSIS uninstall (config/nsis/orca-installer-hooks.nsh) — keep in sync.
const LOCAL_HOST_ROOT_NAME = 'Orca'
// Copy of Orca.exe renamed to a distinct image name so the NSIS updater's `taskkill /IM Orca.exe` can't match it.
+50
View File
@@ -12,6 +12,7 @@ import { registerMainProcessIpcHandlers } from './startup/main-process-ipc-boots
import { initializeMainProcessReady } from './startup/main-process-ready'
import { installMainProcessQuitHandlers } from './startup/main-process-quit'
import { shouldActivateDesktopForSecondInstance } from './startup/single-instance-lock'
import { resolveOpenedMarkdownDocuments } from './startup/os-opened-markdown-files'
function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}): BrowserWindow {
return openMainWindowController(options)
@@ -27,6 +28,7 @@ function requestDesktopActivation(argv: readonly string[] = []): void {
state.skillShareDeepLinks.capture(argv, (shareId) => {
state.mainWindow?.webContents.send('ui:openSkillShare', shareId)
})
state.osOpenedMarkdownFiles.capture(argv, publishOsOpenedMarkdownFiles)
// Why: a duplicate `orca serve` must not drag a headless server into opening a desktop window (#11935).
if (!shouldActivateDesktopForSecondInstance(argv)) {
return
@@ -34,6 +36,39 @@ function requestDesktopActivation(argv: readonly string[] = []): void {
state.desktopActivationGate?.requestActivation()
}
/**
* Hands buffered OS-opened markdown paths to a renderer that has proven it is listening.
*
* Until that proof arrives the paths stay buffered, because `webContents.send` to a renderer
* with no listener attached is dropped silently and the queue would be gone.
*/
function publishOsOpenedMarkdownFiles(): void {
const targetWindow = state.mainWindow
if (!state.markdownFileOpenListenerReady || !targetWindow || targetWindow.isDestroyed()) {
return
}
// Why consumed before the await: a renderer pull racing this resolve must not take the same
// batch again. The restore() calls hand it back if delivery turns out to be impossible.
const filePaths = state.osOpenedMarkdownFiles.consume()
if (filePaths.length === 0) {
return
}
void resolveOpenedMarkdownDocuments(filePaths)
.then((documents) => {
if (targetWindow.isDestroyed() || targetWindow.webContents.isDestroyed()) {
state.osOpenedMarkdownFiles.restore(filePaths)
return
}
if (documents.length > 0) {
targetWindow.webContents.send('ui:openMarkdownFiles', documents)
}
})
.catch((error) => {
state.osOpenedMarkdownFiles.restore(filePaths)
console.warn('[os-open] Failed to resolve OS-opened markdown files:', error)
})
}
const handleMacAppActivation = createMacAppActivationHandler({
getWindow: () => state.mainWindow,
requestActivation: requestDesktopActivation
@@ -53,7 +88,22 @@ if (preflightReady) {
event.preventDefault()
requestDesktopActivation([url])
})
// Why: macOS delivers "Open With" as open-file, often before `ready`, and only to a handler
// that claims the event. Non-markdown paths stay unclaimed so the OS default handler wins.
app.on('open-file', (event, filePath) => {
if (!state.osOpenedMarkdownFiles.captureFilePaths([filePath], publishOsOpenedMarkdownFiles)) {
return
}
event.preventDefault()
// Why gated on isReady: pre-ready the cold-start window is already on its way, and
// activating the gate here would try to open one before Electron can.
if (app.isReady()) {
requestDesktopActivation()
}
})
state.skillShareDeepLinks.capture(process.argv)
// Why no publish: nothing is listening this early, so the first renderer pulls these on mount.
state.osOpenedMarkdownFiles.capture(process.argv)
registerMainProcessIpcHandlers()
installMainProcessQuitHandlers()
void app.whenReady().then(async () => {
@@ -2,6 +2,7 @@ import { ipcMain } from 'electron'
import { recoverLegacyWorkerTerminalsForRendererStartup } from './legacy-worker-renderer-recovery'
import { logStartupMilestone } from './startup-diagnostics'
import { mainProcessState as state } from './main-process-state'
import { resolveOpenedMarkdownDocuments } from './os-opened-markdown-files'
export function registerMainProcessIpcHandlers(): void {
ipcMain.handle('app:awaitFirstWindowStartupServices', async () => {
@@ -36,6 +37,20 @@ export function registerMainProcessIpcHandlers(): void {
state.pendingOpenSettings.matches(event.sender.id, { consume: true })
)
ipcMain.handle('ui:consumePendingSkillShare', () => state.skillShareDeepLinks.consume())
// Why: the renderer pulls this once its ui:openMarkdownFiles listener attaches, so a
// cold-start "Open With" queued before mount still opens. The pull doubles as the proof
// that the listener is live, which is what lets main start pushing.
ipcMain.handle('ui:consumePendingMarkdownFileOpens', async () => {
state.markdownFileOpenListenerReady = true
const filePaths = state.osOpenedMarkdownFiles.consume()
try {
return await resolveOpenedMarkdownDocuments(filePaths)
} catch (error) {
// Why restored: the renderer never received these, so a later mount must still get them.
state.osOpenedMarkdownFiles.restore(filePaths)
throw error
}
})
ipcMain.handle(
'app:startupDiagnostic',
(_event, event: string, details?: Record<string, unknown>) => {
+7
View File
@@ -36,6 +36,7 @@ import type { ServeOptions } from './main-process-serve'
import type { HangDetectionMarker } from '../hang-watchdog/hang-detection-marker'
import { ServeReadinessPublisher } from '../server/serve-readiness'
import { SkillShareDeepLinkState } from './skill-share-deep-link-state'
import { OsOpenedMarkdownFileState } from './os-opened-markdown-files'
import {
DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD,
DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS,
@@ -90,6 +91,12 @@ export const mainProcessState = {
// Why: a tray "Settings…" click can precede the renderer's ui:openSettings listener; it pulls this one-shot on mount.
pendingOpenSettings: createWebContentsTimedFlag(),
skillShareDeepLinks: new SkillShareDeepLinkState(),
// Why: a Finder/Explorer "Open With" can land before any window exists; the renderer pulls this buffer on mount.
osOpenedMarkdownFiles: new OsOpenedMarkdownFileState(),
// Why a latch and not just "a window exists": a window can be up while its renderer has not
// attached the ui:openMarkdownFiles listener yet, and a push into that gap is dropped by
// Electron with no error. Only the renderer's own pull proves the listener is live.
markdownFileOpenListenerReady: false,
firstWindowStartupServicesReady: Promise.resolve(),
managedWslCliReconciliationReady: Promise.resolve(),
managedWslCliStartupBarrierReady: Promise.resolve(),
@@ -145,6 +145,9 @@ export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}
clearExpectedRendererReload(rendererWebContentsId)
recordCrashBreadcrumb('main_window_loaded')
logStartupMilestone('did-finish-load')
// Why cleared here: a reload drops the old ui:openMarkdownFiles listener, and the fresh
// renderer re-attaches by pulling. Pushing into the gap between would be silently lost.
state.markdownFileOpenListenerReady = false
const currentStore = state.store
if (currentStore && resolveConsent(currentStore.getSettings()).effective === 'enabled') {
trackAppOpenedOnce()
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from 'vitest'
import { OsOpenedMarkdownFileState } from './os-opened-markdown-files'
/**
* The two ways a queued "Open With" can be lost between main and the renderer. Both are
* about ownership: main must not drop paths it has not proven the renderer received.
*/
describe('os-opened markdown delivery ownership', () => {
it('keeps the batch when resolution rejects on the pull path', async () => {
const state = new OsOpenedMarkdownFileState()
state.captureFilePaths(['/notes/a.md'])
const resolve = vi.fn().mockRejectedValue(new Error('floating root unavailable'))
// Mirrors the ipcMain.handle('ui:consumePendingMarkdownFileOpens') body.
const pull = async (): Promise<unknown> => {
const filePaths = state.consume()
try {
return await resolve(filePaths)
} catch (error) {
state.restore(filePaths)
throw error
}
}
await expect(pull()).rejects.toThrow('floating root unavailable')
// Without the restore the file would be gone and no later mount could ever open it.
expect(state.consume()).toEqual(['/notes/a.md'])
})
it('holds the batch while the renderer listener is not yet attached', () => {
const state = new OsOpenedMarkdownFileState()
const send = vi.fn()
let listenerReady = false
// Mirrors publishOsOpenedMarkdownFiles()'s guard.
const publish = (): void => {
if (!listenerReady) {
return
}
const filePaths = state.consume()
if (filePaths.length > 0) {
send(filePaths)
}
}
// A window exists, but the renderer has not mounted its bridge yet: send() here would be
// dropped by Electron with no error, and consuming would destroy the queue.
state.captureFilePaths(['/notes/a.md'], publish)
expect(send).not.toHaveBeenCalled()
// The renderer's pull is what proves the listener is live.
listenerReady = true
state.captureFilePaths(['/notes/b.md'], publish)
expect(send).toHaveBeenCalledExactlyOnceWith(['/notes/a.md', '/notes/b.md'])
expect(state.consume()).toEqual([])
})
it('restores a batch the window could no longer receive', () => {
const state = new OsOpenedMarkdownFileState()
state.captureFilePaths(['/notes/a.md'])
const filePaths = state.consume()
// Window died between consume and send.
state.restore(filePaths)
expect(state.consume()).toEqual(['/notes/a.md'])
})
})
@@ -0,0 +1,306 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve, sep } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { isMarkdownDocumentName } from '../ipc/markdown-documents'
import {
MAX_PENDING_OS_OPENED_MARKDOWN_FILES,
OsOpenedMarkdownFileState,
markdownPathsFromArguments,
resolveOpenedMarkdownDocuments
} from './os-opened-markdown-files'
vi.mock('../ipc/filesystem-auth', () => ({
authorizeExternalPath: vi.fn()
}))
vi.mock('../ipc/floating-workspace-directory', () => ({
ensureDefaultFloatingWorkspacePath: vi.fn()
}))
const { authorizeExternalPath } = await import('../ipc/filesystem-auth')
const { ensureDefaultFloatingWorkspacePath } = await import('../ipc/floating-workspace-directory')
describe('markdownPathsFromArguments', () => {
it('keeps absolute markdown paths and drops other extensions', () => {
expect(
markdownPathsFromArguments(
[
'/Users/dev/notes/a.md',
'/Users/dev/notes/b.markdown',
'/Users/dev/notes/c.mdx',
'/Users/dev/notes/d.txt',
'/Users/dev/src/e.tsx',
'/Users/dev/notes/README'
],
'darwin'
)
).toEqual(['/Users/dev/notes/a.md', '/Users/dev/notes/b.markdown', '/Users/dev/notes/c.mdx'])
})
it('drops switches, including Chromium-style ones that would otherwise look like values', () => {
expect(
markdownPathsFromArguments(
['--serve', '-v', '--allow-file-access-from-files', '/Users/dev/notes/a.md'],
'darwin'
)
).toEqual(['/Users/dev/notes/a.md'])
})
it('drops the executable and dev entries because none of them end in a markdown extension', () => {
const nonDocumentEntries = [
'/Applications/Orca.app/Contents/MacOS/Orca',
'/Users/dev/orca/out/main/index.js',
'/Applications/Orca.app/Contents/Resources/app.asar'
]
// The module documents that the extension check alone excludes these; hold it to that.
for (const entry of nonDocumentEntries) {
expect(isMarkdownDocumentName(entry), entry).toBe(false)
}
expect(
markdownPathsFromArguments([...nonDocumentEntries, '/Users/dev/notes/a.md'], 'darwin')
).toEqual(['/Users/dev/notes/a.md'])
})
it('drops relative paths because a second instance has no meaningful cwd', () => {
expect(
markdownPathsFromArguments(['readme.md', './docs/a.md', '../up.md', ''], 'darwin')
).toEqual([])
})
it('accepts win32 drive-letter and UNC paths', () => {
expect(
markdownPathsFromArguments(
['C:\\Users\\dev\\todo.md', '\\\\server\\share\\a.md', 'C:\\Users\\dev\\todo.txt'],
'win32'
)
).toEqual(['C:\\Users\\dev\\todo.md', '\\\\server\\share\\a.md'])
})
it('dedupes case-insensitively on win32 and keeps the first spelling', () => {
expect(markdownPathsFromArguments(['C:\\notes\\A.md', 'c:\\notes\\a.md'], 'win32')).toEqual([
'C:\\notes\\A.md'
])
})
it('normalizes parent segments before deduping', () => {
expect(
markdownPathsFromArguments(['C:\\notes\\sub\\..\\a.md', 'C:\\notes\\a.md'], 'win32')
).toEqual(['C:\\notes\\a.md'])
expect(markdownPathsFromArguments(['/docs/../notes/a.md', '/notes/a.md'], 'darwin')).toEqual([
'/notes/a.md'
])
})
it('does not dedupe case-insensitively on posix, where casing is a different file', () => {
expect(markdownPathsFromArguments(['/a/A.md', '/a/a.md'], 'linux')).toEqual([
'/a/A.md',
'/a/a.md'
])
})
it('accepts a file:// URI, which the desktop entry %U field code permits', () => {
// Why defensive rather than load-bearing: GLib decodes a local file:// URI to a plain
// path before spawning (measured on Ubuntu 24.04), so Linux hits the plain-path branch
// today. The %U spec still allows a URI, and a launcher that passes one literally would
// otherwise be dropped without a trace.
expect(
markdownPathsFromArguments(
['file:///home/me/notes/a.md', 'file:///home/me/notes/b.txt'],
'linux'
)
).toEqual(['/home/me/notes/a.md'])
})
it('percent-decodes a file:// URI so a path with spaces still opens', () => {
expect(markdownPathsFromArguments(['file:///home/me/design%20notes.md'], 'linux')).toEqual([
'/home/me/design notes.md'
])
})
it('decodes win32 file:// URIs, including UNC authority form', () => {
expect(
markdownPathsFromArguments(
['file:///C:/Users/me/todo.md', 'file://server/share/a.md'],
'win32'
)
).toEqual(['C:\\Users\\me\\todo.md', '\\\\server\\share\\a.md'])
})
it('dedupes a path delivered as both a URI and a bare path', () => {
expect(markdownPathsFromArguments(['file:///home/me/a.md', '/home/me/a.md'], 'linux')).toEqual([
'/home/me/a.md'
])
})
it('drops a malformed or non-file URL instead of throwing', () => {
expect(() =>
markdownPathsFromArguments(['file://', 'file:///%zz.md', 'https://example.com/a.md'], 'linux')
).not.toThrow()
expect(
markdownPathsFromArguments(['file://', 'file:///%zz.md', 'https://example.com/a.md'], 'linux')
).toEqual([])
})
it('honours the platform argument rather than the host OS', () => {
const argv = ['C:\\notes\\a.md', '/notes/b.md']
// Same argv, two platforms: a win32 path is not absolute to posix, and posix input is
// renormalized to backslashes on win32. Neither result may depend on where the suite runs.
expect(markdownPathsFromArguments(argv, 'darwin')).toEqual(['/notes/b.md'])
expect(markdownPathsFromArguments(argv, 'win32')).toEqual(['C:\\notes\\a.md', '\\notes\\b.md'])
})
})
// Why resolve(): the state uses the host platform by default, so fixture paths must already be
// spelled the way the host's path module normalizes them (`\n\a.md` and a drive on Windows).
const hostPath = (name: string): string => resolve(sep, 'notes', name)
describe('OsOpenedMarkdownFileState', () => {
it('reports no capture and does not publish when argv carries no markdown', () => {
const state = new OsOpenedMarkdownFileState()
const publish = vi.fn()
expect(state.capture(['/Applications/Orca.app/Contents/MacOS/Orca', '--serve'], publish)).toBe(
false
)
expect(publish).not.toHaveBeenCalled()
expect(state.consume()).toEqual([])
})
it('buffers and publishes when argv carries markdown', () => {
const state = new OsOpenedMarkdownFileState()
const publish = vi.fn()
const filePath = hostPath('a.md')
expect(state.capture(['/Applications/Orca.app/Contents/MacOS/Orca', filePath], publish)).toBe(
true
)
expect(publish).toHaveBeenCalledTimes(1)
expect(state.consume()).toEqual([filePath])
})
it('captures a single macOS open-file path', () => {
const state = new OsOpenedMarkdownFileState()
const publish = vi.fn()
const filePath = hostPath('a.md')
expect(state.captureFilePaths([filePath], publish)).toBe(true)
expect(state.captureFilePaths([hostPath('a.png')], publish)).toBe(false)
expect(publish).toHaveBeenCalledTimes(1)
expect(state.consume()).toEqual([filePath])
})
it('does not duplicate a path captured twice', () => {
const state = new OsOpenedMarkdownFileState()
const filePath = hostPath('a.md')
state.captureFilePaths([filePath])
state.captureFilePaths([filePath])
state.capture(['orca', filePath])
expect(state.consume()).toEqual([filePath])
})
it('drains the buffer on consume', () => {
const state = new OsOpenedMarkdownFileState()
const paths = [hostPath('a.md'), hostPath('b.md')]
state.captureFilePaths(paths)
expect(state.consume()).toEqual(paths)
expect(state.consume()).toEqual([])
})
it('restores an undelivered batch at the front of the buffer', () => {
const state = new OsOpenedMarkdownFileState()
state.captureFilePaths([hostPath('later.md')])
state.restore([hostPath('undelivered.md')])
expect(state.consume()).toEqual([hostPath('undelivered.md'), hostPath('later.md')])
})
it('caps the buffer when captures overflow it', () => {
const state = new OsOpenedMarkdownFileState()
const overflow = MAX_PENDING_OS_OPENED_MARKDOWN_FILES + 5
const paths = Array.from({ length: overflow }, (_, index) => hostPath(`file-${index}.md`))
expect(state.captureFilePaths(paths)).toBe(true)
expect(state.consume()).toEqual(paths.slice(0, MAX_PENDING_OS_OPENED_MARKDOWN_FILES))
})
it('caps the buffer when a restore overflows it', () => {
const state = new OsOpenedMarkdownFileState()
state.captureFilePaths([hostPath('pending.md')])
const restored = Array.from({ length: MAX_PENDING_OS_OPENED_MARKDOWN_FILES }, (_, index) =>
hostPath(`restored-${index}.md`)
)
state.restore(restored)
const pending = state.consume()
expect(pending).toHaveLength(MAX_PENDING_OS_OPENED_MARKDOWN_FILES)
expect(pending).toEqual(restored)
})
})
describe('resolveOpenedMarkdownDocuments', () => {
let floatingRoot: string
let fileRoot: string
beforeEach(async () => {
vi.mocked(authorizeExternalPath).mockClear()
vi.mocked(ensureDefaultFloatingWorkspacePath).mockClear()
floatingRoot = await mkdtemp(join(tmpdir(), 'orca-os-open-root-'))
fileRoot = await mkdtemp(join(tmpdir(), 'orca-os-open-files-'))
vi.mocked(ensureDefaultFloatingWorkspacePath).mockResolvedValue(floatingRoot)
})
afterEach(async () => {
await rm(floatingRoot, { recursive: true, force: true })
await rm(fileRoot, { recursive: true, force: true })
})
it('resolves a real file outside the floating root to a basename-relative document', async () => {
const filePath = join(fileRoot, 'design notes.md')
await writeFile(filePath, '# hi\n', 'utf8')
const documents = await resolveOpenedMarkdownDocuments([filePath])
expect(documents).toEqual([
{
filePath,
relativePath: 'design notes.md',
basename: 'design notes.md',
name: 'design notes'
}
])
expect(authorizeExternalPath).toHaveBeenCalledWith(filePath)
})
it('drops a directory that merely looks like a markdown file', async () => {
const bundlePath = join(fileRoot, 'bundle.md')
await mkdir(bundlePath)
const filePath = join(fileRoot, 'real.md')
await writeFile(filePath, '# hi\n', 'utf8')
const documents = await resolveOpenedMarkdownDocuments([bundlePath, filePath])
expect(documents.map((document) => document.filePath)).toEqual([filePath])
// Security contract: a path we never validated must never be authorized for renderer reads.
expect(authorizeExternalPath).toHaveBeenCalledTimes(1)
expect(authorizeExternalPath).toHaveBeenCalledWith(filePath)
})
it('drops a path that no longer exists without authorizing it', async () => {
const missingPath = join(fileRoot, 'gone.md')
expect(await resolveOpenedMarkdownDocuments([missingPath])).toEqual([])
expect(authorizeExternalPath).not.toHaveBeenCalled()
})
it('returns nothing for an empty input without touching the filesystem', async () => {
expect(await resolveOpenedMarkdownDocuments([])).toEqual([])
expect(ensureDefaultFloatingWorkspacePath).not.toHaveBeenCalled()
expect(authorizeExternalPath).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,149 @@
import { stat } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import type { MarkdownDocument } from '../../shared/filesystem-entry-types'
import { authorizeExternalPath } from '../ipc/filesystem-auth'
import { ensureDefaultFloatingWorkspacePath } from '../ipc/floating-workspace-directory'
import { isMarkdownDocumentName, markdownDocumentFromFilePath } from '../ipc/markdown-documents'
// Why: a shell can only ever hand over the files the user selected; anything past this is a
// runaway argv, and buffering it unbounded would pin the paths for the whole session.
export const MAX_PENDING_OS_OPENED_MARKDOWN_FILES = 32
/**
* Resolves one argv entry to a local absolute path, or null if it is not one.
*
* Why file:// is accepted defensively: electron-builder appends the `%U` field code to the
* generated Linux `Exec=` line, and `%U` is specified as "URLs". GLib turns out to decode a
* local `file://` URI back to a plain path before spawning (measured on Ubuntu 24.04, via
* the same `launch_uris` call a file manager makes), so the branch below is not what fires
* there today — but the spec permits a URI, and a launcher that honours it literally would
* otherwise be silently dropped. macOS `open-file` and the Windows shell `%1` pass paths.
*/
function localPathFromArgument(argument: string, platform: NodeJS.Platform): string | null {
const pathApi = platform === 'win32' ? path.win32 : path.posix
if (argument.startsWith('file://')) {
try {
// Why the explicit windows flag: this must decode the same way on any host so the
// behaviour is testable, and it is what turns `file://server/share` back into a UNC path.
return fileURLToPath(argument, { windows: platform === 'win32' })
} catch {
return null
}
}
return pathApi.isAbsolute(argument) ? argument : null
}
/**
* Absolute markdown paths an OS "Open With" put on a launch or second-instance argv.
*
* Why no executable/asar/dev-entry filtering: none of those argv entries end in a markdown
* extension, so the extension check already excludes them. Relative entries are dropped
* because the shell always passes absolute paths and `cwd` is meaningless for a second instance.
*/
export function markdownPathsFromArguments(
argv: readonly string[],
platform: NodeJS.Platform = process.platform
): string[] {
const pathApi = platform === 'win32' ? path.win32 : path.posix
const seen = new Set<string>()
const paths: string[] = []
for (const rawArgument of argv) {
if (!rawArgument || rawArgument.startsWith('-')) {
continue
}
const argument = localPathFromArgument(rawArgument, platform)
if (!argument || !isMarkdownDocumentName(argument)) {
continue
}
const normalized = pathApi.normalize(argument)
// Why lowercased on win32: the shell round-trips drive letters and 8.3 casing
// inconsistently, and two spellings of one path must not open two tabs.
const key = platform === 'win32' ? normalized.toLowerCase() : normalized
if (seen.has(key)) {
continue
}
seen.add(key)
paths.push(normalized)
}
return paths
}
/**
* Buffers markdown paths the OS handed us until a renderer can receive them.
*
* Mirrors SkillShareDeepLinkState: main pushes when a window is already live, and the
* renderer pulls the same buffer when its listener attaches, so a cold-start "Open With"
* that lands before mount is not dropped.
*/
export class OsOpenedMarkdownFileState {
private pending: string[] = []
/** Returns true when argv carried at least one markdown path. */
capture(argv: readonly string[], publish?: () => void): boolean {
return this.add(markdownPathsFromArguments(argv), publish)
}
/** Returns true when at least one path was a markdown document. */
captureFilePaths(filePaths: readonly string[], publish?: () => void): boolean {
return this.add(markdownPathsFromArguments(filePaths), publish)
}
consume(): string[] {
const pending = this.pending
this.pending = []
return pending
}
/** Puts an undelivered batch back at the front so the next renderer still receives it. */
restore(filePaths: readonly string[]): void {
this.pending = [...filePaths, ...this.pending].slice(0, MAX_PENDING_OS_OPENED_MARKDOWN_FILES)
}
private add(filePaths: readonly string[], publish?: () => void): boolean {
if (filePaths.length === 0) {
return false
}
const merged = [...this.pending]
for (const filePath of filePaths) {
if (!merged.includes(filePath)) {
merged.push(filePath)
}
}
this.pending = merged.slice(0, MAX_PENDING_OS_OPENED_MARKDOWN_FILES)
publish?.()
return true
}
}
/**
* Turns OS-handed paths into the same `MarkdownDocument` shape the floating workspace's own
* file picker produces, authorizing each one for the renderer's later read.
*/
export async function resolveOpenedMarkdownDocuments(
filePaths: readonly string[]
): Promise<MarkdownDocument[]> {
if (filePaths.length === 0) {
return []
}
const floatingRoot = await ensureDefaultFloatingWorkspacePath()
const documents: MarkdownDocument[] = []
for (const filePath of filePaths) {
try {
// Why: the shell can hand over a bundle directory named `*.md`, or a path already
// deleted by the time we resolve. Authorize only something that is really a file.
if (!(await stat(filePath)).isFile()) {
continue
}
} catch {
continue
}
authorizeExternalPath(filePath)
documents.push(
markdownDocumentFromFilePath(floatingRoot, filePath, {
outsideRootRelativePath: 'basename'
})
)
}
return documents
}
@@ -0,0 +1,57 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
const read = (relativePath: string): string =>
// Why source text: this wiring is module-scope side effects in the entry point, which no
// unit test can import without booting Electron. These guards pin the call shapes instead.
readFileSync(join(process.cwd(), relativePath), 'utf8').replaceAll('"', "'")
describe('os-opened markdown wiring', () => {
const index = read('src/main/index.ts')
const bootstrap = read('src/main/startup/main-process-ipc-bootstrap.ts')
const controller = read('src/main/startup/main-window-controller.ts')
it('captures argv before the serve-duplicate early return', () => {
const captureIndex = index.indexOf(
'state.osOpenedMarkdownFiles.capture(argv, publishOsOpenedMarkdownFiles)'
)
const serveGuardIndex = index.indexOf('if (!shouldActivateDesktopForSecondInstance(argv)) {')
expect(captureIndex).toBeGreaterThanOrEqual(0)
expect(serveGuardIndex).toBeGreaterThanOrEqual(0)
// A duplicate `orca serve` returns early; capturing after that would drop the user's files.
expect(captureIndex).toBeLessThan(serveGuardIndex)
})
it('claims the macOS open-file event so the default handler does not win it', () => {
const handlerIndex = index.indexOf("app.on('open-file'")
expect(handlerIndex).toBeGreaterThanOrEqual(0)
const preventDefaultIndex = index.indexOf('event.preventDefault()', handlerIndex)
const nextRegistrationIndex = index.indexOf('app.on(', handlerIndex + 1)
expect(preventDefaultIndex).toBeGreaterThan(handlerIndex)
if (nextRegistrationIndex !== -1) {
expect(preventDefaultIndex).toBeLessThan(nextRegistrationIndex)
}
})
it('captures the cold-start argv and lets the renderer pull it after mount', () => {
expect(index).toContain('state.osOpenedMarkdownFiles.capture(process.argv)')
expect(bootstrap).toContain("ipcMain.handle('ui:consumePendingMarkdownFileOpens'")
})
// Why: `webContents.send` to a renderer that has not attached the listener is dropped with no
// error, so publishing on "a window exists" alone would consume the queue into a void.
it('only pushes once the renderer has proven its listener is attached', () => {
expect(index).toContain('!state.markdownFileOpenListenerReady')
expect(bootstrap).toContain('state.markdownFileOpenListenerReady = true')
// A reload drops the listener; the fresh renderer re-proves itself by pulling again.
expect(controller).toContain('state.markdownFileOpenListenerReady = false')
})
it('restores an undelivered batch on both the push and the pull path', () => {
expect(index).toContain('state.osOpenedMarkdownFiles.restore(filePaths)')
expect(bootstrap).toContain('state.osOpenedMarkdownFiles.restore(filePaths)')
})
})
@@ -1,3 +1,4 @@
import type { MarkdownDocument } from '../../shared/filesystem-entry-types'
import { ipcRenderer } from 'electron'
import type { PersistedUIState } from '../../shared/persisted-ui-state-types'
import type { KeybindingActionId } from '../../shared/keybindings'
@@ -26,6 +27,14 @@ export const uiStateAndMenuCommandsApi = {
},
consumePendingSkillShare: (): Promise<string | null> =>
ipcRenderer.invoke('ui:consumePendingSkillShare'),
onOpenMarkdownFiles: (callback: (documents: MarkdownDocument[]) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, documents: MarkdownDocument[]): void =>
callback(documents)
ipcRenderer.on('ui:openMarkdownFiles', listener)
return () => ipcRenderer.removeListener('ui:openMarkdownFiles', listener)
},
consumePendingMarkdownFileOpens: (): Promise<MarkdownDocument[]> =>
ipcRenderer.invoke('ui:consumePendingMarkdownFileOpens'),
onOpenSetupGuide: (callback: () => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent) => callback()
ipcRenderer.on('ui:openSetupGuide', listener)
+5
View File
@@ -1,3 +1,4 @@
import type { MarkdownDocument } from '../../shared/filesystem-entry-types'
import type { PersistedUIState } from '../../shared/persisted-ui-state-types'
import type { TuiAgent } from '../../shared/tui-agent'
import type {
@@ -48,6 +49,10 @@ export type UiCommandEventApi = {
consumePendingOpenSettings: () => Promise<boolean>
onOpenSkillShare: (callback: (shareId: string) => void) => () => void
consumePendingSkillShare: () => Promise<string | null>
/** OS "Open With" markdown paths pushed while a renderer is already listening. */
onOpenMarkdownFiles: (callback: (documents: MarkdownDocument[]) => void) => () => void
/** Drains the "Open With" paths queued before this renderer's listener attached. */
consumePendingMarkdownFileOpens: () => Promise<MarkdownDocument[]>
onOpenSetupGuide: (callback: () => void) => () => void
onOpenFeatureTour: (callback: () => void) => () => void
onOpenCrashReport: (callback: () => void) => () => void
@@ -4,7 +4,7 @@ import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-v
import { getConnectionId } from '@/lib/connection-context'
import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown'
import { ensureClientCreationActionAllowed } from '@/lib/client-creation-action-error'
import { detectLanguage } from '@/lib/language-detect'
import { openMarkdownDocumentInFloatingWorkspace } from '@/lib/open-markdown-in-floating-workspace'
import { extractIpcErrorMessage } from '@/lib/ipc-error'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { translate } from '@/i18n/i18n'
@@ -123,21 +123,9 @@ export function useFloatingTerminalCreateActions({
if (!document) {
return
}
openFile(
{
filePath: document.filePath,
relativePath: document.relativePath,
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
language: detectLanguage(document.relativePath),
mode: 'edit',
runtimeEnvironmentId: null
},
{
preview: false,
targetGroupId: activeGroup?.id,
suppressActiveRuntimeFallback: true
}
)
openMarkdownDocumentInFloatingWorkspace(openFile, document, {
targetGroupId: activeGroup?.id
})
} catch (error) {
toast.error(extractIpcErrorMessage(error, 'Failed to open markdown file.'))
}
@@ -12,6 +12,7 @@ import { createDirectSshBridgeRuntime } from './direct-ssh-bridge-runtime'
import { registerDirectSshStateIpcBridge } from './direct-ssh-state-ipc-bridge'
import { registerMobileAndTerminalCloseIpcBridge } from './mobile-terminal-close-ipc-bridge'
import { registerMobileDriverIpcBridge } from './mobile-driver-ipc-bridge'
import { registerOsMarkdownFileOpenBridge } from './os-markdown-file-open-bridge'
import { registerProjectCatalogIpcBridge } from './project-catalog-ipc-bridge'
import { registerRateLimitIpcBridge } from './rate-limit-ipc-bridge'
import { registerRemoteWorkspaceIpcBridge } from './remote-workspace-ipc-bridge'
@@ -77,6 +78,7 @@ export function installAppLifetimeIpcEvents(
)
registerSettingsAndSidebarIpcBridge(unsubs)
registerWorkspaceShortcutIpcBridge(unsubs)
registerOsMarkdownFileOpenBridge(unsubs)
unsubs.push(
window.api.ui.onActivateWorktree(({ repoId, worktreeId, setup, startup, defaultTabs }) => {
void worktreeRuntime
@@ -0,0 +1,282 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal'
import type { EditorFilesSlice } from '@/store/slices/editor/types/editor-files-slice'
import type { MarkdownDocument } from '../../../../shared/filesystem-entry-types'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { registerOsMarkdownFileOpenBridge } from './os-markdown-file-open-bridge'
const mocks = vi.hoisted(() => ({
openFile: vi.fn<EditorFilesSlice['openFile']>(() => 'file-1'),
updateSettings: vi.fn(async () => {}),
isFloatingWorkspacePanelVisible: vi.fn(() => false),
toastError: vi.fn()
}))
let storeState: {
openFile: typeof mocks.openFile
updateSettings: typeof mocks.updateSettings
settings: { floatingTerminalEnabled?: boolean } | undefined
}
vi.mock('../../store', () => ({ useAppStore: { getState: () => storeState } }))
vi.mock('@/lib/floating-workspace-terminal-actions', () => ({
isFloatingWorkspacePanelVisible: mocks.isFloatingWorkspacePanelVisible
}))
vi.mock('sonner', () => ({ toast: { error: mocks.toastError } }))
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
type MarkdownFileOpenListener = (documents: MarkdownDocument[]) => void
let frames: FrameRequestCallback[] = []
let dispatchEvent = vi.fn()
let unhandledRejections: unknown[] = []
const recordUnhandledRejection = (reason: unknown): void => void unhandledRejections.push(reason)
function markdownDocument(overrides: Partial<MarkdownDocument> = {}): MarkdownDocument {
return {
filePath: '/Users/me/notes/README.md',
relativePath: 'README.md',
basename: 'README.md',
name: 'README',
...overrides
}
}
function stubPreload(ui: Record<string, unknown>): void {
dispatchEvent = vi.fn()
vi.stubGlobal('window', { api: { ui }, dispatchEvent })
}
/** Runs the callbacks the bridge deferred to the next frame. */
function runFrames(): void {
const pending = frames
frames = []
for (const frame of pending) {
frame(0)
}
}
/** Drains microtasks and lets Node emit any unhandled rejection the bridge leaked. */
async function settle(): Promise<void> {
await new Promise((resolve) => setImmediate(resolve))
await new Promise((resolve) => setImmediate(resolve))
}
describe('registerOsMarkdownFileOpenBridge', () => {
beforeEach(() => {
vi.clearAllMocks()
frames = []
unhandledRejections = []
storeState = {
openFile: mocks.openFile,
updateSettings: mocks.updateSettings,
settings: { floatingTerminalEnabled: true }
}
mocks.openFile.mockReturnValue('file-1')
mocks.updateSettings.mockResolvedValue(undefined)
mocks.isFloatingWorkspacePanelVisible.mockReturnValue(false)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
frames.push(callback)
)
vi.spyOn(console, 'error').mockImplementation(() => {})
process.on('unhandledRejection', recordUnhandledRejection)
})
afterEach(() => {
process.off('unhandledRejection', recordUnhandledRejection)
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('opens every document main queued before the listener attached', async () => {
stubPreload({
onOpenMarkdownFiles: () => () => {},
consumePendingMarkdownFileOpens: () =>
Promise.resolve([
markdownDocument(),
markdownDocument({ filePath: '/Users/me/notes/plan.md', relativePath: 'plan.md' })
])
})
registerOsMarkdownFileOpenBridge([])
await settle()
expect(mocks.openFile).toHaveBeenCalledTimes(2)
expect(mocks.openFile.mock.calls.map((call) => call[0].filePath)).toEqual([
'/Users/me/notes/README.md',
'/Users/me/notes/plan.md'
])
expect(mocks.openFile.mock.calls[0][0].worktreeId).toBe(FLOATING_TERMINAL_WORKTREE_ID)
})
it('opens documents pushed after startup and hands back the unsubscribe', async () => {
const listeners: MarkdownFileOpenListener[] = []
const unsubscribe = vi.fn()
stubPreload({
onOpenMarkdownFiles: (next: MarkdownFileOpenListener) => {
listeners.push(next)
return unsubscribe
},
consumePendingMarkdownFileOpens: () => Promise.resolve([])
})
const unsubs: (() => void)[] = []
registerOsMarkdownFileOpenBridge(unsubs)
expect(unsubs).toEqual([unsubscribe])
listeners[0]([
markdownDocument({ filePath: '/Users/me/notes/live.md', relativePath: 'live.md' })
])
await settle()
expect(mocks.openFile).toHaveBeenCalledTimes(1)
expect(mocks.openFile.mock.calls[0][0].filePath).toBe('/Users/me/notes/live.md')
unsubs.forEach((teardown) => teardown())
expect(unsubscribe).toHaveBeenCalledOnce()
})
it('enables the floating workspace when the setting is off', async () => {
storeState.settings = { floatingTerminalEnabled: false }
stubPreload({
onOpenMarkdownFiles: () => () => {},
consumePendingMarkdownFileOpens: () => Promise.resolve([markdownDocument()])
})
registerOsMarkdownFileOpenBridge([])
await settle()
expect(mocks.updateSettings).toHaveBeenCalledWith({ floatingTerminalEnabled: true })
})
it('leaves settings alone when the floating workspace is already enabled', async () => {
stubPreload({
onOpenMarkdownFiles: () => () => {},
consumePendingMarkdownFileOpens: () => Promise.resolve([markdownDocument()])
})
registerOsMarkdownFileOpenBridge([])
await settle()
expect(mocks.updateSettings).not.toHaveBeenCalled()
})
it('defers the reveal a frame and toggles only while the panel is hidden', async () => {
stubPreload({
onOpenMarkdownFiles: () => () => {},
consumePendingMarkdownFileOpens: () => Promise.resolve([markdownDocument()])
})
registerOsMarkdownFileOpenBridge([])
await settle()
expect(dispatchEvent).not.toHaveBeenCalled()
runFrames()
expect(dispatchEvent).toHaveBeenCalledTimes(1)
expect(dispatchEvent.mock.calls[0][0].type).toBe(TOGGLE_FLOATING_TERMINAL_EVENT)
})
it('does not toggle when the panel is already visible', async () => {
mocks.isFloatingWorkspacePanelVisible.mockReturnValue(true)
stubPreload({
onOpenMarkdownFiles: () => () => {},
consumePendingMarkdownFileOpens: () => Promise.resolve([markdownDocument()])
})
registerOsMarkdownFileOpenBridge([])
await settle()
runFrames()
expect(dispatchEvent).not.toHaveBeenCalled()
})
it('ignores an empty batch', async () => {
storeState.settings = { floatingTerminalEnabled: false }
stubPreload({
onOpenMarkdownFiles: () => () => {},
consumePendingMarkdownFileOpens: () => Promise.resolve([])
})
registerOsMarkdownFileOpenBridge([])
await settle()
runFrames()
expect(mocks.openFile).not.toHaveBeenCalled()
expect(mocks.updateSettings).not.toHaveBeenCalled()
expect(dispatchEvent).not.toHaveBeenCalled()
})
it('reports a rejected pending drain without leaking an unhandled rejection', async () => {
stubPreload({
onOpenMarkdownFiles: () => () => {},
consumePendingMarkdownFileOpens: () => Promise.reject(new Error('ipc unavailable'))
})
registerOsMarkdownFileOpenBridge([])
await settle()
expect(mocks.toastError).toHaveBeenCalledWith('Failed to open the Markdown file.')
// Why: App.tsx awaits hydration around this registration and treats any throw as
// "session restore failed", so the bridge must swallow its own failures.
expect(unhandledRejections).toEqual([])
})
it('reports a throwing openFile without leaking an unhandled rejection', async () => {
mocks.openFile.mockImplementation(() => {
throw new Error('editor slice exploded')
})
const listeners: MarkdownFileOpenListener[] = []
stubPreload({
onOpenMarkdownFiles: (next: MarkdownFileOpenListener) => {
listeners.push(next)
return () => {}
},
consumePendingMarkdownFileOpens: () => Promise.resolve([])
})
registerOsMarkdownFileOpenBridge([])
expect(() => listeners[0]([markdownDocument()])).not.toThrow()
await settle()
expect(mocks.toastError).toHaveBeenCalledWith('Failed to open the Markdown file.')
expect(unhandledRejections).toEqual([])
expect(dispatchEvent).not.toHaveBeenCalled()
})
it('keeps opening the rest of a batch when one document fails', async () => {
mocks.openFile.mockImplementationOnce(() => {
throw new Error('first document exploded')
})
stubPreload({
onOpenMarkdownFiles: () => () => {},
consumePendingMarkdownFileOpens: () =>
Promise.resolve([
markdownDocument({ filePath: '/Users/me/notes/bad.md', relativePath: 'bad.md' }),
markdownDocument({ filePath: '/Users/me/notes/good.md', relativePath: 'good.md' })
])
})
registerOsMarkdownFileOpenBridge([])
await settle()
runFrames()
// Why: a multi-file selection arrives as one batch; one bad file must not cost the rest.
expect(mocks.openFile).toHaveBeenCalledTimes(2)
expect(mocks.toastError).toHaveBeenCalledTimes(1)
expect(dispatchEvent).toHaveBeenCalledTimes(1)
expect(unhandledRejections).toEqual([])
})
it('tolerates a preload without the markdown open channel', async () => {
stubPreload({})
const unsubs: (() => void)[] = []
expect(() => registerOsMarkdownFileOpenBridge(unsubs)).not.toThrow()
await settle()
expect(unsubs).toEqual([])
expect(mocks.openFile).not.toHaveBeenCalled()
expect(mocks.toastError).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,69 @@
import { toast } from 'sonner'
import type { MarkdownDocument } from '../../../../shared/filesystem-entry-types'
import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal'
import { isFloatingWorkspacePanelVisible } from '@/lib/floating-workspace-terminal-actions'
import { openMarkdownDocumentInFloatingWorkspace } from '@/lib/open-markdown-in-floating-workspace'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '../../store'
/**
* Opens markdown files the OS shell handed to Orca ("Open With" / double-click) in the
* floating workspace, which is the one editor surface that needs no project.
*/
async function openOsRequestedMarkdownFiles(documents: MarkdownDocument[]): Promise<void> {
if (documents.length === 0) {
return
}
const store = useAppStore.getState()
let opened = 0
for (const document of documents) {
// Why isolated: selecting several files hands us one batch, and one unopenable file
// must not cost the user the rest of the selection.
try {
openMarkdownDocumentInFloatingWorkspace(store.openFile, document)
opened += 1
} catch (error) {
reportOsRequestedMarkdownFailure(error)
}
}
if (opened === 0) {
return
}
// Why enabled here: the user asked the OS for this file, and the tabs above are already in a
// surface a disabled floating workspace never renders. Same enable-then-reveal as the
// Settings "Edit keybindings in Orca" action.
if (store.settings?.floatingTerminalEnabled !== true) {
await store.updateSettings({ floatingTerminalEnabled: true })
}
// Why deferred a frame: the panel only honors the toggle once the enabled flag has reached React.
requestAnimationFrame(() => {
if (!isFloatingWorkspacePanelVisible()) {
window.dispatchEvent(new CustomEvent(TOGGLE_FLOATING_TERMINAL_EVENT))
}
})
}
function reportOsRequestedMarkdownFailure(error: unknown): void {
console.error('Failed to open markdown files requested by the OS:', error)
toast.error(
translate(
'auto.hooks.ipc.events.os.markdown.file.open.bridge.1e9a1a63c4',
'Failed to open the Markdown file.'
)
)
}
export function registerOsMarkdownFileOpenBridge(unsubs: (() => void)[]): void {
const unsubscribe = window.api.ui.onOpenMarkdownFiles?.((documents) => {
void openOsRequestedMarkdownFiles(documents).catch(reportOsRequestedMarkdownFailure)
})
if (unsubscribe) {
unsubs.push(unsubscribe)
}
// Why: a cold-start "Open With" resolves before this listener attaches; drain what main queued.
const pending = window.api.ui.consumePendingMarkdownFileOpens?.()
if (pending && typeof pending.then === 'function') {
void pending.then(openOsRequestedMarkdownFiles).catch(reportOsRequestedMarkdownFailure)
}
}
+11
View File
@@ -1112,6 +1112,17 @@
"events": {
"browserStateIpcBridge": {
"docPreviewLinkFailed": "Could not open this link in Orca Browser."
},
"os": {
"markdown": {
"file": {
"open": {
"bridge": {
"1e9a1a63c4": "Failed to open the Markdown file."
}
}
}
}
}
}
}
@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from 'vitest'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
import type { MarkdownDocument } from '../../../shared/filesystem-entry-types'
import type { EditorFilesSlice } from '@/store/slices/editor/types/editor-files-slice'
import { openMarkdownDocumentInFloatingWorkspace } from './open-markdown-in-floating-workspace'
function openFileMock(): ReturnType<typeof vi.fn<EditorFilesSlice['openFile']>> {
return vi.fn<EditorFilesSlice['openFile']>(() => 'file-1')
}
function markdownDocument(overrides: Partial<MarkdownDocument> = {}): MarkdownDocument {
return {
filePath: '/Users/me/notes/README.md',
relativePath: 'README.md',
basename: 'README.md',
name: 'README',
...overrides
}
}
describe('openMarkdownDocumentInFloatingWorkspace', () => {
it('opens the document as a permanent floating-workspace edit tab', () => {
const openFile = openFileMock()
const fileId = openMarkdownDocumentInFloatingWorkspace(openFile, markdownDocument())
expect(fileId).toBe('file-1')
expect(openFile).toHaveBeenCalledTimes(1)
expect(openFile.mock.calls[0][0]).toEqual({
filePath: '/Users/me/notes/README.md',
relativePath: 'README.md',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
language: 'markdown',
mode: 'edit',
runtimeEnvironmentId: null
})
expect(openFile.mock.calls[0][1]).toEqual({
preview: false,
targetGroupId: undefined,
suppressActiveRuntimeFallback: true
})
})
it('pins the open to this machine instead of the active runtime', () => {
const openFile = openFileMock()
openMarkdownDocumentInFloatingWorkspace(openFile, markdownDocument())
// Why: the caller already resolved an absolute local path, so a null runtime plus the
// fallback suppression is what keeps the read off a remote SSH host the user is focused on.
// Dropping either one silently reads the file on the wrong machine.
expect(openFile.mock.calls[0][0].runtimeEnvironmentId).toBeNull()
expect(openFile.mock.calls[0][1]?.suppressActiveRuntimeFallback).toBe(true)
})
it('derives the language from the relative path', () => {
const openFile = openFileMock()
openMarkdownDocumentInFloatingWorkspace(
openFile,
markdownDocument({
filePath: '/Users/me/notes/plan.mdx',
relativePath: 'plan.mdx',
basename: 'plan.mdx',
name: 'plan'
})
)
expect(openFile.mock.calls[0][0].language).toBe('markdown')
})
it('forwards a requested target group', () => {
const openFile = openFileMock()
openMarkdownDocumentInFloatingWorkspace(openFile, markdownDocument(), {
targetGroupId: 'group-2'
})
expect(openFile.mock.calls[0][1]?.targetGroupId).toBe('group-2')
})
})
@@ -0,0 +1,32 @@
import type { MarkdownDocument } from '../../../shared/filesystem-entry-types'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
import type { EditorFilesSlice } from '@/store/slices/editor/types/editor-files-slice'
import { detectLanguage } from './language-detect'
/**
* Opens a markdown file that belongs to no workspace as a floating-workspace editor tab.
*
* Why local-only: every caller resolves an absolute path on this machine (a native picker or
* the OS shell), so routing it through the active runtime would read it on the wrong host.
*/
export function openMarkdownDocumentInFloatingWorkspace(
openFile: EditorFilesSlice['openFile'],
document: MarkdownDocument,
options: { targetGroupId?: string } = {}
): string {
return openFile(
{
filePath: document.filePath,
relativePath: document.relativePath,
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
language: detectLanguage(document.relativePath),
mode: 'edit',
runtimeEnvironmentId: null
},
{
preview: false,
targetGroupId: options.targetGroupId,
suppressActiveRuntimeFallback: true
}
)
}
@@ -157,6 +157,9 @@ export function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
consumePendingOpenSettings: () => Promise.resolve(false),
onOpenSkillShare: () => noopUnsubscribe,
consumePendingSkillShare: () => Promise.resolve(null),
// Why: the web client has no OS shell handing it files, so there is never a queued open.
onOpenMarkdownFiles: () => noopUnsubscribe,
consumePendingMarkdownFileOpens: () => Promise.resolve([]),
onOpenSetupGuide: () => noopUnsubscribe,
onOpenFeatureTour: () => noopUnsubscribe,
onOpenCrashReport: () => noopUnsubscribe,