diff --git a/src/features/run/ConfigChooser.tsx b/src/features/run/ConfigChooser.tsx index 6be63e45..a70d9b74 100644 --- a/src/features/run/ConfigChooser.tsx +++ b/src/features/run/ConfigChooser.tsx @@ -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 '' 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 && ( + { + e.stopPropagation(); + togglePinned(c.id); + }} + /> + { + e.stopPropagation(); + togglePinned(c.id); + }} + /> ({ runConfigs: vi.fn() })); +vi.mock("@/platform/ipc", async (importOriginal) => ({ + ...(await importOriginal()), + 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) => + 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(); + 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([]); +}); diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 90d22e7b..0099c8f5 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -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(() => ({ loadError: null, selectedId: null, temporary: [], + pinned: [], sessions: [], activeSession: null, versions: {}, @@ -199,7 +202,14 @@ export async function loadConfigs(): Promise { 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: {}, diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index aa6fcb9e..86d3d1ff 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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": "取消固定" }