fix(deployment): filter servers by selected path coverage (#544)

The server choosers listed every configured server without checking mapping coverage or exclusions, so choosing the wrong one silently skipped the files.

- Add a shared eligibility check, available through desktop IPC and host RPC, that never contacts a server.
- Filter Upload to, Download from, Compare and Delete choosers by it. A selection lists a server if it covers at least one path, and the rest are reported as skipped.
- Explain why the default server cannot take a selection, by file name, instead of doing nothing. Apply the same exclusion rules when a transfer runs.

Fixes #538
This commit is contained in:
l0ng-ai
2026-10-31 14:53:48 +08:00
committed by GitHub
parent 999eb86408
commit ee2585e7e9
14 changed files with 333 additions and 38 deletions
+8 -1
View File
@@ -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<IpcPath>) -> Vec<PathBuf> {
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<IpcPath>) -> IdeResult<Vec<DeploymentCoverage>> {
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
});
+4
View File
@@ -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}");
+46 -9
View File
@@ -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<String>,
pub skipped: Vec<TransferError>,
}
pub fn coverage(settings: &ide_core::settings::Settings, root: &Path, paths: &[PathBuf]) -> Vec<DeploymentCoverage> {
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<String, &'static str> {
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<Vec<SyncEntry>> {
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<String, SyncEntry> = 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;
};
+3 -1
View File
@@ -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};
+53
View File
@@ -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());
}
+11 -1
View File
@@ -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<IpcPath>,
) -> IdeResult<Vec<DeploymentCoverage>> {
ide_host::ops::deploy::deploy_coverage(window.label(), &state.host, paths).await
}
#[tauri::command]
#[specta::specta]
pub async fn deploy_upload(
+1
View File
@@ -451,6 +451,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
ssh::git_credentials,
ssh::git_credential_forget,
ssh::ssh_terminal_command,
deploy::deploy_coverage,
deploy::deploy_upload,
deploy::deploy_download,
deploy::deploy_delete_remote,
+11
View File
@@ -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<SshTerminalCommand, IdeError>(__TAURI_INVOKE("ssh_terminal_command", { id })),
deployCoverage: (paths: IpcPath[]) => typedError<DeploymentCoverage[], IdeError>(__TAURI_INVOKE("deploy_coverage", { paths })),
deployUpload: (paths: IpcPath[], server: string | null, request: RequestId) => typedError<TransferReport, IdeError>(__TAURI_INVOKE("deploy_upload", { paths, server, request })),
deployDownload: (paths: IpcPath[], server: string | null, request: RequestId) => typedError<TransferReport, IdeError>(__TAURI_INVOKE("deploy_download", { paths, server, request })),
deployDeleteRemote: (paths: IpcPath[], server: string | null) => typedError<TransferReport, IdeError>(__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,
+2 -1
View File
@@ -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,
+7 -1
View File
@@ -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 (
<Dialog
@@ -110,6 +110,12 @@ function SyncDialog() {
<span className={styles.mono}>{where}</span>
{entries && <> · {txt(`${steps.length} of ${entries.length} files will change`)}</>}
</div>
{!!sync.skipped?.length && (
<div className={styles.note}>
{txt(`Skipped: ${sync.skipped.length}`)}:{" "}
{sync.skipped.map((s) => (root ? relativePath(root, s.path) : s.path)).join(", ")}
</div>
)}
{sync.error && <div className={styles.error}>{sync.error}</div>}
{!entries && !sync.error && (
<div className={styles.center}>
+107
View File
@@ -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");
});
});
+70 -20
View File
@@ -51,22 +51,54 @@ function notifyNoTargets() {
});
}
/** Asks which server to use (the only one without asking). */
async function pickServer(title: string): Promise<DeploymentServerValid | null> {
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 ? <ReportBody report={{ files: 0, skipped: skipped.length, errors: skipped }} /> : 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<DeploymentServerValid | null> {
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<DeploymentServerValid | null> {
return resolveServer(deployment(), null) ?? pickServer(title);
async function defaultServer(title: string, paths: string[]): Promise<DeploymentServerValid | null> {
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));
},
},
{
+4 -3
View File
@@ -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;
+6 -1
View File
@@ -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": "已从部署中排除"
}