diff --git a/.gitattributes b/.gitattributes index bdb3376b27..9ec1c1bb7f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -30,3 +30,4 @@ Makefile text eol=lf .githooks/** text eol=lf benchmarks/**/*.sh text eol=lf internal/runtime/agent/testdata/perseveration/** -text +desktop/frontend-next/perf/media/delivery.pdf binary diff --git a/desktop/electron/test/document_preview_live.js b/desktop/electron/test/document_preview_live.js new file mode 100644 index 0000000000..db092734cf --- /dev/null +++ b/desktop/electron/test/document_preview_live.js @@ -0,0 +1,81 @@ +"use strict"; +const os = require("node:os"); +const path = require("node:path"); +const fs = require("node:fs"); +const { app } = require("electron"); +const { checker, scriptedModel, listen, seedHome, settledWindow, until } = require("./livekit"); +const { pdf } = require("../../frontend-next/perf/document-fixtures.cjs"); +const { check, failures } = checker(); + +async function main() { + const workspace = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "rx-documents-"))); + fs.writeFileSync(path.join(workspace, "方案.md"), "# Delivery report\n\nThe file opens in the workbench."); + fs.writeFileSync(path.join(workspace, "table.csv"), 'id,value\n001,"hello\nworld"\n002,=SUM(A1:A2)'); + fs.writeFileSync(path.join(workspace, "page.html"), '

Static document

'); + fs.writeFileSync(path.join(workspace, "report.pdf"), pdf()); + let issued = false; + const provider = scriptedModel(() => { + if (issued) return null; + issued = true; + return { name: "present_files", arguments: { files: [ + { path: "方案.md", description: "Delivery report" }, + { path: "table.csv", description: "Data" }, + { path: "page.html", description: "Static document" }, + { path: "report.pdf", description: "PDF document" }, + ] } }; + }); + const model = await listen(provider.handler); + const home = fs.mkdtempSync(path.join(os.tmpdir(), "rx-document-home-")); + process.env.REASONIX_HOME = home; + seedHome(home, model.url, workspace); + const configPath = path.join(home, "config.toml"); + fs.writeFileSync(configPath, fs.readFileSync(configPath, "utf8").replace("[desktop]", '[desktop]\nlanguage = "zh"')); + const { current } = require("../src/main.js"); + const win = await settledWindow(); + win.webContents.session.webRequest.onBeforeRequest((details, callback) => { + const url = new URL(details.url); + callback({ cancel: ["http:", "https:"].includes(url.protocol) && url.hostname !== "127.0.0.1" && url.hostname !== "localhost" }); + }); + const { client } = current(); + const runtimes = await until("workspace runtime", async () => { + const rows = await client.json("GET", "/runtimes"); + return rows?.length ? rows : null; + }); + const base = runtimes[0].base; + const js = (code) => win.webContents.executeJavaScript(code); + await until("visible application", () => js("!document.getElementById('boot') && !!document.querySelector('.compose')")); + await client.request("POST", `${base}/submit`, { input: "Present the four existing files: 方案.md, table.csv, page.html, report.pdf." }); + await until("delivery cards", () => js("document.querySelectorAll('.delivery-open').length === 4"), 60000); + check("real tool execution presents four files", true); + await js("document.querySelector('.delivery-open').click()"); + await until("rendered Markdown", () => js("document.querySelector('.workbench-read h1')?.textContent === 'Delivery report'")); + check("card opens the actual workspace document", true); + const history = await client.json("GET", `${base}/history`); + check("history includes confirmed file deliveries", history.some((row) => row.deliveries?.length === 4)); + await js("[...document.querySelectorAll('.delivery-open')].find(b => b.textContent.includes('table.csv')).click()"); + await until("CSV table from raw host bytes", () => js("document.querySelector('.document-table')?.textContent.includes('001')")); + check("packaged CSV worker renders literal cells", await js("document.querySelector('.document-table').textContent.includes('=SUM(A1:A2)')")); + await js("[...document.querySelectorAll('.delivery-open')].find(b => b.textContent.includes('page.html')).click()"); + await until("static HTML frame", () => js("!!document.querySelector('iframe[title=\"HTML 静态预览\"]')")); + check("static HTML keeps scripts disabled", await js("!document.querySelector('iframe[title=\"HTML 静态预览\"]').sandbox.contains('allow-scripts') && !window.__unsafe")); + await js("[...document.querySelectorAll('.delivery-open')].find(b => b.textContent.includes('report.pdf')).click()"); + await until("offline PDF worker and text layer", () => js("document.querySelector('.textLayer')?.textContent.includes('Report preview')")); + check("production PDF worker and fonts work without external network", true); + fs.writeFileSync("/tmp/reasonix-native-document-preview.png", (await win.webContents.capturePage()).toPNG()); + const loaded = new Promise((resolve) => win.webContents.once("did-finish-load", resolve)); + win.webContents.reload(); + await loaded; + await until("visible application after reload", () => js("!document.getElementById('boot') && !!document.querySelector('.compose')").catch(() => false)); + await until("restored delivery cards", () => js("document.querySelectorAll('.delivery-open').length === 4").catch(() => false)); + check("delivery cards survive window reload", true); + await js("document.querySelector('.delivery-open').click()"); + await until("reopened report", () => js("document.querySelector('.workbench-read h1')?.textContent === 'Delivery report'")); + check("restored card opens the same file", true); + model.server.close(); + app.exit(failures.length ? 1 : 0); +} + +app.whenReady().then(main).catch((err) => { + process.stderr.write(`${err.stack || err}\n`); + app.exit(1); +}); diff --git a/desktop/frontend-next/package.json b/desktop/frontend-next/package.json index 518b3d0220..21fa7866eb 100644 --- a/desktop/frontend-next/package.json +++ b/desktop/frontend-next/package.json @@ -37,10 +37,14 @@ "@mantine/hooks": "9.7.2", "@noble/curves": "2.4.0", "codemirror": "^6.0.2", + "dompurify": "^3.4.17", "katex": "^0.19.0", + "lucide-react": "^1.60.0", "maplibre-gl": "6.14.0", "react": "^19.3.1", "react-dom": "^19.3.1", + "papaparse": "^5.7.0", + "pdfjs-dist": "^6.4.299", "react-map-gl": "8.1.3", "react-markdown": "^10.1.0", "recharts": "3.10.1", @@ -57,6 +61,7 @@ "@babel/parser": "^8.0.7", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.8", + "@types/papaparse": "^5.5.2", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.2", diff --git a/desktop/frontend-next/perf/delivery-data.ts b/desktop/frontend-next/perf/delivery-data.ts new file mode 100644 index 0000000000..7e2d6b8017 --- /dev/null +++ b/desktop/frontend-next/perf/delivery-data.ts @@ -0,0 +1,23 @@ +import type { HistoryMessage } from "../src/port/session"; +import pdfText from "./media/delivery.pdf?raw"; + +export const deliveryFiles = [ + { path: "方案.md", description: "Markdown 方案文档,包含正文、表格和指向其他示例文件的链接。" }, + { path: "数据.csv", description: "CSV 数据示例,含表头、多行字段、引号转义、公式样式和空字段。" }, + { path: "报告.html", description: "静态 HTML 报告页面,可用于对比静态预览与本地交互运行效果。" }, + { path: "示例.pdf", description: "PDF 示例文件,可测试分页、缩放和文字选择等预览能力。" }, +]; +export const deliveryContents: Record = { + "方案.md": "# 文件交付与预览体验\n\n点击聊天中的交付卡片,可在右侧打开对应文件。\n\n| 示例文件 | 体验内容 |\n| --- | --- |\n| 方案.md | Markdown 正文与表格 |\n| 数据.csv | 表格与源码 |\n| 报告.html | 静态 HTML |\n| 示例.pdf | 分页与文字选择 |", + "数据.csv": '编号,内容\n001,"第一行\n第二行"\n002,=SUM(A1:A2)', + "报告.html": "

交付文件可以直接阅读

文件交付预览示例。

", + "示例.pdf": pdfText, +}; +export function deliveryHistory(): HistoryMessage[] { + return [ + { role: "user", content: "请展示这四份文件,方便我直接预览。", msgIndex: 0 }, + { role: "assistant", content: "", toolCalls: [{ id: "delivery", name: "present_files" }] }, + { role: "tool", content: "Presented files", toolCallId: "delivery", toolName: "present_files", deliveries: deliveryFiles }, + { role: "assistant", content: "四份文件已准备好。点击卡片在右侧预览,其他操作可从卡片的更多菜单进入。" }, + ]; +} diff --git a/desktop/frontend-next/perf/document-fixtures.cjs b/desktop/frontend-next/perf/document-fixtures.cjs new file mode 100644 index 0000000000..f6e626194f --- /dev/null +++ b/desktop/frontend-next/perf/document-fixtures.cjs @@ -0,0 +1,24 @@ +"use strict"; +function pdf() { + const objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 500] /Resources << /Font << /F1 7 0 R >> >> /Contents 4 0 R >>", + "BT /F1 24 Tf 40 440 Td (Report preview) Tj ET", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 500] /Resources << /Font << /F1 7 0 R >> >> /Contents 6 0 R >>", + "BT /F1 24 Tf 40 440 Td (Second page) Tj ET", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + ]; + let data = "%PDF-1.4\n"; + const offsets = [0]; + objects.forEach((body, i) => { + offsets.push(Buffer.byteLength(data)); + if (i === 3 || i === 5) body = `<< /Length ${body.length} >>\nstream\n${body}\nendstream`; + data += `${i + 1} 0 obj\n${body}\nendobj\n`; + }); + const xref = Buffer.byteLength(data); + data += `xref\n0 8\n0000000000 65535 f \n`; + data += offsets.slice(1).map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`).join(""); + return data + `trailer\n<< /Size 8 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF`; +} +module.exports = { pdf }; diff --git a/desktop/frontend-next/perf/document-preview.mjs b/desktop/frontend-next/perf/document-preview.mjs new file mode 100644 index 0000000000..2eeefdf8b7 --- /dev/null +++ b/desktop/frontend-next/perf/document-preview.mjs @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { chromium } from "playwright"; + +import { pdf } from "./document-fixtures.cjs"; + +const browser = await chromium.launch(); +let page; +try { + page = await browser.newPage({ viewport: { width: 1440, height: 960 } }); + const errors = []; + const requests = []; + page.on("pageerror", (err) => errors.push(err.message)); + page.on("console", (msg) => { if (msg.type() === "error") console.error(msg.text()); }); + page.on("request", (req) => requests.push(req.url())); + await page.goto(process.env.PERF_URL ?? "http://127.0.0.1:4399/perf.html?pref=zh", { waitUntil: "networkidle" }); + await page.waitForFunction(() => window.__panes?.() > 0); + await page.evaluate(async (pdf) => { + const data = { + "report.pdf": pdf, + "broken.pdf": "%PDF-1.7\ninvalid document", + "table.csv": 'id,value\n001,"hello\nworld"\n002,"=SUM(A1:A2)"\n' + Array.from({ length: 500 }, (_, n) => `${n},row ${n}`).join("\n"), + "page.html": '

Static report

Preview stays in the workbench.

', + }; + window.__previewFiles(Object.fromEntries(Object.entries(data).map(([path, text]) => [ + path, { text, kind: path.endsWith("pdf") ? "pdf" : path.endsWith("csv") ? "table" : "html" }, + ]))); + window.__feed({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "Create reports" }); + window.__feed({ kind: "tool_dispatch", tool: { id: "present", name: "present_files", readOnly: true } }); + window.__feed({ kind: "tool_result", tool: { id: "present", name: "present_files", readOnly: true, + output: "Presented files", deliveries: Object.keys(data).map((path) => ({ path, description: "Preview fixture" })) } }); + window.__feed({ kind: "text", text: "Reports are ready." }); + window.__feed({ kind: "turn_done" }); + }, pdf()); + await page.locator(".delivery-open").filter({ hasText: "report.pdf" }).click(); + await page.locator(".textLayer").getByText("Report preview", { exact: true }).waitFor(); + assert.equal(await page.locator(".document-pdf-page canvas").evaluate((el) => el.width > 0 && el.height > 0), true); + for (const [selector, label] of [ + ['[data-action="share.card"]', "设备访问"], + ['[data-action="transcript.find"]', "在这段对话里查找"], + ['[data-action="appearance.theme"]', "切换深色主题"], + ['.chrome [data-action="settings.section"][data-value="tools"]', "沙盒与运行边界"], + ['[data-action="pane.view"][data-value="analysis"]', "打开运行分析"], + ['[data-action="pane.view"][data-value="browser"]', "关闭工作台"], + ['[data-action="browser.wide"]', "加宽工作台"], + ['[data-action="browser.full"]', "全屏工作台"], + ['[data-action="workbench.files"]', "展开文件树"], + ['[data-action="workbench.new"]', "新建标签"], + [".acts .copy", "复制回复"], + ['[data-action="reply.quote"]', "引用到输入框"], + ['[data-action="reply.more"]', "更多"], + ]) { + const button = page.locator(selector).last(); + await button.hover(); + const tip = page.locator(".icon-tooltip"); + await tip.filter({ hasText: label }).waitFor(); + assert.equal(await button.getAttribute("title"), null); + const box = await tip.boundingBox(); + assert.ok(box.x >= 0 && box.y >= 0 && box.x + box.width <= 1440 && box.y + box.height <= 960); + if (selector.includes("browser.wide")) await page.screenshot({ path: "/tmp/reasonix-tooltip-workbench.png" }); + if (selector.includes("reply.quote")) await page.screenshot({ path: "/tmp/reasonix-tooltip-reply.png" }); + await page.mouse.move(1, 1); + await tip.waitFor({ state: "detached" }); + } + await page.locator('[data-action="browser.wide"]').focus(); + await page.getByRole("tooltip").filter({ hasText: "加宽工作台" }).waitFor(); + await page.keyboard.press("Escape"); + assert.equal(await page.locator(".icon-tooltip").count(), 0); + await page.getByRole("button", { name: "下一页", exact: true }).click(); + await page.locator(".textLayer").getByText("Second page", { exact: true }).waitFor(); + await page.screenshot({ path: "/tmp/reasonix-preview-pdf.png" }); + await page.locator(".delivery-open").filter({ hasText: "table.csv" }).click(); + await page.getByRole("table", { name: "table.csv" }).waitFor(); + assert.equal(await page.locator(".document-table td").filter({ hasText: "=SUM(A1:A2)" }).count(), 1); + assert.ok(await page.locator(".document-table tr").count() < 100); + await page.locator(".document-table").evaluate((el) => { el.scrollTop = 6000; }); + await page.waitForFunction(() => document.querySelector(".document-table th")?.textContent !== "1"); + await page.screenshot({ path: "/tmp/reasonix-preview-csv.png" }); + await page.locator(".delivery-open").filter({ hasText: "page.html" }).click(); + const frame = page.frameLocator('iframe[title="HTML 静态预览"]'); + await frame.getByRole("heading", { name: "Static report" }).waitFor(); + assert.equal(await page.evaluate(() => window.__unsafe), undefined); + assert.equal(requests.some((url) => url.includes("preview-test.invalid")), false); + await page.screenshot({ path: "/tmp/reasonix-preview-html.png" }); + await page.locator(".delivery-open").filter({ hasText: "report.pdf" }).click(); + await page.locator(".textLayer").getByText("Second page", { exact: true }).waitFor(); + assert.equal(await page.locator('[role="tab"][data-target="file:report.pdf"]').count(), 1); + await page.locator(".delivery-open").filter({ hasText: "broken.pdf" }).click(); + await page.getByRole("alert").filter({ hasText: "PDF 无法显示" }).waitFor(); + assert.equal(await page.locator(".document-pdf-page").isVisible(), false); + assert.deepEqual(errors, []); + console.log("Document previews: PDF text/navigation/tab reuse/corruption, virtual CSV, isolated HTML and delivery clicks passed."); +} catch (error) { + if (page) { + await page.screenshot({ path: "/tmp/reasonix-preview-failure.png" }); + console.error(await page.locator(".document-preview").innerText().catch(() => "No preview")); + } + throw error; +} finally { + await browser.close(); +} diff --git a/desktop/frontend-next/perf/harness.tsx b/desktop/frontend-next/perf/harness.tsx index cccc809a5b..0b23ef0266 100644 --- a/desktop/frontend-next/perf/harness.tsx +++ b/desktop/frontend-next/perf/harness.tsx @@ -56,6 +56,8 @@ import type { UpdateProgress } from "../src/port/version"; import type { ThemePack } from "../src/port/look"; import type { RuntimeView, TreeWindow, TreeWorkspace } from "../src/port/hub"; import type { WireEvent } from "../src/port/wire"; +import type { PreviewInfo } from "../src/port/workspace"; +import { deliveryContents, deliveryHistory } from "./delivery-data"; // Saves are coalesced (src/ui/trailing.ts), and a fixture answers too fast for // that to show: coalesced and not look identical at zero latency. Counting the @@ -96,6 +98,8 @@ function benchPack(): ThemePack | null { } class BenchPort extends MockPort { + opensFiles() { return query.has("deliveries") || super.opensFiles(); } + revealsFiles() { return query.has("deliveries") || super.revealsFiles(); } // ?mcpactions=1 adds a server whose tools are withheld and one that is followed. constructor() { super(); @@ -184,6 +188,7 @@ class BenchPort extends MockPort { } async workspaceFile(path: string, root?: string) { + if (query.has("deliveries") && path in deliveryContents) return { path, content: deliveryContents[path], revision: "delivery-fixture" }; if (root) { const content = "# Release plan\n\nThe plan lives in the library folder you granted this project.\n\n- freeze on Friday\n- tag after the checks are green\n- announce the next morning\n"; return { path, content, revision: "r1", root, readOnly: true }; @@ -309,7 +314,7 @@ class BenchPort extends MockPort { // that read from a file already on disk, so the size of the answer — not the // stream — is what a switch costs. async history(): Promise { - return storedHistory(); + return query.has("deliveries") ? deliveryHistory() : storedHistory(); } // ?queue= fills the pending queue to the count under measurement. Seeded @@ -552,6 +557,7 @@ declare global { // conversations really cost. __feed: (ev: WireEvent) => void; __panes: () => number; + __previewFiles: (files: Record) => void; // What folding a stored transcript into cards costs on its own, with no // frame in it: the step between the read answering and the first paint. __foldCost: () => number; @@ -568,6 +574,15 @@ declare global { window.__toast = pushToast; window.__feed = (ev) => hub.feeds.forEach((p) => p.feed(ev)); window.__panes = () => hub.feeds.size; +window.__previewFiles = (files) => hub.feeds.forEach((port) => { + port.workspacePreviewInfo = async (ref) => ({ + ref, kind: files[ref.path].kind, size: new TextEncoder().encode(files[ref.path].text).length, + version: "fixture", limit: 32 << 20, readOnly: false, + }); + port.workspacePreviewContent = async (ref) => ({ + data: new TextEncoder().encode(files[ref.path].text).buffer, version: "fixture", + }); +}); window.__refuse = (method, message) => { (hub as unknown as Record)[method] = () => Promise.reject(new Error(message)); }; diff --git a/desktop/frontend-next/perf/media/delivery.pdf b/desktop/frontend-next/perf/media/delivery.pdf new file mode 100644 index 0000000000..ed7ea8992d --- /dev/null +++ b/desktop/frontend-next/perf/media/delivery.pdf @@ -0,0 +1,43 @@ +%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 500] /Resources << /Font << /F1 7 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 45 >> +stream +BT /F1 24 Tf 40 440 Td (Report preview) Tj ET +endstream +endobj +5 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 500] /Resources << /Font << /F1 7 0 R >> >> /Contents 6 0 R >> +endobj +6 0 obj +<< /Length 42 >> +stream +BT /F1 24 Tf 40 440 Td (Second page) Tj ET +endstream +endobj +7 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +xref +0 8 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000121 00000 n +0000000247 00000 n +0000000342 00000 n +0000000468 00000 n +0000000560 00000 n +trailer +<< /Size 8 /Root 1 0 R >> +startxref +630 +%%EOF diff --git a/desktop/frontend-next/pnpm-lock.yaml b/desktop/frontend-next/pnpm-lock.yaml index 1d5d9bf993..b984088c7c 100644 --- a/desktop/frontend-next/pnpm-lock.yaml +++ b/desktop/frontend-next/pnpm-lock.yaml @@ -83,12 +83,24 @@ importers: codemirror: specifier: ^6.0.2 version: 6.0.2 + dompurify: + specifier: ^3.4.17 + version: 3.4.17 katex: specifier: ^0.19.0 version: 0.19.0 + lucide-react: + specifier: ^1.60.0 + version: 1.60.0(@types/react@19.3.0)(react@19.3.1) maplibre-gl: specifier: 6.14.0 version: 6.14.0 + papaparse: + specifier: ^5.7.0 + version: 5.7.0 + pdfjs-dist: + specifier: ^6.4.299 + version: 6.4.299 react: specifier: ^19.3.1 version: 19.3.1 @@ -138,6 +150,9 @@ importers: '@testing-library/user-event': specifier: ^14.6.8 version: 14.6.8(@testing-library/dom@10.4.1) + '@types/papaparse': + specifier: ^5.5.2 + version: 5.5.2 '@types/react': specifier: ^19.3.0 version: 19.3.0 @@ -146,7 +161,7 @@ importers: version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react': specifier: ^6.1.2 - version: 6.1.2(vite@8.3.4(esbuild@0.28.2)) + version: 6.1.2(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)) jsdom: specifier: ^30.1.3 version: 30.1.3(@noble/hashes@2.4.0) @@ -158,10 +173,10 @@ importers: version: 7.0.2 vite: specifier: ^8.3.4 - version: 8.3.4(esbuild@0.28.2) + version: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2) vitest: specifier: ^5.0.4 - version: 5.0.4(jsdom@30.1.3(@noble/hashes@2.4.0))(vite@8.3.4(esbuild@0.28.2)) + version: 5.0.4(@types/node@26.6.5)(jsdom@30.1.3(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)) packages: @@ -625,6 +640,81 @@ packages: '@marijn/find-cluster-break@1.0.4': resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==} + '@napi-rs/canvas-android-arm64@1.0.12': + resolution: {integrity: sha512-Z8hg9BD7ABaZNhdM8O96IrfBaYLM7KzO/YyKA8eU9BDdc5fZJRTWyOhSAyOv59/ari6bOYa+p96h7zN6wbJRDA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@1.0.12': + resolution: {integrity: sha512-dRtZCi/u1IOZyRjEDCA73PqgWakf5689K2jfv4uPRYGMJvEqtV+Eq7MIQkBJLmdfUPe7AktSiBZRml2pggnjRQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@1.0.12': + resolution: {integrity: sha512-YwvMgK5NBXwPY1AjZGXfPCG3hp/OFGM0Kw+JZHCOR/FYaeSOftaZRyxaDEGkJQSLEUZZIMNJXKCWgZYSzgWZEA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.12': + resolution: {integrity: sha512-LlOsZy8qh+qJq1euEvDZqa9muACS1fL1Ag38c7J+axAnA8NeXazMtqmUyKb4NVyft6DqbU/MesEj3C0bAFnv4Q==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@1.0.12': + resolution: {integrity: sha512-nvuqhi85GzaFl/6sid4qf7wwjHuGU6gfIuDyywUb/fB+kp6Fp3h8GyIAKss/QPil3zVO2r8g6O9ah+IOUoBrIw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-arm64-musl@1.0.12': + resolution: {integrity: sha512-Bf6+9PoEr9K06P/oyMab7zZOpW90KimbIuNsy/vL0VhZZKsb0B0rCC78cp0jXhK6PIM7v8t6uX1sn7isOZ8KEw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-linux-riscv64-gnu@1.0.12': + resolution: {integrity: sha512-j7VcZduLSRx60nv/wbdyH+fyB5QriWGuO8GrvMDlFlt7GNUKAw5lHAHpcuuQW3QwLOe+3P+3qryzyT3sW5pCqQ==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-gnu@1.0.12': + resolution: {integrity: sha512-twDb8ZNgoCF8EG6ka+Lk4etU+ea9lv6NiV5ptIf+thz3LgWuHBbt85c+YgL1s05VsHe4WYWK0f+GhR45IuqARg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-musl@1.0.12': + resolution: {integrity: sha512-mtZbBeNCdGA0MYQxivPmMOp5JMH80LaJLQeEMg7Nne517fvgGb+DWTJTmoE/qUW8YJ4nkiP2vQyr1XVGrH/Ktg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-win32-arm64-msvc@1.0.12': + resolution: {integrity: sha512-HgGRuBUkFL8U99ogwUnTz3SmwUu7WKjmj0eOVWRqVZTPPyfWGEIMr+ycb8oDoNZcKM9vSiF1rpy3r+UIqqaKqw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@1.0.12': + resolution: {integrity: sha512-SEEfblIAnYeS4UkeFPp9jw6ZWuHxaTqumUuksMgnMXmPLW8Ofb21uu7cujhw+lk4E9V3aWgGAE9f8g+CdoAU0Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@1.0.12': + resolution: {integrity: sha512-PSwhRdvizfuiSDlhE4Ld2jl/JqkrbSFFyZO0ycamE6mBpf1H0uUF3Tm8yQaVDeAwCNNDzxjRfrN22KmWLTDwcw==} + engines: {node: '>= 10'} + '@noble/curves@2.4.0': resolution: {integrity: sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==} engines: {node: '>= 20.19.0'} @@ -840,6 +930,12 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@26.6.5': + resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==} + + '@types/papaparse@5.5.2': + resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==} + '@types/react-dom@19.3.0': resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} peerDependencies: @@ -1204,6 +1300,9 @@ packages: dompurify@3.4.11: resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} + dompurify@3.4.17: + resolution: {integrity: sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==} + earcut@3.2.4: resolution: {integrity: sha512-FHc6yNpnTORB0tM5kmZ03iD2mDuNakDE2lvf4Hw1XkfxtEHIGVi01B/GqUSwx+NcC1WGjidAfpTXzeALtPtfpg==} @@ -1514,6 +1613,15 @@ packages: resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} engines: {node: 20 || >=22} + lucide-react@1.60.0: + resolution: {integrity: sha512-UqxOW0FgAfDyb9VmjKb6/BkOzW6yfhf/QNboKtS2NDsscnNRW2CDzlyhbMKBQ1ontmcxuIDqHJ2AVgFR3NI+GA==} + peerDependencies: + '@types/react': '*' + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -1691,6 +1799,9 @@ packages: resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} engines: {node: '>=12.20.0'} + papaparse@5.7.0: + resolution: {integrity: sha512-qBGxg/7Q3Kl9Wfhrz2Z74UnvnHTXLNG6jmKJFeBvP2+y4lV7So+7SR62+Zd47JvdrCkX+nDcnr0ObPzek/+6RA==} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -1704,6 +1815,10 @@ packages: resolution: {integrity: sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==} hasBin: true + pdfjs-dist@6.4.299: + resolution: {integrity: sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g==} + engines: {node: '>=22.13.0 || >=24'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2021,6 +2136,9 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + undici@8.12.1: resolution: {integrity: sha512-usz3OVQBeh1LxNn0DzU0voLXMgn5CU/WUdpBsY6Ti5afNao+vTkmn4H/NxoXolc10cOqe2+YIG0Hspq2rsl64w==} engines: {node: '>=22.19.0'} @@ -2740,6 +2858,54 @@ snapshots: '@marijn/find-cluster-break@1.0.4': {} + '@napi-rs/canvas-android-arm64@1.0.12': + optional: true + + '@napi-rs/canvas-darwin-arm64@1.0.12': + optional: true + + '@napi-rs/canvas-darwin-x64@1.0.12': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.12': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@1.0.12': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@1.0.12': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@1.0.12': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@1.0.12': + optional: true + + '@napi-rs/canvas-linux-x64-musl@1.0.12': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@1.0.12': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@1.0.12': + optional: true + + '@napi-rs/canvas@1.0.12': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 1.0.12 + '@napi-rs/canvas-darwin-arm64': 1.0.12 + '@napi-rs/canvas-darwin-x64': 1.0.12 + '@napi-rs/canvas-linux-arm-gnueabihf': 1.0.12 + '@napi-rs/canvas-linux-arm64-gnu': 1.0.12 + '@napi-rs/canvas-linux-arm64-musl': 1.0.12 + '@napi-rs/canvas-linux-riscv64-gnu': 1.0.12 + '@napi-rs/canvas-linux-x64-gnu': 1.0.12 + '@napi-rs/canvas-linux-x64-musl': 1.0.12 + '@napi-rs/canvas-win32-arm64-msvc': 1.0.12 + '@napi-rs/canvas-win32-x64-msvc': 1.0.12 + optional: true + '@noble/curves@2.4.0': dependencies: '@noble/hashes': 2.4.0 @@ -2895,6 +3061,14 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node@26.6.5': + dependencies: + undici-types: 8.9.0 + + '@types/papaparse@5.5.2': + dependencies: + '@types/node': 26.6.5 + '@types/react-dom@19.3.0(@types/react@19.3.0)': dependencies: '@types/react': 19.3.0 @@ -2986,19 +3160,19 @@ snapshots: optionalDependencies: maplibre-gl: 6.14.0 - '@vitejs/plugin-react@6.1.2(vite@8.3.4(esbuild@0.28.2))': + '@vitejs/plugin-react@6.1.2(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.3.4(esbuild@0.28.2) + vite: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2) - '@vitest/mocker@5.0.4(vite@8.3.4(esbuild@0.28.2))': + '@vitest/mocker@5.0.4(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2))': dependencies: '@jridgewell/trace-mapping': 0.3.31 '@vitest/spy': 5.0.4 estree-walker: 3.0.3 magic-string: 1.4.3 optionalDependencies: - vite: 8.3.4(esbuild@0.28.2) + vite: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2) '@vitest/spy@5.0.4': {} @@ -3145,6 +3319,10 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 + dompurify@3.4.17: + optionalDependencies: + '@types/trusted-types': 2.0.7 + earcut@3.2.4: {} entities@4.5.0: {} @@ -3504,6 +3682,12 @@ snapshots: lru-cache@11.5.3: {} + lucide-react@1.60.0(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + lz-string@1.5.0: {} magic-string@1.4.3: @@ -3922,6 +4106,8 @@ snapshots: obug@3.0.0: {} + papaparse@5.7.0: {} + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -3944,6 +4130,10 @@ snapshots: dependencies: resolve-protobuf-schema: 2.1.0 + pdfjs-dist@6.4.299: + optionalDependencies: + '@napi-rs/canvas': 1.0.12 + picocolors@1.1.1: {} picomatch@4.0.7: {} @@ -4323,6 +4513,8 @@ snapshots: uc.micro@2.1.0: {} + undici-types@8.9.0: {} + undici@8.12.1: {} unified@11.0.5: @@ -4428,7 +4620,7 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite@8.3.4(esbuild@0.28.2): + vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -4436,13 +4628,14 @@ snapshots: rolldown: 1.2.12 tinyglobby: 0.2.17 optionalDependencies: + '@types/node': 26.6.5 esbuild: 0.28.2 fsevents: 2.3.3 - vitest@5.0.4(jsdom@30.1.3(@noble/hashes@2.4.0))(vite@8.3.4(esbuild@0.28.2)): + vitest@5.0.4(@types/node@26.6.5)(jsdom@30.1.3(@noble/hashes@2.4.0))(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.4(vite@8.3.4(esbuild@0.28.2)) + '@vitest/mocker': 5.0.4(vite@8.3.4(@types/node@26.6.5)(esbuild@0.28.2)) chai: 6.3.0 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -4453,9 +4646,10 @@ snapshots: tinybench: 6.2.2 tinyexec: 1.3.1 tinyglobby: 0.2.17 - vite: 8.3.4(esbuild@0.28.2) + vite: 8.3.4(@types/node@26.6.5)(esbuild@0.28.2) why-is-node-running: 3.2.1 optionalDependencies: + '@types/node': 26.6.5 jsdom: 30.1.3(@noble/hashes@2.4.0) transitivePeerDependencies: - msw diff --git a/desktop/frontend-next/src/i18n/en.ts b/desktop/frontend-next/src/i18n/en.ts index a23d0a0322..f3bcc115ba 100644 --- a/desktop/frontend-next/src/i18n/en.ts +++ b/desktop/frontend-next/src/i18n/en.ts @@ -1,5 +1,6 @@ import { EN_SURFACES } from "./en_surfaces"; import { EN_COMPOSITION } from "./en_composition"; +import { EN_DOCUMENT } from "./en_document"; // English catalogue, keyed by the Chinese source text. A key that is missing // here renders as Chinese rather than as a blank, so an incomplete translation // degrades instead of breaking — and i18n.test.ts is what stops it from @@ -25,6 +26,7 @@ import { EN_SPONSOR } from "./en_sponsor"; export const EN: Record = { ...EN_COMPOSITION, + ...EN_DOCUMENT, "此检查点不包含文件快照": "This checkpoint contains no file snapshots", ...EN_SURFACES, "重命名聊天": "Rename chat", diff --git a/desktop/frontend-next/src/i18n/en_document.ts b/desktop/frontend-next/src/i18n/en_document.ts new file mode 100644 index 0000000000..28efee5d19 --- /dev/null +++ b/desktop/frontend-next/src/i18n/en_document.ts @@ -0,0 +1,44 @@ +export const EN_DOCUMENT: Record = { + "预览 {name}": "Preview {name}", + "更多操作:{name}": "More actions for {name}", + "文件操作:{name}": "File actions for {name}", + "查看中": "Viewing", + "项目根目录": "Project root", + "已添加到输入框": "Added to the composer", + "已请求系统应用打开": "Requested opening in the system application", + "已请求显示文件位置": "Requested showing the file location", + "已复制路径": "Path copied", + "加宽工作台": "Widen workbench", + "恢复分栏": "Restore split view", + "全屏工作台": "Expand workbench", + "退出工作台全屏": "Exit expanded workbench", + "展开文件树": "Show file tree", + "收起文件树": "Hide file tree", + "打开运行分析": "Open run analysis", + "关闭运行分析": "Close run analysis", + "打开工作台": "Open workbench", + "关闭工作台": "Close workbench", + "磁盘内容已变化,未保存的编辑已保留": "The file changed on disk. Your unsaved edits have been preserved.", + "静态预览,脚本和外部资源未加载": "Static preview. Scripts and external resources are not loaded.", + "HTML 静态预览": "Static HTML preview", + "表格无法解析或超过 10 万个单元格,请切换源码查看": "The table is invalid or exceeds 100,000 cells. Switch to source to inspect it.", + "PDF 无法显示,文件可能损坏或需要密码": "Cannot display this PDF. It may be damaged or require a password.", + "此文件暂不支持预览": "Preview is not available for this file.", + "文件超过预览大小限制": "This file exceeds the preview size limit.", + "用系统应用打开": "Open in system application", + "交付文件": "Delivered files", + "添加到对话": "Add to chat", + "在文件夹中显示": "Show in folder", + "交互运行": "Run interactively", + "上一页": "Previous page", + "下一页": "Next page", + "缩放": "Zoom", + "适应宽度": "Fit width", + "缩小": "Zoom out", + "放大": "Zoom in", + "没有访问此文件的权限": "You do not have access to this file.", + "只能预览普通文件": "Only regular files can be previewed.", + "文件不是有效的 UTF-8 文本": "The file is not valid UTF-8 text.", + "读取期间文件发生变化,请刷新": "The file changed while being read. Refresh to try again.", + "文件预览读取失败,请重试": "Could not read the file preview. Try again.", +}; diff --git a/desktop/frontend-next/src/i18n/kernel.ts b/desktop/frontend-next/src/i18n/kernel.ts index b25cb56250..4dc9e4633a 100644 --- a/desktop/frontend-next/src/i18n/kernel.ts +++ b/desktop/frontend-next/src/i18n/kernel.ts @@ -35,6 +35,13 @@ const SAID: Record = { "agent_composition.conflict": "预设已被其他操作修改,草稿已保留,请重新加载后再保存", "agent_composition.not_found": "找不到该 Agent 预设", "agent_composition.read_only": "内置预设只读,请复制后编辑", + "workspace.preview_denied": "没有访问此文件的权限", + "workspace.preview_not_file": "只能预览普通文件", + "workspace.preview_too_large": "文件超过预览大小限制", + "workspace.preview_not_text": "文件不是有效的 UTF-8 文本", + "workspace.preview_unsupported": "此文件暂不支持预览", + "workspace.preview_changed": "读取期间文件发生变化,请刷新", + "workspace.preview_failed": "文件预览读取失败,请重试", "surface.value_invalid": "填写内容不符合字段要求,请检查后重试", "surface.expired": "界面已失效,请等待新的请求", "surface.schema_invalid": "界面描述不符合约定", diff --git a/desktop/frontend-next/src/port/mock.ts b/desktop/frontend-next/src/port/mock.ts index 9dbc3fd010..49ac843665 100644 --- a/desktop/frontend-next/src/port/mock.ts +++ b/desktop/frontend-next/src/port/mock.ts @@ -386,8 +386,6 @@ export class MockPort extends MockWorkspace implements AgentPort { if (path === ".reasonix") return { files: [".reasonix/REASONIX.local.md"], directories: [] }; return hidden ? { files: [".gitignore", "README.md"], directories: [".reasonix", "internal"] } : { files: ["README.md"], directories: ["internal"] }; } - async workspaceFile(path: string, _root?: string): Promise { return { path, content: `// ${path}\n`, revision: "fixture" }; } - workspaceImageURL(path: string) { return `/fixture/${path}`; } async saveWorkspaceFile(file: import("./port").WorkspaceFile) { return { ...file, revision: file.revision + "-saved" }; } async trajectory(): Promise { diff --git a/desktop/frontend-next/src/port/mock_workspace.ts b/desktop/frontend-next/src/port/mock_workspace.ts index 5e3aac14ff..05f20b5b81 100644 --- a/desktop/frontend-next/src/port/mock_workspace.ts +++ b/desktop/frontend-next/src/port/mock_workspace.ts @@ -20,6 +20,17 @@ export function mockWorktreeSources(root: string, branch: string): import("./wor } export class MockWorkspace extends MockToolOutput { + async workspaceFile(path: string, _root?: string): Promise { return { path, content: `// ${path}\n`, revision: "fixture" }; } + async workspacePreviewInfo(ref: import("./workspace").FileRef): Promise { + const file = await this.workspaceFile(ref.path, ref.root); + const { previewKind } = await import("../ui/document/registry"); + return { ref, kind: previewKind(ref.path), size: new TextEncoder().encode(file.content).length, version: file.revision, readOnly: !!ref.root, limit: 2 << 20 }; + } + async workspacePreviewContent(ref: import("./workspace").FileRef) { + const file = await this.workspaceFile(ref.path, ref.root); + return { data: new TextEncoder().encode(file.content).buffer, version: file.revision }; + } + workspaceImageURL(path: string) { return `/fixture/${path}`; } opensFiles() { return false; } async openFile() {} diff --git a/desktop/frontend-next/src/port/port.ts b/desktop/frontend-next/src/port/port.ts index b58397cb02..7db370eb3a 100644 --- a/desktop/frontend-next/src/port/port.ts +++ b/desktop/frontend-next/src/port/port.ts @@ -31,7 +31,7 @@ import type { ContextBreakdown, ShellOption, ShellSettings } from "./shell"; import type { SkillCatalog, SkillEntry, SkillUpdateChange, SkillUpdatePlan } from "./skill"; import type { SubagentRun, SubagentTranscript } from "./subagent"; import type { StudioCrash, StudioLogs, UpdateMode, UpdateProgress, VersionEntry, VersionHub, VersionNotes } from "./version"; -import type { ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, DiscardRequest, GitCommitFiles, GitRepos, PushRequest, PushResult, PushPlan, StageRequest, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo } from "./workspace"; +import type { ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, DiscardRequest, GitCommitFiles, GitRepos, PushRequest, PushResult, PushPlan, StageRequest, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo, WorkspacePreviewPort } from "./workspace"; // The port is one contract; its subjects each keep their own file, the way the // wire and the layers below already do. This is where a reader still finds @@ -43,7 +43,7 @@ export type { AccountState, AccountUser, ApprovalDefault, ApprovalMode, Approval MemoryEntry, ModelEntry, ModelMode, ModelPrice, NetworkProbe, NetworkSettings, Preset, RewindPlan, RewindResult, RewindUndo, RewindScope, RoleAssignments, RoleOverride, ScopeLayer, SessionEntry, SessionStatus, SessionUsage, SourceUsage, ShellOption, ShellSettings, SkillCatalog, SkillEntry, SkillUpdateChange, SkillUpdatePlan, UpdateProgress, VersionEntry, - StudioCrash, StudioLogs, UpdateMode, VersionHub, VersionNotes, WalletLine, WalletReading, ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, DiscardRequest, GitCommitFiles, GitRepos, PushRequest, PushResult, PushPlan, StageRequest, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo }; + StudioCrash, StudioLogs, UpdateMode, VersionHub, VersionNotes, WalletLine, WalletReading, ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, DiscardRequest, GitCommitFiles, GitRepos, PushRequest, PushResult, PushPlan, StageRequest, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo, WorkspacePreviewPort }; import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire"; import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan } from "./plugin"; @@ -185,7 +185,7 @@ export interface TrayPrefs { closeToTray: boolean; } -export interface AgentPort { +export interface AgentPort extends WorkspacePreviewPort { providerSetup(): Promise; saveProviderKey(apiKey: string): Promise; // The models that answer the job asked about; "chat" when none is named. diff --git a/desktop/frontend-next/src/port/session.ts b/desktop/frontend-next/src/port/session.ts index d40e21ec2b..f7ead1e59e 100644 --- a/desktop/frontend-next/src/port/session.ts +++ b/desktop/frontend-next/src/port/session.ts @@ -35,6 +35,7 @@ export interface HistoryMessage { // The host's own account of a result that did not succeed. Without it a // rebuilt card has only the words, which a tool's own output can imitate. toolFailed?: boolean; + deliveries?: import("./workspace").DeliveredFile[]; toolRefusalCode?: string; // outputDiff marks a rebuilt shell result whose whole output is a unified // diff, re-derived by the host so a reopened transcript renders it as one. diff --git a/desktop/frontend-next/src/port/sse.ts b/desktop/frontend-next/src/port/sse.ts index 09208c9a2b..24b7c09731 100644 --- a/desktop/frontend-next/src/port/sse.ts +++ b/desktop/frontend-next/src/port/sse.ts @@ -408,6 +408,20 @@ export class SsePort extends SseWorkspace implements AgentPort { return this.get(`/workspace/file?path=${encodeURIComponent(path)}${root ? `&root=${encodeURIComponent(root)}` : ""}`); } + async workspacePreviewInfo(ref: import("./workspace").FileRef, signal?: AbortSignal) { + const query = new URLSearchParams({ path: ref.path, ...(ref.root ? { root: ref.root } : {}) }); + const res = await fetch(`${this.base}/workspace/preview/info?${query}`, { credentials: "same-origin", signal }); + if (!res.ok) await SsePort.fail("/workspace/preview/info", res); + return await res.json() as import("./workspace").PreviewInfo; + } + + async workspacePreviewContent(ref: import("./workspace").FileRef, signal?: AbortSignal) { + const query = new URLSearchParams({ path: ref.path, ...(ref.root ? { root: ref.root } : {}) }); + const res = await fetch(`${this.base}/workspace/preview/content?${query}`, { credentials: "same-origin", signal }); + if (!res.ok) await SsePort.fail("/workspace/preview/content", res); + return { data: await res.arrayBuffer(), version: res.headers.get("X-Preview-Version") ?? "" }; + } + workspaceImageURL(path: string, root?: string) { return `${this.base}/workspace/image?path=${encodeURIComponent(path)}${root ? `&root=${encodeURIComponent(root)}` : ""}`; } diff --git a/desktop/frontend-next/src/port/wire.ts b/desktop/frontend-next/src/port/wire.ts index 96ab66e6cd..250cef0257 100644 --- a/desktop/frontend-next/src/port/wire.ts +++ b/desktop/frontend-next/src/port/wire.ts @@ -106,6 +106,7 @@ export interface Bound { export type ToolIssuer = "model" | "host" | "user" | "provider"; export interface Tool { + deliveries?: import("./workspace").DeliveredFile[]; id?: string; name: string; args?: string; diff --git a/desktop/frontend-next/src/port/workspace.ts b/desktop/frontend-next/src/port/workspace.ts index 5569b9d236..b82397f9d5 100644 --- a/desktop/frontend-next/src/port/workspace.ts +++ b/desktop/frontend-next/src/port/workspace.ts @@ -92,12 +92,36 @@ export interface WorkspaceFile { path: string; content: string; revision: string; + observedVersion?: string; // The granted folder the file was read from; absent for the workspace itself. root?: string; // True for a file read from a granted folder: it can be read here, never saved. readOnly?: boolean; } +export interface FileRef { + path: string; + root?: string; +} + +export interface PreviewInfo { + ref: FileRef; + kind: "markdown" | "text" | "image" | "pdf" | "table" | "html" | "unsupported"; + size: number; + version: string; + limit: number; + readOnly: boolean; +} + +export interface DeliveredFile extends FileRef { + description?: string; +} + +export interface WorkspacePreviewPort { + workspacePreviewInfo(ref: FileRef, signal?: AbortSignal): Promise; + workspacePreviewContent(ref: FileRef, signal?: AbortSignal): Promise<{ data: ArrayBuffer; version: string }>; +} + export interface WorkspaceEntry { path: string; name: string; diff --git a/desktop/frontend-next/src/state/session.ts b/desktop/frontend-next/src/state/session.ts index 6cd2867650..422912fe94 100644 --- a/desktop/frontend-next/src/state/session.ts +++ b/desktop/frontend-next/src/state/session.ts @@ -121,8 +121,6 @@ function sealTurn(items: Item[], err?: string): Item[] { // the flag is the identity, and its err only repeats the sentinel. return [...sealed, { t: "notice" as const, id: nextId(), level: "error", text: err }]; } - - // The card reads the call's own arguments: they carry what was saved and how it // will be recalled, which the tool's textual receipt does not. function parseRemembered(tool: Tool): RememberedFact | null { @@ -782,6 +780,7 @@ export function fromHistory(msgs: HistoryMessage[]): { items: Item[]; executions tool: capToolOutput({ ...prev.tool, output: m.content, + deliveries: m.deliveries, ...(m.outputDiff ? { outputDiff: true } : {}), ...(m.toolFailed ? { err: m.content, refusalCode: m.toolRefusalCode } : {}), }), diff --git a/desktop/frontend-next/src/ui/BrowserFullToggle.tsx b/desktop/frontend-next/src/ui/BrowserFullToggle.tsx index 1cd8887ec2..be2a33bffb 100644 --- a/desktop/frontend-next/src/ui/BrowserFullToggle.tsx +++ b/desktop/frontend-next/src/ui/BrowserFullToggle.tsx @@ -1,14 +1,15 @@ import { t } from "../i18n"; import { StudioIcon } from "./StudioIcon"; import { ariaChord, chord } from "./keys"; +import { TooltipButton } from "./TooltipButton"; export function BrowserFullToggle({ full, onToggle }: { full: boolean; onToggle: () => void }) { - const label = full ? t("退出全屏") : t("全屏浏览器"); + const label = full ? t("退出工作台全屏") : t("全屏工作台"); return ( - + ); } diff --git a/desktop/frontend-next/src/ui/BrowserWideToggle.tsx b/desktop/frontend-next/src/ui/BrowserWideToggle.tsx index 723293ee23..4ff55a883e 100644 --- a/desktop/frontend-next/src/ui/BrowserWideToggle.tsx +++ b/desktop/frontend-next/src/ui/BrowserWideToggle.tsx @@ -1,14 +1,15 @@ import { t } from "../i18n"; import { StudioIcon } from "./StudioIcon"; import { ariaChord, chord } from "./keys"; +import { TooltipButton } from "./TooltipButton"; export function BrowserWideToggle({ wide, onToggle }: { wide: boolean; onToggle: () => void }) { - const label = wide ? t("恢复浏览器分栏") : t("加宽浏览器"); + const label = wide ? t("恢复分栏") : t("加宽工作台"); return ( - + ); } diff --git a/desktop/frontend-next/src/ui/Chrome.tsx b/desktop/frontend-next/src/ui/Chrome.tsx index 7ae0a735ec..49208363ba 100644 --- a/desktop/frontend-next/src/ui/Chrome.tsx +++ b/desktop/frontend-next/src/ui/Chrome.tsx @@ -9,6 +9,7 @@ import { useFolded } from "./usefold"; import { ariaChord, chord } from "./keys"; import { PhonePop } from "./PhonePop"; import { ViewSwitch } from "./ViewSwitch"; +import { TooltipButton } from "./TooltipButton"; import { WindowControls, zoomOnTitleBar } from "./WindowControls"; import type { PaneView } from "../state/paneview"; @@ -123,19 +124,19 @@ export function Chrome({ port, status, title, steer, onSettings, account, host,
{hub && onError && } {port && ( - + )} - - + {port && }
diff --git a/desktop/frontend-next/src/ui/CopyButton.test.tsx b/desktop/frontend-next/src/ui/CopyButton.test.tsx index 6c0ae14e61..8a292e1501 100644 --- a/desktop/frontend-next/src/ui/CopyButton.test.tsx +++ b/desktop/frontend-next/src/ui/CopyButton.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import "./testkit"; import { t } from "../i18n"; @@ -15,7 +15,8 @@ describe("a copy button", () => { it("on a code block, offers to copy the code rather than the response", () => { render(x := 1); const button = screen.getByRole("button", { name: t("复制这段代码") }); - expect(button.getAttribute("title")).toBe(t("复制这段代码")); + fireEvent.focus(button); + expect(screen.getByRole("tooltip").textContent).toBe(t("复制这段代码")); }); it("with a label and visible text, says that label instead of the response's", () => { @@ -27,7 +28,8 @@ describe("a copy button", () => { it("without a label, still offers to copy the response", () => { render(); - expect(screen.getByRole("button").getAttribute("title")).toBe(t("复制回复")); + fireEvent.focus(screen.getByRole("button")); + expect(screen.getByRole("tooltip").textContent).toBe(t("复制回复")); }); }); diff --git a/desktop/frontend-next/src/ui/CopyButton.tsx b/desktop/frontend-next/src/ui/CopyButton.tsx index 467f104a26..08e55ac2ff 100644 --- a/desktop/frontend-next/src/ui/CopyButton.tsx +++ b/desktop/frontend-next/src/ui/CopyButton.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { t } from "../i18n"; import { StudioIcon } from "./StudioIcon"; +import { TooltipButton } from "./TooltipButton"; // Wails serves the window over a custom scheme on macOS and Linux, so those two // hosts are not a secure context and navigator.clipboard is undefined there. @@ -39,9 +40,10 @@ export function CopyButton({ text, iconOnly = false, showFeedback = false, class // A clipboard the host denied is not the same as nothing happening, and the // reader is about to try again — so the failure says so instead of staying idle. const label = state === "done" ? t("已复制") : state === "failed" ? t("复制失败,请重试") : what ?? t("复制回复"); + const Button = iconOnly ? TooltipButton : "button"; return ( - + ); } diff --git a/desktop/frontend-next/src/ui/Markdown.tsx b/desktop/frontend-next/src/ui/Markdown.tsx index 9cd1912803..0c5ef2274b 100644 --- a/desktop/frontend-next/src/ui/Markdown.tsx +++ b/desktop/frontend-next/src/ui/Markdown.tsx @@ -159,6 +159,7 @@ function textOf(node: ReactNode): string { /** File navigation for a workspace document or a conversation. */ export interface LocalRefs { + file?(ref: import("../port/workspace").FileRef): () => void; /** An action for href, or null when it is not a file in the workspace. */ link(href: string): (() => void) | null; /** Where to load src from, or null to leave it as written. */ diff --git a/desktop/frontend-next/src/ui/Pane.tsx b/desktop/frontend-next/src/ui/Pane.tsx index fbe188be6e..8b73e78133 100644 --- a/desktop/frontend-next/src/ui/Pane.tsx +++ b/desktop/frontend-next/src/ui/Pane.tsx @@ -429,8 +429,20 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc const find = useFind(s.items, findPulse, active, useCallback(() => showView("flow"), [showView]), receiptPref, findTogglePulse); const onRunDetail = useCallback(() => showView("analysis"), [showView]); + const previewScope = JSON.stringify([rt.id, rt.root, status?.sessionPath ?? rt.sessionPath]); + const [previewFile, setPreviewFile] = useState<{ port: typeof port; scope: string; path?: string }>(); + const onActiveFile = useCallback((path: string | undefined) => { + setPreviewFile((old) => old?.port === port && old.scope === previewScope && old.path === path + ? old : { port, scope: previewScope, path }); + }, [port, previewScope]); + const activeFile = visible && workbench && previewFile?.port === port && previewFile.scope === previewScope ? previewFile.path : undefined; const { quote, reply, onResend } = useReplyActions({ port, items: s.items, checkpoints, running, model: status?.label, submit: (line) => submit(line).then((r) => r === true), reloadSession, onSettings, onRunDetail, onError: fail, branchName: `${title} · ${t("分支")}`, onBranched: onOpenSession }); - const fileReply = useMemo(() => ({ ...reply, files: conversationFiles.refs }), [reply, conversationFiles.refs]); + const fileReply = useMemo(() => ({ + ...reply, files: conversationFiles.refs, activeFile, + onAddFile: (path: string) => setInserted((was) => ({ n: was.n + 1, text: "@" + path.replace(/[ \t]/g, "\\$&") })), + openDeliveredFile: !rt.host && port.opensFiles() ? (path: string) => port.openDocument(path) : undefined, + revealDeliveredFile: !rt.host && port.revealsFiles() ? (path: string) => port.revealInFileManager(path) : undefined, + }), [reply, conversationFiles.refs, rt.host, port, activeFile]); // Where the bottom is moves as blocks mount under it, so this only asks the // transcript to follow again and lets it scroll itself into place. @@ -563,6 +575,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc manual={manualBrowser} shown={visible && workbench} onSurfaces={setSurfaces} + onActiveFile={onActiveFile} scheme={theme === "light" ? "light" : "dark"} changes={tree?.changes ?? []} onTreeChanged={refreshTree} revealChanges={revealChanges} toggleFiles={toggleFiles} running={running} diff --git a/desktop/frontend-next/src/ui/PhonePop.tsx b/desktop/frontend-next/src/ui/PhonePop.tsx index cb3d5e92cb..001d09faaa 100644 --- a/desktop/frontend-next/src/ui/PhonePop.tsx +++ b/desktop/frontend-next/src/ui/PhonePop.tsx @@ -7,6 +7,7 @@ import { copyText } from "./CopyButton"; import { useDismiss } from "./dismiss"; import { clock, deviceLabel, type Share, useShare } from "./PhoneAccess"; import { Switch } from "./Switch"; +import { TooltipButton } from "./TooltipButton"; /** The chrome's way to put a phone on this window: a code on a card, one * press from anywhere. Drawn only where the kernel has a window to share — @@ -62,7 +63,7 @@ export function PhonePop({ hub }: { hub: HubPort }) { const online = share.st.devices.filter((d) => d.online).length + share.st.cloudDevices.length; return (
- + {open && } {(cloudNote || localNote) &&
{cloudNote || localNote}
}
diff --git a/desktop/frontend-next/src/ui/TooltipButton.test.tsx b/desktop/frontend-next/src/ui/TooltipButton.test.tsx new file mode 100644 index 0000000000..aee79f843a --- /dev/null +++ b/desktop/frontend-next/src/ui/TooltipButton.test.tsx @@ -0,0 +1,80 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { createRef } from "react"; +import { TooltipButton } from "./TooltipButton"; +import { ViewSwitch } from "./ViewSwitch"; + +afterEach(() => { cleanup(); vi.useRealTimers(); }); + +it("waits 400ms for hover and cancels both pending and visible hints on leave", () => { + vi.useFakeTimers(); + render(Copy); + const button = screen.getByRole("button"); + expect(button.hasAttribute("title")).toBe(false); + fireEvent.pointerEnter(button); + act(() => vi.advanceTimersByTime(399)); + expect(screen.queryByRole("tooltip")).toBeNull(); + fireEvent.pointerLeave(button); + act(() => vi.advanceTimersByTime(400)); + expect(screen.queryByRole("tooltip")).toBeNull(); + fireEvent.pointerEnter(button); + act(() => vi.advanceTimersByTime(400)); + expect(screen.getByRole("tooltip").textContent).toBe("复制回复"); + expect(screen.getByRole("tooltip").parentElement).toBe(document.body); + expect(button.getAttribute("aria-describedby")).toBe(screen.getByRole("tooltip").id); + fireEvent.pointerLeave(button); + expect(screen.queryByRole("tooltip")).toBeNull(); +}); + +it("supports keyboard focus, Escape, current-state labels and the existing button ref", () => { + const ref = createRef(); + const clicked = vi.fn(); + const view = render(Wide); + const button = screen.getByRole("button"); + expect(ref.current).toBe(button); + fireEvent.focus(button); + expect(screen.getByRole("tooltip").textContent).toContain("⌘Shift+B"); + view.rerender(Wide); + expect(screen.getByRole("tooltip").textContent).toBe("恢复分栏"); + fireEvent.keyDown(button, { key: "Escape" }); + expect(screen.queryByRole("tooltip")).toBeNull(); + fireEvent.blur(button); + fireEvent.focus(button); + fireEvent.click(button); + expect(clicked).toHaveBeenCalledOnce(); + expect(screen.queryByRole("tooltip")).toBeNull(); +}); + +it("cancels delayed work when the button unmounts", () => { + vi.useFakeTimers(); + const view = render(Files); + fireEvent.pointerEnter(screen.getByRole("button")); + view.unmount(); + act(() => vi.advanceTimersByTime(400)); + expect(screen.queryByRole("tooltip")).toBeNull(); +}); + +it("shows only the latest hint when hover moves away from a keyboard-focused control", () => { + vi.useFakeTimers(); + render(<>QuoteMore); + fireEvent.focus(screen.getByRole("button", { name: "Quote" })); + fireEvent.pointerEnter(screen.getByRole("button", { name: "More" })); + act(() => vi.advanceTimersByTime(400)); + expect(screen.getAllByRole("tooltip")).toHaveLength(1); + expect(screen.getByRole("tooltip").textContent).toBe("更多"); +}); + +it("describes opening and closing the top-bar workbench without changing its control identity", () => { + const onView = vi.fn(); + const view = render(); + const button = screen.getByRole("button", { name: "工作台" }); + fireEvent.focus(button); + expect(screen.getByRole("tooltip").textContent).toContain("打开工作台"); + expect(screen.getByRole("tooltip").textContent).toContain("3"); + view.rerender(); + expect(screen.getByRole("button", { name: "工作台" })).toBe(button); + expect(screen.getByRole("tooltip").textContent).toContain("关闭工作台"); + fireEvent.click(button); + expect(onView).toHaveBeenCalledWith("flow"); +}); diff --git a/desktop/frontend-next/src/ui/TooltipButton.tsx b/desktop/frontend-next/src/ui/TooltipButton.tsx new file mode 100644 index 0000000000..142899d1b8 --- /dev/null +++ b/desktop/frontend-next/src/ui/TooltipButton.tsx @@ -0,0 +1,73 @@ +import { useCallback, useEffect, useId, useImperativeHandle, useRef, useState, type ComponentPropsWithRef } from "react"; +import { createPortal } from "react-dom"; +import { beside, pinToViewport, useFollow } from "./place"; +import "./tooltip.css"; + +type Props = ComponentPropsWithRef<"button"> & { shortcut?: string }; +let dismissActive: (() => void) | undefined; + +export function TooltipButton({ + title, shortcut, ref, children, onPointerEnter, onPointerLeave, onPointerDown, + onFocus, onBlur, onClick, "aria-describedby": describedBy, ...props +}: Props) { + const anchor = useRef(null); + const tip = useRef(null); + const timer = useRef | undefined>(undefined); + const [open, setOpen] = useState(false); + const id = useId(); + useImperativeHandle(ref, () => anchor.current!, []); + const clear = useCallback(() => { clearTimeout(timer.current); }, []); + const close = useCallback(() => { + clear(); setOpen(false); + if (dismissActive === close) dismissActive = undefined; + }, [clear]); + const show = () => { + clear(); + if (!title || props.disabled) return; + dismissActive?.(); + dismissActive = close; + setOpen(true); + }; + useEffect(() => () => { + clear(); + if (dismissActive === close) dismissActive = undefined; + }, [clear, close]); + useEffect(() => { + if (!open) return; + const key = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.stopPropagation(); + close(); + }; + addEventListener("keydown", key, true); + addEventListener("blur", close); + return () => { removeEventListener("keydown", key, true); removeEventListener("blur", close); }; + }, [open, close]); + const place = useCallback(() => { + if (!anchor.current || !tip.current) return; + const rect = anchor.current.getBoundingClientRect(); + const box = tip.current.getBoundingClientRect(); + pinToViewport(tip.current, rect.left + (rect.width - box.width) / 2, beside(rect, box.height, innerHeight, "above", 6)); + }, []); + useFollow(open, place, `${title}:${shortcut}`); + return <> + + {open && title && !props.disabled && createPortal( + , document.body, + )} + ; +} diff --git a/desktop/frontend-next/src/ui/Transcript.tsx b/desktop/frontend-next/src/ui/Transcript.tsx index 00ac7055af..6c037f927c 100644 --- a/desktop/frontend-next/src/ui/Transcript.tsx +++ b/desktop/frontend-next/src/ui/Transcript.tsx @@ -16,6 +16,8 @@ import { revealNested } from "./cards/nestreveal"; import { SurfaceCard, surfaceTool } from "./surface/SurfaceCard"; import { ElicitCard } from "./cards/ElicitCard"; import { SayCard, type ReplyActions } from "./cards/SayCard"; +import { DeliveryCard } from "./document/DeliveryCard"; +import { deliveriesByReply } from "./document/deliveries"; import { CompactionCard } from "./cards/CompactionCard"; import { WorkedLine } from "./cards/WorkedLine"; import { ReadsCard } from "./cards/ReadsCard"; @@ -564,6 +566,8 @@ const Block = memo(function Block({ // messages; transcriptRows folds it and keeps each sentence above its own. const rows = transcriptRows(items, foldWork ? { running: tail } : undefined); const newAnswerAt = useNewAnswerAnchor(); + const deliveries = useMemo(() => deliveriesByReply(items), [items]); + const delivery = (id: string) => deliveries.has(id) ? : null; // Which runs of work are open, held here so re-hosting one under its sentence // does not close it. const [opened, setOpened] = useState>({}); @@ -586,11 +590,14 @@ const Block = memo(function Block({ {...rowProps} cp={checkpoints.get(row.item.id)} settled={!row.activity?.some((item) => item.t === "tool" && item.running)} - afterAnswer={row.activity ? : undefined} + afterAnswer={row.activity || deliveries.has(row.item.id) ? <>{delivery(row.item.id)}{row.activity && } : undefined} /> ) : ( - + + {row.activity.map((item) => {delivery(item.id)})} + + ); }); diff --git a/desktop/frontend-next/src/ui/ViewSwitch.tsx b/desktop/frontend-next/src/ui/ViewSwitch.tsx index f6f05dd011..478752f413 100644 --- a/desktop/frontend-next/src/ui/ViewSwitch.tsx +++ b/desktop/frontend-next/src/ui/ViewSwitch.tsx @@ -2,6 +2,7 @@ import { t } from "../i18n"; import type { PaneView } from "../state/paneview"; import { ariaChord, chord } from "./keys"; import { StudioIcon } from "./StudioIcon"; +import { TooltipButton } from "./TooltipButton"; /** The focused pane's two side views, drawn on the window bar. The conversation * is where a pane rests, so it has no button: pressing the view that is showing @@ -17,22 +18,22 @@ export function ViewSwitch({ view, rows, surfaces, onView }: { return ( <> {rows > 0 && ( - + )} - + ); } diff --git a/desktop/frontend-next/src/ui/WorkbenchFileBar.tsx b/desktop/frontend-next/src/ui/WorkbenchFileBar.tsx index 56788c45b0..c0c3742667 100644 --- a/desktop/frontend-next/src/ui/WorkbenchFileBar.tsx +++ b/desktop/frontend-next/src/ui/WorkbenchFileBar.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from "react"; import { t } from "../i18n"; import { StudioIcon } from "./StudioIcon"; -export type FileMode = "file" | "diff" | "read" | "preview"; +export type FileMode = "file" | "diff" | "read" | "preview" | "document"; /** The open file's path, the views it can be seen in, and save. A document that * renders (Markdown, a page) leads with that view and calls the editor @@ -18,6 +18,8 @@ export function WorkbenchFileBar({ onSave, openWith, onReveal, + document = false, + source = true, }: { path: string; mode: FileMode; @@ -31,6 +33,8 @@ export function WorkbenchFileBar({ openWith?: ReactNode; // Present only where the kernel's own machine can show the file. onReveal?: () => void; + document?: boolean; + source?: boolean; }) { const view = (value: FileMode, label: string) => ( - )} + ) : null} ); } diff --git a/desktop/frontend-next/src/ui/WorkbenchPanel.tsx b/desktop/frontend-next/src/ui/WorkbenchPanel.tsx index c2cb02a8b5..b5ae05ec6b 100644 --- a/desktop/frontend-next/src/ui/WorkbenchPanel.tsx +++ b/desktop/frontend-next/src/ui/WorkbenchPanel.tsx @@ -36,73 +36,15 @@ import { useHtmlPreview } from "./useHtmlPreview"; import { setShowsHiddenFiles, showsHiddenFiles } from "../state/prefs"; import { BrowserWideToggle } from "./BrowserWideToggle"; import { BrowserFullToggle } from "./BrowserFullToggle"; -import { WorkspaceImage, isWorkspaceImage } from "./WorkspaceImage"; +import { documentView, sourceView } from "./document/registry"; +import { TooltipButton } from "./TooltipButton"; +import { documentState, type DocumentViewState } from "./document/viewState"; +const DocumentPreview = lazy(() => import("./document/DocumentPreview")); // The editor and its grammars load with the first file opened, not with Studio. const CodeEditor = lazy(() => import("./CodeEditor")); -type TreeRow = { - kind: "folder" | "file"; - path: string; - name: string; - depth: number; -}; - -/** Folders first at every level, with the tree still reading as a tree. Sorting - * flat paths as text interleaves `bin/` with `boot.test.exe`, because text is - * the only thing it compares. Segment by segment, whichever row is a directory - * where the two paths part wins there, and its children stay under it. */ -function before(a: TreeRow, b: TreeRow): number { - const x = a.path.split("/"), y = b.path.split("/"); - for (let i = 0; i < Math.min(x.length, y.length); i++) { - if (x[i] === y[i]) continue; - const dirs = [i < x.length - 1 || a.kind === "folder", i < y.length - 1 || b.kind === "folder"]; - return dirs[0] === dirs[1] ? x[i].localeCompare(y[i]) : dirs[0] ? -1 : 1; - } - return x.length - y.length; -} - -function treeRows( - files: string[], - directories: string[], - query: string, - closed: Set, -): TreeRow[] { - const matches = query - ? files.filter((path) => - path.toLocaleLowerCase().includes(query.toLocaleLowerCase()), - ) - : files; - const folders = new Set(directories); - for (const path of matches) - for (let i = 1, bits = path.split("/"); i < bits.length; i++) - folders.add(bits.slice(0, i).join("/")); - const hidden = (path: string) => - [...closed].some( - (folder) => path === folder || path.startsWith(`${folder}/`), - ); - const rows: TreeRow[] = [...folders] - .filter( - (path) => ![...closed].some((parent) => path.startsWith(`${parent}/`)), - ) - .map((path) => ({ - kind: "folder", - path, - name: path.split("/").at(-1)!, - depth: path.split("/").length - 1, - })); - rows.push( - ...matches - .filter((path) => !hidden(path.split("/").slice(0, -1).join("/"))) - .map((path) => ({ - kind: "file" as const, - path, - name: path.split("/").at(-1)!, - depth: path.split("/").length - 1, - })), - ); - return rows.sort(before); -} +import { treeRows } from "./workbench_tree"; export function WorkbenchPanel({ port, @@ -121,6 +63,7 @@ export function WorkbenchPanel({ toggleFiles = 0, onCloseManual, onSurfaces, + onActiveFile, onExternal, onAddToChat, onQuoteSelection, @@ -152,6 +95,7 @@ export function WorkbenchPanel({ onCloseManual: () => void; // How many surfaces the strip holds, for the pane's own tab to count. onSurfaces: (n: number) => void; + onActiveFile?: (path: string | undefined) => void; onExternal: (url: string) => void; onAddToChat?: (reference: string) => void; // Text selected in an open file, with the file it came from, on its way to the composer. @@ -206,6 +150,10 @@ export function WorkbenchPanel({ const [platform, setPlatform] = useState(""); useEffect(() => void host().describe().then((h) => setPlatform(h.platform)), []); const scope = useMemo(() => ({ port, workspaceRoot }), [port, workspaceRoot]); + const documentStates = useMemo(() => new Map(), [scope]); + useEffect(() => { + for (const path of documentStates.keys()) if (!openFiles.includes(path)) documentStates.delete(path); + }, [documentStates, openFiles]); const listedScope = useRef(scope); const currentScope = useRef(scope); currentScope.current = scope; @@ -339,6 +287,10 @@ export function WorkbenchPanel({ // A document opens rendered: it is read far more often than edited, and the // source view is one click away. const filePath = active?.kind === "file" ? active.path : ""; + useEffect(() => { + onActiveFile?.(shown && !launcher && !showFiles && filePath ? filePath : undefined); + return () => onActiveFile?.(undefined); + }, [filePath, shown, launcher, showFiles, onActiveFile]); const [selectionMenu, setSelectionMenu] = useState(null); // A right-click over text selected in an open file offers to bring that passage into the // conversation; anywhere else, or with nothing selected, the browser's own menu stays. @@ -352,19 +304,21 @@ export function WorkbenchPanel({ }; const grantedFile = parseGrantedFile(filePath); const readable = /\.(md|markdown|mdx)$/i.test(grantedFile?.rel ?? filePath); + const hasDocument = documentView(grantedFile?.rel ?? filePath); + const source = sourceView(grantedFile?.rel ?? filePath); const previewable = !!filePath && !grantedFile && preview.can(filePath); useEffect(() => { setMode((m) => { - const kept = (m === "read" && !readable) || (m === "preview" && !previewable) ? "file" : m; - return kept === "file" ? (readable ? "read" : previewable ? "preview" : "file") : kept; + return m === "diff" && source ? m : hasDocument ? "document" : readable ? "read" : "file"; }); - }, [filePath, readable, previewable]); + }, [filePath, readable, hasDocument, source]); const previewTab = previewable ? preview.tabFor(filePath) : undefined; const { open: openPreview, sync: syncPreview } = preview; - const { file, draft, diff, busy, error: fileFailed, missing, setDraft, save } = useWorkbenchFiles({ - port, workspaceRoot, repos: gitRepos.repos, path: isWorkspaceImage(filePath) ? "" : filePath, openPaths: openFiles, shown, refresh: JSON.stringify([changeKey, glance, running, wrote]), + const { file, draft, diff, busy, error: fileFailed, errorCode, missing, setDraft, save } = useWorkbenchFiles({ + port, workspaceRoot, repos: gitRepos.repos, path: !source || (hasDocument && mode === "document") ? "" : filePath, openPaths: openFiles, shown, refresh: JSON.stringify([changeKey, glance, running, wrote]), poll: (readable && mode === "read") || (previewable && mode === "preview"), }); + const unpreviewableText = !hasDocument && errorCode === "workspace.file_unreadable"; const revision = file?.revision; useEffect(() => { if (mode !== "preview" || !previewable || !shown || showFiles || launcher || previewTab || (!revision && !fileFailed && !failed)) return; @@ -566,17 +520,17 @@ export function WorkbenchPanel({ {wideMode && } {fullMode && } - - + {closeFailure &&
{closeFailure}
}
@@ -624,7 +578,7 @@ export function WorkbenchPanel({ onOpenDocument={(path) => port.openDocument(path)} /> )} - {active?.kind === "file" && (isWorkspaceImage(active.path) ? : ( + {active?.kind === "file" && ( <> void save()} openWith={hub && !remote ? setFailed(reason(e))} /> : undefined} onReveal={revealable && !grantedFile ? () => { setFailed(""); port.revealInFileManager(active.path).catch((e) => setFailed(reason(e))); } : undefined} /> - {(fileFailed || failed) && !(mode === "preview" && previewTab) && ( + {(fileFailed || failed) && mode !== "document" && !unpreviewableText && !(mode === "preview" && previewTab) && (
{fileFailed || failed}
)} - {mode === "preview" ? ( + {mode === "document" || unpreviewableText ? ( + {t("正在读取…")}
}> + { + const path = grantedFile ? `${grantedFile.root}/${grantedFile.rel}` : filePath; + void port.openDocument(path).catch((e) => setFailed(reason(e))); + } : undefined} /> + {failed &&
{failed}
} + + ) : mode === "preview" ? ( previewTab ? ( port.openDocument(path)} /> ) : ( @@ -673,7 +640,7 @@ export function WorkbenchPanel({ ) : null} - ))} + )}