From b31938db00062d6888c2572cd5dd2467d132c14a Mon Sep 17 00:00:00 2001 From: Mariano Dupont Date: Wed, 4 Nov 2026 01:40:45 -0300 Subject: [PATCH] feat(deployment): add standalone comparison and selected-file sync (#1070) Sync with Deployed opens in its own window, one per project window. It lists local files next to the server's copies, with filters, content and binary comparison, and an embedded side-by-side diff whose local side can be edited. The selected file can be synchronized (Enter) without closing the window. The window is the `deployment` kind of the auxiliary windows that Settings and comparisons use. A cancelled close in it stops its project window from closing. The preview uses the vcs diff viewer through its `diff` editor kind. Also covers #811 (compare with the deployed versions). Co-authored-by: marianomd <274989+marianomd@users.noreply.github.com> --- e2e/deployment.spec.ts | 287 ++++++++++ src-tauri/capabilities/default.json | 3 +- src-tauri/capabilities/e2e.json | 2 +- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/window.rs | 14 + src-tauri/src/windows.rs | 46 +- src/bindings.ts | 16 + src/editor/core/embeddedDocuments.test.ts | 46 ++ src/editor/core/embeddedDocuments.ts | 49 ++ src/features/deployment/Deployment.module.css | 164 +++++- src/features/deployment/Dialogs.test.tsx | 146 ++++- src/features/deployment/Dialogs.tsx | 172 +----- src/features/deployment/SyncDialog.tsx | 522 ++++++++++++++++++ src/features/deployment/SyncPreview.test.tsx | 72 +++ src/features/deployment/SyncPreview.tsx | 93 ++++ src/features/deployment/actions.test.tsx | 148 ++++- src/features/deployment/actions.tsx | 114 +++- src/features/deployment/comparison.test.ts | 117 ++++ src/features/deployment/comparison.ts | 91 +++ src/features/deployment/index.ts | 4 + src/features/deployment/window.test.ts | 107 ++++ src/features/deployment/window.tsx | 187 +++++++ src/features/deployment/windowBridge.ts | 40 ++ src/features/index.ts | 23 +- src/features/vcs/DiffEditor.tsx | 5 +- .../vcs/__tests__/diffToolbar.test.tsx | 8 + src/features/vcs/diff-editor-tab.tsx | 2 + src/features/vcs/diffInput.ts | 4 + src/features/vcs/index.ts | 17 +- src/main.tsx | 18 +- src/platform/auxWindow.test.ts | 1 + src/platform/auxWindow.ts | 6 +- src/platform/locales/actionTitles.test.ts | 2 +- src/platform/locales/text/fr.json | 9 - src/platform/locales/text/ko.json | 9 - src/platform/locales/text/zh.json | 49 +- 36 files changed, 2332 insertions(+), 262 deletions(-) create mode 100644 e2e/deployment.spec.ts create mode 100644 src/editor/core/embeddedDocuments.test.ts create mode 100644 src/editor/core/embeddedDocuments.ts create mode 100644 src/features/deployment/SyncDialog.tsx create mode 100644 src/features/deployment/SyncPreview.test.tsx create mode 100644 src/features/deployment/SyncPreview.tsx create mode 100644 src/features/deployment/comparison.test.ts create mode 100644 src/features/deployment/comparison.ts create mode 100644 src/features/deployment/window.test.ts create mode 100644 src/features/deployment/window.tsx create mode 100644 src/features/deployment/windowBridge.ts diff --git a/e2e/deployment.spec.ts b/e2e/deployment.spec.ts new file mode 100644 index 000000000..9f110d065 --- /dev/null +++ b/e2e/deployment.spec.ts @@ -0,0 +1,287 @@ +/** Deployment diff over an in-memory server: no SSH or filesystem writes. */ +import { test, expect } from "./fixtures"; +import type { Page } from "@playwright/test"; + +test("native deployment entry fills its own window and exposes minimize and maximize controls", async ({ + page, +}, info) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.route("**/deployment-window-test", (route) => + route.fulfill({ + contentType: "text/html", + body: '
', + }), + ); + await page.goto("/deployment-window-test"); + await page.evaluate(async () => { + const refreshModule = "/@react-refresh"; + const { default: refresh } = await import(/* @vite-ignore */ refreshModule); + refresh.injectIntoGlobalHook(window); + Object.assign(window, { + $RefreshReg$: () => {}, + $RefreshSig$: () => (type: unknown) => type, + __vite_plugin_react_preamble_installed__: true, + }); + const mocksModule = "/node_modules/@tauri-apps/api/mocks.js"; + const { mockIPC, mockWindows } = await import(/* @vite-ignore */ mocksModule); + const calls: string[] = []; + (window as unknown as { deploymentCalls: string[] }).deploymentCalls = calls; + const settings = { + appearance: { theme: "dark", syncWithOs: false }, + deployment: { servers: [{ id: "staging", name: "Staging", kind: "local", rootPath: "/srv/app" }] }, + }; + let launch = true; + const local = ") => { + calls.push(command); + if (/deploy_(upload|download|sync|delete)|doc_save|fs_write/.test(command)) throw new Error("Unexpected write"); + if (command === "app_info") + return { platform: "windows", version: "test", configDir: "/config", wayland: false }; + if (command === "settings_get") return settings; + if (command === "project_current") return { root: "/second", name: "Second", settings }; + if (command === "window_take_launch") { + if (!launch) return null; + launch = false; + return { deployment: { server: "staging", paths: ["/second/perfil.php"] } }; + } + if (command === "deploy_coverage") return [{ server: "staging", paths: ["/second/perfil.php"], skipped: [] }]; + if (command === "deploy_compare") + return [ + { + path: "perfil.php", + remotePath: "/srv/app/perfil.php", + local: { size: local.length, mtime: 1700000100 }, + remote: { size: remote.length, mtime: 1700000000 }, + status: "localNewer", + }, + ]; + if (command === "remote_open") return "/snapshot/perfil.php"; + if (command === "fs_probe") return { size: local.length, binary: false, tooLarge: false }; + if (command === "fs_read_text") return String(args.path).startsWith("/snapshot/") ? remote : local; + if (command === "doc_open") throw new Error("Read-only test fixture"); + if (command === "languages") return []; + if (command === "plugin:window|is_maximized" || command === "plugin:window|is_fullscreen") return false; + return null; + }, + { shouldMockEvents: true }, + ); + const entry = "/src/main.tsx"; + await import(/* @vite-ignore */ entry); + }); + const comparison = page.getByRole("region", { name: "Diff Between 'local files' and 'files on Staging'" }); + await expect(comparison.getByRole("grid", { name: "Files to synchronize" })).toBeVisible(); + await expect(comparison.getByRole("region", { name: "perfil.php" }).getByRole("textbox")).toHaveCount(2); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page.getByRole("tree", { name: "Project" })).toHaveCount(0); + const frame = await comparison.boundingBox(); + expect(frame!.y).toBe(0); + expect(frame!.height).toBe(page.viewportSize()!.height); + await expect(comparison.getByRole("button", { name: "Synchronize All", exact: true })).toBeInViewport(); + await expect(comparison.getByRole("button", { name: "Synchronize Selected", exact: true })).toBeVisible(); + await comparison.getByRole("button", { name: "Minimize", exact: true }).click(); + await comparison.getByRole("button", { name: "Maximize", exact: true }).click(); + // The caption buttons reach the window API asynchronously. + const calls = () => page.evaluate(() => (window as unknown as { deploymentCalls: string[] }).deploymentCalls); + await expect.poll(calls).toContain("plugin:window|minimize"); + await expect.poll(calls).toContain("plugin:window|toggle_maximize"); + expect(errors).toEqual([]); + await page.screenshot({ path: info.outputPath("deployment-native-window.png") }); +}); + +async function showComparison(page: Page) { + await page.evaluate(async () => { + const projectModule = "/src/platform/project.ts"; + const settingsModule = "/src/platform/remoteSettings.ts"; + const stateModule = "/src/features/deployment/store.ts"; + const routeModule = "/src/platform/tauriCore.ts"; + const backendModule = "/src/editor/core/backend.ts"; + const [{ useProject }, { DEFAULT_DEPLOYMENT_SERVER }, { useDeployment }, { setInvokeRoute }, { docBackend }] = + await Promise.all([ + import(/* @vite-ignore */ projectModule), + import(/* @vite-ignore */ settingsModule), + import(/* @vite-ignore */ stateModule), + import(/* @vite-ignore */ routeModule), + import(/* @vite-ignore */ backendModule), + ]); + const local: Record = { + "app.js": "export const version = 'local';\n", + "perfil.php": + "find('list', [\n 'fields' => [$this->alias . '.id', $this->alias . '.descripcion'],\n 'order' => $this->alias . '.descripcion ASC',\n ]);\n }\n}\n", + "hidden.php": "alpha", + "same.js": "same", + }; + const remote: Record = { + "app.js": "export const version = 'deployed';\n", + "perfil.php": + " (path.startsWith("/snapshot/") ? remote : local)[path.split("/").at(-1)!] ?? ""; + setInvokeRoute((command: string, args: Record) => { + const path = String(args?.path ?? ""); + if (command === "remote_open") return Promise.resolve(`/snapshot/${path.split("/").at(-1)}`); + if (command === "fs_probe") return Promise.resolve({ size: text(path).length, binary: false, tooLarge: false }); + if (command === "fs_read_text") return Promise.resolve(text(path)); + if (command === "fs_read_range") { + const content = text(path); + return Promise.resolve({ + total: content.length, + offset: args.offset, + data: btoa(content.slice(Number(args.offset), Number(args.offset) + Number(args.len))), + }); + } + if (command === "doc_open") return Promise.reject(new Error("No live document in this fixture")); + if (command === "deploy_sync" && syncState.enabled) { + const steps = args.steps as { path: string; action: string }[]; + syncState.transfers.push(steps.map((step) => ({ ...step }))); + writes.push(command); + for (const step of steps) { + if (step.action === "upload") remote[step.path] = local[step.path]!; + else if (step.action === "download") local[step.path] = remote[step.path]!; + else if (step.action === "deleteLocal") delete local[step.path]; + else if (step.action === "deleteRemote") delete remote[step.path]; + } + return Promise.resolve({ files: steps.length, skipped: 0, errors: [] }); + } + if (command === "deploy_compare") { + const name = path.slice(root.length + 1); + if (local[name] === undefined && remote[name] === undefined) return Promise.resolve([]); + const identical = local[name] === remote[name]; + return Promise.resolve([ + { + path: name, + remotePath: `/srv/app/${name}`, + local: local[name] === undefined ? null : { size: local[name]!.length, mtime: 1_700_000_100 }, + remote: + remote[name] === undefined + ? null + : { size: remote[name]!.length, mtime: identical ? 1_700_000_100 : 1_700_000_000 }, + status: + local[name] === undefined + ? "remoteOnly" + : remote[name] === undefined + ? "localOnly" + : identical + ? "same" + : "localNewer", + }, + ]); + } + if (/deploy_(upload|download|sync|delete)|doc_save|fs_write/.test(command)) { + writes.push(command); + return Promise.reject(new Error("Unexpected write")); + } + return null; + }); + const entries = Object.keys(local).map((path) => ({ + path, + remotePath: `/srv/app/${path}`, + local: { size: local[path]!.length, mtime: 1_700_000_100 }, + remote: { size: remote[path]!.length, mtime: 1_700_000_000 }, + status: path === "hidden.php" || path === "same.js" ? "same" : "localNewer", + })); + useProject.setState({ + settings: { + ...useProject.getState().settings, + appearance: { ...useProject.getState().settings?.appearance, theme: "dark", syncWithOs: false }, + deployment: { + servers: [ + { ...DEFAULT_DEPLOYMENT_SERVER, id: "staging", kind: "local", name: "Staging", rootPath: "/srv/app" }, + ], + }, + }, + }); + useDeployment.setState({ + sync: { + server: "staging", + serverName: "Staging", + paths: [useProject.getState().project.root], + entries, + skipped: [], + request: null, + error: null, + choices: Object.fromEntries(entries.map((entry) => [entry.path, entry.status === "same" ? "skip" : "upload"])), + hideSame: true, + running: false, + }, + }); + }); +} + +test("deployment shows mirrored lists and a selectable side-by-side content diff", async ({ + workbench: page, +}, info) => { + await showComparison(page); + const dialog = page.getByRole("dialog", { name: "Diff Between 'local files' and 'files on Staging'" }); + await expect(dialog).toBeVisible(); + const grid = dialog.getByRole("grid", { name: "Files to synchronize" }); + await grid.getByRole("combobox", { name: "Action perfil.php" }).locator("..", {}).locator("..").click(); + const preview = dialog.locator('[aria-label="Deployment content comparison"]'); + await expect(preview.locator(".cm-editor")).toHaveCount(2); + await expect(preview.locator(".cm-editor").first()).toContainText("getPerfilesValidos"); + await expect(preview.locator(".cm-editor").last()).not.toContainText("getPerfilesValidos"); + await expect(preview.locator(".cm-editor").last().locator(".cm-content")).toHaveAttribute("contenteditable", "false"); + await expect(dialog.getByRole("toolbar", { name: "Diff" })).toBeVisible(); + await expect(dialog.getByRole("separator", { name: "Resize file list and diff preview" })).toBeVisible(); + await dialog.screenshot({ path: info.outputPath("deployment-comparison.png"), animations: "disabled" }); + await page.getByLabel("Action perfil.php", { exact: true }).selectOption("skip"); + await grid.focus(); + await page.keyboard.press("Enter"); + await expect(dialog).toBeVisible(); + expect(await page.evaluate(() => (window as unknown as { deploymentWrites: string[] }).deploymentWrites)).toEqual([]); +}); + +test("Synchronize Selected and Enter transfer one file at a time while the comparison stays open", async ({ + workbench: page, +}) => { + await showComparison(page); + await page.evaluate(() => { + (window as unknown as { deploymentSyncState: { enabled: boolean } }).deploymentSyncState.enabled = true; + }); + const dialog = page.getByRole("dialog", { name: "Diff Between 'local files' and 'files on Staging'" }); + const grid = dialog.getByRole("grid", { name: "Files to synchronize" }); + await grid.getByRole("combobox", { name: "Action perfil.php" }).locator("..").locator("..").click(); + await dialog.getByRole("button", { name: "Synchronize Selected", exact: true }).click(); + await expect(dialog).toBeVisible(); + await expect(grid.getByRole("combobox", { name: "Action perfil.php" })).toHaveCount(0); + await expect(grid.getByRole("combobox", { name: "Action app.js" })).toHaveValue("upload"); + await expect(dialog.locator('[aria-label="Deployment content comparison"] .cm-editor').last()).toContainText( + "deployed", + ); + await grid.focus(); + await page.keyboard.press("Enter"); + await expect(dialog.getByRole("button", { name: "Synchronize All", exact: true })).toBeDisabled(); + await expect(dialog.getByRole("button", { name: "Synchronize Selected", exact: true })).toBeDisabled(); + await expect(dialog).toBeVisible(); + expect( + await page.evaluate( + () => (window as unknown as { deploymentSyncState: { transfers: unknown[] } }).deploymentSyncState.transfers, + ), + ).toEqual([[{ path: "perfil.php", action: "upload" }], [{ path: "app.js", action: "upload" }]]); +}); + +test("binary content finds changes hidden by metadata and file filters keep the preview in sync", async ({ + workbench: page, +}) => { + await showComparison(page); + await page.getByLabel("Compare by", { exact: true }).selectOption("content"); + await expect(page.getByLabel("Action hidden.php", { exact: true })).toHaveValue("upload"); + await expect(page.getByLabel("Action same.js", { exact: true })).toHaveCount(0); + await page.getByLabel("Filter by name", { exact: true }).fill("hidden.php"); + const preview = page.locator('[aria-label="Deployment content comparison"]'); + await expect(preview.locator(".cm-editor").first()).toContainText("alpha"); + await expect(preview.locator(".cm-editor").last()).toContainText("bravo"); + await page.getByLabel("Action hidden.php", { exact: true }).selectOption("skip"); + expect(await page.evaluate(() => (window as unknown as { deploymentWrites: string[] }).deploymentWrites)).toEqual([]); +}); diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index d75c7fd77..86277d7c1 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,10 +2,11 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Capability for the main window", - "windows": ["main", "project-*", "settings-*", "comparison-*"], + "windows": ["main", "project-*", "settings-*", "comparison-*", "deployment-*"], "permissions": [ "core:default", "core:window:allow-start-dragging", + "core:window:allow-start-resize-dragging", "core:window:allow-minimize", "core:window:allow-toggle-maximize", "core:window:allow-internal-toggle-maximize", diff --git a/src-tauri/capabilities/e2e.json b/src-tauri/capabilities/e2e.json index 10e5dea30..253b1ce6e 100644 --- a/src-tauri/capabilities/e2e.json +++ b/src-tauri/capabilities/e2e.json @@ -2,6 +2,6 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "e2e-bridge", "description": "Debug-only automation bridge", - "windows": ["main", "project-*", "settings-*", "comparison-*"], + "windows": ["main", "project-*", "settings-*", "comparison-*", "deployment-*"], "permissions": ["mcp-bridge:default"] } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index d2fba7d5f..a9fd89c3e 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -384,6 +384,7 @@ pub fn builder() -> tauri_specta::Builder { window::window_new, window::window_open_settings, window::window_open_comparison, + window::window_open_deployment, window::window_cycle, window::window_toggle_fullscreen, window::window_set_titlebar_height, diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index 7c4abb1c7..1716669d3 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -100,6 +100,20 @@ pub async fn window_open_comparison( windows::open_comparison_window(&app, &window, comparison, background) } +/// Opens this window's Sync with Deployed window comparing `paths` with `server` (or hands them +/// to the open one). `background` (`#rrggbb`) paints a new window until its page has loaded. +#[tauri::command] +#[specta::specta] +pub async fn window_open_deployment( + app: AppHandle, + window: WebviewWindow, + server: String, + paths: Vec, + background: Option, +) -> IdeResult<()> { + windows::open_deployment_window(&app, &window, server, paths, background) +} + /// Focuses the next / previous window (⌘` / ⇧⌘`). #[tauri::command] #[specta::specta] diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index 871b81daa..c0483b128 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -6,7 +6,7 @@ //! (`window_take_launch`), and re-taken whenever [`LAUNCH_EVENT`] fires (a //! second app instance asking an already-loaded window to open a path). //! -//! Auxiliary windows ([`AuxKind`]: Settings, Compare with Local) belong to a project window and share its project (see +//! Auxiliary windows ([`AuxKind`]: Settings, Compare with Local, Sync with Deployed) belong to a project window and share its project (see //! [`AppState::project`]). Their labels name the kind and the parent //! (`settings-main`, `comparison-project-2-3`); their [`Launch`] says what to show. @@ -32,6 +32,8 @@ const SETTINGS_SIZE: (f64, f64) = (980.0, 720.0); const SETTINGS_MIN_SIZE: (f64, f64) = (640.0, 420.0); const COMPARISON_SIZE: (f64, f64) = (1100.0, 720.0); const COMPARISON_MIN_SIZE: (f64, f64) = (640.0, 360.0); +const DEPLOYMENT_SIZE: (f64, f64) = (1400.0, 900.0); +const DEPLOYMENT_MIN_SIZE: (f64, f64) = (800.0, 500.0); /// What a freshly opened window should do once its frontend is up. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -54,6 +56,17 @@ pub struct Launch { /// Comparison windows: the revisions to compare (boxed: most launches have none). #[serde(default)] pub comparison: Option>, + /// Deployment windows: the server and local selection to compare. + #[serde(default)] + pub deployment: Option, +} + +/// Local files to compare with a deployment server (Sync with Deployed). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +pub struct DeploymentLaunch { + /// Deployment server id. + pub server: String, + pub paths: Vec, } /// A Git revision and how the comparison window names it. @@ -193,16 +206,18 @@ impl WindowsState { pub enum AuxKind { Settings, Comparison, + Deployment, } impl AuxKind { - const ALL: [AuxKind; 2] = [AuxKind::Settings, AuxKind::Comparison]; + const ALL: [AuxKind; 3] = [AuxKind::Settings, AuxKind::Comparison, AuxKind::Deployment]; /// Label prefix, and the key of the one remembered geometry shared by every window of the kind. pub fn name(self) -> &'static str { match self { AuxKind::Settings => "settings", AuxKind::Comparison => "comparison", + AuxKind::Deployment => "deployment", } } @@ -1070,6 +1085,30 @@ pub fn open_comparison_window( ) } +/// Opens (or focuses and hands over to) the project window's Sync with Deployed window: local +/// files next to their copies on a deployment server, like IDEA's. +pub fn open_deployment_window( + app: &AppHandle, + parent: &WebviewWindow, + server: String, + paths: Vec, + background: Option, +) -> IdeResult<()> { + let paths = paths.into_iter().map(Into::into).collect(); + open_auxiliary_window( + app, + parent, + AuxWindow { + kind: AuxKind::Deployment, + title: "Sync with Deployed", + size: DEPLOYMENT_SIZE, + min_size: DEPLOYMENT_MIN_SIZE, + launch: Launch { deployment: Some(DeploymentLaunch { server, paths }), ..Default::default() }, + background, + }, + ) +} + /// Fit outer bounds, including native decorations, using one monitor's logical coordinate space. #[cfg(target_os = "macos")] fn fit_over_parent(window: &WebviewWindow, parent: &WebviewWindow, min_size: (f64, f64)) -> tauri::Result<()> { @@ -1504,12 +1543,13 @@ mod tests { assert_eq!(settings_label("project-2"), "settings-project-2"); assert_eq!(auxiliary("settings-main"), Some((AuxKind::Settings, "main"))); assert_eq!(auxiliary("comparison-project-2-14"), Some((AuxKind::Comparison, "project-2"))); + assert_eq!(auxiliary("deployment-project-2"), Some((AuxKind::Deployment, "project-2"))); for label in ["main", "project-1", "comparison-main", "comparison-main-x", "settings-", "settingsmain"] { assert_eq!(auxiliary(label), None, "{label}"); } assert_eq!(state_key("comparison-main-3"), "comparison"); assert_eq!(state_key("project-3"), "project-3"); - let labels = ["comparison-main-0", "project-2", "main", "settings-main", "comparison-project-2-1"]; + let labels = ["comparison-main-0", "project-2", "main", "settings-main", "deployment-project-2"]; assert_eq!(sorted_labels(labels.into_iter().filter(|l| auxiliary_parent(l).is_none())), ["main", "project-2"]); } diff --git a/src/bindings.ts b/src/bindings.ts index 9a4339428..a88a23b1e 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -774,6 +774,11 @@ export const commands = { * `background` (`#rrggbb`) paints it until its page has loaded. */ windowOpenComparison: (comparison: ComparisonLaunch, background: string | null) => typedError(__TAURI_INVOKE("window_open_comparison", { comparison, background })), + /** + * Opens this window's Sync with Deployed window comparing `paths` with `server` (or hands them + * to the open one). `background` (`#rrggbb`) paints a new window until its page has loaded. + */ + windowOpenDeployment: (server: string, paths: string[], background: string | null) => typedError(__TAURI_INVOKE("window_open_deployment", { server, paths, background })), /** Focuses the next / previous window (⌘` / ⇧⌘`). */ windowCycle: (forward: boolean) => __TAURI_INVOKE("window_cycle", { forward }), windowToggleFullscreen: () => typedError(__TAURI_INVOKE("window_toggle_fullscreen")), @@ -798,6 +803,8 @@ export const commands = { positions?: FilePosition[], /** Comparison windows: the revisions to compare (boxed: most launches have none). */ comparison?: ComparisonLaunch | null, + /** Deployment windows: the server and local selection to compare. */ + deployment?: DeploymentLaunch | null, } | null>("window_take_launch"), windowCloseContext: () => __TAURI_INVOKE("window_close_context"), /** Projects open in project windows, in window order (title bar project widget). */ @@ -2728,6 +2735,13 @@ export type DeploymentCoverage = { skipped: TransferError[], }; +/** Local files to compare with a deployment server (Sync with Deployed). */ +export type DeploymentLaunch = { + /** Deployment server id. */ + server: string, + paths: IpcPath[], +}; + export type DeploymentMapping = { /** Project-relative folder, or absolute inside the project; empty = the project root. */ local?: string, @@ -4491,6 +4505,8 @@ export type Launch = { positions?: FilePosition[], /** Comparison windows: the revisions to compare (boxed: most launches have none). */ comparison?: ComparisonLaunch | null, + /** Deployment windows: the server and local selection to compare. */ + deployment?: DeploymentLaunch | null, }; export type LaunchConfig = { diff --git a/src/editor/core/embeddedDocuments.test.ts b/src/editor/core/embeddedDocuments.test.ts new file mode 100644 index 000000000..23bc57e80 --- /dev/null +++ b/src/editor/core/embeddedDocuments.test.ts @@ -0,0 +1,46 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { EditorView } from "@codemirror/view"; +import { attachDocument } from "@/editor/api"; +import { useProject } from "@/platform/project"; +import { _resetLanguages } from "./cm/languages"; +import { ipcBackend, type MemoryBackend } from "./backend"; +import { activateEmbeddedDocuments } from "./embeddedDocuments"; + +vi.mock("./backend", async (original) => { + const module = await original(); + return { ...module, ipcBackend: module.createMemoryBackend({ "/p/a.txt": "original\n", "/p/b.txt": "other\n" }) }; +}); + +afterEach(() => useProject.setState({ settings: null })); + +it("retains local changes when another comparison row is selected and saves before releasing document references", async () => { + _resetLanguages(); + useProject.setState({ settings: { files: { autoSave: false } } as never }); + const documents = activateEmbeddedDocuments(); + const backend = ipcBackend as MemoryBackend; + const first = await attachDocument("/p/a.txt"); + const view = new EditorView({ doc: first.text(), extensions: [first.extension] }); + try { + view.dispatch({ changes: { from: 0, insert: "changed " } }); + view.destroy(); + first.release(); + const second = await attachDocument("/p/b.txt"); + const otherView = new EditorView({ doc: second.text(), extensions: [second.extension] }); + otherView.dispatch({ changes: { from: 0, insert: "pending " } }); + otherView.destroy(); + second.release(); + expect(documents.dirty()).toBe(true); + expect(backend.files.get("/p/a.txt")).toBe("original\n"); + expect(backend.docs.size).toBe(2); + await documents.savePaths(["/p/a.txt"]); + expect(backend.files.get("/p/a.txt")).toBe("changed original\n"); + expect(backend.files.get("/p/b.txt")).toBe("other\n"); + expect(documents.dirty()).toBe(true); + await documents.save(); + expect(backend.files.get("/p/b.txt")).toBe("pending other\n"); + expect(documents.dirty()).toBe(false); + } finally { + await documents.dispose(); + } + expect(backend.docs.size).toBe(0); +}); diff --git a/src/editor/core/embeddedDocuments.ts b/src/editor/core/embeddedDocuments.ts new file mode 100644 index 000000000..eaebc4516 --- /dev/null +++ b/src/editor/core/embeddedDocuments.ts @@ -0,0 +1,49 @@ +/** Live document services for a native comparison window, without editor tabs or session restoration. */ +import { _provideCodePreview, _provideDocumentAttacher } from "@/editor/api"; +import { useProject } from "@/platform/project"; +import { notifications } from "@/workbench/contributions"; +import { ipcBackend } from "./backend"; +import { previewExtensions } from "./cm/preview"; +import { installColorScheme } from "./colorSchemeRuntime"; +import { attachDocument } from "./model/attachment"; +import { DocumentManager, type DocModel } from "./model/document"; + +class EmbeddedDocumentManager extends DocumentManager { + // Keep files visited in this comparison until it closes, including unsaved + // edits in a row that is no longer selected. No project tab/session is made. + override release(_model: DocModel) {} +} + +export function activateEmbeddedDocuments() { + const manager = new EmbeddedDocumentManager(ipcBackend, { + settings: () => useProject.getState().settings, + notify: (message) => notifications().notify(message), + }); + manager.start(); + _provideDocumentAttacher((path) => attachDocument(manager, path)); + _provideCodePreview((path) => previewExtensions(path, useProject.getState().settings?.editor, ipcBackend)); + const offColor = installColorScheme(); + const offSettings = useProject.subscribe((s, prev) => { + if (s.settings !== prev.settings) manager.applySettings(); + }); + return { + dirty: () => [...manager.byPath.values()].some((model) => model.getMeta().dirty), + save: () => manager.saveAll("explicit"), + savePaths: (paths: readonly string[]) => + Promise.all(paths.map((path) => manager.get(path)?.save("explicit"))).then(() => {}), + async dispose() { + _provideDocumentAttacher(null); + _provideCodePreview(null); + offSettings(); + offColor(); + manager.stop(); + for (const model of manager.byPath.values()) { + await model.sync.whenIdle(); + model.dispose(); + await ipcBackend.close(model.doc); + } + manager.byPath.clear(); + manager.byId.clear(); + }, + }; +} diff --git a/src/features/deployment/Deployment.module.css b/src/features/deployment/Deployment.module.css index 6b52c57cc..42be83c72 100644 --- a/src/features/deployment/Deployment.module.css +++ b/src/features/deployment/Deployment.module.css @@ -80,21 +80,136 @@ } /* Sync dialog */ +.nativeWindow { + display: flex; + flex-direction: column; + height: 100%; + min-width: 0; + overflow: hidden; + background: var(--bg-dialog, var(--bg-elev)); + color: var(--fg); +} +.nativeBody { + flex: 1; + min-height: 0; + overflow: hidden; +} +.nativeFooter { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 16px; + flex: none; +} +.syncWindow { + height: calc(100vh - 48px); +} +.syncWindow [data-dialog-body] { + overflow: hidden; +} .sync { display: flex; flex-direction: column; gap: 8px; - min-height: 360px; + height: 100%; + min-height: 0; + padding: 8px 10px 0; +} +.comparisonToolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + flex: none; + gap: 4px; +} +.separator { + height: 20px; + border-left: 1px solid var(--border); + margin: 0 5px; +} +.filterButton { + width: 28px; + height: 26px; + padding: 0; + border: 1px solid transparent; + border-radius: var(--radius-control, 4px); + color: var(--fg-muted); + background: transparent; + font-size: 19px; + cursor: pointer; +} +.filterButton[aria-pressed="true"] { + background: var(--bg-tree-hover); + border-color: var(--border); + color: var(--accent); +} +.filterButton:focus-visible { + outline: 2px solid var(--focus-ring); +} +.compareBy, +.fileFilter { + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} +.compareBy select { + height: 26px; + color: var(--fg); + background: var(--bg-input, var(--bg-island)); + border: 1px solid var(--border); + border-radius: var(--radius-control, 4px); + font: inherit; +} +.fileFilter { + margin-left: auto; + width: clamp(180px, 30%, 480px); +} +.fileFilter > input { + width: 100%; + min-width: 0; +} +.comparisonRoots { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18px; + flex: none; +} +.comparisonRoots > span { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + border: 1px solid var(--border); + border-radius: var(--radius-control, 4px); + padding: 5px 8px; +} +.comparisonRoots > span > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.comparisonSplit { + flex: 1; + min-height: 0; +} +.fileList { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; } .syncTableHost { flex: 1; - max-height: 460px; + min-height: 0; overflow: auto; border: 1px solid var(--border); border-radius: var(--radius-control, 4px); } .syncTable { width: 100%; + min-width: 900px; border-collapse: collapse; table-layout: fixed; } @@ -123,17 +238,28 @@ .syncTable tbody tr:hover { background: var(--bg-tree-hover); } -.choiceCol { - width: 128px; +.syncTable tbody tr[aria-selected="true"] { + background: var(--bg-tree-selected, var(--accent-soft)); +} +.fileName { + display: flex; + align-items: center; + gap: 6px; + overflow: hidden; + text-overflow: ellipsis; +} +.operationCol { + width: 108px; +} +.operationCell { + border-left: 1px solid var(--border); + border-right: 1px solid var(--border); } .sizeCol { width: 80px; } .timeCol { - width: 128px; -} -.statusCol { - width: 112px; + width: 148px; } .num { text-align: right; @@ -169,6 +295,28 @@ .status-same { color: var(--fg-subtle); } +.listHint { + flex: none; + color: var(--fg-subtle); + font-size: var(--fs-small); + padding: 3px 6px; + text-align: right; +} +.preview { + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--border); +} +.previewMessage { + padding: 18px; + display: flex; + align-items: flex-start; + flex-direction: column; + gap: 12px; +} /* SSH password prompt */ .secret { diff --git a/src/features/deployment/Dialogs.test.tsx b/src/features/deployment/Dialogs.test.tsx index 15ce23198..372e6eb73 100644 --- a/src/features/deployment/Dialogs.test.tsx +++ b/src/features/deployment/Dialogs.test.tsx @@ -1,18 +1,27 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import type { SyncEntry } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { DEFAULT_DEPLOYMENT_SERVER } from "@/platform/remoteSettings"; + +const mocks = vi.hoisted(() => ({ equal: vi.fn(), sync: vi.fn(), refresh: vi.fn() })); +vi.mock("./comparison", () => ({ createDeploymentComparison: () => ({ equal: mocks.equal, preview: vi.fn() }) })); +vi.mock("./SyncPreview", () => ({ + SyncPreview: ({ entry }: { entry: SyncEntry | null }) =>
{entry?.remotePath}
, +})); +vi.mock("./actions", () => ({ closeSync: vi.fn(), runSync: mocks.sync, openSync: mocks.refresh })); import { DeploymentDialogs } from "./Dialogs"; import { initialSyncChoices } from "./model"; import { useDeployment } from "./store"; +import { SyncDialog } from "./SyncDialog"; const entry = (path: string, status: SyncEntry["status"]): SyncEntry => ({ path, remotePath: `/srv/app/${path}`, local: status === "remoteOnly" ? null : { size: 2048, mtime: 1_700_000_000 }, - remote: status === "localOnly" ? null : { size: 10, mtime: 1_700_000_100 }, + remote: status === "localOnly" ? null : { size: 2048, mtime: 1_700_000_100 }, status, }); - function openSync(entries: SyncEntry[]) { useDeployment.setState({ sync: { @@ -29,31 +38,132 @@ function openSync(entries: SyncEntry[]) { }, }); } - +beforeEach(() => { + vi.clearAllMocks(); + mocks.equal.mockResolvedValue(false); + useProject.setState({ + project: { root: "/work/app" } as never, + settings: { + deployment: { servers: [{ ...DEFAULT_DEPLOYMENT_SERVER, id: "s1", name: "Staging" }] }, + } as never, + }); +}); afterEach(() => { cleanup(); useDeployment.setState({ sync: null }); }); +const table = () => screen.getByRole("grid", { name: "Files to synchronize" }); +const row = (path: string) => + within(table()) + .getByRole("combobox", { name: `Action ${path}` }) + .closest("tr")!; -describe("Sync dialog", () => { - it("lists the differing files with their default actions and hides identical ones", () => { +describe("deployment directory and content comparison", () => { + it("fills a secondary window without creating an in-page modal and saves local edits before synchronization", async () => { + let saved!: () => void; + const beforeSynchronize = vi.fn( + () => + new Promise((resolve) => { + saved = resolve; + }), + ); + openSync([entry("a.php", "localOnly")]); + render(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(screen.getByRole("region", { name: "Diff Between 'local files' and 'files on Staging'" })).toContainElement( + table(), + ); + fireEvent.click(screen.getByRole("button", { name: "Synchronize All" })); + expect(screen.getByRole("button", { name: "Synchronize All" })).toBeDisabled(); + expect(mocks.sync).not.toHaveBeenCalled(); + await act(async () => saved()); + expect(mocks.sync).toHaveBeenCalledWith([{ path: "a.php", action: "upload" }]); + }); + it("shows mirrored metadata and previews the selected file, with identical rows filtered", () => { openSync([entry("a.php", "localOnly"), entry("b.php", "remoteNewer"), entry("c.php", "same")]); render(); - expect(screen.getByText("a.php")).toBeTruthy(); - expect(screen.queryByText("c.php")).toBeNull(); - expect((screen.getByLabelText("Action a.php") as HTMLSelectElement).value).toBe("upload"); - expect((screen.getByLabelText("Action b.php") as HTMLSelectElement).value).toBe("download"); - - fireEvent.click(screen.getByLabelText(/Hide identical files/)); - expect(screen.getByText("c.php")).toBeTruthy(); - expect((screen.getByLabelText("Action c.php") as HTMLSelectElement).value).toBe("skip"); + expect(within(table()).getByRole("columnheader", { name: "Local name" })).toBeInTheDocument(); + expect(within(table()).getByRole("columnheader", { name: "Remote name" })).toBeInTheDocument(); + expect(screen.queryByRole("combobox", { name: "Action c.php" })).not.toBeInTheDocument(); + expect(screen.getByTestId("preview")).toHaveTextContent("/srv/app/a.php"); + fireEvent.click(row("b.php")); + expect(row("b.php")).toHaveAttribute("aria-selected", "true"); + expect(screen.getByTestId("preview")).toHaveTextContent("/srv/app/b.php"); + fireEvent.click(screen.getByRole("button", { name: "Show identical files" })); + expect(screen.getByRole("combobox", { name: "Action c.php" })).toHaveValue("skip"); }); - - it("updates the choice of a row", () => { + it("filters by name and category, navigates with arrows and cycles operations with Space", () => { + openSync([entry("a.php", "localOnly"), entry("b.php", "remoteNewer")]); + render(); + fireEvent.keyDown(table(), { key: "ArrowDown" }); + expect(row("b.php")).toHaveAttribute("aria-selected", "true"); + fireEvent.keyDown(table(), { key: " " }); + expect(screen.getByRole("combobox", { name: "Action b.php" })).toHaveValue("deleteRemote"); + fireEvent.keyDown(screen.getByLabelText("Filter by name"), { key: "Enter" }); + expect(mocks.sync).not.toHaveBeenCalled(); + fireEvent.change(screen.getByLabelText("Filter by name"), { target: { value: "b.php" } }); + expect(screen.queryByRole("combobox", { name: "Action a.php" })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Show different files" })); + expect(screen.getByText("No matching differences")).toBeInTheDocument(); + }); + it("updates a row's operation and only applies explicit synchronization", () => { openSync([entry("a.php", "localOnly")]); render(); fireEvent.change(screen.getByLabelText("Action a.php"), { target: { value: "skip" } }); + expect(screen.getByRole("button", { name: "Synchronize All" })).toBeDisabled(); + fireEvent.change(screen.getByLabelText("Action a.php"), { target: { value: "upload" } }); + fireEvent.click(screen.getByRole("button", { name: "Synchronize All" })); + expect(mocks.sync).toHaveBeenCalledWith([{ path: "a.php", action: "upload" }]); + }); + it("finds content hidden by identical metadata and skips byte-identical files", async () => { + mocks.equal.mockImplementation(async (e: SyncEntry) => e.path === "a.php"); + openSync([entry("a.php", "localNewer"), entry("b.php", "same")]); + render(); + fireEvent.change(screen.getByLabelText("Compare by"), { target: { value: "content" } }); + await waitFor(() => expect(screen.getByLabelText("Action b.php")).toHaveValue("upload")); + expect(screen.queryByLabelText("Action a.php")).not.toBeInTheDocument(); expect(useDeployment.getState().sync?.choices["a.php"]).toBe("skip"); - expect((screen.getByRole("button", { name: "Synchronize" }) as HTMLButtonElement).disabled).toBe(true); + expect(mocks.equal).toHaveBeenCalledTimes(2); + fireEvent.change(screen.getByLabelText("Compare by"), { target: { value: "metadata" } }); + expect(screen.getByLabelText("Action a.php")).toHaveValue("upload"); + }); + it("synchronizes only the current row from the toolbar or Enter, and disables skipped selections", async () => { + openSync([entry("a.php", "localOnly"), entry("b.php", "remoteNewer")]); + render(); + fireEvent.click(row("b.php")); + fireEvent.click(screen.getByRole("button", { name: "Synchronize Selected" })); + expect(mocks.sync).toHaveBeenCalledWith([{ path: "b.php", action: "download" }], "selected"); + await act(async () => {}); + mocks.sync.mockClear(); + fireEvent.click(row("a.php")); + fireEvent.keyDown(table(), { key: "Enter" }); + expect(mocks.sync).toHaveBeenCalledWith([{ path: "a.php", action: "upload" }], "selected"); + await act(async () => {}); + mocks.sync.mockClear(); + fireEvent.change(screen.getByLabelText("Action a.php"), { target: { value: "skip" } }); + expect(screen.getByRole("button", { name: "Synchronize Selected" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Synchronize All" })).toBeEnabled(); + fireEvent.keyDown(table(), { key: "Enter" }); + expect(mocks.sync).not.toHaveBeenCalled(); + }); + it("preserves manual choices, reports failures, and ignores a cancelled scan", async () => { + let finish!: (value: boolean) => void; + mocks.equal.mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve; + }), + ); + openSync([entry("a.php", "localNewer")]); + render(); + fireEvent.change(screen.getByLabelText("Action a.php"), { target: { value: "skip" } }); + fireEvent.change(screen.getByLabelText("Compare by"), { target: { value: "content" } }); + await waitFor(() => expect(mocks.equal).toHaveBeenCalledOnce()); + fireEvent.click(screen.getByRole("button", { name: "Stop content comparison" })); + await act(async () => finish(true)); + expect(screen.getByLabelText("Action a.php")).toHaveValue("skip"); + mocks.equal.mockRejectedValueOnce(new Error("Permission denied")); + fireEvent.change(screen.getByLabelText("Compare by"), { target: { value: "content" } }); + expect(await screen.findByRole("alert")).toHaveTextContent("Permission denied"); + expect(screen.getByLabelText("Action a.php")).toHaveValue("skip"); }); }); diff --git a/src/features/deployment/Dialogs.tsx b/src/features/deployment/Dialogs.tsx index 982a1cda4..a60893de2 100644 --- a/src/features/deployment/Dialogs.tsx +++ b/src/features/deployment/Dialogs.tsx @@ -1,173 +1,11 @@ -/** Deployment dialogs: Sync with Deployed and the SSH password prompt. */ +/** Deployment comparison and SSH password prompt. */ import { useState } from "react"; -import clsx from "clsx"; -import type { SyncEntry, SyncStatus } from "@/platform/ipc"; -import { useTx, useTxTemplate } from "@/platform/i18n"; -import { relativePath, useProject } from "@/platform/project"; -import { Checkbox, Dialog, Input, Spinner, T } from "@/ui"; -import { closeSync, runSync } from "./actions"; -import { formatSize, formatTime, syncChoices, syncSteps, type SyncChoice } from "./model"; -import { patchSync, useDeployment } from "./store"; +import { Checkbox, Dialog, Input } from "@/ui"; +import { useDeployment } from "./store"; +import { SyncDialog } from "./SyncDialog"; import styles from "./Deployment.module.css"; -const STATUS_LABELS: Record = { - localOnly: "Local only", - remoteOnly: "Remote only", - localNewer: "Local is newer", - remoteNewer: "Remote is newer", - different: "Different size", - same: "Identical", -}; - -const CHOICE_LABELS: Record = { - skip: "Skip", - upload: "Upload", - download: "Download", - deleteRemote: "Delete remote", - deleteLocal: "Delete local", -}; - -function SyncRow({ - entry, - choice, - onChoice, - disabled, -}: { - entry: SyncEntry; - choice: SyncChoice; - onChoice: (c: SyncChoice) => void; - disabled: boolean; -}) { - const tx = useTx(); - return ( - - - - - - {entry.path} - - {entry.local ? formatSize(entry.local.size) : ""} - {formatTime(entry.local?.mtime)} - {entry.remote ? formatSize(entry.remote.size) : ""} - {formatTime(entry.remote?.mtime)} - {tx(STATUS_LABELS[entry.status])} - - ); -} - -function SyncDialog() { - const sync = useDeployment((s) => s.sync); - const root = useProject((s) => s.project?.root ?? null); - const tx = useTx(); - const txt = useTxTemplate(); - if (!sync) return null; - const entries = sync.entries; - const shown = entries?.filter((e) => !sync.hideSame || e.status !== "same") ?? []; - const steps = syncSteps(sync.choices); - const same = entries?.filter((e) => e.status === "same").length ?? 0; - const where = sync.paths.map((path) => (root ? relativePath(root, path) || "/" : path)).join(", "); - - return ( - patchSync({ hideSame })} - label={txt(`Hide identical files (${same})`)} - /> - ) - } - buttons={[ - { label: "Cancel", role: "cancel" }, - { - label: "Synchronize", - role: "default", - disabled: !entries || sync.running || steps.length === 0, - onClick: () => void runSync(steps), - }, - ]} - > -
-
- {where} - {entries && <> · {txt(`${steps.length} of ${entries.length} files will change`)}} -
- {!!sync.skipped?.length && ( -
- {txt(`Skipped: ${sync.skipped.length}`)}:{" "} - {sync.skipped.map((s) => (root ? relativePath(root, s.path) : s.path)).join(", ")} -
- )} - {sync.error &&
{sync.error}
} - {!entries && !sync.error && ( -
- Comparing local and remote files… -
- )} - {entries && ( -
- - - - - - - - - - - - - - {shown.length === 0 && ( - - - - )} - {shown.map((e) => ( - patchSync({ choices: { ...sync.choices, [e.path]: c } })} - /> - ))} - -
{tx("Action")}{tx("Path")}{tx("Local size")}{tx("Local modified")}{tx("Remote size")}{tx("Remote modified")}{tx("Status")}
- Local and remote files are identical -
-
- )} - {sync.running && ( -
- Synchronizing… -
- )} -
-
- ); -} - -function SecretDialog() { +export function SecretDialog() { const prompt = useDeployment((s) => s.secret); const [secret, setSecret] = useState(""); const [save, setSave] = useState(false); diff --git a/src/features/deployment/SyncDialog.tsx b/src/features/deployment/SyncDialog.tsx new file mode 100644 index 000000000..0d100e5a3 --- /dev/null +++ b/src/features/deployment/SyncDialog.tsx @@ -0,0 +1,522 @@ +/** Deployment directory diff: mirrored file lists above an embedded content diff. */ +import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; +import clsx from "clsx"; +import type { SyncEntry, SyncStatus, SyncStep } from "@/platform/ipc"; +import { useTx, useTxTemplate } from "@/platform/i18n"; +import { useProject } from "@/platform/project"; +import { useWorkflowSettings } from "@/platform/workflowSettings"; +import { + Dialog, + DialogButtons, + Icon, + IconButton, + Input, + Spinner, + SplitGroup, + SplitHandle, + SplitPanel, + T, + fileIconName, + type DialogProps, +} from "@/ui"; +import { TitleBar } from "@/workbench/TitleBar"; +import { usePlatform } from "@/platform/platformInfo"; +import { closeSync, openSync, runSync, type SyncRunMode } from "./actions"; +import { createDeploymentComparison, type DeploymentComparison } from "./comparison"; +import { defaultSyncAction, formatSize, formatTime, syncChoices, syncSteps, type SyncChoice } from "./model"; +import { patchSync, useDeployment, type SyncDialogState } from "./store"; +import { SyncPreview } from "./SyncPreview"; +import styles from "./Deployment.module.css"; + +const STATUS: Record = { + localOnly: "Local only", + remoteOnly: "Remote only", + localNewer: "Local is newer", + remoteNewer: "Remote is newer", + different: "Contents differ", + same: "Identical", +}; +const CHOICES: Record = { + skip: ["—", "Skip"], + upload: ["→", "Upload"], + download: ["←", "Download"], + deleteRemote: ["× →", "Delete remote"], + deleteLocal: ["← ×", "Delete local"], +}; +type CompareBy = "metadata" | "content"; +const NO_VERIFIED: Record = {}; +const NO_ERRORS: Record = {}; +interface ContentScan { + comparison: DeploymentComparison; + entries: SyncEntry[]; + verified: Record; + errors: Record; + progress: number | null; +} + +function ComparisonFrame({ standalone, ...props }: DialogProps & { standalone: boolean }) { + const ownTitleBar = usePlatform((s) => s.overlayTitleBar || s.framelessTitleBar); + if (!standalone) return ; + return ( +
+ {ownTitleBar && } +
{props.children}
+
+ {props.footerStart as ReactNode} + +
+
+ ); +} + +function SyncComparison({ + sync, + standalone, + beforeSynchronize, +}: { + sync: SyncDialogState; + standalone: boolean; + beforeSynchronize?: (steps: SyncStep[]) => Promise; +}) { + const root = useProject((s) => s.project?.root ?? null); + const server = useWorkflowSettings((s) => s.deployment.servers.find((item) => item.id === sync.server)); + const tx = useTx(); + const txt = useTxTemplate(); + const [selectedPath, setSelectedPath] = useState(null); + const [filter, setFilter] = useState(""); + const [showLocal, setShowLocal] = useState(true); + const [showDifferent, setShowDifferent] = useState(true); + const [showRemote, setShowRemote] = useState(true); + const [compareBy, setCompareBy] = useState("metadata"); + const [scan, setScan] = useState(null); + const [generation, setGeneration] = useState(0); + const touched = useRef(new Set()); + const synchronizing = useRef(false); + const comparison = useMemo( + () => (server && root ? { ...createDeploymentComparison(server, root), generation, entries: sync.entries } : null), + [server, root, generation, sync.entries], + ); + const entries = sync.entries; + const activeScan = + compareBy === "content" && scan?.comparison === comparison && scan.entries === entries ? scan : null; + const verified = activeScan?.verified ?? NO_VERIFIED; + const checkErrors = activeScan?.errors ?? NO_ERRORS; + const progress = compareBy === "content" && entries && comparison ? (activeScan ? activeScan.progress : 0) : null; + const changeCompareBy = (mode: CompareBy) => { + setScan(null); + setCompareBy(mode); + }; + + useEffect(() => { + const abort = new AbortController(); + if (!entries || !comparison) return; + const suggest = (entry: SyncEntry, status: SyncStatus) => { + const current = useDeployment.getState().sync; + if (current?.entries !== entries || touched.current.has(entry.path)) return; + patchSync({ choices: { ...current.choices, [entry.path]: defaultSyncAction(status) } }); + }; + if (compareBy === "metadata") { + entries.forEach((entry) => suggest(entry, entry.status)); + return; + } + void (async () => { + await Promise.resolve(); + const verified: Record = {}; + const errors: Record = {}; + for (let i = 0; i < entries.length; i++) { + if (abort.signal.aborted) return; + const entry = entries[i]!; + try { + if (entry.local && entry.remote) { + const equal = await comparison.equal(entry, abort.signal); + if (abort.signal.aborted) return; + verified[entry.path] = equal; + suggest(entry, equal ? "same" : entry.status === "same" ? "different" : entry.status); + } + } catch (e) { + if (abort.signal.aborted) return; + errors[entry.path] = e instanceof Error ? e.message : String(e); + } + if (abort.signal.aborted) return; + setScan({ comparison, entries, verified: { ...verified }, errors: { ...errors }, progress: i + 1 }); + } + if (!abort.signal.aborted) setScan({ comparison, entries, verified, errors, progress: null }); + })(); + return () => abort.abort(); + }, [entries, comparison, compareBy]); + + const compared = useMemo( + () => + entries?.map((entry) => + verified[entry.path] === undefined + ? entry + : { + ...entry, + status: verified[entry.path] ? ("same" as const) : ("different" as const), + }, + ) ?? [], + [entries, verified], + ); + const shown = useMemo(() => { + const query = filter.trim().toLocaleLowerCase(); + return compared.filter( + (entry) => + entry.path.toLocaleLowerCase().includes(query) && + (checkErrors[entry.path] || + (entry.status === "same" + ? !sync.hideSame + : entry.status === "localOnly" + ? showLocal + : entry.status === "remoteOnly" + ? showRemote + : showDifferent)), + ); + }, [compared, filter, checkErrors, sync.hideSame, showLocal, showRemote, showDifferent]); + const selected = shown.find((entry) => entry.path === selectedPath) ?? shown[0] ?? null; + const previewEntry = entries?.find((entry) => entry.path === selected?.path) ?? null; + const steps = syncSteps(sync.choices); + const selectedSteps = steps.filter((step) => step.path === selected?.path); + const busy = sync.running || !entries || progress !== null; + const synchronize = async (mode: SyncRunMode) => { + const selectedOperations = mode === "selected" ? selectedSteps : steps; + if (busy || synchronizing.current || !selectedOperations.length) return; + synchronizing.current = true; + if (mode === "selected") selectedOperations.forEach((step) => touched.current.add(step.path)); + try { + if (beforeSynchronize) { + patchSync({ running: true }); + await beforeSynchronize(selectedOperations); + if (useDeployment.getState().sync?.paths !== sync.paths) return; + } + const report = await (mode === "selected" ? runSync(selectedOperations, mode) : runSync(selectedOperations)); + if (mode === "selected" && report?.files && !report.errors.length && !report.skipped) { + const index = shown.findIndex((entry) => entry.path === selected?.path); + const choices = useDeployment.getState().sync?.choices; + const next = [...shown.slice(index + 1), ...shown.slice(0, index)].find( + (entry) => choices?.[entry.path] && choices[entry.path] !== "skip", + ); + if (next) setSelectedPath((current) => (current === selectedPath ? next.path : current)); + } + } catch (error) { + if (useDeployment.getState().sync?.paths === sync.paths) + patchSync({ running: false, error: error instanceof Error ? error.message : String(error) }); + } finally { + synchronizing.current = false; + } + }; + const choose = (entry: SyncEntry, choice: SyncChoice) => { + if (sync.running) return; + touched.current.add(entry.path); + setSelectedPath(entry.path); + patchSync({ choices: { ...useDeployment.getState().sync?.choices, [entry.path]: choice } }); + }; + const onTableKey = (event: KeyboardEvent) => { + if ((event.target as HTMLElement).closest("select, button, input")) return; + const index = shown.findIndex((entry) => entry.path === selected?.path); + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + const next = shown[Math.max(0, Math.min(shown.length - 1, index + (event.key === "ArrowDown" ? 1 : -1)))]; + if (next) { + setSelectedPath(next.path); + document.getElementById(`deploy-row-${shown.indexOf(next)}`)?.scrollIntoView({ block: "nearest" }); + } + } else if ( + event.key === "Enter" && + !event.altKey && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey && + !event.nativeEvent.isComposing + ) { + event.preventDefault(); + void synchronize("selected"); + } else if (event.key === " " && selected && !busy) { + event.preventDefault(); + const choices = syncChoices(selected); + choose(selected, choices[(choices.indexOf(sync.choices[selected.path] ?? "skip") + 1) % choices.length]!); + } + }; + const refresh = () => { + if (!server || busy) return; + touched.current.clear(); + setGeneration((n) => n + 1); + void openSync(server, sync.paths); + }; + + return ( + void getCurrentWebviewWindow().close() : closeSync} + width="calc(100vw - 48px)" + className={styles.syncWindow} + resizable="both" + footerStart={ + {entries && txt(`${steps.length} of ${entries.length} files will change`)} + } + buttons={[ + { label: "Cancel", role: "cancel" }, + { + label: "Synchronize All", + role: "extra", + disabled: busy || steps.length === 0, + onClick: () => void synchronize("all"), + }, + ]} + > +
+
+ + + + + + + + + + void synchronize("selected")} + /> + {progress !== null && ( + <> + + + {txt(`Comparing contents: ${progress} of ${entries?.length ?? 0}`)} + + changeCompareBy("metadata")} + /> + + )} + +
+
+ + + {root ?? tx("Local files")} + + + + + {sync.serverName}: {server?.rootPath || "/"} + + +
+ {sync.error && ( +
+ {sync.error} +
+ )} + {!!sync.skipped?.length && ( +
+ {txt(`Skipped: ${sync.skipped.length}`)}: {sync.skipped.map((item) => item.path).join(", ")} +
+ )} + {!entries && !sync.error && ( +
+ + Comparing local and remote files… +
+ )} + {sync.running && ( +
+ + Synchronizing… +
+ )} + {entries && ( + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + {!shown.length && ( + + + + )} + {shown.map((entry, index) => ( + { + setSelectedPath(entry.path); + if (!(event.target as HTMLElement).closest("select, button, input")) + event.currentTarget.closest("table")?.focus({ preventScroll: true }); + }} + > + + + + + + + + + ))} + +
{tx("Local name")}{tx("Size")}{tx("Date")}↔{tx("Date")}{tx("Size")}{tx("Remote name")}
+ No matching differences +
+ {entry.local && ( + + + {entry.path} + + )} + {entry.local ? formatSize(entry.local.size) : ""}{formatTime(entry.local?.mtime)} + + {formatTime(entry.remote?.mtime)}{entry.remote ? formatSize(entry.remote.size) : ""} + {entry.remote && ( + + + {entry.path} + + )} +
+
+
+ + Space changes the selected operation. Enter synchronizes the selected file. Synchronize All applies + all chosen operations. + +
+
+
+ + + {selected && checkErrors[selected.path] && ( +
+ {checkErrors[selected.path]} +
+ )} + {comparison ? ( + + ) : ( +
+ The deployment server or project is no longer available +
+ )} +
+
+ )} +
+
+ ); +} + +export function SyncDialog({ + standalone = false, + beforeSynchronize, +}: { + standalone?: boolean; + beforeSynchronize?: (steps: SyncStep[]) => Promise; +}) { + const sync = useDeployment((s) => s.sync); + return sync ? : null; +} diff --git a/src/features/deployment/SyncPreview.test.tsx b/src/features/deployment/SyncPreview.test.tsx new file mode 100644 index 000000000..6765e852c --- /dev/null +++ b/src/features/deployment/SyncPreview.test.tsx @@ -0,0 +1,72 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { SyncEntry } from "@/platform/ipc"; +import { registerEditorKind } from "@/workbench/contributions"; +import type { DeploymentComparison, DeploymentPreview } from "./comparison"; +import { SyncPreview } from "./SyncPreview"; + +// The diff viewer comes from the vcs feature through its editor kind. +registerEditorKind({ + kind: "diff", + title: () => "diff", + component: ({ input }) => ( +
+ {JSON.stringify(input.data)} +
+ ), +}); + +const entry = (path: string) => ({ path, remotePath: `/srv/${path}` }) as SyncEntry; +const result = (text: string): DeploymentPreview => ({ + kind: "text", + data: { + path: "/work/file.php", + title: "file.php", + left: { type: "text", text: "local", label: "Local" }, + right: { type: "text", text, label: "Remote" }, + }, +}); + +describe("deployment diff preview", () => { + it("ignores a late response for a file that is no longer selected", async () => { + let finish!: (value: DeploymentPreview) => void; + const preview = vi + .fn() + .mockReturnValueOnce( + new Promise((resolve) => { + finish = resolve; + }), + ) + .mockResolvedValue(result("current")); + const comparison: DeploymentComparison = { preview, equal: vi.fn() }; + const mounted = render(); + const firstSignal = preview.mock.calls[0]![1] as AbortSignal; + mounted.rerender(); + expect(firstSignal.aborted).toBe(true); + expect(await screen.findByTestId("diff")).toHaveTextContent("current"); + await act(async () => finish(result("stale"))); + expect(screen.getByTestId("diff")).not.toHaveTextContent("stale"); + expect(screen.getByTestId("diff")).toHaveAttribute("data-mode", "split"); + }); + it("shows read errors and allows retrying without closing the comparison", async () => { + const preview = vi.fn().mockRejectedValueOnce(new Error("Offline")).mockResolvedValue(result("recovered")); + render(); + expect(await screen.findByRole("alert")).toHaveTextContent("Offline"); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(await screen.findByTestId("diff")).toHaveTextContent("recovered"); + }); + it.each(["binary", "tooLarge"] as const)("explains why %s files have no text preview", async (kind) => { + render( + , + ); + expect( + await screen.findByText( + kind === "binary" ? "Binary files cannot be shown as text" : /too large for the text preview/, + ), + ).toBeInTheDocument(); + expect(screen.queryByTestId("diff")).not.toBeInTheDocument(); + }); +}); diff --git a/src/features/deployment/SyncPreview.tsx b/src/features/deployment/SyncPreview.tsx new file mode 100644 index 000000000..8e0099287 --- /dev/null +++ b/src/features/deployment/SyncPreview.tsx @@ -0,0 +1,93 @@ +import { Suspense, useEffect, useState } from "react"; +import type { SyncEntry } from "@/platform/ipc"; +import { txTemplate } from "@/platform/i18n"; +import { Button, Spinner, T } from "@/ui"; +import { useContributions } from "@/workbench/contributions"; +import type { DeploymentComparison, DeploymentDiff, DeploymentPreview } from "./comparison"; +import styles from "./Deployment.module.css"; + +/** An input of the diff viewer (editor kind `diff`), embedded and side by side. */ +function diffInput(data: DeploymentDiff) { + return { + kind: "diff", + key: `deployment:${data.path}`, + path: data.path, + data: { ...data, embedded: true, mode: "split" }, + }; +} + +export function SyncPreview({ entry, comparison }: { entry: SyncEntry | null; comparison: DeploymentComparison }) { + const [loaded, setLoaded] = useState<{ + comparison: DeploymentComparison; + entry: SyncEntry; + attempt: number; + result: DeploymentPreview | null; + error: string | null; + } | null>(null); + const [attempt, setAttempt] = useState(0); + const current = + loaded?.comparison === comparison && loaded.entry === entry && loaded.attempt === attempt ? loaded : null; + const result = current?.result; + const error = current?.error; + const diffViewer = useContributions((s) => s.editorKinds.get("diff")); + useEffect(() => { + const abort = new AbortController(); + if (entry) + void comparison.preview(entry, abort.signal).then( + (value) => { + if (!abort.signal.aborted) setLoaded({ comparison, entry, attempt, result: value, error: null }); + }, + (e: unknown) => { + if (!abort.signal.aborted) + setLoaded({ + comparison, + entry, + attempt, + result: null, + error: txTemplate(e instanceof Error ? e.message : String(e)), + }); + }, + ); + return () => abort.abort(); + }, [entry, comparison, attempt]); + + return ( +
+ {!entry ? ( +
+ Select a file to compare its contents +
+ ) : error ? ( +
+
{error}
+ +
+ ) : !result ? ( +
+ + Loading file contents… +
+ ) : result.kind === "binary" ? ( +
+ Binary files cannot be shown as text +
+ ) : result.kind === "tooLarge" ? ( +
+ This file is too large for the text preview. Use Binary Content to compare it. +
+ ) : ( + + +
+ } + > + {diffViewer && } + + )} + + ); +} diff --git a/src/features/deployment/actions.test.tsx b/src/features/deployment/actions.test.tsx index f3a1ae487..63ab8cdb8 100644 --- a/src/features/deployment/actions.test.tsx +++ b/src/features/deployment/actions.test.tsx @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { DeploymentServerValid } from "@/platform/remoteSettings"; +import type { SyncEntry } from "@/platform/ipc"; const mocks = vi.hoisted(() => ({ actions: [] as { id: string; run: (context: { arg: string[] }) => unknown }[], @@ -9,6 +10,17 @@ const mocks = vi.hoisted(() => ({ notify: vi.fn(), upload: vi.fn(), compare: vi.fn(), + sync: vi.fn(), + save: vi.fn(), + release: vi.fn(), + attachedPaths: [] as string[], + desktop: false, + openWindow: vi.fn(), +})); +vi.mock("./windowBridge", () => ({ showDeploymentComparison: mocks.openWindow })); +vi.mock("@/editor/api", () => ({ + editorsFor: (path: string) => (mocks.attachedPaths.includes(path) ? [{}] : []), + attachDocument: async () => ({ save: mocks.save, release: mocks.release }), })); vi.mock("@/platform/actions", () => ({ executeAction: vi.fn(), @@ -38,13 +50,15 @@ vi.mock("@/platform/ipc", () => ({ call: (value: unknown) => value, nextRequestId: () => 1, isCancelled: () => false, + isTauri: () => mocks.desktop, commands: { deployCoverage: async () => mocks.coverage, deployUpload: mocks.upload, deployCompare: mocks.compare, + deploySync: mocks.sync, }, })); -import { registerDeploymentActions } from "./actions"; +import { registerDeploymentActions, runSync } from "./actions"; import { useDeployment } from "./store"; async function run(id: string, paths = ["/project/a", "/project/b"]) { @@ -69,10 +83,53 @@ beforeEach(() => { mocks.choose.mockResolvedValue(null); mocks.upload.mockResolvedValue({ files: 1, skipped: 1, errors: [] }); mocks.compare.mockResolvedValue([]); + mocks.sync.mockResolvedValue({ files: 1, skipped: 0, errors: [] }); + mocks.save.mockResolvedValue(undefined); + mocks.attachedPaths = []; + mocks.desktop = false; + mocks.openWindow.mockResolvedValue(undefined); useDeployment.setState({ sync: null }); registerDeploymentActions(); }); describe("deployment eligibility", () => { + it("keeps upstream's server-specific explanation when only one server is configured", async () => { + mocks.servers = [mocks.servers[0]!]; + mocks.coverage[0]!.paths = []; + await run("deployment.uploadTo"); + expect(mocks.notify).toHaveBeenCalledWith( + expect.objectContaining({ title: "No selected paths can be deployed to one" }), + ); + expect(mocks.notify.mock.calls[0]![0].body.props.report.errors).toEqual(mocks.coverage[0]!.skipped); + }); + it("opens the selected server and paths in a native window on desktop without an in-page comparison", async () => { + mocks.desktop = true; + mocks.choose.mockResolvedValue("one"); + await run("deployment.syncWith"); + await vi.waitFor(() => expect(mocks.openWindow).toHaveBeenCalledWith("one", ["/project/a", "/project/b"])); + expect(useDeployment.getState().sync).toBeNull(); + expect(mocks.compare).not.toHaveBeenCalled(); + }); + it("saves edits made in the local diff before synchronization and stops if saving fails", async () => { + await run("deployment.syncWith"); + mocks.choose.mockResolvedValue("one"); + await run("deployment.syncWith"); + await vi.waitFor(() => expect(useDeployment.getState().sync?.entries).toEqual([])); + const sync = useDeployment.getState().sync!; + mocks.attachedPaths = ["/project/a"]; + await runSync([ + { path: "a", action: "upload" }, + { path: "b", action: "download" }, + ]); + expect(mocks.save).toHaveBeenCalledOnce(); + expect(mocks.release).toHaveBeenCalledOnce(); + expect(mocks.save.mock.invocationCallOrder[0]).toBeLessThan(mocks.sync.mock.invocationCallOrder[0]!); + useDeployment.setState({ sync }); + mocks.sync.mockClear(); + mocks.save.mockRejectedValueOnce(new Error("Cannot save local file")); + await runSync([{ path: "a", action: "upload" }]); + expect(mocks.sync).not.toHaveBeenCalled(); + expect(useDeployment.getState().sync?.error).toBe("Cannot save local file"); + }); it.each(["deployment.uploadTo", "deployment.downloadFrom", "deployment.syncWith"])( "filters %s and keeps a single option visible", async (id) => { @@ -80,17 +137,24 @@ describe("deployment eligibility", () => { expect(mocks.choose).toHaveBeenCalledWith(expect.objectContaining({ items: ["one"] })); }, ); - it("explains an empty match and links settings", async () => { - mocks.coverage[0]!.paths = []; - await run("deployment.uploadTo"); - expect(mocks.choose).not.toHaveBeenCalled(); - expect(mocks.notify).toHaveBeenCalledWith( - expect.objectContaining({ - title: "No deployment server covers the selected paths", - actions: [expect.objectContaining({ label: "Configure…" })], - }), - ); - }); + it.each(["deployment.uploadTo", "deployment.downloadFrom", "deployment.syncWith"])( + "explains the rejected paths for %s and links settings", + async (id) => { + mocks.coverage[0]!.paths = []; + await run(id); + expect(mocks.choose).not.toHaveBeenCalled(); + expect(mocks.notify).toHaveBeenCalledWith( + expect.objectContaining({ + title: "No deployment server covers the selected paths", + actions: [expect.objectContaining({ label: "Configure…" })], + }), + ); + expect(mocks.notify.mock.calls[0]![0].body.props.report.errors).toEqual([ + { path: "/project/b", message: "one: Not covered by this server's mappings" }, + ]); + expect(mocks.compare).not.toHaveBeenCalled(); + }, + ); it("explains why the default cannot deploy the named file", async () => { mocks.coverage[0]!.paths = []; await run("deployment.uploadToDefault"); @@ -104,4 +168,64 @@ describe("deployment eligibility", () => { await vi.waitFor(() => expect(mocks.compare).toHaveBeenCalledTimes(2)); expect(useDeployment.getState().sync?.skipped?.[0]?.path).toBe("/project/b"); }); + it("keeps the comparison and other choices after synchronizing a single file, then supports the next file", async () => { + mocks.choose.mockResolvedValue("one"); + await run("deployment.syncWith"); + await vi.waitFor(() => expect(useDeployment.getState().sync?.entries).toEqual([])); + const entries: SyncEntry[] = ["a", "b"].map((path) => ({ + path, + remotePath: `/remote/${path}`, + local: { size: 10, mtime: 2 }, + remote: { size: 5, mtime: 1 }, + status: "localNewer", + })); + useDeployment.setState((state) => ({ + sync: { ...state.sync!, entries, choices: { a: "upload", b: "deleteRemote" } }, + })); + const refreshed = { ...entries[0]!, remote: entries[0]!.local, status: "same" as const }; + mocks.compare.mockResolvedValueOnce([refreshed]); + await runSync([{ path: "a", action: "upload" }], "selected"); + expect(mocks.sync).toHaveBeenLastCalledWith([{ path: "a", action: "upload" }], "one", 1); + expect(mocks.compare).toHaveBeenLastCalledWith("/project/a", "one", 1); + expect(useDeployment.getState().sync).toMatchObject({ + running: false, + request: null, + choices: { a: "skip", b: "deleteRemote" }, + entries: [refreshed, entries[1]], + }); + mocks.compare.mockResolvedValueOnce([{ ...entries[1]!, remote: null, status: "localOnly" }]); + await runSync([{ path: "b", action: "deleteRemote" }], "selected"); + expect(useDeployment.getState().sync?.choices).toEqual({ a: "skip", b: "skip" }); + expect(useDeployment.getState().sync?.entries).toHaveLength(2); + }); + it("retains failed selected operations for retry and does not change pending files when refresh fails", async () => { + mocks.choose.mockResolvedValue("one"); + await run("deployment.syncWith"); + await vi.waitFor(() => expect(useDeployment.getState().sync?.entries).toEqual([])); + const entries: SyncEntry[] = ["a", "b"].map((path) => ({ + path, + remotePath: `/remote/${path}`, + local: { size: 10, mtime: 2 }, + remote: null, + status: "localOnly", + })); + useDeployment.setState((state) => ({ sync: { ...state.sync!, entries, choices: { a: "upload", b: "upload" } } })); + mocks.sync.mockResolvedValueOnce({ files: 0, skipped: 0, errors: [{ path: "a", message: "Permission denied" }] }); + mocks.compare.mockResolvedValueOnce([entries[0]]); + await runSync([{ path: "a", action: "upload" }], "selected"); + expect(useDeployment.getState().sync).toMatchObject({ + running: false, + choices: { a: "upload", b: "upload" }, + error: "a: Permission denied", + }); + mocks.compare.mockRejectedValueOnce(new Error("Connection lost")); + await runSync([{ path: "a", action: "upload" }], "selected"); + expect(useDeployment.getState().sync).toMatchObject({ + running: false, + request: null, + choices: { a: "skip", b: "upload" }, + entries, + }); + expect(useDeployment.getState().sync?.error).toContain("Could not refresh comparison: a: Connection lost"); + }); }); diff --git a/src/features/deployment/actions.tsx b/src/features/deployment/actions.tsx index 9a9dbcfe1..2c558afbb 100644 --- a/src/features/deployment/actions.tsx +++ b/src/features/deployment/actions.tsx @@ -1,8 +1,18 @@ /** Deployment actions: upload / download / delete / sync / open the deployed URL. */ import { executeAction, registerActions } from "@/platform/actions"; +import { attachDocument, editorsFor } from "@/editor/api"; import { getContext } from "@/platform/context"; -import { call, commands, isCancelled, nextRequestId, type SyncAction, type TransferReport } from "@/platform/ipc"; -import { txTemplate, useTxTemplate } from "@/platform/i18n"; +import { + call, + commands, + isCancelled, + isTauri, + nextRequestId, + type TransferReport, + type SyncEntry, + type SyncStep, +} from "@/platform/ipc"; +import { tx, txTemplate, useTxTemplate } from "@/platform/i18n"; import { openExternalUrl } from "@/platform/openUrl"; import { relativePath, useProject } from "@/platform/project"; import type { AutoUploadValid, DeploymentServerValid } from "@/platform/remoteSettings"; @@ -14,6 +24,7 @@ import { initialSyncChoices, resolveServer, serverLabel, summarizeReport, target import { withSsh } from "./ssh"; import { REMOTE_HOST_WINDOW, patchSync, useDeployment } from "./store"; import styles from "./Deployment.module.css"; +import { showDeploymentComparison } from "./windowBridge"; const GROUP = "tools"; const CATEGORY = "Deployment"; @@ -90,8 +101,17 @@ async function pickServer(title: string, paths: string[]): Promise coverage.some((c) => c.server === server.id && c.paths.length)); if (!servers.length) { // One server: say why, as an explicit upload to it would. - if (configured.length === 1) notifyNoCoverage(configured[0], coverage[0]?.skipped); - else notifyNoCoverage(); + if (configured.length === 1) + notifyNoCoverage(configured[0], coverage.find((c) => c.server === configured[0]!.id)?.skipped); + else { + const skipped = configured.flatMap((server) => + (coverage.find((c) => c.server === server.id)?.skipped ?? []).map((error) => ({ + ...error, + message: `${serverLabel(server)}: ${error.message}`, + })), + ); + notifyNoCoverage(undefined, skipped); + } return null; } const labels = servers.map(serverLabel); @@ -250,20 +270,86 @@ export function closeSync() { useDeployment.setState({ sync: null }); } -export async function runSync(steps: { path: string; action: SyncAction }[]) { +export type SyncRunMode = "all" | "selected"; + +export async function runSync(steps: SyncStep[], mode: SyncRunMode = "all") { const sync = useDeployment.getState().sync; const server = sync && deployment().servers.find((s) => s.id === sync.server); - if (!sync || !server) return; + if (!sync || !server || !steps.length) return; const request = nextRequestId(); - patchSync({ running: true, request }); + patchSync({ running: true, request, error: null }); try { + // The embedded diff can edit the local side. Flush those live documents + // before uploading, so the server receives the content the user reviewed. + const root = projectRoot(); + for (const step of steps) { + if (step.action !== "upload" || !root) continue; + const path = `${root.replace(/\/$/, "")}/${step.path}`; + if (!editorsFor(path).length) continue; + const document = await attachDocument(path); + try { + await document.save(); + } finally { + document.release(); + } + } + if (useDeployment.getState().sync?.request !== request) return; const report = await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => call(commands.deploySync(steps, server.id, request)), ); - useDeployment.setState({ sync: null }); - report.skipped += sync.skipped.length; + if (useDeployment.getState().sync?.request !== request) return; + if (mode === "selected" && root) { + const refreshed = new Map(); + const checked = new Set(); + const refreshErrors: string[] = []; + for (const step of steps) { + if (useDeployment.getState().sync?.request !== request) return; + try { + const entries = await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => + call(commands.deployCompare(`${root.replace(/\/$/, "")}/${step.path}`, server.id, request)), + ); + checked.add(step.path); + const entry = entries.find((entry) => entry.path === step.path); + if (entry) refreshed.set(step.path, entry); + } catch (error) { + if (isCancelled(error)) throw error; + refreshErrors.push(`${step.path}: ${errorText(error)}`); + } + } + const current = useDeployment.getState().sync; + if (current?.request !== request) return; + const failed = new Set(report.errors.map((error) => error.path)); + // A skipped or interrupted batch does not identify every completed file. + const completed = report.skipped === 0 && report.files === steps.length - failed.size; + const choices = { ...current.choices }; + if (completed) for (const step of steps) if (!failed.has(step.path)) choices[step.path] = "skip"; + const entries = + current.entries?.flatMap((entry) => { + if (!checked.has(entry.path)) return [entry]; + const updated = refreshed.get(entry.path); + if (updated) return [updated]; + delete choices[entry.path]; + return []; + }) ?? null; + patchSync({ + entries, + choices, + running: false, + request: null, + error: + [ + ...report.errors.map((error) => `${error.path}: ${error.message}`), + ...refreshErrors.map((error) => tx`Could not refresh comparison: ${error}`), + ].join("\n") || null, + }); + } else { + useDeployment.setState({ sync: null }); + report.skipped += sync.skipped.length; + } showReport(report, "sync", server); + return report; } catch (e) { + if (useDeployment.getState().sync?.request !== request) return; if (isCancelled(e)) patchSync({ running: false, request: null }); else patchSync({ running: false, request: null, error: errorText(e) }); } @@ -367,7 +453,15 @@ export function registerDeploymentActions(): () => void { const target = paths[0] ?? projectRoot(); if (!target) return; const targets = paths.length ? paths : [target]; - void pickServer("Sync with Deployed to", targets).then((server) => server && openSync(server, targets)); + void pickServer("Sync with Deployed to", targets).then(async (server) => { + if (!server) return; + if (!isTauri()) return openSync(server, targets); + try { + await showDeploymentComparison(server.id, targets); + } catch (error) { + showFailure("Cannot open comparison for {server}", server, error); + } + }); }, }, { diff --git a/src/features/deployment/comparison.test.ts b/src/features/deployment/comparison.test.ts new file mode 100644 index 000000000..6df84c506 --- /dev/null +++ b/src/features/deployment/comparison.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { SyncEntry } from "@/platform/ipc"; +import { DEFAULT_DEPLOYMENT_SERVER } from "@/platform/remoteSettings"; + +const mocks = vi.hoisted(() => ({ remote: vi.fn(), probe: vi.fn(), text: vi.fn(), range: vi.fn() })); +vi.mock("@/platform/ipc", () => ({ + call: (value: unknown) => value, + commands: { remoteOpen: mocks.remote, fsProbe: mocks.probe, fsReadText: mocks.text, fsReadRange: mocks.range }, +})); +vi.mock("./ssh", () => ({ withSsh: (_: unknown, run: () => unknown) => run() })); +import { createDeploymentComparison } from "./comparison"; + +const server = { ...DEFAULT_DEPLOYMENT_SERVER, id: "s1", name: "Staging", sshConfig: "ssh" }; +const entry = (patch: Partial = {}): SyncEntry => ({ + path: "src/a.php", + remotePath: "/srv/app/src/a.php", + status: "same", + local: { size: 4, mtime: 1 }, + remote: { size: 4, mtime: 1 }, + ...patch, +}); +const signal = () => new AbortController().signal; +beforeEach(() => { + vi.resetAllMocks(); + mocks.remote.mockResolvedValue("/cache/a.php"); + mocks.probe.mockResolvedValue({ size: 4, binary: false, tooLarge: false }); + mocks.text.mockResolvedValue("old\n"); + mocks.range.mockResolvedValue({ total: 4, data: "b2xkCg==" }); +}); + +describe("deployment content snapshots", () => { + it("keeps the local document live and the remote side immutable, sharing one downloaded snapshot", async () => { + const compare = createDeploymentComparison(server, "/work/app/"); + expect(await compare.preview(entry(), signal())).toMatchObject({ + kind: "text", + data: { + path: "/work/app/src/a.php", + left: { type: "file", path: "/work/app/src/a.php" }, + right: { type: "text", text: "old\n", label: "Staging: /srv/app/src/a.php" }, + }, + }); + expect(mocks.remote).toHaveBeenCalledWith({ kind: "sftp", ssh: "ssh" }, "/srv/app/src/a.php"); + expect(mocks.text).toHaveBeenCalledWith("/cache/a.php"); + await compare.equal(entry(), signal()); + expect(mocks.remote).toHaveBeenCalledTimes(1); + }); + it("represents missing sides without reading missing files", async () => { + const compare = createDeploymentComparison(server, "/work/app"); + expect(await compare.preview(entry({ remote: null }), signal())).toMatchObject({ + data: { right: { type: "text", text: "", label: expect.stringContaining("does not exist") } }, + }); + expect(mocks.remote).not.toHaveBeenCalled(); + expect(mocks.text).not.toHaveBeenCalled(); + mocks.probe.mockClear(); + expect(await compare.preview(entry({ local: null }), signal())).toMatchObject({ + data: { left: { type: "text", text: "" }, right: { text: "old\n" } }, + }); + expect(mocks.probe).toHaveBeenCalledExactlyOnceWith("/cache/a.php"); + }); + it("does not decode binaries or download oversized text previews", async () => { + const compare = createDeploymentComparison(server, "/work/app"); + expect(await compare.preview(entry({ local: { size: 20_000_000, mtime: 1 } }), signal())).toEqual({ + kind: "tooLarge", + }); + expect(mocks.remote).not.toHaveBeenCalled(); + mocks.probe.mockResolvedValue({ size: 4, binary: true }); + expect(await compare.preview(entry(), signal())).toEqual({ kind: "binary" }); + expect(mocks.text).not.toHaveBeenCalled(); + }); + it("compares bytes even when size and timestamps match", async () => { + const compare = createDeploymentComparison(server, "/work/app"); + mocks.range.mockImplementation(async (path: string) => ({ + total: 4, + data: path.startsWith("/work") ? "bmV3Cg==" : "b2xkCg==", + })); + expect(await compare.equal(entry(), signal())).toBe(false); + mocks.range.mockResolvedValue({ total: 4, data: "b2xkCg==" }); + expect(await compare.equal(entry({ remote: { size: 4, mtime: 99 } }), signal())).toBe(true); + }); + it("uses bounded reads and stops at the first differing block", async () => { + const compare = createDeploymentComparison(server, "/work/app"); + const large = entry({ local: { size: 150_000, mtime: 1 }, remote: { size: 150_000, mtime: 1 } }); + mocks.range.mockImplementation(async (path: string, offset: number) => ({ + total: 150_000, + data: offset >= 65_536 ? path : "same", + })); + expect(await compare.equal(large, signal())).toBe(false); + expect(mocks.range).toHaveBeenCalledTimes(4); + expect(mocks.range.mock.calls.every(([, , len]) => len === 65_536)).toBe(true); + }); + it("detects changed sizes during comparison and handles empty files", async () => { + const compare = createDeploymentComparison(server, "/work/app"); + mocks.range.mockResolvedValue({ total: 5, data: "changed" }); + await expect(compare.equal(entry(), signal())).rejects.toThrow("changed during comparison"); + mocks.range.mockResolvedValue({ total: 0, data: "" }); + expect(await compare.equal(entry({ local: { size: 0, mtime: 1 }, remote: { size: 0, mtime: 1 } }), signal())).toBe( + true, + ); + mocks.remote.mockClear(); + expect(await compare.equal(entry({ remote: { size: 8, mtime: 1 } }), signal())).toBe(false); + expect(mocks.remote).not.toHaveBeenCalled(); + }); + it("does not continue after selection is cancelled and retries failed downloads", async () => { + const compare = createDeploymentComparison(server, "/work/app"); + const abort = new AbortController(); + mocks.remote.mockImplementationOnce(async () => { + abort.abort(); + return "/cache/a.php"; + }); + await expect(compare.preview(entry(), abort.signal)).rejects.toMatchObject({ name: "AbortError" }); + expect(mocks.text).not.toHaveBeenCalled(); + const retry = createDeploymentComparison(server, "/work/app"); + mocks.remote.mockRejectedValueOnce(new Error("Offline")); + await expect(retry.preview(entry(), signal())).rejects.toThrow("Offline"); + await expect(retry.preview(entry(), signal())).resolves.toMatchObject({ kind: "text" }); + }); +}); diff --git a/src/features/deployment/comparison.ts b/src/features/deployment/comparison.ts new file mode 100644 index 000000000..67b71ce0d --- /dev/null +++ b/src/features/deployment/comparison.ts @@ -0,0 +1,91 @@ +/** Read-only remote snapshots for the deployment comparison; project files are never downloaded over. */ +import { call, commands, type SyncEntry } from "@/platform/ipc"; +import { tx } from "@/platform/i18n"; +import { deploymentConnection, type DeploymentServerValid } from "@/platform/remoteSettings"; +import { withSsh } from "./ssh"; + +const PREVIEW_LIMIT = 8 * 1024 * 1024; +const BLOCK_SIZE = 64 * 1024; + +/** The data of a `diff` editor input (the diff viewer's): the local file, editable, next to the server's text. */ +export interface DeploymentDiff { + path: string; + title: string; + left: { type: "file"; path: string; label: string } | { type: "text"; text: string; label: string }; + right: { type: "text"; text: string; label: string }; +} + +export type DeploymentPreview = { kind: "text"; data: DeploymentDiff } | { kind: "binary" } | { kind: "tooLarge" }; + +export function createDeploymentComparison(server: DeploymentServerValid, root: string) { + // Only the downloaded path is cached, never a live remote document. The diff + // receives immutable text, so its merge controls cannot save to the server. + const snapshots = new Map>(); + const localPath = (entry: SyncEntry) => `${root.replace(/\/$/, "")}/${entry.path}`; + const remoteSnapshot = (entry: SyncEntry) => { + let pending = snapshots.get(entry.remotePath); + if (!pending) { + pending = withSsh(server.kind === "sftp" ? server.sshConfig : "", () => + call(commands.remoteOpen(deploymentConnection(server), entry.remotePath)), + ); + snapshots.set(entry.remotePath, pending); + void pending.catch(() => snapshots.delete(entry.remotePath)); + } + return pending; + }; + + return { + async preview(entry: SyncEntry, signal: AbortSignal): Promise { + signal.throwIfAborted(); + if (Math.max(entry.local?.size ?? 0, entry.remote?.size ?? 0) > PREVIEW_LIMIT) return { kind: "tooLarge" }; + const remote = entry.remote ? await remoteSnapshot(entry) : null; + signal.throwIfAborted(); + const probes = await Promise.all( + [entry.local ? localPath(entry) : null, remote].map((path) => (path ? call(commands.fsProbe(path)) : null)), + ); + signal.throwIfAborted(); + if (probes.some((p) => p?.binary)) return { kind: "binary" }; + if (probes.some((p) => p?.tooLarge || (p?.size ?? 0) > PREVIEW_LIMIT)) return { kind: "tooLarge" }; + const text = remote ? await call(commands.fsReadText(remote)) : ""; + signal.throwIfAborted(); + return { + kind: "text", + data: { + path: localPath(entry), + title: entry.path, + left: entry.local + ? { type: "file", path: localPath(entry), label: tx`Local: ${entry.path}` } + : { type: "text", text: "", label: tx("Local: (does not exist)") }, + right: { + type: "text", + text, + label: `${server.name || server.id}: ${entry.remote ? entry.remotePath : tx("(does not exist)")}`, + }, + }, + }; + }, + + /** Exact byte comparison, including binary files and line endings, in bounded reads. */ + async equal(entry: SyncEntry, signal: AbortSignal): Promise { + signal.throwIfAborted(); + if (!entry.local || !entry.remote || entry.local.size !== entry.remote.size) return false; + const remote = await remoteSnapshot(entry); + signal.throwIfAborted(); + // Read once for empty files too, to detect files changed since the listing. + for (let offset = 0; offset === 0 || offset < entry.local.size; offset += BLOCK_SIZE) { + signal.throwIfAborted(); + const [a, b] = await Promise.all( + [localPath(entry), remote].map((path) => call(commands.fsReadRange(path, offset, BLOCK_SIZE))), + ); + signal.throwIfAborted(); + if (a!.total !== entry.local.size || b!.total !== entry.remote.size) + throw new Error("A file changed during comparison. Refresh and compare again."); + if (a!.data !== b!.data) return false; + if (entry.local.size === 0) break; + } + return true; + }, + }; +} + +export type DeploymentComparison = ReturnType; diff --git a/src/features/deployment/index.ts b/src/features/deployment/index.ts index 314114393..e45ef8f32 100644 --- a/src/features/deployment/index.ts +++ b/src/features/deployment/index.ts @@ -15,6 +15,9 @@ import { autoUploadOn, registerDeploymentActions } from "./actions"; import { DeploymentDialogs } from "./Dialogs"; import { transferMenuEntries } from "./model"; import { REMOTE_HOST_WINDOW, useDeployment } from "./store"; +import { installDeploymentEditorHost } from "./windowBridge"; + +export { activateDeploymentWindow, DeploymentWindow } from "./window"; /** Deployed URL of the path the last menu was prepared for. */ let webUrlFor: { path: string; url: string | null } | null = null; @@ -70,6 +73,7 @@ export function activate(): () => void { unsubFocus, unsubProject, registerDeploymentActions(), + installDeploymentEditorHost(), registerToolWindow({ id: REMOTE_HOST_WINDOW, title: "Remote Host", diff --git a/src/features/deployment/window.test.ts b/src/features/deployment/window.test.ts new file mode 100644 index 000000000..fa3143533 --- /dev/null +++ b/src/features/deployment/window.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { useProject } from "@/platform/project"; +import { useDeployment } from "./store"; +import { DEFAULT_DEPLOYMENT_SERVER } from "@/platform/remoteSettings"; + +const mocks = vi.hoisted(() => ({ + launch: vi.fn(), + project: vi.fn(), + open: vi.fn(), + cancel: vi.fn(), + ask: vi.fn(), + error: vi.fn(), + dirty: vi.fn(), + save: vi.fn(), + dispose: vi.fn(), + destroy: vi.fn(), + close: vi.fn(), + title: vi.fn(), + emit: vi.fn(async () => {}), + closeRequested: null as null | ((event: { preventDefault(): void }) => Promise), +})); +vi.mock("@tauri-apps/api/webviewWindow", () => ({ + getCurrentWebviewWindow: () => ({ + label: "deployment-project-2", + listen: async () => () => {}, + onCloseRequested: async (callback: typeof mocks.closeRequested) => { + mocks.closeRequested = callback; + return () => {}; + }, + setTitle: mocks.title, + close: mocks.close, + destroy: mocks.destroy, + }), +})); +vi.mock("@/platform/ipc", () => ({ + isTauri: () => true, + commands: { windowTakeLaunch: mocks.launch, projectCurrent: mocks.project }, +})); +vi.mock("@tauri-apps/api/event", () => ({ emitTo: mocks.emit })); +vi.mock("@/editor/core/embeddedDocuments", () => ({ + activateEmbeddedDocuments: () => ({ dirty: mocks.dirty, save: mocks.save, dispose: mocks.dispose }), +})); +vi.mock("@/workbench/messages", () => ({ ask: mocks.ask, showError: mocks.error })); +vi.mock("./actions", () => ({ openSync: mocks.open, closeSync: mocks.cancel })); +vi.mock("./windowBridge", () => ({ openDeploymentSource: vi.fn() })); +vi.mock("./Dialogs", () => ({ SecretDialog: () => null })); +vi.mock("./SyncDialog", () => ({ SyncDialog: () => null })); +vi.mock("@/workbench/OverlayHost", () => ({ OverlayHost: () => null })); +vi.mock("@/workbench/TitleBar", () => ({ TitleBar: () => null })); + +import { activateDeploymentWindow } from "./window"; + +let stop: (() => void) | undefined; +beforeEach(() => { + vi.clearAllMocks(); + mocks.launch.mockResolvedValue(null); + mocks.dirty.mockReturnValue(false); + mocks.save.mockResolvedValue(undefined); + mocks.dispose.mockResolvedValue(undefined); + mocks.ask.mockResolvedValue({ button: "cancel" }); + useProject.setState({ project: null, settings: { files: { autoSave: false } } as never }); + useDeployment.setState({ sync: null }); +}); +afterEach(() => { + stop?.(); + stop = undefined; +}); + +it("loads the parent project before comparing the launched server and local selection", async () => { + const server = { ...DEFAULT_DEPLOYMENT_SERVER, id: "s2", name: "Second project" }; + const project = { root: "/second", settings: { deployment: { servers: [server] } } }; + mocks.project.mockResolvedValue(project); + mocks.launch.mockResolvedValueOnce({ deployment: { server: "s2", paths: ["/second/a.php"] } }); + mocks.open.mockImplementation(async () => useDeployment.setState({ sync: { server: "s2" } as never })); + stop = activateDeploymentWindow("project-2"); + await vi.waitFor(() => + expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ id: "s2" }), ["/second/a.php"]), + ); + expect(useProject.getState().project).toBe(project); +}); + +it("keeps the window and unsaved local edits when closing is cancelled or saving fails", async () => { + mocks.dirty.mockReturnValue(true); + stop = activateDeploymentWindow("project-2"); + const event = { preventDefault: vi.fn() }; + await mocks.closeRequested!(event); + expect(event.preventDefault).toHaveBeenCalled(); + expect(mocks.save).not.toHaveBeenCalled(); + expect(mocks.destroy).not.toHaveBeenCalled(); + // The project window waiting to close stops. + expect(mocks.emit).toHaveBeenCalledWith( + "project-2", + "openidea://auxiliary/closed", + expect.objectContaining({ label: "deployment-project-2", closed: false }), + ); + mocks.ask.mockResolvedValue({ button: "save" }); + mocks.save.mockRejectedValueOnce(new Error("Disk full")); + await mocks.closeRequested!(event); + expect(mocks.error).toHaveBeenCalledWith("Cannot close deployment comparison", "Disk full"); + expect(mocks.dispose).not.toHaveBeenCalled(); + expect(mocks.destroy).not.toHaveBeenCalled(); + await mocks.closeRequested!(event); + expect(mocks.cancel).toHaveBeenCalledOnce(); + expect(mocks.dispose).toHaveBeenCalledOnce(); + expect(mocks.destroy).toHaveBeenCalledOnce(); + expect(mocks.save.mock.invocationCallOrder.at(-1)).toBeLessThan(mocks.destroy.mock.invocationCallOrder[0]!); +}); diff --git a/src/features/deployment/window.tsx b/src/features/deployment/window.tsx new file mode 100644 index 000000000..076febfad --- /dev/null +++ b/src/features/deployment/window.tsx @@ -0,0 +1,187 @@ +/** + * The Sync with Deployed window (`deployment-`): one per project window, sharing that + * project's documents. + */ +import { useSyncExternalStore } from "react"; +import { emitTo } from "@tauri-apps/api/event"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; +import { activateEmbeddedDocuments } from "@/editor/core/embeddedDocuments"; +import { AUX_CLOSED, type AuxCloseResult } from "@/platform/auxWindow"; +import { commands, type SyncStep } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { workflowSettings } from "@/platform/workflowSettings"; +import { isImeKey } from "@/platform/ime"; +import { useApplyTheme } from "@/workbench/theme"; +import { OverlayHost } from "@/workbench/OverlayHost"; +import { TitleBar } from "@/workbench/TitleBar"; +import { usePlatform } from "@/platform/platformInfo"; +import { WindowResizeHandles } from "@/workbench/WindowResizeHandles"; +import { provideEditorService } from "@/workbench/contributions"; +import { ask, showError } from "@/workbench/messages"; +import { Spinner, T, isPopupOpen } from "@/ui"; +import { closeSync, openSync } from "./actions"; +import { SecretDialog } from "./Dialogs"; +import { SyncDialog } from "./SyncDialog"; +import { patchSync, useDeployment } from "./store"; +import styles from "./Deployment.module.css"; +import { openDeploymentSource } from "./windowBridge"; + +const LAUNCH_EVENT = "openidea://launch"; + +let startupError: string | null = null; +const startupListeners = new Set<() => void>(); +let saveSyncDocuments: ((steps: SyncStep[]) => Promise) | undefined; +const subscribeStartup = (listener: () => void) => { + startupListeners.add(listener); + return () => { + startupListeners.delete(listener); + }; +}; +function fail(error: unknown) { + if (useDeployment.getState().sync) { + patchSync({ error: error instanceof Error ? error.message : String(error) }); + return; + } + startupError = error instanceof Error ? error.message : String(error); + startupListeners.forEach((listener) => listener()); +} + +export function activateDeploymentWindow(parent: string): () => void { + const native = getCurrentWebviewWindow(); + const documents = activateEmbeddedDocuments(); + provideEditorService({ + openFile: (path, options) => openDeploymentSource(parent, path, options), + openInput: (input, options) => (input.path ? openDeploymentSource(parent, input.path, options) : Promise.resolve()), + activePath: () => null, + activeCursor: () => null, + saveAll: documents.save, + }); + saveSyncDocuments = (steps) => { + const root = useProject.getState().project?.root; + return root + ? documents.savePaths(steps.map((step) => `${root.replace(/\/$/, "")}/${step.path}`)) + : Promise.resolve(); + }; + let disposed = false; + let closing = false; + let queue = Promise.resolve(); + const disposers: Array<() => void> = []; + // Tells the project window this window stays open, so it stops closing (see `closeDocumentWindows`). + const stayOpen = () => { + const result: AuxCloseResult = { label: native.label, closed: false, documents: [] }; + void emitTo(parent, AUX_CLOSED, result).catch(() => {}); + }; + const listen = (ready: Promise<() => void>) => void ready.then((off) => (disposed ? off() : disposers.push(off))); + const launch = () => { + queue = queue + .then(async () => { + if (disposed || closing) return; + const pending = await commands.windowTakeLaunch(); + if (!pending?.deployment) return; + if (useDeployment.getState().sync?.running) { + await showError( + "Deployment comparison", + "Wait for synchronization to finish before opening another comparison.", + ); + return; + } + const project = await commands.projectCurrent(); + if (!project) throw new Error("The project is no longer open."); + useProject.setState({ project, settings: project.settings }); + const server = workflowSettings().deployment.servers.find((item) => item.id === pending.deployment!.server); + if (!server) throw new Error("The deployment server is no longer available."); + await native.setTitle(`Diff Between 'local files' and 'files on ${server.name || server.id}'`); + await openSync(server, pending.deployment.paths); + if (!useDeployment.getState().sync && !closing) + throw new Error("No selected files are mapped to this deployment server."); + }) + .catch(fail); + }; + listen( + native.listen(LAUNCH_EVENT, launch).then((off) => { + launch(); + return off; + }), + ); + listen( + native.onCloseRequested(async (event) => { + event.preventDefault(); + if (closing) return; + closing = true; + try { + if (documents.dirty() && useProject.getState().settings?.files?.autoSave === false) { + const answer = await ask({ + title: "Save local changes?", + body: "Save the local files edited in this comparison before closing.", + buttons: [{ id: "save", label: "Save and Close", primary: true }], + }); + if (answer.button !== "save") return stayOpen(); + } + await documents.save(); + closeSync(); + useDeployment.getState().secret?.resolve(null); + await documents.dispose(); + await native.destroy(); + } catch (error) { + stayOpen(); + await showError("Cannot close deployment comparison", error instanceof Error ? error.message : String(error)); + } finally { + closing = false; + } + }), + ); + disposers.push( + useDeployment.subscribe((s, prev) => { + if (prev.sync && !s.sync && !closing) void native.close(); + }), + ); + const onKey = (event: KeyboardEvent) => { + if (event.defaultPrevented || isImeKey(event)) return; + if ( + (event.key === "Escape" && !isPopupOpen()) || + ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "w") + ) { + event.preventDefault(); + void native.close(); + } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") { + event.preventDefault(); + void documents.save().catch(fail); + } + }; + window.addEventListener("keydown", onKey); + disposers.push(() => window.removeEventListener("keydown", onKey)); + return () => { + disposed = true; + saveSyncDocuments = undefined; + disposers.reverse().forEach((off) => off()); + }; +} + +export function DeploymentWindow() { + useApplyTheme(); + const sync = useDeployment((s) => s.sync); + const error = useSyncExternalStore(subscribeStartup, () => startupError); + const ownTitleBar = usePlatform((s) => s.overlayTitleBar || s.framelessTitleBar); + return ( + <> + {sync ? ( + saveSyncDocuments?.(steps) ?? Promise.resolve()} /> + ) : ( +
+ {ownTitleBar && } +
+ {error || ( + <> + + Opening deployment comparison… + + )} +
+
+ )} + + + + + ); +} diff --git a/src/features/deployment/windowBridge.ts b/src/features/deployment/windowBridge.ts new file mode 100644 index 000000000..9dab0cde6 --- /dev/null +++ b/src/features/deployment/windowBridge.ts @@ -0,0 +1,40 @@ +import { emitTo } from "@tauri-apps/api/event"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; +import { call, commands, isTauri } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { editorService, type OpenOptions } from "@/workbench/contributions"; +import { windowBackgroundHex } from "@/workbench/theme"; + +const OPEN_SOURCE = "openidea://deployment/open-source"; + +export function showDeploymentComparison(server: string, paths: string[]) { + return call(commands.windowOpenDeployment(server, paths, windowBackgroundHex())); +} + +export function openDeploymentSource(parent: string, path: string, options?: OpenOptions) { + return emitTo(parent, OPEN_SOURCE, { path, options }); +} + +/** Jump to Source from the comparison opens the local file in its project's editor. */ +export function installDeploymentEditorHost(): () => void { + if (!isTauri()) return () => {}; + let disposed = false; + let unlisten: (() => void) | undefined; + void getCurrentWebviewWindow() + .listen<{ path: string; options?: OpenOptions }>(OPEN_SOURCE, ({ payload }) => { + const root = useProject.getState().project?.root; + if (root && payload.path.startsWith(`${root}/`)) { + void editorService() + .openFile(payload.path, payload.options) + .then(() => getCurrentWebviewWindow().setFocus()); + } + }) + .then((off) => { + if (disposed) off(); + else unlisten = off; + }); + return () => { + disposed = true; + unlisten?.(); + }; +} diff --git a/src/features/index.ts b/src/features/index.ts index 24df08c5f..ec4fa3c7f 100644 --- a/src/features/index.ts +++ b/src/features/index.ts @@ -12,7 +12,12 @@ import { activate as projectTree } from "./project-tree"; import { activate as search } from "./search"; import { activate as dependencies } from "./dependencies"; import { activate as codeInsight } from "./code-insight"; -import { activate as vcs, activateComparisonWindow, activateSettingsPages as vcsPages } from "./vcs"; +import { + activate as vcs, + activateComparisonWindow, + activateDiffViewer, + activateSettingsPages as vcsPages, +} from "./vcs"; import { activate as terminal } from "./terminal"; import { activate as run } from "./run"; import { activate as runAnything } from "./run-anything"; @@ -39,7 +44,7 @@ import { activate as services } from "./services"; import { activate as github } from "./github"; import { activate as httpClient } from "./http-client"; import { activate as database } from "./database"; -import { activate as deployment } from "./deployment"; +import { activate as deployment, activateDeploymentWindow } from "./deployment"; import { activate as agents, activateSettingsPages as agentsPages } from "./agents"; import { activate as vim, activateSettingsPages as vimPages } from "./vim"; import { activate as ssh } from "./ssh"; @@ -109,6 +114,7 @@ export function activateFeatures(): () => void { export { SettingsWindow } from "./settings"; export { ComparisonWindow } from "./vcs"; +export { DeploymentWindow } from "./deployment"; /** * A comparison window of project window `parent`: the changed files between two revisions and @@ -125,6 +131,19 @@ export function activateComparisonWindowFeatures(parent: string): () => void { ]); } +/** + * The Sync with Deployed window of project window `parent`: local files next to their copies on a + * deployment server, with the diff viewer but no workbench. + */ +export function activateDeploymentWindowFeatures(parent: string): () => void { + return activateAll("deployment window", [ + ["locale", appLocale], + ["settings", activateRuntimeSettings], + ["diff", activateDiffViewer], + ["deployment", () => activateDeploymentWindow(parent)], + ]); +} + /** * The native Settings window of project window `parent`: only the settings * pages (no workbench, project, editors or background services). diff --git a/src/features/vcs/DiffEditor.tsx b/src/features/vcs/DiffEditor.tsx index b00dd6e70..b1b83e2d0 100644 --- a/src/features/vcs/DiffEditor.tsx +++ b/src/features/vcs/DiffEditor.tsx @@ -217,7 +217,10 @@ export function DiffEditor({ input, toolbarActions }: { input: EditorInput; tool /** Text of the sides that are not live documents. */ const [statics, setStatics] = useState<{ a: string | null; b: string | null } | null>(null); const [error, setError] = useState(null); - const [opts, setOptsState] = useState(loadDiffOptions); + const [opts, setOptsState] = useState(() => { + const saved = loadDiffOptions(); + return data.mode ? { ...saved, mode: data.mode } : saved; + }); const [lang, setLang] = useState(null); const [chunkInfo, setChunkInfo] = useState({ count: 0, current: -1, moves: 0, whitespaceOnly: false }); const [busy, setBusy] = useState(false); diff --git a/src/features/vcs/__tests__/diffToolbar.test.tsx b/src/features/vcs/__tests__/diffToolbar.test.tsx index e7f1e33d4..bdac8d2e6 100644 --- a/src/features/vcs/__tests__/diffToolbar.test.tsx +++ b/src/features/vcs/__tests__/diffToolbar.test.tsx @@ -71,6 +71,14 @@ beforeEach(() => { }); describe("Diff toolbar", () => { + it("can start an embedded comparison side by side without overwriting saved viewer preferences", async () => { + localStorage.setItem("openidea.vcs.diffOptions", JSON.stringify({ mode: "unified" })); + const mounted = render(); + await waitFor(() => expect(mounted.container.querySelectorAll(".cm-editor")).toHaveLength(2)); + expect(JSON.parse(localStorage.getItem("openidea.vcs.diffOptions")!).mode).toBe("unified"); + mounted.unmount(); + localStorage.removeItem("openidea.vcs.diffOptions"); + }); it("shows a binary notice for disk comparisons instead of identical empty text", async () => { probe.mockResolvedValue({ status: "ok", data: { size: 3, binary: true, tooLarge: false } }); const binaryInput = { diff --git a/src/features/vcs/diff-editor-tab.tsx b/src/features/vcs/diff-editor-tab.tsx index 12f19afb8..5f9779a8e 100644 --- a/src/features/vcs/diff-editor-tab.tsx +++ b/src/features/vcs/diff-editor-tab.tsx @@ -3,9 +3,11 @@ import { hideAllToolWindows, useToolWindowLayout } from "@/workbench/toolwindows import { shortcutLabel } from "@/platform/keymap"; import { IconButton } from "@/ui"; import { DiffEditor } from "./DiffEditor"; +import type { DiffData } from "./diffInput"; export function DiffEditorTab({ input }: { input: EditorInput }) { const maximized = useToolWindowLayout((state) => !!state.stashed && !Object.values(state.visible).some(Boolean)); + if ((input.data as DiffData).embedded) return ; return ( void { + return registerEditorKind({ + kind: DIFF_KIND, + component: lazyComponent(() => import("./diff-editor-tab").then((m) => m.DiffEditorTab)), + title: (input) => diffTitle(input.data as DiffData), + icon: () => "diff", + }); +} + export function activateSettingsPages(): () => void { return registerSettingsPage({ id: "vcs.log", @@ -230,12 +240,7 @@ export function activate(): () => void { component: lazyComponent(() => import("./GitLog").then((m) => m.GitToolWindow)), headerContent: lazyComponent(() => import("./GitLog").then((m) => m.GitTabs)), }), - registerEditorKind({ - kind: DIFF_KIND, - component: lazyComponent(() => import("./diff-editor-tab").then((m) => m.DiffEditorTab)), - title: (input) => diffTitle(input.data as DiffData), - icon: () => "diff", - }), + activateDiffViewer(), registerEditorKind({ kind: MERGE_KIND, component: lazyComponent(() => import("./MergeEditor").then((m) => m.MergeEditor)), diff --git a/src/main.tsx b/src/main.tsx index cb709b39a..aa0490b42 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,9 +5,11 @@ import { Workbench } from "./workbench/Workbench"; import { activateSecondaryWindow, activateWorkbench } from "./workbench/activate"; import { activateComparisonWindowFeatures, + activateDeploymentWindowFeatures, activateFeatures, activateSettingsWindowFeatures, ComparisonWindow, + DeploymentWindow, SettingsWindow, } from "./features"; import { auxiliaryWindow } from "./platform/auxWindow"; @@ -28,7 +30,8 @@ if (import.meta.env.DEV && isTauri()) { installRepeatedDoubleClick(); installErrorLogging(); -// Auxiliary windows (Settings `settings-`, comparisons `comparison--`) load this page too. +// Auxiliary windows (Settings `settings-`, comparisons `comparison--`, Sync with +// Deployed `deployment-`) load this page too. const aux = auxiliaryWindow(); if (aux?.kind === "settings") { @@ -38,6 +41,9 @@ if (aux?.kind === "settings") { activateSecondaryWindow(); activateComparisonWindowFeatures(aux.parent); installKeyboardDispatcher(); +} else if (aux?.kind === "deployment") { + activateSecondaryWindow(); + activateDeploymentWindowFeatures(aux.parent); } else { activateWorkbench(); // `vite build --mode perf` is a production bundle for the performance harness (`perf/`). @@ -58,7 +64,15 @@ if (aux?.kind === "settings") { ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - {aux?.kind === "settings" ? : aux?.kind === "comparison" ? : } + {aux?.kind === "settings" ? ( + + ) : aux?.kind === "comparison" ? ( + + ) : aux?.kind === "deployment" ? ( + + ) : ( + + )} , ); hideSplash(); diff --git a/src/platform/auxWindow.test.ts b/src/platform/auxWindow.test.ts index 273566643..1eca833f2 100644 --- a/src/platform/auxWindow.test.ts +++ b/src/platform/auxWindow.test.ts @@ -7,6 +7,7 @@ describe("auxiliaryWindow", () => { expect(auxiliaryWindow("settings-project-2")).toEqual({ kind: "settings", parent: "project-2" }); expect(auxiliaryWindow("comparison-main-0")).toEqual({ kind: "comparison", parent: "main" }); expect(auxiliaryWindow("comparison-project-2-14")).toEqual({ kind: "comparison", parent: "project-2" }); + expect(auxiliaryWindow("deployment-project-2")).toEqual({ kind: "deployment", parent: "project-2" }); }); it("leaves project windows and malformed labels alone", () => { diff --git a/src/platform/auxWindow.ts b/src/platform/auxWindow.ts index 24a5b1f1e..15d95c3b5 100644 --- a/src/platform/auxWindow.ts +++ b/src/platform/auxWindow.ts @@ -1,5 +1,5 @@ /** - * Auxiliary windows: Settings and comparison windows that belong + * Auxiliary windows: Settings, comparison and deployment windows that belong * to a project window (their parent) and share its project. Labels name the * kind and the parent (`settings-main`, `comparison-project-2-3`), mirroring * `windows::auxiliary` in src-tauri/src/windows.rs. @@ -8,11 +8,11 @@ import { getAllWindows, getCurrentWindow } from "@tauri-apps/api/window"; import { TauriEvent } from "@tauri-apps/api/event"; import { isTauri } from "./ipc"; -export type AuxKind = "settings" | "comparison"; +export type AuxKind = "settings" | "comparison" | "deployment"; /** Kinds with several windows per parent (`--`); the others have one (`-`). */ const MULTIPLE: ReadonlySet = new Set(["comparison"]); -const KINDS: readonly AuxKind[] = ["settings", "comparison"]; +const KINDS: readonly AuxKind[] = ["settings", "comparison", "deployment"]; /** An auxiliary window reports to its parent whether it closed, and which documents it released. */ export const AUX_CLOSED = "openidea://auxiliary/closed"; diff --git a/src/platform/locales/actionTitles.test.ts b/src/platform/locales/actionTitles.test.ts index 18274c970..2af3d22f4 100644 --- a/src/platform/locales/actionTitles.test.ts +++ b/src/platform/locales/actionTitles.test.ts @@ -4,7 +4,7 @@ import ts from "typescript"; import { expect, it } from "vitest"; import zh from "./text/zh.json"; -const root = fileURLToPath(new URL("../../../", import.meta.url)); +const root = fileURLToPath(new URL("../../../", import.meta.url)).replaceAll("\\", "/"); const config = ts.readConfigFile(`${root}tsconfig.json`, ts.sys.readFile); const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, root); const program = ts.createProgram(parsed.fileNames, parsed.options); diff --git a/src/platform/locales/text/fr.json b/src/platform/locales/text/fr.json index 64a26e97c..2cc108f62 100644 --- a/src/platform/locales/text/fr.json +++ b/src/platform/locales/text/fr.json @@ -4284,24 +4284,15 @@ "Delete {n} items from the server?": "Supprimer {n} éléments du serveur ?", "Not mapped to a project path": "Non associé à un chemin du projet", "Unknown Host Key": "Clé d'hôte inconnue", - "Sync with {server}": "Synchroniser avec {server}", - "Hide identical files ({n})": "Masquer les fichiers identiques ({n})", - "Synchronize": "Synchroniser", "{n} of {total} files will change": "{n} fichiers sur {total} seront modifiés", "Comparing local and remote files…": "Comparaison des fichiers locaux et distants…", "Files to synchronize": "Fichiers à synchroniser", "Action": "Action", - "Local size": "Taille locale", - "Local modified": "Modification locale", - "Remote size": "Taille distante", - "Remote modified": "Modification distante", - "Local and remote files are identical": "Les fichiers locaux et distants sont identiques", "Synchronizing…": "Synchronisation…", "Local only": "Local uniquement", "Remote only": "Distant uniquement", "Local is newer": "Le local est plus récent", "Remote is newer": "Le distant est plus récent", - "Different size": "Taille différente", "Identical": "Identique", "Upload": "Envoyer", "Delete remote": "Supprimer le distant", diff --git a/src/platform/locales/text/ko.json b/src/platform/locales/text/ko.json index 18d7826c5..e95c258ce 100644 --- a/src/platform/locales/text/ko.json +++ b/src/platform/locales/text/ko.json @@ -4395,24 +4395,15 @@ "Delete {n} items from the server?": "서버에서 항목 {n}개를 삭제하시겠습니까?", "Not mapped to a project path": "프로젝트 경로에 매핑되지 않음", "Unknown Host Key": "알 수 없는 호스트 키", - "Sync with {server}": "{server}와(과) 동기화", - "Hide identical files ({n})": "동일한 파일 숨기기({n})", - "Synchronize": "동기화", "{n} of {total} files will change": "파일 {total}개 중 {n}개가 변경됩니다", "Comparing local and remote files…": "로컬 파일과 원격 파일 비교 중…", "Files to synchronize": "동기화할 파일", "Action": "액션", - "Local size": "로컬 크기", - "Local modified": "로컬 수정 시간", - "Remote size": "원격 크기", - "Remote modified": "원격 수정 시간", - "Local and remote files are identical": "로컬 파일과 원격 파일이 동일합니다", "Synchronizing…": "동기화 중…", "Local only": "로컬에만 있음", "Remote only": "원격에만 있음", "Local is newer": "로컬이 더 최신임", "Remote is newer": "원격이 더 최신임", - "Different size": "크기가 다름", "Identical": "동일함", "Upload": "업로드", "Delete remote": "원격 삭제", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 4f4ff1edf..3ac83fd51 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4407,24 +4407,15 @@ "Delete {n} items from the server?": "要从服务器删除 {n} 个项目吗?", "Not mapped to a project path": "未映射到项目路径", "Unknown Host Key": "未知的主机密钥", - "Sync with {server}": "与 {server} 同步", - "Hide identical files ({n})": "隐藏相同的文件({n})", - "Synchronize": "同步", "{n} of {total} files will change": "{total} 个文件中有 {n} 个将被更改", "Comparing local and remote files…": "正在比较本地和远程文件…", "Files to synchronize": "要同步的文件", "Action": "操作", - "Local size": "本地大小", - "Local modified": "本地修改时间", - "Remote size": "远程大小", - "Remote modified": "远程修改时间", - "Local and remote files are identical": "本地和远程文件相同", "Synchronizing…": "正在同步…", "Local only": "仅本地", "Remote only": "仅远程", "Local is newer": "本地较新", "Remote is newer": "远程较新", - "Different size": "大小不同", "Identical": "相同", "Upload": "上传", "Delete remote": "删除远程", @@ -5720,6 +5711,46 @@ "The account's 5-hour and weekly limits, when the agent reports them (Claude Code)": "账户的 5 小时和每周限额,在智能体报告时显示(Claude Code)", "Session cost": "会话花费", "What the session has cost so far, as the agent reports it (API pricing, also shown on subscriptions)": "智能体报告的本会话目前花费(按 API 价格计算,订阅账户也会显示)", + "Local: {path}": "本地:{path}", + "Local: (does not exist)": "本地:(不存在)", + "Synchronize All": "同步全部", + "Deployment comparison options": "部署比较选项", + "Refresh comparison": "刷新比较", + "Show local-only files": "显示仅本地存在的文件", + "Show different files": "显示有差异的文件", + "Show remote-only files": "显示仅远程存在的文件", + "Compare by:": "比较依据:", + "Compare by": "比较依据", + "Size and Timestamp": "大小和时间戳", + "Binary Content": "二进制内容", + "Synchronize Selected": "同步所选文件", + "Stop content comparison": "停止内容比较", + "Filter:": "筛选:", + "Local files": "本地文件", + "Local name": "本地名称", + "Remote name": "远程名称", + "Space changes the selected operation. Enter synchronizes the selected file. Synchronize All applies all chosen operations.": "空格键切换所选操作,Enter 键同步所选文件。“同步全部”执行所有已选择的操作。", + "Resize file list and diff preview": "调整文件列表和差异预览的大小", + "The deployment server or project is no longer available": "部署服务器或项目已不可用", + "Select a file to compare its contents": "选择文件以比较其内容", + "Loading file contents…": "正在加载文件内容…", + "This file is too large for the text preview. Use Binary Content to compare it.": "此文件过大,无法进行文本预览。请使用“二进制内容”比较。", + "Deployment comparison": "部署比较", + "Wait for synchronization to finish before opening another comparison.": "请等待同步完成后再打开其他比较。", + "Save local changes?": "保存本地更改?", + "Save the local files edited in this comparison before closing.": "关闭前保存本次比较中编辑的本地文件。", + "Save and Close": "保存并关闭", + "Cannot close deployment comparison": "无法关闭部署比较", + "Deployment Comparison": "部署比较", + "Opening deployment comparison…": "正在打开部署比较…", + "Diff Between 'local files' and 'files on {server}'": "本地文件与“{server}”上的文件的差异", + "Comparing contents: {done} of {total}": "正在比较内容:{done}/{total}", + "A file changed during comparison. Refresh and compare again.": "比较期间文件已更改,请刷新后重新比较。", + "The project is no longer open.": "项目已关闭。", + "The deployment server is no longer available.": "部署服务器已不可用。", + "No selected files are mapped to this deployment server.": "所选文件均未映射到此部署服务器。", + "Cannot open comparison for {server}": "无法打开与 {server} 的比较", + "Could not refresh comparison: {error}": "无法刷新比较:{error}", "Build and run using": "构建和运行使用", "Gradle runs a main class as a JavaExec task on its source set's runtime classpath (Gradle's resource processing included). OpenIDEA launches it on the Java language server's classpath.": "Gradle 以 JavaExec 任务在源集的运行时类路径上运行主类(包括 Gradle 的资源处理)。OpenIDEA 在 Java 语言服务器的类路径上启动它。", "Set an absolute root path on the Connection tab first": "请先在“连接”页设置绝对根路径",