mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 16:08:05 +00:00
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:
@@ -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
|
||||
});
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": "已从部署中排除"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user