feat(run): pin configurations to the top of the chooser (#1119)

Hovering a configuration in the Run/Debug configuration popup shows a Pin button. Pinned configurations are listed first under "Pinned", in pin order, and not repeated in their group; a search keeps pinned matches on top. Pins are kept per project.

Refs #997
This commit is contained in:
l0ng-ai
2026-11-04 08:06:23 +08:00
committed by GitHub
parent aeb3abca34
commit 6bf1d25711
4 changed files with 173 additions and 8 deletions
+48 -6
View File
@@ -8,7 +8,16 @@ import { useWorkflowSettings } from "@/platform/workflowSettings";
import { useDebugRunSessions } from "@/workbench/contributions";
import { commandLine, configIcon, groupConfigs } from "./configs";
import { targetName } from "./remote";
import { canDeleteConfig, CURRENT_FILE, deleteConfig, saveTemporary, selectConfig, startRun, useRun } from "./store";
import {
canDeleteConfig,
CURRENT_FILE,
deleteConfig,
saveTemporary,
selectConfig,
startRun,
togglePinned,
useRun,
} from "./store";
import { selectedTarget } from "./sessionTargets";
import styles from "./Run.module.css";
import { tx } from "@/platform/i18n";
@@ -28,6 +37,7 @@ export function buildRows(
query: string,
selected?: RunConfig | null,
temporary: readonly string[] = [],
pinned: readonly string[] = [],
): Row[] {
const rows: Row[] = [];
// IDEA: "Save '<name>' Configuration" first while a temporary configuration is selected.
@@ -35,10 +45,18 @@ export function buildRows(
// IDEA: the permanent "Current File" entry runs the active file's context.
const currentIndices = query ? fuzzyMatch(CURRENT_FILE_LABEL, query) : null;
if (!query || currentIndices) rows.push({ type: "current", key: "current", indices: currentIndices });
for (const group of groupConfigs(configs)) {
const matched = group.configs
.map((c) => ({ c, indices: query ? fuzzyMatch(c.name, query) : null }))
.filter((x) => !query || x.indices);
const matching = (list: readonly RunConfig[]) =>
list.map((c) => ({ c, indices: query ? fuzzyMatch(c.name, query) : null })).filter((x) => !query || x.indices);
// IDEA: pinned configurations come first, in the order they were pinned.
const pinnedConfigs = pinned.flatMap((id) => configs.find((c) => c.id === id) ?? []);
const pinnedRows = matching(pinnedConfigs);
if (pinnedRows.length) {
rows.push({ type: "section", key: "s:pinned", label: tx("Pinned") });
for (const { c, indices } of pinnedRows) rows.push({ type: "config", key: `c:${c.id}`, config: c, indices });
}
const rest = configs.filter((c) => !pinnedConfigs.includes(c));
for (const group of groupConfigs(rest)) {
const matched = matching(group.configs);
if (matched.length === 0) continue;
rows.push({ type: "section", key: `s:${group.kind}`, label: group.label });
for (const { c, indices } of matched) rows.push({ type: "config", key: `c:${c.id}`, config: c, indices });
@@ -80,6 +98,7 @@ function ConfigChooser() {
const configs = useRun((s) => s.configs);
const selectedId = useRun((s) => s.selectedId);
const temporary = useRun((s) => s.temporary);
const pinned = useRun((s) => s.pinned);
const runTargets = useWorkflowSettings((s) => s.runTargets);
const sessions = useRun((s) => s.sessions);
const debugSessions = useDebugRunSessions((s) => s.sessions);
@@ -91,8 +110,9 @@ function ConfigChooser() {
query,
configs.find((c) => c.id === selectedId),
temporary,
pinned,
),
[configs, query, selectedId, temporary],
[configs, query, selectedId, temporary, pinned],
);
const isSection = (r: Row) => r.type === "section";
const initial = Math.max(
@@ -308,6 +328,17 @@ function ConfigChooser() {
)}
{isActive && running && (
<span className={styles.chooserActions}>
<IconButton
icon="pin"
size="small"
iconSize={12}
title={pinned.includes(c.id) ? tx("Unpin") : tx("Pin")}
tabIndex={-1}
onClick={(e) => {
e.stopPropagation();
togglePinned(c.id);
}}
/>
<IconButton
icon={running.debug ? "restart-debug-filled" : "rerun-filled"}
size="small"
@@ -336,6 +367,17 @@ function ConfigChooser() {
)}
{isActive && !running && (
<span className={styles.chooserActions}>
<IconButton
icon="pin"
size="small"
iconSize={12}
title={pinned.includes(c.id) ? tx("Unpin") : tx("Pin")}
tabIndex={-1}
onClick={(e) => {
e.stopPropagation();
togglePinned(c.id);
}}
/>
<IconButton
icon="run"
size="small"
+84
View File
@@ -0,0 +1,84 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
const ipc = vi.hoisted(() => ({ runConfigs: vi.fn() }));
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 { buildRows, ConfigChooserOverlay } from "./ConfigChooser";
import { loadConfigs, resetRuns, togglePinned, useRun } from "./store";
const config = (id: string, kind = "npm"): RunConfig => ({
id,
name: id,
kind,
command: "run",
args: [],
cwd: null,
env: {},
detected: true,
isTest: false,
debugAdapter: null,
});
const configs = [config("build"), config("serve"), config("App", "application")];
beforeEach(() => {
resetRuns();
localStorage.clear();
useProject.setState({ project: { root: "/pinned" } as never });
ipc.runConfigs.mockImplementation(async () => ({ status: "ok", data: configs }));
});
afterEach(() => {
cleanup();
useProject.setState({ project: null });
});
const names = (rows: ReturnType<typeof buildRows>) =>
rows.map((r) => (r.type === "section" ? `[${r.label}]` : r.type === "config" ? r.config.name : r.type));
it("lists pinned configurations first, in pin order, and not again in their group", () => {
expect(names(buildRows(configs, "", null, [], ["serve", "App", "gone"]))).toEqual([
"current",
"[Pinned]",
"serve",
"App",
"[npm]",
"build",
"edit",
]);
// A search keeps the pinned matches on top.
expect(names(buildRows(configs, "s", null, [], ["serve"]))).toEqual(["[Pinned]", "serve", "edit"]);
});
it("keeps pins per project across reloads", async () => {
await loadConfigs();
togglePinned("serve");
togglePinned("build");
resetRuns();
await loadConfigs();
expect(useRun.getState().pinned).toEqual(["serve", "build"]);
togglePinned("serve");
expect(useRun.getState().pinned).toEqual(["build"]);
useProject.setState({ project: { root: "/other" } as never });
resetRuns();
await loadConfigs();
expect(useRun.getState().pinned).toEqual([]);
});
it("pins and unpins from the row's button", async () => {
await loadConfigs();
useRun.setState({ chooserOpen: true });
render(<ConfigChooserOverlay />);
fireEvent.pointerMove(screen.getByRole("option", { name: /^build/ }));
fireEvent.click(screen.getByRole("button", { name: "Pin" }));
expect(useRun.getState().pinned).toEqual(["build"]);
expect(screen.getByText("Pinned")).toBeInTheDocument();
fireEvent.pointerMove(screen.getByRole("option", { name: /^build/ }));
fireEvent.click(screen.getByRole("button", { name: "Unpin" }));
expect(useRun.getState().pinned).toEqual([]);
});
+38 -1
View File
@@ -112,6 +112,8 @@ interface RunState {
selectedId: string | null;
/** Ids of temporary configurations (created by gutter / context runs, not saved; last in `configs`). */
temporary: string[];
/** Ids of configurations pinned to the top of the chooser, in pin order. */
pinned: string[];
sessions: RunSession[];
activeSession: string | null;
/** Output version per session (re-render trigger for the console). */
@@ -137,6 +139,7 @@ export const useRun = create<RunState>(() => ({
loadError: null,
selectedId: null,
temporary: [],
pinned: [],
sessions: [],
activeSession: null,
versions: {},
@@ -199,7 +202,14 @@ export async function loadConfigs(): Promise<void> {
stored === CURRENT_FILE || configs.some((c) => c.id === stored)
? stored
: (defaultSelection(saved)?.id ?? CURRENT_FILE);
useRun.setState({ configs, temporary: temps.map((t) => t.id), loaded: true, loadError: null, selectedId });
useRun.setState({
configs,
temporary: temps.map((t) => t.id),
pinned: readPinned(root),
loaded: true,
loadError: null,
selectedId,
});
} catch (e) {
useRun.setState({ loaded: true, loadError: e instanceof Error ? e.message : String(e) });
}
@@ -224,6 +234,32 @@ export function selectConfig(id: string | null) {
if (root) writeSelected(root, id);
}
const PINNED_KEY = "openidea.run.pinned:";
function readPinned(root: string): string[] {
try {
const raw = JSON.parse(localStorage.getItem(PINNED_KEY + root) ?? "[]") as unknown;
return Array.isArray(raw) ? raw.filter((id): id is string => typeof id === "string") : [];
} catch {
return [];
}
}
/** Pins a configuration to the top of the chooser, or unpins it. Kept per project. */
export function togglePinned(id: string) {
const { pinned } = useRun.getState();
const next = pinned.includes(id) ? pinned.filter((p) => p !== id) : [...pinned, id];
useRun.setState({ pinned: next });
const root = useProject.getState().project?.root;
if (!root) return;
try {
if (next.length) localStorage.setItem(PINNED_KEY + root, JSON.stringify(next));
else localStorage.removeItem(PINNED_KEY + root);
} catch {
/* storage unavailable */
}
}
/** Whether the chooser's "Current File" is selected. */
export function currentFileSelected(): boolean {
return useRun.getState().selectedId === CURRENT_FILE;
@@ -1094,6 +1130,7 @@ export function resetRuns() {
loadError: null,
selectedId: null,
temporary: [],
pinned: [],
sessions: [],
activeSession: null,
versions: {},
+3 -1
View File
@@ -5800,5 +5800,7 @@
"Reports code the built-in parser cannot read, in files no language server checks (none is configured for the language, or it is not installed, stopped or crashed). A running language server reports syntax errors itself.": "报告内置解析器无法解析的代码,仅针对没有语言服务器检查的文件(该语言未配置语言服务器,或未安装、已停止、已崩溃)。语言服务器运行时由它自己报告语法错误。",
"Some items could not be restored": "部分项目无法恢复",
"Not in Local History: {names}. They are in the Trash.": "本地历史中没有:{names}。它们在回收站中。",
"Show All Commits": "显示所有提交"
"Show All Commits": "显示所有提交",
"Pin": "固定",
"Unpin": "取消固定"
}