feat(documents): open CSV and TSV files from the OS

Extend existing OS document associations and delivery to CSV/TSV, preserving restoration and authorization.

Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
Neil
2026-10-03 00:51:13 -07:00
committed by GitHub
co-authored by Neil
parent 5f5c26662f
commit 130b2ef425
27 changed files with 815 additions and 536 deletions
+20 -10
View File
@@ -164,9 +164,10 @@ const rpmElectronRuntimeDependencies = [
]
// 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
// Keep in sync with isOsOpenedDocumentName() in src/main/startup/os-opened-documents.ts and with
// config/nsis/orca-installer-hooks.nsh, which registers the same set on Windows.
const MARKDOWN_FILE_EXTENSIONS = ['md', 'markdown', 'mdx']
const TABULAR_FILE_EXTENSIONS = ['csv', 'tsv']
// Why: the config must load on a host-only install without resolving unused Windows addons.
// This is load-time tolerance only; beforePack enforces that the target's natives are installed.
@@ -509,16 +510,25 @@ module.exports = {
include: resolve(__dirname, 'nsis', 'orca-installer-hooks.nsh')
},
mac: {
// Why rank Alternate: Orca joins Finder's "Open With" list for Markdown without claiming
// Why rank Alternate: Orca joins Finder's "Open With" list 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'
})),
fileAssociations: [
...MARKDOWN_FILE_EXTENSIONS.map((ext) => ({
ext,
name: 'Markdown Document',
description: 'Markdown Document',
role: 'Editor',
rank: 'Alternate'
})),
...TABULAR_FILE_EXTENSIONS.map((ext) => ({
ext,
name: `${ext.toUpperCase()} Document`,
description: `${ext.toUpperCase()} Document`,
role: 'Editor',
rank: 'Alternate'
}))
],
icon: 'resources/build/icon.icns',
entitlements: 'resources/build/entitlements.mac.plist',
entitlementsInherit: 'resources/build/entitlements.mac.plist',
@@ -611,7 +621,7 @@ module.exports = {
// 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'],
mimeTypes: ['text/markdown', 'text/csv', 'text/tab-separated-values'],
// 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',
+27 -17
View File
@@ -6,7 +6,7 @@
!include "${__FILEDIR__}\orca-process-check.nsh"
; ---------------------------------------------------------------------------
; Markdown "Open with Orca" (issue #10138)
; Markdown and CSV/TSV "Open with Orca" (issues #10138, #23225)
;
; Why hand-rolled instead of electron-builder's `fileAssociations` on Windows:
; app-builder-lib emits !insertmacro APP_ASSOCIATE, whose first line is
@@ -21,29 +21,36 @@
; 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.
; Keep the extension list in sync with isOsOpenedDocumentName().
; ---------------------------------------------------------------------------
!define MARKDOWN_PROGID "Orca.Markdown"
!define TABULAR_PROGID "Orca.Tabular"
!macro ORCA_REGISTER_MARKDOWN_OPEN_WITH EXT
WriteRegNone SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${MARKDOWN_PROGID}"
!macro ORCA_REGISTER_DOCUMENT_OPEN_WITH EXT PROGID
WriteRegNone SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${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}"
!macro ORCA_UNREGISTER_DOCUMENT_OPEN_WITH EXT PROGID
DeleteRegValue SHELL_CONTEXT "Software\Classes\${EXT}\OpenWithProgids" "${PROGID}"
DeleteRegValue SHELL_CONTEXT "Software\Classes\Applications\${APP_EXECUTABLE_FILENAME}\SupportedTypes" "${EXT}"
!macroend
!macro ORCA_REGISTER_DOCUMENT_PROGID PROGID NAME
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROGID}" "" "${NAME}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROGID}\DefaultIcon" "" "$appExe,0"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROGID}\shell\open" "" "Open with ${PRODUCT_NAME}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROGID}\shell\open\command" "" '"$appExe" "%1"'
!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"
!insertmacro ORCA_REGISTER_DOCUMENT_PROGID "${MARKDOWN_PROGID}" "Markdown Document"
!insertmacro ORCA_REGISTER_DOCUMENT_PROGID "${TABULAR_PROGID}" "Tabular Document"
!insertmacro ORCA_REGISTER_DOCUMENT_OPEN_WITH ".md" "${MARKDOWN_PROGID}"
!insertmacro ORCA_REGISTER_DOCUMENT_OPEN_WITH ".markdown" "${MARKDOWN_PROGID}"
!insertmacro ORCA_REGISTER_DOCUMENT_OPEN_WITH ".mdx" "${MARKDOWN_PROGID}"
!insertmacro ORCA_REGISTER_DOCUMENT_OPEN_WITH ".csv" "${TABULAR_PROGID}"
!insertmacro ORCA_REGISTER_DOCUMENT_OPEN_WITH ".tsv" "${TABULAR_PROGID}"
; Why: Explorer caches the association list until told otherwise.
System::Call "shell32::SHChangeNotify(i,i,i,i) (0x08000000, 0x1000, 0, 0)"
!macroend
@@ -100,8 +107,11 @@
; 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"
DeleteRegKey SHELL_CONTEXT "Software\Classes\${TABULAR_PROGID}"
!insertmacro ORCA_UNREGISTER_DOCUMENT_OPEN_WITH ".md" "${MARKDOWN_PROGID}"
!insertmacro ORCA_UNREGISTER_DOCUMENT_OPEN_WITH ".markdown" "${MARKDOWN_PROGID}"
!insertmacro ORCA_UNREGISTER_DOCUMENT_OPEN_WITH ".mdx" "${MARKDOWN_PROGID}"
!insertmacro ORCA_UNREGISTER_DOCUMENT_OPEN_WITH ".csv" "${TABULAR_PROGID}"
!insertmacro ORCA_UNREGISTER_DOCUMENT_OPEN_WITH ".tsv" "${TABULAR_PROGID}"
System::Call "shell32::SHChangeNotify(i,i,i,i) (0x08000000, 0x1000, 0, 0)"
!macroend
@@ -8,6 +8,8 @@ const require = createRequire(import.meta.url)
const electronBuilderConfig = require('../electron-builder.config.cjs')
const MARKDOWN_EXTENSIONS = ['md', 'markdown', 'mdx']
const TABULAR_EXTENSIONS = ['csv', 'tsv']
const DOCUMENT_EXTENSIONS = [...MARKDOWN_EXTENSIONS, ...TABULAR_EXTENSIONS]
// 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
@@ -23,23 +25,23 @@ const stripNsisCommentLines = (source) =>
const readInstallerHooks = () => readFile(electronBuilderConfig.nsis.include, 'utf8')
describe('electron-builder markdown file associations', () => {
describe('electron-builder document 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', () => {
it('never claims the Windows default document handler', () => {
expect(electronBuilderConfig.fileAssociations).toBeUndefined()
expect(electronBuilderConfig.win?.fileAssociations).toBeUndefined()
})
it('joins the macOS Open With list for every markdown extension without owning it', () => {
it('joins the macOS Open With list for every supported 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()
[...DOCUMENT_EXTENSIONS].sort()
)
for (const association of associations) {
expect(association).toMatchObject({ role: 'Editor', rank: 'Alternate' })
@@ -54,6 +56,14 @@ describe('electron-builder markdown file associations', () => {
expect(electronBuilderConfig.linux.fileAssociations).toBeUndefined()
})
it('adds CSV and TSV handlers to the Linux desktop entry', () => {
expect(electronBuilderConfig.linux.mimeTypes).toEqual([
'text/markdown',
'text/csv',
'text/tab-separated-values'
])
})
it('points the single NSIS include at the installer hooks file on disk', () => {
const includePath = electronBuilderConfig.nsis.include
expect(existsSync(includePath)).toBe(true)
@@ -84,7 +94,7 @@ describe('electron-builder markdown file associations', () => {
expect(stripped).toMatch(DEFAULT_HANDLER_WRITE)
})
it('registers Windows markdown Open With additively, never as the default', async () => {
it('registers Windows document Open With additively, never as the default', async () => {
const hooks = await readInstallerHooks()
expect(stripNsisCommentLines(hooks)).not.toMatch(DEFAULT_HANDLER_WRITE)
@@ -92,11 +102,18 @@ describe('electron-builder markdown file associations', () => {
expect(hooks).toMatch(
/WriteRegNone\s+SHELL_CONTEXT\s+"Software\\Classes\\\$\{EXT\}\\OpenWithProgids"/
)
expect(hooks).toMatch(/!macro\s+ORCA_REGISTER_MARKDOWN_OPEN_WITH\s+EXT/)
expect(hooks).toMatch(/!macro\s+ORCA_REGISTER_DOCUMENT_OPEN_WITH\s+EXT\s+PROGID/)
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).toContain(`ORCA_REGISTER_DOCUMENT_OPEN_WITH ".${ext}" "\${MARKDOWN_PROGID}"`)
expect(hooks).toContain(`ORCA_UNREGISTER_DOCUMENT_OPEN_WITH ".${ext}" "\${MARKDOWN_PROGID}"`)
}
for (const ext of TABULAR_EXTENSIONS) {
expect(hooks).toContain(`ORCA_REGISTER_DOCUMENT_OPEN_WITH ".${ext}" "\${TABULAR_PROGID}"`)
expect(hooks).toContain(`ORCA_UNREGISTER_DOCUMENT_OPEN_WITH ".${ext}" "\${TABULAR_PROGID}"`)
}
expect(hooks).toContain('!define TABULAR_PROGID "Orca.Tabular"')
expect(hooks).toContain('ORCA_REGISTER_DOCUMENT_PROGID "${TABULAR_PROGID}" "Tabular Document"')
expect(hooks).toContain('DeleteRegKey SHELL_CONTEXT "Software\\Classes\\${TABULAR_PROGID}"')
expect(hooks).toMatch(/!macro\s+customInstall\b/)
expect(hooks).toMatch(/!macro\s+customUnInstall\b/)
})
+13 -13
View File
@@ -13,7 +13,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'
import { resolveOsOpenedDocuments } from './startup/os-opened-documents'
import {
formatProfileStateStartupFailure,
isDivergedProfileStateFailure,
@@ -41,7 +41,7 @@ function requestDesktopActivation(argv: readonly string[] = []): void {
state.skillShareDeepLinks.capture(argv, (shareId) => {
state.mainWindow?.webContents.send('ui:openSkillShare', shareId)
})
state.osOpenedMarkdownFiles.capture(argv, publishOsOpenedMarkdownFiles)
state.osOpenedDocuments.capture(argv, publishOsOpenedDocuments)
// Why: a duplicate `orca serve` must not drag a headless server into opening a desktop window (#11935).
if (!shouldActivateDesktopForSecondInstance(argv)) {
return
@@ -50,26 +50,26 @@ function requestDesktopActivation(argv: readonly string[] = []): void {
}
/**
* Hands buffered OS-opened markdown paths to a renderer that has proven it is listening.
* Hands buffered OS-opened document 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 {
function publishOsOpenedDocuments(): void {
const targetWindow = state.mainWindow
if (!state.markdownFileOpenListenerReady || !targetWindow || targetWindow.isDestroyed()) {
if (!state.osDocumentOpenListenerReady || !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()
const filePaths = state.osOpenedDocuments.consume()
if (filePaths.length === 0) {
return
}
void resolveOpenedMarkdownDocuments(filePaths)
void resolveOsOpenedDocuments(filePaths)
.then((documents) => {
if (targetWindow.isDestroyed() || targetWindow.webContents.isDestroyed()) {
state.osOpenedMarkdownFiles.restore(filePaths)
state.osOpenedDocuments.restore(filePaths)
return
}
if (documents.length > 0) {
@@ -77,8 +77,8 @@ function publishOsOpenedMarkdownFiles(): void {
}
})
.catch((error) => {
state.osOpenedMarkdownFiles.restore(filePaths)
console.warn('[os-open] Failed to resolve OS-opened markdown files:', error)
state.osOpenedDocuments.restore(filePaths)
console.warn('[os-open] Failed to resolve OS-opened documents:', error)
})
}
@@ -102,9 +102,9 @@ if (preflightReady) {
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.
// that claims the event. Unsupported paths stay unclaimed.
app.on('open-file', (event, filePath) => {
if (!state.osOpenedMarkdownFiles.captureFilePaths([filePath], publishOsOpenedMarkdownFiles)) {
if (!state.osOpenedDocuments.captureFilePaths([filePath], publishOsOpenedDocuments)) {
return
}
event.preventDefault()
@@ -116,7 +116,7 @@ if (preflightReady) {
})
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)
state.osOpenedDocuments.capture(process.argv)
registerMainProcessIpcHandlers()
installMainProcessQuitHandlers()
void app.whenReady().then(async () => {
@@ -43,6 +43,8 @@ describe('Markdown listing with bundled ripgrep', () => {
]
const excluded = [
'plain.txt',
'data.csv',
'data.tsv',
'.md',
'docs/.MDX',
'.git/hidden.md',
+5 -3
View File
@@ -1,5 +1,5 @@
import { basename as pathBasename, extname, isAbsolute, join, relative, resolve } from 'node:path'
import type { MarkdownDocument } from '../../shared/filesystem-entry-types'
import type { FileDocument, MarkdownDocument } from '../../shared/filesystem-entry-types'
import { spawnBundledRipgrep } from '../ripgrep/bundled-ripgrep-spawn'
import { parseWslPath } from '../wsl'
import {
@@ -39,11 +39,11 @@ function rootRelativePath(rootPath: string, filePath: string): string | null {
return normalizeRelativePath(relativePath)
}
export function markdownDocumentFromFilePath(
export function fileDocumentFromFilePath(
rootPath: string,
filePath: string,
options: { outsideRootRelativePath?: 'basename' | 'relative' } = {}
): MarkdownDocument {
): FileDocument {
const basename = pathBasename(filePath)
const extension = extname(basename)
const relativePath =
@@ -59,6 +59,8 @@ export function markdownDocumentFromFilePath(
}
}
export const markdownDocumentFromFilePath = fileDocumentFromFilePath
export function markdownDocumentFromRelativePath(
rootPath: string,
relativePath: string
@@ -2,7 +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'
import { resolveOsOpenedDocuments } from './os-opened-documents'
import {
onStructuredAgentSessionsHeldChanged,
structuredAgentSessionsHeld
@@ -61,13 +61,13 @@ export function registerMainProcessIpcHandlers(): void {
// 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()
state.osDocumentOpenListenerReady = true
const filePaths = state.osOpenedDocuments.consume()
try {
return await resolveOpenedMarkdownDocuments(filePaths)
return await resolveOsOpenedDocuments(filePaths)
} catch (error) {
// Why restored: the renderer never received these, so a later mount must still get them.
state.osOpenedMarkdownFiles.restore(filePaths)
state.osOpenedDocuments.restore(filePaths)
throw error
}
})
+3 -3
View File
@@ -37,7 +37,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 { OsOpenedDocumentState } from './os-opened-documents'
import {
DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD,
DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS,
@@ -115,11 +115,11 @@ export const mainProcessState = {
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(),
osOpenedDocuments: new OsOpenedDocumentState(),
// 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,
osDocumentOpenListenerReady: false,
firstWindowStartupServicesReady: Promise.resolve(),
// Why published: the default-session proxy must be applied before the first app-owned fetcher,
// but window creation has no reason to queue behind it (the request guard already fences it).
+1 -1
View File
@@ -186,7 +186,7 @@ export function openMainWindow(options: { revealOnDidFinishLoad?: boolean } = {}
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
state.osDocumentOpenListenerReady = false
const currentStore = state.store
if (currentStore && resolveConsent(currentStore.getSettings()).effective === 'enabled') {
trackAppOpenedOnce()
@@ -0,0 +1,176 @@
import { describe, expect, it } from 'vitest'
import { isMarkdownDocumentName } from '../ipc/markdown-documents'
import { documentPathsFromArguments } from './os-opened-documents'
describe('documentPathsFromArguments', () => {
it('accepts CSV/TSV alongside Markdown without widening Markdown discovery', () => {
expect(
documentPathsFromArguments(['/notes/a.md', '/notes/b.CSV', '/notes/c.TsV'], 'darwin')
).toEqual(['/notes/a.md', '/notes/b.CSV', '/notes/c.TsV'])
expect(isMarkdownDocumentName('b.csv')).toBe(false)
expect(isMarkdownDocumentName('c.tsv')).toBe(false)
})
it('accepts and dedupes Windows tabular paths and file URLs', () => {
expect(
documentPathsFromArguments(
[
'C:\\notes\\a.csv',
'file:///C:/notes/A.csv',
'\\\\server\\share\\b.tsv',
'file://server/share/b.tsv'
],
'win32'
)
).toEqual(['C:\\notes\\a.csv', '\\\\server\\share\\b.tsv'])
})
it('decodes tabular file URLs and excludes invalid or unsupported inputs', () => {
expect(
documentPathsFromArguments(
[
'file:///notes/query%20result.csv',
'/notes/query result.csv',
'file:///notes/export.tsv',
'relative.csv',
'../export.tsv',
'https://example.com/export.csv',
'file:///%zz.tsv',
'file://server/share/a.csv',
'--output=/notes/a.csv',
'/notes/private.txt',
'/notes/program.js'
],
'linux'
)
).toEqual(['/notes/query result.csv', '/notes/export.tsv'])
})
it('keeps absolute markdown paths and drops other extensions', () => {
expect(
documentPathsFromArguments(
[
'/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(
documentPathsFromArguments(
['--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(
documentPathsFromArguments([...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(
documentPathsFromArguments(['readme.md', './docs/a.md', '../up.md', ''], 'darwin')
).toEqual([])
})
it('accepts win32 drive-letter and UNC paths', () => {
expect(
documentPathsFromArguments(
['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(documentPathsFromArguments(['C:\\notes\\A.md', 'c:\\notes\\a.md'], 'win32')).toEqual([
'C:\\notes\\A.md'
])
})
it('normalizes parent segments before deduping', () => {
expect(
documentPathsFromArguments(['C:\\notes\\sub\\..\\a.md', 'C:\\notes\\a.md'], 'win32')
).toEqual(['C:\\notes\\a.md'])
expect(documentPathsFromArguments(['/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(documentPathsFromArguments(['/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(
documentPathsFromArguments(
['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(documentPathsFromArguments(['file:///home/me/design%20notes.md'], 'linux')).toEqual([
'/home/me/design notes.md'
])
})
it('decodes win32 file:// URIs, including UNC authority form', () => {
expect(
documentPathsFromArguments(
['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(documentPathsFromArguments(['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(() =>
documentPathsFromArguments(['file://', 'file:///%zz.md', 'https://example.com/a.md'], 'linux')
).not.toThrow()
expect(
documentPathsFromArguments(['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(documentPathsFromArguments(argv, 'darwin')).toEqual(['/notes/b.md'])
expect(documentPathsFromArguments(argv, 'win32')).toEqual(['C:\\notes\\a.md', '\\notes\\b.md'])
})
})
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { OsOpenedMarkdownFileState } from './os-opened-markdown-files'
import { OsOpenedDocumentState } from './os-opened-documents'
/**
* The two ways a queued "Open With" can be lost between main and the renderer. Both are
@@ -7,7 +7,7 @@ import { OsOpenedMarkdownFileState } from './os-opened-markdown-files'
*/
describe('os-opened markdown delivery ownership', () => {
it('keeps the batch when resolution rejects on the pull path', async () => {
const state = new OsOpenedMarkdownFileState()
const state = new OsOpenedDocumentState()
state.captureFilePaths(['/notes/a.md'])
const resolve = vi.fn().mockRejectedValue(new Error('floating root unavailable'))
@@ -28,11 +28,11 @@ describe('os-opened markdown delivery ownership', () => {
})
it('holds the batch while the renderer listener is not yet attached', () => {
const state = new OsOpenedMarkdownFileState()
const state = new OsOpenedDocumentState()
const send = vi.fn()
let listenerReady = false
// Mirrors publishOsOpenedMarkdownFiles()'s guard.
// Mirrors publishOsOpenedDocuments()'s guard.
const publish = (): void => {
if (!listenerReady) {
return
@@ -56,7 +56,7 @@ describe('os-opened markdown delivery ownership', () => {
})
it('restores a batch the window could no longer receive', () => {
const state = new OsOpenedMarkdownFileState()
const state = new OsOpenedDocumentState()
state.captureFilePaths(['/notes/a.md'])
const filePaths = state.consume()
@@ -0,0 +1,91 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { resolveOsOpenedDocuments } from './os-opened-documents'
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('resolveOsOpenedDocuments', () => {
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.each(['md', 'csv', 'tsv'])(
'resolves a real %s file outside the floating root',
async (extension) => {
const basename = `design notes.${extension}`
const filePath = join(fileRoot, basename)
await writeFile(filePath, '# hi\n', 'utf8')
const documents = await resolveOsOpenedDocuments([filePath])
expect(documents).toEqual([
{
filePath,
relativePath: basename,
basename,
name: 'design notes'
}
])
expect(authorizeExternalPath).toHaveBeenCalledWith(filePath)
}
)
it('never authorizes unsupported or relative files even when they exist', async () => {
const filePath = join(fileRoot, 'private.txt')
await writeFile(filePath, 'private')
expect(await resolveOsOpenedDocuments([filePath, 'relative.csv', 'file:///%zz.tsv'])).toEqual(
[]
)
expect(authorizeExternalPath).not.toHaveBeenCalled()
expect(ensureDefaultFloatingWorkspacePath).not.toHaveBeenCalled()
})
it('drops a directory that merely looks like a markdown file', async () => {
const bundlePath = join(fileRoot, 'bundle.tsv')
await mkdir(bundlePath)
const filePath = join(fileRoot, 'real.md')
await writeFile(filePath, '# hi\n', 'utf8')
const documents = await resolveOsOpenedDocuments([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.csv')
expect(await resolveOsOpenedDocuments([missingPath])).toEqual([])
expect(authorizeExternalPath).not.toHaveBeenCalled()
})
it('returns nothing for an empty input without touching the filesystem', async () => {
expect(await resolveOsOpenedDocuments([])).toEqual([])
expect(ensureDefaultFloatingWorkspacePath).not.toHaveBeenCalled()
expect(authorizeExternalPath).not.toHaveBeenCalled()
})
})
@@ -14,7 +14,7 @@ describe('os-opened markdown wiring', () => {
it('captures argv before the serve-duplicate early return', () => {
const captureIndex = index.indexOf(
'state.osOpenedMarkdownFiles.capture(argv, publishOsOpenedMarkdownFiles)'
'state.osOpenedDocuments.capture(argv, publishOsOpenedDocuments)'
)
const serveGuardIndex = index.indexOf('if (!shouldActivateDesktopForSecondInstance(argv)) {')
@@ -37,21 +37,21 @@ describe('os-opened markdown wiring', () => {
})
it('captures the cold-start argv and lets the renderer pull it after mount', () => {
expect(index).toContain('state.osOpenedMarkdownFiles.capture(process.argv)')
expect(index).toContain('state.osOpenedDocuments.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')
expect(index).toContain('!state.osDocumentOpenListenerReady')
expect(bootstrap).toContain('state.osDocumentOpenListenerReady = true')
// A reload drops the listener; the fresh renderer re-proves itself by pulling again.
expect(controller).toContain('state.markdownFileOpenListenerReady = false')
expect(controller).toContain('state.osDocumentOpenListenerReady = 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)')
expect(index).toContain('state.osOpenedDocuments.restore(filePaths)')
expect(bootstrap).toContain('state.osOpenedDocuments.restore(filePaths)')
})
})
@@ -0,0 +1,179 @@
import { resolve, sep } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { MAX_PENDING_OS_OPENED_DOCUMENTS, OsOpenedDocumentState } from './os-opened-documents'
// 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('OsOpenedDocumentState', () => {
it('reports no capture and does not publish when argv carries no markdown', () => {
const state = new OsOpenedDocumentState()
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 OsOpenedDocumentState()
const publish = vi.fn()
const filePath = hostPath('a.csv')
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 OsOpenedDocumentState()
const publish = vi.fn()
const filePath = hostPath('a.tsv')
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 OsOpenedDocumentState()
const filePath = hostPath('a.csv')
state.captureFilePaths([filePath])
state.captureFilePaths([filePath])
state.capture(['orca', filePath])
expect(state.consume()).toEqual([filePath])
})
it('drains the buffer on consume', () => {
const state = new OsOpenedDocumentState()
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 OsOpenedDocumentState()
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 OsOpenedDocumentState()
const overflow = MAX_PENDING_OS_OPENED_DOCUMENTS + 5
const paths = Array.from({ length: overflow }, (_, index) =>
hostPath(`file-${index}.${index % 2 === 0 ? 'csv' : 'tsv'}`)
)
expect(state.captureFilePaths(paths)).toBe(true)
expect(state.consume()).toEqual(paths.slice(0, MAX_PENDING_OS_OPENED_DOCUMENTS))
})
it('caps the buffer when a restore overflows it', () => {
const state = new OsOpenedDocumentState()
state.captureFilePaths([hostPath('pending.md')])
const restored = Array.from({ length: MAX_PENDING_OS_OPENED_DOCUMENTS }, (_, index) =>
hostPath(`restored-${index}.md`)
)
state.restore(restored)
const pending = state.consume()
expect(pending).toHaveLength(MAX_PENDING_OS_OPENED_DOCUMENTS)
expect(pending).toEqual(restored)
})
})
describe('OsOpenedDocumentState delivery cap', () => {
it('stops merging an OS file batch at the pending delivery cap', () => {
const state = new OsOpenedDocumentState()
const includes = vi.spyOn(Array.prototype, 'includes')
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
let probes: number
try {
state.captureFilePaths(
Array.from({ length: 10000 }, (_, index) => resolve(`/notes/${index}.md`))
)
probes = includes.mock.calls.length
} finally {
includes.mockRestore()
warn.mockRestore()
}
expect(probes).toBeLessThan(100)
expect(state.consume()).toEqual(
Array.from({ length: MAX_PENDING_OS_OPENED_DOCUMENTS }, (_, index) =>
resolve(`/notes/${index}.md`)
)
)
})
// Why: the cap drops files the user explicitly asked to open. Pin which end is
// dropped (the tail, in shell order) and that the loss is reported, not silent.
it('keeps the first paths in shell order and reports the dropped tail', () => {
const state = new OsOpenedDocumentState()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const total = MAX_PENDING_OS_OPENED_DOCUMENTS + 8
const paths = Array.from({ length: total }, (_, index) =>
resolve(`/notes/${String(index).padStart(3, '0')}.md`)
)
try {
expect(state.captureFilePaths(paths)).toBe(true)
expect(warn).toHaveBeenCalledWith(
expect.stringContaining(`Dropped 8 of ${total} OS-opened documents`)
)
} finally {
warn.mockRestore()
}
expect(state.consume()).toEqual(paths.slice(0, MAX_PENDING_OS_OPENED_DOCUMENTS))
})
it('stays silent for a batch that fits under the cap', () => {
const state = new OsOpenedDocumentState()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
state.captureFilePaths(
Array.from({ length: MAX_PENDING_OS_OPENED_DOCUMENTS }, (_, index) =>
resolve(`/notes/${index}.md`)
)
)
expect(warn).not.toHaveBeenCalled()
} finally {
warn.mockRestore()
}
})
it('reports a drop when an already-full queue rejects a later batch', () => {
const state = new OsOpenedDocumentState()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
state.captureFilePaths(
Array.from({ length: MAX_PENDING_OS_OPENED_DOCUMENTS }, (_, index) =>
resolve(`/first/${index}.md`)
)
)
expect(warn).not.toHaveBeenCalled()
state.captureFilePaths([resolve('/second/a.md'), resolve('/second/b.md')])
expect(warn).toHaveBeenCalledWith(expect.stringContaining('Dropped 2 of 2'))
} finally {
warn.mockRestore()
}
expect(state.consume()).toEqual(
Array.from({ length: MAX_PENDING_OS_OPENED_DOCUMENTS }, (_, index) =>
resolve(`/first/${index}.md`)
)
)
})
})
@@ -1,14 +1,19 @@
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 type { FileDocument } 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'
import { fileDocumentFromFilePath, isMarkdownDocumentName } from '../ipc/markdown-documents'
export function isOsOpenedDocumentName(name: string): boolean {
const extension = path.extname(name).toLowerCase()
return isMarkdownDocumentName(name) || extension === '.csv' || extension === '.tsv'
}
// 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
export const MAX_PENDING_OS_OPENED_DOCUMENTS = 32
/**
* Resolves one argv entry to a local absolute path, or null if it is not one.
@@ -35,13 +40,13 @@ function localPathFromArgument(argument: string, platform: NodeJS.Platform): str
}
/**
* Absolute markdown paths an OS "Open With" put on a launch or second-instance argv.
* Absolute document 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
* Why no executable/asar/dev-entry filtering: none of those argv entries end in a supported
* 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(
export function documentPathsFromArguments(
argv: readonly string[],
platform: NodeJS.Platform = process.platform
): string[] {
@@ -53,7 +58,7 @@ export function markdownPathsFromArguments(
continue
}
const argument = localPathFromArgument(rawArgument, platform)
if (!argument || !isMarkdownDocumentName(argument)) {
if (!argument || !isOsOpenedDocumentName(argument)) {
continue
}
const normalized = pathApi.normalize(argument)
@@ -70,23 +75,23 @@ export function markdownPathsFromArguments(
}
/**
* Buffers markdown paths the OS handed us until a renderer can receive them.
* Buffers document 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 {
export class OsOpenedDocumentState {
private pending: string[] = []
/** Returns true when argv carried at least one markdown path. */
/** Returns true when argv carried at least one supported document path. */
capture(argv: readonly string[], publish?: () => void): boolean {
return this.add(markdownPathsFromArguments(argv), publish)
return this.add(documentPathsFromArguments(argv), publish)
}
/** Returns true when at least one path was a markdown document. */
/** Returns true when at least one path was a supported document. */
captureFilePaths(filePaths: readonly string[], publish?: () => void): boolean {
return this.add(markdownPathsFromArguments(filePaths), publish)
return this.add(documentPathsFromArguments(filePaths), publish)
}
consume(): string[] {
@@ -97,7 +102,7 @@ export class OsOpenedMarkdownFileState {
/** 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)
this.pending = [...filePaths, ...this.pending].slice(0, MAX_PENDING_OS_OPENED_DOCUMENTS)
}
private add(filePaths: readonly string[], publish?: () => void): boolean {
@@ -107,7 +112,7 @@ export class OsOpenedMarkdownFileState {
const merged = [...this.pending]
let index = 0
for (; index < filePaths.length; index++) {
if (merged.length >= MAX_PENDING_OS_OPENED_MARKDOWN_FILES) {
if (merged.length >= MAX_PENDING_OS_OPENED_DOCUMENTS) {
break
}
const filePath = filePaths[index]!
@@ -119,30 +124,34 @@ export class OsOpenedMarkdownFileState {
// Why logged: the cap drops the tail of an oversized selection, and a file the
// user explicitly asked to open must not vanish without leaving a trace.
console.warn(
`[os-open] Dropped ${filePaths.length - index} of ${filePaths.length} OS-opened markdown files; the pending queue is capped at ${MAX_PENDING_OS_OPENED_MARKDOWN_FILES}.`
`[os-open] Dropped ${filePaths.length - index} of ${filePaths.length} OS-opened documents; the pending queue is capped at ${MAX_PENDING_OS_OPENED_DOCUMENTS}.`
)
}
this.pending = merged.slice(0, MAX_PENDING_OS_OPENED_MARKDOWN_FILES)
this.pending = merged.slice(0, MAX_PENDING_OS_OPENED_DOCUMENTS)
publish?.()
return true
}
}
/**
* Turns OS-handed paths into the same `MarkdownDocument` shape the floating workspace's own
* Turns OS-handed paths into the same document shape the floating workspace's own
* file picker produces, authorizing each one for the renderer's later read.
*/
export async function resolveOpenedMarkdownDocuments(
export async function resolveOsOpenedDocuments(
filePaths: readonly string[]
): Promise<MarkdownDocument[]> {
if (filePaths.length === 0) {
): Promise<FileDocument[]> {
const supportedPaths = documentPathsFromArguments(filePaths).slice(
0,
MAX_PENDING_OS_OPENED_DOCUMENTS
)
if (supportedPaths.length === 0) {
return []
}
const floatingRoot = await ensureDefaultFloatingWorkspacePath()
const documents: MarkdownDocument[] = []
for (const filePath of filePaths) {
const documents: FileDocument[] = []
for (const filePath of supportedPaths) {
try {
// Why: the shell can hand over a bundle directory named `*.md`, or a path already
// Why: the shell can hand over a directory named like a document, or a path already
// deleted by the time we resolve. Authorize only something that is really a file.
if (!(await stat(filePath)).isFile()) {
continue
@@ -152,7 +161,7 @@ export async function resolveOpenedMarkdownDocuments(
}
authorizeExternalPath(filePath)
documents.push(
markdownDocumentFromFilePath(floatingRoot, filePath, {
fileDocumentFromFilePath(floatingRoot, filePath, {
outsideRootRelativePath: 'basename'
})
)
@@ -1,387 +0,0 @@
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()
})
})
describe('OsOpenedMarkdownFileState delivery cap', () => {
it('stops merging an OS file batch at the pending delivery cap', () => {
const state = new OsOpenedMarkdownFileState()
const includes = vi.spyOn(Array.prototype, 'includes')
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
let probes: number
try {
state.captureFilePaths(
Array.from({ length: 10000 }, (_, index) => resolve(`/notes/${index}.md`))
)
probes = includes.mock.calls.length
} finally {
includes.mockRestore()
warn.mockRestore()
}
expect(probes).toBeLessThan(100)
expect(state.consume()).toEqual(
Array.from({ length: MAX_PENDING_OS_OPENED_MARKDOWN_FILES }, (_, index) =>
resolve(`/notes/${index}.md`)
)
)
})
// Why: the cap drops files the user explicitly asked to open. Pin which end is
// dropped (the tail, in shell order) and that the loss is reported, not silent.
it('keeps the first paths in shell order and reports the dropped tail', () => {
const state = new OsOpenedMarkdownFileState()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const total = MAX_PENDING_OS_OPENED_MARKDOWN_FILES + 8
const paths = Array.from({ length: total }, (_, index) =>
resolve(`/notes/${String(index).padStart(3, '0')}.md`)
)
try {
expect(state.captureFilePaths(paths)).toBe(true)
expect(warn).toHaveBeenCalledWith(
expect.stringContaining(`Dropped 8 of ${total} OS-opened markdown files`)
)
} finally {
warn.mockRestore()
}
expect(state.consume()).toEqual(paths.slice(0, MAX_PENDING_OS_OPENED_MARKDOWN_FILES))
})
it('stays silent for a batch that fits under the cap', () => {
const state = new OsOpenedMarkdownFileState()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
state.captureFilePaths(
Array.from({ length: MAX_PENDING_OS_OPENED_MARKDOWN_FILES }, (_, index) =>
resolve(`/notes/${index}.md`)
)
)
expect(warn).not.toHaveBeenCalled()
} finally {
warn.mockRestore()
}
})
it('reports a drop when an already-full queue rejects a later batch', () => {
const state = new OsOpenedMarkdownFileState()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
state.captureFilePaths(
Array.from({ length: MAX_PENDING_OS_OPENED_MARKDOWN_FILES }, (_, index) =>
resolve(`/first/${index}.md`)
)
)
expect(warn).not.toHaveBeenCalled()
state.captureFilePaths([resolve('/second/a.md'), resolve('/second/b.md')])
expect(warn).toHaveBeenCalledWith(expect.stringContaining('Dropped 2 of 2'))
} finally {
warn.mockRestore()
}
expect(state.consume()).toEqual(
Array.from({ length: MAX_PENDING_OS_OPENED_MARKDOWN_FILES }, (_, index) =>
resolve(`/first/${index}.md`)
)
)
})
})
+1 -1
View File
@@ -56,7 +56,7 @@ 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. */
/** OS "Open With" Markdown/CSV/TSV documents; the local IPC name is retained. */
onOpenMarkdownFiles: (callback: (documents: MarkdownDocument[]) => void) => () => void
/** Drains the "Open With" paths queued before this renderer's listener attached. */
consumePendingMarkdownFileOpens: () => Promise<MarkdownDocument[]>
@@ -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 { openMarkdownDocumentInFloatingWorkspace } from '@/lib/open-markdown-in-floating-workspace'
import { openDocumentInFloatingWorkspace } from '@/lib/open-document-in-floating-workspace'
import { extractIpcErrorMessage } from '@/lib/ipc-error'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { createFloatingWorkspaceTerminalTab } from '@/lib/floating-workspace-tab-creation'
@@ -111,7 +111,7 @@ export function useFloatingTerminalCreateActions({
if (!document) {
return
}
openMarkdownDocumentInFloatingWorkspace(openFile, document, {
openDocumentInFloatingWorkspace(openFile, document, {
targetGroupId: activeGroup?.id
})
} catch (error) {
@@ -9,16 +9,20 @@ const mocks = vi.hoisted(() => ({
openFile: vi.fn<EditorFilesSlice['openFile']>(() => 'file-1'),
updateSettings: vi.fn(async () => {}),
isFloatingWorkspacePanelVisible: vi.fn(() => false),
toastError: vi.fn()
toastError: vi.fn(),
subscribe: vi.fn<(listener: (state: { workspaceSessionReady: boolean }) => void) => () => void>()
}))
let storeState: {
openFile: typeof mocks.openFile
updateSettings: typeof mocks.updateSettings
settings: { floatingTerminalEnabled?: boolean } | undefined
workspaceSessionReady: boolean
}
vi.mock('../../store', () => ({ useAppStore: { getState: () => storeState } }))
vi.mock('../../store', () => ({
useAppStore: { getState: () => storeState, subscribe: mocks.subscribe }
}))
vi.mock('@/lib/floating-workspace-terminal-actions', () => ({
isFloatingWorkspacePanelVisible: mocks.isFloatingWorkspacePanelVisible
}))
@@ -70,11 +74,13 @@ describe('registerOsMarkdownFileOpenBridge', () => {
storeState = {
openFile: mocks.openFile,
updateSettings: mocks.updateSettings,
settings: { floatingTerminalEnabled: true }
settings: { floatingTerminalEnabled: true },
workspaceSessionReady: true
}
mocks.openFile.mockReturnValue('file-1')
mocks.updateSettings.mockResolvedValue(undefined)
mocks.isFloatingWorkspacePanelVisible.mockReturnValue(false)
mocks.subscribe.mockReset().mockReturnValue(() => {})
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
frames.push(callback)
)
@@ -82,6 +88,27 @@ describe('registerOsMarkdownFileOpenBridge', () => {
process.on('unhandledRejection', recordUnhandledRejection)
})
it('leaves documents in main until startup restoration has finished', async () => {
storeState.workspaceSessionReady = false
const unsubscribe = vi.fn()
mocks.subscribe.mockReturnValue(unsubscribe)
const consume = vi.fn(async () => [markdownDocument({ relativePath: 'export.csv' })])
stubPreload({ onOpenMarkdownFiles: () => () => {}, consumePendingMarkdownFileOpens: consume })
const unsubs: (() => void)[] = []
registerOsMarkdownFileOpenBridge(unsubs)
await settle()
expect(consume).not.toHaveBeenCalled()
expect(mocks.openFile).not.toHaveBeenCalled()
expect(unsubs).toEqual([unsubscribe])
storeState.workspaceSessionReady = true
mocks.subscribe.mock.calls[0][0](storeState)
await settle()
expect(unsubscribe).toHaveBeenCalledOnce()
expect(consume).toHaveBeenCalledOnce()
expect(mocks.openFile.mock.calls[0][0].language).toBe('csv')
})
afterEach(() => {
process.off('unhandledRejection', recordUnhandledRejection)
vi.unstubAllGlobals()
@@ -94,7 +121,7 @@ describe('registerOsMarkdownFileOpenBridge', () => {
consumePendingMarkdownFileOpens: () =>
Promise.resolve([
markdownDocument(),
markdownDocument({ filePath: '/Users/me/notes/plan.md', relativePath: 'plan.md' })
markdownDocument({ filePath: '/Users/me/notes/plan.csv', relativePath: 'plan.csv' })
])
})
@@ -104,7 +131,7 @@ describe('registerOsMarkdownFileOpenBridge', () => {
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'
'/Users/me/notes/plan.csv'
])
expect(mocks.openFile.mock.calls[0][0].worktreeId).toBe(FLOATING_TERMINAL_WORKTREE_ID)
})
@@ -125,12 +152,12 @@ describe('registerOsMarkdownFileOpenBridge', () => {
expect(unsubs).toEqual([unsubscribe])
listeners[0]([
markdownDocument({ filePath: '/Users/me/notes/live.md', relativePath: 'live.md' })
markdownDocument({ filePath: '/Users/me/notes/live.tsv', relativePath: 'live.tsv' })
])
await settle()
expect(mocks.openFile).toHaveBeenCalledTimes(1)
expect(mocks.openFile.mock.calls[0][0].filePath).toBe('/Users/me/notes/live.md')
expect(mocks.openFile.mock.calls[0][0].filePath).toBe('/Users/me/notes/live.tsv')
unsubs.forEach((teardown) => teardown())
expect(unsubscribe).toHaveBeenCalledOnce()
@@ -216,7 +243,7 @@ describe('registerOsMarkdownFileOpenBridge', () => {
registerOsMarkdownFileOpenBridge([])
await settle()
expect(mocks.toastError).toHaveBeenCalledWith('Failed to open the Markdown file.')
expect(mocks.toastError).toHaveBeenCalledWith('Failed to open the 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([])
@@ -239,7 +266,7 @@ describe('registerOsMarkdownFileOpenBridge', () => {
expect(() => listeners[0]([markdownDocument()])).not.toThrow()
await settle()
expect(mocks.toastError).toHaveBeenCalledWith('Failed to open the Markdown file.')
expect(mocks.toastError).toHaveBeenCalledWith('Failed to open the file.')
expect(unhandledRejections).toEqual([])
expect(dispatchEvent).not.toHaveBeenCalled()
})
@@ -1,16 +1,16 @@
import { toast } from 'sonner'
import type { MarkdownDocument } from '../../../../shared/filesystem-entry-types'
import type { FileDocument } 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 { openDocumentInFloatingWorkspace } from '@/lib/open-document-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
* Opens documents 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> {
async function openOsRequestedDocuments(documents: FileDocument[]): Promise<void> {
// Why the shape check: this payload crosses the preload boundary, so a stale or mismatched
// preload can hand back something that is not an array. Reading .length off that throws
// inside the promise chain rather than failing loudly at the boundary.
@@ -23,10 +23,10 @@ async function openOsRequestedMarkdownFiles(documents: MarkdownDocument[]): Prom
// 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)
openDocumentInFloatingWorkspace(store.openFile, document)
opened += 1
} catch (error) {
reportOsRequestedMarkdownFailure(error)
reportOsRequestedDocumentFailure(error)
}
}
if (opened === 0) {
@@ -46,19 +46,26 @@ async function openOsRequestedMarkdownFiles(documents: MarkdownDocument[]): Prom
})
}
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.'
)
)
function reportOsRequestedDocumentFailure(error: unknown): void {
console.error('Failed to open documents requested by the OS:', error)
toast.error(translate('osDocumentOpen.failed', 'Failed to open the file.'))
}
export function registerOsMarkdownFileOpenBridge(unsubs: (() => void)[]): void {
// Startup restoration replaces tabs; keep OS requests in main until it finishes.
if (!useAppStore.getState().workspaceSessionReady) {
const unsubscribe = useAppStore.subscribe((state) => {
if (state.workspaceSessionReady) {
unsubscribe()
registerOsMarkdownFileOpenBridge(unsubs)
}
})
unsubs.push(unsubscribe)
return
}
// Keep the legacy local IPC names; the payload also carries CSV/TSV documents.
const unsubscribe = window.api.ui.onOpenMarkdownFiles?.((documents) => {
void openOsRequestedMarkdownFiles(documents).catch(reportOsRequestedMarkdownFailure)
void openOsRequestedDocuments(documents).catch(reportOsRequestedDocumentFailure)
})
if (unsubscribe) {
unsubs.push(unsubscribe)
@@ -67,6 +74,6 @@ export function registerOsMarkdownFileOpenBridge(unsubs: (() => void)[]): void {
// 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)
void pending.then(openOsRequestedDocuments).catch(reportOsRequestedDocumentFailure)
}
}
+15
View File
@@ -2618,6 +2618,21 @@
}
},
"hooks": {
"ipc": {
"events": {
"os": {
"markdown": {
"file": {
"open": {
"bridge": {
"1e9a1a63c4": "Failed to open the Markdown file."
}
}
}
}
}
}
},
"useComposerState": {
"dropPartiallyAttached_one": "{{failureCount}} of {{count}} item could not be attached.",
"dropPartiallyAttached_other": "{{failureCount}} of {{count}} items could not be attached."
+3
View File
@@ -1,4 +1,7 @@
{
"osDocumentOpen": {
"failed": "Failed to open the file."
},
"sidebar": {
"revealFiltered": {
"title": "Reveal hidden workspace?",
@@ -2,7 +2,7 @@ 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'
import { openDocumentInFloatingWorkspace } from './open-document-in-floating-workspace'
function openFileMock(): ReturnType<typeof vi.fn<EditorFilesSlice['openFile']>> {
return vi.fn<EditorFilesSlice['openFile']>(() => 'file-1')
@@ -18,11 +18,11 @@ function markdownDocument(overrides: Partial<MarkdownDocument> = {}): MarkdownDo
}
}
describe('openMarkdownDocumentInFloatingWorkspace', () => {
describe('openDocumentInFloatingWorkspace', () => {
it('opens the document as a permanent floating-workspace edit tab', () => {
const openFile = openFileMock()
const fileId = openMarkdownDocumentInFloatingWorkspace(openFile, markdownDocument())
const fileId = openDocumentInFloatingWorkspace(openFile, markdownDocument())
expect(fileId).toBe('file-1')
expect(openFile).toHaveBeenCalledTimes(1)
@@ -44,7 +44,7 @@ describe('openMarkdownDocumentInFloatingWorkspace', () => {
it('pins the open to this machine instead of the active runtime', () => {
const openFile = openFileMock()
openMarkdownDocumentInFloatingWorkspace(openFile, markdownDocument())
openDocumentInFloatingWorkspace(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.
@@ -53,26 +53,31 @@ describe('openMarkdownDocumentInFloatingWorkspace', () => {
expect(openFile.mock.calls[0][1]?.suppressActiveRuntimeFallback).toBe(true)
})
it('derives the language from the relative path', () => {
const openFile = openFileMock()
it.each(['mdx', 'csv', 'tsv'])(
'derives the language for %s from the relative path',
(extension) => {
const openFile = openFileMock()
openMarkdownDocumentInFloatingWorkspace(
openFile,
markdownDocument({
filePath: '/Users/me/notes/plan.mdx',
relativePath: 'plan.mdx',
basename: 'plan.mdx',
name: 'plan'
})
)
openDocumentInFloatingWorkspace(
openFile,
markdownDocument({
filePath: `/Users/me/notes/plan.${extension}`,
relativePath: `plan.${extension}`,
basename: `plan.${extension}`,
name: 'plan'
})
)
expect(openFile.mock.calls[0][0].language).toBe('markdown')
})
expect(openFile.mock.calls[0][0].language).toBe(extension === 'mdx' ? 'markdown' : extension)
expect(openFile.mock.calls[0][0].runtimeEnvironmentId).toBeNull()
expect(openFile.mock.calls[0][1]?.suppressActiveRuntimeFallback).toBe(true)
}
)
it('forwards a requested target group', () => {
const openFile = openFileMock()
openMarkdownDocumentInFloatingWorkspace(openFile, markdownDocument(), {
openDocumentInFloatingWorkspace(openFile, markdownDocument(), {
targetGroupId: 'group-2'
})
@@ -1,17 +1,17 @@
import type { MarkdownDocument } from '../../../shared/filesystem-entry-types'
import type { FileDocument } 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.
* Opens a local document 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(
export function openDocumentInFloatingWorkspace(
openFile: EditorFilesSlice['openFile'],
document: MarkdownDocument,
document: FileDocument,
options: { targetGroupId?: string } = {}
): string {
return openFile(
+3 -1
View File
@@ -7,13 +7,15 @@ export type DirEntry = {
isSymlink: boolean
}
export type MarkdownDocument = {
export type FileDocument = {
filePath: string
relativePath: string
basename: string
name: string
}
export type MarkdownDocument = FileDocument
// ─── Filesystem watcher ─────────────────────────────────────
export type FsChangeEvent = {
kind: 'create' | 'update' | 'delete' | 'rename' | 'overflow'
+2 -1
View File
@@ -37,6 +37,7 @@ type LaunchedOrca = {
}
type LaunchOptions = {
extraArgs?: string[]
/**
* Called for each chunk the relaunched main process writes to stderr. The
* listener is attached before `firstWindow()` resolves so main-process
@@ -177,7 +178,7 @@ export function createRestartSession(
const launch = async (options?: LaunchOptions): Promise<LaunchedOrca> => {
runtimeWsPort ??= await reserveRestartRuntimeWsPort()
const app = await electron.launch({
args: getOrcaElectronLaunchArgs(mainPath, headful),
args: [...getOrcaElectronLaunchArgs(mainPath, headful), ...(options?.extraArgs ?? [])],
env: {
...homeIsolation.env,
...options?.extraEnv,
+110
View File
@@ -0,0 +1,110 @@
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { test, expect } from './helpers/orca-app'
import { createRestartSession } from './helpers/orca-restart'
for (const extension of ['md', 'csv', 'tsv'] as const) {
test(`opens OS-requested ${extension} documents on cold and warm launches without duplicate tabs`, async ({
seedTestRepo
}, testInfo) => {
void seedTestRepo
const root = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'orca-os-documents-')))
const delimiter = extension === 'tsv' ? '\t' : ','
const coldPath = path.join(root, `cold.${extension}`)
const warmPath = path.join(root, `warm.${extension}`)
writeFileSync(
coldPath,
extension === 'md'
? '# Cold result\n\n42\n'
: `name${delimiter}amount\nCold result${delimiter}42\n`
)
writeFileSync(
warmPath,
extension === 'md'
? '# Warm result\n\n84\n'
: `name${delimiter}amount\nWarm result${delimiter}84\n`
)
const unsupportedPath = path.join(root, 'unsupported.txt')
writeFileSync(unsupportedPath, 'private content')
const session = createRestartSession(testInfo, {
ORCA_BACKGROUND_LAUNCH: '1',
ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK: '1'
})
let launched: Awaited<ReturnType<typeof session.launch>> | undefined
try {
launched = await session.launch({ extraArgs: [coldPath, pathToFileURL(coldPath).href] })
const { app, page } = launched
const table = page.getByRole('table')
const coldResult =
extension === 'md'
? page.getByRole('heading', { name: 'Cold result' })
: table.getByRole('cell', { name: 'Cold result', exact: true })
const warmResult =
extension === 'md'
? page.getByRole('heading', { name: 'Warm result' })
: table.getByRole('cell', { name: 'Warm result', exact: true })
await expect(coldResult).toBeVisible()
await expect(page.getByText('42', { exact: true })).toBeVisible()
await expect(
page.locator('[data-tab-id]').filter({ hasText: `cold.${extension}` })
).toHaveCount(1)
const coldProof = testInfo.outputPath('cold-open.png')
await page.screenshot({ path: coldProof })
await testInfo.attach('cold-open', { path: coldProof, contentType: 'image/png' })
await app.evaluate(({ app }, fileUrl) => {
app.emit('second-instance', {}, ['orca', fileUrl], process.cwd())
}, pathToFileURL(warmPath).href)
await expect(warmResult).toBeVisible()
await expect(page.getByText('84', { exact: true })).toBeVisible()
await page
.locator('[data-tab-id]')
.filter({ hasText: `cold.${extension}` })
.click()
await expect(coldResult).toBeVisible()
await app.evaluate(({ app }, filePath) => {
app.emit('open-file', { preventDefault() {} }, filePath)
}, warmPath)
await expect(warmResult).toBeVisible()
await expect(
page.locator('[data-tab-id]').filter({ hasText: `warm.${extension}` })
).toHaveCount(1)
const warmProof = testInfo.outputPath('warm-open.png')
await page.screenshot({ path: warmProof })
await testInfo.attach('warm-open', { path: warmProof, contentType: 'image/png' })
await app.evaluate(({ app }, filePath) => {
app.emit('open-file', { preventDefault() {} }, filePath)
}, unsupportedPath)
await expect(
page.locator('[data-tab-id]').filter({ hasText: 'unsupported.txt' })
).toHaveCount(0)
const unauthorizedRead = await page.evaluate(async (filePath) => {
try {
await window.api.fs.readFile({ filePath })
return 'allowed'
} catch (error) {
return error instanceof Error ? error.message : String(error)
}
}, unsupportedPath)
expect(unauthorizedRead).toContain('Access denied')
expect(
await app.evaluate(({ BrowserWindow }) =>
BrowserWindow.getAllWindows().every(
(window) => !window.isVisible() && !window.isFocused()
)
)
).toBe(true)
} finally {
if (launched) {
await session.close(launched.app)
}
await session.dispose()
rmSync(root, { recursive: true, force: true })
}
})
}