Skip to content
Draft
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
80 changes: 80 additions & 0 deletions crates/ccteam-web/web/src/components/Markdown.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Markdown } from "./Markdown";

let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(() => root.unmount());
container.remove();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

describe("Markdown code copy", () => {
it("adds an accessible icon for each block and copies only its exact code", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
await act(() => root.render(<Markdown content={'Intro\n\n```ts\nconst x = "<value>";\n```\n\n```\nsecond\n```'} />));
const buttons = container.querySelectorAll<HTMLButtonElement>("pre button");
expect(buttons).toHaveLength(2);
expect(buttons[0].getAttribute("aria-label")).toBe("复制代码");
expect(buttons[0].querySelector("svg")).not.toBeNull();
await act(() => root.render(<Markdown className="updated" content={'Intro\n\n```ts\nconst x = "<value>";\n```\n\n```\nsecond\n```'} />));
expect(container.querySelectorAll("pre button")).toHaveLength(2);
await act(() => buttons[0].click());
expect(writeText).toHaveBeenCalledWith('const x = "<value>";\n');
expect(buttons[0].getAttribute("aria-label")).toBe("已复制");
expect(buttons[1].getAttribute("aria-label")).toBe("复制代码");
});

it("uses the shared fallback and reports a failed copy honestly", async () => {
Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined });
const exec = vi.fn().mockReturnValue(false);
Object.defineProperty(document, "execCommand", { configurable: true, value: exec });
await act(() => root.render(<Markdown content={'```\nhello\n```'} />));
const button = container.querySelector("button")!;
await act(() => button.click());
expect(exec).toHaveBeenCalledWith("copy");
expect(button.getAttribute("aria-label")).toBe("复制失败,点击重试");
});
});

describe("Markdown file previews", () => {
it("replaces document hrefs with minted same-site previews while retaining ordinary links", async () => {
const resolve = vi.fn().mockResolvedValue("/api/v1/projects/team/files/preview/fresh/docs/issue.md");
const origin = window.location.origin;
await act(() => root.render(<Markdown
content={`[issue](${origin}/repo/docs/issue.md) [web](https://other.example/a.md) [line](/repo/README.md:12)`}
resolveFilePreview={resolve}
/>));
expect(resolve).toHaveBeenCalledExactlyOnceWith({ path: "/repo/docs/issue.md" });
const links = container.querySelectorAll("a");
expect(links[0].getAttribute("href")).toBe("/api/v1/projects/team/files/preview/fresh/docs/issue.md");
expect(links[0].target).toBe("_blank");
expect(links[1].getAttribute("href")).toBe("https://other.example/a.md");
expect(links[2].getAttribute("href")).toBe("/repo/README.md:12");
});

it("renews normal, modified and middle-click previews after backend tickets are lost", async () => {
const resolve = vi.fn().mockResolvedValue("/api/v1/projects/team/files/preview/old/README.md");
const replace = vi.fn();
vi.spyOn(window, "open").mockReturnValue({ opener: null, location: { replace }, close: vi.fn() } as unknown as Window);
await act(() => root.render(<Markdown content="[doc](README.md)" resolveFilePreview={resolve} />));
resolve.mockResolvedValue("/api/v1/projects/team/files/preview/new/README.md");
const link = container.querySelector("a")!;
for (const [type, init] of [["click", {}], ["click", { ctrlKey: true }], ["auxclick", { button: 1 }]] as const) {
await act(() => { link.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, ...init })); });
expect(resolve).toHaveBeenLastCalledWith({ path: "README.md" }, true);
expect(replace).toHaveBeenLastCalledWith("/api/v1/projects/team/files/preview/new/README.md");
}
});
});
118 changes: 88 additions & 30 deletions crates/ccteam-web/web/src/components/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,69 +4,127 @@
// button is added to the DOM AFTER the sanitized innerHTML, so it never rides
// through DOMPurify / can't be injected by message content).

import { useEffect, useMemo, useRef } from "react";
import { useEffect, useMemo, useRef, type MouseEvent } from "react";
import { renderMarkdown } from "../lib/markdown";
import { fileLinkTarget, type FileTarget } from "../lib/filesApi";
import { fileLinkTarget, supportsFilePreview, type FileTarget } from "../lib/filesApi";
import { copyText } from "../lib/clipboard";

// Static SVG only; never interpolate message content into button markup.
const COPY_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
const CHECK_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m5 12 4 4L19 6"/></svg>';
const ERROR_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m6 6 12 12M6 18 18 6"/></svg>';

export function Markdown({
content,
className,
onFileOpen,
resolveFilePreview,
}: {
content: string;
className?: string;
onFileOpen?: (target: FileTarget) => void;
resolveFilePreview?: (target: FileTarget, renew?: boolean) => Promise<string | null>;
}) {
const ref = useRef<HTMLDivElement>(null);
const previewLinks = useRef(new WeakMap<Element, FileTarget>());
const ordinaryLinks = useRef(new WeakSet<Element>());
const html = useMemo(() => renderMarkdown(content), [content]);
// React otherwise reapplies a new innerHTML object on parent updates,
// removing the post-render controls without rerunning the html effect.
const markup = useMemo(() => ({ __html: html }), [html]);

useEffect(() => {
const root = ref.current;
if (!root) return;
const timers = new Set<number>();
const buttons: HTMLButtonElement[] = [];
root.querySelectorAll("pre").forEach((pre) => {
if (pre.querySelector(".code-copy")) return;
pre.classList.add("has-copy");
const btn = document.createElement("button");
btn.type = "button";
btn.className = "code-copy";
btn.textContent = "复制";
btn.addEventListener("click", () => {
const code =
pre.querySelector("code")?.textContent ?? pre.textContent ?? "";
void navigator.clipboard?.writeText(code);
btn.textContent = "已复制";
window.setTimeout(() => {
btn.textContent = "复制";
}, 1200);
const label = (text: string, icon: string) => {
btn.setAttribute("aria-label", text);
btn.title = text;
btn.innerHTML = icon;
};
label("复制代码", COPY_ICON);
btn.addEventListener("click", async () => {
const code = pre.querySelector("code")?.textContent ?? "";
const copied = await copyText(code);
if (!root.contains(btn)) return;
label(copied ? "已复制" : "复制失败,点击重试", copied ? CHECK_ICON : ERROR_ICON);
const timer = window.setTimeout(() => {
label("复制代码", COPY_ICON);
timers.delete(timer);
}, 1600);
timers.add(timer);
});
pre.appendChild(btn);
buttons.push(btn);
});
return () => {
timers.forEach(window.clearTimeout);
buttons.forEach((button) => button.remove());
};
}, [html]);

useEffect(() => {
const root = ref.current;
if (!root || !resolveFilePreview) return;
let active = true;
root.querySelectorAll<HTMLAnchorElement>("a[href]").forEach((link) => {
const target = previewLinks.current.get(link) ?? fileLinkTarget(link.getAttribute("href") ?? "", window.location.origin);
if (!target || target.line || !supportsFilePreview(target.path)) return;
void resolveFilePreview(target).then((url) => {
if (!active) return;
if (!url) { ordinaryLinks.current.add(link); return; }
previewLinks.current.set(link, target);
link.setAttribute("href", url);
link.target = "_blank";
link.rel = "noopener noreferrer";
}).catch(() => {
// A failed preview keeps the ordinary file-workspace click path.
});
});
return () => { active = false; };
}, [html, resolveFilePreview]);

const openLink = (event: MouseEvent<HTMLDivElement>) => {
const link = (event.target as Element).closest("a[href]");
if (!link || ordinaryLinks.current.has(link)) return;
const preview = previewLinks.current.get(link);
if (preview && resolveFilePreview) {
event.preventDefault();
// Each activation mints a fresh capability: TTL and daemon restarts
// invalidate old tickets, including on modified and middle clicks.
const page = window.open("about:blank", "_blank");
if (page) page.opener = null;
void resolveFilePreview(preview, true).then((url) => {
if (page && url) {
link.setAttribute("href", url);
page.location.replace(url);
} else { page?.close(); onFileOpen?.(preview); }
}).catch(() => { page?.close(); onFileOpen?.(preview); });
return;
}
if (!onFileOpen || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
const target = fileLinkTarget(link.getAttribute("href") ?? "", window.location.origin);
if (target) {
event.preventDefault();
onFileOpen(target);
}
};

return (
<div
ref={ref}
className={`cockpit-markdown ${className ?? ""}`}
onClick={(event) => {
if (
!onFileOpen ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.altKey
)
return;
const link = (event.target as Element).closest("a[href]");
const target = link
? fileLinkTarget(link.getAttribute("href") ?? "")
: null;
if (target) {
event.preventDefault();
onFileOpen(target);
}
}}
onClick={openLink}
onAuxClick={(event) => { if (event.button === 1) openLink(event); }}
// html is renderMarkdown() output = marked + DOMPurify.sanitize.
dangerouslySetInnerHTML={{ __html: html }}
dangerouslySetInnerHTML={markup}
/>
);
}
12 changes: 7 additions & 5 deletions crates/ccteam-web/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1140,7 +1140,12 @@ html.dark .cockpit-markdown a { color: #67e8f9; }
position: absolute;
top: 0.3rem;
right: 0.3rem;
padding: 0.15rem 0.45rem;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
padding: 0.3rem;
font-family: var(--font-sans);
font-size: 0.65rem;
line-height: 1.3;
Expand All @@ -1149,11 +1154,8 @@ html.dark .cockpit-markdown a { color: #67e8f9; }
border: 1px solid var(--border);
border-radius: 0.3rem;
cursor: pointer;
opacity: 0;
transition: opacity 0.12s;
}
.cockpit-markdown pre.has-copy:hover .code-copy,
.cockpit-markdown .code-copy:focus-visible { opacity: 1; }
.cockpit-markdown pre.has-copy { padding-top: 2.2rem; }
.cockpit-markdown .code-copy:hover { color: var(--text); }
.cockpit-markdown hr { border: none; border-top: 1px solid var(--border-soft); margin: 0.75rem 0; }
.cockpit-markdown .cockpit-table-wrap {
Expand Down
52 changes: 51 additions & 1 deletion crates/ccteam-web/web/src/lib/filesApi.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
fileLinkTarget,
createFilePreviewResolver,
openFileTab,
projectRelativePath,
supportsFilePreview,
} from "./filesApi";

describe("project file navigation", () => {
it("recognizes file URLs on the current origin without hardcoding a host", () => {
for (const origin of ["https://console.example", "http://localhost:7331"]) {
expect(fileLinkTarget(`${origin}/repo/docs/issue.md`, origin)).toEqual({
path: "/repo/docs/issue.md",
});
expect(fileLinkTarget(`${origin}/api/v1/projects/a`, origin)).toBeNull();
expect(fileLinkTarget("https://elsewhere.example/repo/issue.md", origin)).toBeNull();
}
});
it("shares preview controls between HTML and Markdown documents", () => {
for (const path of [
"README.md",
Expand Down Expand Up @@ -73,3 +83,43 @@ describe("project file navigation", () => {
expect(projectRelativePath("/repo", p)).toBeNull();
});
});

describe("chat document preview links", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});

it("resolves project paths and shares fresh tickets, then renews expired ones", async () => {
vi.useFakeTimers();
let minted = 0;
const fetcher = vi.fn(async (_url: string, init?: RequestInit) =>
new Response(JSON.stringify(init?.method === "POST"
? { url: `/api/v1/projects/team/files/preview/t${++minted}/docs/issue.md`, expires_in: 3600 }
: { root: "/repo", paths: [], git_status: {}, truncated: false })),
);
vi.stubGlobal("fetch", fetcher);
const resolve = createFilePreviewResolver("team");
const first = await Promise.all([
resolve({ path: "/repo/docs/issue.md" }),
resolve({ path: "docs/issue.md" }),
]);
expect(first).toEqual(Array(2).fill("/api/v1/projects/team/files/preview/t1/docs/issue.md"));
expect(fetcher).toHaveBeenCalledTimes(2);
expect(JSON.parse(fetcher.mock.calls[1][1]?.body as string)).toEqual({ path: "docs/issue.md" });
vi.advanceTimersByTime(3600_000);
expect(await resolve({ path: "/repo/docs/issue.md" })).toContain("/preview/t2/");
expect(await resolve({ path: "/repo/docs/issue.md" }, true)).toContain("/preview/t3/");
});

it("does not mint previews for sources, line links or files outside the project", async () => {
const fetcher = vi.fn(async () => new Response(JSON.stringify({ root: "/repo" })));
vi.stubGlobal("fetch", fetcher);
const resolve = createFilePreviewResolver("team");
expect(await resolve({ path: "src/lib.rs" })).toBeNull();
expect(await resolve({ path: "README.md", line: 10 })).toBeNull();
expect(await resolve({ path: "/repository/issue.md" })).toBeNull();
expect(await resolve({ path: "../issue.md" })).toBeNull();
expect(fetcher).toHaveBeenCalledTimes(1);
});
});
43 changes: 42 additions & 1 deletion crates/ccteam-web/web/src/lib/filesApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,18 @@ export const mintFilePreview = (

/** Local Markdown references only; ordinary web/navigation links retain their
* browser behavior. Project containment is checked again by the server. */
export function fileLinkTarget(href: string): FileTarget | null {
export function fileLinkTarget(href: string, origin?: string): FileTarget | null {
// Replies may spell a local path as an absolute URL on this console.
// Compare origins, never a deployment hostname or a string prefix.
if (origin && /^(?:https?:)?\/\//i.test(href)) {
try {
const url = new URL(href, origin);
if (url.origin !== origin) return null;
href = `${url.pathname}${url.search}${url.hash}`;
} catch {
return null;
}
}
if (
/^(?:https?|ftp|javascript|data|blob|mailto|tel|file):/i.test(href) ||
/^[a-z][a-z\d+.-]*:\/\//i.test(href) ||
Expand Down Expand Up @@ -91,6 +102,36 @@ export function fileLinkTarget(href: string): FileTarget | null {
return { path, ...(line && line > 0 ? { line } : {}) };
}

/** One resolver per mounted conversation: share root discovery and preview
* requests across its messages, without caching capabilities across logins. */
export function createFilePreviewResolver(slug: string) {
let root: Promise<string> | undefined;
const previews = new Map<string, { expires: number; url: Promise<string> }>();
return async (target: FileTarget, renew = false): Promise<string | null> => {
if (target.line || !supportsFilePreview(target.path)) return null;
root ??= getProjectFiles(slug).then((tree) => tree.root).catch((error: unknown) => {
root = undefined;
throw error;
});
const path = projectRelativePath(await root, target.path);
if (!path) return null;
const cached = previews.get(path);
if (cached && (cached.expires === Infinity || (!renew && cached.expires > Date.now()))) return cached.url;
const entry = {
expires: Infinity,
url: mintFilePreview(slug, path).then((preview) => {
entry.expires = Date.now() + Math.max(0, preview.expires_in * 1000 - 5000);
return preview.url;
}).catch((error: unknown) => {
previews.delete(path);
throw error;
}),
};
previews.set(path, entry);
return entry.url;
};
}

export interface FileTab {
path: string;
pinned: boolean;
Expand Down
Loading
Loading