From 192e6138303c69ada207679e77c6a5f958e045ce Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:15:13 +0800 Subject: [PATCH] feat(run): list recently started configurations at the top of the chooser (#1121) The Run/Debug configuration popup lists the five configurations started last under "Recent", newest first, after the pinned ones and not repeated in their group. Starts are recorded from run and debug sessions (any executor, reruns included). Kept per project. Refs #997 --- src/features/run/ConfigChooser.tsx | 14 +++++- src/features/run/chooserPinned.test.tsx | 47 +++++++++++++++++++ src/features/run/index.ts | 2 + src/features/run/store.ts | 62 ++++++++++++++++++++----- 4 files changed, 112 insertions(+), 13 deletions(-) diff --git a/src/features/run/ConfigChooser.tsx b/src/features/run/ConfigChooser.tsx index a70d9b740..967d53437 100644 --- a/src/features/run/ConfigChooser.tsx +++ b/src/features/run/ConfigChooser.tsx @@ -38,6 +38,7 @@ export function buildRows( selected?: RunConfig | null, temporary: readonly string[] = [], pinned: readonly string[] = [], + recent: readonly string[] = [], ): Row[] { const rows: Row[] = []; // IDEA: "Save '' Configuration" first while a temporary configuration is selected. @@ -54,7 +55,14 @@ export function buildRows( 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)); + // Then the ones started last, newest first. + const recentConfigs = recent.flatMap((id) => configs.find((c) => c.id === id && !pinned.includes(id)) ?? []); + const recentRows = matching(recentConfigs); + if (recentRows.length) { + rows.push({ type: "section", key: "s:recent", label: tx("Recent") }); + for (const { c, indices } of recentRows) rows.push({ type: "config", key: `c:${c.id}`, config: c, indices }); + } + const rest = configs.filter((c) => !pinnedConfigs.includes(c) && !recentConfigs.includes(c)); for (const group of groupConfigs(rest)) { const matched = matching(group.configs); if (matched.length === 0) continue; @@ -99,6 +107,7 @@ function ConfigChooser() { const selectedId = useRun((s) => s.selectedId); const temporary = useRun((s) => s.temporary); const pinned = useRun((s) => s.pinned); + const recent = useRun((s) => s.recent); const runTargets = useWorkflowSettings((s) => s.runTargets); const sessions = useRun((s) => s.sessions); const debugSessions = useDebugRunSessions((s) => s.sessions); @@ -111,8 +120,9 @@ function ConfigChooser() { configs.find((c) => c.id === selectedId), temporary, pinned, + recent, ), - [configs, query, selectedId, temporary, pinned], + [configs, query, selectedId, temporary, pinned, recent], ); const isSection = (r: Row) => r.type === "section"; const initial = Math.max( diff --git a/src/features/run/chooserPinned.test.tsx b/src/features/run/chooserPinned.test.tsx index 561683587..035d4d718 100644 --- a/src/features/run/chooserPinned.test.tsx +++ b/src/features/run/chooserPinned.test.tsx @@ -82,3 +82,50 @@ it("pins and unpins from the row's button", async () => { fireEvent.click(screen.getByRole("button", { name: "Unpin" })); expect(useRun.getState().pinned).toEqual([]); }); + +it("lists the configurations started last after the pinned ones, newest first", () => { + expect(names(buildRows(configs, "", null, [], ["serve"], ["App", "serve", "build"]))).toEqual([ + "current", + "[Pinned]", + "serve", + "[Recent]", + "App", + "build", + "edit", + ]); +}); + +it("marks a configuration recent when a run or debug session starts", async () => { + const { markRecent, trackRecentConfigurations, MAX_RECENT } = await import("./store"); + const { useDebugRunSessions } = await import("@/workbench/contributions"); + await loadConfigs(); + const off = trackRecentConfigurations(); + const session = (id: string, startedAt: number) => + ({ id: `s-${id}`, config: config(id), runId: null, status: "running", startedAt }) as never; + useRun.setState({ sessions: [session("build", 1)] }); + useDebugRunSessions.setState({ + sessions: [ + { + id: 1, + configId: "App", + name: "App", + startedAt: 2, + paused: false, + rerun: null, + stop: () => {}, + resume: () => {}, + }, + ], + }); + expect(useRun.getState().recent).toEqual(["App", "build"]); + // A rerun reuses the session with a new start time. + useRun.setState({ sessions: [session("build", 3)] }); + expect(useRun.getState().recent).toEqual(["build", "App"]); + off(); + useDebugRunSessions.setState({ sessions: [] }); + for (let i = 0; i < MAX_RECENT + 2; i++) markRecent(`c${i}`); + expect(useRun.getState().recent).toHaveLength(MAX_RECENT); + resetRuns(); + await loadConfigs(); + expect(useRun.getState().recent[0]).toBe(`c${MAX_RECENT + 1}`); +}); diff --git a/src/features/run/index.ts b/src/features/run/index.ts index 5b996de9f..c51262f7f 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -71,6 +71,7 @@ import { selectedConfig, startRun, stopSession, + trackRecentConfigurations, useRun, } from "./store"; import { compoundTargets, selectedTarget } from "./sessionTargets"; @@ -296,6 +297,7 @@ export function activate(): () => void { registerExternalTools(), activateStackTrace(), listenForApplicationDeactivation(), + trackRecentConfigurations(), useDebugRunSessions.subscribe(() => setContext("run.updatableRunning", updatableSessions().length > 0)), registerEditorContribution({ id: "run.targets", order: 55, extensions: runGutter }), registerEditorContribution({ id: "run.npmScripts", order: 58, extensions: npmGutter }), diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 0099c8f5f..9c8f5096f 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -114,6 +114,8 @@ interface RunState { temporary: string[]; /** Ids of configurations pinned to the top of the chooser, in pin order. */ pinned: string[]; + /** Ids of the configurations started last, newest first (at most `MAX_RECENT`). */ + recent: string[]; sessions: RunSession[]; activeSession: string | null; /** Output version per session (re-render trigger for the console). */ @@ -140,6 +142,7 @@ export const useRun = create(() => ({ selectedId: null, temporary: [], pinned: [], + recent: [], sessions: [], activeSession: null, versions: {}, @@ -205,7 +208,8 @@ export async function loadConfigs(): Promise { useRun.setState({ configs, temporary: temps.map((t) => t.id), - pinned: readPinned(root), + pinned: readIds(PINNED_KEY, root), + recent: readIds(RECENT_KEY, root), loaded: true, loadError: null, selectedId, @@ -235,29 +239,64 @@ export function selectConfig(id: string | null) { } const PINNED_KEY = "openidea.run.pinned:"; +const RECENT_KEY = "openidea.run.recent:"; +export const MAX_RECENT = 5; -function readPinned(root: string): string[] { +function readIds(key: string, root: string): string[] { try { - const raw = JSON.parse(localStorage.getItem(PINNED_KEY + root) ?? "[]") as unknown; + const raw = JSON.parse(localStorage.getItem(key + root) ?? "[]") as unknown; return Array.isArray(raw) ? raw.filter((id): id is string => typeof id === "string") : []; } catch { return []; } } +function writeIds(key: string, ids: readonly string[]) { + const root = useProject.getState().project?.root; + if (!root) return; + try { + if (ids.length) localStorage.setItem(key + root, JSON.stringify(ids)); + else localStorage.removeItem(key + root); + } catch { + /* storage unavailable */ + } +} + /** 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 */ - } + writeIds(PINNED_KEY, next); +} + +/** A configuration was started: it leads the chooser's recent configurations. Kept per project. */ +export function markRecent(id: string) { + const { recent } = useRun.getState(); + if (id === CURRENT_FILE || recent[0] === id) return; + const next = [id, ...recent.filter((r) => r !== id)].slice(0, MAX_RECENT); + useRun.setState({ recent: next }); + writeIds(RECENT_KEY, next); +} + +/** Marks every configuration whose run or debug session starts (any executor, reruns included). */ +export function trackRecentConfigurations(): () => void { + const offRun = useRun.subscribe((s, prev) => { + if (s.sessions === prev.sessions) return; + for (const x of s.sessions) { + if (!prev.sessions.some((p) => p.id === x.id && p.startedAt === x.startedAt)) markRecent(x.config.id); + } + }); + const offDebug = useDebugRunSessions.subscribe((s, prev) => { + if (s.sessions === prev.sessions) return; + for (const x of s.sessions) { + if (x.configId && !prev.sessions.some((p) => p.id === x.id)) markRecent(x.configId); + } + }); + return () => { + offRun(); + offDebug(); + }; } /** Whether the chooser's "Current File" is selected. */ @@ -1131,6 +1170,7 @@ export function resetRuns() { selectedId: null, temporary: [], pinned: [], + recent: [], sessions: [], activeSession: null, versions: {},