дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
0396f0d552
коміт
2d4d6ee6bf
17 files changed
+372
-65
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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] : []),
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" }));
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Заблокувати користувача