fix(deployment): accept absolute local paths in mappings and name menu entries after the server (#885)

A mapping's local path may now be an absolute path inside the project (the form IntelliJ writes), and a deployment path of . means the server root; absolute paths outside the project are kept as they are. The project-tree Deployment submenu names the default server (Upload to <server>, Download from <server>, Sync with Deployed to <server>…) and gains Sync with Deployed to Default Server. Remote Host downloads use the backend-resolved mapping, which fixes downloads under symlinked or case-different paths.

Fixes #877

Co-authored-by: florentvdry <[email protected]>
Цей коміт міститься в:
Florent Vтаflorentvdry authored and GitHub committed 2026-11-03 23:35:43 +08:00
1 джерело 0396f0d552
коміт 2d4d6ee6bf
17 files changed
+372 -65

No files matched your search

+15 -1
Переглянути файл
@@ -89,7 +89,9 @@ async function pickServer(title: string, paths: string[]): Promise<DeploymentSer
if (!coverage) return null;
const servers = configured.filter((server) => coverage.some((c) => c.server === server.id && c.paths.length));
if (!servers.length) {
notifyNoCoverage();
// One server: say why, as an explicit upload to it would.
if (configured.length === 1) notifyNoCoverage(configured[0], coverage[0]?.skipped);
else notifyNoCoverage();
return null;
}
const labels = servers.map(serverLabel);
@@ -368,6 +370,18 @@ export function registerDeploymentActions(): () => void {
void pickServer("Sync with Deployed to", targets).then((server) => server && openSync(server, targets));
},
},
{
id: "deployment.syncWithDefault",
title: "Sync with Deployed to Default Server",
description: "Compare local files with the default deployment server and synchronize them",
category: CATEGORY,
when,
keywords: ["deploy", "sftp", "compare", "synchronize", "remote", "diff"],
run: withTargets(async (paths) => {
const server = await defaultServer("Sync with Deployed to", paths);
if (server) await openSync(server, paths);
}),
},
{
id: "deployment.deleteFromServer",
title: "Delete from Server",
+7 -5
Переглянути файл
@@ -13,6 +13,7 @@ import { registerMenuGroup, registerOverlay, registerToolWindow } from "@/workbe
import { executeAction } from "@/platform/actions";
import { autoUploadOn, registerDeploymentActions } from "./actions";
import { DeploymentDialogs } from "./Dialogs";
import { transferMenuEntries } from "./model";
import { REMOTE_HOST_WINDOW, useDeployment } from "./store";
/** Deployed URL of the path the last menu was prepared for. */
@@ -32,11 +33,12 @@ function deploymentMenu(paths: string[]): MenuItem[] {
label: "Deployment",
icon: "web",
submenu: [
{ kind: "action", action: "deployment.uploadToDefault", arg },
{ kind: "action", action: "deployment.uploadTo", arg },
{ kind: "action", action: "deployment.downloadFromDefault", arg },
{ kind: "action", action: "deployment.downloadFrom", arg },
{ kind: "action", action: "deployment.syncWith", arg },
...transferMenuEntries(workflowSettings().deployment).map(({ action, label }): MenuItem => ({
kind: "action",
action,
arg,
...(label ? { label } : {}),
})),
{ kind: "action", action: "deployment.deleteFromServer", arg },
{ kind: "separator" },
...(webUrl ? [{ kind: "action", action: "deployment.openInBrowser", arg } as MenuItem] : []),
+25 -5
Переглянути файл
@@ -12,6 +12,7 @@ import {
resolveServer,
sshRemedy,
summarizeReport,
transferMenuEntries,
syncChoices,
syncSteps,
targetPaths,
@@ -102,6 +103,16 @@ describe("servers and reports", () => {
expect(resolveServer({ ...DEFAULT_DEPLOYMENT, servers: [a, b], defaultServer: "b" }, "a")?.id).toBe("a");
});
it("names the menu's transfers after the default server", () => {
const actions = (servers: (typeof a)[], defaultServer: string) =>
transferMenuEntries({ ...DEFAULT_DEPLOYMENT, servers, defaultServer }).map((e) => e.label ?? e.action);
const named = ["Upload to Staging", "Download from Staging", "Sync with Deployed to Staging…"];
const choosers = ["deployment.uploadTo", "deployment.downloadFrom", "deployment.syncWith"];
expect(actions([a], "")).toEqual(named);
expect(actions([a, b], "a")).toEqual([...named, ...choosers]);
expect(actions([a, b], "")).toEqual(choosers);
});
it("summarizes transfer reports", () => {
expect(summarizeReport({ files: 3, skipped: 1, errors: [] }, "upload", "Staging")).toEqual({
level: "info",
@@ -140,16 +151,25 @@ describe("remote paths", () => {
it("maps remote paths back to project paths through the most specific mapping", () => {
const mappings = [
{ local: "", deployment: "", web: "" },
{ local: "public", deployment: "htdocs", web: "" },
{ local: "shared", deployment: "/opt/shared", web: "" },
{ local: ROOT, deployment: "/var/www" },
{ local: `${ROOT}/public`, deployment: "/var/www/htdocs" },
{ local: `${ROOT}/shared`, deployment: "/var/www/opt/shared" },
];
const map = (p: string) => localPathForRemote(p, "/var/www", mappings, ROOT);
const map = (p: string) => localPathForRemote(p, mappings);
expect(map("/var/www/index.php")).toBe(`${ROOT}/index.php`);
expect(map("/var/www/htdocs/css/a.css")).toBe(`${ROOT}/public/css/a.css`);
expect(map("/var/www/htdocs")).toBe(`${ROOT}/public`);
expect(map("/opt/shared/lib")).toBe(`${ROOT}/shared/lib`);
expect(map("/var/www/opt/shared/lib")).toBe(`${ROOT}/shared/lib`);
expect(map("/opt/shared/lib")).toBeNull();
expect(map("/var/wwwx/a")).toBeNull();
expect(map("/etc/passwd")).toBeNull();
});
it("uses the backend folder for aliases and does not invent mappings", () => {
const mappings = [{ local: `${ROOT}/src`, deployment: "/mnt/deploy" }];
expect(localPathForRemote("/mnt/deploy/a.txt", mappings)).toBe(`${ROOT}/src/a.txt`);
expect(localPathForRemote("/mnt/deploy/a.txt", [])).toBeNull();
expect(localPathForRemote("/mnt/deploy/../a.txt", mappings)).toBeNull();
expect(localPathForRemote("/a.txt", [{ local: ROOT, deployment: "/" }])).toBe(`${ROOT}/a.txt`);
});
});
+25 -17
Переглянути файл
@@ -1,5 +1,6 @@
/** Pure helpers of the deployment feature: target paths, sync defaults, reports, remote paths. */
import type { SyncAction, SyncEntry, SyncStatus, TransferReport } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import type { ResolvedMapping, SyncAction, SyncEntry, SyncStatus, TransferReport } from "@/platform/ipc";
import type { DeploymentServerValid, DeploymentSettingsValid, SshAuthValid } from "@/platform/remoteSettings";
/** Which part had focus last: the project view's selection or the editor's file is the target. */
@@ -87,6 +88,24 @@ export function resolveServer(settings: DeploymentSettingsValid, id: string | nu
export const serverLabel = (s: Pick<DeploymentServerValid, "id" | "name">) => s.name.trim() || s.id;
/** Transfer entries of the Deployment menu: named after the default server, choosers when there are several. */
export function transferMenuEntries(settings: DeploymentSettingsValid): { action: string; label?: string }[] {
const target = resolveServer(settings, null);
const name = target && serverLabel(target);
const named = target
? [
{ action: "deployment.uploadToDefault", label: tx`Upload to ${name}` },
{ action: "deployment.downloadFromDefault", label: tx`Download from ${name}` },
{ action: "deployment.syncWithDefault", label: tx`Sync with Deployed to ${name}…` },
]
: [];
const choosers =
settings.servers.length > 1 || !target
? [{ action: "deployment.uploadTo" }, { action: "deployment.downloadFrom" }, { action: "deployment.syncWith" }]
: [];
return [...named, ...choosers];
}
export interface ReportSummary {
level: "info" | "warning" | "error";
/** English source text with `{n}` / `{server}` placeholders (translated by the caller). */
@@ -129,28 +148,17 @@ export function remoteName(path: string): string {
return trimmed.slice(trimmed.lastIndexOf("/") + 1) || "/";
}
/**
* The local project path a remote path is deployed from, through the server's
* mappings (the most specific deployment path wins); null when not mapped.
* `root` is the server's root path as resolved on the host.
*/
export function localPathForRemote(
remotePath: string,
root: string,
mappings: DeploymentServerValid["mappings"],
projectRoot: string,
): string | null {
/** The most specific backend-resolved mapping of a remote path; null when unmapped. */
export function localPathForRemote(remotePath: string, mappings: readonly ResolvedMapping[]): string | null {
let best: { len: number; local: string } | null = null;
for (const m of mappings) {
const deployed = m.deployment.startsWith("/")
? m.deployment.replace(/\/+$/, "") || "/"
: joinRemote(root, m.deployment);
const deployed = m.deployment;
const within =
remotePath === deployed || remotePath.startsWith(deployed === "/" ? "/" : `${deployed}/`) ? deployed : null;
if (within === null || (best && best.len >= deployed.length)) continue;
const rest = remotePath.slice(deployed.length).replace(/^\/+/, "");
const base = joinRemote(projectRoot, m.local);
best = { len: deployed.length, local: joinRemote(base, rest) };
if (rest.split("/").some((part) => part === "." || part === "..")) return null;
best = { len: deployed.length, local: joinRemote(m.local, rest) };
}
return best?.local ?? null;
}
+48 -4
Переглянути файл
@@ -1,9 +1,16 @@
import { beforeEach, expect, it, vi } from "vitest";
import { DEFAULT_DEPLOYMENT_SERVER } from "@/platform/remoteSettings";
import { ensureRoot } from "./remoteHost";
import { downloadToProject, ensureRoot } from "./remoteHost";
import { useDeployment } from "./store";
const ipc = vi.hoisted(() => ({ root: vi.fn(), list: vi.fn() }));
const ipc = vi.hoisted(() => ({ root: vi.fn(), list: vi.fn(), download: vi.fn(), notify: vi.fn() }));
vi.mock("./actions", () => ({ downloadPaths: ipc.download }));
vi.mock("./ssh", () => ({ withSsh: (_: string, run: () => unknown) => run() }));
vi.mock("@/ui", () => ({ copyText: vi.fn() }));
vi.mock("@/workbench/inputDialogs", () => ({ promptInput: vi.fn() }));
vi.mock("@/workbench/messages", () => ({ confirm: vi.fn() }));
vi.mock("@/platform/project", () => ({ useProject: { getState: () => ({ project: { root: "/project" } }) } }));
vi.mock("@/workbench/contributions", () => ({ notifications: () => ({ notify: ipc.notify }) }));
vi.mock("@/platform/ipc", async (original) => ({
...(await original<typeof import("@/platform/ipc")>()),
commands: { deployServerRoot: ipc.root, remoteList: ipc.list },
@@ -12,7 +19,7 @@ vi.mock("@/platform/ipc", async (original) => ({
beforeEach(() => {
useDeployment.setState({ trees: {} });
vi.clearAllMocks();
ipc.root.mockResolvedValue({ status: "ok", data: "/srv" });
ipc.root.mockResolvedValue({ status: "ok", data: { root: "/srv", mappings: [] } });
ipc.list.mockResolvedValue({ status: "ok", data: [] });
});
@@ -25,8 +32,45 @@ it("reloads a server's tree when changing its filesystem type or root", async ()
const local = { ...server, kind: "local" as const };
await ensureRoot(local);
expect(ipc.list).toHaveBeenLastCalledWith({ kind: "local" }, "/srv");
ipc.root.mockResolvedValue({ status: "ok", data: "/mounted" });
ipc.root.mockResolvedValue({ status: "ok", data: { root: "/mounted", mappings: [] } });
await ensureRoot({ ...local, rootPath: "/mounted" });
expect(ipc.list).toHaveBeenLastCalledWith({ kind: "local" }, "/mounted");
expect(useDeployment.getState().trees.existing?.root).toBe("/mounted");
});
it("refreshes resolved mappings when the local mapping changes", async () => {
const server = { ...DEFAULT_DEPLOYMENT_SERVER, id: "local", kind: "local" as const };
await ensureRoot(server);
const mappings = [{ local: "/project/src", deployment: "/srv" }];
ipc.root.mockResolvedValue({ status: "ok", data: { root: "/srv", mappings } });
await ensureRoot({ ...server, mappings: [{ local: "/alias", deployment: "/", web: "" }] });
expect(ipc.root).toHaveBeenCalledTimes(2);
expect(useDeployment.getState().trees.local?.mappings).toEqual(mappings);
});
it("downloads using backend-resolved folders instead of the configured alias", async () => {
const server = {
...DEFAULT_DEPLOYMENT_SERVER,
id: "local",
kind: "local" as const,
mappings: [{ local: "/alias", deployment: ".", web: "" }],
};
const mappings = [{ local: "/project/src", deployment: "/srv" }];
ipc.root.mockResolvedValue({ status: "ok", data: { root: "/srv", mappings } });
const entry = {
path: "/srv/a.txt",
name: "a.txt",
isDir: false,
isSymlink: false,
size: 5,
mtime: null,
permissions: "rw-r--r--",
};
await downloadToProject(server, [entry]);
expect(ipc.download).toHaveBeenCalledWith(server, ["/project/src/a.txt"]);
ipc.download.mockClear();
ipc.root.mockResolvedValue({ status: "ok", data: { root: "/srv", mappings: [] } });
await downloadToProject(server, [entry]);
expect(ipc.download).not.toHaveBeenCalled();
expect(ipc.notify).toHaveBeenCalledWith(expect.objectContaining({ title: "Not mapped to a project path" }));
});
+12 -9
Переглянути файл
@@ -14,7 +14,8 @@ import { EMPTY_TREE, patchTree, useDeployment } from "./store";
const errorText = (e: unknown) => txTemplate(e instanceof Error ? e.message : String(e));
const sourceKey = (server: DeploymentServerValid) => JSON.stringify([deploymentConnection(server), server.rootPath]);
const sourceKey = (server: DeploymentServerValid) =>
JSON.stringify([deploymentConnection(server), server.rootPath, server.mappings, useProject.getState().project?.root]);
const tree = (server: string) => useDeployment.getState().trees[server] ?? EMPTY_TREE;
@@ -46,7 +47,7 @@ export async function loadFolder(server: DeploymentServerValid, path: string) {
}
}
/** Resolves the server root once per session and lists it. */
/** Resolves the server root and mappings, then lists the root. */
export async function ensureRoot(server: DeploymentServerValid, force = false) {
const source = sourceKey(server);
if (tree(server.id).source !== source) patchTree(server.id, { ...EMPTY_TREE, source });
@@ -54,15 +55,15 @@ export async function ensureRoot(server: DeploymentServerValid, force = false) {
if (current.root && !force && current.listings[current.root]) return;
patchTree(server.id, { error: null });
try {
const root =
const resolved =
current.root && !force
? current.root
? { root: current.root, mappings: current.mappings }
: await withSsh(server.kind === "sftp" ? server.sshConfig : "", () =>
call(commands.deployServerRoot(server.id)),
);
if (tree(server.id).source !== source) return;
patchTree(server.id, { root });
await loadFolder(server, root);
patchTree(server.id, resolved);
await loadFolder(server, resolved.root);
} catch (e) {
if (tree(server.id).source !== source) return;
patchTree(server.id, { error: errorText(e) });
@@ -158,12 +159,14 @@ export async function deleteEntries(server: DeploymentServerValid, entries: Remo
/** Downloads mapped entries to their local paths; unmapped ones are reported. */
export async function downloadToProject(server: DeploymentServerValid, entries: RemoteEntry[]) {
const projectRoot = useProject.getState().project?.root;
const root = tree(server.id).root;
if (!projectRoot || !root) return;
if (!projectRoot) return;
await ensureRoot(server, true);
const { root, mappings } = tree(server.id);
if (!root || tree(server.id).error) return;
const locals: string[] = [];
const unmapped: string[] = [];
for (const e of entries) {
const local = localPathForRemote(e.path, root, server.mappings, projectRoot);
const local = localPathForRemote(e.path, mappings);
if (local) locals.push(local);
else unmapped.push(remoteName(e.path));
}
+3 -1
Переглянути файл
@@ -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 { TransferReport, RemoteEntry, RequestId, SyncEntry } from "@/platform/ipc";
import type { TransferReport, ResolvedMapping, RemoteEntry, RequestId, SyncEntry } from "@/platform/ipc";
import type { FocusedPart, SyncChoice } from "./model";
export const REMOTE_HOST_WINDOW = "remoteHost";
@@ -29,6 +29,7 @@ export interface SecretPrompt {
export interface RemoteTreeState {
source: string | null;
root: string | null;
mappings: ResolvedMapping[];
error: string | null;
listings: Record<string, RemoteEntry[]>;
folderErrors: Record<string, string>;
@@ -56,6 +57,7 @@ export const useDeployment = create<DeploymentState>(() => ({
export const EMPTY_TREE: RemoteTreeState = {
source: null,
root: null,
mappings: [],
error: null,
listings: {},
folderErrors: {},
+1 -1
Переглянути файл
@@ -459,7 +459,7 @@ function MappingsTab({
<div className={styles.note}>
<T>
{
"Local paths are relative to the project root (empty: the whole project); deployment paths are relative to the server's root path."
"Local paths are relative to the project root or absolute inside it (empty: the whole project); deployment paths are relative to the server's root path."
}
</T>
</div>