mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
feat(studio): open conversation file links in the workbench
This commit is contained in:
1 parent
0876ccc422
commit
472f1a89ca
23 files changed
+467
-18
No files matched your search
@@ -17,7 +17,7 @@ const { uiLanguage } = require("./uilang");
|
||||
const { installFullScreenKey } = require("./fullscreen");
|
||||
const { installReload } = require("./reload");
|
||||
const { externalTarget } = require("./links");
|
||||
const { reveal, revealWorkspace, revealLogs, openWorkspace } = require("./reveal");
|
||||
const { reveal, revealWorkspace, revealLogs, openWorkspace, openFile } = require("./reveal");
|
||||
const { appIcon } = require("./appicon");
|
||||
const { setUnreadBadge } = require("./badge");
|
||||
const layout = require("./layout");
|
||||
@@ -436,6 +436,9 @@ ipcMain.handle("shell:open-workspace", (event, root) =>
|
||||
ipcMain.handle("shell:reveal", (event, base, rel) =>
|
||||
fromWindow(event) && client ? reveal(client, shell, String(base), String(rel)) : { code: "", error: "no window" },
|
||||
);
|
||||
ipcMain.handle("shell:open-file", (event, base, rel) =>
|
||||
fromWindow(event) && client ? openFile(client, shell, String(base), String(rel)) : { code: "", error: "no window" },
|
||||
);
|
||||
|
||||
// A dismissed dialog answers with "", which is what the page reads as "they
|
||||
// said no". Only a failure to write is an error.
|
||||
|
||||
@@ -31,6 +31,7 @@ contextBridge.exposeInMainWorld("reasonixHost", {
|
||||
openExternal: (url) => ipcRenderer.invoke("shell:open-external", String(url)),
|
||||
// A pane and a path inside its workspace; main asks the kernel where that is.
|
||||
revealPath: (base, rel) => ipcRenderer.invoke("shell:reveal", String(base), String(rel)),
|
||||
openFile: (base, rel) => ipcRenderer.invoke("shell:open-file", String(base), String(rel)),
|
||||
// A project the sidebar lists, which may have no pane at all.
|
||||
revealWorkspace: (root) => ipcRenderer.invoke("shell:reveal-workspace", String(root)),
|
||||
revealLogs: () => ipcRenderer.invoke("shell:reveal-logs"),
|
||||
|
||||
@@ -5,6 +5,12 @@ const path = require("node:path");
|
||||
// A pane's base as the hub publishes it. Nothing else may steer a request this
|
||||
// process sends with the credential, and the unprefixed root is no pane.
|
||||
const PANE_BASE = /^\/rt\/[A-Za-z0-9_-]+$/;
|
||||
const DOCUMENT_MEDIA = new Set([
|
||||
".pdf", ".txt", ".md", ".csv", ".tsv", ".json", ".yaml", ".yml", ".xml", ".rtf",
|
||||
".docx", ".xlsx", ".pptx", ".odt", ".ods", ".odp",
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tif", ".tiff", ".avif", ".heic",
|
||||
".mp3", ".wav", ".ogg", ".m4a", ".flac", ".mp4", ".mov", ".webm", ".mkv",
|
||||
]);
|
||||
|
||||
function refused(error, code = "") {
|
||||
return { code, error };
|
||||
@@ -110,4 +116,18 @@ async function openWorkspace(client, shell, root, platform = process.platform, f
|
||||
return why ? refused(why) : null;
|
||||
}
|
||||
|
||||
module.exports = { reveal, revealWorkspace, revealLogs, openWorkspace };
|
||||
async function openFile(client, shell, base, rel, platform = process.platform, fsApi = fs.promises) {
|
||||
const found = await locate(client, String(base), String(rel), platform);
|
||||
if (found.refusal) return found.refusal;
|
||||
try {
|
||||
if (!(await fsApi.stat(found.target)).isFile()) return refused("not a regular file");
|
||||
const ext = (platform === "win32" ? path.win32 : path.posix).extname(found.target).toLowerCase();
|
||||
if (!DOCUMENT_MEDIA.has(ext)) return refused("only documents and media can be opened");
|
||||
const why = await shell.openPath(found.target);
|
||||
return why ? refused(why) : null;
|
||||
} catch (err) {
|
||||
return refused(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { reveal, revealWorkspace, revealLogs, openWorkspace, openFile };
|
||||
@@ -290,6 +290,50 @@ test("openWorkspace refuses a folder the hub does not list", async () => {
|
||||
|
||||
const ROOT = path.resolve(os.tmpdir(), "rx-workspace");
|
||||
|
||||
test("file opening refuses scripts, executables and unknown types on every platform", async () => {
|
||||
const { openFile } = require("../src/reveal.js");
|
||||
const opened = [];
|
||||
const shell = { openPath: async (target) => { opened.push(target); return ""; } };
|
||||
for (const platform of ["darwin", "win32", "linux"]) {
|
||||
for (const name of ["task.command", "task.sh", "task.bat", "task.cmd", "task.ps1", "task.lnk", "task.jar", "task.desktop", "task.scpt", "task.exe", "task.EXE", "report.pdf.exe", "task", "task.unknown", "page.html", "image.svg"]) {
|
||||
const target = platform === "win32" ? `C:\\work\\${name}` : `/work/${name}`;
|
||||
const client = { request: async () => ({ status: 200, body: JSON.stringify({ path: target }) }) };
|
||||
const why = await openFile(client, shell, "/rt/r1", "report.pdf", platform, { stat: async () => ({ isFile: () => true }) });
|
||||
assert.ok(why?.error, `${platform}: ${name} must be refused`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(opened, []);
|
||||
});
|
||||
|
||||
test("file opening accepts kernel-located documents and media on every platform", async () => {
|
||||
const { openFile } = require("../src/reveal.js");
|
||||
for (const platform of ["darwin", "win32", "linux"]) {
|
||||
for (const name of ["report.pdf", "notes.TXT", "report.docx", "table.xlsx", "slides.pptx", "data.csv", "notes.md", "image.PNG", "photo.jpg", "video.mp4", "audio.mp3"]) {
|
||||
const target = platform === "win32" ? `C:\\work\\${name}` : `/work/${name}`;
|
||||
const opened = [];
|
||||
const shell = { openPath: async (p) => { opened.push(p); return ""; } };
|
||||
const client = { request: async () => ({ status: 200, body: JSON.stringify({ path: target }) }) };
|
||||
assert.equal(await openFile(client, shell, "/rt/r1", name, platform, { stat: async () => ({ isFile: () => true }) }), null);
|
||||
assert.deepEqual(opened, [target]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("file opening uses only a kernel-located regular file and reports shell failures", async () => {
|
||||
const { openFile } = require("../src/reveal.js");
|
||||
const opened = [];
|
||||
const shell = { openPath: async (target) => { opened.push(target); return "no application"; } };
|
||||
const client = { request: async () => ({ status: 200, body: JSON.stringify({ path: path.join(ROOT, "report.pdf") }) }) };
|
||||
assert.deepEqual(await openFile(client, shell, "/rt/r1", "report.pdf", process.platform, { stat: async () => ({ isFile: () => true }) }), { error: "no application", code: "" });
|
||||
assert.deepEqual(opened, [path.join(ROOT, "report.pdf")]);
|
||||
opened.length = 0;
|
||||
assert.ok(await openFile(client, shell, "/rt/r1", "folder", process.platform, { stat: async () => ({ isFile: () => false }) }));
|
||||
assert.ok(await openFile(client, shell, "https://attacker.test", "report.pdf"));
|
||||
const refused = { request: async () => ({ status: 400, body: JSON.stringify({ code: "workspace.path_outside_tree", error: "outside" }) }) };
|
||||
assert.equal((await openFile(refused, shell, "/rt/r1", "../secret.pdf")).code, "workspace.path_outside_tree");
|
||||
assert.deepEqual(opened, []);
|
||||
});
|
||||
|
||||
test("reveal opens only what the kernel located inside the pane's workspace", async () => {
|
||||
const rig = await revealRig((url) => {
|
||||
const rel = url.searchParams.get("path") ?? "";
|
||||
|
||||
@@ -513,6 +513,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "workbench.files", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.hidden", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.reveal", kind: "shell-native", target: "optional", proof: "interaction" },
|
||||
{ id: "workbench.open-file", kind: "shell-native", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.copy-path", kind: "shell-native", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.add-to-chat", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.copy-ref", kind: "shell-native", target: "entity", proof: "interaction" },
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
export const EN_WINDOW: Record<string, string> = {
|
||||
"这台机器的更多操作": "More actions for this machine",
|
||||
"使用系统默认应用打开": "Open with default application",
|
||||
"图片加载失败,它可能已被移动、删除或超过大小限制": "The image could not be loaded. It may have been moved, deleted, or exceed the size limit.",
|
||||
"选择本机会话": "Select local sessions",
|
||||
"选择会话:{name}": "Select session: {name}",
|
||||
"已选 {n} 个会话": "{n} sessions selected",
|
||||
|
||||
@@ -53,6 +53,8 @@ export interface HostPort {
|
||||
* root. The shell asks the kernel where that is and selects only the answer;
|
||||
* null once shown, otherwise why it was not. */
|
||||
revealPath(base: string, path: string): Promise<Refusal | null>;
|
||||
opensFiles(): boolean;
|
||||
openFile(base: string, path: string): Promise<Refusal | null>;
|
||||
/** Show a project the sidebar lists, which needs no pane. The hub answers
|
||||
* where it is and refuses any folder it does not list. */
|
||||
revealWorkspace(root: string): Promise<Refusal | null>;
|
||||
@@ -161,6 +163,7 @@ interface ElectronBridge {
|
||||
saveBytes(name: string, bytes: Uint8Array): Promise<string>;
|
||||
pickFolder(startIn: string): Promise<string>;
|
||||
revealPath?(base: string, path: string): Promise<Refusal | null>;
|
||||
openFile?(base: string, path: string): Promise<Refusal | null>;
|
||||
revealWorkspace?(root: string): Promise<Refusal | null>;
|
||||
revealLogs?(): Promise<Refusal | null>;
|
||||
openWorkspace?(root: string): Promise<Refusal | null>;
|
||||
@@ -237,6 +240,10 @@ class ElectronHost implements HostPort {
|
||||
revealPath(base: string, path: string) {
|
||||
return this.api.revealPath?.(base, path) ?? Promise.resolve({ error: "this shell cannot show files" });
|
||||
}
|
||||
opensFiles() { return typeof this.api.openFile === "function"; }
|
||||
openFile(base: string, path: string) {
|
||||
return this.api.openFile?.(base, path) ?? Promise.resolve({ error: "this shell cannot open files" });
|
||||
}
|
||||
revealWorkspace(root: string) {
|
||||
return this.api.revealWorkspace?.(root) ?? Promise.resolve({ error: "this shell cannot show files" });
|
||||
}
|
||||
@@ -327,6 +334,8 @@ class BrowserHost implements HostPort {
|
||||
revealPath() {
|
||||
return Promise.resolve({ error: "a browser tab cannot show files" });
|
||||
}
|
||||
opensFiles() { return false; }
|
||||
openFile() { return Promise.resolve({ error: "a browser tab cannot open files" }); }
|
||||
revealWorkspace() {
|
||||
return Promise.resolve({ error: "a browser tab cannot show files" });
|
||||
}
|
||||
|
||||
@@ -13,6 +13,15 @@ it("offers reveal only when the shell can show both a pane's file and a project"
|
||||
expect((await hostOver({ revealPath: async () => null })).revealsFiles()).toBe(false);
|
||||
});
|
||||
|
||||
it("offers opening files only with a native bridge and preserves its refusal", async () => {
|
||||
expect((await hostOver({})).opensFiles()).toBe(false);
|
||||
const openFile = vi.fn().mockResolvedValue({ code: "workspace.file_missing", error: "missing" });
|
||||
const host = await hostOver({ openFile });
|
||||
expect(host.opensFiles()).toBe(true);
|
||||
expect(await host.openFile("/rt/r1", "report.pdf")).toEqual({ code: "workspace.file_missing", error: "missing" });
|
||||
expect(openFile).toHaveBeenCalledExactlyOnceWith("/rt/r1", "report.pdf");
|
||||
});
|
||||
|
||||
it("offers the logs folder only when the shell can open it, and passes its refusal through", async () => {
|
||||
expect((await hostOver({})).revealsLogs()).toBe(false);
|
||||
const refusing = await hostOver({ revealLogs: async () => ({ error: "no such folder" }) });
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { ChangeDiff, StageRequest, WorkspaceChange, WorkspaceChanges, Works
|
||||
import { MockFeedback } from "./mock_feedback";
|
||||
|
||||
export class MockWorkspace extends MockFeedback {
|
||||
opensFiles() { return false; }
|
||||
async openFile() {}
|
||||
|
||||
// Held in a field so a switch is visible to the next read, as the kernel's endpoints keep.
|
||||
branchState: WorkspaceGit = { repo: true, name: "reasonix", branch: "main", detached: false, added: 2, removed: 1, untracked: 3 };
|
||||
|
||||
|
||||
@@ -435,6 +435,8 @@ export interface AgentPort {
|
||||
/** Show a workspace entry, "" being the root, in the system file manager.
|
||||
* Refuses with the kernel's code for anything outside the workspace. */
|
||||
revealInFileManager(path: string): Promise<void>;
|
||||
opensFiles(): boolean;
|
||||
openFile(path: string): Promise<void>;
|
||||
sessions(): Promise<SessionEntry[]>;
|
||||
resume(path: string): Promise<void>;
|
||||
newSession(): Promise<void>;
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import type { ChangeDiff, StageRequest, WorkspaceBranches, WorkspaceChanges, WorkspaceGit } from "./port";
|
||||
import { SseFeedback } from "./sse_feedback";
|
||||
import { host } from "./host";
|
||||
import { HttpError } from "./http_error";
|
||||
|
||||
// The workspace's tree facts: what it differs by and which branch git says it
|
||||
// is on. The reads answer from the tree, so a frontend re-asks rather than
|
||||
// deriving either from tool events.
|
||||
export class SseWorkspace extends SseFeedback {
|
||||
opensFiles() { return host().opensFiles(); }
|
||||
|
||||
async openFile(path: string) {
|
||||
const why = await host().openFile(this.base, path);
|
||||
if (why) throw new HttpError(0, why.error || "not opened", why, !!(why.code || why.error));
|
||||
}
|
||||
|
||||
changes() {
|
||||
return this.get<WorkspaceChanges>("/changes");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
.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,9 +1,10 @@
|
||||
import { isValidElement, memo, useEffect, useState, type ReactNode } from "react";
|
||||
import { isValidElement, memo, useEffect, useState, type ReactNode, type MouseEvent, type KeyboardEvent } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { CodeBlock } from "./CodeBlock";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import { remarkCodeSpanLink, remarkTrimAutolink } from "./autolink";
|
||||
import { remarkFileLinks } from "./filelinks";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
||||
import { useRevealed } from "./reveal";
|
||||
@@ -155,13 +156,15 @@ function textOf(node: ReactNode): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
/** How a document shown from the workspace reads its own references: a link to
|
||||
* another file there, and an image stored beside it. Absent for a reply. */
|
||||
/** File navigation for a workspace document or a conversation. */
|
||||
export interface LocalRefs {
|
||||
/** 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. */
|
||||
image(src: string): string | null;
|
||||
path?(href: string): string | null;
|
||||
title?(href: string): string;
|
||||
menu?(event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>, href: string): void;
|
||||
}
|
||||
|
||||
// live is the tail of a message still arriving; tail alone is only the last
|
||||
@@ -171,7 +174,7 @@ const Block = memo(function Block({ src, math, emoji, code, tail, live, local, i
|
||||
const body = normalizeMath(tail ? balanceFences(src) : src);
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={(emoji ? [...BASE_REMARK, emoji] : BASE_REMARK) as never}
|
||||
remarkPlugins={([...BASE_REMARK, [remarkFileLinks, local?.path], ...(emoji ? [emoji] : [])]) as never}
|
||||
rehypePlugins={([...BASE_REHYPE, math, code].filter(Boolean)) as never}
|
||||
components={{
|
||||
// Every link here comes from model output; a webview navigating away
|
||||
@@ -182,6 +185,15 @@ const Block = memo(function Block({ src, math, emoji, code, tail, live, local, i
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
title={local?.title?.(href!)}
|
||||
data-action-click="workbench.file" data-target={local?.path?.(href!) ?? href}
|
||||
data-action-contextmenu={local?.menu ? "workbench.menu" : undefined}
|
||||
data-action-keydown={local?.menu ? "workbench.menu" : undefined}
|
||||
onContextMenu={(e) => local?.menu?.(e, href!)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "ContextMenu" && !(e.key === "F10" && e.shiftKey)) return;
|
||||
local?.menu?.(e, href!);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
open();
|
||||
|
||||
@@ -57,6 +57,7 @@ import { LiveWork, useLiveWork } from "../state/foldpref";
|
||||
import { loadChangedFilesPref, useShowsChangedFiles } from "../state/changedfiles";
|
||||
import { loadFoldWorkPref, useFoldsTurnWork } from "../state/foldwork";
|
||||
import { ChangedFilesPill } from "./ChangedFilesPill";
|
||||
import { useConversationFiles } from "./useConversationFiles";
|
||||
|
||||
export type { PaneReport };
|
||||
|
||||
@@ -107,6 +108,7 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
const fail = useCallback((e: unknown) => {
|
||||
dispatch({ kind: "__error", text: reason(e) } as never);
|
||||
}, []);
|
||||
const conversationFiles = useConversationFiles(port, status?.workspaceRoot ?? rt.root, !!rt.host, visible && tab === "flow", revealBrowser, fail);
|
||||
const compact = useCompactNow(port, running || s.items.some((item) => item.t === "compaction" && !item.done), fail);
|
||||
// Elapsed is a clock reading and belongs on the tick. Throughput is not: it
|
||||
// follows the deltas themselves, and expires rather than being re-derived.
|
||||
@@ -364,6 +366,7 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
|
||||
const onRunDetail = useCallback(() => showView("analysis"), [showView]);
|
||||
const { quote, reply, onResend } = useReplyActions({ port, items: s.items, checkpoints, running, model: status?.label, submit, reloadSession, onSettings, onRunDetail, onError: fail, branchName: `${title} · ${t("分支")}`, onBranched: onOpenSession });
|
||||
const fileReply = useMemo(() => ({ ...reply, files: conversationFiles.refs }), [reply, conversationFiles.refs]);
|
||||
|
||||
// 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.
|
||||
@@ -427,7 +430,7 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
<Transcript port={port}
|
||||
foldWork={foldWork}
|
||||
turnRunning={running}
|
||||
reply={reply}
|
||||
reply={fileReply}
|
||||
onResend={onResend}
|
||||
items={s.items}
|
||||
entering={s.entranceOwed}
|
||||
@@ -461,6 +464,7 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
</LiveWork.Provider>
|
||||
</PaneShown.Provider>
|
||||
</ReceiptPrefContext.Provider>
|
||||
{conversationFiles.menuView}
|
||||
|
||||
<div className="scroll" data-pane="analysis" hidden={tab !== "analysis"}>
|
||||
{tab === "analysis" && (
|
||||
@@ -482,6 +486,7 @@ function PaneView({ port, rt, title, active, visible, sideHost, side, onFocus, o
|
||||
<div className="scroll" data-pane="browser" hidden={tab === "analysis"} inert={!workbench}>
|
||||
<WorkbenchPanel
|
||||
port={port}
|
||||
fileRequest={conversationFiles.request}
|
||||
tabs={pages}
|
||||
wideMode={docked ? { wide: browserSize.size === "wide", onToggle: browserSize.toggle } : undefined}
|
||||
manual={manualBrowser}
|
||||
|
||||
@@ -6,16 +6,17 @@ import { pinToViewport } from "./place";
|
||||
|
||||
export type FileMenu = { path: string; folder: boolean; x: number; y: number; anchor: HTMLElement };
|
||||
|
||||
function absoluteWorkspacePath(root: string, path: string): string {
|
||||
export function absoluteWorkspacePath(root: string, path: string): string {
|
||||
if (!root.startsWith("/") && !/^[a-z]:[\\/]/i.test(root) && !/^\\\\[^\\]+\\[^\\]+/.test(root)) return "";
|
||||
const separator = root.startsWith("\\\\") || /^[a-z]:\\/i.test(root) ? "\\" : "/";
|
||||
const base = root.replace(separator === "\\" ? /[\\/]+$/ : /\/+$/, "");
|
||||
return base + separator + (separator === "\\" ? path.replaceAll("/", "\\") : path);
|
||||
}
|
||||
|
||||
export function WorkbenchFileMenu({ menu, root, revealable, platform, onReveal, onAddToChat, onClose }: {
|
||||
export function WorkbenchFileMenu({ menu, root, revealable, platform, onReveal, onOpen, onAddToChat, onClose }: {
|
||||
menu: FileMenu; root: string; revealable: boolean; platform: string;
|
||||
onReveal: (path: string) => void; onAddToChat?: (reference: string) => void; onClose: () => void;
|
||||
onOpen?: (path: string) => void;
|
||||
}) {
|
||||
const [state, setState] = useState<"idle" | "copying" | "failed">("idle");
|
||||
const live = useRef(false);
|
||||
@@ -100,6 +101,10 @@ export function WorkbenchFileMenu({ menu, root, revealable, platform, onReveal,
|
||||
onClick={() => { close(); onAddToChat(reference); }}
|
||||
>{t("加入对话")}</button>
|
||||
)}
|
||||
{onOpen && !menu.folder && (
|
||||
<button role="menuitem" data-action="workbench.open-file" data-target={menu.path}
|
||||
onClick={() => { close(); onOpen(menu.path); }}>{t("使用系统默认应用打开")}</button>
|
||||
)}
|
||||
{state === "failed" && <div role="alert">{t("复制失败,请重试")}</div>}
|
||||
</div>, document.body,
|
||||
);
|
||||
|
||||
@@ -31,6 +31,7 @@ import { useHtmlPreview } from "./useHtmlPreview";
|
||||
import { setShowsHiddenFiles, showsHiddenFiles } from "../state/prefs";
|
||||
import { useCommitCard } from "./CommitCard";
|
||||
import { BrowserWideToggle } from "./BrowserWideToggle";
|
||||
import { WorkspaceImage, isWorkspaceImage } from "./WorkspaceImage";
|
||||
|
||||
// The editor and its grammars load with the first file opened, not with Studio.
|
||||
const CodeEditor = lazy(() => import("./CodeEditor"));
|
||||
@@ -117,6 +118,7 @@ export function WorkbenchPanel({
|
||||
onExternal,
|
||||
onAddToChat,
|
||||
wideMode,
|
||||
fileRequest,
|
||||
}: {
|
||||
port: AgentPort;
|
||||
tabs: BrowserTab[];
|
||||
@@ -143,6 +145,7 @@ export function WorkbenchPanel({
|
||||
onExternal: (url: string) => void;
|
||||
onAddToChat?: (reference: string) => void;
|
||||
wideMode?: { wide: boolean; onToggle: () => void };
|
||||
fileRequest?: { path: string };
|
||||
}) {
|
||||
const preview = useHtmlPreview(port, allTabs, remote);
|
||||
const tabs = preview.tabs;
|
||||
@@ -263,7 +266,8 @@ export function WorkbenchPanel({
|
||||
const dropBlankStart = useCallback(() => setBrowsers((open) => open.filter((id) => !!visited.current[id])), []);
|
||||
const agentNow = useRef(agentTarget);
|
||||
agentNow.current = agentTarget;
|
||||
useColumnOpening({ manual, browserAsk, filesAsk: revealChanges, port, agentNow, dropBlankStart, pick, follow, current, setFailed, setBrowsers, manualSelection: selected.startsWith("manual:") ? selected : `manual:${browsers[0] ?? "b0"}` });
|
||||
const filesAsk = useMemo(() => ({ revealChanges, fileRequest }), [revealChanges, fileRequest]);
|
||||
useColumnOpening({ manual, browserAsk, filesAsk, port, agentNow, dropBlankStart, pick, follow, current, setFailed, setBrowsers, manualSelection: selected.startsWith("manual:") ? selected : `manual:${browsers[0] ?? "b0"}` });
|
||||
// A page that has just appeared is the one somebody wants to see, whoever
|
||||
// opened it: the agent's popup, or a link the person followed, which opens
|
||||
// beside the agent's tab without becoming the one it acts on.
|
||||
@@ -327,7 +331,7 @@ export function WorkbenchPanel({
|
||||
const previewTab = previewable ? preview.tabFor(filePath) : undefined;
|
||||
const { open: openPreview, sync: syncPreview } = preview;
|
||||
const { file, draft, diff, busy, error: fileFailed, setDraft, save } = useWorkbenchFiles({
|
||||
port, workspaceRoot, path: filePath, openPaths: openFiles, shown, refresh: JSON.stringify([changeKey, glance, running, wrote]),
|
||||
port, workspaceRoot, path: isWorkspaceImage(filePath) ? "" : filePath, openPaths: openFiles, shown, refresh: JSON.stringify([changeKey, glance, running, wrote]),
|
||||
poll: (readable && mode === "read") || (previewable && mode === "preview"),
|
||||
});
|
||||
const revision = file?.revision;
|
||||
@@ -355,7 +359,7 @@ export function WorkbenchPanel({
|
||||
}, [activeKey, surfaces.length]);
|
||||
// Picking a file is asking to read it. Docked, the list and the file share one
|
||||
// column, so the list steps aside; side by side it stays where it is.
|
||||
const openFile = (path: string) => {
|
||||
const openFile = useCallback((path: string) => {
|
||||
setOpenFiles((v) => (v.includes(path) ? v : [...v, path]));
|
||||
setDismissed((v) => {
|
||||
const n = new Set(v);
|
||||
@@ -363,7 +367,8 @@ export function WorkbenchPanel({
|
||||
return n;
|
||||
});
|
||||
pick(`file:${path}`);
|
||||
};
|
||||
}, [pick]);
|
||||
useEffect(() => { if (fileRequest) openFile(fileRequest.path); }, [fileRequest, openFile]);
|
||||
// A rendered document's own links open beside it and its images load from the
|
||||
// tree. Held stable per document, so typing re-renders the text alone.
|
||||
const openRef = useRef(openFile);
|
||||
@@ -547,7 +552,7 @@ export function WorkbenchPanel({
|
||||
showTabs={false}
|
||||
/>
|
||||
)}
|
||||
{active?.kind === "file" && (
|
||||
{active?.kind === "file" && (isWorkspaceImage(active.path) ? <WorkspaceImage key={active.path} port={port} path={active.path} /> : (
|
||||
<>
|
||||
<WorkbenchFileBar
|
||||
path={active.path}
|
||||
@@ -555,7 +560,7 @@ export function WorkbenchPanel({
|
||||
onMode={setMode}
|
||||
readable={readable}
|
||||
previewable={previewable}
|
||||
canSave={!busy && !!file && draft !== file.content}
|
||||
canSave={!busy && file?.path === active.path && draft !== file.content}
|
||||
onSave={() => void save()}
|
||||
/>
|
||||
{(fileFailed || failed) && !(mode === "preview" && previewTab) && (
|
||||
@@ -592,7 +597,7 @@ export function WorkbenchPanel({
|
||||
</Suspense>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
<aside className="workbench-explorer">
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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>
|
||||
</>;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { useShown } from "../shown";
|
||||
import { ReplyMenu } from "./ReplyMenu";
|
||||
import { useDismiss } from "../dismiss";
|
||||
import { pinToViewport } from "../place";
|
||||
import type { LocalRefs } from "../Markdown";
|
||||
|
||||
// Folded, the only thing left of a thought is how much of the turn it was. The
|
||||
// spec puts both halves there — how long, and how much — because either alone
|
||||
@@ -61,6 +62,7 @@ export interface Quote {
|
||||
/** What a finished reply can be acted on with. Regeneration belongs to the
|
||||
* individual reply and requires a paired checkpoint. */
|
||||
export interface ReplyActions {
|
||||
files?: LocalRefs;
|
||||
onQuote: (text: string, id: string) => void;
|
||||
canRegenerate: (id: string) => boolean;
|
||||
hasLaterTurns: (id: string) => boolean;
|
||||
@@ -163,7 +165,7 @@ export function SayCard({ item, afterAnswer, reply, quiet }: { item: Extract<Ite
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
<Boundary retryKey={item.text} fallback={<div className="md" style={{ whiteSpace: "pre-wrap" }}>{item.text}</div>}>
|
||||
<LazyMarkdown text={item.text} streaming={!item.done} />
|
||||
<LazyMarkdown text={item.text} streaming={!item.done} local={reply?.files} />
|
||||
</Boundary>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { useState } from "react";
|
||||
import "./testkit";
|
||||
import { Pane } from "./Pane";
|
||||
import { MockPort } from "../port/mock";
|
||||
import type { RuntimeView } from "../port/hub";
|
||||
import { HttpError } from "../port/http_error";
|
||||
import { host } from "../port/host";
|
||||
import { boot, STORAGE } from "../i18n";
|
||||
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); localStorage.clear(); sessionStorage.clear(); });
|
||||
|
||||
async function mount(remote = false, existingBrowser = false) {
|
||||
localStorage.setItem(STORAGE, "zh"); boot();
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
|
||||
const port = new MockPort();
|
||||
if (existingBrowser) vi.spyOn(port, "browserTabs").mockResolvedValue([{ id: "b1", target: "t1", url: "https://example.com", title: "Agent page", active: true }]);
|
||||
const status = await port.status();
|
||||
vi.spyOn(port, "status").mockResolvedValue({ ...status, workspaceRoot: "/work" });
|
||||
vi.spyOn(port, "history").mockResolvedValue([{ role: "user", content: "Create a report" }, { role: "assistant", content: "[Report](/work/docs/report.md) and `/work/docs/missing.md` [Image](images/chart.png)" }]);
|
||||
const read = vi.spyOn(port, "workspaceFile").mockImplementation(async (path) => {
|
||||
if (path === "docs/missing.md") throw new HttpError(404, "missing", { code: "workspace.file_missing" }, true);
|
||||
return { path, content: "# Generated report", revision: "1" };
|
||||
});
|
||||
const reveal = vi.spyOn(port, "revealInFileManager").mockResolvedValue();
|
||||
const openFile = vi.spyOn(port, "openFile").mockResolvedValue();
|
||||
vi.spyOn(port, "opensFiles").mockReturnValue(true);
|
||||
vi.spyOn(port, "revealsFiles").mockReturnValue(true);
|
||||
vi.spyOn(host(), "describe").mockResolvedValue({ ...await host().describe(), platform: "darwin" });
|
||||
const browser = vi.spyOn(port, "browserOpen");
|
||||
function Harness() {
|
||||
const [manualBrowser, onManualBrowser] = useState(false);
|
||||
return <Pane port={port} rt={{ id: "p1", root: "/work", name: "work", host: remote ? "server" : undefined } as RuntimeView}
|
||||
title="work" active visible sideHost={null} side={false} onFocus={() => {}} onReport={() => {}} onSessionChanged={() => {}}
|
||||
pulse={0} findPulse={0} onSettings={() => {}} needsProject={false} onOpenProject={() => {}} onKeepHere={() => {}}
|
||||
theme="dark" dockW={560} dockMax={880} onDockW={() => {}} manualBrowser={manualBrowser} onManualBrowser={onManualBrowser} />;
|
||||
}
|
||||
await act(async () => { render(<Harness />); });
|
||||
await screen.findByText("Report");
|
||||
return { port, read, reveal, browser, openFile };
|
||||
}
|
||||
|
||||
it("opens a restored reply's file in the right column and reuses its tab on repeated clicks", async () => {
|
||||
const { read, browser } = await mount();
|
||||
fireEvent.click(screen.getByText("Report"));
|
||||
await screen.findByRole("heading", { name: "Generated report" });
|
||||
expect(read).toHaveBeenCalledWith("docs/report.md");
|
||||
expect(document.querySelector('[data-pane="browser"]')!.hasAttribute("inert")).toBe(false);
|
||||
fireEvent.click(screen.getByText("Report"));
|
||||
expect(screen.getAllByRole("tab", { name: "report.md" })).toHaveLength(1);
|
||||
expect(browser).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByText("/work/docs/missing.md"));
|
||||
await screen.findByText("找不到该文件,它可能已被移动或删除");
|
||||
});
|
||||
|
||||
it("offers file actions on right-click and restores focus to the link", async () => {
|
||||
const { reveal } = await mount();
|
||||
fireEvent.contextMenu(screen.getByText("Report"));
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "在访达中显示" }));
|
||||
await waitFor(() => expect(reveal).toHaveBeenCalledExactlyOnceWith("docs/report.md"));
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
expect(document.activeElement).toBe(screen.getByText("Report"));
|
||||
fireEvent.keyDown(screen.getByText("Report"), { key: "F10", shiftKey: true });
|
||||
expect(screen.getByRole("menuitem", { name: "复制路径" })).toBeTruthy();
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps remote paths out of the local file manager", async () => {
|
||||
const { reveal } = await mount(true);
|
||||
fireEvent.contextMenu(screen.getByText("Report"));
|
||||
expect(await screen.findByRole("menuitem", { name: "复制路径" })).toBeTruthy();
|
||||
expect(screen.queryByRole("menuitem", { name: "在访达中显示" })).toBeNull();
|
||||
expect(reveal).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("menuitem", { name: "使用系统默认应用打开" })).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the selected file in its default application only on request", async () => {
|
||||
const { openFile } = await mount();
|
||||
expect(openFile).not.toHaveBeenCalled();
|
||||
fireEvent.contextMenu(screen.getByText("Report"));
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "使用系统默认应用打开" }));
|
||||
expect(openFile).toHaveBeenCalledExactlyOnceWith("docs/report.md");
|
||||
});
|
||||
|
||||
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(read).not.toHaveBeenCalledWith("images/chart.png");
|
||||
fireEvent.load(image);
|
||||
expect(image.hasAttribute("hidden")).toBe(false);
|
||||
fireEvent.error(image);
|
||||
expect(screen.getByRole("alert").textContent).toContain("图片加载失败");
|
||||
});
|
||||
|
||||
it("keeps a conversation file selected when the column already has an agent browser", async () => {
|
||||
await mount(false, true);
|
||||
fireEvent.click(screen.getByText("Report"));
|
||||
await screen.findByRole("heading", { name: "Generated report" });
|
||||
expect(screen.getByRole("tab", { name: "report.md" }).getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { conversationFile } from "./filelinks";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it.each([
|
||||
["docs/report.md", "docs/report.md"], ["./docs/report.md", "docs/report.md"],
|
||||
["/work/docs/report.md", "docs/report.md"], ["file:///work/docs/report%20one.md", "docs/report one.md"],
|
||||
["docs/report.md#heading", "docs/report.md"], ["../secret.txt", null],
|
||||
["/workspace/report.md", null], ["https://example.com/report.md", null],
|
||||
["javascript:alert(1)", null], ["#heading", null], ["//host/share/report.md", null],
|
||||
["/work/../secret.txt", null], ["file://host/work/report.md", null],
|
||||
])("resolves %s inside the conversation's workspace", (href, expected) => {
|
||||
expect(conversationFile("/work", href)).toBe(expected);
|
||||
});
|
||||
|
||||
it("resolves Windows drive paths without treating web schemes or other drives as files", () => {
|
||||
expect(conversationFile("C:\\Work", "c:\\work\\docs\\report.md")).toBe("docs/report.md");
|
||||
expect(conversationFile("C:\\Work", "file:///C:/Work/docs/report.md")).toBe("docs/report.md");
|
||||
expect(conversationFile("C:\\Work", "D:\\Work\\report.md")).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["fmt.Println", "e.g.", "report.pdf"])("leaves the bare code span %s as code", (value) => {
|
||||
const path = (href: string) => conversationFile("/work", href);
|
||||
const local = { path, link: (href: string) => path(href) ? vi.fn() : null, image: () => null };
|
||||
const { container, getByText } = render(<Markdown local={local} text={`\`${value}\``} />);
|
||||
expect(getByText(value).tagName).toBe("CODE");
|
||||
expect(container.querySelector("a")).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/work", "./report.pdf", "report.pdf"],
|
||||
["/work", "docs/report.md", "docs/report.md"],
|
||||
["C:\\Work", "docs\\report.pdf", "docs/report.pdf"],
|
||||
])("links the code span %s %s", (root, value, expected) => {
|
||||
const opened = vi.fn();
|
||||
const path = (href: string) => conversationFile(root, href);
|
||||
const local = { path, link: (href: string) => path(href) ? () => opened(path(href)) : null, image: () => null };
|
||||
const { getByText } = render(<Markdown local={local} text={`\`${value}\``} />);
|
||||
fireEvent.click(getByText(value));
|
||||
expect(opened).toHaveBeenCalledWith(expected);
|
||||
});
|
||||
|
||||
it("links file references in prose and code spans while preserving web links, labels and code listings", () => {
|
||||
const opened = vi.fn(), menu = vi.fn();
|
||||
const path = (href: string) => conversationFile("/work", href);
|
||||
const local = { path, link: (href: string) => path(href) ? () => opened(path(href)) : null, image: () => null, menu };
|
||||
const { container, getByText } = render(<Markdown local={local} text={'[Report](/work/docs/report.md)\n\n`/work/docs/report one.md`\n\nCreated /work/docs/result.txt。\n\n[web](https://example.com)\n\n```sh\n/work/docs/listing.sh\n```'} />);
|
||||
fireEvent.click(getByText("Report"));
|
||||
fireEvent.click(getByText("/work/docs/report one.md"));
|
||||
fireEvent.click(getByText("/work/docs/result.txt"));
|
||||
expect(opened.mock.calls).toEqual([["docs/report.md"], ["docs/report one.md"], ["docs/result.txt"]]);
|
||||
expect(getByText("web").getAttribute("target")).toBe("_blank");
|
||||
expect(container.querySelector("pre a")).toBeNull();
|
||||
fireEvent.contextMenu(getByText("Report"));
|
||||
expect(menu).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { docRef } from "./docrefs";
|
||||
|
||||
export function conversationFile(root: string, href: string): string | null {
|
||||
let raw = href.trim();
|
||||
if (/^file:/i.test(raw)) {
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
if (url.hostname && url.hostname !== "localhost") return null;
|
||||
raw = url.pathname;
|
||||
if (/^\/[a-z]:\//i.test(raw)) raw = raw.slice(1);
|
||||
} catch { return null; }
|
||||
} else if (/^[a-z][a-z0-9+.-]*:/i.test(raw) && !/^[a-z]:[\\/]/i.test(raw)) return null;
|
||||
const windows = /^[a-z]:[\\/]/i.test(root);
|
||||
const base = (windows ? root.replaceAll("\\", "/") : root).replace(/\/+$/, "");
|
||||
if (windows) raw = raw.replaceAll("\\", "/");
|
||||
raw = raw.replace(/[?#].*$/, "");
|
||||
try { raw = decodeURIComponent(raw); } catch { return null; }
|
||||
if (raw.startsWith("//")) return null;
|
||||
if (raw.startsWith("/") || /^[a-z]:[\\/]/i.test(raw)) {
|
||||
const prefix = base + "/";
|
||||
if (!(windows ? raw.toLowerCase().startsWith(prefix.toLowerCase()) : raw.startsWith(prefix))) return null;
|
||||
raw = raw.slice(prefix.length);
|
||||
}
|
||||
// docRef decodes URLs; encode literal filename characters before resolving.
|
||||
return docRef("", raw.split("/").map(encodeURIComponent).join("/"));
|
||||
}
|
||||
|
||||
interface Node {
|
||||
type: string;
|
||||
url?: string;
|
||||
value?: string;
|
||||
children?: Node[];
|
||||
}
|
||||
|
||||
const TOKEN = /[^\s<>"'`()\[\]{},。;!?:]+/g;
|
||||
const FILE = /(?:^|\/)[^/]+\.[a-z0-9]{1,16}$/i;
|
||||
|
||||
export function remarkFileLinks(resolve?: (href: string) => string | null) {
|
||||
return (tree: Node) => {
|
||||
if (!resolve) return;
|
||||
const walk = (node: Node) => {
|
||||
if (node.type === "link") {
|
||||
const path = node.url && resolve(node.url);
|
||||
if (path) node.url = path.split("/").map(encodeURIComponent).join("/");
|
||||
return;
|
||||
}
|
||||
if (!node.children) return;
|
||||
node.children = node.children.flatMap((child) => {
|
||||
if (child.type === "inlineCode" && child.value && /[\\/]/.test(child.value) && FILE.test(child.value.replaceAll("\\", "/"))) {
|
||||
const path = resolve(child.value);
|
||||
if (path) return [{ type: "link", url: path.split("/").map(encodeURIComponent).join("/"), children: [child] }];
|
||||
}
|
||||
if (child.type === "text" && child.value) {
|
||||
const out: Node[] = [];
|
||||
let at = 0;
|
||||
for (const match of child.value.matchAll(TOKEN)) {
|
||||
const value = match[0].replace(/[.,;!?]+$/, "");
|
||||
if (!/[\\/]/.test(value) || !FILE.test(value.replaceAll("\\", "/"))) continue;
|
||||
const path = resolve(value);
|
||||
if (!path) continue;
|
||||
if (match.index > at) out.push({ type: "text", value: child.value.slice(at, match.index) });
|
||||
out.push({ type: "link", url: path.split("/").map(encodeURIComponent).join("/"), children: [{ type: "text", value }] });
|
||||
at = match.index + value.length;
|
||||
}
|
||||
if (out.length) {
|
||||
if (at < child.value.length) out.push({ type: "text", value: child.value.slice(at) });
|
||||
return out;
|
||||
}
|
||||
}
|
||||
walk(child);
|
||||
return [child];
|
||||
});
|
||||
};
|
||||
walk(tree);
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,7 @@ interface Opening {
|
||||
manual: boolean;
|
||||
/** Counts the title-bar Browser presses; a change asks for a browser, not just the column. */
|
||||
browserAsk: number;
|
||||
filesAsk: number;
|
||||
filesAsk: unknown;
|
||||
port: AgentPort;
|
||||
agentNow: MutableRefObject<string>;
|
||||
dropBlankStart: () => void;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent, type KeyboardEvent } from "react";
|
||||
import type { AgentPort } from "../port/port";
|
||||
import { host } from "../port/host";
|
||||
import type { LocalRefs } from "./Markdown";
|
||||
import { conversationFile } from "./filelinks";
|
||||
import { WorkbenchFileMenu, absoluteWorkspacePath, type FileMenu } from "./WorkbenchFileMenu";
|
||||
|
||||
export function useConversationFiles(port: AgentPort, root: string, remote: boolean, shown: boolean, reveal: () => void, fail: (e: unknown) => void) {
|
||||
const [request, setRequest] = useState<{ path: string; port: AgentPort; root: string }>();
|
||||
const [menu, setMenu] = useState<(FileMenu & { id: number; port: AgentPort; root: string }) | null>(null);
|
||||
const menuID = useRef(0);
|
||||
const [platform, setPlatform] = useState("");
|
||||
const close = useCallback(() => setMenu(null), []);
|
||||
useEffect(close, [port, root, shown, close]);
|
||||
useEffect(() => { void host().describe().then((h) => setPlatform(h.platform)).catch(() => {}); }, []);
|
||||
const refs = useMemo<LocalRefs>(() => {
|
||||
const path = (href: string) => conversationFile(root, href);
|
||||
const offer = (e: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>, href: string) => {
|
||||
const file = path(href);
|
||||
if (!file || document.getSelection()?.isCollapsed === false) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const keyed = !("clientX" in e) || (e.clientX === 0 && e.clientY === 0);
|
||||
setMenu({ id: ++menuID.current, port, root, path: file, folder: false, anchor: e.currentTarget,
|
||||
x: keyed ? rect.left : e.clientX, y: keyed ? rect.bottom : e.clientY });
|
||||
};
|
||||
return {
|
||||
path,
|
||||
title: (href) => { const file = path(href); return file ? absoluteWorkspacePath(root, file) : ""; },
|
||||
link: (href) => {
|
||||
const file = path(href);
|
||||
return file ? () => { setRequest({ path: file, port, root }); reveal(); } : null;
|
||||
},
|
||||
image: () => null,
|
||||
menu: offer,
|
||||
};
|
||||
}, [port, root, reveal]);
|
||||
const menuView = shown && menu?.port === port && menu.root === root && <WorkbenchFileMenu key={menu.id} menu={menu} root={root} platform={platform}
|
||||
revealable={!remote && port.revealsFiles()} onClose={close}
|
||||
onOpen={!remote && port.opensFiles() ? (path) => { void port.openFile(path).catch(fail); } : undefined}
|
||||
onReveal={(path) => { void port.revealInFileManager(path).catch(fail); }} />;
|
||||
return { refs, request: request?.port === port && request.root === root ? request : undefined, menuView };
|
||||
}
|
||||
Reference in new issue
Block a user