дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
fix(build-tools): 优化 Maven 命令分组与跳过测试的视觉反馈 (#2095)
The Maven command window listed recent commands and goals in one flat list, and Skip Tests gave no sign that the test phase would not run. Recent commands and goals now show as separate groups, in the Gradle entry point too, and with Skip Tests on the Maven test phase is greyed and struck through. Run Anything uses the UI font and group separators. Siblings covered: selection and Tab completion skip group headers and stay valid while typing, and Run Anything keeps results that other providers contribute. Co-authored-by: PLZY <[email protected]>
Цей коміт міститься в:
1 джерело
24bee8eef2
коміт
a601a51b94
12 files changed
+432
-52
No files matched your search
@@ -232,6 +232,7 @@ function BuildBanner({ info, tool }: { info: MavenModel | GradleModel; tool: Bui
|
||||
|
||||
function NodeRow({ node, state }: { node: BuildNode; state: TreeRowState }) {
|
||||
const choices = useBuild((s) => s.settings.maven.profiles);
|
||||
const skipTests = useBuild((s) => s.settings.maven.skipTests);
|
||||
if (node.kind === "profile") {
|
||||
return (
|
||||
<span className={styles.row} onClick={(e) => e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()}>
|
||||
@@ -255,7 +256,15 @@ function NodeRow({ node, state }: { node: BuildNode; state: TreeRowState }) {
|
||||
return (
|
||||
<span className={styles.row} title={node.kind === "task" ? (node.task.description ?? undefined) : undefined}>
|
||||
<Icon name={nodeIcon(node)} size={16} className={styles.icon} />
|
||||
<span className={clsx(styles.label, state.selected && styles.selectedLabel)}>{nodeLabel(node)}</span>
|
||||
<span
|
||||
className={clsx(
|
||||
styles.label,
|
||||
state.selected && styles.selectedLabel,
|
||||
skipTests && node.kind === "phase" && node.phase === "test" && styles.skippedGoal,
|
||||
)}
|
||||
>
|
||||
{nodeLabel(node)}
|
||||
</span>
|
||||
{detail && <span className={styles.detail}>{detail}</span>}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -79,6 +79,10 @@
|
||||
text-overflow: ellipsis;
|
||||
color: var(--fg);
|
||||
}
|
||||
.skippedGoal {
|
||||
color: var(--fg-muted);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.selectedLabel {
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
@@ -95,7 +99,7 @@
|
||||
|
||||
/* Execute Maven Goal / Gradle Task popup */
|
||||
.popup {
|
||||
width: 560px;
|
||||
width: 620px;
|
||||
max-width: calc(100vw - 32px);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -124,18 +128,37 @@
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--border-popup);
|
||||
}
|
||||
.commandIcon {
|
||||
flex: none;
|
||||
}
|
||||
.inputPrefix {
|
||||
flex: none;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-code);
|
||||
color: var(--fg-subtle);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--fs-ui);
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.input {
|
||||
flex: 1;
|
||||
font-family: var(--font-mono);
|
||||
min-width: 0;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
.completionSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 10px;
|
||||
padding: 0;
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.completionSection::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border-popup);
|
||||
}
|
||||
.completions {
|
||||
max-height: 280px;
|
||||
max-height: min(380px, 55vh);
|
||||
height: auto;
|
||||
}
|
||||
.completion {
|
||||
@@ -146,8 +169,10 @@
|
||||
flex: 1;
|
||||
}
|
||||
.completionLabel {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-code);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--fs-ui);
|
||||
color: var(--fg-emphasis);
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
@@ -157,6 +182,12 @@
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.completionDetail {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 45%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-style: italic;
|
||||
margin-left: auto;
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { afterEach, beforeEach, expect, it } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import type { GradleModel } from "@/platform/ipc";
|
||||
import { ExecutePopupHost } from "./ExecutePopup";
|
||||
import { goalCompletions } from "./model";
|
||||
import { pushHistory, useBuild } from "./store";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useBuild.setState({ root: "/p", model: null, executeOpen: "maven" });
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
useBuild.setState({ executeOpen: null });
|
||||
});
|
||||
|
||||
it("separates recent commands from Maven goals and accepts a recent command with Tab", () => {
|
||||
pushHistory("maven", "package -o");
|
||||
render(<ExecutePopupHost />);
|
||||
expect(screen.getByText("Recent")).toBeTruthy();
|
||||
expect(screen.getByText("Maven Goals")).toBeTruthy();
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
expect(input).toHaveValue("package -o");
|
||||
});
|
||||
|
||||
it("skips section headers during keyboard navigation", () => {
|
||||
pushHistory("maven", "package -o");
|
||||
render(<ExecutePopupHost />);
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
expect(input).toHaveValue("clean ");
|
||||
});
|
||||
|
||||
it("omits the empty Recent section when no commands have been run", () => {
|
||||
render(<ExecutePopupHost />);
|
||||
expect(screen.queryByText("Recent")).toBeNull();
|
||||
expect(screen.getByText("Maven Goals")).toBeTruthy();
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
expect(input).toHaveValue("clean ");
|
||||
});
|
||||
|
||||
it("starts PageUp on a completion rather than a section", () => {
|
||||
render(<ExecutePopupHost />);
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.keyDown(input, { key: "PageUp" });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(input).toHaveValue("clean ");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[["ArrowUp"], "site-deploy "],
|
||||
[["ArrowUp", "ArrowDown"], "package -o"],
|
||||
[["ArrowDown", "ArrowUp"], "site-deploy "],
|
||||
[["ArrowDown", "ArrowDown", "ArrowUp"], "package -o"],
|
||||
[["PageDown", "PageUp"], "package -o"],
|
||||
] as const)("navigates sections and wraps with %j", (keys, expected) => {
|
||||
pushHistory("maven", "package -o");
|
||||
render(<ExecutePopupHost />);
|
||||
const input = screen.getByRole("textbox");
|
||||
for (const key of keys) fireEvent.keyDown(input, { key });
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
expect(input).toHaveValue(expected);
|
||||
});
|
||||
|
||||
it("clears selection when typing changes the rows, and ignores section clicks", () => {
|
||||
pushHistory("maven", "package -o");
|
||||
render(<ExecutePopupHost />);
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.keyDown(input, { key: "ArrowUp" });
|
||||
fireEvent.change(input, { target: { value: "cl" } });
|
||||
fireEvent.click(screen.getByText("Maven Goals"));
|
||||
expect(input).toHaveValue("cl");
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
expect(input).toHaveValue("clean ");
|
||||
});
|
||||
|
||||
it("keeps an empty completion list safe for all navigation and acceptance keys", () => {
|
||||
render(<ExecutePopupHost />);
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: "-Dunknown" } });
|
||||
for (const key of ["ArrowDown", "ArrowUp", "PageDown", "PageUp", "Tab", "Enter"]) {
|
||||
fireEvent.keyDown(input, { key });
|
||||
expect(input).toHaveValue("-Dunknown");
|
||||
}
|
||||
expect(screen.queryByText("Maven Goals")).toBeNull();
|
||||
});
|
||||
|
||||
it("PageDown selects a completion rather than a section at its landing position", () => {
|
||||
const history = Array.from({ length: 9 }, (_, i) => `command-${i}`);
|
||||
for (const line of history) pushHistory("maven", line);
|
||||
render(<ExecutePopupHost />);
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(input, { key: "PageDown" });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(input).toHaveValue(`${goalCompletions("", [])[0]!.line} `);
|
||||
});
|
||||
|
||||
it("resets selection when changing the Gradle build used for completion", () => {
|
||||
const build = (dir: string, name: string): GradleModel => ({
|
||||
dir,
|
||||
projects: [
|
||||
{ path: ":", name, dir, buildFile: "build.gradle.kts", tasks: [{ name, group: "build", description: null }] },
|
||||
],
|
||||
javaVersion: null,
|
||||
command: "gradle",
|
||||
env: {},
|
||||
syncedAt: null,
|
||||
stale: false,
|
||||
syncError: null,
|
||||
});
|
||||
useBuild.setState({
|
||||
executeOpen: "gradle",
|
||||
model: { maven: null, gradle: [build("a", "assemble"), build("b", "build")] },
|
||||
});
|
||||
render(<ExecutePopupHost />);
|
||||
const input = screen.getByRole("textbox");
|
||||
expect(screen.getByText("Gradle Tasks")).toBeTruthy();
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Project to run in" }));
|
||||
fireEvent.click(within(screen.getByRole("listbox", { name: "Project to run in" })).getByText("build"));
|
||||
expect(screen.queryByRole("option", { selected: true })).toBeNull();
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
expect(input).toHaveValue("build ");
|
||||
act(() => useBuild.setState({ model: { maven: null, gradle: [] } }));
|
||||
fireEvent.keyDown(input, { key: "Tab" });
|
||||
expect(input).toHaveValue("build ");
|
||||
});
|
||||
@@ -7,12 +7,18 @@
|
||||
import { useMemo, useState, type KeyboardEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import type { BuildTool } from "@/platform/ipc";
|
||||
import { HighlightedText, Input, List, Popup, Select, listNavigate, T } from "@/ui";
|
||||
import { HighlightedText, Icon, Input, List, Popup, Select, listNavigate, T } from "@/ui";
|
||||
import { goalCompletions, gradleProjectForKey, gradleProjectKey, taskCompletions, type Completion } from "./model";
|
||||
import { executeBuildLine } from "./runAnything";
|
||||
import { readHistory, useBuild } from "./store";
|
||||
import styles from "./BuildTools.module.css";
|
||||
|
||||
type CompletionRow =
|
||||
| { kind: "section"; key: string; label: string }
|
||||
| { kind: "completion"; key: string; completion: Completion };
|
||||
|
||||
const isSection = (row: CompletionRow) => row.kind === "section";
|
||||
|
||||
export function ExecutePopupHost() {
|
||||
const tool = useBuild((s) => s.executeOpen);
|
||||
if (!tool) return null;
|
||||
@@ -23,7 +29,7 @@ function ExecutePopup({ tool }: { tool: BuildTool }) {
|
||||
const model = useBuild((s) => s.model);
|
||||
const current = useBuild((s) => s.current[tool]);
|
||||
const [line, setLine] = useState("");
|
||||
const [active, setActive] = useState(-1);
|
||||
const [selection, setSelection] = useState<{ rows: CompletionRow[]; index: number } | null>(null);
|
||||
const history = useMemo(() => readHistory(tool), [tool]);
|
||||
const maven = model?.maven;
|
||||
const gradle = model?.gradle;
|
||||
@@ -54,6 +60,33 @@ function ExecutePopup({ tool }: { tool: BuildTool }) {
|
||||
return selected ? taskCompletions(line, selected.build, history).slice(0, 200) : [];
|
||||
}, [tool, maven, gradle, project, line, history]);
|
||||
|
||||
const command = tool === "maven" ? "mvn" : "gradle";
|
||||
const commandIcon = tool === "maven" ? "file-maven" : "file-gradle";
|
||||
const rows = useMemo<CompletionRow[]>(() => {
|
||||
const sections = [
|
||||
{ key: "recent", label: "Recent", items: completions.filter((item) => item.detail === "recent") },
|
||||
{
|
||||
key: "commands",
|
||||
label: tool === "maven" ? "Maven Goals" : "Gradle Tasks",
|
||||
items: completions.filter((item) => item.detail !== "recent"),
|
||||
},
|
||||
];
|
||||
return sections.flatMap<CompletionRow>((section) => {
|
||||
if (!section.items.length) return [];
|
||||
return [
|
||||
{ kind: "section", key: section.key, label: section.label },
|
||||
...section.items.map((completion): CompletionRow => ({
|
||||
kind: "completion",
|
||||
key: `${section.key}:${completion.line}`,
|
||||
completion,
|
||||
})),
|
||||
];
|
||||
});
|
||||
}, [completions, tool]);
|
||||
const firstCompletion = rows.findIndex((row) => !isSection(row));
|
||||
const active = selection?.rows === rows ? selection.index : -1;
|
||||
const setActive = (index: number) => setSelection({ rows, index });
|
||||
|
||||
const close = () => useBuild.setState({ executeOpen: null });
|
||||
|
||||
const execute = () => {
|
||||
@@ -61,27 +94,28 @@ function ExecutePopup({ tool }: { tool: BuildTool }) {
|
||||
if (text && executeBuildLine(tool, text, project)) close();
|
||||
};
|
||||
|
||||
const accept = (c: Completion | undefined) => {
|
||||
if (!c) return;
|
||||
setLine(c.detail === "recent" ? c.line : `${c.line} `);
|
||||
const accept = (row: CompletionRow | undefined) => {
|
||||
if (row?.kind !== "completion") return;
|
||||
const completion = row.completion;
|
||||
setLine(completion.detail === "recent" ? completion.line : `${completion.line} `);
|
||||
setActive(-1);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "PageDown" || e.key === "PageUp") {
|
||||
const next = listNavigate(e.key, { items: completions, active: Math.max(active, 0), wrap: true });
|
||||
const next = listNavigate(e.key, { items: rows, active, wrap: true, isSection });
|
||||
if (next !== null) {
|
||||
e.preventDefault();
|
||||
setActive(active < 0 && e.key === "ArrowDown" ? 0 : next);
|
||||
setActive(next < 0 ? firstCompletion : next);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
accept(completions[Math.max(active, 0)]);
|
||||
accept(rows[active < 0 ? firstCompletion : active]);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (active >= 0) accept(completions[active]);
|
||||
if (active >= 0) accept(rows[active]);
|
||||
else execute();
|
||||
}
|
||||
};
|
||||
@@ -107,7 +141,8 @@ function ExecutePopup({ tool }: { tool: BuildTool }) {
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.inputRow}>
|
||||
<span className={styles.inputPrefix}>{tool === "maven" ? "mvn" : "gradle"}</span>
|
||||
<Icon name={commandIcon} size={16} className={styles.commandIcon} />
|
||||
<span className={styles.inputPrefix}>{command}</span>
|
||||
<Input
|
||||
data-autofocus
|
||||
variant="bare"
|
||||
@@ -123,28 +158,44 @@ function ExecutePopup({ tool }: { tool: BuildTool }) {
|
||||
/>
|
||||
</div>
|
||||
<List
|
||||
items={completions}
|
||||
getKey={(c) => `${c.detail === "recent" ? "h" : "c"}:${c.line}`}
|
||||
items={rows}
|
||||
getKey={(row) => row.key}
|
||||
isSection={isSection}
|
||||
renderSection={(row) => (
|
||||
<div className={styles.completionSection}>{row.kind === "section" && <T>{row.label}</T>}</div>
|
||||
)}
|
||||
activeIndex={active}
|
||||
onActiveChange={setActive}
|
||||
onActivate={(c) => accept(c)}
|
||||
focusable={false}
|
||||
hoverSelects={false}
|
||||
rowHeight={22}
|
||||
rowHeight={24}
|
||||
sectionHeight={28}
|
||||
className={styles.completions}
|
||||
aria-label="Completions"
|
||||
empty={<div className={styles.empty}>{line.trim() ? "No completions" : "Type a goal"}</div>}
|
||||
renderItem={(c, state) => (
|
||||
<div className={clsx(styles.completion, state.active && styles.activeRow)}>
|
||||
<span className={styles.completionLabel}>
|
||||
<HighlightedText
|
||||
text={c.label}
|
||||
indices={c.detail === "recent" ? undefined : [...Array(Math.min(word.length, c.label.length)).keys()]}
|
||||
/>
|
||||
</span>
|
||||
{c.detail && <span className={styles.completionDetail}>{c.detail}</span>}
|
||||
</div>
|
||||
)}
|
||||
renderItem={(row, state) => {
|
||||
if (row.kind !== "completion") return null;
|
||||
const completion = row.completion;
|
||||
const recent = completion.detail === "recent";
|
||||
const matchStart = completion.line.length - completion.label.length;
|
||||
const indices = recent
|
||||
? undefined
|
||||
: Array.from(
|
||||
{ length: Math.min(word.length, completion.label.length) },
|
||||
(_, index) => matchStart + index,
|
||||
);
|
||||
const detail = recent || completion.detail === "phase" ? undefined : completion.detail;
|
||||
return (
|
||||
<div className={clsx(styles.completion, state.active && styles.activeRow)}>
|
||||
<Icon name={commandIcon} size={16} className={styles.commandIcon} />
|
||||
<span className={styles.completionLabel}>
|
||||
{command} <HighlightedText text={completion.line} indices={indices} />
|
||||
</span>
|
||||
{detail && <span className={styles.completionDetail}>{detail}</span>}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className={styles.popupFooter}>
|
||||
<span>
|
||||
|
||||
@@ -4,9 +4,11 @@ import { allActions, executeAction, isActionEnabled, registerAction } from "@/pl
|
||||
import { getContext, setContext } from "@/platform/context";
|
||||
import type { BuildModel, RunConfig } from "@/platform/ipc";
|
||||
import { useContributions } from "@/workbench/contributions";
|
||||
import { MavenView } from "./BuildToolView";
|
||||
import { GradleView, MavenView } from "./BuildToolView";
|
||||
import { activate } from "./index";
|
||||
import { gradleProjectKey } from "./model";
|
||||
import { useBuild } from "./store";
|
||||
import styles from "./BuildTools.module.css";
|
||||
|
||||
const mavenModel: BuildModel = {
|
||||
maven: {
|
||||
@@ -119,7 +121,12 @@ describe("build tools feature", () => {
|
||||
await executeAction("maven.toggleSkipTests");
|
||||
render(<MavenView />);
|
||||
expect(screen.getByText("Build files changed since the last reload")).toBeTruthy();
|
||||
const phase = screen.getByText("package");
|
||||
const phase = screen.getByText("test");
|
||||
expect(phase).toHaveClass(styles.skippedGoal!);
|
||||
await act(() => executeAction("maven.toggleSkipTests"));
|
||||
expect(phase).not.toHaveClass(styles.skippedGoal!);
|
||||
await act(() => executeAction("maven.toggleSkipTests"));
|
||||
expect(phase).toHaveClass(styles.skippedGoal!);
|
||||
await act(async () => {
|
||||
phase.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
|
||||
});
|
||||
@@ -128,10 +135,42 @@ describe("build tools feature", () => {
|
||||
const { config, temporary } = started[0] as { config: RunConfig; temporary: boolean };
|
||||
expect(temporary).toBe(true);
|
||||
expect(config).toMatchObject({
|
||||
name: "spring-demo [package]",
|
||||
name: "spring-demo [test]",
|
||||
command: "/p/mvnw",
|
||||
args: ["package", "-DskipTests"],
|
||||
args: ["test", "-DskipTests"],
|
||||
kind: "maven",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mark Gradle test tasks as skipped when Maven Skip Tests is on", async () => {
|
||||
const build = {
|
||||
dir: "",
|
||||
projects: [
|
||||
{
|
||||
path: ":",
|
||||
name: "demo",
|
||||
dir: "",
|
||||
buildFile: "build.gradle.kts",
|
||||
tasks: [{ name: "test", group: "verification", description: null }],
|
||||
},
|
||||
],
|
||||
command: "gradle",
|
||||
env: {},
|
||||
javaVersion: null,
|
||||
syncedAt: null,
|
||||
stale: false,
|
||||
syncError: null,
|
||||
};
|
||||
const key = gradleProjectKey(build, build.projects[0]!);
|
||||
act(() =>
|
||||
useBuild.setState({
|
||||
root: "/p",
|
||||
model: { maven: mavenModel.maven, gradle: [build] },
|
||||
expanded: { maven: new Set(), gradle: new Set([key, `${key}:tasks`, `${key}:tasks:verification`]) },
|
||||
}),
|
||||
);
|
||||
await executeAction("maven.toggleSkipTests");
|
||||
render(<GradleView />);
|
||||
expect(screen.getByText("test")).not.toHaveClass(styles.skippedGoal!);
|
||||
});
|
||||
});
|
||||
@@ -83,7 +83,11 @@ export const buildToolsRunAnything: RunAnythingProvider = {
|
||||
.map((c): RunAnythingItem => ({
|
||||
key: `${c.detail === "recent" ? "h" : "c"}:${c.line}`,
|
||||
label: `${WORD[tool]} ${c.line}`,
|
||||
detail: c.detail,
|
||||
detail: c.detail === "recent" || c.detail === "phase" ? undefined : c.detail,
|
||||
group:
|
||||
c.detail === "recent"
|
||||
? { id: "recent", label: "Recent" }
|
||||
: { id: tool, label: tool === "maven" ? "Maven Goals" : "Gradle Tasks" },
|
||||
icon: tool === "maven" ? "file-maven" : "file-gradle",
|
||||
completion: `${WORD[tool]} ${c.detail === "recent" ? c.line : `${c.line} `}`,
|
||||
run: () => void executeBuildLine(tool, c.line),
|
||||
|
||||
@@ -45,17 +45,27 @@
|
||||
}
|
||||
.input {
|
||||
flex: 1;
|
||||
font-family: var(--font-mono);
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
.rows {
|
||||
max-height: 360px;
|
||||
height: auto;
|
||||
}
|
||||
.section {
|
||||
padding: 4px 10px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 10px;
|
||||
padding: 0;
|
||||
font-size: var(--fs-small);
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.section::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border-popup);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -68,18 +78,25 @@
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: pre;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-code);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--fs-ui);
|
||||
color: var(--fg-emphasis);
|
||||
}
|
||||
.activeRow .label {
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.detail {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 45%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-style: italic;
|
||||
margin-left: auto;
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
|
||||
@@ -71,3 +71,79 @@ describe("run anything", () => {
|
||||
expect(cfg.name).toHaveLength(58);
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider command sections", () => {
|
||||
it("preserves same-label results, order and the limit for providers without item groups", () => {
|
||||
const items = Array.from({ length: 10 }, (_, i) => ({
|
||||
key: String(i),
|
||||
label: "Run",
|
||||
detail: `configuration ${i}`,
|
||||
completion: "run ",
|
||||
run: () => {},
|
||||
}));
|
||||
const providers = ["first", "second"].map((id) => ({ id, group: id, items: () => items }));
|
||||
const rows = buildRows("", ["run"], providers, () => {});
|
||||
expect(rows.filter((r) => r.kind === "section").map((r) => r.key)).toEqual(["s:recent", "s:first", "s:second"]);
|
||||
for (const id of ["first", "second"]) {
|
||||
const results = rows.filter((r) => r.kind === "item" && r.group === id);
|
||||
expect(results.map((r) => r.kind === "item" && r.item)).toEqual(items.slice(0, 8));
|
||||
}
|
||||
});
|
||||
|
||||
it("merges build-tool recents with shared history without duplicate commands", () => {
|
||||
const provider: RunAnythingProvider = {
|
||||
id: "build-tools",
|
||||
group: "Build Tools",
|
||||
items: () => [
|
||||
{
|
||||
key: "recent",
|
||||
label: "mvn clean",
|
||||
completion: "mvn clean ",
|
||||
group: { id: "recent", label: "Recent" },
|
||||
run: () => {},
|
||||
},
|
||||
{ key: "goal", label: "mvn clean", group: { id: "maven", label: "Maven Goals" }, run: () => {} },
|
||||
],
|
||||
};
|
||||
const rows = buildRows("mvn", ["mvn clean"], [provider], () => {});
|
||||
expect(rows.map((r) => (r.kind === "section" ? r.label : r.item.label))).toEqual([
|
||||
"Recent",
|
||||
"mvn clean",
|
||||
"Maven Goals",
|
||||
"mvn clean",
|
||||
]);
|
||||
expect(new Set(rows.map((r) => r.key)).size).toBe(rows.length);
|
||||
});
|
||||
|
||||
it("limits each section separately so recent commands cannot hide available goals", () => {
|
||||
const provider: RunAnythingProvider = {
|
||||
id: "build-tools",
|
||||
group: "Build Tools",
|
||||
items: () => [
|
||||
...Array.from({ length: 20 }, (_, i) => ({
|
||||
key: `recent-${i}`,
|
||||
label: `mvn command-${i}`,
|
||||
group: { id: "recent", label: "Recent" },
|
||||
run: () => {},
|
||||
})),
|
||||
{ key: "compile", label: "mvn compile", group: { id: "maven", label: "Maven Goals" }, run: () => {} },
|
||||
],
|
||||
};
|
||||
const rows = buildRows("mvn", [], [provider], () => {});
|
||||
expect(rows.filter((r) => r.kind === "item" && r.group === "recent")).toHaveLength(8);
|
||||
expect(rows.at(-1)).toMatchObject({ kind: "item", item: { label: "mvn compile" } });
|
||||
});
|
||||
|
||||
it("keeps groups from different providers separate even when their local ids match", () => {
|
||||
const providers: RunAnythingProvider[] = ["maven", "gradle"].map((id) => ({
|
||||
id,
|
||||
group: id,
|
||||
items: () => [{ key: "build", label: `${id} build`, group: { id: "commands", label: id }, run: () => {} }],
|
||||
}));
|
||||
const rows = buildRows("", [], providers, () => {});
|
||||
expect(rows.filter((r) => r.kind === "section").map((r) => r.key)).toEqual([
|
||||
"s:maven:commands",
|
||||
"s:gradle:commands",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -64,13 +64,18 @@ export function buildRows(
|
||||
providers: readonly RunAnythingProvider[],
|
||||
runLine: (line: string) => void,
|
||||
): Row[] {
|
||||
const rows: Row[] = [];
|
||||
const section = (key: string, label: string, items: RunAnythingItem[]) => {
|
||||
if (!items.length) return;
|
||||
rows.push({ kind: "section", key: `s:${key}`, label });
|
||||
for (const item of items) rows.push({ kind: "item", key: `${key}:${item.key}`, item, group: key });
|
||||
const groups = new Map<string, { label: string; items: RunAnythingItem[]; commands: Set<string> }>();
|
||||
const addItems = (key: string, label: string, items: RunAnythingItem[], deduplicate = false) => {
|
||||
const group = groups.get(key) ?? { label, items: [], commands: new Set<string>() };
|
||||
for (const item of items) {
|
||||
const command = (item.completion ?? item.label).trim();
|
||||
if (deduplicate && group.commands.has(command)) continue;
|
||||
group.commands.add(command);
|
||||
group.items.push(item);
|
||||
}
|
||||
if (group.items.length) groups.set(key, group);
|
||||
};
|
||||
section(
|
||||
addItems(
|
||||
"recent",
|
||||
"Recent",
|
||||
history
|
||||
@@ -81,11 +86,21 @@ export function buildRows(
|
||||
for (const p of providers) {
|
||||
let items: RunAnythingItem[] = [];
|
||||
try {
|
||||
items = p.items(query).slice(0, GROUP_LIMIT);
|
||||
items = p.items(query);
|
||||
} catch (e) {
|
||||
console.error(`run anything provider ${p.id} failed`, e);
|
||||
}
|
||||
section(p.id, p.group, items);
|
||||
for (const item of items) {
|
||||
const group = item.group;
|
||||
const key = group?.id === "recent" ? "recent" : group ? `${p.id}:${group.id}` : p.id;
|
||||
addItems(key, group?.label ?? p.group, [item], group?.id === "recent");
|
||||
}
|
||||
}
|
||||
const rows: Row[] = [];
|
||||
for (const [key, group] of groups) {
|
||||
rows.push({ kind: "section", key: `s:${key}`, label: group.label });
|
||||
for (const item of group.items.slice(0, GROUP_LIMIT))
|
||||
rows.push({ kind: "item", key: `${key}:${item.key}`, item, group: key });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -432,5 +432,7 @@
|
||||
"Always allowed": "Always allowed",
|
||||
"Declined": "Declined",
|
||||
"Always declined": "Always declined",
|
||||
"Branch '{name}' is checked out in the worktree at {path}.\nDelete the worktree and the branch? Its folder is deleted.": "Branch '{name}' is checked out in the worktree at {path}.\nDelete the worktree and the branch? Its folder is deleted."
|
||||
"Branch '{name}' is checked out in the worktree at {path}.\nDelete the worktree and the branch? Its folder is deleted.": "Branch '{name}' is checked out in the worktree at {path}.\nDelete the worktree and the branch? Its folder is deleted.",
|
||||
"Maven Goals": "Maven Goals",
|
||||
"Gradle Tasks": "Gradle Tasks"
|
||||
}
|
||||
@@ -6784,5 +6784,7 @@
|
||||
"Always allowed": "已设为始终允许",
|
||||
"Declined": "已拒绝",
|
||||
"Always declined": "已设为始终拒绝",
|
||||
"Branch '{name}' is checked out in the worktree at {path}.\nDelete the worktree and the branch? Its folder is deleted.": "分支 '{name}' 被位于 {path} 的工作树签出。\n删除该工作树和分支?其文件夹将被删除。"
|
||||
"Branch '{name}' is checked out in the worktree at {path}.\nDelete the worktree and the branch? Its folder is deleted.": "分支 '{name}' 被位于 {path} 的工作树签出。\n删除该工作树和分支?其文件夹将被删除。",
|
||||
"Maven Goals": "Maven 目标",
|
||||
"Gradle Tasks": "Gradle 任务"
|
||||
}
|
||||
@@ -1442,6 +1442,8 @@ export function localChangedPaths(
|
||||
// running a whole command line; anything else runs as a shell command.
|
||||
|
||||
export interface RunAnythingItem {
|
||||
/** Optional section within the provider; the shared `recent` id merges command histories. */
|
||||
group?: { id: string; label: string };
|
||||
/** Stable within the provider. */
|
||||
key: string;
|
||||
label: string;
|
||||
|
||||
Reference in new issue
Заблокувати користувача