diff --git a/packages/webui/src/client/components/WebuiClientFoundationApp.tsx b/packages/webui/src/client/components/WebuiClientFoundationApp.tsx index cc29e365d..4e9766d83 100644 --- a/packages/webui/src/client/components/WebuiClientFoundationApp.tsx +++ b/packages/webui/src/client/components/WebuiClientFoundationApp.tsx @@ -1433,7 +1433,7 @@ export function WebuiClientFoundationApp( ) : null} - {!homeMode && workspacePanel.open ? dispatchWorkspacePanel({ type: "close-panel" })} /> : null} + {!homeMode && workspacePanel.open ? dispatchWorkspacePanel({ type: "close-panel" })} /> : null} } diff --git a/packages/webui/src/client/components/WorkspaceArchiveView.tsx b/packages/webui/src/client/components/WorkspaceArchiveView.tsx new file mode 100644 index 000000000..834a8b05d --- /dev/null +++ b/packages/webui/src/client/components/WorkspaceArchiveView.tsx @@ -0,0 +1,290 @@ +// Owns the workspace archive feature (list + extract inside .zip/.tar archives). +// `WorkspacePanels.tsx` mounts this for every path `isWorkspaceArchivePath` +// accepts, and the runtime owns every hardening decision — this file renders +// what `readWorkspaceArchive` is willing to name and never invents a path. + +import { useCallback, useEffect, useState, type ReactElement } from "react"; +import type { + WebuiArchiveEntry, + WebuiWorkspaceArchiveExtractResult, + WebuiWorkspaceArchiveListing, +} from "../../server/port.js"; + +export type WorkspaceArchiveViewProps = { + /** Absolute workspace root; every archive path below is relative to it. */ + readonly workspaceDir: string; + /** The archive's path inside the workspace, as the file tree reported it. */ + readonly path: string; + /** + * Lists one level of the archive. The runtime owns every hardening + * decision (entry ceiling, expansion ratio, path validation), so the view + * renders only what this call is willing to name. `prefix` walks deeper + * levels; absent, the listing is the archive root. + */ + readonly readWorkspaceArchive?: (request: { readonly workspaceDir: string; readonly path: string; readonly prefix?: string }) => Promise; + /** Writes selected entries out of the archive into `destination`. */ + readonly extractWorkspaceArchive?: (request: { readonly workspaceDir: string; readonly path: string; readonly destination: string; readonly prefix?: string }) => Promise; + /** + * The directory extraction is allowed to write into. A browser cannot ask + * for an arbitrary path, so the view picks one from the workspace and + * hands it to the port rather than typing a raw destination. + */ + readonly destination?: string; +}; + +type ArchiveLoadState = + | { readonly status: "loading" } + | { readonly status: "error"; readonly error: string } + | { readonly status: "ready"; readonly listing: WebuiWorkspaceArchiveListing }; + +type ArchiveExtractState = + | { readonly status: "idle" } + | { readonly status: "running" } + | { readonly status: "done"; readonly result: WebuiWorkspaceArchiveExtractResult } + | { readonly status: "error"; readonly error: string }; + +/** + * One directory level of an archive, with navigation and the extract control. + * + * Split out from the container so every state is reachable without a running + * effect: `renderToStaticMarkup` does not run effects, and the parent would + * otherwise leave loading, empty, error and truncated untestable. + */ +export function WorkspaceArchiveListing({ + archivePath, + prefix, + destination, + entries, + totalEntries, + truncated, + extractState, + onEnterDirectory, + onGoUp, + onExtract, +}: { + readonly archivePath: string; + /** The directory being shown, relative to the archive root; "" is the root. */ + readonly prefix: string; + /** The workspace-relative directory an extraction would write into. */ + readonly destination: string; + readonly entries: readonly WebuiArchiveEntry[]; + readonly totalEntries: number; + readonly truncated: boolean; + readonly extractState: ArchiveExtractState; + readonly onEnterDirectory?: (name: string) => void; + readonly onGoUp?: () => void; + readonly onExtract?: () => void; +}): ReactElement { + return ( +
+

+ {`${archivePath}${prefix ? ` › ${prefix}` : ""}`} +

+ {prefix + ? + : null} + {truncated + ? ( +

+ {`压缩包共 ${totalEntries} 项,这里只显示了 ${entries.length} 项;其余条目未列出。`} +

+ ) + : null} + {entries.length === 0 + ?

该目录没有条目。

+ : ( +
    + {entries.map((entry) => ( +
  • + {entry.isDirectory + ? ( + + ) + : ( + <> + {entry.name} + {entry.size === undefined + ? null + : {formatArchiveSize(entry.size)}} + + )} +
  • + ))} +
+ )} + +
+ ); +} + +/** + * The extract affordance, with its destination shown rather than typed. + * + * The destination is a workspace-relative path this component derived from the + * archive (or a caller-supplied one); there is no free-text field anywhere in + * this view, because a destination a browser can type is a path-traversal + * endpoint rather than a feature. The server re-validates it regardless. + */ +function ArchiveExtractControls({ + destination, + extractState, + onExtract, +}: { + readonly destination: string; + readonly extractState: ArchiveExtractState; + readonly onExtract?: () => void; +}): ReactElement { + return ( +
+

+ {destination ? `解压目标:${destination}` : "没有可用的解压目标。"} +

+ + {extractState.status === "done" + ? ( +

+ {`已解压 ${extractState.result.writtenFiles} 个文件到 ${extractState.result.destination}${extractState.result.truncated ? "(条目过多,仅解压了前一部分)" : ""}。`} +

+ ) + : null} + {extractState.status === "error" + ?

{extractState.error}

+ : null} +
+ ); +} + +/** Shown for every state that is not a listing: unavailable, loading, failed. */ +export function WorkspaceArchiveNotice({ + reason, + role = "status", +}: { + readonly reason: string; + /** `alert` for a failure, `status` for a state that was never available. */ + readonly role?: "status" | "alert"; +}): ReactElement { + return

{reason}

; +} + +/** + * The archive preview: one level at a time, with the extract control. + * + * Entering a directory re-lists with `prefix`; going up drops the last segment. + * A `truncated` listing is passed straight through rather than smoothed over — + * the runtime hit a ceiling and the user has to know that what they see is not + * the whole archive. + */ +export function WorkspaceArchiveView(props: WorkspaceArchiveViewProps): ReactElement { + const { workspaceDir, path, destination, readWorkspaceArchive, extractWorkspaceArchive } = props; + const [prefix, setPrefix] = useState(""); + const [load, setLoad] = useState({ status: "loading" }); + const [extract, setExtract] = useState({ status: "idle" }); + const target = destination ?? archiveDestinationFor(path); + + useEffect(() => { + if (!readWorkspaceArchive) return; + let stale = false; + setLoad({ status: "loading" }); + readWorkspaceArchive({ workspaceDir, path, ...(prefix ? { prefix } : {}) }) + .then((listing) => { + if (stale) return; + setLoad({ status: "ready", listing }); + }) + .catch((error: unknown) => { + if (stale) return; + setLoad({ status: "error", error: describeError(error) }); + }); + return () => { + stale = true; + }; + }, [workspaceDir, path, prefix, readWorkspaceArchive]); + + const enterDirectory = useCallback((name: string) => { + setPrefix((current) => (current ? `${current}/${name}` : name)); + }, []); + const goUp = useCallback(() => { + setPrefix((current) => { + const slash = current.lastIndexOf("/"); + return slash === -1 ? "" : current.slice(0, slash); + }); + }, []); + const extractHere = useCallback(() => { + if (!extractWorkspaceArchive || !target) return; + setExtract({ status: "running" }); + extractWorkspaceArchive({ workspaceDir, path, destination: target, ...(prefix ? { prefix } : {}) }) + .then((result) => setExtract({ status: "done", result })) + .catch((error: unknown) => setExtract({ status: "error", error: describeError(error) })); + }, [extractWorkspaceArchive, workspaceDir, path, prefix, target]); + + if (!readWorkspaceArchive) { + return ; + } + if (load.status === "loading") return ; + if (load.status === "error") return ; + return ( + + ); +} + +/** + * The destination an extraction uses when the caller supplies none: the + * archive's own name without its extension, beside the archive. + * + * Derived rather than typed for the reason `destination`'s prop comment gives — + * a browser cannot name an absolute path, and every path it could type would + * need the server to distrust it. Returns "" for a name that is not a plain + * relative path, which the view renders as "no usable destination" instead of + * handing the server something it would have to reject. + */ +export function archiveDestinationFor(archivePath: string): string { + const cleaned = archivePath.replace(/\\/gu, "/"); + const slash = cleaned.lastIndexOf("/"); + const directory = slash === -1 ? "" : cleaned.slice(0, slash); + const name = cleaned.slice(slash + 1).replace(/\.(?:tar\.gz|tgz|zip|tar|gz|bz2|xz|7z|rar)$/iu, ""); + const candidate = directory ? `${directory}/${name}` : name; + if (!candidate) return ""; + return candidate.split("/").some((segment) => segment === "" || segment === "." || segment === "..") ? "" : candidate; +} + +/** Bytes as a short human string; absent stays absent rather than becoming 0. */ +export function formatArchiveSize(size: number): string { + if (!Number.isFinite(size) || size < 0) return ""; + if (size < 1024) return `${size} B`; + const units = ["KB", "MB", "GB", "TB"]; + let value = size / 1024; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return `${value.toFixed(1)} ${units[unit]}`; +} + +function describeError(error: unknown): string { + return error instanceof Error && error.message ? error.message : String(error); +} diff --git a/packages/webui/src/client/components/WorkspaceCanvas.tsx b/packages/webui/src/client/components/WorkspaceCanvas.tsx new file mode 100644 index 000000000..b2bddd4ff --- /dev/null +++ b/packages/webui/src/client/components/WorkspaceCanvas.tsx @@ -0,0 +1,738 @@ +// Owns the workspace canvas feature: the canvas document state, the zoom and +// pan state, and the whole `webui-canvas-content` render. A different implementer +// works in this file; the container in `WorkspacePanels.tsx` only mounts it. +// +// The canvas is a real surface, not a readout: nodes are absolutely positioned +// on a pannable stage, and every mutation (move, resize, add, remove) is sent +// through `applyCanvas` and then adopted from the returned document. The +// server document is the only truth — local state is never treated as saved. +// +// Port contract, determined from the runtime rather than guessed: +// `packages/local-runtime-v2/src/service/canvas/contracts.ts` declares +// `CanvasOperationV1 { schemaVersion: 1, operationId, mutations }` and +// `CanvasMutationV1` = add_file | update_layout | remove_node | the three +// annotation mutations. The webui boundary keeps `operation` as an opaque +// `Record` (`src/server/operation/workspace.ts` only checks +// that it is a non-null, non-array object) and forwards it verbatim to that +// service, so the payload below is the shape the runtime actually validates. +// Its rules this file honours: `operationId` must be non-blank and unique per +// session (a reused id is rejected as OPERATION_ID_REUSED), `mutations` must +// be non-empty, `add_file` must carry exactly one of `relativePath` / +// `assetId`, and a layout must be finite with positive width/height and a +// safe-integer zIndex. + +import { + useEffect, + useRef, + useState, + type FormEvent, + type KeyboardEvent as ReactKeyboardEvent, + type PointerEvent as ReactPointerEvent, + type ReactElement, + type WheelEvent as ReactWheelEvent, +} from "react"; +import type { WebuiCanvasDocument } from "../../server/port.js"; + +const DESKTOP_COPY = { + canvasEmptyTitle: "把文件放到画布上", + canvasEmptyDescription: "添加图片或其他工作区文件,然后自由排列和调整大小。", + canvasAddLabel: "工作区相对路径", + canvasAddPlaceholder: "docs/diagram.png", + canvasAddSubmit: "添加到画布", + canvasNodeRemove: "移除", + canvasNodeHint: "方向键移动,Alt+方向键缩放,Shift 加速。", + canvasStageLabel: "画布:空白处拖动平移,滚轮缩放,方向键平移。", + canvasReadOnly: "画布当前为只读:保存操作没有接入。", + canvasSaving: "正在保存画布改动…", + canvasBusy: "上一个画布改动还在保存,请稍候。", + canvasAddRequired: "请先输入工作区相对路径。", + canvasFailed: "画布改动没有保存。", +} as const; + +/** Wheel zoom keeps the clamp and step the panel shipped with. */ +export const CANVAS_ZOOM_MIN = 0.4; +export const CANVAS_ZOOM_MAX = 2; +export const CANVAS_ZOOM_STEP = 0.1; + +/** Canvas units per arrow key press, and the Shift multiplier. */ +export const CANVAS_NUDGE_STEP = 8; +export const CANVAS_NUDGE_STEP_LARGE = 32; +/** The runtime rejects a non-positive width or height, so clamp before sending. */ +export const CANVAS_MIN_NODE_SIZE = 24; + +/** Backing box for the stage; nodes are placed inside it in canvas units. */ +const CANVAS_WORLD_SIZE = 4000; +const CANVAS_ADD_GAP_X = 264; +const CANVAS_ADD_GAP_Y = 204; +const CANVAS_ADD_COLUMNS = 5; +const CANVAS_ADD_ORIGIN = 32; +const CANVAS_ADD_WIDTH = 240; +const CANVAS_ADD_HEIGHT = 180; +const CANVAS_PAN_KEY_SCALE = 4; + +// Re-exported so the canvas keeps a single import site for everything a +// caller or a test needs, while the definitions themselves live in +// `contracts.ts` — the only layer `WebuiTransport` is allowed to depend on. +export type { CanvasMutation, CanvasNodeLayout, CanvasOperation } from "../contracts.js"; +import type { CanvasMutation, CanvasNodeLayout, CanvasOperation } from "../contracts.js"; + +export interface CanvasNodeView { + readonly id: string; + readonly label: string; + readonly layout: CanvasNodeLayout; +} + +export interface CanvasPan { + readonly x: number; + readonly y: number; +} + +/** + * A node drag, a node resize and a canvas pan all start from one pointer and + * one gesture, so the marker on the pointerdown target picks the winner once. + * `CanvasMutation` itself is declared in `contracts.ts` and re-exported above. + */ + +export type CanvasFileIdentity = + | { readonly relativePath: string } + | { readonly assetId: string }; + +/** + * Pan-vs-drag rule: the pointerdown target decides, once, and the decision is + * never revised for the rest of the gesture. + * + * * no marker, or a marker the canvas does not own → pan the stage; + * * a node marked `move` → drag that node; + * * a node's corner marked `resize` → resize that node; + * * a marked `control` (the remove button) → no gesture at all, the click + * belongs to the control. + * + * Because the choice is frozen at pointerdown, a node drag that wanders off the + * node stays a node drag and a pan that crosses a node stays a pan: neither + * needs a movement threshold to disambiguate, and the pointer can be captured + * on the viewport so a drag that leaves the surface still delivers its moves. + */ +export type CanvasPointerGesture = "pan" | "move" | "resize" | "none"; + +export const CANVAS_GESTURE_ATTRIBUTE = "data-canvas-gesture"; +export const CANVAS_NODE_ID_ATTRIBUTE = "data-canvas-node-id"; + +export function resolveCanvasPointerGesture( + marker: string | null | undefined, +): CanvasPointerGesture { + if (marker === "move" || marker === "resize") return marker; + if (marker === "control") return "none"; + return "pan"; +} + +/** Reads the gesture marker an element (or an ancestor) carries. */ +function readCanvasGestureContext(target: EventTarget | null): { + readonly kind: CanvasPointerGesture; + readonly nodeId?: string; +} { + const element = target instanceof Element ? target : null; + const marker = element?.closest( + `[${CANVAS_GESTURE_ATTRIBUTE}="move"], [${CANVAS_GESTURE_ATTRIBUTE}="resize"]`, + ) ?? null; + const kind = resolveCanvasPointerGesture(marker?.getAttribute(CANVAS_GESTURE_ATTRIBUTE) ?? null); + const nodeId = marker?.getAttribute(CANVAS_NODE_ID_ATTRIBUTE) ?? undefined; + // A move/resize marker without a node identity is a wiring bug, not a pan. + if ((kind === "move" || kind === "resize") && !nodeId) return { kind: "none" }; + return nodeId ? { kind, nodeId } : { kind }; +} + +function roundCanvasNumber(value: number): number { + return Number.isFinite(value) ? Math.round(value) : 0; +} + +function normalizeCanvasZIndex(value: number): number { + return Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0; +} + +/** Brings a layout inside the bounds the runtime validates before storing it. */ +export function normalizeCanvasLayout(layout: CanvasNodeLayout): CanvasNodeLayout { + return { + x: roundCanvasNumber(layout.x), + y: roundCanvasNumber(layout.y), + width: Math.max(CANVAS_MIN_NODE_SIZE, roundCanvasNumber(layout.width)), + height: Math.max(CANVAS_MIN_NODE_SIZE, roundCanvasNumber(layout.height)), + zIndex: normalizeCanvasZIndex(layout.zIndex), + }; +} + +export function clampCanvasZoom(value: number): number { + if (!Number.isFinite(value)) return 1; + // Rounded to two decimals so repeated 0.1 steps cannot accumulate float + // error, while keeping the step and the clamp ends exactly as shipped. + return Math.round(Math.min(CANVAS_ZOOM_MAX, Math.max(CANVAS_ZOOM_MIN, value)) * 100) / 100; +} + +/** `deltaY > 0` scrolls down and zooms out, as the panel has always done. */ +export function stepCanvasZoom(current: number, deltaY: number): number { + const base = Number.isFinite(current) ? current : 1; + return clampCanvasZoom(base + (deltaY > 0 ? -CANVAS_ZOOM_STEP : CANVAS_ZOOM_STEP)); +} + +/** + * The stage transforms as `translate(pan) scale(zoom)` from a `0 0` origin, so + * the translation is already in screen pixels while a node's layout is in + * canvas units: a pointer delta has to be divided by the zoom to move a node by + * the amount the pointer travelled. + */ +export function pointerDeltaToCanvas( + deltaX: number, + deltaY: number, + zoom: number, +): { readonly dx: number; readonly dy: number } { + const scale = Number.isFinite(zoom) && zoom > 0 ? zoom : 1; + return { dx: roundCanvasNumber(deltaX / scale), dy: roundCanvasNumber(deltaY / scale) }; +} + +export function canvasPointerMoveLayout( + start: CanvasNodeLayout, + deltaClientX: number, + deltaClientY: number, + zoom: number, +): CanvasNodeLayout { + const delta = pointerDeltaToCanvas(deltaClientX, deltaClientY, zoom); + return normalizeCanvasLayout({ ...start, x: start.x + delta.dx, y: start.y + delta.dy }); +} + +export function canvasPointerResizeLayout( + start: CanvasNodeLayout, + deltaClientX: number, + deltaClientY: number, + zoom: number, +): CanvasNodeLayout { + const delta = pointerDeltaToCanvas(deltaClientX, deltaClientY, zoom); + return normalizeCanvasLayout({ + ...start, + width: start.width + delta.dx, + height: start.height + delta.dy, + }); +} + +export interface CanvasDirection { + readonly dx: number; + readonly dy: number; +} + +export function canvasArrowDirection(key: string): CanvasDirection | undefined { + switch (key) { + case "ArrowLeft": return { dx: -1, dy: 0 }; + case "ArrowRight": return { dx: 1, dy: 0 }; + case "ArrowUp": return { dx: 0, dy: -1 }; + case "ArrowDown": return { dx: 0, dy: 1 }; + default: return undefined; + } +} + +export function canvasKeyStep(shiftKey: boolean): number { + return shiftKey ? CANVAS_NUDGE_STEP_LARGE : CANVAS_NUDGE_STEP; +} + +/** Keyboard equivalent of dragging a node. */ +export function nudgeCanvasLayout( + layout: CanvasNodeLayout, + direction: CanvasDirection, + step: number, +): CanvasNodeLayout { + return normalizeCanvasLayout({ + ...layout, + x: layout.x + direction.dx * step, + y: layout.y + direction.dy * step, + }); +} + +/** Keyboard equivalent of dragging the resize corner. */ +export function resizeCanvasLayoutBy( + layout: CanvasNodeLayout, + direction: CanvasDirection, + step: number, +): CanvasNodeLayout { + return normalizeCanvasLayout({ + ...layout, + width: layout.width + direction.dx * step, + height: layout.height + direction.dy * step, + }); +} + +export function canvasLayoutsEqual( + left: CanvasNodeLayout | undefined, + right: CanvasNodeLayout | undefined, +): boolean { + if (!left || !right) return left === right; + return left.x === right.x + && left.y === right.y + && left.width === right.width + && left.height === right.height + && left.zIndex === right.zIndex; +} + +export function buildCanvasUpdateLayoutMutation( + nodeId: string, + layout: CanvasNodeLayout, +): CanvasMutation { + return { kind: "update_layout", nodeId, layout: normalizeCanvasLayout(layout) }; +} + +/** Emits exactly one file identity key, never both and never neither. */ +export function buildCanvasAddFileMutation( + nodeId: string, + layout: CanvasNodeLayout, + target: CanvasFileIdentity, +): CanvasMutation { + const base = { kind: "add_file" as const, nodeId, layout: normalizeCanvasLayout(layout) }; + return "assetId" in target + ? { ...base, assetId: target.assetId } + : { ...base, relativePath: target.relativePath }; +} + +export function buildCanvasRemoveNodeMutation(nodeId: string): CanvasMutation { + return { kind: "remove_node", nodeId }; +} + +/** + * The runtime keys applied operations by `(sessionId, operationId)` and + * rejects a reuse whose payload differs, so every operation needs a fresh id + * rather than a stable one derived from the node. + */ +export function createCanvasOperationId(): string { + return randomCanvasId("canvas-op"); +} + +export function createCanvasNodeId(): string { + return randomCanvasId("canvas-node"); +} + +function randomCanvasId(prefix: string): string { + const webCrypto = globalThis.crypto; + const unique = typeof webCrypto?.randomUUID === "function" + ? webCrypto.randomUUID() + : `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`; + return `${prefix}_${unique}`; +} + +/** + * Fails here for exactly the two reasons the runtime rejects an operation, so + * the panel never sends a payload that is guaranteed to be refused. + */ +export function buildCanvasOperation( + operationId: string, + mutations: readonly CanvasMutation[], +): CanvasOperation { + const id = operationId.trim(); + if (!id) throw new Error("canvas operationId is required"); + if (mutations.length === 0) throw new Error("canvas operation needs at least one mutation"); + return { schemaVersion: 1, operationId: id, mutations: [...mutations] }; +} + +export function nextCanvasZIndex(nodes: readonly CanvasNodeView[]): number { + return nodes.reduce((highest, node) => Math.max(highest, node.layout.zIndex), 0) + 1; +} + +/** Cascades a new node so consecutive adds do not stack on one spot. */ +export function nextCanvasNodeLayout(nodes: readonly CanvasNodeView[]): CanvasNodeLayout { + const index = Math.max(0, nodes.length); + const column = index % CANVAS_ADD_COLUMNS; + const row = Math.floor(index / CANVAS_ADD_COLUMNS); + return normalizeCanvasLayout({ + x: CANVAS_ADD_ORIGIN + column * CANVAS_ADD_GAP_X, + y: CANVAS_ADD_ORIGIN + row * CANVAS_ADD_GAP_Y, + width: CANVAS_ADD_WIDTH, + height: CANVAS_ADD_HEIGHT, + zIndex: nextCanvasZIndex(nodes), + }); +} + +export function describeCanvasError(cause: unknown): string { + if (cause instanceof Error && cause.message.trim()) return cause.message; + if (typeof cause === "string" && cause.trim()) return cause.trim(); + return DESKTOP_COPY.canvasFailed; +} + +function readCanvasRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function readCanvasLayout(value: unknown): CanvasNodeLayout | undefined { + const record = readCanvasRecord(value); + if (!record) return undefined; + const { x, y, width, height, zIndex } = record; + if ( + typeof x !== "number" || !Number.isFinite(x) + || typeof y !== "number" || !Number.isFinite(y) + || typeof width !== "number" || !Number.isFinite(width) + || typeof height !== "number" || !Number.isFinite(height) + ) return undefined; + return normalizeCanvasLayout({ + x, + y, + width, + height, + zIndex: typeof zIndex === "number" ? zIndex : 0, + }); +} + +/** + * `WebuiCanvasDocument.nodes` is `Record[]` at the webui + * boundary, so a node missing its id or its layout is dropped rather than + * allowed to break the render. + */ +export function parseCanvasNodes( + document: WebuiCanvasDocument | undefined, +): readonly CanvasNodeView[] { + const nodes = Array.isArray(document?.nodes) ? document?.nodes : []; + const parsed: CanvasNodeView[] = []; + for (const raw of nodes ?? []) { + const record = readCanvasRecord(raw); + const id = typeof record?.id === "string" ? record.id : ""; + const layout = readCanvasLayout(record?.layout); + if (!id || !layout) continue; + const file = readCanvasRecord(record?.file); + const fileName = typeof file?.fileName === "string" ? file.fileName : ""; + const relativePath = typeof file?.relativePath === "string" ? file.relativePath : ""; + parsed.push({ id, label: fileName || relativePath || id, layout }); + } + return parsed; +} + +export interface CanvasLayoutPreview { + readonly nodeId: string; + readonly layout: CanvasNodeLayout; +} + +/** + * The in-flight drag preview. It is deliberately separate from the canvas + * document: the document stays the last server-confirmed truth, and a refused + * operation drops the preview rather than leaving an optimistic value on screen. + */ +export function applyCanvasLayoutPreview( + nodes: readonly CanvasNodeView[], + preview: CanvasLayoutPreview | undefined, +): readonly CanvasNodeView[] { + if (!preview) return nodes; + return nodes.map((node) => + node.id === preview.nodeId ? { ...node, layout: preview.layout } : node, + ); +} + +interface CanvasGestureState { + readonly kind: CanvasPointerGesture; + readonly pointerId: number; + readonly startClientX: number; + readonly startClientY: number; + readonly startZoom: number; + readonly startPan: CanvasPan; + readonly startLayout?: CanvasNodeLayout; + readonly nodeId?: string; +} + +export function WorkspaceCanvas({ sessionId, readCanvas, applyCanvas }: { + /** The session whose canvas document is shown; absent means no canvas. */ + readonly sessionId?: string; + /** Port call that reads the canvas document for a session. */ + readonly readCanvas?: (request: { sessionId: string }) => Promise; + /** + * Port call that applies one operation and returns the document the runtime + * actually stored. Absent means the canvas is read-only, and the panel says + * so instead of pretending a change was saved. + */ + readonly applyCanvas?: (request: { + readonly sessionId: string; + readonly operation: CanvasOperation; + }) => Promise<{ readonly operationId: string; readonly document: WebuiCanvasDocument }>; +}): ReactElement { + const [canvas, setCanvas] = useState(); + const [zoom, setZoom] = useState(1); + const [pan, setPan] = useState({ x: 0, y: 0 }); + const [preview, setPreview] = useState(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + const [notice, setNotice] = useState(); + const [draftPath, setDraftPath] = useState(""); + const gestureRef = useRef(); + const viewportRef = useRef(null); + // Identifies the newest read or apply so a late reply from a session the user + // has already left cannot overwrite the current document. + const applySeqRef = useRef(0); + + useEffect(() => { + if (!sessionId || !readCanvas) { setCanvas(undefined); return undefined; } + let cancelled = false; + void readCanvas({ sessionId }) + .then((next) => { if (!cancelled) { setCanvas(next); setError(undefined); } }) + .catch((cause: unknown) => { + if (cancelled) return; + setCanvas(undefined); + setError(describeCanvasError(cause)); + }); + return () => { cancelled = true; }; + }, [sessionId, readCanvas]); + + // A different session is a different world: start it at the origin. + useEffect(() => { + setPan({ x: 0, y: 0 }); + setZoom(1); + setPreview(undefined); + }, [sessionId]); + + const confirmedNodes = parseCanvasNodes(canvas); + const nodes = applyCanvasLayoutPreview(confirmedNodes, preview); + const readOnly = !applyCanvas || !sessionId; + + const applyMutations = ( + mutations: readonly CanvasMutation[], + onSuccess?: () => void, + ): void => { + const port = applyCanvas; + const targetSessionId = sessionId; + if (!port || !targetSessionId) { setError(DESKTOP_COPY.canvasReadOnly); return; } + let operation: CanvasOperation; + try { + operation = buildCanvasOperation(createCanvasOperationId(), mutations); + } catch (cause: unknown) { + setError(describeCanvasError(cause)); + return; + } + const seq = ++applySeqRef.current; + setBusy(true); + setError(undefined); + setNotice(undefined); + void port({ sessionId: targetSessionId, operation }) + .then((result) => { + if (seq !== applySeqRef.current) return; + setCanvas(result.document); + setPreview(undefined); + onSuccess?.(); + }) + .catch((cause: unknown) => { + if (seq !== applySeqRef.current) return; + // The stored document is still the last one this view read, so drop the + // optimistic preview and report the refusal instead of keeping it. + setPreview(undefined); + setError(describeCanvasError(cause)); + }) + .finally(() => { if (seq === applySeqRef.current) setBusy(false); }); + }; + + const releaseCapture = (pointerId: number): void => { + try { viewportRef.current?.releasePointerCapture(pointerId); } catch { /* already released */ } + }; + + const handlePointerDown = (event: ReactPointerEvent): void => { + const context = readCanvasGestureContext(event.target); + if (context.kind === "none") return; + if (event.pointerType === "mouse" && event.button !== 0) return; + if (busy) { setNotice(DESKTOP_COPY.canvasBusy); return; } + const startLayout = context.nodeId + ? confirmedNodes.find((node) => node.id === context.nodeId)?.layout + : undefined; + if ((context.kind === "move" || context.kind === "resize") && !startLayout) return; + gestureRef.current = { + kind: context.kind, + pointerId: event.pointerId, + startClientX: event.clientX, + startClientY: event.clientY, + startZoom: zoom, + startPan: pan, + ...(startLayout ? { startLayout } : {}), + ...(context.nodeId ? { nodeId: context.nodeId } : {}), + }; + // Capture on the viewport for every gesture: the decision is already in the + // ref, so a pointer that leaves the surface still reports its moves. + try { viewportRef.current?.setPointerCapture(event.pointerId); } catch { /* best effort */ } + event.preventDefault(); + }; + + const handlePointerMove = (event: ReactPointerEvent): void => { + const gesture = gestureRef.current; + if (!gesture || gesture.pointerId !== event.pointerId) return; + const deltaClientX = event.clientX - gesture.startClientX; + const deltaClientY = event.clientY - gesture.startClientY; + if (gesture.kind === "pan") { + setPan({ x: gesture.startPan.x + deltaClientX, y: gesture.startPan.y + deltaClientY }); + return; + } + if (!gesture.startLayout || !gesture.nodeId) return; + setPreview({ + nodeId: gesture.nodeId, + layout: gesture.kind === "resize" + ? canvasPointerResizeLayout(gesture.startLayout, deltaClientX, deltaClientY, gesture.startZoom) + : canvasPointerMoveLayout(gesture.startLayout, deltaClientX, deltaClientY, gesture.startZoom), + }); + }; + + const handlePointerUp = (event: ReactPointerEvent): void => { + const gesture = gestureRef.current; + if (!gesture || gesture.pointerId !== event.pointerId) return; + // Clear before releasing, so the implicit lostpointercapture is a no-op. + gestureRef.current = undefined; + releaseCapture(event.pointerId); + if (!gesture.startLayout || !gesture.nodeId) return; + const deltaClientX = event.clientX - gesture.startClientX; + const deltaClientY = event.clientY - gesture.startClientY; + const layout = gesture.kind === "resize" + ? canvasPointerResizeLayout(gesture.startLayout, deltaClientX, deltaClientY, gesture.startZoom) + : canvasPointerMoveLayout(gesture.startLayout, deltaClientX, deltaClientY, gesture.startZoom); + if (canvasLayoutsEqual(layout, gesture.startLayout)) { setPreview(undefined); return; } + applyMutations([buildCanvasUpdateLayoutMutation(gesture.nodeId, layout)]); + }; + + const handlePointerCancel = (): void => { + if (!gestureRef.current) return; + gestureRef.current = undefined; + setPreview(undefined); + }; + + const handlePanKeyDown = (event: ReactKeyboardEvent): void => { + const direction = canvasArrowDirection(event.key); + if (!direction) return; + event.preventDefault(); + const step = canvasKeyStep(event.shiftKey) * CANVAS_PAN_KEY_SCALE; + setPan((current) => ({ + x: current.x + direction.dx * step, + y: current.y + direction.dy * step, + })); + }; + + const handleNodeKeyDown = ( + event: ReactKeyboardEvent, + node: CanvasNodeView, + ): void => { + const direction = canvasArrowDirection(event.key); + if (!direction) return; + if (readOnly) { setError(DESKTOP_COPY.canvasReadOnly); return; } + event.preventDefault(); + const step = canvasKeyStep(event.shiftKey); + // Drag and resize both have a real keyboard equivalent, so the pointer + // interaction is never the only way to reach a layout. + const layout = event.altKey + ? resizeCanvasLayoutBy(node.layout, direction, step) + : nudgeCanvasLayout(node.layout, direction, step); + applyMutations([buildCanvasUpdateLayoutMutation(node.id, layout)]); + }; + + const handleAddSubmit = (event: FormEvent): void => { + event.preventDefault(); + const relativePath = draftPath.trim(); + if (!relativePath) { setError(DESKTOP_COPY.canvasAddRequired); return; } + if (readOnly) { setError(DESKTOP_COPY.canvasReadOnly); return; } + applyMutations( + [buildCanvasAddFileMutation(createCanvasNodeId(), nextCanvasNodeLayout(confirmedNodes), { relativePath })], + () => setDraftPath(""), + ); + }; + + const handleWheel = (event: ReactWheelEvent): void => { + event.preventDefault(); + setZoom((value) => stepCanvasZoom(value, event.deltaY)); + }; + + return
+ {nodes.length === 0 ? <>{DESKTOP_COPY.canvasEmptyTitle}

{DESKTOP_COPY.canvasEmptyDescription}

:
+
+ {nodes.map((node) =>
handleNodeKeyDown(event, node)} + style={{ + position: "absolute", + left: node.layout.x, + top: node.layout.y, + width: node.layout.width, + height: node.layout.height, + zIndex: node.layout.zIndex, + boxSizing: "border-box", + overflow: "hidden", + touchAction: "none", + cursor: "grab", + userSelect: "none", + }} + > + {node.label} + + )} +
+
} + {Math.round(zoom * 100)}% +
+ setDraftPath(event.target.value)} + style={{ border: "1px solid var(--border_default)", borderRadius: "var(--radius_8)", background: "var(--bg_grouped_secondary)", color: "var(--text_default_primary)", padding: "var(--spacing_8)", fontSize: "var(--size_12)", textAlign: "left" }} + /> + +
+ {/* Status text is stacked in one column so the read-only note, the + in-flight state, a notice and a failure can never overlap. */} +
+ {readOnly ?

{DESKTOP_COPY.canvasReadOnly}

: null} + {busy ?

{DESKTOP_COPY.canvasSaving}

: null} + {notice ?

{notice}

: null} + {error ?

{error}

: null} +
+
; +} diff --git a/packages/webui/src/client/components/WorkspaceFileTree.tsx b/packages/webui/src/client/components/WorkspaceFileTree.tsx new file mode 100644 index 000000000..9e79c420c --- /dev/null +++ b/packages/webui/src/client/components/WorkspaceFileTree.tsx @@ -0,0 +1,131 @@ +// Owns the workspace file-tree feature: the recursive tree component and the +// file lookup/filter helpers. A different implementer works in this file; the +// container in `WorkspacePanels.tsx` only imports from it. + +import type { ReactElement } from "react"; +import type { WebuiWorkspaceFile } from "../../server/port.js"; +import { WebuiIconChevronLeft, WebuiIconFile } from "../icons.js"; + +export function filterWorkspaceFiles(files: readonly WebuiWorkspaceFile[], query: string): WebuiWorkspaceFile[] { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return [...files]; + return files.flatMap((file) => { + const children = file.children ? filterWorkspaceFiles(file.children, normalizedQuery) : []; + if (file.name.toLowerCase().includes(normalizedQuery) || children.length > 0) { + return [{ ...file, ...(file.children ? { children } : {}) }]; + } + return []; + }); +} + +export function mergeWorkspaceFileChildren( + files: readonly WebuiWorkspaceFile[], + directoryPath: string, + children: readonly WebuiWorkspaceFile[], +): readonly WebuiWorkspaceFile[] { + return files.map((file) => { + if (file.path === directoryPath && file.type === "directory") { + return { ...file, children }; + } + return file.children?.length + ? { ...file, children: mergeWorkspaceFileChildren(file.children, directoryPath, children) } + : file; + }); +} + +export function findWorkspaceFile(files: readonly WebuiWorkspaceFile[], path: string): WebuiWorkspaceFile | undefined { + for (const file of files) { + if (file.path === path) return file; + const nested = file.children ? findWorkspaceFile(file.children, path) : undefined; + if (nested) return nested; + } + return undefined; +} + +export function getWorkspaceFileParentPaths(path: string): string[] { + const segments = path.replace(/\\/gu, "/").split("/").filter(Boolean); + return segments.slice(0, -1).map((_, index) => segments.slice(0, index + 1).join("/")); +} + +const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB"] as const; +const BYTE_STEP = 1024; + +/** + * Size scheme: binary units (1 KB = 1024 B), a space before the unit, whole + * bytes with no decimal, and one decimal place from KB up. The displayed value + * is rounded BEFORE the unit is promoted, because promoting on the raw value + * lets 1048575 B round to "1024.0" KB and read `1024 KB`; rounding first sends + * it to `1 MB`. Returns undefined for a value that cannot be a byte count, so + * an unusable field renders nothing at all. + */ +export function formatWorkspaceFileSize(size: number): string | undefined { + if (!Number.isFinite(size) || size < 0) return undefined; + let value = size; + let unitIndex = 0; + for (;;) { + const amount = unitIndex === 0 ? Math.round(value) : Number(value.toFixed(1)); + if (amount < BYTE_STEP || unitIndex === BYTE_UNITS.length - 1) return `${amount} ${BYTE_UNITS[unitIndex]}`; + value /= BYTE_STEP; + unitIndex += 1; + } +} + +/** + * Time scheme: `YYYY-MM-DD HH:mm` in UTC, assembled from the UTC date getters + * instead of `toLocaleString` / `toLocaleDateString`. Those follow the host + * locale and timezone, so the same file would read differently on two machines + * and any assertion on the text would be environment-dependent; fixed-width + * ISO fields in UTC are identical everywhere and still sort chronologically. + * Seconds are dropped because a file tree is read at a glance, and an + * out-of-range epoch returns undefined rather than throwing the `Invalid Date` + * that `toISOString` would raise. + */ +export function formatWorkspaceFileModifiedAt(modifiedAt: number): string | undefined { + if (!Number.isFinite(modifiedAt)) return undefined; + const date = new Date(modifiedAt); + const time = date.getTime(); + if (Number.isNaN(time)) return undefined; + const pad = (value: number): string => String(value).padStart(2, "0"); + return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}`; +} + +/** + * The file-metadata column. `size` and `modifiedAt` are optional by contract: + * absent for directories, and absent for any runtime that does not stat, so + * each half is formatted on its own and the whole span is dropped when neither + * produced a value. Directories never reach it — a directory's byte count is + * meaningless — which also keeps a stray value from a permissive runtime out + * of the row. + */ +function FileTreeRowMeta({ file }: { readonly file: WebuiWorkspaceFile }): ReactElement | null { + if (file.type === "directory") return null; + const parts = [ + file.size === undefined ? undefined : formatWorkspaceFileSize(file.size), + file.modifiedAt === undefined ? undefined : formatWorkspaceFileModifiedAt(file.modifiedAt), + ].filter((part): part is string => part !== undefined); + if (parts.length === 0) return null; + // `webui-file-tree-meta` is a hook for the row layout: the utilities keep + // the metadata pinned to the end of the flex row while the name truncates, + // so a long name can never push this off the end. + return {parts.join(" · ")}; +} + +export function FileTree({ files, onOpen, expandedPaths, loadingPaths, directoryErrors, onToggle, selectedPath }: { readonly files: readonly WebuiWorkspaceFile[]; readonly onOpen: (file: WebuiWorkspaceFile) => void; readonly expandedPaths: ReadonlySet; readonly loadingPaths: ReadonlySet; readonly directoryErrors: Readonly>; readonly onToggle: (file: WebuiWorkspaceFile) => void; readonly selectedPath?: string }): ReactElement { + return
{files.map((file) => { + const expanded = expandedPaths.has(file.path); + return
+ + {file.type === "directory" ?
element?.toggleAttribute("inert", !expanded)}> +
+ {loadingPaths.has(file.path) ?

正在加载目录…

+ : directoryErrors[file.path] ?

{directoryErrors[file.path]}

+ : file.children?.length ? + :

此文件夹为空。

} +
+
: null} +
; + })}
; +} diff --git a/packages/webui/src/client/components/WorkspaceHtmlPreview.tsx b/packages/webui/src/client/components/WorkspaceHtmlPreview.tsx new file mode 100644 index 000000000..f26742cc1 --- /dev/null +++ b/packages/webui/src/client/components/WorkspaceHtmlPreview.tsx @@ -0,0 +1,190 @@ +// Owns the workspace HTML-preview feature (rendering an .html file inside +// the workspace panel). The dispatch in `WebuiFilePreview` routes +// `.html`/`.htm` here before the read result is consulted; nothing else in +// the panel needs to know this component exists. +// +// The document is rendered by pointing an `