mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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": "取消固定"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user