diff --git a/app/api/files/[...path]/route.ts b/app/api/files/[...path]/route.ts index 4dd4874b8..9b8e83c87 100644 --- a/app/api/files/[...path]/route.ts +++ b/app/api/files/[...path]/route.ts @@ -496,6 +496,9 @@ export async function GET( } if (type === "meta") { + if (stat?.isDirectory()) { + return NextResponse.json({ isDirectory: true }); + } if (!stat?.isFile()) { return NextResponse.json({ error: "Not a file" }, { status: 400 }); } diff --git a/app/globals.css b/app/globals.css index 61c4bca47..894a735fe 100644 --- a/app/globals.css +++ b/app/globals.css @@ -533,6 +533,43 @@ pre, code { font-size: 0.92em; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border) 70%, transparent); } +.markdown-body a.markdown-local-file-link, +.markdown-body a.markdown-local-file-link:hover { + color: var(--text); + text-decoration: none; + cursor: pointer; + background: var(--bg-subtle); + border-radius: 5px; + padding: 1px 5px; + font-family: var(--font-mono); + font-size: 0.92em; + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border) 70%, transparent); +} +.markdown-body a.markdown-local-file-link .markdown-inline-code { + background: transparent; + box-shadow: none; + padding: 0; + font-size: inherit; +} +.markdown-body a.markdown-local-file-link:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; +} +.directory-viewer-entry { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px; + border: none; + border-radius: 5px; + background: transparent; + color: var(--text); + text-align: left; + overflow-wrap: anywhere; + cursor: pointer; +} +.directory-viewer-entry:hover { background: var(--bg-hover); } .markdown-body .contains-task-list { padding-left: 0; list-style: none; diff --git a/components/DirectoryViewer.test.mjs b/components/DirectoryViewer.test.mjs new file mode 100644 index 000000000..813da5842 --- /dev/null +++ b/components/DirectoryViewer.test.mjs @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const route = await readFile(new URL("../app/api/files/[...path]/route.ts", import.meta.url), "utf8"); +const viewer = await readFile(new URL("./FileViewer.tsx", import.meta.url), "utf8"); +const directory = await readFile(new URL("./DirectoryViewer.tsx", import.meta.url), "utf8"); + +test("metadata recognizes directories after existing authorization checks", () => { + const meta = route.indexOf('if (type === "meta")'); + assert.ok(route.indexOf("isExistingFilePathAllowed(existingAuthorizationPath, allowedRoots)") < meta); + assert.match(route.slice(meta), /stat\?\.isDirectory\(\)[\s\S]*?isDirectory: true/); +}); + +test("right panel dispatches directories by metadata rather than filename extension", () => { + assert.match(viewer, /data\.isDirectory \? "directory" : "file"/); + assert.match(viewer, /kind === "directory"[\s\S]*? void; +}) { + const { t } = useI18n(); + const [result, setResult] = useState<{ path: string; entries?: Entry[]; error?: string } | null>(null); + useEffect(() => { + const controller = new AbortController(); + void fetch(`/api/files/${encodeFilePathForApi(filePath)}?type=list`, { signal: controller.signal }) + .then(async (response) => { + const data = await response.json(); + if (!response.ok) throw new Error(data.error ?? response.statusText); + if (!controller.signal.aborted) setResult({ path: filePath, entries: data.entries }); + }) + .catch((error) => { + if (!controller.signal.aborted) setResult({ path: filePath, error: String(error) }); + }); + return () => controller.abort(); + }, [filePath]); + + return ( +
+
{filePath}
+ {result?.path !== filePath ?
{t("files.loading")}
: result.error ? ( +
{result.error}
+ ) : ( +
+ {result.entries?.length === 0 &&
{t("files.noFiles")}
} + {result.entries?.map((entry) => { + const childPath = `${filePath.replace(/[\\/]+$/, "")}/${entry.name}`; + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/components/FileViewer.tsx b/components/FileViewer.tsx index 4a583cd81..0314eff0c 100644 --- a/components/FileViewer.tsx +++ b/components/FileViewer.tsx @@ -24,6 +24,7 @@ import { parseFrontmatter } from "@/lib/frontmatter"; import { markdownPreviewRehypePlugins, markdownPreviewRemarkPlugins, markdownUrlTransform, normalizeDisplayMath } from "@/lib/markdown"; import { CodeBlock, MermaidBlock } from "./MermaidBlock"; import { FrontmatterCard } from "./FrontmatterCard"; +import { DirectoryViewer } from "./DirectoryViewer"; import { parseUnifiedPatch } from "@/lib/patch"; import type { GitFileDiffResponse } from "@/lib/git-types"; import { useI18n } from "@/hooks/useI18n"; @@ -1076,7 +1077,34 @@ function DocumentViewer({ filePath, cwd, sourceSessionId, watchEnabled = true }: ); } -export function FileViewer({ +export function FileViewer(props: Props) { + return ; +} + +function PathViewer(props: Props) { + const { t } = useI18n(); + const [kind, setKind] = useState<"file" | "directory" | null>(null); + const [error, setError] = useState(null); + useEffect(() => { + const controller = new AbortController(); + void fetch(getFileApiUrl(props.filePath, "meta", props.sourceSessionId), { signal: controller.signal }) + .then(async (response) => { + const data = await response.json(); + if (!response.ok) throw new Error(data.error ?? response.statusText); + if (!controller.signal.aborted) setKind(data.isDirectory ? "directory" : "file"); + }) + .catch((error) => { + if (!controller.signal.aborted) setError(String(error)); + }); + return () => controller.abort(); + }, [props.filePath, props.sourceSessionId]); + if (error) return
{error}
; + if (!kind) return
{t("files.loading")}
; + if (kind === "directory") return ; + return ; +} + +function FileContentViewer({ filePath, cwd, sourceSessionId, diff --git a/components/LocalFileLink.tsx b/components/LocalFileLink.tsx new file mode 100644 index 000000000..1e04a7fee --- /dev/null +++ b/components/LocalFileLink.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useEffect, useState, type ReactNode, type MouseEvent } from "react"; +import { shouldOpenLocalFileInApp } from "@/lib/file-links"; +import { validateFileLink } from "@/lib/file-link-validation"; + +interface Props { + filePath: string; + href?: string; + title?: string; + target?: string; + children: ReactNode; + fullPathLabel?: string; + onOpenFile: (path: string) => void; +} + +export function LocalFileLink({ filePath, href, target, children, fullPathLabel, onOpenFile }: Props) { + const [verifiedPath, setVerifiedPath] = useState(null); + useEffect(() => { + let active = true; + const check = () => { + void validateFileLink(filePath).then((exists) => { + if (active) setVerifiedPath(exists ? filePath : null); + }); + }; + check(); + // Recheck files created/deleted by the agent, including previously missing paths. + const timer = setInterval(() => { + if (document.visibilityState === "visible") check(); + }, 15000); + return () => { active = false; clearInterval(timer); }; + }, [filePath]); + + if (verifiedPath !== filePath) return <>{children}; + + const handleClick = async (event: MouseEvent) => { + if (!shouldOpenLocalFileInApp(event)) return; + if (target && target !== "_self") return; + event.preventDefault(); + // A file may have disappeared since the initial check. + if (await validateFileLink(filePath)) onOpenFile(filePath); + else setVerifiedPath(null); + }; + + return ( + + {children} + + ); +} diff --git a/components/MarkdownBody.test.mjs b/components/MarkdownBody.test.mjs index 305449540..27f094588 100644 --- a/components/MarkdownBody.test.mjs +++ b/components/MarkdownBody.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; import test from "node:test"; import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; @@ -9,7 +10,9 @@ const jiti = createJiti(import.meta.url, { tsconfigPaths: true, }); const { MarkdownBody } = await jiti.import("./MarkdownBody.tsx"); -const { normalizeDisplayMath } = await jiti.import("../lib/markdown.ts"); +const { normalizeDisplayMath, markdownRemarkPlugins, markdownRehypePlugins } = await jiti.import("../lib/markdown.ts"); +const { remarkFileLinks } = await jiti.import("../lib/remark-file-links.ts"); +const { default: ReactMarkdown } = await import("react-markdown"); const { I18nProvider } = await jiti.import("@/hooks/useI18n"); function renderMarkdown(markdown, props = {}) { @@ -26,6 +29,49 @@ function renderMarkdown(markdown, props = {}) { ); } +test("full-path metadata survives sanitization while the original label remains intact", () => { + let label; + const html = renderToStaticMarkup(React.createElement(ReactMarkdown, { + remarkPlugins: [...markdownRemarkPlugins, [remarkFileLinks, { cwd: "D:/project" }]], + rehypePlugins: markdownRehypePlugins, + components: { a({ node, children }) { + label = node.properties.dataFilePathLabel; + return React.createElement("span", null, children); + } }, + }, "`src/main.ts`")); + assert.equal(label, "D:/project/src/main.ts"); + assert.match(html, /src\/main.ts<\/code>/); +}); + +test("local path links retain neutral code styling without an underline", async () => { + const css = await readFile(new URL("../app/globals.css", import.meta.url), "utf8"); + assert.match(css, /a\.markdown-local-file-link:hover \{[^}]*color: var\(--text\);[^}]*text-decoration: none;[^}]*background: var\(--bg-subtle\);/); + const link = await readFile(new URL("./LocalFileLink.tsx", import.meta.url), "utf8"); + assert.match(link, /title=\{fullPathLabel \?\? filePath\}/); + assert.match(link, /onClick=\{handleClick\}>\s*\{children\}/); + assert.doesNotMatch(link, /\{fullPathLabel \?\? children\}/); +}); + +test("keeps original inline-code path labels until filesystem validation succeeds", () => { + const html = renderMarkdown("`components/MarkdownBody.tsx:12` 和 `D:\\My Project\\报告.md`", { cwd: "D:/repo" }); + assert.match(html, /]*>components\/MarkdownBody.tsx:12<\/code>/); + assert.ok(html.includes("D:\\My Project\\报告.md")); + assert.doesNotMatch(html, / { + const html = renderMarkdown("文件: /home/me/project/report.md,另见 components/MarkdownBody.tsx。"); + assert.ok(html.includes("文件: /home/me/project/report.md,另见 components/MarkdownBody.tsx。")); + assert.doesNotMatch(html, / { + assert.doesNotMatch(renderMarkdown("```text\n/home/me/project/report.md\n```\n\n`hello`"), / { const html = renderMarkdown("[docs](https://example.com/docs)"); @@ -36,14 +82,13 @@ test("opens non-file markdown links in a safe new tab", () => { assert.doesNotMatch(html, /\snode=/); }); -test("keeps local file markdown links in the app", () => { +test("also requires validation for explicit local markdown links", () => { const relativeHtml = renderMarkdown("[file](components/MarkdownBody.tsx)"); const fileUrlHtml = renderMarkdown("[report](file:///home/me/project/report.html)"); - assert.match(relativeHtml, /file<\/a>/); - assert.doesNotMatch(relativeHtml, /target=|rel=|\snode=/); - assert.match(fileUrlHtml, /report<\/a>/); - assert.doesNotMatch(fileUrlHtml, /target=|rel=|\snode=/); + assert.match(relativeHtml, />file<\/p>/); + assert.match(fileUrlHtml, />report<\/p>/); + assert.doesNotMatch(relativeHtml + fileUrlHtml, / { diff --git a/components/MarkdownBody.tsx b/components/MarkdownBody.tsx index d81df80cf..1c91cd351 100644 --- a/components/MarkdownBody.tsx +++ b/components/MarkdownBody.tsx @@ -1,9 +1,11 @@ "use client"; -import { useMemo, type MouseEvent } from "react"; +import { useMemo } from "react"; import ReactMarkdown, { type Components } from "react-markdown"; -import { resolveLocalFileHref, shouldOpenLocalFileInApp } from "@/lib/file-links"; +import { resolveLocalFileHref } from "@/lib/file-links"; +import { LocalFileLink } from "./LocalFileLink"; import { encodeFilePathForApi } from "@/lib/file-paths"; +import { remarkFileLinks } from "@/lib/remark-file-links"; import { markdownRehypePlugins, markdownRemarkPlugins, markdownUrlTransform, normalizeDisplayMath } from "@/lib/markdown"; import { MermaidBlock, CodeBlock } from "./MermaidBlock"; @@ -17,6 +19,9 @@ interface MarkdownBodyProps { export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile }: MarkdownBodyProps) { const normalizedMarkdown = useMemo(() => normalizeDisplayMath(children), [children]); + const remarkPlugins = useMemo(() => onOpenFile + ? [...(markdownRemarkPlugins ?? []), [remarkFileLinks, { cwd }] as [typeof remarkFileLinks, { cwd?: string }]] + : markdownRemarkPlugins, [cwd, onOpenFile]); // Stable renderer identities keep stateful blocks mounted across message hover updates. const components = useMemo(() => ({ code({ className, children, ...props }) { @@ -48,6 +53,7 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile return <>{children}; }, a({ href, children, ...props }) { + const fullPathLabel = props.node?.properties.dataFilePathLabel; // `node` is react-markdown metadata, not a DOM attribute. delete props.node; const filePath = onOpenFile ? resolveLocalFileHref(href, cwd) : null; @@ -60,18 +66,18 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile ); } - const handleClick = (event: MouseEvent) => { - if (!shouldOpenLocalFileInApp(event)) return; - const target = event.currentTarget.getAttribute("target"); - if (target && target !== "_self") return; - event.preventDefault(); - openFile(filePath); - }; - return ( - + {children} - + ); }, img({ src, alt, ...props }) { @@ -96,7 +102,7 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile return (
{ + let status = 404; + let calls = 0; + t.mock.method(globalThis, "fetch", async (url) => { + calls++; + assert.match(url, /^\/api\/files\/.*\?type=meta$/); + return new Response(null, { status }); + }); + for (const code of [404, 403, 400, 500]) { + status = code; + assert.equal(await validateFileLink("/project/foo.ts"), false); + } + status = 200; + assert.equal(await validateFileLink("/project/foo.ts"), true); + assert.equal(calls, 5); +}); + +test("deduplicates concurrent validation and fails closed on network errors", async (t) => { + let calls = 0; + t.mock.method(globalThis, "fetch", async () => { calls++; throw new Error("offline"); }); + assert.deepEqual(await Promise.all([validateFileLink("/p/a"), validateFileLink("/p/a")]), [false, false]); + assert.equal(calls, 1); +}); + +test("candidate links preserve original text, with full labels stored separately", () => { + const tree = { type: "root", children: [{ type: "paragraph", children: [ + { type: "inlineCode", value: "on/start/input" }, + { type: "inlineCode", value: "src/main.ts:12" }, + ] }] }; + remarkFileLinks({ cwd: "D:/project" })(tree); + const nodes = tree.children[0].children; + assert.equal(nodes[0].children[0].value, "on/start/input"); + assert.equal(nodes[1].children[0].value, "src/main.ts:12"); + assert.equal(nodes[1].data.hProperties.dataFilePathLabel, "D:/project/src/main.ts:12"); +}); diff --git a/lib/file-link-validation.ts b/lib/file-link-validation.ts new file mode 100644 index 000000000..eda87e91a --- /dev/null +++ b/lib/file-link-validation.ts @@ -0,0 +1,36 @@ +import { encodeFilePathForApi } from "./file-paths"; + +// Share in-flight requests across repeated references, but do not cache missing +// files: an agent may create them later in the same conversation. +const pending = new Map>(); +const waiters: Array<() => void> = []; +let activeRequests = 0; + +async function checkMetadata(filePath: string): Promise { + // Long histories can contain hundreds of candidates; avoid flooding the server. + if (activeRequests >= 6) await new Promise((resolve) => waiters.push(resolve)); + else activeRequests++; + try { + const response = await fetch(`/api/files/${encodeFilePathForApi(filePath)}?type=meta`, { + cache: "no-store", + signal: AbortSignal.timeout(10000), + }); + return response.ok; + } catch { + return false; + } finally { + const next = waiters.shift(); + if (next) next(); + else activeRequests--; + } +} + +export function validateFileLink(filePath: string): Promise { + const existing = pending.get(filePath); + if (existing) return existing; + const request = checkMetadata(filePath).finally(() => { + pending.delete(filePath); + }); + pending.set(filePath, request); + return request; +} diff --git a/lib/markdown.ts b/lib/markdown.ts index e7ccfff0f..66bff742a 100644 --- a/lib/markdown.ts +++ b/lib/markdown.ts @@ -10,6 +10,7 @@ const markdownSanitizeSchema = { ...defaultSchema, attributes: { ...defaultSchema.attributes, + a: [...(defaultSchema.attributes?.a ?? []), "dataFilePathLabel"], code: [["className", /^language-./, "math-inline", "math-display"]], }, protocols: { diff --git a/lib/remark-file-links.ts b/lib/remark-file-links.ts new file mode 100644 index 000000000..9abcc75f2 --- /dev/null +++ b/lib/remark-file-links.ts @@ -0,0 +1,62 @@ +import { resolveLocalFileHref } from "./file-links"; + +interface MarkdownNode { + type: string; + value?: string; + url?: string; + data?: { hProperties: { dataFilePathLabel: string } }; + children?: MarkdownNode[]; +} + +function fileLink(value: string, cwd?: string, inlineCode = false): MarkdownNode | null { + // Be conservative: do not turn commands, URLs, or arbitrary inline code into links. + if (/\r|\n/.test(value) || !/^(?:[a-zA-Z]:[\\/]|\\\\|\/(?!\/)|\.\.?\/|[\w.@-]+\/|[\w@-]+\.[\w-]+$)/.test(value)) return null; + if (!inlineCode && /\s/.test(value)) return null; + const path = resolveLocalFileHref(value, cwd); + if (!path) return null; + const suffix = value.match(/(:\d+(?::\d+)?|#L\d+(?:-L?\d+)?)$/)?.[0] ?? ""; + // file: URLs survive markdown sanitization and preserve spaces and URL delimiters. + const url = path.startsWith("//") + ? `file://${path.slice(2).split("/").map(encodeURIComponent).join("/")}` + : `file://${path.startsWith("/") ? "" : "/"}${path.split("/").map(encodeURIComponent).join("/")}`; + return { + type: "link", + url, + data: { hProperties: { dataFilePathLabel: path + suffix } }, + children: [{ type: inlineCode ? "inlineCode" : "text", value }], + }; +} + +/** Link filesystem references without rewriting fenced code, math, or existing links. */ +export function remarkFileLinks({ cwd }: { cwd?: string } = {}) { + return (tree: MarkdownNode) => { + const walk = (parent: MarkdownNode) => { + if (!parent.children || ["link", "linkReference", "code", "html", "image", "imageReference"].includes(parent.type)) return; + parent.children = parent.children.flatMap((node): MarkdownNode[] => { + if (node.type === "inlineCode") return [fileLink(node.value ?? "", cwd, true) ?? node]; + if (node.type !== "text") { + walk(node); + return [node]; + } + const text = node.value ?? ""; + const result: MarkdownNode[] = []; + // Plain paths cannot contain whitespace; use backticks for paths with spaces. + const pattern = /(^|[\s::,,;;((【「“"'])((?:[a-zA-Z]:[\\/]|\\\\|\/(?!\/)|\.\.?\/|[\w.@-]+\/)[^\s<>"'`,。;!?、()【】「」“”]+)/g; + let offset = 0; + for (const match of text.matchAll(pattern)) { + const start = match.index! + match[1].length; + const value = match[2].replace(/[.,;!:)\]}]+$/, ""); + const link = fileLink(value, cwd); + if (!link) continue; + if (start > offset) result.push({ type: "text", value: text.slice(offset, start) }); + result.push(link); + offset = start + value.length; + } + if (!result.length) return [node]; + if (offset < text.length) result.push({ type: "text", value: text.slice(offset) }); + return result; + }); + }; + walk(tree); + }; +}