Compare commits

..
Author SHA1 Message Date
l0ng-ai 564f08b062 test(i18n): allow "Python {short}" to read the same in every locale
The conda Python version picker labels its entries "Python 3.12", which every locale writes like English.
2026-11-13 19:11:53 +08:00
19 changed files with 42 additions and 406 deletions

No files matched your search

-13
View File
@@ -77,8 +77,6 @@ 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,
@@ -94,7 +92,6 @@ 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,
@@ -106,16 +103,6 @@ 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 {
-10
View File
@@ -4090,14 +4090,6 @@ 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. */
@@ -4141,8 +4133,6 @@ 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("{Library@1}");
const value = await screen.findByText("com.example.library.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 -23
View File
@@ -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, shownValue, sortVariables } from "../values";
import { classifyValue, identifiersIn, inlineValue, sortVariables } from "../values";
import { inlineValuesFor } from "../editor/execution";
import { frameLabel, groupFrames, isLibraryFrame } from "../frames";
import type { StackFrame } from "@/platform/ipc";
@@ -150,25 +150,3 @@ 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);
});
});
+3 -5
View File
@@ -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, shownValue } from "../values";
import { identifiersIn, inlineValue } from "../values";
import { valueHint } from "./valueTooltip";
import styles from "./editor.module.css";
@@ -113,13 +113,11 @@ 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(value, 40)}`,
text: `${id}: ${inlineValue(variable.value, 40)}`,
variable,
truncated: value.length > 40,
truncated: variable.value.length > 40,
});
if (parts.length >= MAX_PER_LINE) break;
}
+8 -14
View File
@@ -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, shownValue } from "../values";
import { inlineValue } from "../values";
import { tx } from "@/platform/i18n";
import { iconMarkup, selectableText } from "@/ui";
import { mountValueTree } from "../ui/ValueHintTree";
@@ -70,19 +70,13 @@ export function valueHint(
const name = document.createElement("span");
name.className = styles.valueName!;
name.textContent = `${expression} = `;
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}`));
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);
}
return { dom, destroy: () => tree?.unmount() };
}
+7 -12
View File
@@ -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, shownValue, sortVariables } from "../values";
import { classifyValue, oneLine, sortVariables } from "../values";
import { valueText } from "../valueText";
import { workflowSettings } from "@/platform/workflowSettings";
import styles from "./VariablesView.module.css";
@@ -486,25 +486,20 @@ 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} />
{!shown.entry && (
<>
<span className={clsx(styles.name, node.kind === "var" && styles.varName, changed && styles.changed)}>
{node.name}
</span>
<span className={styles.eq}>=</span>
</>
)}
<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>
) : (
<>
{shown.label && <span className={styles.type}>{shown.label}</span>}
{node.typeName && <span className={styles.type}>{`{${node.typeName}}`}</span>}
<span ref={valueRef} className={clsx(styles.value, kind && styles[kind])} title={node.value}>
{oneLine(shown.text)}
{oneLine(node.value)}
</span>
{overflow && (
<button
-32
View File
@@ -69,35 +69,3 @@ 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 };
}
+1 -31
View File
@@ -5,7 +5,6 @@ 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(),
@@ -38,7 +37,7 @@ vi.mock("@/platform/project", () => ({
useProject: { getState: () => ({ project: { root: "/project" } }) },
}));
vi.mock("@/platform/workflowSettings", () => ({
workflowSettings: () => ({ deployment: { servers: mocks.servers, groups: mocks.groups, defaultServer: "one" } }),
workflowSettings: () => ({ deployment: { servers: mocks.servers, defaultServer: "one" } }),
}));
vi.mock("@/workbench/contributions", () => ({
notifications: () => ({ notify: mocks.notify }),
@@ -73,7 +72,6 @@ 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",
@@ -93,34 +91,6 @@ 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]!];
+6 -35
View File
@@ -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: server ? `deployment.noCoverage.${server.id}` : "deployment.noCoverage",
key: "deployment.noCoverage",
actions: [{ label: "Configure…", run: () => void executeAction("deployment.configure") }],
});
}
/** The servers covering `paths`, or null (after saying why) when none does. */
async function coveringServers(paths: string[]): Promise<DeploymentServerValid[] | null> {
/** 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();
@@ -119,36 +119,11 @@ async function coveringServers(paths: string[]): Promise<DeploymentServerValid[]
}
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);
}
@@ -185,8 +160,7 @@ 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,
// One per server, so each server of a group reports on its own.
key: `deployment.${verb}.${server.id}`,
key: `deployment.${verb}`,
});
}
@@ -197,7 +171,7 @@ function showFailure(title: string, server: DeploymentServerValid, e: unknown) {
title: title.replace("{server}", serverLabel(server)),
body: errorText(e),
group: GROUP,
key: `deployment.failure.${server.id}`,
key: "deployment.failure",
});
}
@@ -477,10 +451,7 @@ export function registerDeploymentActions(): () => void {
category: CATEGORY,
when,
keywords: ["deploy", "sftp", "upload", "remote"],
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);
}),
run: withTargets(async (paths) => transfer("upload", await pickServer("Upload to", paths), paths)),
},
{
id: "deployment.downloadRemoteInto",
@@ -194,13 +194,3 @@
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;
}
+12 -121
View File
@@ -13,24 +13,10 @@ import {
deploymentConnectable,
deploymentConnection,
deploymentSecretKey,
type DeploymentGroupValid,
type DeploymentServerValid,
type FtpConnectionValid,
} from "@/platform/remoteSettings";
import {
Badge,
Button,
Checkbox,
Dialog,
Icon,
IconButton,
Input,
Select,
showContextMenu,
Spinner,
T,
Tabs,
} from "@/ui";
import { Badge, Button, Checkbox, Dialog, Icon, IconButton, Input, Select, Spinner, T, Tabs } from "@/ui";
import { GlobListEditor, Row } from "../components/Form";
import { SshConfigSelect, useSshConfigs } from "../components/SshConfigSelect";
import { useSetting } from "../components/context";
@@ -39,13 +25,10 @@ import {
fixDefaultServer,
folderWithin,
formatExcludes,
newGroup,
newServer,
parseExcludes,
pruneGroups,
remoteParent,
removeMapping,
toggleGroupServer,
updateMapping,
} from "./deploymentModel";
import { choosePath } from "../languages/api";
@@ -628,118 +611,44 @@ 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 groupList = groups ?? [];
const [selected, setSelected] = useState<Selection>(list[0] ? { kind: "server", id: list[0].id } : null);
const [selected, setSelected] = useState<string | null>(list[0]?.id ?? null);
const [tab, setTab] = useState<ServerTab>("connection");
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 current = list.find((s) => s.id === selected) ?? 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({ kind: "server", id: server.id });
setSelected(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);
const next = rest[Math.min(index, rest.length - 1)] ?? null;
setSelected(next ? { kind: "server", id: next.id } : null);
setSelected(rest[Math.min(index, rest.length - 1)]?.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={(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="plus" title="Add" size="small" onClick={() => add()} />
<IconButton icon="minus" title="Remove" size="small" disabled={!current} onClick={remove} />
<IconButton
icon="copy"
title="Copy"
@@ -756,31 +665,18 @@ function DeploymentServersEditor() {
/>
</div>
<div className={styles.serverList} role="listbox" aria-label={tx("Deployment servers")}>
{list.length === 0 && groupList.length === 0 && (
{list.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={isSelected("server", s.id)}
className={clsx(styles.serverRow, isSelected("server", s.id) && styles.serverSelected)}
onMouseDown={() => setSelected({ kind: "server", id: s.id })}
aria-selected={s.id === selected}
className={clsx(styles.serverRow, s.id === selected && styles.serverSelected)}
onMouseDown={() => setSelected(s.id)}
>
<Icon name="web" />
<span className={clsx(styles.serverName, s.id === effectiveDefault && styles.default)}>
@@ -791,11 +687,7 @@ function DeploymentServersEditor() {
))}
</div>
</div>
{currentGroup ? (
<div className={styles.detail}>
<GroupPanel key={currentGroup.id} group={currentGroup} servers={list} update={updateGroup} />
</div>
) : current ? (
{current ? (
<div className={styles.detail}>
<Tabs
variant="compact"
@@ -888,7 +780,6 @@ export const DEPLOYMENT_PAGES: PageSpec[] = [
"Passive mode",
"Test Connection",
"Use as Default",
"Server Group",
"Local path",
"Deployment path",
"Web path",
@@ -5,40 +5,14 @@ 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,7 +2,6 @@
import {
DEFAULT_DEPLOYMENT_SERVER,
newRemoteId,
type DeploymentGroupValid,
type DeploymentMappingValid,
type DeploymentServerValid,
} from "@/platform/remoteSettings";
@@ -68,37 +67,6 @@ 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;
+1 -5
View File
@@ -1506,9 +1506,5 @@
"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.",
"{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"
"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."
}
+1 -5
View File
@@ -7907,9 +7907,5 @@
"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 运行时使用这些工具,令牌泄露时请重新生成。",
"{name} (group)": "{name}(分组)",
"Servers in group": "组内服务器",
"Upload to… on this group uploads the files to each of its servers in turn.": "对此分组执行“上传到…”时,会依次上传到组内的每台服务器。",
"Server Group": "服务器分组"
"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 运行时使用这些工具,令牌泄露时请重新生成。"
}
-16
View File
@@ -43,17 +43,8 @@ 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;
@@ -115,7 +106,6 @@ export const DEFAULT_DEPLOYMENT_SERVER: DeploymentServerValid = {
export const DEFAULT_DEPLOYMENT: DeploymentSettingsValid = {
servers: [],
groups: [],
defaultServer: "",
autoUpload: "never",
deleteRemote: false,
@@ -216,14 +206,8 @@ 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),
-3
View File
@@ -344,9 +344,6 @@ 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 });
}}
-11
View File
@@ -283,17 +283,6 @@ 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 });