mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 04:34:21 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20f6a7b4c7 | ||
|
|
c333990404 | ||
|
|
70b953c77a | ||
|
|
cb18b88531 |
No files matched your search
@@ -77,6 +77,8 @@ pub enum AutoUpload {
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DeploymentSettings {
|
||||
pub servers: Vec<DeploymentServer>,
|
||||
/// Server groups: Upload to… on a group uploads to each of its servers.
|
||||
pub groups: Vec<DeploymentGroup>,
|
||||
/// Server used by Upload / Download / Sync and automatic upload; empty = none.
|
||||
pub default_server: String,
|
||||
pub auto_upload: AutoUpload,
|
||||
@@ -92,6 +94,7 @@ impl Default for DeploymentSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
servers: Vec::new(),
|
||||
groups: Vec::new(),
|
||||
default_server: String::new(),
|
||||
auto_upload: AutoUpload::Never,
|
||||
delete_remote: false,
|
||||
@@ -103,6 +106,16 @@ impl Default for DeploymentSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Named set of deployment servers that files are uploaded to together.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DeploymentGroup {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// [`DeploymentServer::id`]s, in upload order.
|
||||
pub servers: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DeploymentType {
|
||||
|
||||
@@ -4090,6 +4090,14 @@ export type DeploymentCoverage = {
|
||||
skipped: TransferError[],
|
||||
};
|
||||
|
||||
/** Named set of deployment servers that files are uploaded to together. */
|
||||
export type DeploymentGroup = {
|
||||
id?: string,
|
||||
name?: string,
|
||||
/** [`DeploymentServer::id`]s, in upload order. */
|
||||
servers?: string[],
|
||||
};
|
||||
|
||||
/** Local files to compare with a deployment server (Sync with Deployed). */
|
||||
export type DeploymentLaunch = {
|
||||
/** Deployment server id. */
|
||||
@@ -4133,6 +4141,8 @@ export type DeploymentServer = {
|
||||
/** Build, Execution, Deployment › Deployment (and its Options page). */
|
||||
export type DeploymentSettings = {
|
||||
servers?: DeploymentServer[],
|
||||
/** Server groups: Upload to… on a group uploads to each of its servers. */
|
||||
groups?: DeploymentGroup[],
|
||||
/** Server used by Upload / Download / Sync and automatic upload; empty = none. */
|
||||
defaultServer?: string,
|
||||
autoUpload?: AutoUpload,
|
||||
|
||||
@@ -307,7 +307,7 @@ describe("variables the adapter computes after the stop", () => {
|
||||
expect(await screen.findByText("Collecting data…")).toBeTruthy();
|
||||
adapterData.library = "com.example.library.Library@1";
|
||||
act(() => handleDebugEvent(1, { kind: "invalidated", areas: ["variables"] }));
|
||||
const value = await screen.findByText("com.example.library.Library@1");
|
||||
const value = await screen.findByText("{Library@1}");
|
||||
expect(screen.queryByText("Collecting data…")).toBeNull();
|
||||
expect(value.closest("[role=treeitem]")?.querySelector("[class*=changed]")).toBeNull();
|
||||
});
|
||||
@@ -348,7 +348,7 @@ describe("variables the adapter computes after the stop", () => {
|
||||
await screen.findByText("Collecting data…");
|
||||
adapterData.library = "Library@2";
|
||||
await act(() => setVariableValue({ container: 1, name: "count", evaluateName: "count" }, "4", session));
|
||||
expect(await screen.findByText("Library@2")).toBeTruthy();
|
||||
expect(await screen.findByText("{Library@2}")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does nothing for an invalidation while the program runs", async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EditorSelection, EditorState } from "@codemirror/state";
|
||||
import { selectionExpression } from "../editor/valueTooltip";
|
||||
import { classifyValue, identifiersIn, inlineValue, sortVariables } from "../values";
|
||||
import { classifyValue, identifiersIn, inlineValue, shownValue, sortVariables } from "../values";
|
||||
import { inlineValuesFor } from "../editor/execution";
|
||||
import { frameLabel, groupFrames, isLibraryFrame } from "../frames";
|
||||
import type { StackFrame } from "@/platform/ipc";
|
||||
@@ -150,3 +150,25 @@ describe("selectionExpression", () => {
|
||||
expect(selectionExpression(multi)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shown values", () => {
|
||||
it.each([
|
||||
["String[0]@9", "String[]", { label: "{String[0]@9}", text: "[]" }],
|
||||
["int[3]@12", "int[]", { label: "{int[3]@12}", text: "" }],
|
||||
["HashMap@10 size=1", "HashMap", { label: "{HashMap@10}", text: "size = 1" }],
|
||||
["ArrayList@15 size=0", "java.util.ArrayList", { label: "{ArrayList@15}", text: "size = 0" }],
|
||||
["Db@21", "Db", { label: "{Db@21}", text: "" }],
|
||||
["com.example.Library@1", "Library", { label: "{Library@1}", text: "" }],
|
||||
['HashMap$Node@31 "a":"b"', "HashMap$Node", { label: null, text: '"a" -> "b"', entry: true }],
|
||||
['HashMap$Node@32 "a:b":"c"', "HashMap$Node", { label: null, text: '"a:b" -> "c"', entry: true }],
|
||||
['TreeMap$Entry@33 1:"x"', "TreeMap$Entry", { label: null, text: '1 -> "x"', entry: true }],
|
||||
['"hello"', "String", { label: null, text: '"hello"' }],
|
||||
["42", "int", { label: "{int}", text: "42" }],
|
||||
// Other debuggers' values keep their text and type.
|
||||
["Foo@1", "Bar", { label: "{Bar}", text: "Foo@1" }],
|
||||
["{'a': 1}", "dict", { label: "{dict}", text: "{'a': 1}" }],
|
||||
["0x1000", null, { label: null, text: "0x1000" }],
|
||||
])("%s {%s}", (value, type, shown) => {
|
||||
expect(shownValue(value, type)).toEqual(shown);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import { showHint } from "@/editor/lsp/hint";
|
||||
import { activeSession, useExecutionPoint } from "../state";
|
||||
import { onWorkflowSettingsChange, workflowSettings } from "@/platform/workflowSettings";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { identifiersIn, inlineValue } from "../values";
|
||||
import { identifiersIn, inlineValue, shownValue } from "../values";
|
||||
import { valueHint } from "./valueTooltip";
|
||||
import styles from "./editor.module.css";
|
||||
|
||||
@@ -113,11 +113,13 @@ export function inlineValuesFor(
|
||||
evaluateName: id,
|
||||
})
|
||||
: raw;
|
||||
const shown = shownValue(variable.value, variable.typeName);
|
||||
const value = shown.text || shown.label || variable.value;
|
||||
parts.push({
|
||||
name: id,
|
||||
text: `${id}: ${inlineValue(variable.value, 40)}`,
|
||||
text: `${id}: ${inlineValue(value, 40)}`,
|
||||
variable,
|
||||
truncated: variable.value.length > 40,
|
||||
truncated: value.length > 40,
|
||||
});
|
||||
if (parts.length >= MAX_PER_LINE) break;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { showHint } from "@/editor/lsp/hint";
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import { navigationModifierHeld } from "@/platform/navigationModifier";
|
||||
import { activeSession, evaluate } from "../state";
|
||||
import { inlineValue } from "../values";
|
||||
import { inlineValue, shownValue } from "../values";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { iconMarkup, selectableText } from "@/ui";
|
||||
import { mountValueTree } from "../ui/ValueHintTree";
|
||||
@@ -70,13 +70,19 @@ export function valueHint(
|
||||
const name = document.createElement("span");
|
||||
name.className = styles.valueName!;
|
||||
name.textContent = `${expression} = `;
|
||||
dom.append(name, max === null ? value.value : inlineValue(value.value, max));
|
||||
if (value.typeName) {
|
||||
const type = document.createElement("span");
|
||||
type.className = styles.valueType!;
|
||||
type.textContent = ` {${value.typeName}}`;
|
||||
dom.append(type);
|
||||
}
|
||||
const shown = shownValue(value.value, value.typeName);
|
||||
// A JVM object's `{Type@id}` leads its value; other types follow it.
|
||||
const leading = shown.label?.includes("@") ?? false;
|
||||
const type = (text: string) => {
|
||||
const span = document.createElement("span");
|
||||
span.className = styles.valueType!;
|
||||
span.textContent = text;
|
||||
return span;
|
||||
};
|
||||
dom.append(name);
|
||||
if (leading) dom.append(type(`${shown.label} `));
|
||||
dom.append(max === null ? shown.text : inlineValue(shown.text, max));
|
||||
if (shown.label && !leading) dom.append(type(` ${shown.label}`));
|
||||
return { dom, destroy: () => tree?.unmount() };
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { addFieldWatchpoint } from "../breakpoints/special";
|
||||
import { methodAt } from "../breakpoints/symbols";
|
||||
import { activeSession, addWatch, removeWatch, revealFrame, sessionSupports, setVariableValue } from "../state";
|
||||
import { blurDebugView, focusDebugView } from "./focusedView";
|
||||
import { classifyValue, oneLine, sortVariables } from "../values";
|
||||
import { classifyValue, oneLine, shownValue, sortVariables } from "../values";
|
||||
import { valueText } from "../valueText";
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import styles from "./VariablesView.module.css";
|
||||
@@ -486,20 +486,25 @@ function VarRow({
|
||||
</span>
|
||||
);
|
||||
const kind = node.error ? null : classifyValue(node.value, node.typeName);
|
||||
const shown = shownValue(node.value, node.typeName);
|
||||
return (
|
||||
<span className={styles.row}>
|
||||
<Icon name={nodeIcon(node)} size={16} className={styles.nodeIcon} />
|
||||
<span className={clsx(styles.name, node.kind === "var" && styles.varName, changed && styles.changed)}>
|
||||
{node.name}
|
||||
</span>
|
||||
<span className={styles.eq}>=</span>
|
||||
{!shown.entry && (
|
||||
<>
|
||||
<span className={clsx(styles.name, node.kind === "var" && styles.varName, changed && styles.changed)}>
|
||||
{node.name}
|
||||
</span>
|
||||
<span className={styles.eq}>=</span>
|
||||
</>
|
||||
)}
|
||||
{node.error ? (
|
||||
<span className={styles.error}>{oneLine(node.error)}</span>
|
||||
) : (
|
||||
<>
|
||||
{node.typeName && <span className={styles.type}>{`{${node.typeName}}`}</span>}
|
||||
{shown.label && <span className={styles.type}>{shown.label}</span>}
|
||||
<span ref={valueRef} className={clsx(styles.value, kind && styles[kind])} title={node.value}>
|
||||
{oneLine(node.value)}
|
||||
{oneLine(shown.text)}
|
||||
</span>
|
||||
{overflow && (
|
||||
<button
|
||||
|
||||
@@ -69,3 +69,35 @@ export function sortVariables<T extends { name: string }>(vars: readonly T[]): T
|
||||
if (vars.every((v) => INDEX.test(v.name))) return [...vars];
|
||||
return [...vars].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
|
||||
}
|
||||
|
||||
/** How a variable's type and value read in the Variables view: the braced label before the value, then the value. */
|
||||
export interface ShownValue {
|
||||
label: string | null;
|
||||
text: string;
|
||||
/** A map entry: the row shows `key -> value` alone, without its index. */
|
||||
entry?: true;
|
||||
}
|
||||
|
||||
// `HashMap@10 size=1`, `String[0]@9`: a JVM object as the Java debugger writes it (type, then the object id).
|
||||
const JVM_OBJECT = /^([\w$.]+(?:\[\d*\])*)@(\d+)(?:\s+(.*))?$/s;
|
||||
// A map entry's key, at the start of `"a":"b"` or `1:"b"`.
|
||||
const ENTRY_KEY = /^("(?:[^"\\]|\\.)*"|[^:"]+?):(.*)$/s;
|
||||
|
||||
/**
|
||||
* JVM objects read as `{HashMap@10} size = 1` and a map entry as `"a" -> "b"`; strings show without
|
||||
* a type. Values in other forms keep the adapter's text under `{type}`.
|
||||
*/
|
||||
export function shownValue(value: string, typeName: string | null | undefined): ShownValue {
|
||||
const plain = { label: typeName ? `{${typeName}}` : null, text: value };
|
||||
if (!typeName) return plain;
|
||||
if (typeName === "String" && /^".*"$/s.test(value)) return { label: null, text: value };
|
||||
const object = JVM_OBJECT.exec(value);
|
||||
if (!object) return plain;
|
||||
const [, head, id, rest = ""] = object;
|
||||
const simple = (t: string) => t.replace(/\[\d*\]/g, "[]").replace(/^.*\./, "");
|
||||
if (simple(head!) !== simple(typeName)) return plain;
|
||||
const entry = /(?:^|\$)\w*(?:Entry|Node)$/.test(typeName) ? ENTRY_KEY.exec(rest) : null;
|
||||
if (entry) return { label: null, text: `${entry[1]!.trim()} -> ${entry[2]!.trim()}`, entry: true };
|
||||
const text = rest.replace(/\b(size|length)=(\d+)/g, "$1 = $2") || (/\[0\]/.test(head!) ? "[]" : "");
|
||||
return { label: `{${head!.replace(/^.*\./, "")}@${id}}`, text };
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { SyncEntry } from "@/platform/ipc";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
actions: [] as { id: string; run: (context: { arg: string[] }) => unknown }[],
|
||||
servers: [] as DeploymentServerValid[],
|
||||
groups: [] as { id: string; name: string; servers: string[] }[],
|
||||
coverage: [] as { server: string; paths: string[]; skipped: { path: string; message: string }[] }[],
|
||||
choose: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
@@ -37,7 +38,7 @@ vi.mock("@/platform/project", () => ({
|
||||
useProject: { getState: () => ({ project: { root: "/project" } }) },
|
||||
}));
|
||||
vi.mock("@/platform/workflowSettings", () => ({
|
||||
workflowSettings: () => ({ deployment: { servers: mocks.servers, defaultServer: "one" } }),
|
||||
workflowSettings: () => ({ deployment: { servers: mocks.servers, groups: mocks.groups, defaultServer: "one" } }),
|
||||
}));
|
||||
vi.mock("@/workbench/contributions", () => ({
|
||||
notifications: () => ({ notify: mocks.notify }),
|
||||
@@ -72,6 +73,7 @@ async function run(id: string, paths = ["/project/a", "/project/b"]) {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.servers = ["one", "two"].map((id) => ({ id, name: id, kind: "local" }) as DeploymentServerValid);
|
||||
mocks.groups = [];
|
||||
mocks.coverage = [
|
||||
{
|
||||
server: "one",
|
||||
@@ -91,6 +93,34 @@ beforeEach(() => {
|
||||
useDeployment.setState({ sync: null });
|
||||
registerDeploymentActions();
|
||||
});
|
||||
describe("server groups", () => {
|
||||
it("offers a group with a covering server and uploads to each of its servers, past a failure", async () => {
|
||||
mocks.groups = [
|
||||
{ id: "g", name: "Production", servers: ["one", "two"] },
|
||||
{ id: "none", name: "Elsewhere", servers: ["two"] },
|
||||
];
|
||||
mocks.coverage[1]!.paths = [];
|
||||
mocks.choose.mockResolvedValue("Production (group)");
|
||||
mocks.upload.mockRejectedValueOnce(new Error("connection refused"));
|
||||
await run("deployment.uploadTo");
|
||||
expect(mocks.choose).toHaveBeenCalledWith({ title: "Upload to", items: ["one", "Production (group)"] });
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "No selected paths can be deployed to two" }),
|
||||
),
|
||||
);
|
||||
expect(mocks.upload).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({ title: "Cannot upload to one" }));
|
||||
});
|
||||
it("uploads to every covered server of the group in order", async () => {
|
||||
mocks.groups = [{ id: "g", name: "Production", servers: ["two", "one"] }];
|
||||
mocks.coverage[1]!.paths = ["/project/b"];
|
||||
mocks.choose.mockResolvedValue("Production (group)");
|
||||
await run("deployment.uploadTo");
|
||||
await vi.waitFor(() => expect(mocks.upload).toHaveBeenCalledTimes(2));
|
||||
expect(mocks.upload.mock.calls.map((c) => c[1])).toEqual(["two", "one"]);
|
||||
});
|
||||
});
|
||||
describe("deployment eligibility", () => {
|
||||
it("keeps upstream's server-specific explanation when only one server is configured", async () => {
|
||||
mocks.servers = [mocks.servers[0]!];
|
||||
|
||||
@@ -89,13 +89,13 @@ function notifyNoCoverage(server?: DeploymentServerValid, skipped?: TransferRepo
|
||||
: "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",
|
||||
key: server ? `deployment.noCoverage.${server.id}` : "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> {
|
||||
/** The servers covering `paths`, or null (after saying why) when none does. */
|
||||
async function coveringServers(paths: string[]): Promise<DeploymentServerValid[] | null> {
|
||||
const configured = deployment().servers;
|
||||
if (!configured.length) {
|
||||
notifyNoServers();
|
||||
@@ -119,11 +119,36 @@ async function pickServer(title: string, paths: string[]): Promise<DeploymentSer
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return servers;
|
||||
}
|
||||
|
||||
/** Keep the chooser visible even when only one server covers the selection. */
|
||||
async function pickServer(title: string, paths: string[]): Promise<DeploymentServerValid | null> {
|
||||
const servers = await coveringServers(paths);
|
||||
if (!servers) return null;
|
||||
const labels = servers.map(serverLabel);
|
||||
const picked = await chooseItem({ title, items: labels });
|
||||
return picked === null ? null : (servers[labels.indexOf(picked)] ?? null);
|
||||
}
|
||||
|
||||
/** Upload to…: a server covering the selection, or a group with such a server (all its servers). */
|
||||
async function pickUploadTargets(paths: string[]): Promise<DeploymentServerValid[] | null> {
|
||||
const servers = await coveringServers(paths);
|
||||
if (!servers) return null;
|
||||
const { servers: configured, groups } = deployment();
|
||||
const choices = new Map<string, DeploymentServerValid[]>();
|
||||
for (const server of servers) if (!choices.has(serverLabel(server))) choices.set(serverLabel(server), [server]);
|
||||
for (const group of groups) {
|
||||
const members = group.servers.flatMap((id) => configured.find((s) => s.id === id) ?? []);
|
||||
if (!members.some((m) => servers.includes(m))) continue;
|
||||
const name = group.name.trim() || group.id;
|
||||
const label = tx`${name} (group)`;
|
||||
if (!choices.has(label)) choices.set(label, members);
|
||||
}
|
||||
const picked = await chooseItem({ title: "Upload to", items: [...choices.keys()] });
|
||||
return picked === null ? null : (choices.get(picked) ?? null);
|
||||
}
|
||||
|
||||
async function defaultServer(title: string, paths: string[]): Promise<DeploymentServerValid | null> {
|
||||
return resolveServer(deployment(), null) ?? pickServer(title, paths);
|
||||
}
|
||||
@@ -160,7 +185,8 @@ export function showReport(
|
||||
title: s.title.replace("{n}", String(s.params.n)).replace("{server}", s.params.server),
|
||||
body: report.errors.length || report.skipped ? <ReportBody report={report} /> : undefined,
|
||||
group: GROUP,
|
||||
key: `deployment.${verb}`,
|
||||
// One per server, so each server of a group reports on its own.
|
||||
key: `deployment.${verb}.${server.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -171,7 +197,7 @@ function showFailure(title: string, server: DeploymentServerValid, e: unknown) {
|
||||
title: title.replace("{server}", serverLabel(server)),
|
||||
body: errorText(e),
|
||||
group: GROUP,
|
||||
key: "deployment.failure",
|
||||
key: `deployment.failure.${server.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -451,7 +477,10 @@ export function registerDeploymentActions(): () => void {
|
||||
category: CATEGORY,
|
||||
when,
|
||||
keywords: ["deploy", "sftp", "upload", "remote"],
|
||||
run: withTargets(async (paths) => transfer("upload", await pickServer("Upload to", paths), paths)),
|
||||
run: withTargets(async (paths) => {
|
||||
// A group uploads to each server in turn; each reports on its own, and a failure doesn't stop the rest.
|
||||
for (const server of (await pickUploadTargets(paths)) ?? []) await transfer("upload", server, paths);
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "deployment.downloadRemoteInto",
|
||||
|
||||
@@ -194,3 +194,13 @@
|
||||
padding: 16px;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.groupMembers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.groupTitle {
|
||||
color: var(--fg-subtle);
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
@@ -13,10 +13,24 @@ import {
|
||||
deploymentConnectable,
|
||||
deploymentConnection,
|
||||
deploymentSecretKey,
|
||||
type DeploymentGroupValid,
|
||||
type DeploymentServerValid,
|
||||
type FtpConnectionValid,
|
||||
} from "@/platform/remoteSettings";
|
||||
import { Badge, Button, Checkbox, Dialog, Icon, IconButton, Input, Select, Spinner, T, Tabs } from "@/ui";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
Icon,
|
||||
IconButton,
|
||||
Input,
|
||||
Select,
|
||||
showContextMenu,
|
||||
Spinner,
|
||||
T,
|
||||
Tabs,
|
||||
} from "@/ui";
|
||||
import { GlobListEditor, Row } from "../components/Form";
|
||||
import { SshConfigSelect, useSshConfigs } from "../components/SshConfigSelect";
|
||||
import { useSetting } from "../components/context";
|
||||
@@ -25,10 +39,13 @@ import {
|
||||
fixDefaultServer,
|
||||
folderWithin,
|
||||
formatExcludes,
|
||||
newGroup,
|
||||
newServer,
|
||||
parseExcludes,
|
||||
pruneGroups,
|
||||
remoteParent,
|
||||
removeMapping,
|
||||
toggleGroupServer,
|
||||
updateMapping,
|
||||
} from "./deploymentModel";
|
||||
import { choosePath } from "../languages/api";
|
||||
@@ -611,44 +628,118 @@ function ExcludedPathsTab({
|
||||
|
||||
type ServerTab = "connection" | "mappings" | "excluded";
|
||||
|
||||
function GroupPanel({
|
||||
group,
|
||||
servers,
|
||||
update,
|
||||
}: {
|
||||
group: DeploymentGroupValid;
|
||||
servers: readonly DeploymentServerValid[];
|
||||
update: (patch: Partial<DeploymentGroupValid>) => void;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<TextField label="Name" value={group.name} onChange={(name) => update({ name })} />
|
||||
<div className={styles.groupMembers} role="group" aria-label={tx("Servers in group")}>
|
||||
<div className={styles.groupTitle}>
|
||||
<T>Servers in group</T>
|
||||
</div>
|
||||
{servers.length === 0 && (
|
||||
<div className={styles.note}>
|
||||
<T>No servers. Click + to add one.</T>
|
||||
</div>
|
||||
)}
|
||||
{servers.map((s) => (
|
||||
<Checkbox
|
||||
key={s.id}
|
||||
checked={group.servers.includes(s.id)}
|
||||
onChange={() => update(toggleGroupServer(group, s.id, servers))}
|
||||
label={<span>{s.name || s.id}</span>}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.note}>
|
||||
<T>Upload to… on this group uploads the files to each of its servers in turn.</T>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Selection = { kind: "server" | "group"; id: string } | null;
|
||||
|
||||
function DeploymentServersEditor() {
|
||||
const tx = useTx();
|
||||
const [servers, setServers] = useSetting<DeploymentServerValid[]>("deployment.servers");
|
||||
const [groups, setGroups] = useSetting<DeploymentGroupValid[]>("deployment.groups");
|
||||
const [defaultServer, setDefaultServer] = useSetting<string>("deployment.defaultServer");
|
||||
const list = servers ?? [];
|
||||
const [selected, setSelected] = useState<string | null>(list[0]?.id ?? null);
|
||||
const groupList = groups ?? [];
|
||||
const [selected, setSelected] = useState<Selection>(list[0] ? { kind: "server", id: list[0].id } : null);
|
||||
const [tab, setTab] = useState<ServerTab>("connection");
|
||||
const current = list.find((s) => s.id === selected) ?? null;
|
||||
const current = selected?.kind === "server" ? (list.find((s) => s.id === selected.id) ?? null) : null;
|
||||
const currentGroup = selected?.kind === "group" ? (groupList.find((g) => g.id === selected.id) ?? null) : null;
|
||||
const effectiveDefault = fixDefaultServer(list, defaultServer ?? "");
|
||||
|
||||
const commit = (next: DeploymentServerValid[]) => {
|
||||
setServers(next);
|
||||
const d = fixDefaultServer(next, defaultServer ?? "");
|
||||
if (d !== (defaultServer ?? "")) setDefaultServer(d);
|
||||
const pruned = pruneGroups(groupList, next);
|
||||
if (pruned.some((g, i) => g !== groupList[i])) setGroups(pruned);
|
||||
};
|
||||
const update = (patch: Partial<DeploymentServerValid>) => {
|
||||
if (current) commit(list.map((s) => (s.id === current.id ? { ...s, ...patch } : s)));
|
||||
};
|
||||
const updateGroup = (patch: Partial<DeploymentGroupValid>) => {
|
||||
if (currentGroup) setGroups(groupList.map((g) => (g.id === currentGroup.id ? { ...g, ...patch } : g)));
|
||||
};
|
||||
const add = (base?: DeploymentServerValid) => {
|
||||
const server = newServer(list, base);
|
||||
commit([...list, server]);
|
||||
setSelected(server.id);
|
||||
setSelected({ kind: "server", id: server.id });
|
||||
setTab("connection");
|
||||
};
|
||||
const addGroup = () => {
|
||||
const group = newGroup(groupList);
|
||||
setGroups([...groupList, group]);
|
||||
setSelected({ kind: "group", id: group.id });
|
||||
};
|
||||
const remove = () => {
|
||||
if (currentGroup) {
|
||||
const index = groupList.indexOf(currentGroup);
|
||||
const rest = groupList.filter((g) => g.id !== currentGroup.id);
|
||||
setGroups(rest);
|
||||
const next = rest[Math.min(index, rest.length - 1)] ?? null;
|
||||
setSelected(next ? { kind: "group", id: next.id } : list[0] ? { kind: "server", id: list[0].id } : null);
|
||||
return;
|
||||
}
|
||||
if (!current) return;
|
||||
const index = list.indexOf(current);
|
||||
const rest = list.filter((s) => s.id !== current.id);
|
||||
commit(rest);
|
||||
setSelected(rest[Math.min(index, rest.length - 1)]?.id ?? null);
|
||||
const next = rest[Math.min(index, rest.length - 1)] ?? null;
|
||||
setSelected(next ? { kind: "server", id: next.id } : null);
|
||||
};
|
||||
const isSelected = (kind: "server" | "group", id: string) => selected?.kind === kind && selected.id === id;
|
||||
|
||||
return (
|
||||
<div className={styles.layout}>
|
||||
<div className={workflow.tableBox}>
|
||||
<div className={workflow.toolbar} role="toolbar" aria-label={tx("Deployment servers actions")}>
|
||||
<IconButton icon="plus" title="Add" size="small" onClick={() => add()} />
|
||||
<IconButton icon="minus" title="Remove" size="small" disabled={!current} onClick={remove} />
|
||||
<IconButton
|
||||
icon="plus"
|
||||
title="Add"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
showContextMenu({ x: r.left, y: r.bottom }, [
|
||||
{ label: "Server", icon: "web", run: () => add() },
|
||||
{ label: "Server Group", icon: "folder", run: addGroup },
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
<IconButton icon="minus" title="Remove" size="small" disabled={!current && !currentGroup} onClick={remove} />
|
||||
<IconButton
|
||||
icon="copy"
|
||||
title="Copy"
|
||||
@@ -665,18 +756,31 @@ function DeploymentServersEditor() {
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.serverList} role="listbox" aria-label={tx("Deployment servers")}>
|
||||
{list.length === 0 && (
|
||||
{list.length === 0 && groupList.length === 0 && (
|
||||
<div className={styles.empty}>
|
||||
<T>No servers. Click + to add one.</T>
|
||||
</div>
|
||||
)}
|
||||
{groupList.map((g) => (
|
||||
<div
|
||||
key={g.id}
|
||||
role="option"
|
||||
aria-selected={isSelected("group", g.id)}
|
||||
className={clsx(styles.serverRow, isSelected("group", g.id) && styles.serverSelected)}
|
||||
onMouseDown={() => setSelected({ kind: "group", id: g.id })}
|
||||
>
|
||||
<Icon name="folder" />
|
||||
<span className={styles.serverName}>{g.name || g.id}</span>
|
||||
<Badge>{g.servers.length}</Badge>
|
||||
</div>
|
||||
))}
|
||||
{list.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
role="option"
|
||||
aria-selected={s.id === selected}
|
||||
className={clsx(styles.serverRow, s.id === selected && styles.serverSelected)}
|
||||
onMouseDown={() => setSelected(s.id)}
|
||||
aria-selected={isSelected("server", s.id)}
|
||||
className={clsx(styles.serverRow, isSelected("server", s.id) && styles.serverSelected)}
|
||||
onMouseDown={() => setSelected({ kind: "server", id: s.id })}
|
||||
>
|
||||
<Icon name="web" />
|
||||
<span className={clsx(styles.serverName, s.id === effectiveDefault && styles.default)}>
|
||||
@@ -687,7 +791,11 @@ function DeploymentServersEditor() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{current ? (
|
||||
{currentGroup ? (
|
||||
<div className={styles.detail}>
|
||||
<GroupPanel key={currentGroup.id} group={currentGroup} servers={list} update={updateGroup} />
|
||||
</div>
|
||||
) : current ? (
|
||||
<div className={styles.detail}>
|
||||
<Tabs
|
||||
variant="compact"
|
||||
@@ -780,6 +888,7 @@ export const DEPLOYMENT_PAGES: PageSpec[] = [
|
||||
"Passive mode",
|
||||
"Test Connection",
|
||||
"Use as Default",
|
||||
"Server Group",
|
||||
"Local path",
|
||||
"Deployment path",
|
||||
"Web path",
|
||||
|
||||
@@ -5,14 +5,40 @@ import {
|
||||
fixDefaultServer,
|
||||
folderWithin,
|
||||
formatExcludes,
|
||||
newGroup,
|
||||
newServer,
|
||||
nextServerName,
|
||||
parseExcludes,
|
||||
pruneGroups,
|
||||
remoteParent,
|
||||
removeMapping,
|
||||
toggleGroupServer,
|
||||
updateMapping,
|
||||
} from "./deploymentModel";
|
||||
|
||||
describe("server groups", () => {
|
||||
const servers = ["a", "b", "c"].map((id) => ({ ...DEFAULT_DEPLOYMENT_SERVER, id, name: id }));
|
||||
it("names a new group after the first free number", () => {
|
||||
const g = newGroup([{ id: "x", name: "Group 2", servers: [] }]);
|
||||
expect(g.name).toBe("Group 3");
|
||||
expect(g.servers).toEqual([]);
|
||||
expect(g.id).toMatch(/^group-/);
|
||||
});
|
||||
it("toggles members, keeping the server list's order", () => {
|
||||
let g = { id: "g", name: "G", servers: [] as string[] };
|
||||
g = toggleGroupServer(g, "c", servers);
|
||||
g = toggleGroupServer(g, "a", servers);
|
||||
expect(g.servers).toEqual(["a", "c"]);
|
||||
expect(toggleGroupServer(g, "a", servers).servers).toEqual(["c"]);
|
||||
});
|
||||
it("drops removed servers from groups and keeps untouched groups as they are", () => {
|
||||
const keep = { id: "k", name: "K", servers: ["a"] };
|
||||
const pruned = pruneGroups([keep, { id: "g", name: "G", servers: ["a", "b"] }], servers.slice(0, 1));
|
||||
expect(pruned[0]).toBe(keep);
|
||||
expect(pruned[1]!.servers).toEqual(["a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exclude names", () => {
|
||||
it("parses a ;-separated list", () => {
|
||||
expect(parseExcludes(".git; .idea;;*.pyc ;")).toEqual([".git", ".idea", "*.pyc"]);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import {
|
||||
DEFAULT_DEPLOYMENT_SERVER,
|
||||
newRemoteId,
|
||||
type DeploymentGroupValid,
|
||||
type DeploymentMappingValid,
|
||||
type DeploymentServerValid,
|
||||
} from "@/platform/remoteSettings";
|
||||
@@ -67,6 +68,37 @@ export function newServer(
|
||||
};
|
||||
}
|
||||
|
||||
/** A new, empty group named "Group <n>" (the first free number). */
|
||||
export function newGroup(groups: readonly DeploymentGroupValid[]): DeploymentGroupValid {
|
||||
const names = new Set(groups.map((g) => g.name));
|
||||
let n = groups.length + 1;
|
||||
while (names.has(`Group ${n}`)) n++;
|
||||
return { id: newRemoteId("group"), name: `Group ${n}`, servers: [] };
|
||||
}
|
||||
|
||||
/** Adds or removes `server` in `group`, keeping the members in the server list's order. */
|
||||
export function toggleGroupServer(
|
||||
group: DeploymentGroupValid,
|
||||
server: string,
|
||||
servers: readonly DeploymentServerValid[],
|
||||
): DeploymentGroupValid {
|
||||
const members = new Set(group.servers);
|
||||
if (members.has(server)) members.delete(server);
|
||||
else members.add(server);
|
||||
return { ...group, servers: servers.map((s) => s.id).filter((id) => members.has(id)) };
|
||||
}
|
||||
|
||||
/** `groups` without members that are no longer in `servers`. */
|
||||
export function pruneGroups(
|
||||
groups: readonly DeploymentGroupValid[],
|
||||
servers: readonly DeploymentServerValid[],
|
||||
): DeploymentGroupValid[] {
|
||||
const ids = new Set(servers.map((s) => s.id));
|
||||
return groups.map((g) =>
|
||||
g.servers.every((id) => ids.has(id)) ? g : { ...g, servers: g.servers.filter((id) => ids.has(id)) },
|
||||
);
|
||||
}
|
||||
|
||||
/** The default server after `servers` changed: kept when still present, else the first one (or none). */
|
||||
export function fixDefaultServer(servers: readonly DeploymentServerValid[], defaultServer: string): string {
|
||||
if (servers.some((s) => s.id === defaultServer)) return defaultServer;
|
||||
|
||||
@@ -264,6 +264,7 @@ describe("catalogs", () => {
|
||||
"my-agent",
|
||||
"Java",
|
||||
"Python",
|
||||
"Python {short}",
|
||||
"C/C++",
|
||||
"C#",
|
||||
"Go",
|
||||
|
||||
@@ -1506,5 +1506,9 @@
|
||||
"Port": "Port",
|
||||
"The fixed port on 127.0.0.1, used while Use a fixed port is on.": "The fixed port on 127.0.0.1, used while Use a fixed port is on.",
|
||||
"Keep the token": "Keep the token",
|
||||
"Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks.": "Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks."
|
||||
"Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks.": "Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks.",
|
||||
"{name} (group)": "{name} (group)",
|
||||
"Servers in group": "Servers in group",
|
||||
"Upload to… on this group uploads the files to each of its servers in turn.": "Upload to… on this group uploads the files to each of its servers in turn.",
|
||||
"Server Group": "Server Group"
|
||||
}
|
||||
@@ -7907,5 +7907,9 @@
|
||||
"Listen on the same port in every run, so HTTP clients and port forwards keep working after OpenIDEA restarts. When the port is in use, this run takes a free one and Status says so. Off: a free port in every run.": "每次运行都监听同一个端口,这样 OpenIDEA 重启后 HTTP 客户端和端口转发仍然有效。端口被占用时,本次运行改用一个空闲端口,并在状态中说明。关闭时:每次运行使用一个空闲端口。",
|
||||
"The fixed port on 127.0.0.1, used while Use a fixed port is on.": "127.0.0.1 上的固定端口,在开启“使用固定端口”时使用。",
|
||||
"Keep the token": "保留令牌",
|
||||
"Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks.": "每次运行都使用同一个访问令牌,只为你的账户保存。关闭时:每次运行生成新令牌。任何拿到令牌的人都能在 OpenIDEA 运行时使用这些工具,令牌泄露时请重新生成。"
|
||||
"Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks.": "每次运行都使用同一个访问令牌,只为你的账户保存。关闭时:每次运行生成新令牌。任何拿到令牌的人都能在 OpenIDEA 运行时使用这些工具,令牌泄露时请重新生成。",
|
||||
"{name} (group)": "{name}(分组)",
|
||||
"Servers in group": "组内服务器",
|
||||
"Upload to… on this group uploads the files to each of its servers in turn.": "对此分组执行“上传到…”时,会依次上传到组内的每台服务器。",
|
||||
"Server Group": "服务器分组"
|
||||
}
|
||||
@@ -43,8 +43,17 @@ export interface DeploymentServerValid {
|
||||
excludedPaths: string[];
|
||||
}
|
||||
|
||||
/** Servers that Upload to… on the group uploads to, one after another. */
|
||||
export interface DeploymentGroupValid {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Server ids, in upload order. */
|
||||
servers: string[];
|
||||
}
|
||||
|
||||
export interface DeploymentSettingsValid {
|
||||
servers: DeploymentServerValid[];
|
||||
groups: DeploymentGroupValid[];
|
||||
defaultServer: string;
|
||||
autoUpload: AutoUploadValid;
|
||||
deleteRemote: boolean;
|
||||
@@ -106,6 +115,7 @@ export const DEFAULT_DEPLOYMENT_SERVER: DeploymentServerValid = {
|
||||
|
||||
export const DEFAULT_DEPLOYMENT: DeploymentSettingsValid = {
|
||||
servers: [],
|
||||
groups: [],
|
||||
defaultServer: "",
|
||||
autoUpload: "never",
|
||||
deleteRemote: false,
|
||||
@@ -206,8 +216,14 @@ export function validateDeployment(raw: unknown): DeploymentSettingsValid {
|
||||
mappings: validateMappings(s.mappings),
|
||||
excludedPaths: strings(s.excludedPaths, []),
|
||||
}));
|
||||
const groups = withIds(o.groups, "group", (g) => ({
|
||||
id: text(g.id, ""),
|
||||
name: text(g.name, ""),
|
||||
servers: strings(g.servers, []),
|
||||
}));
|
||||
return {
|
||||
servers,
|
||||
groups,
|
||||
defaultServer: text(o.defaultServer, d.defaultServer),
|
||||
autoUpload: oneOf(o.autoUpload, ["never", "onExplicitSave", "always"] as const, d.autoUpload),
|
||||
deleteRemote: bool(o.deleteRemote, d.deleteRemote),
|
||||
|
||||
@@ -344,6 +344,9 @@ export function MenuList({
|
||||
aria-label={aria["aria-label"]}
|
||||
className={styles.menu}
|
||||
onKeyDown={onKeyDown}
|
||||
// A right-click in an open menu does nothing; without this it reaches whatever is under the
|
||||
// menu in the component tree, e.g. the title bar's toolbar menu behind File › Open Recent.
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
onPointerMove={(e) => {
|
||||
if (e.currentTarget.contains(e.target as Node)) pointerMove({ x: e.clientX, y: e.clientY });
|
||||
}}
|
||||
|
||||
@@ -283,6 +283,17 @@ describe("Menu action enablement", () => {
|
||||
expect(open).toHaveFocus();
|
||||
});
|
||||
|
||||
it("keeps a right-click in a menu from reaching the components behind it", () => {
|
||||
const behind = vi.fn((e: { defaultPrevented: boolean }) => e.defaultPrevented);
|
||||
render(
|
||||
<div onContextMenu={(e) => behind(e)}>
|
||||
<MenuList items={[{ label: "Recent", run: () => undefined }]} onDone={() => {}} />
|
||||
</div>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByRole("menuitem", { name: "Recent" }));
|
||||
expect(behind).toHaveReturnedWith(true);
|
||||
});
|
||||
|
||||
it("reports a hovered row once and lets the keyboard continue from it", () => {
|
||||
const active: string[] = [];
|
||||
const item = (label: string) => ({ label, onActive: () => active.push(label), run: () => undefined });
|
||||
|
||||
Reference in new issue
Block a user