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": "请先在“连接”页设置绝对根路径",