diff --git a/crates/ide-host/src/ops/deploy.rs b/crates/ide-host/src/ops/deploy.rs index 1b843995..e6b0bf02 100644 --- a/crates/ide-host/src/ops/deploy.rs +++ b/crates/ide-host/src/ops/deploy.rs @@ -1,6 +1,6 @@ use crate::Host; use ide_core::{CancellationToken, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId}; -use ide_remote::{DeployedPath, SyncEntry, SyncStep, TransferReport}; +use ide_remote::{DeployedPath, DeploymentCoverage, SyncEntry, SyncStep, TransferReport}; use ide_ssh::{FsConnection, RemoteEntry}; use std::path::PathBuf; @@ -31,6 +31,12 @@ fn local(paths: Vec) -> Vec { paths.into_iter().map(|p| p.to_path_buf()).collect() } +/// Eligible paths per server, without contacting any deployment host. +pub async fn deploy_coverage(window: &str, state: &Host, paths: Vec) -> IdeResult> { + let project = state.project(window)?; + Ok(ide_remote::coverage(&project.settings.effective(), &project.root, &local(paths))) +} + /// Tools › Deployment › Upload to…: files and folders to `server` (default server when `None`). pub async fn deploy_upload( window: &str, @@ -169,6 +175,7 @@ pub async fn deploy_test_connection(state: &Host, connection: FsConnection, path } pub(crate) fn register(d: &mut crate::rpc::Dispatcher) { + d.add("deploy_coverage", |c| async move { deploy_coverage(&c.window, &c.host, c.arg("paths")?).await }); d.add("deploy_upload", |c| async move { deploy_upload(&c.window, &c.host, c.arg("paths")?, c.arg("server")?, c.arg("request")?).await }); diff --git a/crates/ide-host/tests/rpc.rs b/crates/ide-host/tests/rpc.rs index b3d51c75..39d9c129 100644 --- a/crates/ide-host/tests/rpc.rs +++ b/crates/ide-host/tests/rpc.rs @@ -216,6 +216,7 @@ async fn local_deployment_runs_on_the_project_host() { })); let dispatcher = Dispatcher::standard(); for cmd in [ + "deploy_coverage", "deploy_upload", "deploy_download", "deploy_delete_remote", @@ -243,6 +244,9 @@ async fn local_deployment_runs_on_the_project_host() { invoke(&peer, "settings_set", json!({"scope":"project", "key":"deployment", "value":deployment.to_string()})) .await .unwrap(); + let coverage = invoke(&peer, "deploy_coverage", json!({"paths":[file]})).await.unwrap(); + assert_eq!(coverage[0]["server"], "local"); + assert_eq!(coverage[0]["paths"], json!([ide_core::IpcPath::from(&file)])); invoke(&peer, "deploy_test_connection", json!({"connection":{"kind":"local"}, "path":target})).await.unwrap(); let report = invoke(&peer, "deploy_upload", json!({"paths":[file], "request":1001})).await.unwrap(); assert_eq!(report["files"], 1, "{report}"); diff --git a/crates/ide-remote/src/deploy.rs b/crates/ide-remote/src/deploy.rs index 712b9c19..a9197e40 100644 --- a/crates/ide-remote/src/deploy.rs +++ b/crates/ide-remote/src/deploy.rs @@ -10,6 +10,45 @@ use specta::Type; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +/// Mapping eligibility uses a common synthetic server root: mapping and exclusion +/// paths are both relative to that root, so checking never opens a connection. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DeploymentCoverage { + pub server: String, + pub paths: Vec, + pub skipped: Vec, +} + +pub fn coverage(settings: &ide_core::settings::Settings, root: &Path, paths: &[PathBuf]) -> Vec { + settings + .deployment + .servers + .iter() + .map(|server| { + let map = Mappings::new(server, root, "/"); + let exclude = Excludes::new(&settings.deployment, server, "/"); + let mut result = DeploymentCoverage { server: server.id.clone(), paths: Vec::new(), skipped: Vec::new() }; + for path in paths { + let name = ide_core::ipc_path::to_ipc(path); + match mapped_path(&map, &exclude, path) { + Ok(_) => result.paths.push(name), + Err(message) => result.skipped.push(TransferError { path: name, message: message.into() }), + } + } + result + }) + .collect() +} + +fn mapped_path(map: &Mappings, exclude: &Excludes, local: &Path) -> Result { + let remote = map.remote(local).ok_or("Not covered by this server's mappings")?; + if exclude.local(local.strip_prefix(map.project_root()).unwrap_or(local)) || exclude.remote(&remote) { + return Err("Excluded from deployment"); + } + Ok(remote) +} + /// Modification times closer than this count as equal (FAT, rounding by servers). const MTIME_SLACK: u32 = 2; @@ -171,7 +210,7 @@ impl Deployment<'_> { break; } let rel = self.rel(local); - let Some(remote) = self.map.remote(local).filter(|r| !self.exclude.remote(r)) else { + let Some(remote) = mapped_path(self.map, self.exclude, local).ok() else { report.skipped += 1; continue; }; @@ -193,7 +232,7 @@ impl Deployment<'_> { let mut report = TransferReport::default(); let mut jobs: Vec<(String, PathBuf)> = Vec::new(); for p in paths { - let Some(remote) = self.map.remote(p).filter(|r| !self.exclude.remote(r)) else { + let Some(remote) = mapped_path(self.map, self.exclude, p).ok() else { report.skipped += 1; continue; }; @@ -239,7 +278,7 @@ impl Deployment<'_> { let mut report = TransferReport::default(); for p in paths { // Never a whole mapping (the project folder went away, or a mapped folder did). - let Some(remote) = self.map.remote(p).filter(|r| !self.exclude.remote(r) && !self.map.is_root(r)) else { + let Some(remote) = mapped_path(self.map, self.exclude, p).ok().filter(|r| !self.map.is_root(r)) else { report.skipped += 1; continue; }; @@ -257,12 +296,12 @@ impl Deployment<'_> { /// Sync with Deployed: local and remote files under `local` side by side. pub async fn compare(&self, local: &Path) -> IdeResult> { - let remote_root = - self.map.remote(local).ok_or_else(|| IdeError::invalid(format!("{} is not mapped", local.display())))?; + let remote_root = mapped_path(self.map, self.exclude, local) + .map_err(|reason| IdeError::invalid(format!("{}: {reason}", local.display())))?; let mut by_rel: BTreeMap = BTreeMap::new(); let mut ignored = TransferReport::default(); for f in self.local_files(&[local.to_path_buf()], &mut ignored) { - let Some(remote_path) = self.map.remote(&f) else { continue }; + let Ok(remote_path) = mapped_path(self.map, self.exclude, &f) else { continue }; let size = std::fs::metadata(&f).map(|m| m.len()).unwrap_or(0); let stamp = FileStamp { size, mtime: mtime_of(&f) }; by_rel.insert( @@ -319,9 +358,7 @@ impl Deployment<'_> { break; } let local = step.path.split('/').filter(|s| !s.is_empty()).fold(root.clone(), |p, s| p.join(s)); - let Some(remote) = - self.map.remote(&local).filter(|r| !self.exclude.remote(r) && !self.excluded_local(&local)) - else { + let Some(remote) = mapped_path(self.map, self.exclude, &local).ok() else { report.skipped += 1; continue; }; diff --git a/crates/ide-remote/src/lib.rs b/crates/ide-remote/src/lib.rs index 09af9f62..2bdd2dbb 100644 --- a/crates/ide-remote/src/lib.rs +++ b/crates/ide-remote/src/lib.rs @@ -6,7 +6,9 @@ pub mod mapping; pub mod service; pub mod target; -pub use deploy::{Deployment, SyncAction, SyncEntry, SyncStatus, SyncStep, TransferReport}; +pub use deploy::{ + Deployment, DeploymentCoverage, SyncAction, SyncEntry, SyncStatus, SyncStep, TransferReport, coverage, +}; pub use mapping::{Excludes, Mappings}; pub use service::{DeployedPath, RemoteService}; pub use target::{RemoteRuns, RemoteStart}; diff --git a/crates/ide-remote/tests/local.rs b/crates/ide-remote/tests/local.rs index cb654057..91aee56f 100644 --- a/crates/ide-remote/tests/local.rs +++ b/crates/ide-remote/tests/local.rs @@ -209,3 +209,56 @@ async fn missing_non_directory_and_unwritable_targets_are_clear() { assert!(result.unwrap_err().message.contains("not writable")); assert_eq!(report.errors.len(), 1, "{report:?}"); } + +#[tokio::test] +async fn coverage_matches_transfers_without_connecting() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("project"); + let target = tmp.path().join("target"); + for dir in ["src", "src-other", "assets", "src/private", "src/node_modules"] { + std::fs::create_dir_all(root.join(dir)).unwrap(); + } + let names = ["src/a.txt", "assets/b.txt", "src-other/c.txt", "src/private/key", "src/node_modules/pkg"]; + let paths: Vec<_> = names.iter().map(|name| root.join(name)).collect(); + for path in &paths { + std::fs::write(path, "content").unwrap(); + } + let mut settings = settings(&target); + settings.deployment.exclude = vec!["node_modules".into()]; + settings.deployment.servers[0].mappings[0].local = "src".into(); + settings.deployment.servers[0].excluded_paths = vec!["app/private".into()]; + let mut assets = settings.deployment.servers[0].clone(); + assets.id = "assets".into(); + assets.mappings[0].local = "assets".into(); + let mut offline = assets.clone(); + offline.id = "offline".into(); + offline.kind = DeploymentType::Sftp; + offline.ssh_config = "does-not-exist".into(); + settings.deployment.servers.extend([assets, offline]); + let coverage = ide_remote::coverage(&settings, &root, &paths); + assert_eq!(coverage[0].paths, vec![ide_core::ipc_path::to_ipc(&paths[0])]); + assert_eq!(coverage[1].paths, vec![ide_core::ipc_path::to_ipc(&paths[1])]); + assert_eq!(coverage[2].paths, coverage[1].paths); + assert_eq!(coverage[0].skipped.len(), 4); + assert!(coverage[0].skipped[0].message.contains("mappings")); + assert!(coverage[0].skipped[2].message.contains("Excluded")); + let service = service(tmp.path()); + let report = service + .upload(&settings, &root, Some("local"), &paths, CancellationToken::new(), &mut |_, _, _| {}) + .await + .unwrap(); + assert_eq!((report.files, report.skipped), (1, 4)); + let entries = + service.compare(&settings, &root, Some("local"), &root.join("src"), CancellationToken::new()).await.unwrap(); + assert_eq!(entries.len(), 1); + let report = service + .download(&settings, &root, Some("local"), &paths, CancellationToken::new(), &mut |_, _, _| {}) + .await + .unwrap(); + assert_eq!((report.files, report.skipped), (1, 4)); + let report = service.delete_remote(&settings, &root, Some("local"), &paths).await.unwrap(); + assert_eq!((report.files, report.skipped), (1, 4)); + settings.deployment.servers[0].mappings.clear(); + assert_eq!(ide_remote::coverage(&settings, &root, &[root.join("assets/b.txt")])[0].paths.len(), 1); + assert!(ide_remote::coverage(&settings, &root, &[root.join("../outside")])[0].paths.is_empty()); +} diff --git a/src-tauri/src/commands/deploy.rs b/src-tauri/src/commands/deploy.rs index be959bde..ca486655 100644 --- a/src-tauri/src/commands/deploy.rs +++ b/src-tauri/src/commands/deploy.rs @@ -1,9 +1,19 @@ use crate::state::AppState; use ide_core::{IdeResult, IpcPath, RequestId}; -use ide_remote::{DeployedPath, SyncEntry, SyncStep, TransferReport}; +use ide_remote::{DeployedPath, DeploymentCoverage, SyncEntry, SyncStep, TransferReport}; use ide_ssh::{FsConnection, RemoteEntry}; use tauri::{State, Window}; +#[tauri::command] +#[specta::specta] +pub async fn deploy_coverage( + window: Window, + state: State<'_, AppState>, + paths: Vec, +) -> IdeResult> { + ide_host::ops::deploy::deploy_coverage(window.label(), &state.host, paths).await +} + #[tauri::command] #[specta::specta] pub async fn deploy_upload( diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 739271c7..a031514b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -451,6 +451,7 @@ pub fn builder() -> tauri_specta::Builder { ssh::git_credentials, ssh::git_credential_forget, ssh::ssh_terminal_command, + deploy::deploy_coverage, deploy::deploy_upload, deploy::deploy_download, deploy::deploy_delete_remote, diff --git a/src/bindings.ts b/src/bindings.ts index 113eff5d..bc6221f5 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -968,6 +968,7 @@ export const commands = { * (run in a terminal tab, so prompts and host-key questions are answered there). */ sshTerminalCommand: (id: string) => typedError(__TAURI_INVOKE("ssh_terminal_command", { id })), + deployCoverage: (paths: IpcPath[]) => typedError(__TAURI_INVOKE("deploy_coverage", { paths })), deployUpload: (paths: IpcPath[], server: string | null, request: RequestId) => typedError(__TAURI_INVOKE("deploy_upload", { paths, server, request })), deployDownload: (paths: IpcPath[], server: string | null, request: RequestId) => typedError(__TAURI_INVOKE("deploy_download", { paths, server, request })), deployDeleteRemote: (paths: IpcPath[], server: string | null) => typedError(__TAURI_INVOKE("deploy_delete_remote", { paths, server })), @@ -2396,6 +2397,16 @@ export type DeployedPath = { webUrl: string | null, }; +/** + * Mapping eligibility uses a common synthetic server root: mapping and exclusion + * paths are both relative to that root, so checking never opens a connection. + */ +export type DeploymentCoverage = { + server: string, + paths: string[], + skipped: TransferError[], +}; + export type DeploymentMapping = { /** Project-relative folder; empty = the project root. */ local?: string, diff --git a/src/features/deployment/Dialogs.test.tsx b/src/features/deployment/Dialogs.test.tsx index 197893c4..15ce2319 100644 --- a/src/features/deployment/Dialogs.test.tsx +++ b/src/features/deployment/Dialogs.test.tsx @@ -18,7 +18,8 @@ function openSync(entries: SyncEntry[]) { sync: { server: "s1", serverName: "Staging", - path: "/work/app", + paths: ["/work/app"], + skipped: [], request: null, entries, error: null, diff --git a/src/features/deployment/Dialogs.tsx b/src/features/deployment/Dialogs.tsx index 3fea1e47..982a1cda 100644 --- a/src/features/deployment/Dialogs.tsx +++ b/src/features/deployment/Dialogs.tsx @@ -78,7 +78,7 @@ function SyncDialog() { 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 = root ? relativePath(root, sync.path) || "/" : sync.path; + const where = sync.paths.map((path) => (root ? relativePath(root, path) || "/" : path)).join(", "); return ( {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 && (
diff --git a/src/features/deployment/actions.test.tsx b/src/features/deployment/actions.test.tsx new file mode 100644 index 00000000..f3a1ae48 --- /dev/null +++ b/src/features/deployment/actions.test.tsx @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { DeploymentServerValid } from "@/platform/remoteSettings"; + +const mocks = vi.hoisted(() => ({ + actions: [] as { id: string; run: (context: { arg: string[] }) => unknown }[], + servers: [] as DeploymentServerValid[], + coverage: [] as { server: string; paths: string[]; skipped: { path: string; message: string }[] }[], + choose: vi.fn(), + notify: vi.fn(), + upload: vi.fn(), + compare: vi.fn(), +})); +vi.mock("@/platform/actions", () => ({ + executeAction: vi.fn(), + registerActions: (actions: typeof mocks.actions) => { + mocks.actions = actions; + return () => {}; + }, +})); +vi.mock("@/workbench/messages", () => ({ confirm: vi.fn() })); +vi.mock("@/platform/openUrl", () => ({ openExternalUrl: vi.fn() })); +vi.mock("@/platform/context", () => ({ getContext: () => ({}) })); +vi.mock("@/platform/project", () => ({ + relativePath: (_: string, path: string) => path, + useProject: { getState: () => ({ project: { root: "/project" } }) }, +})); +vi.mock("@/platform/workflowSettings", () => ({ + workflowSettings: () => ({ deployment: { servers: mocks.servers, defaultServer: "one" } }), +})); +vi.mock("@/workbench/contributions", () => ({ + notifications: () => ({ notify: mocks.notify }), + projectViewSelection: () => [], + toolWindowService: vi.fn(), +})); +vi.mock("@/workbench/inputDialogs", () => ({ chooseItem: mocks.choose })); +vi.mock("./ssh", () => ({ withSsh: (_: unknown, run: () => unknown) => run() })); +vi.mock("@/platform/ipc", () => ({ + call: (value: unknown) => value, + nextRequestId: () => 1, + isCancelled: () => false, + commands: { + deployCoverage: async () => mocks.coverage, + deployUpload: mocks.upload, + deployCompare: mocks.compare, + }, +})); +import { registerDeploymentActions } from "./actions"; +import { useDeployment } from "./store"; + +async function run(id: string, paths = ["/project/a", "/project/b"]) { + mocks.actions.find((action) => action.id === id)!.run({ arg: paths }); + await vi.waitFor(() => + expect( + mocks.choose.mock.calls.length + mocks.notify.mock.calls.length + (useDeployment.getState().sync ? 1 : 0), + ).toBeGreaterThan(0), + ); +} +beforeEach(() => { + vi.clearAllMocks(); + mocks.servers = ["one", "two"].map((id) => ({ id, name: id, kind: "local" }) as DeploymentServerValid); + mocks.coverage = [ + { + server: "one", + paths: ["/project/a"], + skipped: [{ path: "/project/b", message: "Not covered by this server's mappings" }], + }, + { server: "two", paths: [], skipped: [] }, + ]; + mocks.choose.mockResolvedValue(null); + mocks.upload.mockResolvedValue({ files: 1, skipped: 1, errors: [] }); + mocks.compare.mockResolvedValue([]); + useDeployment.setState({ sync: null }); + registerDeploymentActions(); +}); +describe("deployment eligibility", () => { + it.each(["deployment.uploadTo", "deployment.downloadFrom", "deployment.syncWith"])( + "filters %s and keeps a single option visible", + async (id) => { + await run(id); + 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("explains why the default cannot deploy the named file", async () => { + mocks.coverage[0]!.paths = []; + await run("deployment.uploadToDefault"); + expect(mocks.upload).not.toHaveBeenCalled(); + expect(mocks.notify.mock.calls[0]![0].body.props.report.errors[0].path).toBe("/project/b"); + }); + it("compares every covered selection and retains skipped paths", async () => { + mocks.coverage[0]!.paths.push("/project/c"); + mocks.choose.mockResolvedValue("one"); + await run("deployment.syncWith"); + await vi.waitFor(() => expect(mocks.compare).toHaveBeenCalledTimes(2)); + expect(useDeployment.getState().sync?.skipped?.[0]?.path).toBe("/project/b"); + }); +}); diff --git a/src/features/deployment/actions.tsx b/src/features/deployment/actions.tsx index b54458b0..4e6163fa 100644 --- a/src/features/deployment/actions.tsx +++ b/src/features/deployment/actions.tsx @@ -51,22 +51,54 @@ function notifyNoTargets() { }); } -/** Asks which server to use (the only one without asking). */ -async function pickServer(title: string): Promise { - const servers = deployment().servers; - if (!servers.length) { +async function serverCoverage(paths: string[]) { + try { + return await call(commands.deployCoverage(paths)); + } catch (e) { + notifications().notify({ + level: "error", + title: "Cannot check deployment mappings", + body: errorText(e), + group: GROUP, + }); + return null; + } +} + +function notifyNoCoverage(server?: DeploymentServerValid, skipped?: TransferReport["errors"]) { + notifications().notify({ + level: "warning", + title: server + ? txTemplate(`No selected paths can be deployed to ${serverLabel(server)}`) + : "No deployment server covers the selected paths", + body: skipped?.length ? : undefined, + group: GROUP, + key: "deployment.noCoverage", + actions: [{ label: "Configure…", run: () => void executeAction("deployment.configure") }], + }); +} + +/** Keep the chooser visible even when only one server covers the selection. */ +async function pickServer(title: string, paths: string[]): Promise { + const configured = deployment().servers; + if (!configured.length) { notifyNoServers(); return null; } - if (servers.length === 1) return servers[0]!; + const coverage = await serverCoverage(paths); + if (!coverage) return null; + const servers = configured.filter((server) => coverage.some((c) => c.server === server.id && c.paths.length)); + if (!servers.length) { + notifyNoCoverage(); + return null; + } const labels = servers.map(serverLabel); const picked = await chooseItem({ title, items: labels }); return picked === null ? null : (servers[labels.indexOf(picked)] ?? null); } -/** The default server; asks when none is marked default and there are several. */ -async function defaultServer(title: string): Promise { - return resolveServer(deployment(), null) ?? pickServer(title); +async function defaultServer(title: string, paths: string[]): Promise { + return resolveServer(deployment(), null) ?? pickServer(title, paths); } function ReportBody({ report }: { report: TransferReport }) { @@ -118,6 +150,9 @@ function showFailure(title: string, server: DeploymentServerValid, e: unknown) { async function transfer(direction: "upload" | "download", server: DeploymentServerValid | null, paths: string[]) { if (!server) return; + const coverage = (await serverCoverage(paths))?.find((c) => c.server === server.id); + if (!coverage) return; + if (!coverage.paths.length) return notifyNoCoverage(server, coverage.skipped); const request = nextRequestId(); try { const report = await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => @@ -139,8 +174,11 @@ export function downloadPaths(server: DeploymentServerValid, paths: string[]) { } async function deleteRemote(paths: string[]) { - const server = await defaultServer("Delete from Server"); + const server = await defaultServer("Delete from Server", paths); if (!server) return; + const coverage = (await serverCoverage(paths))?.find((c) => c.server === server.id); + if (!coverage) return; + if (!coverage.paths.length) return notifyNoCoverage(server, coverage.skipped); const root = projectRoot(); const names = paths.map((p) => (root ? relativePath(root, p) || "/" : p)); const ok = await confirm({ @@ -164,14 +202,18 @@ async function deleteRemote(paths: string[]) { } } -/** Opens the Sync dialog for `path` and compares it with `server`. */ -export async function openSync(server: DeploymentServerValid, path: string) { +/** Compares every covered selection with `server`. */ +export async function openSync(server: DeploymentServerValid, paths: string[]) { + const coverage = (await serverCoverage(paths))?.find((c) => c.server === server.id); + if (!coverage) return; + if (!coverage.paths.length) return notifyNoCoverage(server, coverage.skipped); const request = nextRequestId(); useDeployment.setState({ sync: { server: server.id, serverName: serverLabel(server), - path, + paths, + skipped: coverage.skipped, request, entries: null, error: null, @@ -181,9 +223,15 @@ export async function openSync(server: DeploymentServerValid, path: string) { }, }); try { - const entries = await withSsh(server.kind === "sftp" ? server.sshConfig : "", () => - call(commands.deployCompare(path, server.id, request)), - ); + const groups = await withSsh(server.kind === "sftp" ? server.sshConfig : "", async () => { + const entries = []; + for (const path of coverage.paths) { + if (useDeployment.getState().sync?.request !== request) break; + entries.push(...(await call(commands.deployCompare(path, server.id, request)))); + } + return entries; + }); + const entries = [...new Map(groups.map((entry) => [entry.path, entry])).values()]; if (useDeployment.getState().sync?.request !== request) return; patchSync({ entries, choices: initialSyncChoices(entries), request: null }); } catch (e) { @@ -211,6 +259,7 @@ export async function runSync(steps: { path: string; action: SyncAction }[]) { call(commands.deploySync(steps, server.id, request)), ); useDeployment.setState({ sync: null }); + report.skipped += sync.skipped.length; showReport(report, "sync", server); } catch (e) { if (isCancelled(e)) patchSync({ running: false, request: null }); @@ -275,7 +324,7 @@ export function registerDeploymentActions(): () => void { icon: "export", when, keywords: ["deploy", "sftp", "upload", "remote"], - run: withTargets(async (paths) => transfer("upload", await defaultServer("Upload to"), paths)), + run: withTargets(async (paths) => transfer("upload", await defaultServer("Upload to", paths), paths)), }, { id: "deployment.uploadTo", @@ -284,7 +333,7 @@ export function registerDeploymentActions(): () => void { category: CATEGORY, when, keywords: ["deploy", "sftp", "upload", "remote"], - run: withTargets(async (paths) => transfer("upload", await pickServer("Upload to"), paths)), + run: withTargets(async (paths) => transfer("upload", await pickServer("Upload to", paths), paths)), }, { id: "deployment.downloadFromDefault", @@ -293,7 +342,7 @@ export function registerDeploymentActions(): () => void { category: CATEGORY, when, keywords: ["deploy", "sftp", "download", "remote"], - run: withTargets(async (paths) => transfer("download", await defaultServer("Download from"), paths)), + run: withTargets(async (paths) => transfer("download", await defaultServer("Download from", paths), paths)), }, { id: "deployment.downloadFrom", @@ -302,7 +351,7 @@ export function registerDeploymentActions(): () => void { category: CATEGORY, when, keywords: ["deploy", "sftp", "download", "remote"], - run: withTargets(async (paths) => transfer("download", await pickServer("Download from"), paths)), + run: withTargets(async (paths) => transfer("download", await pickServer("Download from", paths), paths)), }, { id: "deployment.syncWith", @@ -315,7 +364,8 @@ export function registerDeploymentActions(): () => void { const paths = actionTargets(arg); const target = paths[0] ?? projectRoot(); if (!target) return; - void pickServer("Sync with Deployed to").then((server) => server && openSync(server, target)); + const targets = paths.length ? paths : [target]; + void pickServer("Sync with Deployed to", targets).then((server) => server && openSync(server, targets)); }, }, { diff --git a/src/features/deployment/store.ts b/src/features/deployment/store.ts index d7edd350..7d7dce02 100644 --- a/src/features/deployment/store.ts +++ b/src/features/deployment/store.ts @@ -1,6 +1,6 @@ /** Session state of the deployment feature: the Sync dialog, the SSH secret prompt and the Remote Host tree. */ import { create } from "zustand"; -import type { RemoteEntry, RequestId, SyncEntry } from "@/platform/ipc"; +import type { TransferReport, RemoteEntry, RequestId, SyncEntry } from "@/platform/ipc"; import type { FocusedPart, SyncChoice } from "./model"; export const REMOTE_HOST_WINDOW = "remoteHost"; @@ -8,8 +8,9 @@ export const REMOTE_HOST_WINDOW = "remoteHost"; export interface SyncDialogState { server: string; serverName: string; - /** Absolute local path compared (file or folder). */ - path: string; + /** Absolute local paths selected for comparison. */ + paths: string[]; + skipped: TransferReport["errors"]; request: RequestId | null; entries: SyncEntry[] | null; error: string | null; diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index a48b7fbf..55b23987 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5308,5 +5308,10 @@ "Architecture": "架构", "Compiler": "编译器", "No CMake executable target for this file": "此文件没有对应的 CMake 可执行目标", - "Run CMake Configure from the Run selector, then choose the executable target. Files in a CMake project are built with their target.": "请从运行选择器执行 CMake Configure,然后选择可执行目标。CMake 项目中的文件由所属目标构建,不会单独编译。" + "Run CMake Configure from the Run selector, then choose the executable target. Files in a CMake project are built with their target.": "请从运行选择器执行 CMake Configure,然后选择可执行目标。CMake 项目中的文件由所属目标构建,不会单独编译。", + "Cannot check deployment mappings": "无法检查部署映射", + "No selected paths can be deployed to {server}": "所选路径均无法部署到 {server}", + "No deployment server covers the selected paths": "没有部署服务器覆盖所选路径", + "Not covered by this server's mappings": "此服务器的映射未覆盖该路径", + "Excluded from deployment": "已从部署中排除" }