diff --git a/apps/desktop/src/main/__tests__/workspace-file-refs-ipc-main.test.ts b/apps/desktop/src/main/__tests__/workspace-file-refs-ipc-main.test.ts new file mode 100644 index 0000000000..de4bda1be6 --- /dev/null +++ b/apps/desktop/src/main/__tests__/workspace-file-refs-ipc-main.test.ts @@ -0,0 +1,259 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { registerWorkspaceFileRefsIpc } from "../workspace-file-refs-ipc-main.js"; + +type Handler = (event: unknown, ...args: any[]) => unknown; + +interface Harness { + handlers: Map; + root: string; + opened: string[]; + revealed: string[]; + setSessionCwd(cwd: string): void; +} + +async function createHarness(): Promise { + const root = await mkdtemp(join(tmpdir(), "maka-workspace-file-refs-")); + const handlers = new Map(); + const opened: string[] = []; + const revealed: string[] = []; + let sessionCwd = root; + registerWorkspaceFileRefsIpc({ + ipcMain: { + handle: (channel, handler) => handlers.set(channel, handler as Handler), + }, + client: { + async getSession(sessionId: string) { + if (sessionId !== "session-1") return null; + return { workspace: { hostCwd: sessionCwd } }; + }, + } as never, + openPath: async (path) => { + opened.push(path); + return ""; + }, + showItemInFolder: (path) => { + revealed.push(path); + }, + }); + return { + handlers, + root, + opened, + revealed, + setSessionCwd(cwd) { + sessionCwd = cwd; + }, + }; +} + +test("workspace file refs read files inside the project root", async () => { + const h = await createHarness(); + try { + await mkdir(join(h.root, "docs"), { recursive: true }); + await writeFile(join(h.root, "docs", "设计 笔记.md"), "# 你好"); + const read = h.handlers.get("workspace-files:readText")!; + // Raw space/CJK reference resolves identically to its percent-encoded form. + assert.deepEqual(await read({}, "session-1", "docs/设计 笔记.md"), { + ok: true, + name: "设计 笔记.md", + text: "# 你好", + }); + assert.deepEqual( + await read({}, "session-1", "docs/%E8%AE%BE%E8%AE%A1%20%E7%AC%94%E8%AE%B0.md"), + { ok: true, name: "设计 笔记.md", text: "# 你好" }, + ); + // Relative-dot and in-root absolute spellings are the same file. + assert.deepEqual(await read({}, "session-1", "./docs/设计 笔记.md"), { + ok: true, + name: "设计 笔记.md", + text: "# 你好", + }); + } finally { + await rm(h.root, { recursive: true, force: true }); + } +}); + +test("workspace file refs reject traversal outside the root", async () => { + const h = await createHarness(); + try { + const secretDir = await mkdtemp(join(tmpdir(), "maka-refs-secret-")); + await writeFile(join(secretDir, "secret.md"), "secret"); + const read = h.handlers.get("workspace-files:readText")!; + assert.deepEqual(await read({}, "session-1", "../secret/secret.md"), { + ok: false, + reason: "outside_workspace", + }); + assert.deepEqual(await read({}, "session-1", join(secretDir, "secret.md")), { + ok: false, + reason: "outside_workspace", + }); + await rm(secretDir, { recursive: true, force: true }); + } finally { + await rm(h.root, { recursive: true, force: true }); + } +}); + +test("workspace file refs reject symlink escapes", async () => { + const h = await createHarness(); + try { + const secretDir = await mkdtemp(join(tmpdir(), "maka-refs-secret-")); + await writeFile(join(secretDir, "outside.md"), "secret"); + await mkdir(join(h.root, "docs"), { recursive: true }); + await symlink(join(secretDir, "outside.md"), join(h.root, "docs", "leak.md")); + const read = h.handlers.get("workspace-files:readText")!; + assert.deepEqual(await read({}, "session-1", "docs/leak.md"), { + ok: false, + reason: "outside_workspace", + }); + await rm(secretDir, { recursive: true, force: true }); + } finally { + await rm(h.root, { recursive: true, force: true }); + } +}); + +test("workspace file refs report missing files non-destructively", async () => { + const h = await createHarness(); + try { + const read = h.handlers.get("workspace-files:readText")!; + assert.deepEqual(await read({}, "session-1", "docs/gone.md"), { + ok: false, + reason: "not_found", + }); + } finally { + await rm(h.root, { recursive: true, force: true }); + } +}); + +test("workspace file refs reject invalid references and non-Markdown targets", async () => { + const h = await createHarness(); + try { + await writeFile(join(h.root, "notes.txt"), "plain"); + const read = h.handlers.get("workspace-files:readText")!; + for (const reference of [ + "notes.txt", + "file:///etc/passwd.md", + "https://example.com/a.md", + "", + 42, + null, + `a\nb.md`, + `${"a".repeat(2049)}.md`, + ]) { + assert.deepEqual(await read({}, "session-1", reference), { + ok: false, + reason: "invalid_reference", + }, String(reference)); + } + } finally { + await rm(h.root, { recursive: true, force: true }); + } +}); + +test("workspace file refs enforce the preview size cap", async () => { + const h = await createHarness(); + try { + await writeFile(join(h.root, "big.md"), "x".repeat(1024 * 1024 + 1)); + const read = h.handlers.get("workspace-files:readText")!; + assert.deepEqual(await read({}, "session-1", "big.md"), { + ok: false, + reason: "too_large", + }); + } finally { + await rm(h.root, { recursive: true, force: true }); + } +}); + +test("open locally and reveal resolve through the same boundary check", async () => { + const h = await createHarness(); + try { + await writeFile(join(h.root, "readme.md"), "# hi"); + const open = h.handlers.get("workspace-files:openLocally")!; + const reveal = h.handlers.get("workspace-files:revealInFolder")!; + assert.deepEqual(await open({}, "session-1", "readme.md"), { + ok: true, + opened: "readme.md", + }); + assert.equal(h.opened.length, 1); + assert.deepEqual(await reveal({}, "session-1", "./readme.md"), { + ok: true, + opened: "readme.md", + }); + assert.equal(h.revealed.length, 1); + assert.ok(h.revealed[0]!.endsWith("readme.md")); + // Escapes never reach the shell. + assert.deepEqual(await open({}, "session-1", "../escape.md"), { + ok: false, + reason: "outside_workspace", + }); + assert.equal(h.opened.length, 1); + } finally { + await rm(h.root, { recursive: true, force: true }); + } +}); + +test("remote hosts have no local workspace to serve", async () => { + const root = await mkdtemp(join(tmpdir(), "maka-workspace-file-refs-remote-")); + try { + await writeFile(join(root, "readme.md"), "# hi"); + const handlers = new Map(); + registerWorkspaceFileRefsIpc({ + ipcMain: { + handle: (channel, handler) => handlers.set(channel, handler as Handler), + }, + client: { + async getSession() { + return { workspace: { hostCwd: root } }; + }, + } as never, + allowLocalWorkspace: false, + openPath: async () => "", + showItemInFolder: () => {}, + }); + assert.deepEqual( + await handlers.get("workspace-files:readText")!({}, "session-1", "readme.md"), + { ok: false, reason: "workspace_unavailable" }, + ); + assert.deepEqual( + await handlers.get("workspace-files:openLocally")!({}, "session-1", "readme.md"), + { ok: false, reason: "workspace_unavailable" }, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("unknown sessions fail loudly instead of resolving against a default root", async () => { + const h = await createHarness(); + try { + const read = h.handlers.get("workspace-files:readText")!; + await assert.rejects( + Promise.resolve(read({}, "session-other", "readme.md")), + /No such Session/, + ); + } finally { + await rm(h.root, { recursive: true, force: true }); + } +}); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 32ac4b5c9e..df057c640f 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -188,6 +188,7 @@ import { import { registerRuntimeHostSkillsIpc } from "./runtime-host-skills-ipc-main.js"; import { registerRuntimeHostUsageIpc } from "./runtime-host-usage-ipc-main.js"; import { registerRuntimeHostWorkspaceIpc } from "./runtime-host-workspace-ipc-main.js"; +import { registerWorkspaceFileRefsIpc } from "./workspace-file-refs-ipc-main.js"; import { resolveShellEnv } from "./shell-env.js"; import { registerSettingsBotsIpc, @@ -1208,6 +1209,13 @@ function registerHostClientIpc( client, allowLocalWorkspace: target.kind === "local", }); + registerWorkspaceFileRefsIpc({ + ipcMain: scopedIpc, + client, + allowLocalWorkspace: target.kind === "local", + openPath: (path) => shell.openPath(path), + showItemInFolder: (path) => shell.showItemInFolder(path), + }); const resolveProjectRootForContext = (sessionId: unknown): Promise => resolveProjectContextRoot(sessionId, { currentProjectRoot: () => targetProjectRoot.current(), diff --git a/apps/desktop/src/main/workspace-file-refs-ipc-main.ts b/apps/desktop/src/main/workspace-file-refs-ipc-main.ts new file mode 100644 index 0000000000..c3355b0891 --- /dev/null +++ b/apps/desktop/src/main/workspace-file-refs-ipc-main.ts @@ -0,0 +1,208 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Workspace file references clicked in transcript Markdown (`#2664`). + * + * Trust model: a reference is untrusted transcript content. Resolution and + * sandbox-boundary enforcement happen ONLY here, on the trusted side, against + * the session's Runtime Host workspace root: + * + * - `../` traversal and out-of-root absolute paths fail the containment + * check (`isPathInside` against the realpath'd root). + * - Symlink escapes fail because the canonical target is resolved through + * `realpathAllowMissing` before containment is decided — a link inside the + * root pointing outside resolves to its outside target and is rejected. + * - Only regular Markdown files inside the root are readable; reads are + * size-capped and strictly read-only. + * + * Open/reveal go through injected main-process `shell` wrappers (the renderer + * has no shell access) after the same resolution + containment check. The + * external-link guard is untouched: `file://` URLs never reach it from here, + * and its allowlist does not grow. + */ + +import { stat } from 'node:fs/promises'; +import { readFile } from 'node:fs/promises'; +import { basename, isAbsolute, resolve } from 'node:path'; +import { isPathInside, realpathAllowMissing } from '@maka/runtime/path-containment'; +import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; +import { + handleReconnectableRead, + type ReconnectableReadIpcMain, +} from './ipc-reconnect-policy.js'; +import type { + WorkspaceFileOpenResult, + WorkspaceFileRefFailureReason, + WorkspaceFileTextReadResult, +} from '../preload/workspace-files-contract.js'; + +type WorkspaceFilesClient = Pick; + +interface RuntimeHostWorkspaceFilesIpcDeps { + readonly ipcMain: ReconnectableReadIpcMain; + readonly client: WorkspaceFilesClient; + /** Remote hosts have no local workspace to resolve against. */ + readonly allowLocalWorkspace?: boolean; + readonly openPath: (path: string) => Promise; + readonly showItemInFolder: (path: string) => void; +} + +/** References are short path texts; anything longer is not a file reference. */ +const MAX_REFERENCE_LENGTH = 2048; +/** Text preview cap — matches the order of the artifact text preview budget. */ +const MAX_READ_BYTES = 1024 * 1024; +const MARKDOWN_SUFFIX = /\.(?:md|markdown)$/i; +const PERCENT_ESCAPE = /%[0-9a-f]{2}/i; +const CONTROL_CHARS = /[\u0000-\u001f\u007f]/; + +export function registerWorkspaceFileRefsIpc( + deps: RuntimeHostWorkspaceFilesIpcDeps, +): void { + handleReconnectableRead( + deps.ipcMain, + 'workspace-files:readText', + async (_event, sessionId: string, reference: unknown): Promise => { + if (deps.allowLocalWorkspace === false) { + return { ok: false, reason: 'workspace_unavailable' }; + } + const resolved = await resolveReference(deps.client, sessionId, reference); + if (!resolved.ok) return resolved; + try { + const info = await stat(resolved.path); + if (!info.isFile()) return { ok: false, reason: 'not_found' }; + if (info.size > MAX_READ_BYTES) return { ok: false, reason: 'too_large' }; + const text = await readFile(resolved.path, 'utf8'); + return { ok: true, name: resolved.name, text }; + } catch (error) { + const code = typeof error === 'object' && error !== null && 'code' in error + ? (error as { code?: unknown }).code + : undefined; + if (code === 'ENOENT' || code === 'ENOTDIR') { + return { ok: false, reason: 'not_found' }; + } + return { ok: false, reason: 'read_failed' }; + } + }, + ); + deps.ipcMain.handle( + 'workspace-files:openLocally', + async (_event, sessionId: string, reference: unknown): Promise => { + if (deps.allowLocalWorkspace === false) { + return { ok: false, reason: 'workspace_unavailable' }; + } + const resolved = await resolveReference(deps.client, sessionId, reference); + if (!resolved.ok) return resolved; + const error = await deps.openPath(resolved.path); + if (error) return { ok: false, reason: 'open-failed' }; + return { ok: true, opened: resolved.name }; + }, + ); + deps.ipcMain.handle( + 'workspace-files:revealInFolder', + async (_event, sessionId: string, reference: unknown): Promise => { + if (deps.allowLocalWorkspace === false) { + return { ok: false, reason: 'workspace_unavailable' }; + } + const resolved = await resolveReference(deps.client, sessionId, reference); + if (!resolved.ok) return resolved; + deps.showItemInFolder(resolved.path); + return { ok: true, opened: resolved.name }; + }, + ); +} + +type ResolvedReference = + | { ok: true; path: string; name: string } + | { ok: false; reason: WorkspaceFileRefFailureReason }; + +/** + * Resolve a raw reference against the session's workspace root with full + * boundary enforcement. Returns typed failures for every rejection so callers + * never need to guess why a reference was refused. + */ +async function resolveReference( + client: WorkspaceFilesClient, + sessionId: string, + rawReference: unknown, +): Promise { + const reference = normalizeReference(rawReference); + if (reference === null) return { ok: false, reason: 'invalid_reference' }; + + let session: Awaited> | null; + try { + session = await client.getSession(sessionId); + } catch { + return { ok: false, reason: 'workspace_unavailable' }; + } + if (!session) throw new Error(`No such Session: ${sessionId}`); + const hostCwd = session.workspace?.hostCwd; + if (typeof hostCwd !== 'string' || hostCwd.length === 0) { + return { ok: false, reason: 'workspace_unavailable' }; + } + + let rootReal: string; + try { + rootReal = await realpathAllowMissing(hostCwd); + } catch { + return { ok: false, reason: 'workspace_unavailable' }; + } + + // Absolute references are allowed only to land back inside the root; the + // containment check below is what rejects out-of-root absolutes. + const candidate = isAbsolute(reference) + ? resolve(reference) + : resolve(rootReal, reference); + + let canonical: string; + try { + canonical = await realpathAllowMissing(candidate); + } catch { + return { ok: false, reason: 'outside_workspace' }; + } + // Traversal (`../`), symlink escapes, and out-of-root absolutes all end up + // here: their canonical target is not inside the realpath'd root. + if (!isPathInside(rootReal, canonical)) return { ok: false, reason: 'outside_workspace' }; + + return { ok: true, path: canonical, name: basename(canonical) }; +} + +/** + * Validate and canonicalize percent-escapes in a raw reference. Scheme-prefixed + * strings (including `file://`), control characters, and oversized inputs are + * rejected; Markdown-suffix checking happens on the decoded spelling so that + * percent-encoded space/CJK references resolve identically to raw ones. + */ +function normalizeReference(rawReference: unknown): string | null { + if (typeof rawReference !== 'string') return null; + if (rawReference.length === 0 || rawReference.length > MAX_REFERENCE_LENGTH) return null; + if (/^[a-z][a-z0-9+.-]*:/i.test(rawReference)) return null; + if (CONTROL_CHARS.test(rawReference)) return null; + + let candidate = rawReference; + if (PERCENT_ESCAPE.test(candidate)) { + try { + candidate = decodeURIComponent(candidate); + } catch { + // Malformed escapes keep the raw spelling; suffix check still applies. + } + } + if (!MARKDOWN_SUFFIX.test(candidate)) return null; + return candidate; +} diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index d478543721..d6db173d33 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1360,6 +1360,20 @@ export interface MakaBridge { delete(sessionId: string, artifactId: string): Promise; subscribeChanges(handler: (event: ArtifactChangedEvent) => void): () => void; }; + workspaceFiles: { + readText( + sessionId: string, + reference: string, + ): Promise; + openLocally( + sessionId: string, + reference: string, + ): Promise; + revealInFolder( + sessionId: string, + reference: string, + ): Promise; + }; skills: { list(host?: DesktopRuntimeHostRef): Promise; listInvocable( diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 656472bf20..a26d16e9ac 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -145,6 +145,10 @@ import type { ArtifactSaveResult, ArtifactTextReadResult, } from '@maka/core/artifacts'; +import type { + WorkspaceFileOpenResult, + WorkspaceFileTextReadResult, +} from './workspace-files-contract.js'; import type { CapabilitySnapshotCollection, PermissionSnapshot } from '@maka/core/capabilities'; import type { LocalMemoryState } from '@maka/core/local-memory'; import type { @@ -3024,6 +3028,17 @@ const makaBridge = { ); }, }, + workspaceFiles: { + readText(sessionId: string, reference: string): Promise { + return invokeSessionRuntimeHost('workspace-files:readText', sessionId, reference); + }, + openLocally(sessionId: string, reference: string): Promise { + return invokeSessionRuntimeHost('workspace-files:openLocally', sessionId, reference); + }, + revealInFolder(sessionId: string, reference: string): Promise { + return invokeSessionRuntimeHost('workspace-files:revealInFolder', sessionId, reference); + }, + }, skills: { list(host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'skills:list'); diff --git a/apps/desktop/src/preload/workspace-files-contract.ts b/apps/desktop/src/preload/workspace-files-contract.ts new file mode 100644 index 0000000000..3107c583e2 --- /dev/null +++ b/apps/desktop/src/preload/workspace-files-contract.ts @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Contract for workspace-file-reference IPC (`workspace-files:*`). + * + * A reference is the raw text exactly as written in transcript Markdown. + * All resolution and sandbox-boundary enforcement happens in desktop main + * against the session's Runtime Host workspace root; the renderer never + * receives absolute paths, mirroring the artifacts pane contract. + */ + +export type WorkspaceFileRefFailureReason = + | 'invalid_reference' + | 'not_found' + | 'outside_workspace' + | 'unsupported_type' + | 'too_large' + | 'read_failed' + | 'workspace_unavailable'; + +export type WorkspaceFileTextReadResult = + | { ok: true; name: string; text: string } + | { ok: false; reason: WorkspaceFileRefFailureReason }; + +export type WorkspaceFileOpenResult = + | { ok: true; opened: string } + | { ok: false; reason: WorkspaceFileRefFailureReason | 'open-failed' }; diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index c599125ae6..228c9dc77b 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -127,6 +127,8 @@ import { createDesktopWorkHubSessionPort } from './workhub-session-port.js'; import { WorkHubSurface } from './workhub-surface.js'; import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy'; import { getDesktopConversationCopy } from './locales/conversation-copy'; +import { getArtifactCopy } from './locales/artifact-copy'; +import { requestWorkspaceFilePreview } from './features/workbar'; import { ErrorBoundary } from './error-boundary'; import { useShellAppearance } from './use-shell-appearance'; import { useShellSearch } from './use-shell-search'; @@ -2459,9 +2461,15 @@ function AppShellContent({ * auto-submit the prompt; the user still presses Enter. That * keeps an injected `maka://compose?text=ransfer my keys...` * from sending without a human in the loop. + * - `kind: 'file-ref'` → stage a workspace-file preview request and open + * the workbar `files` tab, where the EXISTING ArtifactPane viewer shows + * a read-only preview (or an inline refusal for out-of-boundary refs). + * Resolution/boundary checks happen in desktop main; this branch only + * forwards the raw reference. Opening the workbar never unmounts the + * transcript, so conversation scroll survives. * * No other cases exist today by design — the parser only emits - * these two discriminants. If a new variant is added in `MakaUriDest`, + * these discriminants. If a new variant is added in `MakaUriDest`, * TypeScript's exhaustiveness check below trips and a new branch * must be wired here with corresponding fixture and journey coverage. */ @@ -2474,6 +2482,15 @@ function AppShellContent({ composerRef.current?.setText(dest.text); composerRef.current?.focus(); return; + case 'file-ref': { + if (!activeId) { + toastApi.info(getArtifactCopy(uiLocale).workspace.noActiveSession); + return; + } + requestWorkspaceFilePreview({ sessionId: activeId, reference: dest.reference }); + workbar.commands.openTool('files'); + return; + } default: { const _exhaustive: never = dest; return _exhaustive; diff --git a/apps/desktop/src/renderer/features/workbar/index.ts b/apps/desktop/src/renderer/features/workbar/index.ts index a104e6ade0..2a2c121125 100644 --- a/apps/desktop/src/renderer/features/workbar/index.ts +++ b/apps/desktop/src/renderer/features/workbar/index.ts @@ -24,3 +24,4 @@ export { WorkbarServicesProvider } from './services-context'; export { useWorkbarController } from './controller/use-workbar-controller'; export type { SessionWorkbarTabKind } from './model/workbar-tabs'; export type { WorkbarServices } from './ports'; +export { requestWorkspaceFilePreview } from './tools/artifacts/workspace-file-preview-request'; diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts index fb14676742..aa4f76960c 100644 --- a/apps/desktop/src/renderer/features/workbar/ports.ts +++ b/apps/desktop/src/renderer/features/workbar/ports.ts @@ -129,6 +129,43 @@ export type WorkbarOpenArtifactResult = | 'open-failed'; }; +export type WorkspaceFileRefFailureReason = + | 'invalid_reference' + | 'not_found' + | 'outside_workspace' + | 'unsupported_type' + | 'too_large' + | 'read_failed' + | 'workspace_unavailable'; + +export type WorkspaceFileTextReadResult = + | { ok: true; name: string; text: string } + | { ok: false; reason: WorkspaceFileRefFailureReason }; + +export type WorkspaceFileOpenResult = + | { ok: true; opened: string } + | { ok: false; reason: WorkspaceFileRefFailureReason | 'open-failed' }; + +/** Read-only access to workspace files referenced from transcript Markdown + * (`#2664`). All resolution and sandbox-boundary enforcement lives in desktop + * main; this port only forwards raw references and typed results. The shape + * mirrors the Desktop bridge's `workspace-files` contract and is adapted in + * `create-workbar-services`. */ +export interface WorkbarWorkspaceFilesService { + readText( + sessionId: string, + reference: string, + ): Promise; + openLocally( + sessionId: string, + reference: string, + ): Promise; + revealInFolder( + sessionId: string, + reference: string, + ): Promise; +} + export interface WorkbarArtifactsService { list( sessionId: string, @@ -259,6 +296,7 @@ export interface WorkbarServices { readonly tasks: WorkbarTasksService; readonly browser: WorkbarBrowserService; readonly artifacts: WorkbarArtifactsService; + readonly workspaceFiles: WorkbarWorkspaceFilesService; readonly inspector: WorkbarInspectorService; readonly attachments: WorkbarAttachmentsService; readonly sideChat: SideChatSessionPort; diff --git a/apps/desktop/src/renderer/features/workbar/testing.ts b/apps/desktop/src/renderer/features/workbar/testing.ts index f2e3e940b5..7812ef368d 100644 --- a/apps/desktop/src/renderer/features/workbar/testing.ts +++ b/apps/desktop/src/renderer/features/workbar/testing.ts @@ -99,6 +99,11 @@ export function createFakeWorkbarServices( openPath: async () => ({ ok: false, reason: 'missing' }), saveAs: async () => ({ ok: false, reason: 'canceled' }), }, + workspaceFiles: { + readText: async () => ({ ok: false, reason: 'not_found' }), + openLocally: async () => ({ ok: false, reason: 'not_found' }), + revealInFolder: async () => ({ ok: false, reason: 'not_found' }), + }, inspector: { trace: async () => { throw new Error('Fake inspector.trace is not configured'); diff --git a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx index c81f06ce6e..4f39c7e3b3 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx @@ -76,6 +76,11 @@ import { } from '@maka/ui'; import { EmptyState as AstryxEmptyState } from '@astryxdesign/core'; import { ArtifactPreview } from './artifact-preview'; +import { WorkspaceFilePreview } from './workspace-file-preview'; +import { + subscribeWorkspaceFilePreviewRequests, + type WorkspaceFilePreviewRequest, +} from './workspace-file-preview-request'; import { nextArtifactListAction } from './artifact-list-keyboard'; import { filterUserVisibleArtifacts } from './artifact-visibility'; import { openPathFailureCopy } from '../../../../open-path'; @@ -109,6 +114,9 @@ export function ArtifactPane(props: { const [pendingArtifactListRetry, setPendingArtifactListRetry] = useState(false); const [artifactActionBusy, setArtifactActionBusy] = useState(false); const [moreMenuOpen, setMoreMenuOpen] = useState(false); + // #2664: full-panel preview for a workspace file referenced from transcript + // Markdown. Routed through this pane so the SAME viewer surface renders it. + const [workspaceRequest, setWorkspaceRequest] = useState(null); const artifactListRequestSeqRef = useRef(0); const artifactPaneMountedRef = useMountedRef(); const artifactPaneSessionIdRef = useRef(sessionId); @@ -131,8 +139,19 @@ export function ArtifactPane(props: { useEffect(() => { setView({ kind: 'list' }); setSelectedId(null); + setWorkspaceRequest(null); }, [sessionId]); + useEffect(() => { + return subscribeWorkspaceFilePreviewRequests((request) => { + // Only the pane bound to the requesting session answers; requests are + // one-shot, so a stale pane never surfaces an older reference later. + if (request.sessionId === artifactPaneSessionIdRef.current) { + setWorkspaceRequest(request); + } + }); + }, []); + const refresh = useCallback(async () => { const requestSeq = ++artifactListRequestSeqRef.current; if (!sessionId) { @@ -441,7 +460,9 @@ export function ArtifactPane(props: { if (!(target instanceof Node) || !event.currentTarget.contains(target)) return; event.preventDefault(); event.stopPropagation(); - if (view.kind === 'preview') { + if (workspaceRequest) { + setWorkspaceRequest(null); + } else if (view.kind === 'preview') { returnToList(); } else { dismissPaneToComposer(); @@ -469,7 +490,12 @@ export function ArtifactPane(props: { )} /> )} - {view.kind === 'list' ? ( + {workspaceRequest ? ( + setWorkspaceRequest(null)} + /> + ) : view.kind === 'list' ? ( activeRecords.length > 0 ? (
    ; } -function TextFilePreview(props: { name: string; text: string; copy: ArtifactCopy }) { +/** Shared by the artifact preview and the workspace file reference preview + * (`#2664`): one rendered/source Markdown viewer, never a second one. */ +export function TextFilePreview(props: { name: string; text: string; copy: ArtifactCopy }) { const markdown = /\.(?:md|markdown)$/i.test(props.name); const [mode, setMode] = useState<'rendered' | 'source'>(markdown ? 'rendered' : 'source'); const bounded = boundPreviewText(props.text); diff --git a/apps/desktop/src/renderer/features/workbar/tools/artifacts/workspace-file-preview-request.ts b/apps/desktop/src/renderer/features/workbar/tools/artifacts/workspace-file-preview-request.ts new file mode 100644 index 0000000000..cf251fd570 --- /dev/null +++ b/apps/desktop/src/renderer/features/workbar/tools/artifacts/workspace-file-preview-request.ts @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Hand-off from transcript Markdown clicks (`MakaUriDest` kind `file-ref`) to + * the ArtifactPane's workspace-file preview. Module-level staging mirrors + * `quote-companion-panel-state`: the requester (app shell) and the consumer + * (lazily mounted pane) never need a direct prop thread through the workbar. + * + * A request is delivered exactly once: staged until a subscriber consumes it, + * then pushed live to already-mounted subscribers. Nothing here resolves or + * touches the referenced file — that is desktop main's job via IPC. + */ + +export interface WorkspaceFilePreviewRequest { + readonly sessionId: string; + /** Raw reference exactly as written in the Markdown source. */ + readonly reference: string; +} + +let stagedRequest: WorkspaceFilePreviewRequest | null = null; +const subscribers = new Set<(request: WorkspaceFilePreviewRequest) => void>(); + +export function requestWorkspaceFilePreview(request: WorkspaceFilePreviewRequest): void { + stagedRequest = request; + for (const subscriber of subscribers) subscriber(request); +} + +/** Deliver any staged request to `subscriber` and keep it fed; one-shot. */ +export function subscribeWorkspaceFilePreviewRequests( + subscriber: (request: WorkspaceFilePreviewRequest) => void, +): () => void { + if (stagedRequest) { + const request = stagedRequest; + stagedRequest = null; + subscriber(request); + } + subscribers.add(subscriber); + return () => { + subscribers.delete(subscriber); + }; +} diff --git a/apps/desktop/src/renderer/features/workbar/tools/artifacts/workspace-file-preview.tsx b/apps/desktop/src/renderer/features/workbar/tools/artifacts/workspace-file-preview.tsx new file mode 100644 index 0000000000..ec794a2f2d --- /dev/null +++ b/apps/desktop/src/renderer/features/workbar/tools/artifacts/workspace-file-preview.tsx @@ -0,0 +1,172 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Full-panel preview for a workspace file referenced from transcript Markdown + * (`#2664`). Mounted inside the ArtifactPane's `files` tab so the preview goes + * through the SAME viewer surface (`TextFilePreview`) — no second document + * viewer. + * + * Trust/failure posture: + * - The raw reference is forwarded untouched; desktop main owns resolution, + * sandbox containment, and reading. This component never assembles paths. + * - Every failure renders a non-destructive inline notice; nothing navigates + * away and no session state changes (the conversation stays mounted with + * its scroll position). + * - "Open locally" / "Reveal in folder" are explicit user actions routed to + * main-process shell wrappers via IPC. + */ + +import { useEffect, useRef, useState } from 'react'; +import { ArrowLeft, ExternalLink, FolderOpen, ICON_SIZE } from '@maka/ui/icons'; +import { Banner, Button, useToast, useUiLocale } from '@maka/ui'; +import { Spinner } from '@astryxdesign/core/Spinner'; +import type { WorkspaceFileTextReadResult } from '../../ports.js'; +import { TextFilePreview } from './artifact-preview.js'; +import type { WorkspaceFilePreviewRequest } from './workspace-file-preview-request.js'; +import { getArtifactCopy, type ArtifactCopy } from '../../../../locales/artifact-copy.js'; +import { useWorkbarServices } from '../../services-context.js'; + +export function WorkspaceFilePreview(props: { + request: WorkspaceFilePreviewRequest; + onBack: () => void; +}) { + const { request } = props; + const { workspaceFiles } = useWorkbarServices(); + const toast = useToast(); + const copy = getArtifactCopy(useUiLocale()); + const [state, setState] = useState< + | { kind: 'loading' } + | { kind: 'ready'; result: WorkspaceFileTextReadResult } + >({ kind: 'loading' }); + const busyRef = useRef(false); + + useEffect(() => { + let disposed = false; + setState({ kind: 'loading' }); + workspaceFiles + .readText(request.sessionId, request.reference) + .then((result) => { + if (!disposed) setState({ kind: 'ready', result }); + }) + .catch(() => { + // Transport failures stay non-destructive and inline. + if (!disposed) setState({ kind: 'ready', result: { ok: false, reason: 'read_failed' } }); + }); + return () => { + disposed = true; + }; + }, [request, workspaceFiles]); + + async function runAction(action: () => Promise<{ ok: boolean }>) { + if (busyRef.current) return; + busyRef.current = true; + try { + const result = await action(); + if (!result.ok) { + toast.error(copy.workspace.openFailed, copy.workspace.failures['open-failed'].description); + } + } catch { + toast.error(copy.workspace.openFailed, copy.workspace.failures['open-failed'].description); + } finally { + busyRef.current = false; + } + } + + const failure = state.kind === 'ready' && !state.result.ok ? state.result.reason : null; + + return ( +
    +
    +
    + +
    + {state.kind === 'loading' ? ( +
    +
    + ) : state.result.ok ? ( + + ) : ( + + )} +
    + + ); +} + +function FailureNotice(props: { + reason: Extract['reason']; + copy: ArtifactCopy; +}) { + const entry = props.copy.workspace.failures[props.reason]; + const tone = props.reason === 'too_large' + || props.reason === 'unsupported_type' + || props.reason === 'workspace_unavailable' + || props.reason === 'invalid_reference' + ? 'info' + : 'destructive'; + return ( + + ); +} diff --git a/apps/desktop/src/renderer/locales/artifact-copy.ts b/apps/desktop/src/renderer/locales/artifact-copy.ts index 97dc2ec0fa..abd3b41b81 100644 --- a/apps/desktop/src/renderer/locales/artifact-copy.ts +++ b/apps/desktop/src/renderer/locales/artifact-copy.ts @@ -89,6 +89,25 @@ export type ArtifactCopy = { openInFinder: string; loadingImage: string; }; + workspace: { + panelAria(name: string): string; + loading: string; + back: string; + openLocally: string; + revealInFolder: string; + openFailed: string; + noActiveSession: string; + failures: { + invalid_reference: ReasonCopy; + not_found: ReasonCopy; + outside_workspace: ReasonCopy; + unsupported_type: ReasonCopy; + too_large: ReasonCopy; + read_failed: ReasonCopy; + workspace_unavailable: ReasonCopy; + 'open-failed': ReasonCopy; + }; + }; }; const ARTIFACT_COPY = { @@ -127,6 +146,25 @@ const ARTIFACT_COPY = { readFailed: { title: '加载预览失败', description: '无法读取文件内容(可能已被删除、移动或权限不足)。请通过「在 Finder 中打开」检查文件。' }, unsupported: '暂不支持的预览', name: '名称', unnamed: '(未命名)', type: '类型', size: '大小', openInFinder: '在 Finder 中打开', loadingImage: '加载图片预览…', }, + workspace: { + panelAria: (name) => `工作区文件预览 · ${name}`, + loading: '加载文件预览…', + back: '返回生成文件列表', + openLocally: '本地打开', + revealInFolder: '在文件夹中显示', + openFailed: '无法打开文件,请稍后重试。', + noActiveSession: '当前没有活动会话,无法解析文件引用。', + failures: { + invalid_reference: { title: '无法识别的文件引用', description: '该引用不是可解析的项目内 Markdown 文件。会话未受影响。' }, + not_found: { title: '文件不存在', description: '引用的文件在项目中不存在或已被移动。会话未受影响。' }, + outside_workspace: { title: '超出工作区边界', description: '该引用指向项目工作区之外(或通过符号链接逃逸),已拒绝访问。会话未受影响。' }, + unsupported_type: { title: '不支持的文件类型', description: '目前仅支持预览项目内的 Markdown(.md)文件。' }, + too_large: { title: '文件超出预览大小', description: '文件超过文本预览上限,可使用「本地打开」查看完整内容。' }, + read_failed: { title: '读取失败', description: '无法读取文件内容,可能已被删除、移动或权限不足。会话未受影响。' }, + workspace_unavailable: { title: '工作区不可用', description: '当前会话的工作区不支持本地文件预览。' }, + 'open-failed': { title: '无法打开文件', description: '操作系统未能打开该文件,请稍后重试。' }, + }, + }, }, en: { pane: { @@ -163,6 +201,25 @@ const ARTIFACT_COPY = { readFailed: { title: 'Failed to load preview', description: 'The file could not be read. It may have been deleted, moved, or blocked by permissions. Use “Show in Finder” to inspect it.' }, unsupported: 'Unsupported preview', name: 'Name', unnamed: '(unnamed)', type: 'Type', size: 'Size', openInFinder: 'Show in Finder', loadingImage: 'Loading image preview…', }, + workspace: { + panelAria: (name) => `Workspace file preview · ${name}`, + loading: 'Loading file preview…', + back: 'Back to generated files', + openLocally: 'Open locally', + revealInFolder: 'Reveal in folder', + openFailed: 'Could not open the file. Try again later.', + noActiveSession: 'No active session to resolve this file reference against.', + failures: { + invalid_reference: { title: 'Unrecognized file reference', description: 'This reference is not a resolvable in-project Markdown file. The session was not affected.' }, + not_found: { title: 'File not found', description: 'The referenced file does not exist in the project or has moved. The session was not affected.' }, + outside_workspace: { title: 'Outside the workspace boundary', description: 'The reference points outside the project workspace (or escapes via a symlink) and was refused. The session was not affected.' }, + unsupported_type: { title: 'Unsupported file type', description: 'Only Markdown (.md) files inside the project can be previewed for now.' }, + too_large: { title: 'File exceeds preview size', description: 'The file exceeds the text preview limit. Use “Open locally” to view the full content.' }, + read_failed: { title: 'Read failed', description: 'The file could not be read. It may have been deleted, moved, or blocked by permissions. The session was not affected.' }, + workspace_unavailable: { title: 'Workspace unavailable', description: 'This session’s workspace does not support local file previews.' }, + 'open-failed': { title: 'Could not open the file', description: 'The operating system failed to open the file. Try again later.' }, + }, + }, }, } satisfies UiCatalog; diff --git a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts index 86c9d0ef00..f23d2d1153 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts @@ -33,6 +33,7 @@ export type DesktopWorkbarBridge = Pick< | 'shellRuns' | 'tasks' | 'transcripts' + | 'workspaceFiles' >; export interface DesktopWorkbarServiceDependencies { @@ -94,6 +95,14 @@ export function createDesktopWorkbarServices( saveAs: (sessionId, artifactId) => bridge.app.saveArtifactAs(sessionId, artifactId), }, + workspaceFiles: { + readText: (sessionId, reference) => + bridge.workspaceFiles.readText(sessionId, reference), + openLocally: (sessionId, reference) => + bridge.workspaceFiles.openLocally(sessionId, reference), + revealInFolder: (sessionId, reference) => + bridge.workspaceFiles.revealInFolder(sessionId, reference), + }, inspector: { trace: (sessionId, cursor) => bridge.inspector.trace(sessionId, cursor), summary: (sessionId) => bridge.inspector.summary(sessionId), diff --git a/packages/ui/src/__tests__/markdown-file-reference.test.ts b/packages/ui/src/__tests__/markdown-file-reference.test.ts new file mode 100644 index 0000000000..bde1c98702 --- /dev/null +++ b/packages/ui/src/__tests__/markdown-file-reference.test.ts @@ -0,0 +1,105 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { it } from 'node:test'; +import { MarkdownBody } from '../markdown-body.js'; +import { MakaUriContext } from '../markdown.js'; +import { LocaleProvider } from '../locale-context.js'; +import { parseFileReference, type MakaUriDest } from '../maka-uri.js'; + +function renderWithDispatcher(text: string, dispatch?: (dest: MakaUriDest) => void): string { + return renderToStaticMarkup( + createElement( + LocaleProvider, + { + locale: 'en', + children: createElement( + MakaUriContext.Provider, + { value: dispatch }, + createElement(MarkdownBody, { text }), + ), + }, + ), + ); +} + +it('recognizes workspace-relative and absolute .md references raw', () => { + assert.equal(parseFileReference('docs/notes.md'), 'docs/notes.md'); + assert.equal(parseFileReference('./README.markdown'), './README.markdown'); + assert.equal(parseFileReference('../shared/guide.md'), '../shared/guide.md'); + assert.equal(parseFileReference('/workspace/project/PLAN.md'), '/workspace/project/PLAN.md'); +}); + +it('keeps spaces, CJK, and percent-encoded spellings recognizable', () => { + // Raw non-ASCII reference. + assert.equal(parseFileReference('docs/设计 笔记.md'), 'docs/设计 笔记.md'); + // Percent-encoded spelling of the same reference is recognized, but the raw + // text is carried so the trusted side owns decoding. + assert.equal(parseFileReference('docs/%E8%AE%BE%E8%AE%A1%20%E7%AC%94%E8%AE%B0.md'), 'docs/%E8%AE%BE%E8%AE%A1%20%E7%AC%94%E8%AE%B0.md'); +}); + +it('rejects schemes, malformed input, and non-Markdown targets', () => { + assert.equal(parseFileReference('file:///etc/passwd'), null); + assert.equal(parseFileReference('https://example.com/a.md'), null); + assert.equal(parseFileReference('javascript:alert(1)'), null); + assert.equal(parseFileReference('maka://settings/models'), null); + assert.equal(parseFileReference('docs/notes.txt'), null); + assert.equal(parseFileReference('docs/no-extension'), null); + assert.equal(parseFileReference(''), null); + assert.equal(parseFileReference('a\nb.md'), null); + assert.equal(parseFileReference(`${'a'.repeat(2049)}.md`), null); +}); + +it('renders workspace .md references actionable when a dispatcher is installed', () => { + const markup = renderWithDispatcher('[Guide](docs/user%20guide.md)', () => {}); + + assert.match(markup, /data-maka-uri-kind="file-ref"/); +}); + +it('hands the exact raw reference to the dispatcher on activation', () => { + // Percent-encoded spelling of `docs/中文 文件.md`; recognition decodes only + // for the suffix check while the raw reference stays untouched. + const href = 'docs/%E4%B8%AD%E6%96%87%20%E6%96%87%E4%BB%B6.md'; + assert.equal(parseFileReference(href), href); + const markup = renderWithDispatcher(`[文件](${href})`, () => {}); + + assert.match(markup, /data-maka-uri-kind="file-ref"/); + // Raw unencoded spaces are not valid CommonMark link destinations; Astryx + // truncates the destination at the space, so the ref stays inert. + const spacedMarkup = renderWithDispatcher('[文件](docs/中文 文件.md)'); + assert.doesNotMatch(spacedMarkup, /data-maka-uri-kind="file-ref"/); +}); + +it('keeps file references inert when no dispatcher is installed', () => { + const markup = renderWithDispatcher('[Guide](docs/notes.md)'); + + assert.doesNotMatch(markup, /data-maka-uri-kind="file-ref"/); + // Same inert affordance as any other unhandled destination. + assert.match(markup, /data-reason="unsafe-scheme"/); +}); + +it('never turns file:// links into file references even with a dispatcher', () => { + const markup = renderWithDispatcher('[secret](file:///Users/example/.ssh/id_rsa)'); + + assert.doesNotMatch(markup, /data-maka-uri-kind="file-ref"/); + assert.match(markup, /data-reason="unsafe-scheme"/); +}); diff --git a/packages/ui/src/icons.tsx b/packages/ui/src/icons.tsx index 5143904ce6..e0e672b5af 100644 --- a/packages/ui/src/icons.tsx +++ b/packages/ui/src/icons.tsx @@ -87,6 +87,7 @@ export { Download, Eye, EyeOff, + ExternalLink, FileCode, FileEdit, FileImage, diff --git a/packages/ui/src/maka-uri.ts b/packages/ui/src/maka-uri.ts index ac8bfd421a..18047f1407 100644 --- a/packages/ui/src/maka-uri.ts +++ b/packages/ui/src/maka-uri.ts @@ -22,11 +22,14 @@ import { SETTINGS_SECTIONS, type SettingsSection } from '@maka/core/settings'; const ALLOWED_SETTINGS_SECTIONS = new Set(SETTINGS_SECTIONS); const RAW_HREF_MAX_LENGTH = 4096; const COMPOSE_TEXT_MAX_LENGTH = 4096; +const FILE_REFERENCE_MAX_LENGTH = 2048; /** Closed internal navigation surface; it never executes actions. */ export type MakaUriDest = | { kind: 'settings'; section: SettingsSection } - | { kind: 'compose'; text: string }; + | { kind: 'compose'; text: string } + /** Raw workspace file reference exactly as written (never resolved here). */ + | { kind: 'file-ref'; reference: string }; /** * Parse an exact lowercase internal URI. Unsupported namespaces and malformed @@ -68,6 +71,42 @@ export function parseMakaUri(href: string): MakaUriDest | null { } } +/** Markdown file suffixes a transcript reference must carry to be actionable. */ +const FILE_REFERENCE_SUFFIX = /\.(?:md|markdown)$/i; +/** Any URI scheme (`file:`, `http:`, `custom:`) disqualifies a raw file reference. */ +const URI_SCHEME = /^[a-z][a-z0-9+.-]*:/i; +const PERCENT_ESCAPE = /%[0-9a-f]{2}/i; + +/** + * Recognize a workspace file reference in a Markdown link destination and + * return it **raw** (exactly as written, no decoding, no resolution). + * + * Only relative or absolute filesystem paths ending in a Markdown suffix are + * recognized; every URI scheme (including `file://`) stays out so the external + * navigation guard remains the sole authority for those. Percent-escapes are + * decoded for recognition only — spaces, CJK, and other non-ASCII references + * must resolve identically to their percent-encoded spellings. The consumer + * decides whether a handler exists; callers must treat `null` as "leave the + * link inert". + */ +export function parseFileReference(href: string): string | null { + if (typeof href !== 'string') return null; + if (href.length === 0 || href.length > FILE_REFERENCE_MAX_LENGTH) return null; + if (URI_SCHEME.test(href)) return null; + if (/[\u0000-\u001f\u007f]/.test(href)) return null; + + let candidate = href; + if (PERCENT_ESCAPE.test(candidate)) { + try { + candidate = decodeURIComponent(candidate); + } catch { + // Malformed escapes stay raw; the suffix check below still applies. + } + } + if (!FILE_REFERENCE_SUFFIX.test(candidate)) return null; + return href; +} + /** * Case-insensitive probe used to keep internal-looking links out of the * external navigation path. Parsing remains lowercase-only. diff --git a/packages/ui/src/markdown-body.tsx b/packages/ui/src/markdown-body.tsx index 2ea84a9711..3c213ea3b4 100644 --- a/packages/ui/src/markdown-body.tsx +++ b/packages/ui/src/markdown-body.tsx @@ -40,6 +40,7 @@ import { useTranslator } from '@astryxdesign/core/i18n'; import { isMakaUriCandidate, isSafeExternalScheme, + parseFileReference, parseMakaUri, } from './maka-uri.js'; import { MakaUriContext } from './markdown.js'; @@ -381,6 +382,28 @@ function MarkdownLink(props: { href: string; children: ReactNode }) { ); } + + // Workspace Markdown references become actionable only when a dispatcher is + // installed (the Desktop renderer installs one; other surfaces sharing this + // package do not, and keep today's inert rendering). The raw reference is + // handed to the dispatcher unresolved — boundary checks live on the trusted + // side, never here. + if (dispatch) { + const reference = parseFileReference(href); + if (reference !== null) { + return ( + dispatch({ kind: 'file-ref', reference })} + > + {children} + + ); + } + } + return (