fix(run): allow removing detected run configurations (#296)

Detected run configurations (Spring Boot and Java applications, npm scripts, Maven/Gradle tasks, pytest, Cargo, Go, …) could not be removed: the − button was disabled because a removed detection would be found again. As in IntelliJ IDEA, they can now be removed and stay removed: the removed id is stored in the workspace-local run.local.toml (suppressed-ids) and detection skips it; saving a configuration with that id again lifts the suppression. Applies to the Run/Debug Configurations − button, the Delete key in its tree, and Delete in the run configuration chooser. Removing a saved override still restores the detected configuration; temporary configurations are unchanged.

Refs #271
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-30 07:42:00 +08:00
1 джерело 922bbf493a
коміт c55fd9b86e
11 files changed
+304 -28

No files matched your search

+1 -1
Переглянути файл
@@ -528,7 +528,7 @@ export const commands = {
termAvailableShells: () => __TAURI_INVOKE<string[]>("term_available_shells"),
/** Detected configs followed by user-defined ones (user ids win on conflict). */
runConfigs: () => typedError<RunConfig_Serialize[], IdeError>(__TAURI_INVOKE("run_configs")),
runSaveConfigs: (configs: RunConfig_Deserialize[]) => typedError<null, IdeError>(__TAURI_INVOKE("run_save_configs", { configs })),
runSaveConfigs: (configs: RunConfig_Deserialize[], removedDetectedIds: string[] | null) => typedError<null, IdeError>(__TAURI_INVOKE("run_save_configs", { configs, removedDetectedIds })),
/** Configuration templates: per-type defaults applied to new configurations. */
runTemplates: () => typedError<RunConfig_Serialize[], IdeError>(__TAURI_INVOKE("run_templates")),
runSaveTemplates: (templates: RunConfig_Deserialize[]) => typedError<null, IdeError>(__TAURI_INVOKE("run_save_templates", { templates })),
+21 -4
Переглянути файл
@@ -130,6 +130,7 @@ function EditConfigsDialog() {
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set());
// "Modify Run Configuration…": the would-be configuration is saved on OK even when unedited.
const [dirty, setDirty] = useState(() => useRun.getState().editDirty);
const [removedDetectedIds, setRemovedDetectedIds] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const runTargets = useWorkflowSettings((s) => s.runTargets);
const [adding, setAdding] = useState<{ x: number; y: number } | null>(null);
@@ -209,7 +210,10 @@ function EditConfigsDialog() {
setDirty(true);
return;
}
if (!current || current.detected) return;
if (!current) return;
if (current.detected && !temporary.includes(current.id)) {
setRemovedDetectedIds((ids) => [...ids, current.id]);
}
const index = draft.findIndex((c) => c.id === current.id);
const next = removeConfig(draft, current.id);
setDraft(next);
@@ -262,8 +266,9 @@ function EditConfigsDialog() {
}
setSaving(true);
try {
if (dirty) await saveUserConfigs(userConfigs(draft));
if (dirty) await saveUserConfigs(userConfigs(draft), removedDetectedIds);
if (templatesDirty && templates) await call(commands.runSaveTemplates(templates));
setRemovedDetectedIds([]);
setDirty(false);
setTemplatesDirty(false);
if (andClose) {
@@ -325,7 +330,7 @@ function EditConfigsDialog() {
};
const templateTypes = runConfigTypes(types).filter((t) => t.id !== COMPOUND);
const canRemove = selection?.type === "folder" || (!!current && !current.detected);
const canRemove = selection?.type === "folder" || !!current;
return (
<Dialog
@@ -423,7 +428,19 @@ function EditConfigsDialog() {
onClick={() => move(1)}
/>
</div>
<div className={styles.editList} role="tree" aria-label={tx("Run configurations")}>
<div
className={styles.editList}
role="tree"
tabIndex={0}
aria-label={tx("Run configurations")}
onKeyDown={(e) => {
if (e.key === "Delete" && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey && canRemove) {
e.preventDefault();
e.stopPropagation();
remove();
}
}}
>
{rows.map((r) => {
if (r.type === "group")
return (
+126
Переглянути файл
@@ -0,0 +1,126 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
const ipc = vi.hoisted(() => ({
runSaveConfigs: vi.fn(),
runConfigs: vi.fn(),
runTemplates: vi.fn(async () => ({ status: "ok", data: [] })),
}));
vi.mock("@/platform/ipc", async (importOriginal) => ({
...(await importOriginal<typeof import("@/platform/ipc")>()),
isTauri: () => true,
commands: ipc,
}));
import type { RunConfig } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { EditConfigsOverlay } from "./EditConfigsDialog";
import { ConfigChooserOverlay } from "./ConfigChooser";
import { canDeleteConfig, deleteConfig, loadConfigs, resetRuns, useRun } from "./store";
import { registerBuiltinTypes } from "./types";
const config = (kind = "npm"): RunConfig => ({
id: `${kind}:app`,
name: "Detected app",
kind,
command: "run",
args: [],
cwd: null,
env: {},
detected: true,
isTest: false,
debugAdapter: null,
});
let dispose: () => void;
let backend: RunConfig[];
beforeEach(() => {
resetRuns();
localStorage.clear();
dispose = registerBuiltinTypes();
backend = [config()];
useProject.setState({ project: { root: "/test-removal" } as never });
ipc.runConfigs.mockImplementation(async () => ({ status: "ok", data: backend }));
ipc.runSaveConfigs.mockReset();
ipc.runSaveConfigs.mockImplementation(async (saved: RunConfig[], removed: string[]) => {
backend = [...backend.filter((c) => c.detected && !removed.includes(c.id)), ...saved];
return { status: "ok", data: null };
});
useRun.setState({ configs: backend, loaded: true, selectedId: backend[0]!.id, editorOpen: true });
});
afterEach(() => {
cleanup();
dispose();
useProject.setState({ project: null });
});
it("enables minus for detections and stays removed after Apply and backend reload", async () => {
render(<EditConfigsOverlay />);
const minus = screen.getByRole("button", { name: "Remove Configuration" });
expect(minus).toBeEnabled();
fireEvent.click(minus);
expect(ipc.runSaveConfigs).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
await waitFor(() => expect(ipc.runSaveConfigs).toHaveBeenCalledWith([], ["npm:app"]));
await act(() => loadConfigs());
expect(useRun.getState().configs).toEqual([]);
cleanup();
render(<EditConfigsOverlay />);
expect(screen.queryByRole("treeitem", { name: "Detected app" })).not.toBeInTheDocument();
});
it("handles Delete only inside the tree", async () => {
render(<EditConfigsOverlay />);
fireEvent.keyDown(screen.getByRole("textbox", { name: "Name" }), { key: "Delete" });
expect(screen.getByRole("treeitem", { name: "Detected app" })).toBeInTheDocument();
const tree = screen.getByRole("tree");
tree.focus();
fireEvent.keyDown(tree, { key: "Delete" });
expect(screen.queryByRole("treeitem", { name: "Detected app" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "OK" }));
await waitFor(() => expect(useRun.getState().editorOpen).toBe(false));
expect(ipc.runSaveConfigs).toHaveBeenCalledWith([], ["npm:app"]);
});
it("keeps the override label and removes overrides without suppression", async () => {
useRun.setState({ configs: [{ ...config(), detected: false }] });
render(<EditConfigsOverlay />);
fireEvent.click(screen.getByRole("button", { name: "Remove Override (restore detected)" }));
fireEvent.click(screen.getByRole("button", { name: "OK" }));
await waitFor(() => expect(ipc.runSaveConfigs).toHaveBeenCalledWith([], []));
});
it.each(["spring-boot", "application", "maven", "gradle", "npm", "pytest", "cargo", "go"])(
"chooser deletion suppresses detected %s configurations",
async (kind) => {
const c = config(kind);
backend = [c];
useRun.setState({ configs: backend, selectedId: c.id });
expect(canDeleteConfig(c.id)).toBe(true);
await deleteConfig(c.id);
expect(ipc.runSaveConfigs).toHaveBeenCalledWith([], [c.id]);
expect(useRun.getState().configs).toEqual([]);
},
);
it("temporary deletion does not persist suppression", async () => {
useRun.setState({ temporary: ["npm:app"] });
await deleteConfig("npm:app");
expect(ipc.runSaveConfigs).not.toHaveBeenCalled();
expect(useRun.getState().configs).toEqual([]);
});
it("chooser popup Delete removes the selected detection", async () => {
useRun.setState({ chooserOpen: true, editorOpen: false });
render(<ConfigChooserOverlay />);
fireEvent.keyDown(document.querySelector("input[data-autofocus]")!, { key: "Delete" });
await waitFor(() => expect(ipc.runSaveConfigs).toHaveBeenCalledWith([], ["npm:app"]));
await waitFor(() => expect(useRun.getState().configs).toEqual([]));
});
it("Cancel discards a pending detected removal", () => {
render(<EditConfigsOverlay />);
fireEvent.click(screen.getByRole("button", { name: "Remove Configuration" }));
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(ipc.runSaveConfigs).not.toHaveBeenCalled();
expect(useRun.getState().configs).toEqual([config()]);
});
+14 -8
Переглянути файл
@@ -287,14 +287,12 @@ export async function saveTemporary(id: string): Promise<void> {
selectConfig(id);
}
/** Whether the chooser's Delete may remove `id` (user and temporary configurations; detected ones come back). */
/** Whether the chooser's Delete may remove `id`. */
export function canDeleteConfig(id: string): boolean {
const { configs, temporary } = useRun.getState();
const config = configs.find((c) => c.id === id);
return !!config && (temporary.includes(id) || !config.detected);
return useRun.getState().configs.some((c) => c.id === id);
}
/** Deletes a user or temporary configuration. */
/** Deletes a configuration, suppressing detections and preserving temporary behavior. */
export async function deleteConfig(id: string): Promise<void> {
if (!canDeleteConfig(id)) return;
const { configs, temporary, selectedId } = useRun.getState();
@@ -304,12 +302,20 @@ export async function deleteConfig(id: string): Promise<void> {
persistTemporary();
return;
}
await saveUserConfigs(configs.filter((c) => !c.detected && !temporary.includes(c.id) && c.id !== id));
await saveUserConfigs(
configs.filter((c) => !c.detected && !temporary.includes(c.id) && c.id !== id),
configs.find((c) => c.id === id)?.detected ? [id] : [],
);
}
/** Persists user configurations and reloads the merged list. */
export async function saveUserConfigs(user: RunConfig[]): Promise<void> {
await call(commands.runSaveConfigs(user.map((c) => ({ ...c, detected: false }))));
export async function saveUserConfigs(user: RunConfig[], removedDetectedIds: string[] = []): Promise<void> {
await call(
commands.runSaveConfigs(
user.map((c) => ({ ...c, detected: false })),
removedDetectedIds,
),
);
await loadConfigs();
}