Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/api/files/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down
37 changes: 37 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
21 changes: 21 additions & 0 deletions components/DirectoryViewer.test.mjs
Original file line number Diff line number Diff line change
@@ -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]*?<DirectoryViewer/);
assert.match(directory, /\?type=list/);
assert.match(directory, /onOpenFile\?\.\(childPath\)/);
assert.match(directory, /controller\.abort\(\)/);
});
52 changes: 52 additions & 0 deletions components/DirectoryViewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"use client";

import { useEffect, useState } from "react";
import { encodeFilePathForApi } from "@/lib/file-paths";
import { FolderIcon, getFileIcon } from "./FileIcons";
import { useI18n } from "@/hooks/useI18n";

interface Entry { name: string; isDir: boolean }

export function DirectoryViewer({ filePath, onOpenFile }: {
filePath: string;
onOpenFile?: (path: string) => 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 (
<div style={{ height: "100%", overflow: "auto", padding: 16 }}>
<div style={{ fontFamily: "var(--font-mono)", overflowWrap: "anywhere", marginBottom: 16 }} title={filePath}>{filePath}</div>
{result?.path !== filePath ? <div>{t("files.loading")}</div> : result.error ? (
<div role="alert" style={{ color: "var(--text-muted)" }}>{result.error}</div>
) : (
<div>
{result.entries?.length === 0 && <div style={{ color: "var(--text-muted)" }}>{t("files.noFiles")}</div>}
{result.entries?.map((entry) => {
const childPath = `${filePath.replace(/[\\/]+$/, "")}/${entry.name}`;
return (
<button key={entry.name} type="button" className="directory-viewer-entry" title={childPath}
disabled={!onOpenFile} onClick={() => onOpenFile?.(childPath)}>
{entry.isDir ? <FolderIcon size={16} /> : getFileIcon(entry.name, 16)}
<span>{entry.name}</span>
</button>
);
})}
</div>
)}
</div>
);
}
30 changes: 29 additions & 1 deletion components/FileViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1076,7 +1077,34 @@ function DocumentViewer({ filePath, cwd, sourceSessionId, watchEnabled = true }:
);
}

export function FileViewer({
export function FileViewer(props: Props) {
return <PathViewer key={`${props.sourceSessionId ?? ""}:${props.filePath}`} {...props} />;
}

function PathViewer(props: Props) {
const { t } = useI18n();
const [kind, setKind] = useState<"file" | "directory" | null>(null);
const [error, setError] = useState<string | null>(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 <div role="alert" style={{ padding: 16 }}>{error}</div>;
if (!kind) return <div style={{ padding: 16 }}>{t("files.loading")}</div>;
if (kind === "directory") return <DirectoryViewer filePath={props.filePath} onOpenFile={props.onOpenFile} />;
return <FileContentViewer {...props} />;
}

function FileContentViewer({
filePath,
cwd,
sourceSessionId,
Expand Down
50 changes: 50 additions & 0 deletions components/LocalFileLink.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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<HTMLAnchorElement>) => {
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 (
<a className="markdown-local-file-link" href={href} title={fullPathLabel ?? filePath} target={target} onClick={handleClick}>
{children}
</a>
);
}
57 changes: 51 additions & 6 deletions components/MarkdownBody.test.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 = {}) {
Expand All @@ -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, /<code>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, /<code[^>]*>components\/MarkdownBody.tsx:12<\/code>/);
assert.ok(html.includes("D:\\My Project\\报告.md"));
assert.doesNotMatch(html, /<a |D:\/repo/);
});

test("preserves plain paths and punctuation while validation is pending", () => {
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, /<a /);
});

test("does not autolink code blocks, ordinary inline code, or paths without a handler", () => {
assert.doesNotMatch(renderMarkdown("```text\n/home/me/project/report.md\n```\n\n`hello`"), /<a /);
assert.doesNotMatch(renderMarkdown("`src/index.ts` /home/me/project/report.md", { onOpenFile: undefined }), /<a /);
const html = renderMarkdown("[report](/home/me/project/report.md)");
assert.doesNotMatch(html, /<a /);
});

test("opens non-file markdown links in a safe new tab", () => {
const html = renderMarkdown("[docs](https://example.com/docs)");

Expand All @@ -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, /<a href="components\/MarkdownBody\.tsx">file<\/a>/);
assert.doesNotMatch(relativeHtml, /target=|rel=|\snode=/);
assert.match(fileUrlHtml, /<a href="file:\/\/\/home\/me\/project\/report\.html">report<\/a>/);
assert.doesNotMatch(fileUrlHtml, /target=|rel=|\snode=/);
assert.match(relativeHtml, />file<\/p>/);
assert.match(fileUrlHtml, />report<\/p>/);
assert.doesNotMatch(relativeHtml + fileUrlHtml, /<a |target=|rel=|\snode=/);
});

test("keeps file URLs inert without an in-app file handler", () => {
Expand Down
32 changes: 19 additions & 13 deletions components/MarkdownBody.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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<Components>(() => ({
code({ className, children, ...props }) {
Expand Down Expand Up @@ -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;
Expand All @@ -60,18 +66,18 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile
);
}

const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
if (!shouldOpenLocalFileInApp(event)) return;
const target = event.currentTarget.getAttribute("target");
if (target && target !== "_self") return;
event.preventDefault();
openFile(filePath);
};

return (
<a href={href} {...props} onClick={handleClick}>
<LocalFileLink
key={filePath}
href={href}
filePath={filePath}
title={props.title}
target={props.target}
fullPathLabel={typeof fullPathLabel === "string" ? fullPathLabel : undefined}
onOpenFile={openFile}
>
{children}
</a>
</LocalFileLink>
);
},
img({ src, alt, ...props }) {
Expand All @@ -96,7 +102,7 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile
return (
<div className={["markdown-body", className].filter(Boolean).join(" ")}>
<ReactMarkdown
remarkPlugins={markdownRemarkPlugins}
remarkPlugins={remarkPlugins}
rehypePlugins={markdownRehypePlugins}
urlTransform={onOpenFile ? markdownUrlTransform : undefined}
components={components}
Expand Down
Loading