mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge pull request #14031 from SivanCola/feature/file-delivery-preview
Add file deliveries and document previews / 新增文件交付卡片与文档预览
This commit is contained in:
129 files changed
+3659
-304
No files matched your search
@@ -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
|
||||
@@ -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"), '<h1>Static document</h1><script>parent.__unsafe=true</script>');
|
||||
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"') + "\n[tools]\npresent_files = true\n");
|
||||
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);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, string> = {
|
||||
"方案.md": "# 文件交付与预览体验\n\n点击聊天中的交付卡片,可在右侧打开对应文件。\n\n| 示例文件 | 体验内容 |\n| --- | --- |\n| 方案.md | Markdown 正文与表格 |\n| 数据.csv | 表格与源码 |\n| 报告.html | 静态 HTML |\n| 示例.pdf | 分页与文字选择 |",
|
||||
"数据.csv": '编号,内容\n001,"第一行\n第二行"\n002,=SUM(A1:A2)',
|
||||
"报告.html": "<!doctype html><meta charset='utf-8'><h1>交付文件可以直接阅读</h1><p>文件交付预览示例。</p>",
|
||||
"示例.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: "四份文件已准备好。点击卡片在右侧预览,其他操作可从卡片的更多菜单进入。" },
|
||||
];
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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": '<style>h1{color:#385b80}</style><h1>Static report</h1><script>parent.__unsafe=true</script><img src="https://preview-test.invalid/leak"><p>Preview stays in the workbench.</p>',
|
||||
};
|
||||
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();
|
||||
}
|
||||
@@ -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<HistoryMessage[]> {
|
||||
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<string, { kind: PreviewInfo["kind"]; text: string }>) => 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<string, unknown>)[method] = () => Promise.reject(new Error(message));
|
||||
};
|
||||
|
||||
@@ -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
|
||||
Generated
+205
-11
@@ -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/[email protected])([email protected])
|
||||
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/[email protected])
|
||||
'@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/[email protected])
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.1.2
|
||||
version: 6.1.2([email protected]([email protected]))
|
||||
version: 6.1.2([email protected](@types/[email protected])([email protected]))
|
||||
jsdom:
|
||||
specifier: ^30.1.3
|
||||
version: 30.1.3(@noble/[email protected])
|
||||
@@ -158,10 +173,10 @@ importers:
|
||||
version: 7.0.2
|
||||
vite:
|
||||
specifier: ^8.3.4
|
||||
version: 8.3.4([email protected])
|
||||
version: 8.3.4(@types/[email protected])([email protected])
|
||||
vitest:
|
||||
specifier: ^5.0.4
|
||||
version: 5.0.4([email protected](@noble/[email protected]))([email protected]([email protected]))
|
||||
version: 5.0.4(@types/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected]))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -625,6 +640,81 @@ packages:
|
||||
'@marijn/[email protected]':
|
||||
resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==}
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-Z8hg9BD7ABaZNhdM8O96IrfBaYLM7KzO/YyKA8eU9BDdc5fZJRTWyOhSAyOv59/ari6bOYa+p96h7zN6wbJRDA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-dRtZCi/u1IOZyRjEDCA73PqgWakf5689K2jfv4uPRYGMJvEqtV+Eq7MIQkBJLmdfUPe7AktSiBZRml2pggnjRQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-YwvMgK5NBXwPY1AjZGXfPCG3hp/OFGM0Kw+JZHCOR/FYaeSOftaZRyxaDEGkJQSLEUZZIMNJXKCWgZYSzgWZEA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-LlOsZy8qh+qJq1euEvDZqa9muACS1fL1Ag38c7J+axAnA8NeXazMtqmUyKb4NVyft6DqbU/MesEj3C0bAFnv4Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-nvuqhi85GzaFl/6sid4qf7wwjHuGU6gfIuDyywUb/fB+kp6Fp3h8GyIAKss/QPil3zVO2r8g6O9ah+IOUoBrIw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-Bf6+9PoEr9K06P/oyMab7zZOpW90KimbIuNsy/vL0VhZZKsb0B0rCC78cp0jXhK6PIM7v8t6uX1sn7isOZ8KEw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-j7VcZduLSRx60nv/wbdyH+fyB5QriWGuO8GrvMDlFlt7GNUKAw5lHAHpcuuQW3QwLOe+3P+3qryzyT3sW5pCqQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-twDb8ZNgoCF8EG6ka+Lk4etU+ea9lv6NiV5ptIf+thz3LgWuHBbt85c+YgL1s05VsHe4WYWK0f+GhR45IuqARg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-mtZbBeNCdGA0MYQxivPmMOp5JMH80LaJLQeEMg7Nne517fvgGb+DWTJTmoE/qUW8YJ4nkiP2vQyr1XVGrH/Ktg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-HgGRuBUkFL8U99ogwUnTz3SmwUu7WKjmj0eOVWRqVZTPPyfWGEIMr+ycb8oDoNZcKM9vSiF1rpy3r+UIqqaKqw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-SEEfblIAnYeS4UkeFPp9jw6ZWuHxaTqumUuksMgnMXmPLW8Ofb21uu7cujhw+lk4E9V3aWgGAE9f8g+CdoAU0Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
resolution: {integrity: sha512-PSwhRdvizfuiSDlhE4Ld2jl/JqkrbSFFyZO0ycamE6mBpf1H0uUF3Tm8yQaVDeAwCNNDzxjRfrN22KmWLTDwcw==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@noble/[email protected]':
|
||||
resolution: {integrity: sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
@@ -840,6 +930,12 @@ packages:
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==}
|
||||
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
|
||||
peerDependencies:
|
||||
@@ -1204,6 +1300,9 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-FHc6yNpnTORB0tM5kmZ03iD2mDuNakDE2lvf4Hw1XkfxtEHIGVi01B/GqUSwx+NcC1WGjidAfpTXzeALtPtfpg==}
|
||||
|
||||
@@ -1514,6 +1613,15 @@ packages:
|
||||
resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
[email protected]:
|
||||
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
|
||||
|
||||
[email protected]:
|
||||
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'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-qBGxg/7Q3Kl9Wfhrz2Z74UnvnHTXLNG6jmKJFeBvP2+y4lV7So+7SR62+Zd47JvdrCkX+nDcnr0ObPzek/+6RA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
|
||||
|
||||
@@ -1704,6 +1815,10 @@ packages:
|
||||
resolution: {integrity: sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g==}
|
||||
engines: {node: '>=22.13.0 || >=24'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -2021,6 +2136,9 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-usz3OVQBeh1LxNn0DzU0voLXMgn5CU/WUdpBsY6Ti5afNao+vTkmn4H/NxoXolc10cOqe2+YIG0Hspq2rsl64w==}
|
||||
engines: {node: '>=22.19.0'}
|
||||
@@ -2740,6 +2858,54 @@ snapshots:
|
||||
|
||||
'@marijn/[email protected]': {}
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/[email protected]':
|
||||
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/[email protected]':
|
||||
dependencies:
|
||||
'@noble/hashes': 2.4.0
|
||||
@@ -2895,6 +3061,14 @@ snapshots:
|
||||
|
||||
'@types/[email protected]': {}
|
||||
|
||||
'@types/[email protected]':
|
||||
dependencies:
|
||||
undici-types: 8.9.0
|
||||
|
||||
'@types/[email protected]':
|
||||
dependencies:
|
||||
'@types/node': 26.6.5
|
||||
|
||||
'@types/[email protected](@types/[email protected])':
|
||||
dependencies:
|
||||
'@types/react': 19.3.0
|
||||
@@ -2986,19 +3160,19 @@ snapshots:
|
||||
optionalDependencies:
|
||||
maplibre-gl: 6.14.0
|
||||
|
||||
'@vitejs/[email protected]([email protected]([email protected]))':
|
||||
'@vitejs/[email protected]([email protected](@types/[email protected])([email protected]))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.3.4([email protected])
|
||||
vite: 8.3.4(@types/[email protected])([email protected])
|
||||
|
||||
'@vitest/[email protected]([email protected]([email protected]))':
|
||||
'@vitest/[email protected]([email protected](@types/[email protected])([email protected]))':
|
||||
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([email protected])
|
||||
vite: 8.3.4(@types/[email protected])([email protected])
|
||||
|
||||
'@vitest/[email protected]': {}
|
||||
|
||||
@@ -3145,6 +3319,10 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
[email protected]:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -3504,6 +3682,12 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected](@types/[email protected])([email protected]):
|
||||
dependencies:
|
||||
react: 19.3.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.3.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -3922,6 +4106,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
@@ -3944,6 +4130,10 @@ snapshots:
|
||||
dependencies:
|
||||
resolve-protobuf-schema: 2.1.0
|
||||
|
||||
[email protected]:
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas': 1.0.12
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -4323,6 +4513,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -4428,7 +4620,7 @@ snapshots:
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
[email protected]([email protected]):
|
||||
[email protected](@types/[email protected])([email protected]):
|
||||
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
|
||||
|
||||
[email protected]([email protected](@noble/[email protected]))([email protected]([email protected])):
|
||||
[email protected](@types/[email protected])([email protected](@noble/[email protected]))([email protected](@types/[email protected])([email protected])):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/mocker': 5.0.4([email protected]([email protected]))
|
||||
'@vitest/mocker': 5.0.4([email protected](@types/[email protected])([email protected]))
|
||||
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([email protected])
|
||||
vite: 8.3.4(@types/[email protected])([email protected])
|
||||
why-is-node-running: 3.2.1
|
||||
optionalDependencies:
|
||||
'@types/node': 26.6.5
|
||||
jsdom: 30.1.3(@noble/[email protected])
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
@@ -56,7 +56,7 @@ const undeclared = census.roots.filter((r) => !r.actions.length);
|
||||
// denominator that grows because the instrument was corrected is not the same
|
||||
// event as one that grows because debt was added, and numbers either side of
|
||||
// that change do not compare.
|
||||
const UNDECLARED_CEILING = 218;
|
||||
const UNDECLARED_CEILING = 217;
|
||||
|
||||
/** One file, for holding the discovery rules themselves. */
|
||||
const only = (src: string) => certifyRoots(new Map([["src/ui/X.tsx", src]]), { resolve });
|
||||
@@ -169,6 +169,15 @@ describe("what counts as an input a person performs", () => {
|
||||
// name instead is what left every Picker call site outside the universe — with
|
||||
// a kernel write among them — while Seal A read as held.
|
||||
describe("what a certified primitive's call site counts as", () => {
|
||||
it("keeps tooltip-wrapped button actions at the call site", () => {
|
||||
const c = among({
|
||||
"src/ui/X.tsx": `import { TooltipButton } from "./TooltipButton";
|
||||
export const A = () => <TooltipButton data-action="a.b" onClick={() => save()} />;`,
|
||||
"src/ui/TooltipButton.tsx": "export function TooltipButton() { return null }",
|
||||
});
|
||||
expect(c.roots.map((r) => [r.event, r.actions])).toEqual([["click", ["a.b"]]]);
|
||||
expect(c.uncertified).toEqual([]);
|
||||
});
|
||||
it("makes the call site the input, on the contract rather than the spelling", () => {
|
||||
const c = among({
|
||||
"src/ui/X.tsx": `import { Picker } from "./Menu";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MARKET_ACTIONS } from "./actions_market";
|
||||
import { SCHEDULE_ACTIONS } from "./actions_schedule";
|
||||
import { DOCUMENT_ACTIONS } from "./actions_document";
|
||||
|
||||
// The census oracle is independent of production literals so disagreements fail.
|
||||
// An id names the intent; data-target and data-value name its entity and choice.
|
||||
@@ -764,6 +765,7 @@ export const ACTIONS: UIAction[] = [
|
||||
// gutter's own grip, which this pass has not annotated yet.
|
||||
{ id: "rail.toggle", kind: "view", target: "none", proof: "interaction" },
|
||||
...SCHEDULE_ACTIONS,
|
||||
...DOCUMENT_ACTIONS,
|
||||
];
|
||||
|
||||
// No action in Studio is reachable from the keyboard alone: every shortcut in
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { UIAction } from "./actions";
|
||||
|
||||
export const DOCUMENT_ACTIONS: UIAction[] = [
|
||||
{ id: "document.zoom", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "document.page", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "document.scroll", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "document.refresh", kind: "repeatable", target: "entity", proof: "interaction" },
|
||||
{ id: "delivery.preview", kind: "navigation", target: "entity", proof: "interaction" },
|
||||
{ id: "delivery.more", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "delivery.menu-navigation", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "delivery.add-to-chat", kind: "view", target: "entity", proof: "interaction" },
|
||||
];
|
||||
@@ -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<string, string> = {
|
||||
...EN_COMPOSITION,
|
||||
...EN_DOCUMENT,
|
||||
"此检查点不包含文件快照": "This checkpoint contains no file snapshots",
|
||||
...EN_SURFACES,
|
||||
"重命名聊天": "Rename chat",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
export const EN_DOCUMENT: Record<string, string> = {
|
||||
"预览 {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.",
|
||||
};
|
||||
@@ -35,6 +35,13 @@ const SAID: Record<string, string> = {
|
||||
"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": "界面描述不符合约定",
|
||||
|
||||
@@ -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<import("./port").WorkspaceFile> { 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<TrajectoryRead> {
|
||||
|
||||
@@ -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<import("./workspace").WorkspaceFile> { return { path, content: `// ${path}\n`, revision: "fixture" }; }
|
||||
async workspacePreviewInfo(ref: import("./workspace").FileRef): Promise<import("./workspace").PreviewInfo> {
|
||||
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() {}
|
||||
|
||||
|
||||
@@ -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<ProviderSetup | null>;
|
||||
saveProviderKey(apiKey: string): Promise<void>;
|
||||
// The models that answer the job asked about; "chat" when none is named.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -408,6 +408,20 @@ export class SsePort extends SseWorkspace implements AgentPort {
|
||||
return this.get<WorkspaceFile>(`/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)}` : ""}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<PreviewInfo>;
|
||||
workspacePreviewContent(ref: FileRef, signal?: AbortSignal): Promise<{ data: ArrayBuffer; version: string }>;
|
||||
}
|
||||
|
||||
export interface WorkspaceEntry {
|
||||
path: string;
|
||||
name: string;
|
||||
|
||||
@@ -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 } : {}),
|
||||
}),
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
.workbench-image {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.workbench-image img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
@@ -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 (
|
||||
<button className="workbench-files" data-action="browser.full" aria-pressed={full}
|
||||
<TooltipButton className="workbench-files" data-action="browser.full" aria-pressed={full}
|
||||
aria-label={label} aria-keyshortcuts={`${ariaChord("F")}+Shift`}
|
||||
title={`${label} (${chord("Shift+F")})`} onClick={onToggle}>
|
||||
title={label} shortcut={chord("Shift+F")} onClick={onToggle}>
|
||||
<StudioIcon name="expand" />
|
||||
</button>
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<button className="workbench-files" data-action="browser.wide" aria-pressed={wide}
|
||||
<TooltipButton className="workbench-files" data-action="browser.wide" aria-pressed={wide}
|
||||
aria-label={label} aria-keyshortcuts={`${ariaChord("B")}+Shift`}
|
||||
title={`${label} (${chord("Shift+B")})`} onClick={onToggle}>
|
||||
title={label} shortcut={chord("Shift+B")} onClick={onToggle}>
|
||||
<StudioIcon name="panel" />
|
||||
</button>
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
<div className="r-fold" id="chrome-fold" data-open={more ? "" : undefined}>
|
||||
{hub && onError && <PhonePop hub={hub} />}
|
||||
{port && (
|
||||
<button
|
||||
<TooltipButton
|
||||
className="thbtn find-action"
|
||||
data-action="transcript.find"
|
||||
data-value="open"
|
||||
onClick={onFind}
|
||||
aria-label={t("在这段对话里查找")}
|
||||
aria-keyshortcuts={ariaChord("f")}
|
||||
title={`${t("在这段对话里查找")} ${chord("F")}`}
|
||||
title={t("在这段对话里查找")} shortcut={chord("F")}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M7.2 3.1a4.1 4.1 0 1 0 0 8.2 4.1 4.1 0 0 0 0-8.2M10.3 10.3 13 13" /></svg>
|
||||
</button>
|
||||
</TooltipButton>
|
||||
)}
|
||||
<button
|
||||
<TooltipButton
|
||||
className="thbtn theme-toggle"
|
||||
data-action="appearance.theme"
|
||||
onClick={onTheme}
|
||||
@@ -147,10 +148,10 @@ export function Chrome({ port, status, title, steer, onSettings, account, host,
|
||||
) : (
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="2.7" /><path d="M8 1.7v1.4M8 12.9v1.4M1.7 8h1.4M12.9 8h1.4M3.5 3.5l1 1M11.5 11.5l1 1M3.5 12.5l1-1M11.5 4.5l1-1" /></svg>
|
||||
)}
|
||||
</button>
|
||||
<button className="top-action" data-action="settings.section" data-value="tools" onClick={() => onSettings("tools")} aria-label={t("沙盒")} title={t("沙盒与运行边界")}>
|
||||
</TooltipButton>
|
||||
<TooltipButton className="top-action" data-action="settings.section" data-value="tools" onClick={() => onSettings("tools")} aria-label={t("沙盒")} title={t("沙盒与运行边界")}>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m8 2.5 5 2.7v5.6l-5 2.7-5-2.7V5.2Z" /><path d="m3 5.2 5 2.7 5-2.7M8 7.9v5.6" /></svg><span>{t("沙盒")}</span>
|
||||
</button>
|
||||
</TooltipButton>
|
||||
{port && <ViewSwitch view={view} rows={rows} surfaces={surfaces} onView={onView} />}
|
||||
</div>
|
||||
<span className="account-presence" title={account?.signedIn ? account.user?.label : t("未登录")} />
|
||||
|
||||
@@ -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(<CodeBlock lang="go" source={"x := 1\n"}><code>x := 1</code></CodeBlock>);
|
||||
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(<CopyButton text="an answer" iconOnly />);
|
||||
expect(screen.getByRole("button").getAttribute("title")).toBe(t("复制回复"));
|
||||
fireEvent.focus(screen.getByRole("button"));
|
||||
expect(screen.getByRole("tooltip").textContent).toBe(t("复制回复"));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
<Button
|
||||
className={className ? `copy ${className}` : "copy"}
|
||||
type="button"
|
||||
role={role}
|
||||
@@ -54,6 +56,6 @@ export function CopyButton({ text, iconOnly = false, showFeedback = false, class
|
||||
>
|
||||
{iconOnly && <StudioIcon name={state === "done" ? "check" : state === "failed" ? "warning" : "copy"} />}
|
||||
<span className={iconOnly && !(showFeedback && state !== "idle") ? "sr-only" : undefined} aria-live="polite">{label}</span>
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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: (reference: string) => setInserted((was) => ({ n: was.n + 1, text: reference })),
|
||||
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}
|
||||
|
||||
@@ -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 (
|
||||
<div className="phonepop" ref={box}>
|
||||
<button
|
||||
<TooltipButton
|
||||
className="thbtn phone-action"
|
||||
data-action="share.card"
|
||||
data-live={shareOpen ? "" : undefined}
|
||||
@@ -79,7 +80,7 @@ export function PhonePop({ hub }: { hub: HubPort }) {
|
||||
<path d="M9.5 9.5h1.6v1.6M13.5 9.5v.01M9.5 13.5h.01M12 12h1.5v1.5H12Z" />
|
||||
</svg>
|
||||
{online > 0 && <b className="pc-count">{online}</b>}
|
||||
</button>
|
||||
</TooltipButton>
|
||||
{open && <PhoneCard share={share} failure={failure} />}
|
||||
{(cloudNote || localNote) && <div className="pc-note-pop" role="status">{cloudNote || localNote}</div>}
|
||||
</div>
|
||||
|
||||
@@ -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(<TooltipButton title="复制回复">Copy</TooltipButton>);
|
||||
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<HTMLButtonElement>();
|
||||
const clicked = vi.fn();
|
||||
const view = render(<TooltipButton ref={ref} title="加宽工作台" shortcut="⌘Shift+B" onClick={clicked}>Wide</TooltipButton>);
|
||||
const button = screen.getByRole("button");
|
||||
expect(ref.current).toBe(button);
|
||||
fireEvent.focus(button);
|
||||
expect(screen.getByRole("tooltip").textContent).toContain("⌘Shift+B");
|
||||
view.rerender(<TooltipButton ref={ref} title="恢复分栏" onClick={clicked}>Wide</TooltipButton>);
|
||||
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(<TooltipButton title="文件">Files</TooltipButton>);
|
||||
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(<><TooltipButton title="引用">Quote</TooltipButton><TooltipButton title="更多">More</TooltipButton></>);
|
||||
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(<ViewSwitch view="flow" rows={1} surfaces={2} onView={onView} />);
|
||||
const button = screen.getByRole("button", { name: "工作台" });
|
||||
fireEvent.focus(button);
|
||||
expect(screen.getByRole("tooltip").textContent).toContain("打开工作台");
|
||||
expect(screen.getByRole("tooltip").textContent).toContain("3");
|
||||
view.rerender(<ViewSwitch view="browser" rows={1} surfaces={2} onView={onView} />);
|
||||
expect(screen.getByRole("button", { name: "工作台" })).toBe(button);
|
||||
expect(screen.getByRole("tooltip").textContent).toContain("关闭工作台");
|
||||
fireEvent.click(button);
|
||||
expect(onView).toHaveBeenCalledWith("flow");
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
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";
|
||||
import { listenAction } from "./listen";
|
||||
|
||||
type Props = Omit<ComponentPropsWithRef<"button">, "onBlur"> & { shortcut?: string };
|
||||
let dismissActive: (() => void) | undefined;
|
||||
|
||||
export function TooltipButton({
|
||||
title, shortcut, ref, children, onPointerEnter, onPointerLeave, onPointerDown,
|
||||
onFocus, onClick, "aria-describedby": describedBy, ...props
|
||||
}: Props) {
|
||||
const anchor = useRef<HTMLButtonElement>(null);
|
||||
const tip = useRef<HTMLDivElement>(null);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | 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();
|
||||
};
|
||||
const offKey = listenAction(window, "keydown", { action: "layer.dismiss", listener: key as EventListener }, true);
|
||||
addEventListener("blur", close);
|
||||
return () => { offKey(); 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 <>
|
||||
<button {...props} ref={anchor} type={props.type ?? "button"}
|
||||
aria-describedby={[describedBy, open ? id : undefined].filter(Boolean).join(" ") || undefined}
|
||||
onPointerEnter={(event) => {
|
||||
onPointerEnter?.(event);
|
||||
if (event.pointerType !== "touch") { clear(); timer.current = setTimeout(show, 400); }
|
||||
}}
|
||||
onPointerLeave={(event) => { close(); onPointerLeave?.(event); }}
|
||||
onPointerDown={(event) => { close(); onPointerDown?.(event); }}
|
||||
onFocus={(event) => { show(); onFocus?.(event); }}
|
||||
onBlur={close}
|
||||
onClick={(event) => { close(); onClick?.(event); }}>
|
||||
{children}
|
||||
</button>
|
||||
{open && title && !props.disabled && createPortal(
|
||||
<div className="icon-tooltip" id={id} role="tooltip" ref={tip}>
|
||||
<span>{title}</span>{shortcut && <kbd>{shortcut}</kbd>}
|
||||
</div>, document.body,
|
||||
)}
|
||||
</>;
|
||||
}
|
||||
@@ -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) ? <DeliveryCard files={deliveries.get(id)!} reply={rowProps.reply} /> : null;
|
||||
// Which runs of work are open, held here so re-hosting one under its sentence
|
||||
// does not close it.
|
||||
const [opened, setOpened] = useState<Record<string, boolean>>({});
|
||||
@@ -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 ? <ActivityGroup items={row.activity} checkpoints={checkpoints} opened={opened} onOpened={setOpened} {...rowProps} /> : undefined}
|
||||
afterAnswer={row.activity || deliveries.has(row.item.id) ? <>{delivery(row.item.id)}{row.activity && <ActivityGroup items={row.activity} checkpoints={checkpoints} opened={opened} onOpened={setOpened} {...rowProps} />}</> : undefined}
|
||||
/>
|
||||
</Fragment>
|
||||
) : (
|
||||
<ActivityGroup key={`activity:${row.activity[0].id}`} items={row.activity} checkpoints={checkpoints} opened={opened} onOpened={setOpened} {...rowProps} />
|
||||
<Fragment key={`activity:${row.activity[0].id}`}>
|
||||
{row.activity.map((item) => <Fragment key={item.id}>{delivery(item.id)}</Fragment>)}
|
||||
<ActivityGroup items={row.activity} checkpoints={checkpoints} opened={opened} onOpened={setOpened} {...rowProps} />
|
||||
</Fragment>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
<button
|
||||
<TooltipButton
|
||||
type="button" className="thbtn view-action" data-action="pane.view" data-value="analysis"
|
||||
aria-pressed={view === "analysis"} aria-label={t("运行分析")} aria-keyshortcuts={ariaChord("2")}
|
||||
title={`${t("运行分析")} ${chord("2")}`} onClick={() => press("analysis")}
|
||||
title={view === "analysis" ? t("关闭运行分析") : t("打开运行分析")} shortcut={chord("2")} onClick={() => press("analysis")}
|
||||
>
|
||||
<StudioIcon name="gauge" />
|
||||
</button>
|
||||
</TooltipButton>
|
||||
)}
|
||||
<button
|
||||
<TooltipButton
|
||||
type="button" className="thbtn view-action" data-action="pane.view" data-value="browser"
|
||||
aria-pressed={view === "browser"} aria-label={t("工作台")} aria-keyshortcuts={ariaChord("3")}
|
||||
title={`${t("工作台")} ${chord("3")}`} onClick={() => press("browser")}
|
||||
title={view === "browser" ? t("关闭工作台") : t("打开工作台")} shortcut={chord("3")} onClick={() => press("browser")}
|
||||
>
|
||||
<StudioIcon name="panel" />
|
||||
{surfaces > 0 && <span className="view-count">{surfaces}</span>}
|
||||
</button>
|
||||
</TooltipButton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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) => (
|
||||
<button data-action="workbench.mode" data-value={value} aria-pressed={mode === value} onClick={() => onMode(value)}>
|
||||
@@ -47,19 +51,20 @@ export function WorkbenchFileBar({
|
||||
)}
|
||||
<div role="group">
|
||||
{readable && view("read", t("阅读"))}
|
||||
{previewable && view("preview", t("预览"))}
|
||||
{view("file", !readOnlyIn && (readable || previewable) ? t("编辑") : t("文件"))}
|
||||
{!readOnlyIn && view("diff", "Diff")}
|
||||
{document && view("document", t("预览"))}
|
||||
{previewable && view("preview", t("交互运行"))}
|
||||
{source && view("file", !readOnlyIn && (readable || previewable || document) ? t("编辑") : t("文件"))}
|
||||
{source && !readOnlyIn && view("diff", "Diff")}
|
||||
</div>
|
||||
{openWith}
|
||||
{readOnlyIn ? (
|
||||
<span className="workbench-readonly" title={readOnlyIn}>{t("只读 · 来自你授权的文件夹")}</span>
|
||||
) : (
|
||||
) : source ? (
|
||||
<button className="workbench-save" data-action="workbench.save" data-target={path} disabled={!canSave} onClick={onSave}>
|
||||
<StudioIcon name="check" />
|
||||
{t("保存")}
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
|
||||
import { t } from "../i18n";
|
||||
import { copyText } from "./CopyButton";
|
||||
import { pinToViewport } from "./place";
|
||||
import { fileReference } from "./fileReference";
|
||||
|
||||
export type FileMenu = { path: string; folder: boolean; x: number; y: number; anchor: HTMLElement };
|
||||
|
||||
@@ -53,7 +54,7 @@ export function WorkbenchFileMenu({ menu, root, revealable, platform, onReveal,
|
||||
// Keep the same token spelling as control.EscapeRefPath and RefDirItems.
|
||||
const path = menu.path + (menu.folder ? "/" : "");
|
||||
const ambiguous = path.includes(":");
|
||||
const reference = ambiguous || path.endsWith("\\") || path.trimEnd() !== path || /[\r\n\f]|[.,;!?)}\]]$/.test(path) ? "" : "@" + path.replace(/[ \t]/g, "\\$&");
|
||||
const reference = fileReference(path);
|
||||
return createPortal(
|
||||
<div
|
||||
className="tabmenu"
|
||||
|
||||
@@ -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<string>,
|
||||
): TreeRow[] {
|
||||
const matches = query
|
||||
? files.filter((path) =>
|
||||
path.toLocaleLowerCase().includes(query.toLocaleLowerCase()),
|
||||
)
|
||||
: files;
|
||||
const folders = new Set<string>(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<string, DocumentViewState>(), [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<SelectionMenu | null>(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({
|
||||
</div>
|
||||
{wideMode && <BrowserWideToggle {...wideMode} />}
|
||||
{fullMode && <BrowserFullToggle {...fullMode} />}
|
||||
<button
|
||||
<TooltipButton
|
||||
className="workbench-files"
|
||||
data-action="workbench.files"
|
||||
aria-pressed={showFiles && !launcher}
|
||||
aria-label={t("文件")}
|
||||
title={t("文件")}
|
||||
title={showFiles && !launcher ? t("收起文件树") : t("展开文件树")}
|
||||
onClick={toggleExplorer}
|
||||
>
|
||||
<StudioIcon name="folder" />
|
||||
</button>
|
||||
<button
|
||||
</TooltipButton>
|
||||
<TooltipButton
|
||||
className="workbench-new"
|
||||
data-action="workbench.new"
|
||||
ref={newTab}
|
||||
@@ -586,7 +540,7 @@ export function WorkbenchPanel({
|
||||
onClick={toggleLauncher}
|
||||
>
|
||||
<StudioIcon name="plus" />
|
||||
</button>
|
||||
</TooltipButton>
|
||||
</header>
|
||||
{closeFailure && <div className="workbench-error workbench-close-error" role="alert">{closeFailure}</div>}
|
||||
<div className="workbench-body" data-files={showFiles && !launcher ? "" : undefined}>
|
||||
@@ -624,7 +578,7 @@ export function WorkbenchPanel({
|
||||
onOpenDocument={(path) => port.openDocument(path)}
|
||||
/>
|
||||
)}
|
||||
{active?.kind === "file" && (isWorkspaceImage(active.path) ? <WorkspaceImage key={active.path} port={port} path={active.path} /> : (
|
||||
{active?.kind === "file" && (
|
||||
<>
|
||||
<WorkbenchFileBar
|
||||
path={grantedFile ? `${grantedFile.root.replace(/[\\/]+$/, "")}/${grantedFile.rel}` : active.path}
|
||||
@@ -632,18 +586,31 @@ export function WorkbenchPanel({
|
||||
onMode={setMode}
|
||||
readable={readable}
|
||||
previewable={previewable}
|
||||
document={hasDocument}
|
||||
source={source}
|
||||
canSave={!busy && !file?.readOnly && file?.path === (grantedFile?.rel ?? active.path) && draft !== file.content}
|
||||
readOnlyIn={grantedFile?.root ?? file?.root}
|
||||
onSave={() => void save()}
|
||||
openWith={hub && !remote ? <OpenWithMenu hub={hub} root={workspaceRoot} onError={(e) => 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) && (
|
||||
<div className="workbench-error" role="alert">
|
||||
{fileFailed || failed}
|
||||
</div>
|
||||
)}
|
||||
{mode === "preview" ? (
|
||||
{mode === "document" || unpreviewableText ? (
|
||||
<Suspense fallback={<div className="workbench-empty">{t("正在读取…")}</div>}>
|
||||
<DocumentPreview key={filePath} port={port} path={filePath} shown={shown && !showFiles && !launcher}
|
||||
view={documentState(documentStates, filePath)}
|
||||
refresh={JSON.stringify([changeKey, glance, running, wrote])}
|
||||
onOpenExternal={!remote && port.opensFiles() && /\.(pdf|html?|svg|docx?|xlsx?|pptx?)$/i.test(filePath) ? () => {
|
||||
const path = grantedFile ? `${grantedFile.root}/${grantedFile.rel}` : filePath;
|
||||
void port.openDocument(path).catch((e) => setFailed(reason(e)));
|
||||
} : undefined} />
|
||||
{failed && <div className="workbench-error" role="alert">{failed}</div>}
|
||||
</Suspense>
|
||||
) : mode === "preview" ? (
|
||||
previewTab ? (
|
||||
<AgentBrowserPanel tabs={[previewTab]} shown={shown && !launcher} showTabs={false} onOpenDocument={(path) => port.openDocument(path)} />
|
||||
) : (
|
||||
@@ -673,7 +640,7 @@ export function WorkbenchPanel({
|
||||
</Suspense>
|
||||
) : null}
|
||||
</>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<aside className="workbench-explorer">
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import type { AgentPort } from "../port/port";
|
||||
import { t } from "../i18n";
|
||||
import "../styles/workspace-image.css";
|
||||
|
||||
export const isWorkspaceImage = (path: string) => /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i.test(path);
|
||||
|
||||
export function WorkspaceImage({ port, path }: { port: AgentPort; path: string }) {
|
||||
const [state, setState] = useState<"loading" | "loaded" | "failed">("loading");
|
||||
return <>
|
||||
<div className="workbench-filebar"><strong title={path}>{path}</strong></div>
|
||||
<div className="workbench-image" aria-busy={state === "loading"}>
|
||||
{state === "loading" && <div className="workbench-empty">{t("正在读取…")}</div>}
|
||||
{state === "failed" ? <div className="workbench-error" role="alert">{t("图片加载失败,它可能已被移动、删除或超过大小限制")}</div>
|
||||
: <img src={port.workspaceImageURL(path)} alt={path} hidden={state !== "loaded"} onLoad={() => setState("loaded")} onError={() => setState("failed")} />}
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ it.each(['event-first', 'reply-first'])('preserves newer Files when preview work
|
||||
const open = vi.spyOn(port,'browserOpen').mockReturnValue(new Promise((_,reject)=>{fail=reject;}));
|
||||
const {rerender} = render(<WorkbenchPanel {...props} port={port} tabs={[agent]} />);
|
||||
fireEvent.click(await screen.findByRole('button',{name:'index.html'}));
|
||||
fireEvent.click(await screen.findByRole('button',{name:'交互运行'}));
|
||||
await waitFor(()=>expect(workspaces).toHaveBeenCalled());
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
fireEvent.click(document.querySelector('[data-action="workbench.files"]')!);
|
||||
@@ -124,6 +125,7 @@ it('follows a genuine agent tab while an older preview is still preparing its wo
|
||||
const open = vi.spyOn(port, 'browserOpen').mockResolvedValue(page);
|
||||
const {rerender} = render(<WorkbenchPanel {...props} port={port} tabs={[agent]} />);
|
||||
fireEvent.click(await screen.findByRole('button',{name:'index.html'}));
|
||||
fireEvent.click(await screen.findByRole('button',{name:'交互运行'}));
|
||||
await waitFor(()=>expect(lookup).toHaveBeenCalled());
|
||||
fireEvent.click(document.querySelector('[data-action="workbench.files"]')!);
|
||||
const fresh = {...agent,id:'fresh',target:'tf',title:'Fresh',active:false};
|
||||
|
||||
@@ -40,16 +40,16 @@ describe("browser wide mode", () => {
|
||||
const { container, width } = open();
|
||||
const frame = await screen.findByLabelText("网页地址");
|
||||
const composer = container.querySelector(".compose");
|
||||
const button = screen.getByRole("button", { name: "加宽浏览器" });
|
||||
const button = screen.getByRole("button", { name: "加宽工作台" });
|
||||
expect(button.getAttribute("aria-pressed")).toBe("false");
|
||||
fireEvent.click(button);
|
||||
expect(container.querySelector(".pane")?.getAttribute("data-browser-size")).toBe("wide");
|
||||
expect(screen.getByRole("button", { name: "恢复浏览器分栏" }).getAttribute("aria-pressed")).toBe("true");
|
||||
expect(screen.getByRole("button", { name: "恢复分栏" }).getAttribute("aria-pressed")).toBe("true");
|
||||
expect(screen.getByLabelText("网页地址")).toBe(frame);
|
||||
expect(container.querySelector(".compose")).toBe(composer);
|
||||
expect(composer?.hasAttribute("hidden")).toBe(false);
|
||||
expect(container.querySelector(".pbody")?.hasAttribute("data-full")).toBe(false);
|
||||
fireEvent.click(screen.getByRole("button", { name: "恢复浏览器分栏" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "恢复分栏" }));
|
||||
expect(container.querySelector(".pane")?.getAttribute("data-browser-size")).toBe("split");
|
||||
expect(screen.getByLabelText("网页地址")).toBe(frame);
|
||||
expect(width).not.toHaveBeenCalled();
|
||||
@@ -90,17 +90,17 @@ describe("browser wide mode", () => {
|
||||
it("resets to split after closing the dock without replacing the page", async () => {
|
||||
open();
|
||||
const frame = await screen.findByLabelText("网页地址");
|
||||
fireEvent.click(screen.getByRole("button", { name: "加宽浏览器" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "加宽工作台" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "工作台" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "工作台" }));
|
||||
expect(screen.getByRole("button", { name: "加宽浏览器" }).getAttribute("aria-pressed")).toBe("false");
|
||||
expect(screen.getByRole("button", { name: "加宽工作台" }).getAttribute("aria-pressed")).toBe("false");
|
||||
expect(screen.getByLabelText("网页地址")).toBe(frame);
|
||||
});
|
||||
|
||||
it("gives a transient layer first use of Escape", async () => {
|
||||
const { container, close } = open();
|
||||
await screen.findByLabelText("网页地址");
|
||||
fireEvent.click(screen.getByRole("button", { name: "加宽浏览器" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "加宽工作台" }));
|
||||
const dismiss = (e: KeyboardEvent) => e.stopPropagation();
|
||||
window.addEventListener("keydown", dismiss, true);
|
||||
try {
|
||||
@@ -124,9 +124,9 @@ describe("browser wide mode", () => {
|
||||
const { container, close } = open();
|
||||
const frame = await screen.findByLabelText("网页地址");
|
||||
const composer = container.querySelector(".compose");
|
||||
fireEvent.click(screen.getByRole("button", { name: "全屏浏览器" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "全屏工作台" }));
|
||||
expect(container.querySelector(".pane")?.getAttribute("data-browser-size")).toBe("full");
|
||||
expect(screen.getByRole("button", { name: "退出全屏" }).getAttribute("aria-pressed")).toBe("true");
|
||||
expect(screen.getByRole("button", { name: "退出工作台全屏" }).getAttribute("aria-pressed")).toBe("true");
|
||||
expect(screen.getByLabelText("网页地址")).toBe(frame);
|
||||
expect(container.querySelector(".compose")).toBe(composer);
|
||||
fireEvent.keyDown(document.body, { key: "Escape" });
|
||||
@@ -155,7 +155,7 @@ describe("browser wide mode", () => {
|
||||
vi.spyOn(port, "subscribe").mockImplementation((onEvent) => { deliver = onEvent; return () => true; });
|
||||
});
|
||||
await screen.findByLabelText("网页地址");
|
||||
fireEvent.click(screen.getByRole("button", { name: "全屏浏览器" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "全屏工作台" }));
|
||||
expect(container.querySelector(".pane")?.getAttribute("data-browser-size")).toBe("full");
|
||||
waiting = true;
|
||||
await act(async () => { deliver?.({ kind: "ask_request" } as WireEvent); });
|
||||
|
||||
@@ -197,7 +197,8 @@ it.each([
|
||||
onRegenerate: vi.fn(), canBranch: () => true, onBranch };
|
||||
render(<SayCard item={{ t: "say", id: "final", text: "answer", done: true }} reply={reply} />);
|
||||
const button = screen.getByRole("button", { name: label });
|
||||
expect(button.title).toBe(label);
|
||||
fireEvent.focus(button);
|
||||
expect(screen.getByRole("tooltip").textContent).toBe(label);
|
||||
await userEvent.click(button);
|
||||
expect(onBranch).toHaveBeenCalledExactlyOnceWith("final");
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { thoughtStartedAt } from "../../state/say";
|
||||
import { LazyMarkdown } from "../LazyMarkdown";
|
||||
import { Boundary } from "../Boundary";
|
||||
import { CopyButton } from "../CopyButton";
|
||||
import { TooltipButton } from "../TooltipButton";
|
||||
import { useRevealed } from "../reveal";
|
||||
import { useShown } from "../shown";
|
||||
import { ReplyMenu } from "./ReplyMenu";
|
||||
@@ -67,6 +68,10 @@ export interface Quote {
|
||||
* individual reply and requires a paired checkpoint. */
|
||||
export interface ReplyActions {
|
||||
files?: LocalRefs;
|
||||
activeFile?: string;
|
||||
onAddFile?: (reference: string) => void;
|
||||
openDeliveredFile?: (path: string) => Promise<void>;
|
||||
revealDeliveredFile?: (path: string) => Promise<void>;
|
||||
onQuote: (text: string, id: string) => void;
|
||||
onQuoteFile?: (text: string, source: string) => void;
|
||||
canRegenerate: (id: string) => boolean;
|
||||
@@ -185,20 +190,20 @@ export function SayCard({ item, afterAnswer, settled = true, reply, quiet }: { i
|
||||
<div className="acts">
|
||||
<CopyButton text={item.text} iconOnly />
|
||||
{reply && (
|
||||
<button type="button" data-action="reply.quote" data-target={item.id} title={t("引用到输入框")} aria-label={t("引用到输入框")} onClick={() => reply.onQuote(selectedIn(answer.current) || item.text, item.id)}>
|
||||
<TooltipButton type="button" data-action="reply.quote" data-target={item.id} title={t("引用到输入框")} aria-label={t("引用到输入框")} onClick={() => reply.onQuote(selectedIn(answer.current) || item.text, item.id)}>
|
||||
<StudioIcon name="quote" />
|
||||
</button>
|
||||
</TooltipButton>
|
||||
)}
|
||||
{reply?.canBranch(item.id) && (
|
||||
<button type="button" data-action="reply.branch" title={t("创建对话分支")} aria-label={t("创建对话分支")} disabled={branching} onClick={() => { setBranching(true); void Promise.resolve(reply.onBranch(item.id)).finally(() => setBranching(false)); }}>
|
||||
<TooltipButton type="button" data-action="reply.branch" title={t("创建对话分支")} aria-label={t("创建对话分支")} disabled={branching} onClick={() => { setBranching(true); void Promise.resolve(reply.onBranch(item.id)).finally(() => setBranching(false)); }}>
|
||||
<StudioIcon name={branching ? "clock" : "fork"} />
|
||||
</button>
|
||||
</TooltipButton>
|
||||
)}
|
||||
{reply?.canRegenerate(item.id) && (
|
||||
<span className="acts-menu">
|
||||
<button ref={retry} type="button" data-action="reply.retry" title={t("重新生成")} aria-label={t("重新生成")} aria-expanded={menu === "retry"} onClick={() => setMenu((m) => (m === "retry" ? "" : "retry"))}>
|
||||
<TooltipButton ref={retry} type="button" data-action="reply.retry" title={t("重新生成")} aria-label={t("重新生成")} aria-expanded={menu === "retry"} onClick={() => setMenu((m) => (m === "retry" ? "" : "retry"))}>
|
||||
<StudioIcon name="refresh" />
|
||||
</button>
|
||||
</TooltipButton>
|
||||
<ReplyMenu anchor={retry} open={menu === "retry"} onClose={shut}>
|
||||
<div className="acts-pop-head">
|
||||
{t("重新生成")}
|
||||
@@ -218,9 +223,9 @@ export function SayCard({ item, afterAnswer, settled = true, reply, quiet }: { i
|
||||
)}
|
||||
{reply && (
|
||||
<span className="acts-menu">
|
||||
<button ref={more} type="button" data-action="reply.more" title={t("更多")} aria-label={t("更多")} aria-expanded={menu === "more"} onClick={() => setMenu((m) => (m === "more" ? "" : "more"))}>
|
||||
<TooltipButton ref={more} type="button" data-action="reply.more" title={t("更多")} aria-label={t("更多")} aria-expanded={menu === "more"} onClick={() => setMenu((m) => (m === "more" ? "" : "more"))}>
|
||||
<StudioIcon name="more" />
|
||||
</button>
|
||||
</TooltipButton>
|
||||
<ReplyMenu anchor={more} open={menu === "more"} onClose={shut}>
|
||||
<div className="acts-pop-head">{t("这条回复")}</div>
|
||||
<button type="button" role="menuitem" data-action="reply.download" onClick={() => { setMenu(""); download(item.text); }}>
|
||||
|
||||
@@ -89,12 +89,12 @@ it("previews images without attempting to edit their binary content", async () =
|
||||
const { read } = await mount();
|
||||
fireEvent.click(screen.getByText("Image"));
|
||||
const image = await screen.findByAltText("images/chart.png");
|
||||
expect(image.getAttribute("src")).toBe("/fixture/images/chart.png");
|
||||
expect(image.getAttribute("src")).toMatch(/^blob:/);
|
||||
expect(read).not.toHaveBeenCalledWith("images/chart.png");
|
||||
fireEvent.load(image);
|
||||
expect(image.hasAttribute("hidden")).toBe(false);
|
||||
fireEvent.error(image);
|
||||
expect(screen.getByRole("alert").textContent).toContain("图片加载失败");
|
||||
expect(screen.getByRole("alert").textContent).toContain("图片不可用");
|
||||
});
|
||||
|
||||
it("keeps a conversation file selected when the column already has an agent browser", async () => {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { t } from "../../i18n";
|
||||
import type { DocumentViewState } from "./viewState";
|
||||
|
||||
export default function DelimitedPreview({ text, path, view }: { text: string; path: string; view: DocumentViewState }) {
|
||||
const [result, setResult] = useState<{ text: string; rows?: string[][]; error?: boolean }>();
|
||||
const [top, setTop] = useState(view.top ?? 0);
|
||||
const [left, setLeft] = useState(view.left ?? 0);
|
||||
const scroll = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (scroll.current) { scroll.current.scrollTop = view.top ?? 0; scroll.current.scrollLeft = view.left ?? 0; }
|
||||
}, [result, view]);
|
||||
useEffect(() => {
|
||||
const worker = new Worker(new URL("./delimited.worker.ts", import.meta.url), { type: "module" });
|
||||
const timer = window.setTimeout(() => { worker.terminate(); setResult({ text, error: true }); }, 10_000);
|
||||
worker.onmessage = (event: MessageEvent<{ rows?: string[][]; error?: boolean }>) => {
|
||||
window.clearTimeout(timer);
|
||||
setResult({ text, ...event.data });
|
||||
worker.terminate();
|
||||
};
|
||||
worker.onerror = () => { window.clearTimeout(timer); setResult({ text, error: true }); worker.terminate(); };
|
||||
worker.postMessage({ text, delimiter: /\.tsv$/i.test(path) ? "\t" : "," });
|
||||
return () => { window.clearTimeout(timer); worker.terminate(); };
|
||||
}, [text, path]);
|
||||
if (result?.text !== text) return <p role="status">{t("正在读取…")}</p>;
|
||||
if (result.error || !result.rows) return <p role="alert">{t("表格无法解析或超过 10 万个单元格,请切换源码查看")}</p>;
|
||||
const rows = result.rows, height = 30;
|
||||
const start = Math.min(Math.max(0, rows.length - 1), Math.max(0, Math.floor(top / height) - 10));
|
||||
const visible = rows.slice(start, start + 80);
|
||||
const columns = rows.reduce((n, row) => Math.max(n, row.length), 0);
|
||||
const firstColumn = Math.min(Math.max(0, columns - 1), Math.max(0, Math.floor(left / 160) - 2));
|
||||
const count = Math.min(20, columns - firstColumn);
|
||||
return <div className="document-table" ref={scroll} data-action="document.scroll" data-target={path} onScroll={(e) => {
|
||||
view.top = e.currentTarget.scrollTop; view.left = e.currentTarget.scrollLeft;
|
||||
setTop(view.top); setLeft(view.left);
|
||||
}}>
|
||||
<table aria-label={path} aria-rowcount={rows.length} aria-colcount={columns} style={{ width: columns * 160 + 48, tableLayout: "fixed" }}><colgroup>
|
||||
<col style={{ width: 48 }} />
|
||||
{firstColumn > 0 && <col style={{ width: firstColumn * 160 }} />}
|
||||
{Array.from({ length: count }, (_, i) => <col key={i} style={{ width: 160 }} />)}
|
||||
{columns > firstColumn + count && <col style={{ width: (columns - firstColumn - count) * 160 }} />}
|
||||
</colgroup><tbody>
|
||||
<tr aria-hidden="true" style={{ height: start * height }}><td /></tr>
|
||||
{visible.map((row, i) => <tr key={start + i} aria-rowindex={start + i + 1} style={{ height }}>
|
||||
<th scope="row">{start + i + 1}</th>
|
||||
{firstColumn > 0 && <td aria-hidden="true" />}
|
||||
{Array.from({ length: count }, (_, col) => <td key={col} aria-colindex={firstColumn + col + 1} title={row[firstColumn + col] ?? ""}>{row[firstColumn + col] ?? ""}</td>)}
|
||||
{columns > firstColumn + count && <td aria-hidden="true" />}
|
||||
</tr>)}
|
||||
<tr aria-hidden="true" style={{ height: Math.max(0, rows.length - start - visible.length) * height }}><td /></tr>
|
||||
</tbody></table>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { DeliveryCard } from "./DeliveryCard";
|
||||
import type { ReplyActions } from "../cards/SayCard";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it.each(["line\nbreak.md", "trailing\\", "github:report.md", "notes)"])("does not add an ambiguous file reference for %j", async (path) => {
|
||||
const user = userEvent.setup(), reply = actions(), add = vi.fn();
|
||||
reply.onAddFile = add;
|
||||
render(<DeliveryCard files={[{ path }]} reply={reply} />);
|
||||
await user.click(screen.getByRole("button", { name: /^更多操作/ }));
|
||||
const item = screen.getByRole("menuitem", { name: "添加到对话" }) as HTMLButtonElement;
|
||||
expect(item.disabled).toBe(true);
|
||||
const copy = screen.getByRole("menuitem", { name: "复制路径" });
|
||||
await waitFor(() => expect(document.activeElement).toBe(copy));
|
||||
await user.click(item);
|
||||
expect(add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the structured file identity without interpreting percent escapes or Windows separators", () => {
|
||||
const open = vi.fn();
|
||||
const file = { root: "D:\\Library", path: "方案 100%.md", description: "Report" };
|
||||
const select = vi.fn(() => open);
|
||||
const reply: ReplyActions = {
|
||||
files: { file: select, link: () => null, image: () => null },
|
||||
onQuote: vi.fn(), canRegenerate: () => false, hasLaterTurns: () => false,
|
||||
onRegenerate: vi.fn(), canBranch: () => false, onBranch: vi.fn(),
|
||||
};
|
||||
const { getByRole } = render(<DeliveryCard files={[file]} reply={reply} />);
|
||||
fireEvent.click(getByRole("button", { name: /^预览.*方案 100%.md/ }));
|
||||
expect(select).toHaveBeenCalledWith(file);
|
||||
expect(open).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
const actions = (): ReplyActions => ({
|
||||
files: { file: () => vi.fn(), link: () => null, image: () => null },
|
||||
onQuote: vi.fn(), canRegenerate: () => false, hasLaterTurns: () => false,
|
||||
onRegenerate: vi.fn(), canBranch: () => false, onBranch: vi.fn(),
|
||||
});
|
||||
|
||||
it("keeps auxiliary actions in a menu and adds a file without opening its preview", async () => {
|
||||
const user = userEvent.setup();
|
||||
const open = vi.fn(), add = vi.fn(), reply = actions();
|
||||
reply.files!.file = () => open;
|
||||
reply.onAddFile = add;
|
||||
render(<DeliveryCard files={[{ path: "report.pdf", description: "Quarterly report" }]} reply={reply} />);
|
||||
expect(screen.queryByRole("button", { name: "添加到对话" })).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: /更多.*report.pdf/ }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "添加到对话" }));
|
||||
expect(add).toHaveBeenCalledWith("@report.pdf");
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
await screen.findByText("已添加到输入框");
|
||||
});
|
||||
|
||||
it("uses full file identity for the current-view indicator and distinguishes equal basenames", () => {
|
||||
const reply = { ...actions(), activeFile: "docs/report.md" };
|
||||
const files = [{ path: "docs/report.md" }, { path: "other/report.md" }];
|
||||
const { rerender } = render(<DeliveryCard files={files} reply={reply} />);
|
||||
const buttons = screen.getAllByRole("button", { name: /^预览/ });
|
||||
expect(buttons[0].getAttribute("aria-current")).toBe("true");
|
||||
expect(buttons[1].hasAttribute("aria-current")).toBe(false);
|
||||
expect(screen.getAllByText("查看中")).toHaveLength(1);
|
||||
expect(screen.getByText("docs")).toBeTruthy();
|
||||
expect(screen.getByText("other")).toBeTruthy();
|
||||
rerender(<DeliveryCard files={files} reply={{ ...reply, activeFile: undefined }} />);
|
||||
expect(screen.queryByText("查看中")).toBeNull();
|
||||
});
|
||||
|
||||
it("supports keyboard menus and restores focus when Escape closes them", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DeliveryCard files={[{ path: "notes.md" }]} reply={actions()} />);
|
||||
const more = screen.getByRole("button", { name: /更多.*notes.md/ });
|
||||
await user.click(more);
|
||||
const item = screen.getByRole("menuitem", { name: "复制路径" });
|
||||
await waitFor(() => expect(document.activeElement).toBe(item));
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
expect(document.activeElement).toBe(more);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import type { DeliveredFile } from "../../port/workspace";
|
||||
import type { ReplyActions } from "../cards/SayCard";
|
||||
import { copyText } from "../CopyButton";
|
||||
import { t } from "../../i18n";
|
||||
import { reason } from "../../i18n/kernel";
|
||||
import "./delivery.css";
|
||||
import { StudioIcon } from "../StudioIcon";
|
||||
import { TooltipButton } from "../TooltipButton";
|
||||
import { grantedFilePath } from "../grantedfiles";
|
||||
import { DeliveryMenu, type DeliveryAction } from "./DeliveryMenu";
|
||||
import { FileTypeIcon } from "./FileTypeIcon";
|
||||
import { fileReference } from "../fileReference";
|
||||
|
||||
export function DeliveryCard({ files, reply }: { files: DeliveredFile[]; reply?: ReplyActions }) {
|
||||
const counts = new Map<string, number>();
|
||||
for (const file of files) {
|
||||
const name = file.path.split("/").at(-1)!;
|
||||
counts.set(name, (counts.get(name) ?? 0) + 1);
|
||||
}
|
||||
return <div className="delivery-files" aria-label={t("交付文件")}>
|
||||
<div className="delivery-grid" data-single={files.length === 1 ? "" : undefined}>
|
||||
{files.map((file) => <DeliveryEntry key={JSON.stringify([file.root, file.path])}
|
||||
file={file} reply={reply} showLocation={(counts.get(file.path.split("/").at(-1)!) ?? 0) > 1} />)}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function DeliveryEntry({ file, reply, showLocation }: { file: DeliveredFile; reply?: ReplyActions; showLocation: boolean }) {
|
||||
const path = file.root ? `${file.root.replace(/[\\/]+$/, "")}/${file.path}` : file.path;
|
||||
const href = path.split("/").map(encodeURIComponent).join("/");
|
||||
const open = reply?.files?.file?.(file) ?? reply?.files?.link(href);
|
||||
const absolute = file.root ? path : reply?.files?.title?.(href) || path;
|
||||
const reference = fileReference(path);
|
||||
const name = file.path.split("/").at(-1)!;
|
||||
const dot = name.lastIndexOf(".");
|
||||
const stem = dot > 0 ? name.slice(0, dot) : name, extension = dot > 0 ? name.slice(dot) : "";
|
||||
const current = reply?.activeFile === (file.root ? grantedFilePath(file.root, file.path) : file.path);
|
||||
const location = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : file.root;
|
||||
const description = file.description?.trim() || extension.slice(1).toUpperCase() || t("文件");
|
||||
const [menu, setMenu] = useState(false);
|
||||
const close = useCallback(() => setMenu(false), []);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const [feedback, setFeedback] = useState<{ kind: "busy" | "success" | "error"; text: string }>();
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const live = useRef(true);
|
||||
const detailID = useId();
|
||||
useEffect(() => { live.current = true; return () => { live.current = false; clearTimeout(timer.current); }; }, []);
|
||||
const run = async (action: () => void | Promise<void>, success: string) => {
|
||||
if (feedback?.kind === "busy") return;
|
||||
clearTimeout(timer.current);
|
||||
setFeedback({ kind: "busy", text: t("正在处理…") });
|
||||
try {
|
||||
await action();
|
||||
if (!live.current) return;
|
||||
setFeedback({ kind: "success", text: success });
|
||||
timer.current = setTimeout(() => { if (live.current) setFeedback(undefined); }, 3000);
|
||||
} catch (error) {
|
||||
if (live.current) setFeedback({ kind: "error", text: reason(error) });
|
||||
}
|
||||
};
|
||||
const actions: DeliveryAction[] = [];
|
||||
if (reply?.onAddFile) actions.push({ kind: "add", label: t("添加到对话"), icon: "plus", disabled: !reference,
|
||||
title: reference ? undefined : t("此名称不能表示为 @ 引用"),
|
||||
run: () => void run(() => reply.onAddFile!(reference), t("已添加到输入框")) });
|
||||
if (reply?.openDeliveredFile && /\.(md|markdown|txt|csv|tsv|pdf|html?|svg|docx?|xlsx?|pptx?)$/i.test(path))
|
||||
actions.push({ kind: "open", label: t("用系统应用打开"), icon: "external", run: () => void run(() => reply.openDeliveredFile!(absolute), t("已请求系统应用打开")) });
|
||||
if (!file.root && reply?.revealDeliveredFile)
|
||||
actions.push({ kind: "reveal", label: t("在文件夹中显示"), icon: "folder", run: () => void run(() => reply.revealDeliveredFile!(file.path), t("已请求显示文件位置")) });
|
||||
actions.push({ kind: "copy", label: t("复制路径"), icon: "copy", run: () => void run(() => copyText(absolute), t("已复制路径")) });
|
||||
return <div className="delivery-file" data-current={current ? "" : undefined}>
|
||||
<button type="button" className="delivery-open" data-action="delivery.preview" data-target={path} disabled={!open} title={`${absolute}\n${description}`}
|
||||
aria-label={t("预览 {name}", { name })} aria-describedby={detailID} aria-current={current ? "true" : undefined} onClick={() => open?.()}>
|
||||
<FileTypeIcon path={file.path} />
|
||||
<span className="delivery-details">
|
||||
<span className="delivery-heading">
|
||||
<strong className="delivery-name"><span>{stem}</span><span className="delivery-extension">{extension}</span></strong>
|
||||
{current && <span className="delivery-current">{t("查看中")}</span>}
|
||||
</span>
|
||||
{showLocation && <small className="delivery-location" title={absolute}>{location || t("项目根目录")}</small>}
|
||||
<small id={detailID} className="delivery-description">{description}</small>
|
||||
</span>
|
||||
</button>
|
||||
<TooltipButton ref={trigger} className="delivery-more" data-action="delivery.more" data-target={path} title={t("更多操作")} aria-label={t("更多操作:{name}", { name })}
|
||||
aria-haspopup="menu" aria-expanded={menu} disabled={feedback?.kind === "busy"} onClick={() => setMenu((value) => !value)}>
|
||||
<span>{t("更多")}</span><StudioIcon name="down" />
|
||||
</TooltipButton>
|
||||
{menu && <DeliveryMenu anchor={trigger} label={t("文件操作:{name}", { name })} target={path} actions={actions} onClose={close} />}
|
||||
{feedback && <div className="delivery-feedback" role={feedback.kind === "error" ? "alert" : "status"} data-kind={feedback.kind}>{feedback.text}</div>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useCallback, useRef, type RefObject } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useDismiss } from "../dismiss";
|
||||
import { beside, pinToViewport, useFollow } from "../place";
|
||||
import { StudioIcon, type StudioIconName } from "../StudioIcon";
|
||||
|
||||
export interface DeliveryAction {
|
||||
kind: "add" | "open" | "reveal" | "copy";
|
||||
label: string;
|
||||
icon: StudioIconName;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
export function DeliveryMenu({ anchor, label, target, actions, onClose }: {
|
||||
anchor: RefObject<HTMLButtonElement | null>; label: string;
|
||||
actions: DeliveryAction[]; target: string; onClose: () => void;
|
||||
}) {
|
||||
const menu = useRef<HTMLDivElement>(null);
|
||||
const close = useCallback(() => {
|
||||
if (menu.current?.contains(document.activeElement)) anchor.current?.focus({ preventScroll: true });
|
||||
onClose();
|
||||
}, [anchor, onClose]);
|
||||
useDismiss(true, anchor, close, menu);
|
||||
const place = useCallback(() => {
|
||||
if (!anchor.current || !menu.current) return;
|
||||
const rect = anchor.current.getBoundingClientRect(), box = menu.current.getBoundingClientRect();
|
||||
pinToViewport(menu.current, rect.right - box.width, beside(rect, box.height, innerHeight, "below", 6));
|
||||
}, [anchor]);
|
||||
useFollow(true, place);
|
||||
return createPortal(<div className="delivery-menu" role="menu" aria-label={label} ref={menu} data-action="delivery.menu-navigation"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Tab") { close(); return; }
|
||||
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const items = [...event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="menuitem"]:not(:disabled)')];
|
||||
const at = items.indexOf(document.activeElement as HTMLButtonElement);
|
||||
const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1
|
||||
: (at + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length;
|
||||
items[next]?.focus();
|
||||
}}>
|
||||
{actions.map((action, index) => {
|
||||
const add = action.kind === "add", open = action.kind === "open", reveal = action.kind === "reveal";
|
||||
return <button key={action.label} type="button" role="menuitem" data-target={target}
|
||||
data-action={add ? "delivery.add-to-chat" : open ? "workbench.open-file" : reveal ? "workbench.reveal" : "workbench.copy-path"}
|
||||
disabled={action.disabled} title={action.title} autoFocus={index === actions.findIndex((item) => !item.disabled)}
|
||||
onClick={() => { close(); action.run(); }}>
|
||||
<StudioIcon name={action.icon} /><span>{action.label}</span>
|
||||
</button>; })}
|
||||
</div>, document.body);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import type { AgentPort } from "../../port/port";
|
||||
import { t } from "../../i18n";
|
||||
import { useDocument } from "./useDocument";
|
||||
import { Boundary } from "../Boundary";
|
||||
import "./document.css";
|
||||
import type { DocumentViewState } from "./viewState";
|
||||
|
||||
const PdfPreview = lazy(() => import("./PdfPreview"));
|
||||
const DelimitedPreview = lazy(() => import("./DelimitedPreview"));
|
||||
const StaticHtmlPreview = lazy(() => import("./StaticHtmlPreview"));
|
||||
|
||||
function Picture({ data, path, view }: { data: ArrayBuffer; path: string; view: DocumentViewState }) {
|
||||
const [url, setURL] = useState("");
|
||||
const [zoom, updateZoom] = useState(view.zoom ?? 1);
|
||||
const setZoom = (value: number) => { view.zoom = value; updateZoom(value); };
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => {
|
||||
const type = /\.svg$/i.test(path) ? "image/svg+xml" : undefined;
|
||||
const url = URL.createObjectURL(new Blob([data], { type }));
|
||||
setURL(url); setFailed(false);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [data, path]);
|
||||
return <>
|
||||
<div className="document-controls">
|
||||
<button data-action="document.zoom" data-target={path} onClick={() => setZoom(Math.max(0.25, zoom - 0.25))} aria-label={t("缩小")}>−</button>
|
||||
<button data-action="document.zoom" data-target={path} onClick={() => setZoom(1)}>{Math.round(zoom * 100)}%</button>
|
||||
<button data-action="document.zoom" data-target={path} onClick={() => setZoom(Math.min(4, zoom + 0.25))} aria-label={t("放大")}>+</button>
|
||||
</div>
|
||||
{failed ? <p role="alert">{t("图片不可用")}</p> : <div className="document-picture">
|
||||
{url && <img src={url} alt={path} style={{ width: `${zoom * 100}%`, maxWidth: "none" }} onError={() => setFailed(true)} />}
|
||||
</div>}
|
||||
</>;
|
||||
}
|
||||
|
||||
export default function DocumentPreview({ port, path, shown, refresh, onOpenExternal, view }: {
|
||||
port: AgentPort; path: string; shown: boolean; refresh: string; onOpenExternal?: () => void;
|
||||
view: DocumentViewState;
|
||||
}) {
|
||||
const { value, reload } = useDocument(port, path, shown, refresh);
|
||||
const { info, data, error } = value ?? {};
|
||||
const fallback = <p role="alert">{t("此文件暂不支持预览")}</p>;
|
||||
let body = <p role="status">{t("正在读取…")}</p>;
|
||||
if (error) body = <p role="alert">{error}</p>;
|
||||
else if (info?.kind === "unsupported") body = fallback;
|
||||
else if (info && info.size > info.limit) body = <p role="alert">{t("文件超过预览大小限制")}</p>;
|
||||
else if (data && info) {
|
||||
const text = () => new TextDecoder("utf-8", { fatal: true }).decode(data);
|
||||
switch (info.kind) {
|
||||
case "pdf": body = <PdfPreview data={data} view={view} />; break;
|
||||
case "table": body = <DelimitedPreview path={path} text={text()} view={view} />; break;
|
||||
case "html": body = <StaticHtmlPreview text={text()} />; break;
|
||||
case "image": body = <Picture data={data} path={path} view={view} />; break;
|
||||
default: body = fallback;
|
||||
}
|
||||
}
|
||||
return <div className="document-preview">
|
||||
<div className="document-controls">
|
||||
{info && <span>{info.size.toLocaleString()} B</span>}
|
||||
<button data-action="document.refresh" data-target={path} onClick={reload}>{t("刷新")}</button>
|
||||
{onOpenExternal && <button data-action="workbench.open-file" data-target={path} onClick={onOpenExternal}>{t("用系统应用打开")}</button>}
|
||||
</div>
|
||||
<Boundary fallback={fallback} retryKey={info?.version}>
|
||||
<Suspense fallback={<p role="status">{t("正在读取…")}</p>}>{body}</Suspense>
|
||||
</Boundary>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { fileAppearance } from "./FileTypeIcon";
|
||||
|
||||
it.each([
|
||||
["方案.MDX", "markdown"], ["D:\\报告\\REPORT.PDF", "pdf"], ["data.csv", "spreadsheet"],
|
||||
["budget.xlsx", "spreadsheet"], ["合同.docx", "document"], ["slides.key", "presentation"],
|
||||
["index.html", "html"], ["photo.HEIC", "image"], ["art.svg", "vector"],
|
||||
["design.psd", "design"], ["song.flac", "audio"], ["demo.mov", "video"],
|
||||
["backup.tar.gz", "archive"], ["font.woff2", "font"], ["db.sqlite3", "database"],
|
||||
["data.jsonl", "data"], ["feed.xml", "markup"], ["theme.scss", "stylesheet"],
|
||||
[".env.production", "config"], ["config.yaml", "config"], ["start.ps1", "script"],
|
||||
["app.tsx", "code"], ["analysis.py", "code"], ["notebook.ipynb", "notebook"],
|
||||
["server.log", "text"], ["book.epub", "ebook"], ["app.dmg", "package"],
|
||||
["module.wasm", "binary"], ["mesh.glb", "model"], ["drawing.dwg", "cad"],
|
||||
["cert.pem", "certificate"], ["message.eml", "mail"], ["event.ics", "calendar"],
|
||||
["fix.patch", "diff"], ["flow.mmd", "diagram"], ["data.parquet", "dataset"],
|
||||
["Dockerfile.dev", "container"], [".gitignore", "git"], ["Makefile", "script"],
|
||||
["LICENSE", "text"], ["README", "text"], ["foo.unknown", "unknown"],
|
||||
["report.pdf.exe", "binary"], ["folder.pdf/opaque", "unknown"],
|
||||
])("classifies %s for display as %s", (path, kind) => {
|
||||
expect(fileAppearance(path).kind).toBe(kind);
|
||||
});
|
||||
|
||||
it("distinguishes code languages and PDF with short labels", () => {
|
||||
expect(fileAppearance("main.ts").badge).toBe("TS");
|
||||
expect(fileAppearance("main.py").badge).toBe("PY");
|
||||
expect(fileAppearance("report.pdf").badge).toBe("PDF");
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
BookOpen, Box, Braces, CalendarDays, CodeXml, Container, Database, File,
|
||||
FileArchive, FileAxis3d, FileChartColumn, FileCode, FileCog, FileDiff,
|
||||
FileDigit, FileImage, FileKey, FileMusic, FilePenLine, FileScan,
|
||||
FileSpreadsheet, FileTerminal, FileText, FileType, FileVideoCamera,
|
||||
GitBranch, Mail, NotebookPen, Package, Palette, PenTool, Presentation,
|
||||
Workflow, type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
const groups = [
|
||||
{ kind: "markdown", icon: FilePenLine, extensions: "md markdown mdx mdown mkd" },
|
||||
{ kind: "pdf", icon: FileScan, extensions: "pdf" },
|
||||
{ kind: "spreadsheet", icon: FileSpreadsheet, extensions: "csv tsv xls xlsx xlsm xlsb ods numbers" },
|
||||
{ kind: "document", icon: FileText, extensions: "doc docx docm dot dotx odt rtf pages" },
|
||||
{ kind: "presentation", icon: Presentation, extensions: "ppt pptx pptm pps ppsx odp key" },
|
||||
{ kind: "html", icon: CodeXml, extensions: "html htm xhtml" },
|
||||
{ kind: "image", icon: FileImage, extensions: "png jpg jpeg gif webp avif bmp ico tif tiff heic heif raw dng" },
|
||||
{ kind: "vector", icon: PenTool, extensions: "svg eps ai" },
|
||||
{ kind: "design", icon: Palette, extensions: "psd psb sketch fig xd afdesign afphoto xcf" },
|
||||
{ kind: "audio", icon: FileMusic, extensions: "mp3 wav flac aac m4a ogg opus aiff wma mid midi" },
|
||||
{ kind: "video", icon: FileVideoCamera, extensions: "mp4 mov mkv webm avi m4v wmv flv mpeg mpg" },
|
||||
{ kind: "archive", icon: FileArchive, extensions: "zip 7z rar tar gz tgz bz2 tbz2 xz txz zst cab" },
|
||||
{ kind: "font", icon: FileType, extensions: "ttf otf woff woff2 eot" },
|
||||
{ kind: "database", icon: Database, extensions: "sql db sqlite sqlite3 mdb accdb" },
|
||||
{ kind: "data", icon: Braces, extensions: "json jsonc jsonl ndjson geojson" },
|
||||
{ kind: "markup", icon: FileCode, extensions: "xml xsd xsl xslt plist rss atom" },
|
||||
{ kind: "stylesheet", icon: Palette, extensions: "css scss sass less styl" },
|
||||
{ kind: "config", icon: FileCog, extensions: "yaml yml toml ini conf cfg config properties env editorconfig" },
|
||||
{ kind: "script", icon: FileTerminal, extensions: "sh bash zsh fish ps1 bat cmd" },
|
||||
{ kind: "code", icon: FileCode, extensions: "js jsx mjs cjs ts tsx mts cts py pyw go rs java kt kts c h cpp hpp cc cxx cs swift rb php lua r jl dart vue svelte ex exs erl hs scala clj cljs elm sol zig nim pl pm groovy gradle cmake make proto graphql gql" },
|
||||
{ kind: "notebook", icon: NotebookPen, extensions: "ipynb rmd qmd" },
|
||||
{ kind: "text", icon: FileText, extensions: "txt text log rst adoc asciidoc tex bib" },
|
||||
{ kind: "ebook", icon: BookOpen, extensions: "epub mobi azw azw3 fb2" },
|
||||
{ kind: "package", icon: Package, extensions: "dmg pkg deb rpm apk aab ipa msi msix appx jar war vsix crx" },
|
||||
{ kind: "binary", icon: FileDigit, extensions: "exe dll so dylib bin wasm o a lib class" },
|
||||
{ kind: "model", icon: FileAxis3d, extensions: "obj stl fbx glb gltf blend 3ds step stp" },
|
||||
{ kind: "cad", icon: Box, extensions: "dwg dxf" },
|
||||
{ kind: "certificate", icon: FileKey, extensions: "pem crt cer p12 pfx pub asc gpg" },
|
||||
{ kind: "mail", icon: Mail, extensions: "eml msg mbox" },
|
||||
{ kind: "calendar", icon: CalendarDays, extensions: "ics ical" },
|
||||
{ kind: "diff", icon: FileDiff, extensions: "diff patch" },
|
||||
{ kind: "diagram", icon: Workflow, extensions: "drawio mmd mermaid puml plantuml dot" },
|
||||
{ kind: "dataset", icon: FileChartColumn, extensions: "parquet arrow feather h5 hdf5 mat npy npz" },
|
||||
] as const;
|
||||
|
||||
type FileAppearance = { kind: string; icon: LucideIcon };
|
||||
const byExtension = new Map<string, FileAppearance>(
|
||||
groups.flatMap((group) => group.extensions.split(" ").map((extension) => [extension, group] as const)),
|
||||
);
|
||||
const byName: Record<string, FileAppearance> = {
|
||||
dockerfile: { kind: "container", icon: Container },
|
||||
containerfile: { kind: "container", icon: Container },
|
||||
makefile: { kind: "script", icon: FileTerminal },
|
||||
gnumakefile: { kind: "script", icon: FileTerminal },
|
||||
justfile: { kind: "script", icon: FileTerminal },
|
||||
".gitignore": { kind: "git", icon: GitBranch },
|
||||
".gitattributes": { kind: "git", icon: GitBranch },
|
||||
".gitmodules": { kind: "git", icon: GitBranch },
|
||||
".npmrc": { kind: "config", icon: FileCog },
|
||||
".yarnrc": { kind: "config", icon: FileCog },
|
||||
".bashrc": { kind: "script", icon: FileTerminal },
|
||||
".zshrc": { kind: "script", icon: FileTerminal },
|
||||
license: { kind: "text", icon: FileText },
|
||||
readme: { kind: "text", icon: FileText },
|
||||
};
|
||||
|
||||
export function fileAppearance(path: string): FileAppearance & { badge?: string } {
|
||||
const name = path.split(/[\\/]/).at(-1)!.toLowerCase();
|
||||
const extension = name.includes(".") ? name.split(".").at(-1)! : "";
|
||||
if (Object.hasOwn(byName, name)) return byName[name];
|
||||
if (name === ".env" || name.startsWith(".env.")) return { kind: "config", icon: FileCog };
|
||||
if (/^(dockerfile|containerfile)\./.test(name)) return { kind: "container", icon: Container };
|
||||
const appearance = byExtension.get(extension) ?? { kind: "unknown", icon: File };
|
||||
const badge = appearance.kind === "markdown" ? "MD"
|
||||
: ["pdf", "code", "data", "markup", "stylesheet"].includes(appearance.kind) ? extension.toUpperCase() : undefined;
|
||||
return { ...appearance, badge };
|
||||
}
|
||||
|
||||
export function FileTypeIcon({ path }: { path: string }) {
|
||||
const { icon: Icon, kind, badge } = fileAppearance(path);
|
||||
return <span className="delivery-icon" data-file-kind={kind} aria-hidden="true">
|
||||
<Icon size={22} strokeWidth={1.5} />
|
||||
{badge && <span className="delivery-icon-badge">{badge}</span>}
|
||||
</span>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
const pdf = vi.hoisted(() => ({ load: vi.fn(), destroy: vi.fn() }));
|
||||
vi.mock("pdfjs-dist", () => ({
|
||||
GlobalWorkerOptions: {}, TextLayer: vi.fn(), getDocument: pdf.load,
|
||||
}));
|
||||
import PdfPreview from "./PdfPreview";
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.clearAllMocks(); });
|
||||
|
||||
it("keeps XFA off and releases the loading task when the file changes or closes", () => {
|
||||
vi.stubGlobal("ResizeObserver", class { observe() {} disconnect() {} });
|
||||
pdf.load.mockImplementation(() => ({ promise: new Promise(() => {}), destroy: pdf.destroy }));
|
||||
const data = new ArrayBuffer(16);
|
||||
const { rerender, unmount } = render(<PdfPreview data={data} view={{}} />);
|
||||
expect(pdf.load).toHaveBeenCalledWith(expect.objectContaining({ enableXfa: false }));
|
||||
expect(pdf.load.mock.calls[0][0].data).not.toBe(data);
|
||||
rerender(<PdfPreview data={new ArrayBuffer(32)} view={{}} />);
|
||||
expect(pdf.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(pdf.load).toHaveBeenLastCalledWith(expect.objectContaining({ enableXfa: false }));
|
||||
unmount();
|
||||
expect(pdf.destroy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { getDocument, GlobalWorkerOptions, TextLayer, type PDFDocumentProxy } from "pdfjs-dist";
|
||||
import workerURL from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
||||
import "pdfjs-dist/web/pdf_viewer.css";
|
||||
import { t } from "../../i18n";
|
||||
import type { DocumentViewState } from "./viewState";
|
||||
|
||||
GlobalWorkerOptions.workerSrc = workerURL;
|
||||
|
||||
export default function PdfPreview({ data, view }: { data: ArrayBuffer; view: DocumentViewState }) {
|
||||
const [doc, setDoc] = useState<PDFDocumentProxy>();
|
||||
const [page, setPage] = useState(view.page ?? 1);
|
||||
const [zoom, setZoom] = useState(view.zoom ?? 0);
|
||||
useEffect(() => { view.page = page; view.zoom = zoom; }, [view, page, zoom]);
|
||||
const [width, setWidth] = useState(600);
|
||||
const [error, setError] = useState(false);
|
||||
const frame = useRef<HTMLDivElement>(null);
|
||||
const canvas = useRef<HTMLCanvasElement>(null);
|
||||
const text = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!frame.current) return;
|
||||
const observer = new ResizeObserver(([entry]) => setWidth(Math.max(100, entry.contentRect.width - 24)));
|
||||
observer.observe(frame.current);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
setDoc(undefined); setError(false);
|
||||
const assets = new URL("./pdfjs/", document.baseURI).href;
|
||||
// PDF.js 6 removed eval and isEvalSupported (mozilla/pdf.js@f6bac014).
|
||||
// This reader renders only canvas/text; it never creates a scripting manager.
|
||||
const task = getDocument({
|
||||
data: data.slice(0),
|
||||
enableXfa: false,
|
||||
cMapUrl: assets + "cmaps/", cMapPacked: true,
|
||||
standardFontDataUrl: assets + "standard_fonts/", wasmUrl: assets + "wasm/",
|
||||
});
|
||||
task.onPassword = () => { if (live) setError(true); void task.destroy(); };
|
||||
void task.promise.then((next) => {
|
||||
if (live) { setDoc(next); setPage((n) => Math.min(n, next.numPages)); }
|
||||
}).catch(() => { if (live) setError(true); });
|
||||
return () => { live = false; void task.destroy(); };
|
||||
}, [data]);
|
||||
useEffect(() => {
|
||||
if (!doc || !canvas.current || !text.current) return;
|
||||
let live = true;
|
||||
let cancel = () => {};
|
||||
let layer: TextLayer | undefined;
|
||||
const surface = canvas.current, overlay = text.current;
|
||||
void doc.getPage(page).then(async (pdfPage) => {
|
||||
if (!live) return;
|
||||
const base = pdfPage.getViewport({ scale: 1 });
|
||||
const scale = zoom || width / base.width;
|
||||
const viewport = pdfPage.getViewport({ scale });
|
||||
const ratio = Math.min(window.devicePixelRatio || 1, Math.sqrt(16_777_216 / (viewport.width * viewport.height)));
|
||||
surface.width = Math.max(1, Math.floor(viewport.width * ratio));
|
||||
surface.height = Math.max(1, Math.floor(viewport.height * ratio));
|
||||
surface.style.width = `${viewport.width}px`;
|
||||
surface.style.height = `${viewport.height}px`;
|
||||
overlay.replaceChildren();
|
||||
overlay.style.setProperty("--scale-factor", String(scale));
|
||||
overlay.style.setProperty("--total-scale-factor", String(scale));
|
||||
overlay.style.width = `${viewport.width}px`;
|
||||
overlay.style.height = `${viewport.height}px`;
|
||||
const render = pdfPage.render({ canvas: surface, viewport, transform: [ratio, 0, 0, ratio, 0, 0] });
|
||||
cancel = () => render.cancel();
|
||||
layer = new TextLayer({ textContentSource: pdfPage.streamTextContent(), container: overlay, viewport });
|
||||
await Promise.all([render.promise, layer.render()]);
|
||||
}).catch(() => { if (live) setError(true); });
|
||||
return () => { live = false; cancel(); layer?.cancel(); };
|
||||
}, [doc, page, zoom, width]);
|
||||
return <div className="document-pdf">
|
||||
<div className="document-controls">
|
||||
<button data-action="document.page" disabled={page <= 1 || !doc} onClick={() => setPage((n) => n - 1)}>{t("上一页")}</button>
|
||||
<span>{page} / {doc?.numPages ?? "—"}</span>
|
||||
<button data-action="document.page" disabled={!doc || page >= doc.numPages} onClick={() => setPage((n) => n + 1)}>{t("下一页")}</button>
|
||||
<select data-action="document.zoom" aria-label={t("缩放")} value={zoom} onChange={(e) => setZoom(Number(e.target.value))}>
|
||||
<option value={0}>{t("适应宽度")}</option>
|
||||
{[0.5, 1, 1.5, 2].map((n) => <option key={n} value={n}>{n * 100}%</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{error && <p role="alert">{t("PDF 无法显示,文件可能损坏或需要密码")}</p>}
|
||||
{!doc && !error && <p role="status">{t("正在读取…")}</p>}
|
||||
<div className="document-pdf-scroll" ref={frame}>
|
||||
<div className="document-pdf-page" hidden={!doc || error}>
|
||||
<canvas ref={canvas} /><div ref={text} className="textLayer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useMemo } from "react";
|
||||
import { t } from "../../i18n";
|
||||
import { staticDocument } from "./staticDocument";
|
||||
|
||||
export default function StaticHtmlPreview({ text }: { text: string }) {
|
||||
const document = useMemo(() => staticDocument(text), [text]);
|
||||
return <>
|
||||
<p className="document-notice">{t("静态预览,脚本和外部资源未加载")}</p>
|
||||
<iframe className="document-html" title={t("HTML 静态预览")} sandbox="" srcDoc={document} />
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { MockPort } from "../../port/mock";
|
||||
import { WorkbenchPanel } from "../WorkbenchPanel";
|
||||
import "../testkit";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("reports actual tab selection and clears it when the canvas is hidden or its file closes", async () => {
|
||||
const port = new MockPort(), onActiveFile = vi.fn();
|
||||
const props = { port, tabs: [], manual: false, shown: true, scheme: "light" as const, changes: [],
|
||||
workspaceRoot: "/project", onSurfaces: vi.fn(), onCloseManual: vi.fn(), onExternal: vi.fn(), onActiveFile };
|
||||
const view = render(<WorkbenchPanel {...props} fileRequest={{ path: "docs/one.md" }} />);
|
||||
await waitFor(() => expect(onActiveFile).toHaveBeenLastCalledWith("docs/one.md"));
|
||||
view.rerender(<WorkbenchPanel {...props} fileRequest={{ path: "other/one.md" }} />);
|
||||
await waitFor(() => expect(onActiveFile).toHaveBeenLastCalledWith("other/one.md"));
|
||||
view.rerender(<WorkbenchPanel {...props} />);
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "one.md" })[0]);
|
||||
await waitFor(() => expect(onActiveFile).toHaveBeenLastCalledWith("docs/one.md"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "文件" }));
|
||||
await waitFor(() => expect(onActiveFile).toHaveBeenLastCalledWith(undefined));
|
||||
fireEvent.click(screen.getByRole("button", { name: "文件" }));
|
||||
await waitFor(() => expect(onActiveFile).toHaveBeenLastCalledWith("docs/one.md"));
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "关闭 one.md" })[0]);
|
||||
await waitFor(() => expect(onActiveFile).toHaveBeenLastCalledWith("other/one.md"));
|
||||
view.rerender(<WorkbenchPanel {...props} shown={false} />);
|
||||
await waitFor(() => expect(onActiveFile).toHaveBeenLastCalledWith(undefined));
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { parseDelimited } from "./parseDelimited";
|
||||
|
||||
self.onmessage = (event: MessageEvent<{ text: string; delimiter: string }>) => {
|
||||
try {
|
||||
self.postMessage({ rows: parseDelimited(event.data.text, event.data.delimiter) });
|
||||
} catch {
|
||||
self.postMessage({ error: true });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect, it } from "vitest";
|
||||
import type { Item } from "../../state/session";
|
||||
import { deliveriesByReply } from "./deliveries";
|
||||
import { fromHistory, initialState, reduce } from "../../state/session";
|
||||
|
||||
it("keeps the latest declaration per file within a turn and preserves previous turns", () => {
|
||||
const tool = (id: string, description: string): Item => ({ t: "tool", id, running: false, children: [], tool: {
|
||||
name: "present_files", readOnly: true, deliveries: [{ path: "report.md", description }],
|
||||
} });
|
||||
const items: Item[] = [
|
||||
{ t: "user", id: "u1", text: "one" },
|
||||
tool("t1", "old"), tool("t2", "new"),
|
||||
{ t: "say", id: "s1", text: "done", done: true },
|
||||
{ t: "user", id: "u2", text: "two" },
|
||||
tool("t3", "second"),
|
||||
{ t: "say", id: "s2", text: "done", done: true },
|
||||
];
|
||||
const map = deliveriesByReply(items);
|
||||
expect(map.get("s1")?.map((f) => f.description)).toEqual(["new"]);
|
||||
expect(map.get("s2")?.map((f) => f.description)).toEqual(["second"]);
|
||||
});
|
||||
|
||||
it("preserves structured deliveries through live folding and history restoration", () => {
|
||||
const files = [{ root: "/granted", path: "report 100%.md", description: "Report" }];
|
||||
const tool = { id: "present", name: "present_files", readOnly: true, output: "done", deliveries: files };
|
||||
const live = reduce(reduce(initialState, { kind: "tool_dispatch", tool: { id: tool.id, name: tool.name, readOnly: true } }), { kind: "tool_result", tool });
|
||||
const restored = fromHistory([
|
||||
{ role: "assistant", content: "", toolCalls: [{ id: tool.id, name: tool.name }] },
|
||||
{ role: "tool", content: "done", toolCallId: tool.id, toolName: tool.name, deliveries: files },
|
||||
]);
|
||||
expect([...deliveriesByReply(live.items).values()]).toEqual([files]);
|
||||
expect([...deliveriesByReply(restored.items).values()]).toEqual([files]);
|
||||
const failed = fromHistory([
|
||||
{ role: "assistant", content: "", toolCalls: [{ id: tool.id, name: tool.name }] },
|
||||
{ role: "tool", content: "failed", toolCallId: tool.id, toolName: tool.name, toolFailed: true, deliveries: files },
|
||||
]);
|
||||
expect(deliveriesByReply(failed.items).size).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Item } from "../../state/session";
|
||||
import type { DeliveredFile } from "../../port/workspace";
|
||||
import { opensTurn } from "../blocks";
|
||||
|
||||
export function deliveriesByReply(items: Item[]): Map<string, DeliveredFile[]> {
|
||||
const out = new Map<string, DeliveredFile[]>();
|
||||
let files = new Map<string, DeliveredFile>();
|
||||
let anchor = "", call = "";
|
||||
const flush = () => {
|
||||
if (files.size && (anchor || call)) out.set(anchor || call, [...files.values()]);
|
||||
files = new Map(); anchor = ""; call = "";
|
||||
};
|
||||
for (const item of items) {
|
||||
if (opensTurn(item)) flush();
|
||||
if (item.t === "say" && item.text) anchor = item.id;
|
||||
if (item.t === "tool" && !item.running && !item.tool.err && item.tool.deliveries?.length) {
|
||||
call = item.id;
|
||||
for (const file of item.tool.deliveries) files.set(JSON.stringify([file.root ?? "", file.path]), file);
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { MockPort } from "../../port/mock";
|
||||
import { Composer } from "../Composer";
|
||||
import { DeliveryCard } from "./DeliveryCard";
|
||||
import "../testkit";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function DeliveryComposer({ path, submit }: { path: string; submit: (text: string) => Promise<boolean> }) {
|
||||
const [inject, setInject] = useState({ n: 0, text: "" });
|
||||
return <>
|
||||
<DeliveryCard files={[{ path }]} reply={{
|
||||
files: { file: () => vi.fn(), link: () => null, image: () => null, title: () => `C:\\Workspace\\${path}` },
|
||||
onAddFile: (reference) => setInject((was) => ({ n: was.n + 1, text: reference })),
|
||||
onQuote: vi.fn(), canRegenerate: () => false, hasLaterTurns: () => false,
|
||||
onRegenerate: vi.fn(), canBranch: () => false, onBranch: vi.fn(),
|
||||
}} />
|
||||
<Composer port={new MockPort()} status={null} running={false} inject={inject}
|
||||
onSubmit={submit} onChanged={vi.fn()} onError={vi.fn()} />
|
||||
</>;
|
||||
}
|
||||
|
||||
it.each([
|
||||
["docs/方案 100%.md", "@docs/方案\\ 100%.md"],
|
||||
[String.raw`before\ space.txt`, String.raw`@before\\ space.txt`],
|
||||
[String.raw`repeat\\ space.txt`, String.raw`@repeat\\\ space.txt`],
|
||||
])("adds %j to the real Composer without using the Windows display title as identity", async (path, reference) => {
|
||||
const user = userEvent.setup(), submit = vi.fn().mockResolvedValue(true);
|
||||
render(<DeliveryComposer path={path} submit={submit} />);
|
||||
const box = screen.getByRole("combobox", { name: "任务输入" }) as HTMLTextAreaElement;
|
||||
await user.click(box);
|
||||
await user.keyboard("inspect ");
|
||||
await user.click(screen.getByRole("button", { name: /^更多操作/ }));
|
||||
await user.click(screen.getByRole("menuitem", { name: "添加到对话" }));
|
||||
await waitFor(() => expect(box.value).toBe(`inspect ${reference} `));
|
||||
expect(submit).not.toHaveBeenCalled();
|
||||
await user.keyboard("please{Enter}");
|
||||
await waitFor(() => expect(submit).toHaveBeenCalledWith(`inspect ${reference} please`));
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
.delivery-files {
|
||||
container-type: inline-size;
|
||||
min-width: 0;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.delivery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
@container (min-width: 720px) {
|
||||
.delivery-grid:not([data-single]) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
.delivery-file {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
min-height: 72px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md, 8px);
|
||||
background: var(--raised);
|
||||
color: var(--text);
|
||||
}
|
||||
.delivery-file:hover {
|
||||
background: var(--float-hi);
|
||||
}
|
||||
.delivery-file[data-current] {
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent) 5%, var(--raised));
|
||||
}
|
||||
.delivery-open {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
border: 0;
|
||||
border-radius: inherit;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: start;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.delivery-open:focus-visible, .delivery-more:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
.delivery-icon {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 32px;
|
||||
width: 32px;
|
||||
height: 36px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.delivery-icon-badge {
|
||||
font: 600 8px/1.2 var(--mono);
|
||||
letter-spacing: .2px;
|
||||
}
|
||||
.delivery-icon svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
.delivery-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.delivery-heading, .delivery-name {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
min-width: 0;
|
||||
}
|
||||
.delivery-heading {
|
||||
gap: 8px;
|
||||
}
|
||||
.delivery-name {
|
||||
font-size: var(--fs-ui);
|
||||
font-weight: var(--w-med);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.delivery-name > span:first-child, .delivery-description, .delivery-location {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.delivery-extension {
|
||||
flex: none;
|
||||
color: var(--muted);
|
||||
}
|
||||
.delivery-description, .delivery-location {
|
||||
display: block;
|
||||
font-size: var(--fs-meta);
|
||||
line-height: 1.5;
|
||||
color: var(--muted);
|
||||
}
|
||||
.delivery-current {
|
||||
flex: none;
|
||||
color: var(--accent-ink);
|
||||
font-size: var(--fs-micro);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.delivery-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-width: 60px;
|
||||
height: 32px;
|
||||
margin: 0 8px 0 0;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font: var(--w-med) var(--fs-meta) var(--ui);
|
||||
cursor: pointer;
|
||||
}
|
||||
.delivery-more:hover, .delivery-more[aria-expanded="true"] {
|
||||
background: var(--float-hi);
|
||||
color: var(--text);
|
||||
}
|
||||
.delivery-more svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
.delivery-menu {
|
||||
position: fixed;
|
||||
z-index: 60;
|
||||
min-width: 200px;
|
||||
max-width: calc(100vw / var(--zoom, 1) - 12px);
|
||||
padding: 5px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md, 8px);
|
||||
background: var(--float);
|
||||
color: var(--text);
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
.delivery-menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 0;
|
||||
border-radius: var(--r-sm);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: var(--fs-ui) var(--ui);
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
.delivery-menu button:hover:not(:disabled), .delivery-menu button:focus-visible {
|
||||
background: var(--float-hi);
|
||||
outline: none;
|
||||
}
|
||||
.delivery-menu button:disabled { color: var(--muted); cursor: default; }
|
||||
.delivery-menu svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: none;
|
||||
color: var(--muted);
|
||||
}
|
||||
.delivery-feedback {
|
||||
grid-column: 1 / -1;
|
||||
padding: 0 12px 10px 54px;
|
||||
font-size: var(--fs-meta);
|
||||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.delivery-feedback[data-kind="error"] {
|
||||
color: var(--err-ink);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
.document-preview, .document-pdf {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.document-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.document-controls button, .document-controls select {
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.document-notice, .document-preview > p {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.document-html {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 240px;
|
||||
background: white;
|
||||
border: 0;
|
||||
}
|
||||
.document-table, .document-pdf-scroll, .document-picture {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.document-table table {
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.document-table td, .document-table th {
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--border);
|
||||
max-width: 400px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 29px;
|
||||
}
|
||||
.document-table th {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
background: var(--bg);
|
||||
}
|
||||
.document-table tr[aria-hidden] td {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
.document-pdf-scroll {
|
||||
padding: 12px;
|
||||
}
|
||||
.document-pdf-page {
|
||||
position: relative;
|
||||
width: fit-content;
|
||||
margin: auto;
|
||||
}
|
||||
.document-pdf-page canvas {
|
||||
display: block;
|
||||
}
|
||||
.document-pdf-page .textLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.document-picture img {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// @vitest-environment jsdom
|
||||
import { expect, it } from "vitest";
|
||||
import { staticDocument } from "./staticDocument";
|
||||
import { parseDelimited } from "./parseDelimited";
|
||||
|
||||
it("keeps CSV values literal including multiline quoted cells and leading zeros", () => {
|
||||
expect(parseDelimited('id,value\n001,"hello\nworld"\n002,"=SUM(A1:A2)"', ",")).toEqual([
|
||||
["id", "value"], ["001", "hello\nworld"], ["002", "=SUM(A1:A2)"],
|
||||
]);
|
||||
expect(parseDelimited('a\t"b\tc"\n1\t2', "\t")).toEqual([["a", "b\tc"], ["1", "2"]]);
|
||||
expect(() => parseDelimited('"unterminated', ",")).toThrow();
|
||||
expect(() => parseDelimited("a,b,c", ",", 2)).toThrow();
|
||||
});
|
||||
|
||||
it("static HTML cannot execute or load remote resources", () => {
|
||||
const html = staticDocument('<html><head><meta http-equiv="refresh" content="0;url=https://bad.test"><style>p{color:red}</style></head><body><script>alert(1)</script><p onclick="alert(1)">hello</p><iframe src="https://bad.test"></iframe><form action="https://bad.test"><input></form><img src="https://bad.test/x"><a href="https://bad.test">link</a></body></html>');
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
expect(doc.querySelector("script,iframe,form,input,[onclick],[href]")).toBeNull();
|
||||
expect(doc.querySelector('meta[http-equiv="refresh"]')).toBeNull();
|
||||
expect(doc.querySelector("img")?.getAttribute("src")).toBeNull();
|
||||
expect(doc.querySelector("p")?.textContent).toBe("hello");
|
||||
expect(doc.querySelector("style")?.textContent).toContain("color:red");
|
||||
expect(doc.querySelector('meta[http-equiv="Content-Security-Policy"]')?.getAttribute("content")).toContain("default-src 'none'");
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { MockPort } from "../../port/mock";
|
||||
import { useWorkbenchFiles } from "../useWorkbenchFiles";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("warns about disk changes using metadata without reloading an unsaved draft", async () => {
|
||||
const port = new MockPort();
|
||||
const read = vi.spyOn(port, "workspaceFile").mockResolvedValue({ path: "notes.md", content: "# Saved", revision: "hash", observedVersion: "disk-1" });
|
||||
const stat = vi.spyOn(port, "workspacePreviewInfo").mockResolvedValue({
|
||||
ref: { path: "notes.md" }, kind: "markdown", version: "disk-2", size: 9, limit: 2 << 20, readOnly: false,
|
||||
});
|
||||
const paths = ["notes.md"];
|
||||
const { result } = renderHook(() => useWorkbenchFiles({
|
||||
port, workspaceRoot: "/fixture", path: "notes.md", openPaths: paths, shown: true, refresh: "", poll: false,
|
||||
}));
|
||||
await waitFor(() => expect(result.current.file).toBeTruthy());
|
||||
act(() => result.current.setDraft("# My unsaved changes"));
|
||||
await waitFor(() => expect(result.current.error).toContain("未保存的编辑已保留"));
|
||||
expect(result.current.draft).toBe("# My unsaved changes");
|
||||
expect(stat).toHaveBeenCalled();
|
||||
expect(read).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import Papa from "papaparse";
|
||||
|
||||
export function parseDelimited(text: string, delimiter: string, maxCells = 100_000): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let cells = 0;
|
||||
let failure = "";
|
||||
Papa.parse<string[]>(text.replace(/^\uFEFF/, ""), {
|
||||
delimiter, dynamicTyping: false, skipEmptyLines: false,
|
||||
step(result, parser) {
|
||||
if (result.errors.length) failure = "invalid";
|
||||
cells += result.data.length;
|
||||
if (cells > maxCells) failure = "limit";
|
||||
if (failure) parser.abort();
|
||||
else rows.push(result.data);
|
||||
},
|
||||
});
|
||||
if (failure) throw new Error(failure);
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { PreviewInfo } from "../../port/workspace";
|
||||
|
||||
type Kind = PreviewInfo["kind"];
|
||||
export const renderers: ReadonlyArray<{ kind: Kind; extensions: RegExp; source: boolean }> = [
|
||||
{ kind: "markdown", extensions: /\.(md|markdown|mdx)$/i, source: true },
|
||||
{ kind: "image", extensions: /\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i, source: false },
|
||||
{ kind: "pdf", extensions: /\.pdf$/i, source: false },
|
||||
{ kind: "table", extensions: /\.(csv|tsv)$/i, source: true },
|
||||
{ kind: "html", extensions: /\.html?$/i, source: true },
|
||||
{ kind: "unsupported", extensions: /\.(docx?|xlsx?|pptx?|zip|gz|7z|rar|exe|dll|bin)$/i, source: false },
|
||||
];
|
||||
|
||||
export function previewKind(path: string): Kind {
|
||||
return renderers.find((r) => r.extensions.test(path))?.kind ?? "text";
|
||||
}
|
||||
|
||||
export const documentView = (path: string) => !["markdown", "text"].includes(previewKind(path));
|
||||
export const sourceView = (path: string) => renderers.find((r) => r.extensions.test(path))?.source ?? true;
|
||||
@@ -0,0 +1,20 @@
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
export function staticDocument(source: string): string {
|
||||
const clean = DOMPurify.sanitize(source, {
|
||||
WHOLE_DOCUMENT: true,
|
||||
ADD_TAGS: ["style"],
|
||||
FORBID_TAGS: ["script", "iframe", "object", "embed", "form", "input", "button", "textarea", "select", "link", "base", "meta"],
|
||||
FORBID_ATTR: ["srcdoc", "srcset", "action", "formaction", "href", "xlink:href", "ping", "target"],
|
||||
});
|
||||
const doc = new DOMParser().parseFromString(clean, "text/html");
|
||||
for (const el of doc.querySelectorAll("[src]")) {
|
||||
const src = el.getAttribute("src") ?? "";
|
||||
if (el.tagName !== "IMG" || !/^data:image\/(png|jpeg|gif|webp|avif|bmp|x-icon);base64,/i.test(src)) el.removeAttribute("src");
|
||||
}
|
||||
const policy = doc.createElement("meta");
|
||||
policy.httpEquiv = "Content-Security-Policy";
|
||||
policy.content = "default-src 'none'; script-src 'none'; style-src 'unsafe-inline'; img-src data:; connect-src 'none'; form-action 'none'; base-uri 'none'";
|
||||
doc.head.prepend(policy);
|
||||
return "<!doctype html>" + doc.documentElement.outerHTML;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, renderHook } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { MockPort } from "../../port/mock";
|
||||
import { HttpError } from "../../port/port";
|
||||
import { useWorkbenchFiles } from "../useWorkbenchFiles";
|
||||
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); });
|
||||
|
||||
it.each([[404, "workspace.file_missing"], [403, "workspace.preview_denied"]])("surfaces a %s poll failure and removes stale saved text", async (status, code) => {
|
||||
vi.useFakeTimers();
|
||||
const port = new MockPort();
|
||||
const read = vi.spyOn(port, "workspaceFile").mockResolvedValue({ path: "a.md", content: "saved", revision: "r1" });
|
||||
const paths = ["a.md"];
|
||||
const { result } = renderHook(() => useWorkbenchFiles({ port, workspaceRoot: "/work", path: "a.md", openPaths: paths, shown: true, refresh: "", poll: true }));
|
||||
await act(async () => {});
|
||||
expect(result.current.draft).toBe("saved");
|
||||
read.mockRejectedValue(new HttpError(status, "unavailable", { code }));
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(3000); });
|
||||
expect(result.current.errorCode).toBe(code);
|
||||
expect(result.current.file).toBeNull();
|
||||
expect(result.current.draft).toBe("");
|
||||
read.mockResolvedValue({ path: "a.md", content: "recreated", revision: "r2" });
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(3000); });
|
||||
expect(result.current.draft).toBe("recreated");
|
||||
expect(result.current.error).toBe("");
|
||||
});
|
||||
|
||||
it("does not poll metadata for every keystroke and preserves a draft after deletion", async () => {
|
||||
vi.useFakeTimers();
|
||||
const port = new MockPort();
|
||||
vi.spyOn(port, "workspaceFile").mockResolvedValue({ path: "a.md", content: "saved", revision: "r1", observedVersion: "v1" });
|
||||
const stat = vi.spyOn(port, "workspacePreviewInfo").mockResolvedValue({ ref: { path: "a.md" }, kind: "markdown", version: "v1", size: 5, limit: 2 << 20, readOnly: false });
|
||||
const paths = ["a.md"];
|
||||
const { result } = renderHook(() => useWorkbenchFiles({ port, workspaceRoot: "/work", path: "a.md", openPaths: paths, shown: true, refresh: "", poll: true }));
|
||||
await act(async () => {});
|
||||
for (const text of ["a", "ab", "abc"]) await act(async () => result.current.setDraft(text));
|
||||
expect(stat).toHaveBeenCalledOnce();
|
||||
stat.mockRejectedValue(new HttpError(404, "gone", { code: "workspace.file_missing" }));
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(3000); });
|
||||
expect(result.current.draft).toBe("abc");
|
||||
expect(result.current.error).toContain("找不到");
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { MockPort } from "../../port/mock";
|
||||
import { HttpError } from "../../port/port";
|
||||
import { useDocument } from "./useDocument";
|
||||
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
|
||||
|
||||
it("discards a previous file's late response and aborts on close", async () => {
|
||||
const port = new MockPort();
|
||||
let done!: (value: { data: ArrayBuffer; version: string }) => void;
|
||||
let signal: AbortSignal | undefined;
|
||||
vi.spyOn(port, "workspacePreviewContent").mockImplementationOnce((_ref, s?: AbortSignal) => {
|
||||
signal = s;
|
||||
return new Promise((resolve) => { done = resolve; });
|
||||
});
|
||||
const { result, rerender, unmount } = renderHook(({ path }) => useDocument(port, path, true, ""), { initialProps: { path: "one.csv" } });
|
||||
await waitFor(() => expect(done).toBeDefined());
|
||||
rerender({ path: "two.csv" });
|
||||
await waitFor(() => expect(result.current.value?.info.ref.path).toBe("two.csv"));
|
||||
expect(signal?.aborted).toBe(true);
|
||||
await act(async () => done({ data: new ArrayBuffer(0), version: "old" }));
|
||||
expect(result.current.value?.info.ref.path).toBe("two.csv");
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("removes stale bytes when access is revoked and reload fails", async () => {
|
||||
const port = new MockPort();
|
||||
const { result } = renderHook(() => useDocument(port, "report.csv", true, ""));
|
||||
await waitFor(() => expect(result.current.value?.data).toBeTruthy());
|
||||
vi.spyOn(port, "workspacePreviewInfo").mockRejectedValue(new HttpError(403, "denied", { code: "workspace.preview_denied" }));
|
||||
act(() => result.current.reload());
|
||||
await waitFor(() => expect(result.current.value?.error).toBeTruthy());
|
||||
expect(result.current.value?.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it("checks metadata on invalidation and only forces unchanged bytes for manual refresh", async () => {
|
||||
const port = new MockPort();
|
||||
const content = vi.spyOn(port, "workspacePreviewContent");
|
||||
const info = vi.spyOn(port, "workspacePreviewInfo");
|
||||
const { result, rerender } = renderHook(({ refresh }) => useDocument(port, "report.csv", true, refresh), { initialProps: { refresh: "first" } });
|
||||
await waitFor(() => expect(result.current.value?.data).toBeTruthy());
|
||||
rerender({ refresh: "tool finished" });
|
||||
await waitFor(() => expect(info).toHaveBeenCalledTimes(2));
|
||||
expect(content).toHaveBeenCalledTimes(1);
|
||||
act(() => result.current.reload());
|
||||
await waitFor(() => expect(content).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AgentPort } from "../../port/port";
|
||||
import type { PreviewInfo } from "../../port/workspace";
|
||||
import { parseGrantedFile } from "../grantedfiles";
|
||||
import { reason } from "../../i18n/kernel";
|
||||
|
||||
type Loaded = { info: PreviewInfo; data?: ArrayBuffer; error?: string };
|
||||
|
||||
export function useDocument(port: AgentPort, path: string, shown: boolean, refresh: string) {
|
||||
const held = useMemo(() => parseGrantedFile(path), [path]);
|
||||
const ref = useMemo(() => ({ path: held?.rel ?? path, ...(held ? { root: held.root } : {}) }), [held, path]);
|
||||
const [state, setState] = useState<{ ref: typeof ref; port: AgentPort; value: Loaded }>();
|
||||
const [reload, setReload] = useState(0);
|
||||
const cache = useRef<Loaded | undefined>(undefined);
|
||||
const lastReload = useRef(0);
|
||||
const request = useCallback(() => setReload((n) => n + 1), []);
|
||||
useEffect(() => {
|
||||
cache.current = undefined;
|
||||
setState(undefined);
|
||||
}, [ref, port]);
|
||||
useEffect(() => {
|
||||
if (!shown) return;
|
||||
const controller = new AbortController();
|
||||
let busy = false;
|
||||
const read = async (force: boolean) => {
|
||||
if (busy || controller.signal.aborted) return;
|
||||
busy = true;
|
||||
let observed: PreviewInfo | undefined;
|
||||
try {
|
||||
const info = await port.workspacePreviewInfo(ref, controller.signal);
|
||||
observed = info;
|
||||
if (controller.signal.aborted) return;
|
||||
let value: Loaded;
|
||||
if (info.kind === "unsupported" || info.size > info.limit) value = { info };
|
||||
else if (!force && cache.current?.data && cache.current.info.version === info.version) value = { ...cache.current, info, error: undefined };
|
||||
else {
|
||||
const loaded = await port.workspacePreviewContent(ref, controller.signal);
|
||||
value = { info: { ...info, version: loaded.version || info.version }, data: loaded.data };
|
||||
}
|
||||
if (!controller.signal.aborted) { cache.current = value; setState({ ref, port, value }); }
|
||||
} catch (e) {
|
||||
if (!controller.signal.aborted) {
|
||||
cache.current = undefined;
|
||||
setState({ ref, port, value: { info: observed ?? { ref, kind: "unsupported", size: 0, limit: 0, version: "", readOnly: true }, error: reason(e) } });
|
||||
}
|
||||
} finally { busy = false; }
|
||||
};
|
||||
const force = lastReload.current !== reload;
|
||||
lastReload.current = reload;
|
||||
void read(force);
|
||||
const timer = window.setInterval(() => void read(false), 3000);
|
||||
return () => { controller.abort(); window.clearInterval(timer); };
|
||||
}, [port, ref, shown, refresh, reload]);
|
||||
return { value: state?.ref === ref && state.port === port ? state.value : undefined, reload: request };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface DocumentViewState {
|
||||
top?: number;
|
||||
left?: number;
|
||||
page?: number;
|
||||
zoom?: number;
|
||||
}
|
||||
|
||||
export function documentState(states: Map<string, DocumentViewState>, path: string): DocumentViewState {
|
||||
let state = states.get(path);
|
||||
if (!state) { state = {}; states.set(path, state); }
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { fileReference } from "./fileReference";
|
||||
|
||||
it.each([
|
||||
["docs/方案 100%.md", "@docs/方案\\ 100%.md"],
|
||||
[String.raw`literal\name.txt`, String.raw`@literal\name.txt`],
|
||||
[String.raw`before\ space.txt`, String.raw`@before\\ space.txt`],
|
||||
[String.raw`repeat\\ space.txt`, String.raw`@repeat\\\ space.txt`],
|
||||
["before\\\ttab.txt", "@before\\\\\ttab.txt"],
|
||||
[String.raw`\\server\share\team notes.txt`, String.raw`@\\server\share\team\ notes.txt`],
|
||||
])("preserves the kernel @-token spelling for %j", (path, expected) => {
|
||||
expect(fileReference(path)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each(["", "trailing\\", "trailing ", "line\nbreak", "line\rbreak", "line\fbreak", "notes)", "notes.md:10", "server:resource", String.raw`C:\notes.md`])(
|
||||
"refuses an ambiguous or unrepresentable reference %j", (path) => expect(fileReference(path)).toBe(""),
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
/** Mirrors control.EscapeRefPath's @-token grammar, not JS or shell escaping.
|
||||
* Backslashes stay literal; only space/tab consume a preceding backslash.
|
||||
* Reject spellings whose token boundary, punctuation or MCP classification can
|
||||
* change the selected file when the person adds prose after the reference. */
|
||||
export function fileReference(path: string): string {
|
||||
if (!path || path.includes(":") || path.endsWith("\\") || path.trimEnd() !== path || /[\r\n\f]|[.,;!?)}\]]$/.test(path)) return "";
|
||||
let token = "@";
|
||||
for (const character of path) {
|
||||
if (character === " " || character === "\t") token += "\\";
|
||||
token += character;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
@@ -58,6 +58,7 @@ it.each([["before", false], ["before", true], ["after", false], ["after", true]]
|
||||
const fresh = page({ id: "fresh", target: "tf", url: "https://example.com/fresh", title: "Fresh" });
|
||||
const { container, rerender } = render(panel(port, { tabs: [agent] }));
|
||||
await user.click(await screen.findByRole("button", { name: "index.html" }));
|
||||
await user.click(await screen.findByRole("button", { name: "交互运行" }));
|
||||
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
|
||||
await user.click(container.querySelector('[data-action="workbench.files"]')!);
|
||||
expect(container.querySelector(".workbench-body")?.hasAttribute("data-files")).toBe(true);
|
||||
@@ -86,6 +87,7 @@ it("keeps a superseded successful user tab owned while a separate preview claim
|
||||
const userTab = page({ id: "user", target: "tu", url: "https://example.com", title: "User page" });
|
||||
const { container, rerender } = render(panel(port));
|
||||
await user.click(await screen.findByRole("button", { name: "index.html" }));
|
||||
await user.click(await screen.findByRole("button", { name: "交互运行" }));
|
||||
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
|
||||
const pending = openPage(port, userTab.url);
|
||||
await user.click(container.querySelector('[data-action="workbench.files"]')!);
|
||||
@@ -106,6 +108,7 @@ describe("opening an HTML file from the explorer", () => {
|
||||
vi.spyOn(port, "workspaceFile").mockReturnValue(new Promise((resolve) => { read = resolve; }));
|
||||
const { container } = render(panel(port));
|
||||
await user.click(await screen.findByRole("button", { name: "index.html" }));
|
||||
await user.click(await screen.findByRole("button", { name: "交互运行" }));
|
||||
await waitFor(() => expect(port.workspaceFile).toHaveBeenCalled());
|
||||
if (choice === "Files") await user.click(container.querySelector('[data-action="workbench.files"]')!);
|
||||
else await user.click(screen.getByRole("button", { name: "新建标签" }));
|
||||
@@ -129,6 +132,7 @@ describe("opening an HTML file from the explorer", () => {
|
||||
const fresh = page({ id: "fresh", target: "tf", url: "https://example.com/fresh", title: "Fresh" });
|
||||
const { container, rerender } = render(panel(port, { tabs: [agent] }));
|
||||
await user.click(await screen.findByRole("button", { name: "index.html" }));
|
||||
await user.click(await screen.findByRole("button", { name: "交互运行" }));
|
||||
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
|
||||
await user.click(container.querySelector('[data-action="workbench.files"]')!);
|
||||
const reject = () => act(async () => reply(new Response(JSON.stringify({ code: "browser.open_failed", params: { tab: page().id } }), { status: 400 })));
|
||||
@@ -151,13 +155,14 @@ describe("opening an HTML file from the explorer", () => {
|
||||
const { port, open } = setup();
|
||||
const { rerender } = render(panel(port));
|
||||
await user.click(await screen.findByRole("button", { name: "index.html" }));
|
||||
await user.click(await screen.findByRole("button", { name: "交互运行" }));
|
||||
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
|
||||
expect(open).toHaveBeenCalledWith(`${ROOT}/index.html`, true);
|
||||
expect(unseenPageOpenings(port)).toHaveLength(1);
|
||||
rerender(panel(port, { tabs: [page()] }));
|
||||
expect(unseenPageOpenings(port)).toHaveLength(0);
|
||||
await waitFor(() => expect(document.querySelector(".workbench-canvas .bpanel")).toBeTruthy());
|
||||
expect(screen.getByRole("button", { name: "预览" }).getAttribute("aria-pressed")).toBe("true");
|
||||
expect(screen.getByRole("button", { name: "交互运行" }).getAttribute("aria-pressed")).toBe("true");
|
||||
expect(document.querySelector(".cm-content")).toBeNull();
|
||||
expect(unseenPageOpenings(port)).toHaveLength(0);
|
||||
});
|
||||
@@ -170,6 +175,7 @@ describe("opening an HTML file from the explorer", () => {
|
||||
open.mockReturnValue(new Promise((resolve) => (answer = resolve)));
|
||||
const { rerender } = render(panel(port));
|
||||
await user.click(await screen.findByRole("button", { name: "index.html" }));
|
||||
await user.click(await screen.findByRole("button", { name: "交互运行" }));
|
||||
await waitFor(() => expect(open).toHaveBeenCalled());
|
||||
rerender(panel(port, { tabs: [page()] }));
|
||||
expect(screen.queryByRole("tab", { name: "Landing" })).toBeNull();
|
||||
@@ -187,6 +193,8 @@ describe("opening an HTML file from the explorer", () => {
|
||||
render(panel(port));
|
||||
(await screen.findByRole("button", { name: "about.htm" })).focus();
|
||||
await user.keyboard("{Enter}");
|
||||
screen.getByRole("button", { name: "交互运行" }).focus();
|
||||
await user.keyboard("{Enter}");
|
||||
await waitFor(() => expect(open).toHaveBeenCalledWith(`${ROOT}/about.htm`, true));
|
||||
});
|
||||
|
||||
@@ -195,11 +203,12 @@ describe("opening an HTML file from the explorer", () => {
|
||||
const { port, open } = setup();
|
||||
const { rerender } = render(panel(port));
|
||||
await user.click(await screen.findByRole("button", { name: "index.html" }));
|
||||
await user.click(await screen.findByRole("button", { name: "交互运行" }));
|
||||
await waitFor(() => expect(open).toHaveBeenCalledTimes(1));
|
||||
rerender(panel(port, { tabs: [page()] }));
|
||||
await user.click(await screen.findByRole("button", { name: "编辑" }));
|
||||
await waitFor(() => expect(document.querySelector(".cm-content")).toBeTruthy());
|
||||
await user.click(screen.getByRole("button", { name: "预览" }));
|
||||
await user.click(screen.getByRole("button", { name: "交互运行" }));
|
||||
await user.click(screen.getAllByRole("button", { name: "index.html" }).find((b) => b.classList.contains("workbench-tree-row"))!);
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
expect(document.querySelector(".workbench-canvas .bpanel")).toBeTruthy();
|
||||
@@ -211,6 +220,7 @@ describe("opening an HTML file from the explorer", () => {
|
||||
const { port, open, control } = setup({ revision: () => revision });
|
||||
const { rerender } = render(panel(port));
|
||||
await user.click(await screen.findByRole("button", { name: "index.html" }));
|
||||
await user.click(await screen.findByRole("button", { name: "交互运行" }));
|
||||
await waitFor(() => expect(open).toHaveBeenCalled());
|
||||
rerender(panel(port, { tabs: [page()], wrote: 0 }));
|
||||
await waitFor(() => expect(document.querySelector(".bpanel")).toBeTruthy());
|
||||
@@ -229,6 +239,7 @@ describe("opening an HTML file from the explorer", () => {
|
||||
render(panel(port, { tabs: [page()] }));
|
||||
expect(screen.getByRole("tab", { name: "Landing" })).toBeTruthy();
|
||||
await user.click(await screen.findByRole("button", { name: "index.html" }));
|
||||
await user.click(await screen.findByRole("button", { name: "交互运行" }));
|
||||
await waitFor(() => expect(screen.queryByRole("tab", { name: "Landing" })).toBeNull());
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(document.querySelector(".workbench-canvas .bpanel")).toBeTruthy();
|
||||
@@ -240,6 +251,7 @@ describe("opening an HTML file from the explorer", () => {
|
||||
vi.spyOn(port, "workspaces").mockResolvedValue({ current: "D:\\proj\\site\\", canSwitch: true, canIsolate: true, recents: [] });
|
||||
const { rerender } = render(panel(port));
|
||||
await user.click(await screen.findByRole("button", { name: "index.html" }));
|
||||
await user.click(await screen.findByRole("button", { name: "交互运行" }));
|
||||
await waitFor(() => expect(open).toHaveBeenCalledWith("D:\\proj\\site/index.html", true));
|
||||
open.mockClear();
|
||||
rerender(panel(port, { tabs: [page({ url: "file:///D:/proj/site/index.html" })] }));
|
||||
@@ -260,6 +272,7 @@ describe("opening an HTML file from the explorer", () => {
|
||||
refusedLikeTheKernel(port, "browser.open_failed", "browser.url_refused: outside the workspace");
|
||||
render(panel(port));
|
||||
await user.click(await screen.findByRole("button", { name: "index.html" }));
|
||||
await user.click(await screen.findByRole("button", { name: "交互运行" }));
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toBe("打不开这个网页:browser.url_refused: outside the workspace");
|
||||
await waitFor(() => expect(document.querySelector(".cm-content")).toBeTruthy());
|
||||
@@ -272,6 +285,7 @@ describe("opening an HTML file from the explorer", () => {
|
||||
refusedLikeTheKernel(port, "browser.network_path", "a network path");
|
||||
render(panel(port));
|
||||
await user.click(await screen.findByRole("button", { name: "index.html" }));
|
||||
await user.click(await screen.findByRole("button", { name: "交互运行" }));
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toContain("网络上另一台机器");
|
||||
expect(alert.textContent).not.toContain("{");
|
||||
@@ -300,22 +314,24 @@ describe("opening an HTML file from the explorer", () => {
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens an HTML file in the editor where the window draws no pages", async () => {
|
||||
it("opens static HTML where the window draws no browser pages", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { port, open } = setup({ views: false });
|
||||
render(panel(port));
|
||||
await user.click(await screen.findByRole("button", { name: "index.html" }));
|
||||
await waitFor(() => expect(document.querySelector(".cm-content")).toBeTruthy());
|
||||
expect(screen.queryByRole("button", { name: "预览" })).toBeNull();
|
||||
await screen.findByTitle("HTML 静态预览");
|
||||
expect(screen.queryByRole("button", { name: "交互运行" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "预览" })).toBeTruthy();
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens an HTML file in the editor when the workspace is on another machine", async () => {
|
||||
it("opens static HTML when the workspace is on another machine", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { port, open } = setup();
|
||||
render(panel(port, { remote: true }));
|
||||
await user.click(await screen.findByRole("button", { name: "index.html" }));
|
||||
await waitFor(() => expect(document.querySelector(".cm-content")).toBeTruthy());
|
||||
await screen.findByTitle("HTML 静态预览");
|
||||
expect(screen.queryByRole("button", { name: "交互运行" })).toBeNull();
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -149,12 +149,6 @@ function basename(path: string): string {
|
||||
return parts[parts.length - 1] || path;
|
||||
}
|
||||
|
||||
// refToken is the same token the @ completion produces, so a dropped file and a
|
||||
// completed one leave the composer identical.
|
||||
export function refToken(rel: string): string {
|
||||
return "@" + rel;
|
||||
}
|
||||
|
||||
// A paste long enough to bury the composer becomes a chip instead. Lines and
|
||||
// characters both count, whichever comes first: eight hundred lines of narrow
|
||||
// log is under the character cap and has already eaten the window.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, within } from "@testing-library/react";
|
||||
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { Packages } from "./Packages";
|
||||
@@ -64,8 +64,9 @@ describe("installed package invocation copy", () => {
|
||||
button.focus();
|
||||
await user.keyboard("{Enter}");
|
||||
expect(copy).toHaveBeenCalledExactlyOnceWith(command);
|
||||
expect(button.getAttribute("title")).toBe(t("已复制"));
|
||||
expect(button.querySelector('[aria-live="polite"]')?.textContent).toBe(t("已复制"));
|
||||
fireEvent.focus(button);
|
||||
expect(screen.getByRole("tooltip").textContent).toBe(t("已复制"));
|
||||
expect(details.open).toBe(true);
|
||||
expect(toggle).not.toHaveBeenCalled();
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
@@ -101,11 +102,12 @@ describe("installed package invocation copy", () => {
|
||||
copy.mockRejectedValueOnce(new Error("clipboard denied"));
|
||||
const button = row.getByRole("button", { name: label(entries[0][1]) });
|
||||
await user.click(button);
|
||||
expect(button.getAttribute("title")).toBe(t("复制失败,请重试"));
|
||||
expect(button.querySelector('[aria-live="polite"]')?.textContent).toBe(t("复制失败,请重试"));
|
||||
fireEvent.focus(button);
|
||||
expect(screen.getByRole("tooltip").textContent).toBe(t("复制失败,请重试"));
|
||||
expect(row.getByRole<HTMLButtonElement>("switch", { name: t("关闭") + " review-kit" }).disabled).toBe(false);
|
||||
await user.click(button);
|
||||
expect(button.getAttribute("title")).toBe(t("已复制"));
|
||||
expect(button.querySelector('[aria-live="polite"]')?.textContent).toBe(t("已复制"));
|
||||
expect(copy).toHaveBeenCalledTimes(2);
|
||||
expect(details.open).toBe(true);
|
||||
});
|
||||
@@ -123,10 +125,10 @@ describe("installed package invocation copy", () => {
|
||||
const current = row.getByRole("button", { name: label(command) });
|
||||
await act(async () => outcome === "success" ? finish() : fail(new Error("previous clipboard denied")));
|
||||
expect(current.getAttribute("data-state")).toBe("idle");
|
||||
expect(current.getAttribute("title")).toBe(label(command));
|
||||
expect(current.querySelector('[aria-live="polite"]')?.textContent).toBe(label(command));
|
||||
await user.click(current);
|
||||
expect(copy).toHaveBeenLastCalledWith(command);
|
||||
expect(current.getAttribute("title")).toBe(t("已复制"));
|
||||
expect(current.querySelector('[aria-live="polite"]')?.textContent).toBe(t("已复制"));
|
||||
});
|
||||
|
||||
it("keeps copy feedback local while package management remains available", async () => {
|
||||
@@ -160,6 +162,6 @@ describe("installed package invocation copy", () => {
|
||||
expect(props.onChanged).not.toHaveBeenCalled();
|
||||
await user.click(current);
|
||||
expect(copy).toHaveBeenCalledTimes(2);
|
||||
expect(current.getAttribute("title")).toBe(t("已复制"));
|
||||
expect(current.querySelector('[aria-live="polite"]')?.textContent).toBe(t("已复制"));
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,7 @@ export interface Primitive {
|
||||
}
|
||||
|
||||
export const PRIMITIVES: Primitive[] = [
|
||||
{ file: "ui/TooltipButton.tsx", callbacks: { onClick: "click", onPointerDown: "pointerdown" } },
|
||||
{ file: "ui/Switch.tsx", callbacks: { onClick: "click" } },
|
||||
{ file: "ui/Menu.tsx", callbacks: { onPick: "click" } },
|
||||
];
|
||||
|
||||
@@ -37,7 +37,7 @@ it.each(["zh", "en"])("copies the canonical namespaced invocation with keyboard
|
||||
await user.keyboard("{Enter}");
|
||||
expect(write).toHaveBeenCalledWith("/review-kit:review");
|
||||
expect(await screen.findByText(t("已复制"))).toBeTruthy();
|
||||
expect(copy().getAttribute("title")).toBe(t("已复制"));
|
||||
expect(copy().querySelector('[aria-live="polite"]')?.textContent).toBe(t("已复制"));
|
||||
expect(setSkillEnabled).not.toHaveBeenCalled();
|
||||
expect(props.onDone).not.toHaveBeenCalled();
|
||||
expect(props.onFailed).not.toHaveBeenCalled();
|
||||
@@ -97,7 +97,7 @@ it("does not carry a previous invocation's pending copy feedback into refreshed
|
||||
view.rerender(<SkillRow {...props} sk={{ ...props.sk, slashName: "review-kit:audit" }} />);
|
||||
await act(async () => finish());
|
||||
expect(screen.queryByText(t("已复制"))).toBeNull();
|
||||
expect(copy("/review-kit:audit").getAttribute("title")).toBe(t("复制调用命令 {command}", { command: "/review-kit:audit" }));
|
||||
expect(copy("/review-kit:audit").querySelector('[aria-live="polite"]')?.textContent).toBe(t("复制调用命令 {command}", { command: "/review-kit:audit" }));
|
||||
await user.click(copy("/review-kit:audit"));
|
||||
expect(write).toHaveBeenLastCalledWith("/review-kit:audit");
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
.icon-tooltip {
|
||||
position: fixed;
|
||||
z-index: 70;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
max-width: min(340px, calc(100vw / var(--zoom, 1) - 12px));
|
||||
padding: 6px 9px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--float);
|
||||
color: var(--text);
|
||||
box-shadow: var(--shadow-float);
|
||||
font: var(--w-med) var(--fs-ui) var(--ui);
|
||||
overflow-wrap: anywhere;
|
||||
pointer-events: none;
|
||||
}
|
||||
.icon-tooltip kbd {
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { AgentPort } from "../port/port";
|
||||
import { host } from "../port/host";
|
||||
import type { LocalRefs } from "./Markdown";
|
||||
import { GRANTED, conversationFile, isLocalPathShape } from "./filelinks";
|
||||
import { GRANTED_FILE, grantedFileKey } from "./grantedfiles";
|
||||
import { GRANTED_FILE, grantedFileKey, grantedFilePath } from "./grantedfiles";
|
||||
import { WorkbenchFileMenu, absoluteWorkspacePath, type FileMenu } from "./WorkbenchFileMenu";
|
||||
|
||||
export function useConversationFiles(port: AgentPort, root: string, remote: boolean, shown: boolean, reveal: () => void, fail: (e: unknown) => void) {
|
||||
@@ -65,6 +65,7 @@ export function useConversationFiles(port: AgentPort, root: string, remote: bool
|
||||
// A path in text may become a link when it is in the workspace or in a granted folder.
|
||||
const linkable = (href: string) => path(href) ?? (isLocalPathShape(href) ? grantedFileKey(granted, href) : null);
|
||||
return {
|
||||
file: (ref) => () => ask(ref.root ? grantedFilePath(ref.root, ref.path) : ref.path),
|
||||
path,
|
||||
linkable,
|
||||
title: (href) => { const file = path(href); return file ? absoluteWorkspacePath(root, file) : ""; },
|
||||
|
||||
@@ -5,12 +5,14 @@ import { locate } from "../state/gittree";
|
||||
import { WORKSPACE_FILE_MISSING, reason } from "../i18n/kernel";
|
||||
import { HttpError } from "../port/port";
|
||||
import { parseGrantedFile } from "./grantedfiles";
|
||||
import { t } from "../i18n";
|
||||
|
||||
type Entry = {
|
||||
file: WorkspaceFile | null; draft: string; diff: string; error: string; missing: boolean;
|
||||
errorCode: string;
|
||||
pending: "" | "read" | "save"; issued: number;
|
||||
};
|
||||
const blank = (): Entry => ({ file: null, draft: "", diff: "", error: "", missing: false, pending: "", issued: 0 });
|
||||
const blank = (): Entry => ({ file: null, draft: "", diff: "", error: "", errorCode: "", missing: false, pending: "", issued: 0 });
|
||||
const dirty = (entry: Entry) => !!entry.file && entry.draft !== entry.file.content;
|
||||
|
||||
export function useWorkbenchFiles({ port, workspaceRoot, repos = [], path, openPaths, shown, refresh, poll }: {
|
||||
@@ -59,12 +61,19 @@ export function useWorkbenchFiles({ port, workspaceRoot, repos = [], path, openP
|
||||
entry.file = file;
|
||||
entry.draft = file.content;
|
||||
entry.error = "";
|
||||
entry.errorCode = "";
|
||||
entry.missing = false;
|
||||
if (!quiet) entry.diff = patch?.diff ?? "";
|
||||
} catch (e) {
|
||||
if (valid() && !quiet) {
|
||||
const code = e instanceof HttpError ? e.reason?.code ?? "" : "";
|
||||
const unavailable = [WORKSPACE_FILE_MISSING, "workspace.root_not_granted", "workspace.preview_denied", "workspace.file_unreadable"].includes(code);
|
||||
if (valid() && !dirty(entry) && (!quiet || unavailable)) {
|
||||
changed = true;
|
||||
entry.error = reason(e);
|
||||
entry.errorCode = code;
|
||||
entry.missing = e instanceof HttpError && e.reason?.code === WORKSPACE_FILE_MISSING;
|
||||
entry.file = null;
|
||||
entry.draft = "";
|
||||
}
|
||||
} finally {
|
||||
if (valid()) { entry.pending = ""; if (changed) notify(); }
|
||||
@@ -82,6 +91,32 @@ export function useWorkbenchFiles({ port, workspaceRoot, repos = [], path, openP
|
||||
const timer = window.setInterval(() => { cancel = read(true) ?? cancel; }, 3000);
|
||||
return () => { cancel?.(); window.clearInterval(timer); };
|
||||
}, [read, poll, shown]);
|
||||
const isDirty = dirty(entry), observedVersion = entry.file?.observedVersion, saving = entry.pending === "save";
|
||||
useEffect(() => {
|
||||
if (!shown || !isDirty || !observedVersion || saving) return;
|
||||
const controller = new AbortController();
|
||||
let busy = false;
|
||||
const inspect = async () => {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
const held = parseGrantedFile(path);
|
||||
const info = await port.workspacePreviewInfo({ path: held?.rel ?? path, root: held?.root }, controller.signal);
|
||||
if (!controller.signal.aborted && info.version !== entry.file?.observedVersion) {
|
||||
const error = t("磁盘内容已变化,未保存的编辑已保留");
|
||||
if (entry.error !== error) { entry.error = error; notify(); }
|
||||
}
|
||||
} catch (e) {
|
||||
if (!controller.signal.aborted) {
|
||||
const error = reason(e);
|
||||
if (entry.error !== error) { entry.error = error; notify(); }
|
||||
}
|
||||
} finally { busy = false; }
|
||||
};
|
||||
void inspect();
|
||||
const timer = window.setInterval(() => void inspect(), 3000);
|
||||
return () => { controller.abort(); window.clearInterval(timer); };
|
||||
}, [port, entry, path, shown, refresh, isDirty, observedVersion, saving, notify]);
|
||||
|
||||
const setDraft = (draft: string) => {
|
||||
if (current.current !== entries || !entry.file || entry.file.readOnly) return;
|
||||
|
||||
@@ -34,7 +34,7 @@ describe("the controls below a message", () => {
|
||||
const copy = screen.getByRole("button", { name: "复制" });
|
||||
await user.click(copy);
|
||||
expect(write).toHaveBeenCalledExactlyOnceWith(text);
|
||||
expect(copy.getAttribute("title")).toBe("已复制");
|
||||
expect(copy.querySelector('[aria-live="polite"]')?.textContent).toBe("已复制");
|
||||
write.mockRestore();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, 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 { App } from "./App";
|
||||
@@ -53,7 +53,10 @@ describe("the window bar's view switch", () => {
|
||||
it("names its buttons by label and shortcut, not by icon alone", async () => {
|
||||
render(<App hub={readyHub()} />);
|
||||
const button = await screen.findByRole("button", { name: "工作台" });
|
||||
expect(button.getAttribute("title")).toMatch(/工作台\s+(Ctrl 3|⌘3)/);
|
||||
fireEvent.focus(button);
|
||||
const tip = document.querySelector(".icon-tooltip");
|
||||
expect(tip?.textContent).toContain("工作台");
|
||||
expect(tip?.querySelector("kbd")?.textContent).toMatch(/Ctrl 3|⌘3/);
|
||||
expect(button.getAttribute("aria-keyshortcuts")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { MockPort } from "../port/mock";
|
||||
import { WorkbenchPanel } from "./WorkbenchPanel";
|
||||
@@ -40,7 +40,7 @@ it("does not open a preview page for an HTML file that cannot be read", async ()
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still previews an HTML file the editor cannot read, such as one over the size limit", async () => {
|
||||
it("allows explicit interactive opening when static HTML exceeds its limit", async () => {
|
||||
const port = new MockPort();
|
||||
vi.spyOn(port, "workspaceFiles").mockResolvedValue({ files: ["big.html"], directories: [] });
|
||||
vi.spyOn(port, "workspaceFile").mockRejectedValue(new HttpError(400, "unreadable", { code: "workspace.file_unreadable" }));
|
||||
@@ -51,5 +51,21 @@ it("still previews an HTML file the editor cannot read, such as one over the siz
|
||||
const open = vi.spyOn(port, "browserOpen").mockResolvedValue({ id: "p", target: "t", url: "file:///one/big.html", title: "", active: false });
|
||||
const view = render(<WorkbenchPanel {...props(port)} />);
|
||||
view.rerender(<WorkbenchPanel {...props(port)} fileRequest={{ path: "big.html" }} />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "交互运行" }));
|
||||
await waitFor(() => expect(open).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("shows metadata and a typed error for an unknown binary instead of an editor", async () => {
|
||||
const port = new MockPort();
|
||||
vi.spyOn(port, "workspaceFiles").mockResolvedValue({ files: ["opaque.data"], directories: [] });
|
||||
vi.spyOn(port, "workspaceFile").mockRejectedValue(new HttpError(400, "unreadable", { code: "workspace.file_unreadable" }));
|
||||
vi.spyOn(port, "workspacePreviewInfo").mockResolvedValue({
|
||||
ref: { path: "opaque.data" }, kind: "text", size: 128, limit: 2 << 20, version: "v1", readOnly: false,
|
||||
});
|
||||
vi.spyOn(port, "workspacePreviewContent").mockRejectedValue(new HttpError(415, "binary", { code: "workspace.preview_not_text" }));
|
||||
const view = render(<WorkbenchPanel {...props(port)} />);
|
||||
view.rerender(<WorkbenchPanel {...props(port)} fileRequest={{ path: "opaque.data" }} />);
|
||||
await waitFor(() => expect(document.querySelector(".document-preview [role=alert]")?.textContent).toContain("文本"));
|
||||
expect(screen.getByText("128 B")).toBeTruthy();
|
||||
expect(document.querySelector(".cm-content")).toBeNull();
|
||||
});
|
||||
@@ -23,6 +23,7 @@ it.each(['event-first', 'reply-first'])('preserves newer Files when preview work
|
||||
const open = vi.spyOn(port,'browserOpen').mockReturnValue(new Promise((_,reject)=>{fail=reject;}));
|
||||
const {rerender} = render(<WorkbenchPanel {...props} port={port} tabs={[agent]} />);
|
||||
fireEvent.click(await screen.findByRole('button',{name:'index.html'}));
|
||||
fireEvent.click(await screen.findByRole('button',{name:'交互运行'}));
|
||||
await waitFor(()=>expect(workspaces).toHaveBeenCalled());
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
fireEvent.click(document.querySelector('[data-action="workbench.files"]')!);
|
||||
@@ -57,6 +58,7 @@ it('does not start a preview after its file read finishes behind a newer Files s
|
||||
const open = vi.spyOn(port, 'browserOpen').mockResolvedValue(page);
|
||||
render(<WorkbenchPanel {...props} port={port} tabs={[agent]} />);
|
||||
fireEvent.click(await screen.findByRole('button',{name:'index.html'}));
|
||||
fireEvent.click(await screen.findByRole('button',{name:'交互运行'}));
|
||||
await waitFor(()=>expect(reading).toHaveBeenCalled());
|
||||
fireEvent.click(document.querySelector('[data-action="workbench.files"]')!);
|
||||
await act(async()=>read({path:'index.html',content:'hello',revision:'r1'}));
|
||||
@@ -76,6 +78,7 @@ it('follows a genuine agent tab while an older preview is still preparing its wo
|
||||
const open = vi.spyOn(port, 'browserOpen').mockResolvedValue(page);
|
||||
const {rerender} = render(<WorkbenchPanel {...props} port={port} tabs={[agent]} />);
|
||||
fireEvent.click(await screen.findByRole('button',{name:'index.html'}));
|
||||
fireEvent.click(await screen.findByRole('button',{name:'交互运行'}));
|
||||
await waitFor(()=>expect(lookup).toHaveBeenCalled());
|
||||
fireEvent.click(document.querySelector('[data-action="workbench.files"]')!);
|
||||
const fresh = {...agent,id:'fresh',target:'tf',title:'Fresh',active:false};
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export function treeRows(
|
||||
files: string[],
|
||||
directories: string[],
|
||||
query: string,
|
||||
closed: Set<string>,
|
||||
): TreeRow[] {
|
||||
const matches = query
|
||||
? files.filter((path) =>
|
||||
path.toLocaleLowerCase().includes(query.toLocaleLowerCase()),
|
||||
)
|
||||
: files;
|
||||
const folders = new Set<string>(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);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import type { Plugin } from "vite";
|
||||
|
||||
export function pdfAssets(): Plugin {
|
||||
const require = createRequire(import.meta.url);
|
||||
const root = dirname(require.resolve("pdfjs-dist/package.json"));
|
||||
const files = new Map<string, string>();
|
||||
for (const dir of ["cmaps", "standard_fonts", "wasm"]) {
|
||||
for (const name of readdirSync(join(root, dir))) files.set(`pdfjs/${dir}/${name}`, join(root, dir, name));
|
||||
}
|
||||
files.set("pdfjs/LICENSE", join(root, "LICENSE"));
|
||||
files.set("licenses/papaparse.txt", join(dirname(require.resolve("papaparse")), "LICENSE"));
|
||||
const purify = dirname(dirname(require.resolve("dompurify")));
|
||||
files.set("licenses/dompurify.txt", join(purify, "LICENSE"));
|
||||
files.set("licenses/dompurify-mpl.txt", join(purify, "LICENSE-MPL"));
|
||||
files.set("licenses/lucide-react.txt", join(dirname(require.resolve("lucide-react/package.json")), "LICENSE"));
|
||||
return {
|
||||
name: "pdf-local-assets",
|
||||
generateBundle() {
|
||||
for (const [fileName, path] of files) this.emitFile({ type: "asset", fileName, source: readFileSync(path) });
|
||||
},
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
const path = files.get((req.url ?? "").split("?")[0].replace(/^\//, ""));
|
||||
if (!path) return next();
|
||||
res.setHeader("Content-Type", path.endsWith(".wasm") ? "application/wasm" : "application/octet-stream");
|
||||
res.end(readFileSync(path));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { pdfAssets } from "./tools/pdf-assets.ts";
|
||||
|
||||
interface ProxyEvents {
|
||||
on(event: "proxyReq", cb: (req: { setHeader(k: string, v: string): void }) => void): void;
|
||||
@@ -36,7 +37,7 @@ export default defineConfig(({ mode }) => {
|
||||
const serve = loadEnv(mode, ".", "REASONIX").REASONIX_SERVE;
|
||||
return {
|
||||
base: "./",
|
||||
plugins: [react()],
|
||||
plugins: [react(), pdfAssets()],
|
||||
// Stylesheets are stubbed for tests by default, `?raw` included, so a
|
||||
// guard that reads app.css to check a rule gets an empty string and passes
|
||||
// on nothing. Processing it is what makes that guard able to fail.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
owner: @SivanCola
|
||||
backup: @esengine
|
||||
status: active
|
||||
reviewed: 2026-10-21
|
||||
---
|
||||
|
||||
# File delivery and preview / 文件交付与预览
|
||||
|
||||
`present_files` declares one to four existing files. The host validates every
|
||||
file before returning a versioned result; failure publishes no delivery cards.
|
||||
The declaration identifies deliverables, not verified completion.
|
||||
|
||||
`present_files` 每次声明一到四个现有文件。宿主全部校验成功后才产生交付卡片;
|
||||
声明交付文件不等于验证内容正确,也不替代任务完成判定。
|
||||
|
||||
## Reading / 阅读
|
||||
|
||||
The model-facing tool is **off by default**. Opt in in the user configuration
|
||||
(`~/.reasonix/config.toml`, or `config.toml` under `REASONIX_HOME`), then reopen
|
||||
the session. A project cannot enable or disable it. `tools.disabled` and an
|
||||
explicit `tools.enabled` allowlist still apply.
|
||||
|
||||
While off, it is absent from
|
||||
both the tool registry and provider requests; file previews and existing cards
|
||||
remain available.
|
||||
|
||||
Enabling it adds its fixed schema to the next session's tool
|
||||
prefix, which may cause a first-request cache miss, never mid-session churn.
|
||||
|
||||
模型工具**默认关闭**。在用户配置(`~/.reasonix/config.toml`,或
|
||||
`REASONIX_HOME` 下的 `config.toml`)中启用后重新打开会话;项目配置不能修改此选择。
|
||||
`tools.disabled` 和显式的 `tools.enabled` 白名单仍然有效。
|
||||
|
||||
关闭时工具不会进入注册表或模型请求;文件预览、消息文件链接和历史交付卡片仍然可用。
|
||||
|
||||
启用会改变下一会话的工具前缀,
|
||||
可能使首个请求缓存未命中,但不会在会话运行中改变工具定义。
|
||||
|
||||
```toml
|
||||
[tools]
|
||||
present_files = true
|
||||
```
|
||||
|
||||
| Format / 格式 | View / 展示 | Limit / 限额 |
|
||||
| --- | --- | --- |
|
||||
| Markdown, MDX | Rendered Markdown; JSX is not executed / Markdown 正文,不执行 JSX | 2 MiB |
|
||||
| Text, code / 文本、代码 | Existing source editor / 原有源码编辑器 | 2 MiB |
|
||||
| CSV, TSV | Worker parsing, virtual rows and columns / Worker 解析、虚拟表格 | 2 MiB, 100,000 cells / 单元格 |
|
||||
| HTML | Sanitized, sandboxed static iframe / 清理后的静态沙箱预览 | 2 MiB |
|
||||
| PDF | Local PDF.js worker, page controls, zoom, selectable text / 本地阅读器、分页、缩放、文字选择 | 32 MiB |
|
||||
| Images / 图片 | Image element with zoom / 图片缩放 | 10 MiB |
|
||||
| Office, archives, binaries / Office、压缩包、二进制 | Information and available actions / 文件信息与可用操作 | No inline preview / 无内联预览 |
|
||||
|
||||
The workspace tree, message links and cards share workbench tabs. Full references
|
||||
include the authorized root and relative path; display names do not identify files.
|
||||
Open records read the source file's current content, without version snapshots.
|
||||
|
||||
文件树、消息链接和卡片复用工作台标签。文件身份包含授权根目录和相对路径,
|
||||
不能以显示名称去重。历史交付记录读取源文件当前内容,不保存版本快照。
|
||||
|
||||
Cards use two columns at a container width of 720 CSS pixels, otherwise one.
|
||||
The More menu contains copy, attachment and supported native actions. The current
|
||||
marker follows the actual visible workbench file. Type icons do not imply support.
|
||||
|
||||
卡片容器达到 720 CSS 像素时显示两列,否则显示一列。“更多”菜单提供复制路径、
|
||||
添加到对话及宿主支持的原生操作。“查看中”跟随实际可见文件;图标不代表预览能力。
|
||||
|
||||
## Boundaries / 边界
|
||||
|
||||
- Controller preview APIs use the shared bounded, root-confined file service.
|
||||
内容采用原始字节读取,不通过 JSON/base64 传输 PDF。
|
||||
- Root confinement remains enforced on the opened descriptor. Missing, denied,
|
||||
non-regular, oversized and non-text files have typed errors.
|
||||
文件缺失、拒绝访问、非普通文件、超限和非文本均有独立错误码。
|
||||
- HTML scripts, submission, redirects and external resources are blocked.
|
||||
本地桌面可显式选择已有的“交互运行”;远程页面不能调用本机系统打开。
|
||||
- Only host-confirmed successful presenter results project to cards. Live and
|
||||
restored projections use the original tool history, including full raw results.
|
||||
普通工具输出相同 JSON 不会产生交付卡片,失败和拒绝调用也不会产生成功卡片。
|
||||
- Visible binary previews inspect metadata every three seconds and reload bytes
|
||||
only on change. Obsolete requests are aborted or discarded.
|
||||
可见二进制预览约每三秒检查元数据,变化后才读取正文;旧请求会取消或丢弃。
|
||||
- Saved text disappears after a typed missing/denied response. Dirty drafts stay
|
||||
intact and show the observation failure; saving still checks revisions.
|
||||
文件删除或撤权后清除旧的已保存正文,未保存草稿仍保留,并继续使用保存冲突检查。
|
||||
|
||||
New `deliveries` and `observedVersion` fields are optional. Old histories remain
|
||||
readable; older clients keep the ordinary tool result. Tool schemas stay stable
|
||||
within a run, while adding the capability changes the upgrade-time catalog.
|
||||
|
||||
新增字段均可缺省;旧会话仍可读取,旧客户端仍显示普通工具结果。
|
||||
运行期间工具定义不变;升级时新增能力会改变工具目录,可能造成一次缓存失效。
|
||||
@@ -90,7 +90,8 @@ Checked against `studio` at `d779d89bc` on 2026-10-08.
|
||||
| Worktree merge dialog | Unverified | Worktree handling exists in `ui/Workspaces.tsx`; the merge flow was not checked |
|
||||
| Scheduled tasks (heartbeat) | Missing | #11384 |
|
||||
| Keyboard shortcuts cheat sheet | Unverified | Key handling is in `ui/keys.ts`, `ui/windowkeys.ts`; no cheat sheet found by name; no issue yet |
|
||||
| CSV and media preview in the workspace panel | Unverified | `ui/WorkbenchPanel.tsx` and `ui/CodeEditor.tsx` exist; file kinds not checked |
|
||||
| CSV and media preview in the workspace panel | Have differently | `ui/document`: CSV/TSV, PDF and still images render in the workbench; audio/video remain unsupported. CSV/TSV、PDF 和图片可直接阅读,音视频仍不支持内联预览。 |
|
||||
| File delivery cards / 文件交付卡片 | Have | `present_files` and `ui/document/DeliveryCard.tsx`: validated file references reopen in the shared workbench; auxiliary actions live in More. 已验证文件引用复用工作台标签,辅助操作位于“更多”。 |
|
||||
| IM gateway (WeChat, Feishu, QQ) | Removed on purpose | Commit `f352dfbec` "remove the IM bot gateway"; `docs/MIGRATING.md` keeps the `[bot]` section untouched; #11952 asks for it back |
|
||||
|
||||
## Rows added by Studio
|
||||
|
||||
@@ -9,6 +9,31 @@ reviewed: 2026-09-29
|
||||
|
||||
This document records the provider-visible contract for Reasonix compile-time built-in tools. It is generated from the same canonical schema path used by the runtime registry.
|
||||
|
||||
`present_files` declares up to four existing files in the workspace or authorized folders.
|
||||
Its versioned result remains in the canonical tool history; live and restored cards use
|
||||
the host-confirmed capability identity and the complete result, even when model context
|
||||
is shortened.
|
||||
|
||||
Opening a card reads the current source file, not an immutable snapshot.
|
||||
It does not certify task completion and is not admitted by the Observe posture.
|
||||
|
||||
`present_files` 每次声明最多四个工作区或已授权目录中的现有文件。有版本的结果保存在原始工具历史中;
|
||||
实时与恢复后的卡片使用宿主确认的能力身份和完整结果,不依赖模型上下文是否被压缩。
|
||||
点击卡片读取源文件当前内容,不保存版本快照,不构成任务完成证明,也不进入 Observe 模式。
|
||||
|
||||
This tool is conditional: user-only `tools.present_files = true` enables it on
|
||||
session open. Off by default, it contributes nothing to the registry or provider
|
||||
request. The unconditional builtin budget remains 7,185 tokens.
|
||||
|
||||
Boot effect tests
|
||||
verify absence while off, the exact enabled schema, denied reads, direct and
|
||||
capability-proxy calls, and stable schemas within a turn. Viewers remain available.
|
||||
|
||||
该工具按条件注册:仅用户配置 `tools.present_files = true` 在打开会话时启用。
|
||||
默认关闭,不进入注册表或模型请求;无条件内置工具预算保持 7,185 tokens。
|
||||
Boot 效果测试覆盖关闭时缺席、启用时精确的 schema、读取拒绝、直接调用、能力代理及轮内稳定性。
|
||||
文件预览不受此开关影响。
|
||||
|
||||
| Tool | Read-only | Description |
|
||||
| --- | --- | --- |
|
||||
| `bash` | false | Execute a command in the shell and return combined stdout/stderr. Use for builds, tests, git, package managers, etc. To read or modify a file use read_file / edit_file / write_file — never Get-Content, cat, sed, head/tail or a shell redirect. read_file returns up to 2000 lines per call, each prefixed with its line number, and those numbers are what edit_file targets; paging a file through the shell instead costs one round trip per chunk and yields no line anchors. |
|
||||
|
||||
@@ -171,8 +171,8 @@ func applyUnifiedProviderToolSurface(reg *tool.Registry, goalTurnsUnreachable bo
|
||||
}
|
||||
}
|
||||
// advise, best_of_n, run_script, the peer tools and read_mcp_resource are
|
||||
// registered only when config asks, at boot.
|
||||
for _, name := range []string{advisor.Name, bestof.Name, script.Name, "list_peers", "send_peer_message", "dispatch_to_project", "view_image", plugin.ResourceReadToolName} {
|
||||
// registered only when config asks, at boot; present_files follows the same rule.
|
||||
for _, name := range []string{advisor.Name, bestof.Name, script.Name, "list_peers", "send_peer_message", "dispatch_to_project", "view_image", "present_files", plugin.ResourceReadToolName} {
|
||||
if _, ok := reg.Get(name); ok {
|
||||
allow = append(allow, name)
|
||||
}
|
||||
|
||||
@@ -372,6 +372,7 @@ func (b *builder) wireTools() error {
|
||||
// The full inventory registers for use_capability; the provider-visible surface narrows later.
|
||||
addBuiltins(t.reg, cfg.Tools.Enabled, env.writeRoots, env.bash, env.bashTimeout, env.search, b.stderr, root, b.proxy, env.forbidReadRoots, env.readRoots, env.readPaths, env.sessionGuard, env.managedConfig, opts.FileOverlay, opts.TerminalRunner, env.sessionTemp, b.fileWriteReceipt)
|
||||
addViewImage(t.reg, cfg, b.model.entry, env, root)
|
||||
addPresentFiles(t.reg, cfg, env, root)
|
||||
bindFileViews(t.reg, cfg.Tools.ChangedFilesProtected())
|
||||
t.peerLink, t.peerTools = peerTools(t.reg, cfg)
|
||||
t.settingLink = addSettingTool(t.reg, cfg)
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/eventwire"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/runtime/agent/testutil"
|
||||
"reasonix/internal/session/control"
|
||||
"reasonix/internal/tools/builtin"
|
||||
)
|
||||
|
||||
func TestEffectPresentFilesSurvivesTheRealToolPipeline(t *testing.T) {
|
||||
for _, proxy := range []bool{false, true} {
|
||||
name := "direct"
|
||||
if proxy {
|
||||
name = "capability"
|
||||
}
|
||||
for _, mode := range []string{"off", "on", "forbidden", "disabled", "excluded"} {
|
||||
t.Run(name+"/"+mode, func(t *testing.T) { presentFilesEffect(t, proxy, mode) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func presentFilesEffect(t *testing.T, proxy bool, mode string) {
|
||||
t.Helper()
|
||||
home := filepath.Join(isolateConfigHome(t), ".reasonix")
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
settings := "[tools]\npresent_files = true\n"
|
||||
switch mode {
|
||||
case "off":
|
||||
settings = ""
|
||||
case "forbidden":
|
||||
settings += "[sandbox]\nforbid_read = [\"report.md\"]\n"
|
||||
case "disabled":
|
||||
settings += "disabled = [\"present_files\"]\n"
|
||||
case "excluded":
|
||||
settings += "enabled = [\"read_file\"]\n"
|
||||
}
|
||||
writeFile(t, home, "config.toml", settings)
|
||||
enabled := mode == "on" || mode == "forbidden"
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
registerBootTokenProfileTestProvider()
|
||||
call := provider.ToolCall{ID: "present", Name: "present_files", Arguments: `{"files":[{"path":"report.md","description":"Report"}]}`}
|
||||
if proxy {
|
||||
call.Name = "use_capability"
|
||||
call.Arguments = `{"action":"call","capability_id":"tool:present_files","arguments":{"files":[{"path":"report.md","description":"Report"}]}}`
|
||||
}
|
||||
prov := testutil.NewMock("present-files",
|
||||
testutil.Turn{ToolCalls: []provider.ToolCall{call}},
|
||||
testutil.Turn{Text: "Done."},
|
||||
)
|
||||
setBootTokenProfileTestProvider(t, prov)
|
||||
writeFile(t, dir, "reasonix.toml", "default_model = \"test-model\"\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n")
|
||||
writeFile(t, dir, "report.md", "# Report")
|
||||
approveWorkspace(t, dir)
|
||||
events := make(chan event.Event, 1000)
|
||||
ctrl, err := Build(context.Background(), Options{WorkspaceRoot: dir, Sink: event.FuncSink(func(e event.Event) { events <- e })})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(ctrl.Close)
|
||||
want := builtin.Workspace{}.PresentFiles()
|
||||
count := 0
|
||||
for _, entry := range ctrl.AllToolContractEntries() {
|
||||
if entry.Name == want.Name() {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if (count == 1) != enabled || count > 1 {
|
||||
t.Fatalf("registry contribution = %d, enabled = %v", count, enabled)
|
||||
}
|
||||
if err := ctrl.NewSession(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctrl.SetToolApprovalMode(control.ToolApprovalYolo)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
if err := ctrl.Run(ctx, "Present the existing report.md file."); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for len(events) > 0 {
|
||||
e := <-events
|
||||
if e.Kind == event.ToolResult && e.Tool.ID == "present" {
|
||||
wire := eventwire.ToWire(e)
|
||||
if wire.Tool == nil {
|
||||
t.Fatal("tool result missing wire payload")
|
||||
}
|
||||
if mode != "on" {
|
||||
if e.Tool.Err == "" || len(wire.Tool.Deliveries) != 0 {
|
||||
t.Fatalf("unavailable/forbidden call produced delivery: %+v", e.Tool)
|
||||
}
|
||||
} else if len(wire.Tool.Deliveries) != 1 {
|
||||
t.Fatalf("delivery did not reach UI: %+v, output %q", e.Tool, e.Tool.Output)
|
||||
}
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("present tool result missing")
|
||||
}
|
||||
found = false
|
||||
for _, message := range ctrl.History() {
|
||||
for _, call := range message.ToolCalls {
|
||||
if call.ID == "present" && call.CapabilityID == "tool:present_files" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if mode == "on" && !found {
|
||||
t.Fatal("host-confirmed identity was not persisted")
|
||||
}
|
||||
requests := prov.Requests()
|
||||
if len(requests) < 2 || !reflect.DeepEqual(requests[0].Tools, requests[1].Tools) {
|
||||
t.Fatal("delivery changed the tool schema within the turn")
|
||||
}
|
||||
if !enabled {
|
||||
first, err := json.Marshal(requests[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(first, []byte(want.Name())) {
|
||||
t.Fatal("disabled presenter leaked into the first provider request")
|
||||
}
|
||||
}
|
||||
count = 0
|
||||
for _, schema := range requests[0].Tools {
|
||||
if schema.Name != want.Name() {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
var gotSchema, wantSchema any
|
||||
if err := json.Unmarshal(schema.Parameters, &gotSchema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(provider.CanonicalizeSchema(want.Schema()), &wantSchema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if schema.Description != want.Description() || !reflect.DeepEqual(gotSchema, wantSchema) {
|
||||
t.Fatalf("unexpected provider contribution: %+v", schema)
|
||||
}
|
||||
}
|
||||
if (count == 1) != enabled || count > 1 {
|
||||
t.Fatalf("provider contribution = %d, enabled = %v", count, enabled)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/contract/tool"
|
||||
"reasonix/internal/tools/builtin"
|
||||
)
|
||||
|
||||
// addPresentFiles contributes no tool or provider schema until the user opts in.
|
||||
func addPresentFiles(reg *tool.Registry, cfg *config.Config, env toolEnvironment, root string) {
|
||||
if !cfg.Tools.PresentFiles || len(cfg.Tools.Enabled) > 0 && !slices.Contains(cfg.Tools.Enabled, "present_files") {
|
||||
return
|
||||
}
|
||||
reg.Add(builtin.Workspace{
|
||||
Dir: root, WriteRoots: env.writeRoots, ReadRoots: env.readRoots, ForbidReadRoots: env.forbidReadRoots,
|
||||
}.PresentFiles())
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
// Package workspacefile provides bounded, root-confined document reads.
|
||||
package workspacefile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
TextLimit = 2 << 20
|
||||
ImageLimit = 10 << 20
|
||||
PDFLimit = 32 << 20
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDenied = errors.New("file is outside authorized roots")
|
||||
ErrMissing = errors.New("file does not exist")
|
||||
ErrNotFile = errors.New("not a regular file")
|
||||
ErrTooLarge = errors.New("file exceeds preview limit")
|
||||
ErrNotText = errors.New("file is not UTF-8 text")
|
||||
ErrUnsupported = errors.New("file format has no preview")
|
||||
ErrChanged = errors.New("file changed during preview read")
|
||||
)
|
||||
|
||||
type Ref struct {
|
||||
Path string `json:"path"`
|
||||
Root string `json:"root,omitempty"`
|
||||
}
|
||||
|
||||
type Info struct {
|
||||
Ref Ref `json:"ref"`
|
||||
Kind string `json:"kind"`
|
||||
Size int64 `json:"size"`
|
||||
Version string `json:"version"`
|
||||
Limit int64 `json:"limit"`
|
||||
ReadOnly bool `json:"readOnly"`
|
||||
}
|
||||
|
||||
// Service receives roots already authorized by its host.
|
||||
type Service struct {
|
||||
Primary string
|
||||
Extra []string
|
||||
}
|
||||
|
||||
func (s Service) Resolve(path string) (Ref, error) {
|
||||
if !filepath.IsAbs(path) {
|
||||
if !filepath.IsLocal(path) {
|
||||
return Ref{}, ErrDenied
|
||||
}
|
||||
return Ref{Path: filepath.ToSlash(filepath.Clean(path))}, nil
|
||||
}
|
||||
for _, root := range append([]string{s.Primary}, s.Extra...) {
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err == nil && filepath.IsLocal(rel) {
|
||||
ref := Ref{Path: filepath.ToSlash(rel)}
|
||||
if filepath.Clean(root) != filepath.Clean(s.Primary) {
|
||||
ref.Root = root
|
||||
}
|
||||
return ref, nil
|
||||
}
|
||||
}
|
||||
return Ref{}, ErrDenied
|
||||
}
|
||||
|
||||
func (s Service) open(ref Ref) (*os.File, error) {
|
||||
path := filepath.FromSlash(ref.Path)
|
||||
if !filepath.IsLocal(path) || path == "." {
|
||||
return nil, ErrDenied
|
||||
}
|
||||
base := s.Primary
|
||||
if ref.Root != "" && filepath.Clean(ref.Root) != filepath.Clean(base) {
|
||||
found := false
|
||||
for _, extra := range s.Extra {
|
||||
if filepath.Clean(extra) == filepath.Clean(ref.Root) {
|
||||
base, found = extra, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, ErrDenied
|
||||
}
|
||||
}
|
||||
root, err := os.OpenRoot(base)
|
||||
if err != nil {
|
||||
return nil, classify(err)
|
||||
}
|
||||
defer root.Close()
|
||||
resolvedBase, err := filepath.EvalSymlinks(base)
|
||||
if err != nil {
|
||||
return nil, classify(err)
|
||||
}
|
||||
target, err := filepath.EvalSymlinks(filepath.Join(base, path))
|
||||
if err != nil {
|
||||
return nil, classify(err)
|
||||
}
|
||||
relative, err := filepath.Rel(resolvedBase, target)
|
||||
if err != nil || !filepath.IsLocal(relative) {
|
||||
return nil, ErrDenied
|
||||
}
|
||||
// Stat before opening excludes FIFOs and devices, which may block on open.
|
||||
info, err := root.Stat(path)
|
||||
if err != nil {
|
||||
return nil, classify(err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, ErrNotFile
|
||||
}
|
||||
file, err := root.OpenFile(path, os.O_RDONLY|nonblock, 0)
|
||||
if err != nil {
|
||||
return nil, classify(err)
|
||||
}
|
||||
info, err = file.Stat()
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
file.Close()
|
||||
if err != nil {
|
||||
return nil, classify(err)
|
||||
}
|
||||
return nil, ErrNotFile
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func classify(err error) error {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("%w: %w", ErrMissing, err)
|
||||
}
|
||||
if errors.Is(err, fs.ErrPermission) {
|
||||
return fmt.Errorf("%w: %w", ErrDenied, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func metadata(ref Ref, info fs.FileInfo) Info {
|
||||
kind, limit := Format(ref.Path)
|
||||
return Info{Ref: ref, Kind: kind, Size: info.Size(), Limit: limit, ReadOnly: ref.Root != "",
|
||||
Version: strconv.FormatInt(info.ModTime().UnixNano(), 10) + ":" + strconv.FormatInt(info.Size(), 10)}
|
||||
}
|
||||
|
||||
func (s Service) Info(ctx context.Context, ref Ref) (Info, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Info{}, err
|
||||
}
|
||||
f, err := s.open(ref)
|
||||
if err != nil {
|
||||
return Info{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return Info{}, err
|
||||
}
|
||||
return metadata(ref, info), nil
|
||||
}
|
||||
|
||||
func (s Service) Content(ctx context.Context, ref Ref) ([]byte, Info, error) {
|
||||
return s.content(ctx, ref, false)
|
||||
}
|
||||
|
||||
func (s Service) Text(ctx context.Context, ref Ref) ([]byte, Info, error) {
|
||||
return s.content(ctx, ref, true)
|
||||
}
|
||||
|
||||
func (s Service) content(ctx context.Context, ref Ref, textOnly bool) ([]byte, Info, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, Info{}, err
|
||||
}
|
||||
f, err := s.open(ref)
|
||||
if err != nil {
|
||||
return nil, Info{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
before, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, Info{}, err
|
||||
}
|
||||
info := metadata(ref, before)
|
||||
if textOnly {
|
||||
info.Kind, info.Limit = "text", TextLimit
|
||||
}
|
||||
if info.Kind == "unsupported" {
|
||||
return nil, info, ErrUnsupported
|
||||
}
|
||||
if info.Size > info.Limit {
|
||||
return nil, info, ErrTooLarge
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(f, info.Limit+1))
|
||||
if err != nil {
|
||||
return nil, info, err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, info, err
|
||||
}
|
||||
if int64(len(data)) > info.Limit {
|
||||
return nil, info, ErrTooLarge
|
||||
}
|
||||
after, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, info, err
|
||||
}
|
||||
if metadata(ref, after).Version != info.Version {
|
||||
return nil, info, ErrChanged
|
||||
}
|
||||
if info.Kind != "pdf" && info.Kind != "image" && (!utf8.Valid(data) || strings.IndexByte(string(data), 0) >= 0) {
|
||||
return nil, info, ErrNotText
|
||||
}
|
||||
return data, info, nil
|
||||
}
|
||||
|
||||
func Format(path string) (string, int64) {
|
||||
switch strings.ToLower(filepath.Ext(path)) {
|
||||
case ".md", ".markdown", ".mdx":
|
||||
return "markdown", TextLimit
|
||||
case ".html", ".htm":
|
||||
return "html", TextLimit
|
||||
case ".csv", ".tsv":
|
||||
return "table", TextLimit
|
||||
case ".pdf":
|
||||
return "pdf", PDFLimit
|
||||
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".bmp", ".ico", ".svg":
|
||||
return "image", ImageLimit
|
||||
case ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".zip", ".gz", ".7z", ".rar", ".exe", ".dll", ".bin":
|
||||
return "unsupported", 0
|
||||
default:
|
||||
return "text", TextLimit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package workspacefile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPreviewReadsBytesAndRefusesEscape(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
data := []byte("%PDF-1.7\n\x00binary")
|
||||
if err := os.WriteFile(filepath.Join(root, "报告.pdf"), data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := Service{Primary: root}
|
||||
info, err := s.Info(context.Background(), Ref{Path: "报告.pdf"})
|
||||
if err != nil || info.Kind != "pdf" || info.Size != int64(len(data)) {
|
||||
t.Fatalf("info = %+v, %v", info, err)
|
||||
}
|
||||
got, _, err := s.Content(context.Background(), info.Ref)
|
||||
if err != nil || string(got) != string(data) {
|
||||
t.Fatalf("read = %q, %v", got, err)
|
||||
}
|
||||
for _, ref := range []Ref{{Path: "../secret"}, {Root: t.TempDir(), Path: "x"}} {
|
||||
if _, err := s.Info(context.Background(), ref); !errors.Is(err, ErrDenied) {
|
||||
t.Fatalf("escape = %v", err)
|
||||
}
|
||||
}
|
||||
outside := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(root, "link")); err == nil {
|
||||
if _, err := s.Info(context.Background(), Ref{Path: "link/secret.txt"}); err == nil {
|
||||
t.Fatal("symlink escaped root")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewLimitsAndUnknownBinary(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
s := Service{Primary: root}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
data []byte
|
||||
want error
|
||||
}{
|
||||
{"big.txt", make([]byte, TextLimit+1), ErrTooLarge},
|
||||
{"unknown.bin", []byte{0, 1, 2}, ErrUnsupported},
|
||||
{"bad.txt", []byte{0xff, 0xfe}, ErrNotText},
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(root, tc.name), tc.data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := s.Content(context.Background(), Ref{Path: tc.name}); !errors.Is(err, tc.want) {
|
||||
t.Errorf("%s: %v, want %v", tc.name, err, tc.want)
|
||||
}
|
||||
}
|
||||
if _, err := s.Info(context.Background(), Ref{Path: "absent"}); !errors.Is(err, ErrMissing) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantedRootAndCancellation(t *testing.T) {
|
||||
extra := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(extra, "notes.md"), []byte("# Notes"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := Service{Primary: t.TempDir(), Extra: []string{extra}}
|
||||
ref, err := s.Resolve(filepath.Join(extra, "notes.md"))
|
||||
if err != nil || ref.Root != extra || ref.Path != "notes.md" {
|
||||
t.Fatalf("resolve = %+v, %v", ref, err)
|
||||
}
|
||||
info, err := s.Info(t.Context(), ref)
|
||||
if err != nil || !info.ReadOnly {
|
||||
t.Fatalf("grant = %+v, %v", info, err)
|
||||
}
|
||||
s.Extra = nil
|
||||
if _, err := s.Info(t.Context(), ref); !errors.Is(err, ErrDenied) {
|
||||
t.Fatalf("revoked grant = %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
if _, _, err := s.Content(ctx, ref); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("canceled read = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !windows
|
||||
|
||||
package workspacefile
|
||||
|
||||
import "syscall"
|
||||
|
||||
const nonblock = syscall.O_NONBLOCK
|
||||
@@ -0,0 +1,3 @@
|
||||
package workspacefile
|
||||
|
||||
const nonblock = 0
|
||||
@@ -1216,6 +1216,8 @@ type ToolsConfig struct {
|
||||
SettingTool *bool `toml:"setting_tool"`
|
||||
// MCPResources offers a tool that reads a connected server's resource; user file only, off by default.
|
||||
MCPResources bool `toml:"mcp_resources"`
|
||||
// PresentFiles offers file-delivery cards to the model; user file only, off by default.
|
||||
PresentFiles bool `toml:"present_files"`
|
||||
WebAccessSettings
|
||||
// VideoNativeMaxBytes lowers the size read_video sends natively below the endpoint's limit; 0 keeps it. User file only.
|
||||
VideoNativeMaxBytes int64 `toml:"video_native_max_bytes"`
|
||||
|
||||
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user