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: {},