дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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]>
Цей коміт міститься в:
1 джерело
35ba957893
коміт
dd8c8b8993
3 files changed
+178
-15
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Заблокувати користувача