mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
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:
co-authored by
Wooseong Kim
Jaydev
parent
c8937936eb
commit
3fbbfc2b26
@@ -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',
|
||||
|
||||
@@ -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
|
||||
@@ -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\}/)
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
-16
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user