diff --git a/Cargo.lock b/Cargo.lock index 3bb8b3ead..ec2764e4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,9 +100,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" dependencies = [ "clipboard-win", + "image", "log", "objc2 0.6.4", "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", @@ -841,6 +844,12 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.7" @@ -1022,6 +1031,12 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-bigint" version = "0.7.5" @@ -1777,6 +1792,12 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -2204,6 +2225,16 @@ dependencies = [ "polyval", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl 0.1.12", +] + [[package]] name = "gio" version = "0.18.4" @@ -2425,6 +2456,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2826,13 +2868,27 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "gif", + "image-webp", "moxcms", "num-traits", "png 0.18.1", + "tiff", "zune-core", "zune-jpeg", ] +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -3329,6 +3385,7 @@ dependencies = [ "futures-util", "globset", "ignore", + "image", "leveldb-core", "lopdf", "notify", @@ -3415,7 +3472,7 @@ dependencies = [ "thiserror 2.0.18", "time", "ttf-parser", - "weezl", + "weezl 0.2.1", ] [[package]] @@ -4828,6 +4885,12 @@ version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.39.4" @@ -6825,6 +6888,20 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl 0.1.12", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.53" @@ -7800,6 +7877,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "weezl" version = "0.2.1" diff --git a/crates/agent-gateway/web/src/agent-ui-adapters/workspacePreview.tsx b/crates/agent-gateway/web/src/agent-ui-adapters/workspacePreview.tsx index 376c6c34b..1793700a2 100644 --- a/crates/agent-gateway/web/src/agent-ui-adapters/workspacePreview.tsx +++ b/crates/agent-gateway/web/src/agent-ui-adapters/workspacePreview.tsx @@ -5,6 +5,130 @@ export async function readWorkspaceClipboardText() { return null; } +function base64ToBytes(data: string) { + const binary = window.atob(data); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} + +function fileExtension(fileName: string) { + const dot = fileName.lastIndexOf("."); + return dot > 0 && dot < fileName.length - 1 ? fileName.slice(dot).toLowerCase() : null; +} + +async function drawImageBlobToCanvas(source: Blob) { + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + if (!context) throw new Error("Image clipboard canvas is unavailable"); + + if (typeof createImageBitmap === "function") { + try { + const bitmap = await createImageBitmap(source); + try { + canvas.width = bitmap.width; + canvas.height = bitmap.height; + context.drawImage(bitmap, 0, 0); + return canvas; + } finally { + bitmap.close(); + } + } catch { + // SVG and some browser-supported image codecs are not always accepted + // by createImageBitmap, so use the regular image decoder as a fallback. + } + } + + const blobUrl = URL.createObjectURL(source); + try { + const image = new Image(); + await new Promise((resolve, reject) => { + image.onload = () => resolve(); + image.onerror = () => reject(new Error("Failed to decode image for clipboard")); + image.src = blobUrl; + }); + if (!image.naturalWidth || !image.naturalHeight) { + throw new Error("Image has no drawable dimensions"); + } + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + context.drawImage(image, 0, 0); + return canvas; + } finally { + URL.revokeObjectURL(blobUrl); + } +} + +export async function saveWorkspacePreviewImage(request: { + data: string; + fileName: string; + mimeType: string; +}) { + const bytes = base64ToBytes(request.data); + const blob = new Blob([bytes.buffer], { type: request.mimeType }); + const windowWithPicker = window as Window & { + showSaveFilePicker?: (options: { + suggestedName: string; + types?: Array<{ accept: Record; description: string }>; + }) => Promise<{ + createWritable: () => Promise<{ + close: () => Promise; + write: (data: Blob) => Promise; + }>; + }>; + }; + if (windowWithPicker.showSaveFilePicker) { + try { + const extension = fileExtension(request.fileName); + const handle = await windowWithPicker.showSaveFilePicker({ + suggestedName: request.fileName, + types: extension + ? [ + { + accept: { [request.mimeType || "application/octet-stream"]: [extension] }, + description: "Image", + }, + ] + : undefined, + }); + const writable = await handle.createWritable(); + await writable.write(blob); + await writable.close(); + return; + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") return; + throw error; + } + } + + const blobUrl = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = blobUrl; + anchor.download = request.fileName; + anchor.style.display = "none"; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(blobUrl), 0); +} + +export async function copyWorkspacePreviewImage(request: { data: string; mimeType: string }) { + if (!navigator.clipboard?.write || typeof ClipboardItem === "undefined") { + throw new Error("Image clipboard is unavailable"); + } + const source = new Blob([base64ToBytes(request.data).buffer], { type: request.mimeType }); + const canvas = await drawImageBlobToCanvas(source); + const png = await new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) resolve(blob); + else reject(new Error("Could not encode image for clipboard")); + }, "image/png"); + }); + await navigator.clipboard.write([new ClipboardItem({ "image/png": png })]); +} + export function WorkspaceOverlayTitleBar() { return null; } diff --git a/crates/agent-gateway/web/test/workspace-overlay-stack.test.mjs b/crates/agent-gateway/web/test/workspace-overlay-stack.test.mjs index 07d44985a..27c88b709 100644 --- a/crates/agent-gateway/web/test/workspace-overlay-stack.test.mjs +++ b/crates/agent-gateway/web/test/workspace-overlay-stack.test.mjs @@ -11,7 +11,185 @@ const loader = createWebModuleLoader({ const { workspaceOverlayStackClassName } = loader.loadModule( "@liveagent/adapters/workspacePreview", ); +const preview = loader.loadModule("@liveagent/adapters/workspacePreview"); + +function restoreGlobal(name, value) { + if (value === undefined) delete globalThis[name]; + else globalThis[name] = value; +} + +function restorePropertyDescriptor(name, descriptor) { + if (descriptor === undefined) delete globalThis[name]; + else Object.defineProperty(globalThis, name, descriptor); +} test("web workspace overlays keep the web shell stacking level", () => { assert.equal(workspaceOverlayStackClassName, "z-40"); }); + +test("web workspace image save uses the native picker and keeps source bytes", async () => { + const previousWindow = globalThis.window; + const writes = []; + let pickerOptions = null; + globalThis.window = { + atob, + async showSaveFilePicker(options) { + pickerOptions = options; + return { + async createWritable() { + return { + async write(blob) { + writes.push(blob); + }, + async close() {}, + }; + }, + }; + }, + }; + + try { + await preview.saveWorkspacePreviewImage({ + data: "AQID", + fileName: "diagram.png", + mimeType: "image/png", + }); + assert.equal(pickerOptions.suggestedName, "diagram.png"); + assert.deepEqual(pickerOptions.types, [{ accept: { "image/png": [".png"] }, description: "Image" }]); + assert.equal(writes.length, 1); + assert.equal(writes[0].type, "image/png"); + assert.deepEqual([...new Uint8Array(await writes[0].arrayBuffer())], [1, 2, 3]); + } finally { + restoreGlobal("window", previousWindow); + } +}); + +test("web workspace image save treats picker cancellation as a no-op", async () => { + const previousWindow = globalThis.window; + globalThis.window = { + atob, + async showSaveFilePicker() { + throw new DOMException("cancelled", "AbortError"); + }, + }; + + try { + await preview.saveWorkspacePreviewImage({ + data: "AQID", + fileName: "diagram.png", + mimeType: "image/png", + }); + } finally { + restoreGlobal("window", previousWindow); + } +}); + +test("web workspace image save falls back to a browser download", async () => { + const previousWindow = globalThis.window; + const previousDocument = globalThis.document; + const originalCreateObjectUrl = URL.createObjectURL; + const originalRevokeObjectUrl = URL.revokeObjectURL; + const downloads = []; + const revokedUrls = []; + URL.createObjectURL = () => "blob:workspace-preview"; + URL.revokeObjectURL = (url) => revokedUrls.push(url); + globalThis.window = { + atob, + setTimeout(callback) { + callback(); + return 1; + }, + }; + globalThis.document = { + body: { + appendChild() {}, + }, + createElement(tag) { + assert.equal(tag, "a"); + return { + style: {}, + click() { + downloads.push({ href: this.href, download: this.download }); + }, + remove() {}, + }; + }, + }; + + try { + await preview.saveWorkspacePreviewImage({ + data: "AQID", + fileName: "diagram.png", + mimeType: "image/png", + }); + assert.deepEqual(downloads, [{ href: "blob:workspace-preview", download: "diagram.png" }]); + assert.deepEqual(revokedUrls, ["blob:workspace-preview"]); + } finally { + URL.createObjectURL = originalCreateObjectUrl; + URL.revokeObjectURL = originalRevokeObjectUrl; + restoreGlobal("window", previousWindow); + restoreGlobal("document", previousDocument); + } +}); + +test("web workspace image copy writes a PNG clipboard item", async () => { + const previousWindow = globalThis.window; + const previousDocument = globalThis.document; + const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator"); + const previousCreateImageBitmap = globalThis.createImageBitmap; + const previousClipboardItem = globalThis.ClipboardItem; + const clipboardWrites = []; + let closed = false; + globalThis.window = { atob }; + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { + clipboard: { + async write(items) { + clipboardWrites.push(items); + }, + }, + }, + }); + globalThis.createImageBitmap = async () => ({ + width: 2, + height: 3, + close() { + closed = true; + }, + }); + globalThis.ClipboardItem = class ClipboardItem { + constructor(items) { + this.items = items; + } + }; + globalThis.document = { + createElement(tag) { + assert.equal(tag, "canvas"); + return { + width: 0, + height: 0, + getContext() { + return { drawImage() {} }; + }, + toBlob(callback, type) { + callback(new Blob(["png"], { type })); + }, + }; + }, + }; + + try { + await preview.copyWorkspacePreviewImage({ data: "AQID", mimeType: "image/png" }); + assert.equal(closed, true); + assert.equal(clipboardWrites.length, 1); + const item = clipboardWrites[0][0]; + assert.equal(item.items["image/png"].type, "image/png"); + } finally { + restoreGlobal("window", previousWindow); + restoreGlobal("document", previousDocument); + restorePropertyDescriptor("navigator", previousNavigator); + restoreGlobal("createImageBitmap", previousCreateImageBitmap); + restoreGlobal("ClipboardItem", previousClipboardItem); + } +}); diff --git a/crates/agent-gui/src-tauri/Cargo.toml b/crates/agent-gui/src-tauri/Cargo.toml index ab3fee027..001001d52 100644 --- a/crates/agent-gui/src-tauri/Cargo.toml +++ b/crates/agent-gui/src-tauri/Cargo.toml @@ -42,8 +42,8 @@ thiserror = "2.0.18" walkdir = "2.5.0" notify = "8.2.0" rfd = "0.17.2" -# Text-only clipboard reads (image-data default feature intentionally off). -arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] } +arboard = { version = "3.6.1", features = ["wayland-data-control"] } +image = { version = "0.25.9", default-features = false, features = ["bmp", "gif", "ico", "jpeg", "png", "webp"] } tauri-plugin-mcp-bridge = "0.12.0" dirs = "6.0.0" toml = "0.9.11" diff --git a/crates/agent-gui/src-tauri/src/commands/app/system.rs b/crates/agent-gui/src-tauri/src/commands/app/system.rs index 3f89e745c..f756eb823 100644 --- a/crates/agent-gui/src-tauri/src/commands/app/system.rs +++ b/crates/agent-gui/src-tauri/src/commands/app/system.rs @@ -1372,6 +1372,61 @@ pub async fn system_pick_file( .map_err(|e| format!("system_pick_file join 失败:{e}"))? } +#[tauri::command(rename_all = "snake_case")] +pub async fn system_save_preview_file( + file_name: String, + mime_type: Option, + data_base64: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let bytes = BASE64_STANDARD + .decode(data_base64) + .map_err(|error| format!("Invalid preview file data: {error}"))?; + // Preserve the source extension in the suggested name. A MIME type is + // not a usable native dialog extension filter by itself. + let _ = mime_type; + let Some(path) = FileDialog::new().set_file_name(&file_name).save_file() else { + return Ok(false); + }; + fs::write(path, bytes).map_err(|error| format!("Failed to save preview file: {error}"))?; + Ok(true) + }) + .await + .map_err(|e| format!("system_save_preview_file join failed: {e}"))? +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn system_clipboard_write_image( + mime_type: Option, + data_base64: String, +) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let mime_type = mime_type.unwrap_or_default(); + if !mime_type.to_ascii_lowercase().starts_with("image/") { + return Err("The selected workspace preview is not an image".to_string()); + } + let encoded = BASE64_STANDARD + .decode(data_base64) + .map_err(|error| format!("Invalid image clipboard data: {error}"))?; + let image = image::load_from_memory(&encoded) + .map_err(|error| format!("Failed to decode image for clipboard: {error}"))? + .to_rgba8(); + let width = usize::try_from(image.width()).map_err(|_| "Image width is too large")?; + let height = usize::try_from(image.height()).map_err(|_| "Image height is too large")?; + let mut clipboard = + arboard::Clipboard::new().map_err(|error| format!("Clipboard unavailable: {error}"))?; + clipboard + .set_image(arboard::ImageData { + width, + height, + bytes: std::borrow::Cow::Owned(image.into_raw()), + }) + .map_err(|error| format!("Failed to write image clipboard: {error}")) + }) + .await + .map_err(|e| format!("system_clipboard_write_image join failed: {e}"))? +} + #[tauri::command(rename_all = "snake_case")] pub async fn system_create_project_folder( parent: String, diff --git a/crates/agent-gui/src-tauri/src/lib.rs b/crates/agent-gui/src-tauri/src/lib.rs index b19e1f6bb..ef042054b 100644 --- a/crates/agent-gui/src-tauri/src/lib.rs +++ b/crates/agent-gui/src-tauri/src/lib.rs @@ -243,6 +243,7 @@ macro_rules! app_invoke_handler { commands::git::git_stash_pop, commands::system::system_pick_folder, commands::system::system_pick_file, + commands::system::system_save_preview_file, commands::system::system_create_project_folder, commands::system::system_import_pasted_texts, commands::system::system_import_readable_file_paths, @@ -259,6 +260,7 @@ macro_rules! app_invoke_handler { commands::system::system_begin_power_activity, commands::system::system_end_power_activity, commands::system::system_clipboard_read_text, + commands::system::system_clipboard_write_image, commands::gateway::gateway_connect, commands::gateway::gateway_disconnect, commands::gateway::gateway_status, diff --git a/crates/agent-gui/src/agent-ui-adapters/workspacePreview.tsx b/crates/agent-gui/src/agent-ui-adapters/workspacePreview.tsx index f4bcd8bb6..614360f90 100644 --- a/crates/agent-gui/src/agent-ui-adapters/workspacePreview.tsx +++ b/crates/agent-gui/src/agent-ui-adapters/workspacePreview.tsx @@ -1,3 +1,4 @@ +import { invoke } from "@tauri-apps/api/core"; import { MacOsTitleBarSpacer } from "../components/MacOsTitleBarSpacer"; import { readClipboardText } from "../lib/system/clipboardText"; @@ -6,6 +7,25 @@ export const workspaceOverlayStackClassName = "z-50"; export const readWorkspaceClipboardText = readClipboardText; +export async function saveWorkspacePreviewImage(request: { + data: string; + fileName: string; + mimeType: string; +}) { + await invoke("system_save_preview_file", { + data_base64: request.data, + file_name: request.fileName, + mime_type: request.mimeType, + }); +} + +export async function copyWorkspacePreviewImage(request: { data: string; mimeType: string }) { + await invoke("system_clipboard_write_image", { + data_base64: request.data, + mime_type: request.mimeType, + }); +} + export function WorkspaceOverlayTitleBar() { return ; } diff --git a/crates/agent-gui/test/workspace-file-tree-row.test.mjs b/crates/agent-gui/test/workspace-file-tree-row.test.mjs new file mode 100644 index 000000000..54d28321d --- /dev/null +++ b/crates/agent-gui/test/workspace-file-tree-row.test.mjs @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createTsModuleLoader } from "./helpers/load-ts-module.mjs"; + +function createRowHarness() { + const loader = createTsModuleLoader({ + mocks: { + react: { + memo(component) { + return component; + }, + }, + "@liveagent/ui/i18n/index": { + useLocale() { + return { t: (key) => key }; + }, + }, + "@liveagent/ui/components/IconSet": { + ChevronRight: (props) => ({ type: "ChevronRight", props }), + Loader2: (props) => ({ type: "Loader2", props }), + }, + "@liveagent/ui/lib/shared/utils": { + cn(...values) { + return values.filter(Boolean).join(" "); + }, + }, + "@liveagent/ui/components/chat/fileTypeIcons": { + getFileTypeIcon() { + return (props) => ({ type: "FileTypeIcon", props }); + }, + }, + }, + }); + return loader.loadModule("@liveagent/ui/components/project-tools/file-tree/Row.tsx"); +} + +function findAll(node, predicate, matches = []) { + if (Array.isArray(node)) { + for (const child of node) findAll(child, predicate, matches); + return matches; + } + if (!node || typeof node !== "object") return matches; + if (predicate(node)) matches.push(node); + findAll(node.props?.children, predicate, matches); + return matches; +} + +function renderRow(overrides = {}) { + const { FileTreeRow } = createRowHarness(); + const calls = []; + const tree = FileTreeRow({ + path: "assets/preview.png", + name: "preview.png", + kind: "file", + hidden: false, + depth: 1, + expanded: false, + selected: false, + loading: false, + title: "assets/preview.png", + onToggle: (...args) => calls.push(["toggle", ...args]), + onSelect: (...args) => calls.push(["select", ...args]), + onOpen: (...args) => calls.push(["open", ...args]), + onContextMenu() {}, + ...overrides, + }); + return { tree, calls }; +} + +test("workspace file tree opens files from anywhere in the hovered row", () => { + const { tree, calls } = renderRow(); + + assert.equal(tree.props.onDoubleClick, undefined); + assert.match(tree.props.className, /\bw-full\b/); + assert.equal(typeof tree.props.onClick, "function"); + tree.props.onClick(); + + assert.deepEqual(calls, [ + ["select", "assets/preview.png"], + ["open", "assets/preview.png"], + ]); +}); + +test("workspace file tree expands directories with one click", () => { + const { tree, calls } = renderRow({ + path: "assets", + name: "assets", + kind: "dir", + }); + const [expandButton] = findAll(tree, (node) => node.type === "button"); + + tree.props.onClick(); + assert.deepEqual(calls, [ + ["select", "assets"], + ["toggle", "assets", false], + ]); + + calls.length = 0; + let propagationStopped = false; + expandButton.props.onClick({ + stopPropagation() { + propagationStopped = true; + }, + }); + assert.equal(propagationStopped, true); + assert.deepEqual(calls, [["toggle", "assets", false]]); +}); diff --git a/crates/agent-gui/test/workspace-image-preview-adapter.test.mjs b/crates/agent-gui/test/workspace-image-preview-adapter.test.mjs new file mode 100644 index 000000000..b3aa7b857 --- /dev/null +++ b/crates/agent-gui/test/workspace-image-preview-adapter.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { createTsModuleLoader } from "./helpers/load-ts-module.mjs"; + +const invokeCalls = []; +const loader = createTsModuleLoader({ + rootDir: fileURLToPath(new URL("../", import.meta.url)), + mocks: { + "@tauri-apps/api/core": { + invoke(command, args) { + invokeCalls.push({ command, args }); + return Promise.resolve(command === "system_save_preview_file" ? false : undefined); + }, + }, + "../components/MacOsTitleBarSpacer": { MacOsTitleBarSpacer: () => null }, + "../lib/system/clipboardText": { readClipboardText: async () => null }, + }, +}); + +const preview = loader.loadModule("@liveagent/adapters/workspacePreview"); + +test("desktop workspace image actions preserve Tauri command payload names", async () => { + invokeCalls.length = 0; + + await preview.saveWorkspacePreviewImage({ + data: "AQID", + fileName: "diagram.png", + mimeType: "image/png", + }); + await preview.copyWorkspacePreviewImage({ + data: "AQID", + mimeType: "image/png", + }); + + assert.deepEqual(invokeCalls, [ + { + command: "system_save_preview_file", + args: { + data_base64: "AQID", + file_name: "diagram.png", + mime_type: "image/png", + }, + }, + { + command: "system_clipboard_write_image", + args: { + data_base64: "AQID", + mime_type: "image/png", + }, + }, + ]); +}); diff --git a/crates/agent-gui/test/workspace-image-viewer.test.mjs b/crates/agent-gui/test/workspace-image-viewer.test.mjs new file mode 100644 index 000000000..d914f3bca --- /dev/null +++ b/crates/agent-gui/test/workspace-image-viewer.test.mjs @@ -0,0 +1,137 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createTsModuleLoader } from "./helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const viewer = loader.loadModule( + "@liveagent/ui/components/workspace-editor/workspaceImageViewer.ts", +); + +const viewport = { width: 800, height: 600 }; +const image = { width: 800, height: 450 }; + +test("image viewer normalizes rotation and clamps zoom scale", () => { + assert.equal(viewer.normalizeImageViewerRotation(-90), 270); + assert.equal(viewer.normalizeImageViewerRotation(450), 90); + assert.equal(viewer.clampImageViewerScale(-1), 0.25); + assert.equal(viewer.clampImageViewerScale(9), 4); +}); + +test("image viewer scales proportionally for buttons and wheel input", () => { + assert.equal(viewer.imageViewerScaleAfterStep(1, 1), 1.05); + assert.equal(viewer.imageViewerScaleAfterStep(1, -1), 1 / 1.05); + assert.equal(viewer.imageViewerScaleAfterStep(0.25, -1), 0.25); + assert.equal(viewer.imageViewerScaleAfterWheelDelta(1, -100, 0), 1.05); + assert.equal(viewer.imageViewerScaleAfterWheelDelta(1, 100, 0), 1 / 1.05); + assert.equal(viewer.imageViewerScaleAfterWheelDelta(1, -6.25, 1), 1.05); +}); + +test("image viewer copies absolute and workspace-relative paths", () => { + assert.equal( + viewer.workspaceImageAbsolutePathForCopy("H:\\Codezone\\LiveAgent", "assets/preview.png"), + "H:\\Codezone\\LiveAgent\\assets\\preview.png", + ); + assert.equal( + viewer.workspaceImageAbsolutePathForCopy("/workspace/liveagent", "assets/preview.png"), + "/workspace/liveagent/assets/preview.png", + ); + assert.equal( + viewer.workspaceImageAbsolutePathForCopy("H:\\Codezone\\LiveAgent", "H:\\images\\preview.png"), + "H:\\images\\preview.png", + ); + assert.equal( + viewer.workspaceImageAbsolutePathForCopy("H:\\Codezone\\LiveAgent", "\\\\server\\share\\preview.png"), + "\\\\server\\share\\preview.png", + ); + assert.equal( + viewer.workspaceImageRelativePathForCopy("H:\\Codezone\\LiveAgent", "assets\\preview.png"), + "assets/preview.png", + ); + assert.equal( + viewer.workspaceImageRelativePathForCopy( + "H:\\Codezone\\LiveAgent", + "H:\\Codezone\\LiveAgent\\assets\\preview.png", + ), + "assets/preview.png", + ); + assert.equal( + viewer.workspaceImageRelativePathForCopy("/workspace/liveagent", "/workspace/liveagent/preview.png"), + "preview.png", + ); +}); + +test("image viewer retains continuous angles for rotation transitions", () => { + const rotatedLeft = viewer.clampImageViewerState( + { scale: 1, rotation: -90, x: 0, y: 0 }, + { imageSize: image, viewportSize: viewport }, + ); + const rotatedAcrossBoundary = viewer.clampImageViewerState( + { scale: 1, rotation: 360, x: 0, y: 0 }, + { imageSize: image, viewportSize: viewport }, + ); + + assert.equal(rotatedLeft.rotation, -90); + assert.equal(rotatedAcrossBoundary.rotation, 360); + assert.deepEqual(viewer.rotatedImageViewerSize(image, 360), image); +}); + +test("image viewer centres dimensions that fit inside the viewport", () => { + assert.deepEqual(viewer.clampImageViewerPan({ x: 80, y: -80 }, { + imageSize: image, + viewportSize: viewport, + scale: 1, + rotation: 0, + }), { x: 0, y: 0 }); +}); + +test("image viewer fits a rotated image inside the viewport", () => { + assert.deepEqual(viewer.fitImageViewerSize({ width: 1600, height: 900 }, viewport, 90), { + width: 600, + height: 337.5, + }); +}); + +test("image viewer clamps panning against scaled and rotated bounds", () => { + assert.deepEqual(viewer.clampImageViewerPan({ x: 900, y: -900 }, { + imageSize: image, + viewportSize: viewport, + scale: 2, + rotation: 0, + }), { x: 400, y: -150 }); + + assert.deepEqual(viewer.clampImageViewerPan({ x: 900, y: -900 }, { + imageSize: image, + viewportSize: viewport, + scale: 2, + rotation: 90, + }), { x: 50, y: -500 }); +}); + +test("image viewer keeps the image point beneath the zoom anchor stable", () => { + const before = { scale: 1, rotation: 90, x: 30, y: -20 }; + const anchor = { x: 120, y: -60 }; + const after = viewer.zoomImageViewerAtPoint(before, 1.5, anchor, { + imageSize: { width: 1600, height: 900 }, + viewportSize: viewport, + }); + + const beforePoint = { + x: (anchor.x - before.x) / before.scale, + y: (anchor.y - before.y) / before.scale, + }; + const afterPoint = { + x: (anchor.x - after.x) / after.scale, + y: (anchor.y - after.y) / after.scale, + }; + assert.deepEqual(afterPoint, beforePoint); +}); + +test("image viewer reset restores the default fit state", () => { + assert.deepEqual(viewer.resetImageViewerState(), { + scale: 1, + rotation: 0, + x: 0, + y: 0, + }); +}); diff --git a/crates/agent-ui/src/components/project-tools/file-tree/Row.tsx b/crates/agent-ui/src/components/project-tools/file-tree/Row.tsx index 202fbd52b..c7df3b6b9 100644 --- a/crates/agent-ui/src/components/project-tools/file-tree/Row.tsx +++ b/crates/agent-ui/src/components/project-tools/file-tree/Row.tsx @@ -46,17 +46,32 @@ export const FileTreeRow = memo(function FileTreeRow(props: FileTreeRowProps) { } = props; const { t } = useLocale(); const TypeIcon = getFileTypeIcon(path, kind, { expanded }); + const activateRow = () => { + onSelect(path); + if (kind === "dir") { + onToggle(path, expanded); + return; + } + onOpen(path); + }; return (
{ + if (event.currentTarget !== event.target || (event.key !== "Enter" && event.key !== " ")) return; + event.preventDefault(); + activateRow(); + }} onContextMenu={(event) => onContextMenu(event, path)} > {kind === "dir" ? ( @@ -66,7 +81,10 @@ export const FileTreeRow = memo(function FileTreeRow(props: FileTreeRowProps) { "flex h-5 w-5 shrink-0 items-center justify-center rounded hover:bg-background", hidden && "opacity-60 group-hover:opacity-80", )} - onClick={() => onToggle(path, expanded)} + onClick={(event) => { + event.stopPropagation(); + onToggle(path, expanded); + }} title={expanded ? t("projectTools.fileTree.collapse") : t("projectTools.fileTree.expand")} > {loading ? ( @@ -80,25 +98,15 @@ export const FileTreeRow = memo(function FileTreeRow(props: FileTreeRowProps) { ) : ( )} - +
); }); diff --git a/crates/agent-ui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx b/crates/agent-ui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx index 94f090298..5e5c1eda0 100644 --- a/crates/agent-ui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx +++ b/crates/agent-ui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx @@ -1,4 +1,6 @@ import { + copyWorkspacePreviewImage, + saveWorkspacePreviewImage, supportsExternalWorkspaceOpen, WorkspaceOverlayTitleBar, workspaceOverlayStackClassName, @@ -10,10 +12,14 @@ import { import { AlertTriangle, ChevronRight, + Copy, + Download, ExternalLink, FilePenLine, FileText, + Info, Loader2, + Maximize2, Minus, Plus, RefreshCw, @@ -26,11 +32,35 @@ import { isWorkspaceEditablePreviewPath, type WorkspacePreviewKind, } from "@liveagent/ui/components/workspace-editor/workspaceImagePreview"; +import { + clampImageViewerPan, + clampImageViewerState, + fitImageViewerSize, + IMAGE_VIEWER_MAX_SCALE, + IMAGE_VIEWER_MIN_SCALE, + type ImageViewerSize, + type ImageViewerState, + imageViewerScaleAfterStep, + imageViewerScaleAfterWheelDelta, + resetImageViewerState, + workspaceImageAbsolutePathForCopy, + workspaceImageRelativePathForCopy, + zoomImageViewerAtPoint, +} from "@liveagent/ui/components/workspace-editor/workspaceImageViewer"; import { useLocale } from "@liveagent/ui/i18n/index"; import { cn } from "@liveagent/ui/lib/shared/utils"; import { invokeFs } from "@liveagent/ui/lib/tools/fsBackend"; import { renderAsync } from "docx-preview"; -import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + type ReactNode, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; import { read, utils } from "xlsx"; export type WorkspaceFilePreviewOpenRequest = { @@ -80,12 +110,6 @@ type SpreadsheetTable = { const FILE_PREVIEW_OVERLAY_ANIMATION_MS = 180; const SPREADSHEET_MAX_ROWS = 250; const SPREADSHEET_MAX_COLUMNS = 80; -const IMAGE_PREVIEW_MIN_SCALE = 0.25; -const IMAGE_PREVIEW_MAX_SCALE = 4; -const IMAGE_PREVIEW_SCALE_STEP = 0.25; -const IMAGE_PREVIEW_WHEEL_SCALE_STEP = 0.1; -const IMAGE_PREVIEW_ENTER_ANIMATION_MS = 200; - type ImagePreviewTransitionDirection = -1 | 0 | 1; function basename(path: string) { @@ -156,15 +180,6 @@ function decodePreviewText(bytes: Uint8Array) { return new TextDecoder("utf-8").decode(bytes); } -function clampImageScale(scale: number) { - return Math.min(Math.max(scale, IMAGE_PREVIEW_MIN_SCALE), IMAGE_PREVIEW_MAX_SCALE); -} - -function normalizeRotation(degrees: number) { - const next = degrees % 360; - return next < 0 ? next + 360 : next; -} - function normalizeImagePaths(paths: string[] | undefined, activePath: string) { const seen = new Set(); const normalized: string[] = []; @@ -469,7 +484,8 @@ export function WorkspaceFilePreviewOverlay(props: WorkspaceFilePreviewOverlayPr }, [spreadsheet?.activeSheetName]); const activePreviewRequest = activeRequest ?? openRequest; - const activePath = preview?.path ?? activePreviewRequest?.path ?? ""; + // The request comes from the workspace file tree and retains its full logical path. + const activePath = activePreviewRequest?.path ?? preview?.path ?? ""; const kind = preview?.kind ?? (activePath ? getWorkspacePreviewKind(activePath) : null) ?? "text"; const PreviewIcon = getPreviewIcon(kind); const imagePaths = useMemo( @@ -479,8 +495,9 @@ export function WorkspaceFilePreviewOverlay(props: WorkspaceFilePreviewOverlayPr ); const canOpenEditor = Boolean(activePreviewRequest && isWorkspaceEditablePreviewPath(activePath)); const canOpenExternal = Boolean( - supportsExternalWorkspaceOpen && activePreviewRequest && activePath && !canOpenEditor, + supportsExternalWorkspaceOpen && activePreviewRequest && activePath, ); + const showHeaderOpenExternal = canOpenExternal && !canOpenEditor && kind !== "image"; const openImagePath = useCallback( (path: string, transitionDirection: ImagePreviewTransitionDirection = 0) => { @@ -541,7 +558,7 @@ export function WorkspaceFilePreviewOverlay(props: WorkspaceFilePreviewOverlayPr ) : null} - {canOpenExternal ? ( + {showHeaderOpenExternal ? ( + +
+
{t("workspaceFilePreview.fileName")}
+
+ {basename(preview.path)} +
+
{t("workspaceFilePreview.dimensions")}
+
{displayDimensions || "-"}
+
{t("workspaceFilePreview.fileSize")}
+
{formatBytes(preview.sizeBytes)}
+
{t("workspaceFilePreview.fileType")}
+
+ {preview.mimeType} +
+
+ + ) : null} + {contextMenu + ? createPortal( +
event.stopPropagation()} + onContextMenu={(event) => { + event.preventDefault(); + event.stopPropagation(); + }} + > + + + + + {canOpenInSystemViewer ? ( + + ) : null} + + + + + +
, + document.body, + ) + : null} ); diff --git a/crates/agent-ui/src/components/workspace-editor/workspaceImageViewer.ts b/crates/agent-ui/src/components/workspace-editor/workspaceImageViewer.ts new file mode 100644 index 000000000..abb2a628c --- /dev/null +++ b/crates/agent-ui/src/components/workspace-editor/workspaceImageViewer.ts @@ -0,0 +1,190 @@ +export type ImageViewerSize = { + width: number; + height: number; +}; + +export type ImageViewerPan = { + x: number; + y: number; +}; + +export type ImageViewerState = ImageViewerPan & { + scale: number; + rotation: number; +}; + +export const IMAGE_VIEWER_MIN_SCALE = 0.25; +export const IMAGE_VIEWER_MAX_SCALE = 4; +export const IMAGE_VIEWER_ZOOM_RATIO = 1.05; + +const IMAGE_VIEWER_WHEEL_ZOOM_SENSITIVITY = Math.log(IMAGE_VIEWER_ZOOM_RATIO) / 100; + +function finiteSize(value: number) { + return Number.isFinite(value) && value > 0 ? value : 0; +} + +function isAbsoluteWorkspacePath(path: string) { + return ( + path.startsWith("/") || + /^[a-zA-Z]:[\\/]/.test(path) || + /^\\\\/.test(path) || + /^\/\/[^/\\]+[\\/][^/\\]+/.test(path) + ); +} + +/** Returns an absolute local path without changing workspace API paths. */ +export function workspaceImageAbsolutePathForCopy(workdir: string, path: string) { + const rawPath = path.trim(); + if (isAbsoluteWorkspacePath(rawPath)) return rawPath; + + const normalizedPath = rawPath.replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""); + const root = workdir.trim(); + if (!normalizedPath) return root; + if (!root) return normalizedPath; + + const separator = root.includes("\\") && !root.includes("/") ? "\\" : "/"; + const trimmedRoot = root.replace(/[\\/]+$/, ""); + const relativePath = normalizedPath.replace(/\//g, separator); + return trimmedRoot ? `${trimmedRoot}${separator}${relativePath}` : `${separator}${relativePath}`; +} + +/** Returns a path relative to the workspace root when the source is inside it. */ +export function workspaceImageRelativePathForCopy(workdir: string, path: string) { + const rawPath = path.trim(); + if (!isAbsoluteWorkspacePath(rawPath)) { + return rawPath.replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""); + } + + const root = workdir.trim().replace(/\\/g, "/").replace(/\/+$/, ""); + const absolutePath = rawPath.replace(/\\/g, "/").replace(/\/+$/, ""); + if (!root) return rawPath; + + const isWindowsPath = /^[a-zA-Z]:\//.test(root) || root.startsWith("//"); + const comparableRoot = isWindowsPath ? root.toLowerCase() : root; + const comparablePath = isWindowsPath ? absolutePath.toLowerCase() : absolutePath; + if (comparablePath === comparableRoot) return ""; + if (comparablePath.startsWith(`${comparableRoot}/`)) { + return absolutePath.slice(root.length + 1); + } + return rawPath; +} + +export function clampImageViewerScale(scale: number) { + return Math.min(Math.max(scale, IMAGE_VIEWER_MIN_SCALE), IMAGE_VIEWER_MAX_SCALE); +} + +export function imageViewerScaleAfterStep(scale: number, direction: -1 | 1) { + const currentScale = clampImageViewerScale(scale); + return clampImageViewerScale(currentScale * IMAGE_VIEWER_ZOOM_RATIO ** direction); +} + +export function imageViewerScaleAfterWheelDelta(scale: number, deltaY: number, deltaMode: number) { + const currentScale = clampImageViewerScale(scale); + if (!Number.isFinite(deltaY) || deltaY === 0) return currentScale; + + const pixels = deltaMode === 1 ? deltaY * 16 : deltaMode === 2 ? deltaY * 100 : deltaY; + return clampImageViewerScale( + currentScale * Math.exp(-pixels * IMAGE_VIEWER_WHEEL_ZOOM_SENSITIVITY), + ); +} + +export function normalizeImageViewerRotation(degrees: number) { + const next = Math.round(degrees / 90) * 90; + return ((next % 360) + 360) % 360; +} + +export function fitImageViewerSize( + naturalSize: ImageViewerSize, + viewportSize: ImageViewerSize, + rotation = 0, +): ImageViewerSize { + const naturalWidth = finiteSize(naturalSize.width); + const naturalHeight = finiteSize(naturalSize.height); + const viewportWidth = finiteSize(viewportSize.width); + const viewportHeight = finiteSize(viewportSize.height); + if (!naturalWidth || !naturalHeight || !viewportWidth || !viewportHeight) { + return { width: 0, height: 0 }; + } + + const rotatedSize = rotatedImageViewerSize( + { width: naturalWidth, height: naturalHeight }, + rotation, + ); + const ratio = Math.min(viewportWidth / rotatedSize.width, viewportHeight / rotatedSize.height); + return { + width: naturalWidth * ratio, + height: naturalHeight * ratio, + }; +} + +export function rotatedImageViewerSize(size: ImageViewerSize, rotation: number): ImageViewerSize { + const normalizedRotation = normalizeImageViewerRotation(rotation); + if (normalizedRotation === 90 || normalizedRotation === 270) { + return { width: finiteSize(size.height), height: finiteSize(size.width) }; + } + return { width: finiteSize(size.width), height: finiteSize(size.height) }; +} + +export function clampImageViewerPan( + pan: ImageViewerPan, + options: { + imageSize: ImageViewerSize; + viewportSize: ImageViewerSize; + scale: number; + rotation: number; + }, +): ImageViewerPan { + const viewportWidth = finiteSize(options.viewportSize.width); + const viewportHeight = finiteSize(options.viewportSize.height); + const imageSize = rotatedImageViewerSize(options.imageSize, options.rotation); + const scale = clampImageViewerScale(options.scale); + if (!viewportWidth || !viewportHeight || !imageSize.width || !imageSize.height) { + return { x: 0, y: 0 }; + } + + const maxX = Math.max(0, (imageSize.width * scale - viewportWidth) / 2); + const maxY = Math.max(0, (imageSize.height * scale - viewportHeight) / 2); + return { + x: maxX ? Math.min(Math.max(Number.isFinite(pan.x) ? pan.x : 0, -maxX), maxX) : 0, + y: maxY ? Math.min(Math.max(Number.isFinite(pan.y) ? pan.y : 0, -maxY), maxY) : 0, + }; +} + +export function clampImageViewerState( + state: ImageViewerState, + options: { + imageSize: ImageViewerSize; + viewportSize: ImageViewerSize; + }, +): ImageViewerState { + const scale = clampImageViewerScale(state.scale); + // Keep the angle continuous for CSS transitions. Geometry helpers normalize it + // independently, so 360 degrees still has the same dimensions as 0 degrees. + const rotation = Number.isFinite(state.rotation) ? Math.round(state.rotation / 90) * 90 : 0; + const pan = clampImageViewerPan(state, { ...options, scale, rotation }); + return { ...pan, scale, rotation }; +} + +export function zoomImageViewerAtPoint( + state: ImageViewerState, + nextScale: number, + anchor: ImageViewerPan, + options: { + imageSize: ImageViewerSize; + viewportSize: ImageViewerSize; + }, +): ImageViewerState { + const previousScale = clampImageViewerScale(state.scale); + const scale = clampImageViewerScale(nextScale); + if (scale === previousScale) return clampImageViewerState(state, options); + + // The pointer is expressed relative to the view centre. Preserving the same + // image-space coordinate beneath it works for every 90-degree rotation. + const x = anchor.x - ((anchor.x - state.x) * scale) / previousScale; + const y = anchor.y - ((anchor.y - state.y) * scale) / previousScale; + return clampImageViewerState({ ...state, x, y, scale }, options); +} + +export function resetImageViewerState(): ImageViewerState { + return { scale: 1, rotation: 0, x: 0, y: 0 }; +} diff --git a/crates/agent-ui/src/i18n/translations/enUSCommon.ts b/crates/agent-ui/src/i18n/translations/enUSCommon.ts index 686736319..41aee2967 100644 --- a/crates/agent-ui/src/i18n/translations/enUSCommon.ts +++ b/crates/agent-ui/src/i18n/translations/enUSCommon.ts @@ -989,6 +989,7 @@ export const EN_US_COMMON_TRANSLATIONS = { "workspaceFilePreview.edit": "Edit", "workspaceFilePreview.openExternal": "Open in Default App", "workspaceFilePreview.openExternalFailed": "Failed to open in default app", + "workspaceFilePreview.openInSystemImageViewer": "Open in Default Image Viewer", "workspaceFilePreview.reload": "Reload", "workspaceFilePreview.close": "Close preview", "workspaceFilePreview.openFailed": "Failed to open preview", @@ -1000,7 +1001,25 @@ export const EN_US_COMMON_TRANSLATIONS = { "workspaceFilePreview.nextImage": "Next image", "workspaceFilePreview.zoomOut": "Zoom out", "workspaceFilePreview.zoomIn": "Zoom in", - "workspaceFilePreview.rotateImage": "Rotate image", + "workspaceFilePreview.rotateLeft": "Rotate left 90 degrees", + "workspaceFilePreview.rotateRight": "Rotate right 90 degrees", + "workspaceFilePreview.resetImage": "Reset view", + "workspaceFilePreview.downloadImage": "Download / Save as", + "workspaceFilePreview.saveImageFailed": "Failed to save image", + "workspaceFilePreview.copyImage": "Copy image", + "workspaceFilePreview.copyImageFailed": "Failed to copy image", + "workspaceFilePreview.copyImageAbsolutePath": "Copy absolute path", + "workspaceFilePreview.copyImageRelativePath": "Copy workspace-relative path", + "workspaceFilePreview.copyPathFailed": "Failed to copy path", + "workspaceFilePreview.toggleImageInfo": "Show file information", + "workspaceFilePreview.imageInfo": "Image information", + "workspaceFilePreview.fileName": "File name", + "workspaceFilePreview.dimensions": "Dimensions", + "workspaceFilePreview.fileSize": "Size", + "workspaceFilePreview.fileType": "Type", + "workspaceFilePreview.fullscreen": "View full screen", + "workspaceFilePreview.fullscreenFailed": "Failed to enter full screen", + "workspaceFilePreview.imageViewer": "Image viewer", "workspaceFilePreview.imageCounter": "{index} / {total}", "workspaceFilePreview.imageUnavailable": "Image unavailable", "mcpHub.title": "MCP Servers", diff --git a/crates/agent-ui/src/i18n/translations/zhCNCommon.ts b/crates/agent-ui/src/i18n/translations/zhCNCommon.ts index 01cff759c..74fb60c20 100644 --- a/crates/agent-ui/src/i18n/translations/zhCNCommon.ts +++ b/crates/agent-ui/src/i18n/translations/zhCNCommon.ts @@ -942,6 +942,7 @@ export const ZH_CN_COMMON_TRANSLATIONS = { "workspaceFilePreview.edit": "编辑", "workspaceFilePreview.openExternal": "用默认应用打开", "workspaceFilePreview.openExternalFailed": "用默认应用打开失败", + "workspaceFilePreview.openInSystemImageViewer": "用系统图片查看器打开", "workspaceFilePreview.reload": "重新加载", "workspaceFilePreview.close": "关闭预览", "workspaceFilePreview.openFailed": "打开预览失败", @@ -953,7 +954,25 @@ export const ZH_CN_COMMON_TRANSLATIONS = { "workspaceFilePreview.nextImage": "下一张", "workspaceFilePreview.zoomOut": "缩小", "workspaceFilePreview.zoomIn": "放大", - "workspaceFilePreview.rotateImage": "旋转图片", + "workspaceFilePreview.rotateLeft": "向左旋转 90 度", + "workspaceFilePreview.rotateRight": "向右旋转 90 度", + "workspaceFilePreview.resetImage": "重置视图", + "workspaceFilePreview.downloadImage": "下载/另存为", + "workspaceFilePreview.saveImageFailed": "保存图片失败", + "workspaceFilePreview.copyImage": "复制图片", + "workspaceFilePreview.copyImageFailed": "复制图片失败", + "workspaceFilePreview.copyImageAbsolutePath": "复制绝对路径", + "workspaceFilePreview.copyImageRelativePath": "复制工作区相对路径", + "workspaceFilePreview.copyPathFailed": "复制路径失败", + "workspaceFilePreview.toggleImageInfo": "显示文件信息", + "workspaceFilePreview.imageInfo": "图片信息", + "workspaceFilePreview.fileName": "文件名", + "workspaceFilePreview.dimensions": "尺寸", + "workspaceFilePreview.fileSize": "大小", + "workspaceFilePreview.fileType": "类型", + "workspaceFilePreview.fullscreen": "全屏查看", + "workspaceFilePreview.fullscreenFailed": "进入全屏失败", + "workspaceFilePreview.imageViewer": "图片查看器", "workspaceFilePreview.imageCounter": "{index} / {total}", "workspaceFilePreview.imageUnavailable": "图片暂不可用", "mcpHub.title": "MCP Servers",