feat(run): searchable multi-add member picker for Compound configurations (#452)

Adding members to a Compound configuration went through a plain dropdown that closed after each pick and could not be searched. It is now a searchable picker that stays open, so several configurations can be added in a row; Enter adds the highlighted row, and Esc or an outside click closes it. Member rows are left-aligned because the label, not the icon, takes the free space. Members already added, the compound itself and candidates that would close a cycle stay excluded.

Closes #329

Co-authored-by: a-rookie-of-C-language <[email protected]>
Цей коміт міститься в:
arookieofcтаa-rookie-of-C-language authored and GitHub committed 2026-10-30 23:59:46 +08:00
1 джерело 35ba957893
коміт dd8c8b8993
3 files changed
+178 -15

No files matched your search

+162 -14
Переглянути файл
@@ -3,12 +3,29 @@
* common ones, the options added through "Modify options" (⌥M) and the
* Before launch list.
*/
import { useRef, useState, type ReactNode } from "react";
import { useMemo, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from "react";
import clsx from "clsx";
import type { BeforeLaunchTask, RunConfig } from "@/platform/ipc";
import { useWorkflowSettings } from "@/platform/workflowSettings";
import { useTx, useTxTemplate } from "@/platform/i18n";
import { Button, Checkbox, Icon, IconButton, Input, Menu, Select, T, type MenuItem, type SelectOption } from "@/ui";
import {
Button,
Checkbox,
HighlightedText,
Icon,
IconButton,
Input,
List,
listNavigate,
Menu,
Popup,
SearchField,
Select,
T,
fuzzyMatch,
type MenuItem,
type SelectOption,
} from "@/ui";
import { useContributions, type RunConfigTypeContribution } from "@/workbench/contributions";
import { TASK_LABELS, taskLabel, type BeforeLaunchKind } from "./beforeLaunch";
import {
@@ -712,6 +729,26 @@ function OutputField({ label, text }: { label: string; text: string }) {
);
}
/** Candidate rows for a compound's member picker: every config except itself and ones already added. */
export function memberRows(
config: RunConfig,
all: readonly RunConfig[],
members: readonly string[],
query: string,
): Array<{ config: RunConfig; indices: number[] | null }> {
const q = query.trim();
return all
.filter(
(c) =>
c.id !== config.id &&
!members.includes(c.id) &&
!compoundCycle({ ...config, compound: [...members, c.id] }, all),
)
.map((c) => ({ c, indices: q ? fuzzyMatch(c.name, q) : null }))
.filter((x) => !q || x.indices)
.map(({ c, indices }) => ({ config: c, indices }));
}
function CompoundMembers({
config,
all,
@@ -725,10 +762,9 @@ function CompoundMembers({
}) {
const tx = useTx();
const members = config.compound ?? [];
const candidates = all.filter(
(c) =>
c.id !== config.id && !members.includes(c.id) && !compoundCycle({ ...config, compound: [...members, c.id] }, all),
);
const addRef = useRef<HTMLButtonElement>(null);
const [addOpen, setAddOpen] = useState(false);
const hasCandidates = memberRows(config, all, members, "").length > 0;
return (
<Field label="Configurations" error={error} top>
<div className={styles.taskList} role="list" aria-label={tx("Configurations of the compound")}>
@@ -748,19 +784,131 @@ function CompoundMembers({
</div>
);
})}
<Select
value={null}
placeholder="Add configuration…"
options={candidates.map((c) => ({ value: c.id, label: c.name }))}
onChange={(id) => onChange([...members, id])}
disabled={!candidates.length}
aria-label="Add configuration"
/>
<Button
ref={addRef}
size="small"
variant="ghost"
icon="plus"
aria-haspopup="listbox"
aria-expanded={addOpen}
disabled={!hasCandidates}
onClick={() => setAddOpen(true)}
>
<T>Add configuration…</T>
</Button>
{addOpen && (
<MemberPicker
anchor={addRef}
config={config}
all={all}
members={members}
onAdd={(id) => onChange([...members, id])}
onClose={() => setAddOpen(false)}
/>
)}
</div>
</Field>
);
}
const MEMBER_ROW = 24;
/** "Add configuration" picker for a compound: stays open while configs are picked, so several can be added. */
function MemberPicker({
anchor,
config,
all,
members,
onAdd,
onClose,
}: {
anchor: RefObject<HTMLButtonElement | null>;
config: RunConfig;
all: readonly RunConfig[];
members: readonly string[];
onAdd: (id: string) => void;
onClose: () => void;
}) {
const [query, setQuery] = useState("");
const rows = useMemo(() => memberRows(config, all, members, query), [config, all, members, query]);
const [active, setActive] = useState(0);
const clamped = Math.min(active, rows.length - 1);
const pick = (row: { config: RunConfig } | undefined) => {
if (!row) return;
onAdd(row.config.id);
setQuery("");
setActive(0);
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
pick(rows[clamped]);
return;
}
const next = listNavigate(e.key, { items: rows, active: clamped, wrap: true });
if (next !== null) {
e.preventDefault();
setActive(next);
}
};
return (
<Popup
open
anchor={anchor}
onClose={onClose}
placement="bottom-start"
aria-label="Add configuration"
className={styles.chooser}
trapFocus
>
<div className={styles.chooserSearch}>
<SearchField
value={query}
onChange={(v) => {
setQuery(v);
setActive(0);
}}
placeholder="Search configurations"
aria-label="Search configurations"
data-autofocus=""
onKeyDown={onKeyDown}
variant="bare"
/>
</div>
<div style={{ height: Math.max(MEMBER_ROW, Math.min(rows.length * MEMBER_ROW, 10 * MEMBER_ROW)) }}>
<List
aria-label="Configurations"
className={styles.chooserList}
items={rows}
getKey={(r) => r.config.id}
activeIndex={clamped}
onActiveChange={setActive}
focusable={false}
hoverSelects
rowHeight={MEMBER_ROW}
onActivate={(r) => pick(r)}
empty={
<span className={styles.chooserMuted}>
<T>Nothing found</T>
</span>
}
renderItem={(r) => (
<span className={styles.chooserRow}>
<Icon name={configIcon(r.config)} size={13} className={styles.chooserIcon} />
<span className={styles.chooserName}>
{r.indices?.length ? <HighlightedText text={r.config.name} indices={r.indices} /> : r.config.name}
</span>
</span>
)}
/>
</div>
</Popup>
);
}
export function Field({
label,
error,
+4 -1
Переглянути файл
@@ -563,9 +563,12 @@
min-height: var(--h-row);
}
.taskRow > :first-child {
flex: 1;
min-width: 0;
}
.taskRow > .taskLabel,
.taskRow > input {
flex: 1;
}
.taskLabel {
overflow: hidden;
text-overflow: ellipsis;
+12
Переглянути файл
@@ -16,6 +16,7 @@ import {
import { typeRows } from "./AddConfigPopup";
import { runBeforeLaunch } from "./beforeLaunch";
import { buildRows } from "./ConfigChooser";
import { memberRows } from "./ConfigForm";
import { compoundChildren, compoundCycle, formatEnv, parseEnv, validateConfig } from "./configs";
import { contextConfig } from "./context";
import { EditConfigsOverlay, moveConfig, treeRows } from "./EditConfigsDialog";
@@ -347,6 +348,17 @@ describe("compound configurations", () => {
.sort(),
).toEqual(["a", "b"]);
});
it("member picker lists candidates and filters them by name", () => {
// `outer` contains `inner`, so adding it to `inner` would close a cycle.
const rows = memberRows(inner, [a, b, inner, outer], [], "");
expect(rows.map((r) => r.config.id)).toEqual(["a", "b"]);
// Already-added members and the compound itself are excluded.
expect(memberRows(inner, [a, b, inner, outer], ["a"], "").map((r) => r.config.id)).toEqual(["b"]);
const named = cfg("named", { name: "Deploy App" });
expect(memberRows(outer, [a, b, inner, outer, named], ["a"], "deploy").map((r) => r.config.id)).toEqual(["named"]);
expect(memberRows(outer, [a, b, inner, outer, named], ["a"], "zzz")).toEqual([]);
});
});
describe("before launch tasks", () => {