diff --git a/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU= b/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU= index c885c46..94aa5c8 100755 --- a/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU= +++ b/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU= @@ -3,6 +3,6 @@ # This file should be checked into version control along with the pnpm-lock.yaml file. package.json=1645056029 packages/core/package.json=-1407934132 -packages/vscode/package.json=402694317 +packages/vscode/package.json=-323472392 pnpm-lock.yaml=1115513514 pnpm-workspace.yaml=-1673009026 diff --git a/packages/vscode/BUILD.bazel b/packages/vscode/BUILD.bazel index 35a8e1e..59bff8a 100644 --- a/packages/vscode/BUILD.bazel +++ b/packages/vscode/BUILD.bazel @@ -63,6 +63,7 @@ ts_project( "src/newProject.ts", "src/output.ts", "src/projects.ts", + "src/targetShortcuts.ts", "src/tasks.ts", "src/testing.ts", "src/workspacePaths.ts", diff --git a/packages/vscode/README.md b/packages/vscode/README.md index 35f0a98..77d7e68 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -36,7 +36,12 @@ test, and debug commands are available from the editor. `elide test --reporter=tap` with live results, failure messages and stack-trace locations. **Run with Coverage** shows the run's LCOV and JaCoCo XML reports in the editor's coverage gutter and Test Coverage view. - **Elide sidebar.** An activity-bar view with each project's entrypoints, tasks, build targets (`elide build - --inspect`), source sets and dependencies; run, debug, build and sync straight from the tree. + --inspect`), source sets and dependencies; run, debug, build and sync straight from the tree. A workspace's + **Tasks → Build targets** groups targets by project in the CLI's order; standalone projects list them directly. + Targets without descriptions stay alongside the others. **Pin to Top** (a + task's, build target's or Android artifact's context menu) keeps a target in a **Pinned** section above the + projects, and a **Frequently used** section lists the five targets run most often from the sidebar itself — runs + from the CLI, code lenses or the palette are not counted. Both are remembered per workspace. - **Activity.** The sidebar's **Activity** view lists every sync and Compose preview build of the window, live while it runs: each step (reading the manifest, installing dependencies, resolving classpaths, generating resources, writing `workspace.json`), the `elide` commands it ran, and the build tasks those reported, each with @@ -231,6 +236,10 @@ file or version banner alone is not proof of compatibility; no guessed version c load error disables native services just like a failed start or reload. Managed preview protocol/capability metadata is retained for preview consumers, independently of native analysis/debug compatibility. +This includes Android debug actions when the selected adapter is Komodo: their tree actions, code lenses and +palette/menu entries are withdrawn while it is unavailable. Native Compose preview commands are disabled too. +Run/build actions, other selected debuggers and layoutlib previews remain independent. + With `elide.kotlinLsp.server` set to `komodo`, `komodo` is the folder's Kotlin language server. Each sync writes a Komodo project manifest into the extension's storage (nothing in the project: no `workspace.json`) and starts the server, or makes it reload the manifest without a restart. One server serves the whole window: every Komodo @@ -421,7 +430,7 @@ Every other Android step — `android-resources-*`, `android-link-*`, `android-d `android-sign-*`, `android-bundle-*` — is listed under **Build targets** like any other target of the project. A target there that installs onto a device (it takes `--device`: `android-run-*`, `android-debug-*`, `android-test-*`) runs on the deployment target's device, resolved as **Run** resolves it, rather than on whichever device Elide would -pick. Targets Elide gives no description, its internal ones, are grouped under **Other targets**. +pick. Targets without descriptions appear in the same order as `elide build --inspect`, alongside the other targets. A published `.aar` dependency appears under **Dependencies** named by its coordinate, with the sources of the archive attached: Elide unpacks each one into `.dev/android/libraries////` and compiles diff --git a/packages/vscode/package.json b/packages/vscode/package.json index ad09701..863df17 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -671,6 +671,30 @@ "command": "elide.revealLibrary", "category": "Elide", "title": "Reveal in Finder/Explorer" + }, + { + "command": "elide.pinTarget", + "category": "Elide", + "title": "Pin to Top", + "icon": "$(pin)" + }, + { + "command": "elide.unpinTarget", + "category": "Elide", + "title": "Unpin", + "icon": "$(pinned)" + }, + { + "command": "elide.forgetFrequentTarget", + "category": "Elide", + "title": "Remove from Frequently Used", + "icon": "$(close)" + }, + { + "command": "elide.clearFrequentTargets", + "category": "Elide", + "title": "Clear Frequently Used", + "icon": "$(clear-all)" } ], "menus": { @@ -710,6 +734,22 @@ "command": "elide.revealLibrary", "when": "false" }, + { + "command": "elide.pinTarget", + "when": "false" + }, + { + "command": "elide.unpinTarget", + "when": "false" + }, + { + "command": "elide.forgetFrequentTarget", + "when": "false" + }, + { + "command": "elide.clearFrequentTargets", + "when": "false" + }, { "command": "elide.androidSelectTarget", "when": "elide.hasAndroidProjects" @@ -847,42 +887,42 @@ }, { "command": "elide.executeTask", - "when": "view == elide.projects && viewItem == task", + "when": "view == elide.projects && viewItem =~ /^task(\\.|$)/", "group": "inline@1" }, { "command": "elide.executeTask", - "when": "view == elide.projects && viewItem == buildTarget", + "when": "view == elide.projects && viewItem =~ /^buildTarget(\\.|$)/", "group": "inline@1" }, { "command": "elide.androidRunDeviceTask", - "when": "view == elide.projects && viewItem == buildTargetDevice", + "when": "view == elide.projects && viewItem =~ /^buildTargetDevice(\\.|$)/", "group": "inline@1" }, { "command": "elide.executeTask", - "when": "view == elide.projects && viewItem == buildTargetDebuggable", + "when": "view == elide.projects && viewItem =~ /^buildTargetDebuggable(\\.|$)/", "group": "inline@1" }, { "command": "elide.debug", - "when": "view == elide.projects && viewItem == buildTargetDebuggable", + "when": "view == elide.projects && viewItem =~ /^buildTargetDebuggable(\\.|$)/", "group": "inline@2" }, { "command": "elide.runArtifact", - "when": "view == elide.projects && viewItem == buildTargetRunnable", + "when": "view == elide.projects && viewItem =~ /^buildTargetRunnable(\\.|$)/", "group": "inline@1" }, { "command": "elide.debugArtifact", - "when": "view == elide.projects && viewItem == buildTargetRunnable", + "when": "view == elide.projects && viewItem =~ /^buildTargetRunnable(\\.|$)/", "group": "inline@2" }, { "command": "elide.executeTask", - "when": "view == elide.projects && viewItem == buildTargetRunnable", + "when": "view == elide.projects && viewItem =~ /^buildTargetRunnable(\\.|$)/", "group": "inline@3" }, { @@ -911,12 +951,12 @@ }, { "command": "elide.build", - "when": "view == elide.projects && viewItem == androidAab", + "when": "view == elide.projects && viewItem =~ /^androidAab(\\.|$)/", "group": "inline@1" }, { "command": "elide.build", - "when": "view == elide.projects && viewItem =~ /^(androidAar|androidFeature)$/", + "when": "view == elide.projects && viewItem =~ /^(androidAar|androidFeature)(\\.|$)/", "group": "inline@1" }, { @@ -968,6 +1008,26 @@ "command": "elide.revealLibrary", "when": "view == elide.projects && viewItem == library" }, + { + "command": "elide.pinTarget", + "when": "view == elide.projects && viewItem =~ /^(task|buildTarget|androidApk|androidAab|androidAar|androidFeature)(?!.*\\.pinned$)/", + "group": "pin@1" + }, + { + "command": "elide.unpinTarget", + "when": "view == elide.projects && viewItem =~ /\\.pinned$/", + "group": "pin@1" + }, + { + "command": "elide.forgetFrequentTarget", + "when": "view == elide.projects && viewItem =~ /\\.frequent$/", + "group": "pin@2" + }, + { + "command": "elide.clearFrequentTargets", + "when": "view == elide.projects && viewItem == frequent", + "group": "inline@1" + }, { "command": "elide.cancelActivity", "when": "view == elide.activity && viewItem =~ /Cancellable$/", diff --git a/packages/vscode/src/androidTesting.ts b/packages/vscode/src/androidTesting.ts index 5fd6303..210477a 100644 --- a/packages/vscode/src/androidTesting.ts +++ b/packages/vscode/src/androidTesting.ts @@ -61,10 +61,12 @@ export class ElideAndroidTestController implements vscode.Disposable { private readonly running = new Set(); private disposed = false; + /** `onInvoke` sees the argument of every `elide.androidTest` invocation, before the run starts. */ constructor( private readonly workspace: ElideWorkspace, private readonly ui: ElideUi, private readonly deployments: AndroidDeploymentController, + onInvoke: (target: unknown) => void, ) { this.controller.refreshHandler = () => this.refresh(); this.profile = this.controller.createRunProfile("Run on Android Deployment Target", vscode.TestRunProfileKind.Run, @@ -80,7 +82,10 @@ export class ElideAndroidTestController implements vscode.Disposable { }), // A new model can declare, drop or re-target a test APK, or change what Elide lists for it. workspace.onDidChange(() => void this.refresh()), - vscode.commands.registerCommand("elide.androidTest", (target: unknown) => this.runCommand(target)), + vscode.commands.registerCommand("elide.androidTest", (target: unknown) => { + onInvoke(target); + return this.runCommand(target); + }), ]; } diff --git a/packages/vscode/src/explorer.ts b/packages/vscode/src/explorer.ts index dfab36e..d3f26e4 100644 --- a/packages/vscode/src/explorer.ts +++ b/packages/vscode/src/explorer.ts @@ -28,6 +28,7 @@ import { readConfig } from "./config.js"; import { onDidEndDebugRun } from "./debug.js"; import type { ElideUi } from "./output.js"; import type { ElideProject, ElideWorkspace } from "./projects.js"; +import { TargetShortcuts, type ShortcutKind, type TargetRef } from "./targetShortcuts.js"; import { entrypointArgs } from "./tasks.js"; export const PROJECTS_VIEW_ID = "elide.projects"; @@ -35,33 +36,39 @@ export const PROJECTS_VIEW_ID = "elide.projects"; /** * The fixed sections of a project, plus the lazily loaded build targets and Android devices nested under them. A * workspace tree's devices and Android tools are one `androidTools` section of its top-level row, not one per member. + * `pinned` and `frequent` sit above every project: the targets pinned, and the ones run from the sidebar most often. */ -type GroupKind = "entrypoints" | "android" | "androidTools" | "androidDevices" | "tasks" | "buildTargets" | "otherBuildTargets" | "sourceSets" | "dependencies"; +type GroupKind = "pinned" | "frequent" | "entrypoints" | "android" | "androidTools" | "androidDevices" | "tasks" | "buildTargets" | "sourceSets" | "dependencies"; const GROUP_LABELS: Record = { + pinned: "Pinned", + frequent: "Frequently used", entrypoints: "Entrypoints", android: "Android", androidTools: "Android devices & tools", androidDevices: "Devices", tasks: "Tasks", buildTargets: "Build targets", - otherBuildTargets: "Other targets", sourceSets: "Source sets", dependencies: "Dependencies", }; const GROUP_ICONS: Record = { + pinned: "pinned", + frequent: "history", entrypoints: "rocket", android: "device-mobile", androidTools: "remote-explorer", androidDevices: "plug", tasks: "tools", buildTargets: "list-tree", - otherBuildTargets: "ellipsis", sourceSets: "folder-library", dependencies: "library", }; +/** Rows of the `frequent` section: the most used targets that are not pinned. */ +const FREQUENT_LIMIT = 5; + /** Tasks offered for every project, whether or not its model has been resolved yet. */ const PROJECT_TASKS: ElideCommand[] = ["build", "test", "install"]; @@ -70,13 +77,15 @@ const PROJECT_TASKS: ElideCommand[] = ["build", "test", "install"]; * * The command-carrying kinds are shaped like the argument `elide.run`/`elide.debug`/`elide.executeTask` accept * (`root`, `args`, and for tasks `command`), so a node can be handed to those commands unchanged — that is what the - * inline buttons of `view/item/context` pass. + * inline buttons of `view/item/context` pass. A target row repeated in the `pinned` or `frequent` section says which + * in `shortcut`. */ export type ElideNode = | { kind: "project"; label: string; root: string; member: boolean } | { kind: "group"; label: string; root: string; group: GroupKind } + | { kind: "buildTargetProject"; label: string; root: string } | { kind: "entrypoint"; label: string; root: string; args: string[]; script: boolean } - | { kind: "task"; label: string; root: string; command: ElideCommand; args: string[] } + | { kind: "task"; label: string; root: string; command: ElideCommand; args: string[]; shortcut?: ShortcutSection } | { kind: "buildTarget"; label: string; @@ -94,6 +103,9 @@ export type ElideNode = deviceBound: boolean; /** Output name the artifact declares, which decides the binary `elide.runArtifact` runs. */ outputName?: string; + shortcut?: ShortcutSection; + /** Distinguish rows repeated in the workspace listing from the member's own section. */ + workspaceListing?: boolean; } | { kind: "androidTarget"; @@ -107,6 +119,7 @@ export type ElideNode = description: string; /** What Elide lists for an APK — Run, Debug, instrumented tests — which decides the actions its row offers. */ offers?: AndroidDeviceTargets; + shortcut?: ShortcutSection; } /** The obfuscation mapping a minifying Android artifact publishes, beside the archive it belongs to. */ | { kind: "androidMapping"; label: string; root: string; artifact: string; path: string; description: string } @@ -119,8 +132,13 @@ export type ElideNode = | { kind: "module"; label: string; root: string; module: ModuleModel } | { kind: "sourceRoot"; label: string; root: string; module: string; path: string; sourceKind: SourceRootKind } | { kind: "library"; label: string; root: string; classes: string; attached: string } + /** A pinned target its project no longer offers, or that cannot be listed now: `description` says which. */ + | { kind: "missingTarget"; label: string; root: string; target: TargetRef; description: string } | { kind: "message"; label: string }; +type ShortcutSection = "pinned" | "frequent"; +type TargetNode = Extract; + /** What `activate` exposes for the extension-host integration test. */ export interface ElideExplorerApi { getChildren(node?: unknown): Promise; @@ -128,12 +146,14 @@ export interface ElideExplorerApi { /** * The **Elide** activity-bar tree: one node per project with its entrypoints, tasks, source sets and dependencies. - * The members of an Elide workspace are nested under its root, after the root's own sections. + * The members of an Elide workspace are nested under its root, after the root's own sections. Above the projects, the + * targets pinned and the ones run most often from this tree; a run from the CLI, a code lens or the palette is not + * counted. * * Everything but the build targets comes from the project model the sync already resolved, so expanding the tree - * never shells out; `elide build --inspect` runs on first expansion of `Build targets` and is cached until the next - * model change. Inside a workspace the listing covers every project of the build graph wherever it is run from, so it - * is read once for the whole workspace. + * never shells out; `elide build --inspect` runs on first expansion of `Build targets` — or of a section above that + * lists one — and is cached until the next model change. Inside a workspace the listing covers every project of the + * build graph wherever it is run from, so it is read once for the whole workspace. */ export class ElideProjectsView implements vscode.TreeDataProvider, ElideExplorerApi, vscode.Disposable { private readonly changed = new vscode.EventEmitter(); @@ -144,11 +164,16 @@ export class ElideProjectsView implements vscode.TreeDataProvider, El private buildGeneration = 0; /** Artifacts declared by each project's manifest, per project root, dropped when the model changes. */ private readonly artifacts = new Map(); + private readonly shortcuts: TargetShortcuts; + /** The target rows this tree handed out: a command invoked with one of them was invoked from the tree. */ + private readonly issued = new WeakSet(); constructor( private readonly workspace: ElideWorkspace, private readonly ui: ElideUi, + state: vscode.Memento, ) { + this.shortcuts = new TargetShortcuts(state); this.subscriptions = [ workspace.onDidChange(() => { this.buildTasks.clear(); @@ -171,6 +196,13 @@ export class ElideProjectsView implements vscode.TreeDataProvider, El this.changed.fire(undefined); }), vscode.commands.registerCommand("elide.androidRefreshDevices", () => this.changed.fire(undefined)), + vscode.commands.registerCommand("elide.pinTarget", (node: unknown) => this.editShortcuts(node, (target) => this.shortcuts.pin(target))), + vscode.commands.registerCommand("elide.unpinTarget", (node: unknown) => this.editShortcuts(node, (target) => this.shortcuts.unpin(target))), + vscode.commands.registerCommand("elide.forgetFrequentTarget", (node: unknown) => this.editShortcuts(node, (target) => this.shortcuts.forget(target))), + vscode.commands.registerCommand("elide.clearFrequentTargets", async () => { + await this.shortcuts.clearUsage(); + this.changed.fire(undefined); + }), vscode.tasks.onDidEndTask((event) => { if (event.execution.task.source === "elide") this.changed.fire(undefined); }), @@ -184,6 +216,24 @@ export class ElideProjectsView implements vscode.TreeDataProvider, El this.changed.dispose(); } + /** + * Count a run of a target for the `frequent` section, when `value` — the argument of the command that runs it — is + * a row of this tree: its own click or inline action. Anything else, a code lens or the palette, is not counted. + */ + recordUse(value: unknown): void { + if (typeof value !== "object" || value === null || !this.issued.has(value as TargetNode)) return; + void this.shortcuts.record(targetRef(value as TargetNode)).then(() => this.changed.fire(undefined)); + } + + private async editShortcuts(value: unknown, edit: (target: TargetRef) => Promise): Promise { + if (typeof value !== "object" || value === null) return; + const node = value as ElideNode; + const target = node.kind === "missingTarget" ? node.target : isTargetNode(node) ? targetRef(node) : undefined; + if (!target) return; + await edit(target); + this.changed.fire(undefined); + } + getTreeItem(node: ElideNode): vscode.TreeItem { const item = new vscode.TreeItem(node.label, collapsibleState(node)); item.contextValue = contextValueOf(node); @@ -197,6 +247,7 @@ export class ElideProjectsView implements vscode.TreeDataProvider, El const project = this.workspace.projectAt(node.root); if (project && !this.workspace.komodoDebugAvailable(project.folder)) item.contextValue = item.contextValue.replace(".debug", ""); } + item.contextValue += this.shortcutContext(node); const id = nodeId(node); if (id) item.id = id; switch (node.kind) { @@ -214,6 +265,9 @@ export class ElideProjectsView implements vscode.TreeDataProvider, El case "group": item.iconPath = new vscode.ThemeIcon(GROUP_ICONS[node.group]); break; + case "buildTargetProject": + item.iconPath = new vscode.ThemeIcon("package"); + break; case "entrypoint": item.iconPath = new vscode.ThemeIcon(node.script ? "terminal" : "play"); item.command = { command: "elide.run", title: "Run", arguments: [node] }; @@ -274,20 +328,50 @@ export class ElideProjectsView implements vscode.TreeDataProvider, El item.description = node.attached; item.tooltip = node.classes; break; + case "missingTarget": + item.iconPath = new vscode.ThemeIcon("circle-slash"); + item.description = node.description; + item.tooltip = `${node.label} (${node.root}): ${node.description}`; + break; case "message": item.iconPath = new vscode.ThemeIcon("warning"); break; } + // The sections above the projects mix them; with more than one, each row says which it belongs to. + if (((isTargetNode(node) && node.shortcut) || node.kind === "missingTarget") && this.workspace.projects.length > 1) { + item.description = [this.workspace.projectAt(node.root)?.model?.name ?? path.basename(node.root), item.description].filter(Boolean).join(" · "); + } return item; } + /** + * `.pinned` on a pinned target wherever it is shown, `.frequent` on a row of the `frequent` section: what decides + * between its Pin, Unpin and Remove actions. + */ + private shortcutContext(node: ElideNode): string { + if (node.kind === "missingTarget") return ".pinned"; + if (!isTargetNode(node)) return ""; + if (this.shortcuts.isPinned(targetRef(node))) return ".pinned"; + return node.shortcut === "frequent" ? ".frequent" : ""; + } + async getChildren(node?: ElideNode): Promise { - if (!node) return this.workspace.projects.filter((p) => !p.workspaceRoot).map((p) => projectNode(p)); + const children = await this.childrenOf(node); + for (const child of children) if (isTargetNode(child)) this.issued.add(child); + return children; + } + + private async childrenOf(node?: ElideNode): Promise { + if (!node) return [...this.shortcutGroups(), ...this.workspace.projects.filter((p) => !p.workspaceRoot).map((p) => projectNode(p))]; switch (node.kind) { case "project": return [...this.projectGroups(node.root), ...this.workspace.membersOf(node.root).map((p) => projectNode(p))]; case "group": return await this.groupChildren(node); + case "buildTargetProject": { + const project = this.workspace.projectAt(node.root); + return project ? await this.buildTargetNodes(project, node.label) : []; + } case "module": return node.module.contentRoots.flatMap((content) => content.sourceRoots.map((sourceRoot) => ({ @@ -304,6 +388,62 @@ export class ElideProjectsView implements vscode.TreeDataProvider, El } } + /** The `pinned` and `frequent` sections, each while it has something to show. */ + private shortcutGroups(): ElideNode[] { + const groups: ElideNode[] = []; + if (this.shortcuts.pinned().length > 0) groups.push({ kind: "group", label: GROUP_LABELS.pinned, root: "", group: "pinned" }); + if (this.shortcuts.frequent().length > 0) groups.push({ kind: "group", label: GROUP_LABELS.frequent, root: "", group: "frequent" }); + return groups; + } + + /** Every pinned target, as its project offers it now; one it no longer offers stays, saying why, until unpinned. */ + private async pinnedNodes(): Promise { + return await Promise.all(this.shortcuts.pinned().map(async (target): Promise => { + const node = await this.resolveTarget(target); + return typeof node === "string" + ? { kind: "missingTarget", label: target.name, root: target.root, target, description: node } + : { ...node, shortcut: "pinned" }; + })); + } + + /** The most used targets that are not pinned; one its project no longer offers is skipped, not shown. */ + private async frequentNodes(): Promise { + const nodes: ElideNode[] = []; + for (const target of this.shortcuts.frequent()) { + if (nodes.length === FREQUENT_LIMIT) break; + const node = await this.resolveTarget(target); + if (typeof node !== "string") nodes.push({ ...node, shortcut: "frequent" }); + } + return nodes; + } + + /** The row `target` names, as its project's sections list it now; or why there is none. */ + private async resolveTarget(target: TargetRef): Promise { + const project = this.workspace.projectAt(target.root); + if (!project) return "project no longer in the workspace"; + switch (target.kind) { + case "task": { + const command = PROJECT_TASKS.find((task) => task === target.name); + return command ? taskNode(target.root, command) : "no such task"; + } + case "androidTarget": { + if (!project.model) return "project not synced"; + const artifact = project.model.android?.targets.find((candidate) => candidate.name === target.name); + return artifact ? androidTargetNode(target.root, artifact) : "no longer declared"; + } + case "buildTarget": { + let targets: TargetNode[]; + try { + targets = await this.buildTargets(project); + } catch (e) { + this.ui.log(`could not list build targets of ${project.root}: ${e instanceof Error ? e.message : String(e)}`); + return "could not list build targets — see Elide output"; + } + return targets.find((candidate) => candidate.label === target.name) ?? "no longer a build target"; + } + } + } + /** * A project without a model knows no entrypoints, source sets or libraries yet; its tasks still run. A section * with nothing under it is left out: a workspace root commonly declares members and no sources of its own. A @@ -325,6 +465,8 @@ export class ElideProjectsView implements vscode.TreeDataProvider, El } private async groupChildren(node: Extract): Promise { + if (node.group === "pinned") return await this.pinnedNodes(); + if (node.group === "frequent") return await this.frequentNodes(); const project = this.workspace.projectAt(node.root); if (!project) return []; const model = project.model; @@ -332,14 +474,9 @@ export class ElideProjectsView implements vscode.TreeDataProvider, El case "entrypoints": return (model?.entrypoints ?? []).map((entrypoint) => entrypointNode(node.root, entrypoint)); case "tasks": - return [ - ...PROJECT_TASKS.map((command) => ({ kind: "task" as const, label: command, root: node.root, command, args: [] })), - { kind: "group", label: GROUP_LABELS.buildTargets, root: node.root, group: "buildTargets" }, - ]; + return [...PROJECT_TASKS.map((command) => taskNode(node.root, command)), { kind: "group", label: GROUP_LABELS.buildTargets, root: node.root, group: "buildTargets" }]; case "buildTargets": - return await this.buildTargetNodes(project, true); - case "otherBuildTargets": - return await this.buildTargetNodes(project, false); + return await this.buildTargetNodes(project); case "sourceSets": return (model?.modules ?? []).map((module) => ({ kind: "module", label: module.sourceSet, root: node.root, module })); case "android": @@ -363,26 +500,36 @@ export class ElideProjectsView implements vscode.TreeDataProvider, El } } - /** - * The project's build targets: the ones Elide describes, then a group of the rest. Elide gives its internal - * targets — indexing, classpath files, the source rewriter — no description, and among ~30 rows those are noise. - */ - private async buildTargetNodes(project: ElideProject, described: boolean): Promise { - let tasks: BuildTaskInfo[]; + /** Mirror inspect's project groups and target order, including targets without descriptions. */ + private async buildTargetNodes(project: ElideProject, scope?: string): Promise { + let targets: Extract[]; try { - tasks = await this.inspectBuild(project); + if (!scope && !project.workspaceRoot) { + const tasks = await this.inspectBuild(project); + const projects = [...new Set(tasks.flatMap((task) => task.project === undefined ? [] : [task.project]))]; + if (projects.length > 0) { + return projects.map((label) => ({ kind: "buildTargetProject", label, root: project.root })); + } + } + targets = await this.buildTargets(project, scope); } catch (e) { this.ui.log(`could not list build targets of ${project.root}: ${e instanceof Error ? e.message : String(e)}`); return [{ kind: "message", label: "could not list build targets — see Elide output" }]; } + return targets; + } + + /** Every build target of the project, described or not. */ + private async buildTargets(project: ElideProject, scope?: string): Promise[]> { + const tasks = await this.inspectBuild(project); + const owner = scope ? [project, ...this.workspace.membersOf(project.root)] + .find((member) => (member.model?.name ?? path.basename(member.root)) === scope) : undefined; + if (owner) return (await this.buildTargets(owner)).map((target) => ({ ...target, workspaceListing: true })); // A build target takes the name of the artifact it produces, so the manifest says which ones are binaries. const images = new Map((await this.manifestArtifacts(project)).filter(isNativeImageBinary).map((a) => [a.name, a] as const)); - const name = project.model?.name ?? path.basename(project.root); - const own = buildTasksOf(tasks, name); - const nodes: ElideNode[] = own.filter((target) => (target.description.length > 0) === described).map((target) => { - // Tasks run in the project's own directory, which focuses the CLI on it: a bare name then resolves to this - // project's task rather than to a same-named one of the root, and stays the artifact's key in the manifest. - const label = unqualifiedTaskName(target); + const name = scope ?? project.model?.name ?? path.basename(project.root); + return buildTasksOf(tasks, name).map((target) => { + const label = scope ? target.name : unqualifiedTaskName(target); const image = images.get(label); return { kind: "buildTarget", @@ -398,10 +545,6 @@ export class ElideProjectsView implements vscode.TreeDataProvider, El ...(image?.outputName ? { outputName: image.outputName } : {}), }; }); - if (described && own.some((target) => target.description.length === 0)) { - nodes.push({ kind: "group", label: GROUP_LABELS.otherBuildTargets, root: project.root, group: "otherBuildTargets" }); - } - return nodes; } /** @@ -463,6 +606,26 @@ function projectNode(project: ElideProject): ElideNode { return { kind: "project", label: project.model?.name ?? path.basename(project.root), root: project.root, member: project.workspaceRoot !== undefined }; } +function taskNode(root: string, command: ElideCommand): TargetNode { + return { kind: "task", label: command, root, command, args: [] }; +} + +function isTargetNode(node: ElideNode): node is TargetNode { + return node.kind === "task" || node.kind === "buildTarget" || node.kind === "androidTarget"; +} + +/** What the `pinned` and `frequent` sections remember of a target row. */ +function targetRef(node: TargetNode): TargetRef { + switch (node.kind) { + case "task": + return { kind: node.kind, root: node.root, name: node.command }; + case "buildTarget": + return { kind: node.kind, root: node.root, name: node.label }; + case "androidTarget": + return { kind: node.kind, root: node.root, name: node.artifact }; + } +} + /** * The devices & tools section of a workspace tree, shown once however many Android members it has: what is attached, * then the actions. `owner` is the project they act for; selection and logcat choose among all Android projects, as @@ -500,17 +663,7 @@ function androidToolNodes(owner: ElideProject, tree: readonly ElideProject[]): E * is what a stack trace from a shrunk build is read with. */ function androidTargetNodes(root: string, target: AndroidTargetModel): ElideNode[] { - const node: ElideNode = { - kind: "androidTarget", - label: target.name, - root, - artifact: target.name, - targetKind: target.kind, - args: [target.kind === "feature" ? androidFeatureTask(target.name) : target.name], - description: target.kind === "feature" ? `${target.delivery} → ${target.base}${target.featureDependencies?.length ? ` · requires: ${target.featureDependencies.join(", ")}` : ""}` : - target.features?.length ? `${path.basename(target.output)} · features: ${target.features.join(", ")}` : path.basename(target.output), - ...(target.device ? { offers: currentAndroidDeviceTargets(root, target) ?? target.device } : {}), - }; + const node = androidTargetNode(root, target); if (!target.mapping) return [node]; return [ node, @@ -525,6 +678,20 @@ function androidTargetNodes(root: string, target: AndroidTargetModel): ElideNode ]; } +function androidTargetNode(root: string, target: AndroidTargetModel): TargetNode { + return { + kind: "androidTarget", + label: target.name, + root, + artifact: target.name, + targetKind: target.kind, + args: [target.kind === "feature" ? androidFeatureTask(target.name) : target.name], + description: target.kind === "feature" ? `${target.delivery} → ${target.base}${target.featureDependencies?.length ? ` · requires: ${target.featureDependencies.join(", ")}` : ""}` : + target.features?.length ? `${path.basename(target.output)} · features: ${target.features.join(", ")}` : path.basename(target.output), + ...(target.device ? { offers: currentAndroidDeviceTargets(root, target) ?? target.device } : {}), + }; +} + /** Row icon and context value of each Android artifact: an installable package, an upload bundle, a library. */ const ANDROID_TARGET_ICONS: Record = { apk: "device-mobile", aab: "archive", aar: "package", feature: "extensions" }; const ANDROID_TARGET_CONTEXTS: Record = { apk: "androidApk", aab: "androidAab", aar: "androidAar", feature: "androidFeature" }; @@ -535,11 +702,12 @@ const ANDROID_TARGET_CONTEXTS: Record = { apk: "and * action only when it declares `--debugger`, the Run/Debug pair of a Native Image only when it is a binary one, and * runs on the deployment target when it installs onto a device. An APK row carries `.run`, `.debug` and `.test` for * the device targets Elide lists for it; only a device in the `device` state can take an install, and only an - * emulator can be stopped. + * emulator can be stopped. The view appends a target's pinned state (see `shortcutContext`), so menus match target + * keys as prefixes. */ function contextValueOf(node: ElideNode): string { if (node.kind === "project") return node.member ? "projectMember" : "project"; - if (node.kind === "group" && (node.group === "android" || node.group === "androidTools" || node.group === "androidDevices")) return node.group; + if (node.kind === "group" && (node.group === "android" || node.group === "androidTools" || node.group === "androidDevices" || node.group === "frequent")) return node.group; if (node.kind === "entrypoint") return node.script ? "script" : node.kind; if (node.kind === "buildTarget" && node.runnable) return "buildTargetRunnable"; if (node.kind === "buildTarget" && node.deviceBound) return "buildTargetDevice"; @@ -570,6 +738,10 @@ function collapsibleState(node: ElideNode): vscode.TreeItemCollapsibleState { // Members sit below their root's own sections: expanded, a workspace would bury them. return node.member ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.Expanded; case "group": + // The sections above the projects are what the tree is opened for. + return node.group === "pinned" || node.group === "frequent" ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.Collapsed; + case "buildTargetProject": + return vscode.TreeItemCollapsibleState.Collapsed; case "module": return vscode.TreeItemCollapsibleState.Collapsed; default: @@ -577,20 +749,30 @@ function collapsibleState(node: ElideNode): vscode.TreeItemCollapsibleState { } } -/** Stable per-row identity, so expansion survives a refresh and same-named rows of different projects never clash. */ +/** + * Stable per-row identity, so expansion survives a refresh and same-named rows of different projects never clash. A + * target repeated in a section above the projects is a row of its own there. + */ function nodeId(node: ElideNode): string | undefined { + const id = ownNodeId(node); + return id && isTargetNode(node) && node.shortcut ? `${node.shortcut}:${id}` : id; +} + +function ownNodeId(node: ElideNode): string | undefined { switch (node.kind) { case "project": return `p:${node.root}`; case "group": return `g:${node.root}:${node.group}`; + case "buildTargetProject": + return `bp:${node.root}:${node.label}`; case "entrypoint": return `e:${node.root}:${node.label}`; case "task": // Several build tasks share one subcommand, so the arguments are part of the row's identity. return `t:${node.root}:${node.command}:${node.args.join(" ")}`; case "buildTarget": - return `b:${node.root}:${node.label}`; + return `${node.workspaceListing ? "workspace:" : ""}b:${node.root}:${node.label}`; case "module": return `m:${node.root}:${node.module.name}`; case "sourceRoot": @@ -608,5 +790,7 @@ function nodeId(node: ElideNode): string | undefined { return `x:${node.root}:${node.command}:${node.label}`; case "androidMapping": return `am:${node.root}:${node.artifact}`; + case "missingTarget": + return `pinned:missing:${node.target.kind}:${node.root}:${node.target.name}`; } } diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 70046de..f1f9779 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -76,9 +76,14 @@ export async function activate(context: vscode.ExtensionContext): Promise unknown) => (target: unknown) => { + explorer.recordUse(target); + return run(target); + }; + const androidTests = new ElideAndroidTestController(workspace, ui, deployments, (target) => explorer.recordUse(target)); const codeLenses = new ElideCodeLensProvider(workspace); - const explorer = new ElideProjectsView(workspace, ui); context.subscriptions.push( codeLenses, registerBuildExtensionLinks(workspace), @@ -92,16 +97,16 @@ export async function activate(context: vscode.ExtensionContext): Promise newProject(ui)), vscode.commands.registerCommand("elide.addMember", (target: unknown) => addWorkspaceMember(workspace, ui, target)), vscode.commands.registerCommand("elide.run", (target: unknown) => runEntrypoint(workspace, ui, target, "run")), - vscode.commands.registerCommand("elide.build", (target: unknown) => runEntrypoint(workspace, ui, target, "build")), - vscode.commands.registerCommand("elide.runArtifact", (target: unknown) => runArtifact(workspace, ui, target)), - vscode.commands.registerCommand("elide.debug", (target: unknown) => debugEntrypoint(workspace, target)), - vscode.commands.registerCommand("elide.debugArtifact", (target: unknown) => debugArtifact(workspace, target)), - vscode.commands.registerCommand("elide.executeTask", (target: unknown) => runNamedTask(workspace, ui, target)), + vscode.commands.registerCommand("elide.build", counted((target) => runEntrypoint(workspace, ui, target, "build"))), + vscode.commands.registerCommand("elide.runArtifact", counted((target) => runArtifact(workspace, ui, target))), + vscode.commands.registerCommand("elide.debug", counted((target) => debugEntrypoint(workspace, target))), + vscode.commands.registerCommand("elide.debugArtifact", counted((target) => debugArtifact(workspace, target))), + vscode.commands.registerCommand("elide.executeTask", counted((target) => runNamedTask(workspace, ui, target))), vscode.commands.registerCommand("elide.openManifest", (target: unknown) => openManifest(workspace, target)), vscode.commands.registerCommand("elide.androidSelectTarget", (target: unknown) => deployments.select(target)), - vscode.commands.registerCommand("elide.androidRun", (target: unknown) => runAndroid(workspace, ui, deployments, target)), - vscode.commands.registerCommand("elide.androidDebug", (target: unknown) => debugAndroid(workspace, deployments, target)), - vscode.commands.registerCommand("elide.androidRunDeviceTask", (target: unknown) => runAndroidDeviceTask(workspace, ui, deployments, target)), + vscode.commands.registerCommand("elide.androidRun", counted((target) => runAndroid(workspace, ui, deployments, target))), + vscode.commands.registerCommand("elide.androidDebug", counted((target) => debugAndroid(workspace, deployments, target))), + vscode.commands.registerCommand("elide.androidRunDeviceTask", counted((target) => runAndroidDeviceTask(workspace, ui, deployments, target))), vscode.commands.registerCommand("elide.androidStartEmulator", (target: unknown) => startAndroidEmulator(workspace, target)), vscode.commands.registerCommand("elide.androidStopEmulator", (target: unknown) => stopAndroidEmulator(workspace, target)), vscode.commands.registerCommand("elide.androidCreateAvd", (target: unknown) => createAndroidAvd(workspace, target)), diff --git a/packages/vscode/src/targetShortcuts.ts b/packages/vscode/src/targetShortcuts.ts new file mode 100644 index 0000000..bdd4c28 --- /dev/null +++ b/packages/vscode/src/targetShortcuts.ts @@ -0,0 +1,98 @@ +import type * as vscode from "vscode"; + +/** The sidebar rows that can be pinned and are counted: a project task, a build target, an Android artifact. */ +export type ShortcutKind = "task" | "buildTarget" | "androidTarget"; + +/** + * What identifies a target row across refreshes and sessions: its project and the name it is built by — the + * subcommand of a task, the build target's name, the Android artifact's key. Everything else on the row (description, + * the actions it offers) is read again from the project when it is shown. + */ +export interface TargetRef { + kind: ShortcutKind; + root: string; + name: string; +} + +interface Usage extends TargetRef { + count: number; + /** When it last ran, in epoch milliseconds: the tie-break between targets used equally often. */ + last: number; +} + +const PINNED_KEY = "elide.sidebar.pinnedTargets"; +const USAGE_KEY = "elide.sidebar.targetUsage"; +/** Usage records kept per workspace; the least used go first, so targets since removed do not pile up. */ +export const USAGE_LIMIT = 50; + +export function sameTarget(a: TargetRef, b: TargetRef): boolean { + return a.kind === b.kind && a.root === b.root && a.name === b.name; +} + +/** Most used first, then the most recently used. */ +function byUse(a: Usage, b: Usage): number { + return b.count - a.count || b.last - a.last; +} + +/** + * The targets pinned to the top of the Elide sidebar, in the order they were pinned, and how often each target was + * run from the sidebar. Both are per workspace (`ExtensionContext.workspaceState`): the roots are its projects'. + */ +export class TargetShortcuts { + constructor( + private readonly state: Pick, + private readonly now: () => number = Date.now, + ) {} + + pinned(): TargetRef[] { + return this.state.get(PINNED_KEY) ?? []; + } + + isPinned(target: TargetRef): boolean { + return this.pinned().some((pinned) => sameTarget(pinned, target)); + } + + async pin(target: TargetRef): Promise { + if (this.isPinned(target)) return; + await this.state.update(PINNED_KEY, [...this.pinned(), refOf(target)]); + } + + async unpin(target: TargetRef): Promise { + await this.state.update(PINNED_KEY, this.pinned().filter((pinned) => !sameTarget(pinned, target))); + } + + /** Count one run of `target`. Beyond {@link USAGE_LIMIT} records the least used is dropped, never this one. */ + async record(target: TargetRef): Promise { + const usage = this.usage(); + const previous = usage.find((entry) => sameTarget(entry, target)); + const current: Usage = { ...refOf(target), count: (previous?.count ?? 0) + 1, last: this.now() }; + const others = usage.filter((entry) => entry !== previous).sort(byUse).slice(0, USAGE_LIMIT - 1); + await this.state.update(USAGE_KEY, [current, ...others]); + } + + /** Every target run from the sidebar, most used first; pinned ones are left out, they are at the top already. */ + frequent(): TargetRef[] { + const pinned = this.pinned(); + return this.usage() + .filter((entry) => !pinned.some((target) => sameTarget(target, entry))) + .sort(byUse) + .map(refOf); + } + + async forget(target: TargetRef): Promise { + await this.state.update(USAGE_KEY, this.usage().filter((entry) => !sameTarget(entry, target))); + } + + async clearUsage(): Promise { + await this.state.update(USAGE_KEY, undefined); + } + + private usage(): Usage[] { + return this.state.get(USAGE_KEY) ?? []; + } +} + +/** Just the identity: callers pass whole sidebar rows. */ +function refOf(target: TargetRef): TargetRef { + return { kind: target.kind, root: target.root, name: target.name }; +} diff --git a/packages/vscode/test/androidTestingLifecycle.test.ts b/packages/vscode/test/androidTestingLifecycle.test.ts index fe0576c..dda1657 100644 --- a/packages/vscode/test/androidTestingLifecycle.test.ts +++ b/packages/vscode/test/androidTestingLifecycle.test.ts @@ -94,6 +94,7 @@ test("Android instrumented tests report crashes, honor exclusions and reject lat { projects: [project], projectAt: () => project, onDidChange: () => events$ }, { log() {} }, { resolve: async () => ({}), readyDevice: async () => "serial" }, + () => {}, ); await commands.get("elide.androidTest")(undefined); assert.deepEqual(events, [ @@ -206,6 +207,7 @@ test("Disposing the controller ends the detached android-test process of a run i { projects: [project], projectAt: () => project, onDidChange: () => events$ }, { log() {} }, { resolve: async () => ({}), readyDevice: async () => "serial" }, + () => {}, ); const command = commands.get("elide.androidTest")(undefined); const signal = await running; diff --git a/packages/vscode/test/integration/index.ts b/packages/vscode/test/integration/index.ts index 25c4bd6..ceb24ea 100644 --- a/packages/vscode/test/integration/index.ts +++ b/packages/vscode/test/integration/index.ts @@ -395,10 +395,10 @@ export async function run(): Promise { const taskNodes = await childrenOf("Tasks"); assert.deepEqual(taskNodes.map((n) => n.label), ["build", "test", "install", "Build targets"]); const targetRows = (await explorer.getChildren(taskNodes[3])) as { kind: string; label: string; group?: string; debuggable?: boolean }[]; - // Described targets are rows; the ones Elide gives no description (its internal targets) sit in one group after them. const targets = targetRows.filter((n) => n.kind === "buildTarget"); - assert.ok(targets.length > 0 && targetRows.every((n) => n.kind === "buildTarget" || n.group === "otherBuildTargets"), - `elide build --inspect targets, got ${JSON.stringify(targetRows)}`); + assert.ok(targetRows.every((n) => n.kind === "buildTarget"), `flat standalone targets, got ${JSON.stringify(targetRows)}`); + assert.ok(targets.some((n) => n.label === "index-local"), "undescribed targets are not hidden in a separate group"); + assert.deepEqual(targets.map((n) => n.label), targets.map((n) => n.label).sort(), "standalone targets retain inspect order"); // Only targets that start a JVM declare `--debugger`, and those are the ones the tree offers a Debug action on. assert.deepEqual( targets.filter((n) => n.debuggable).map((n) => n.label).sort(), diff --git a/packages/vscode/test/integration/workspace.ts b/packages/vscode/test/integration/workspace.ts index addf7a7..4e4e987 100644 --- a/packages/vscode/test/integration/workspace.ts +++ b/packages/vscode/test/integration/workspace.ts @@ -122,12 +122,27 @@ export async function runWorkspaceScenario(): Promise { "a member shows every section it has", ); const tasks = (await explorer.getChildren(cliGroups.find((g) => g.group === "tasks"))) as Node[]; - // Described targets are rows; the undescribed ones Elide uses internally sit in one group after them. + // Every target stays in inspect order, including the internal targets that have no description. const targets = ((await explorer.getChildren(tasks.find((t) => t.group === "buildTargets"))) as Node[]).filter((t) => t.kind === "buildTarget"); const labels = targets.map((t) => t.label); assert.ok(labels.includes("cli") && labels.includes("run"), `cli's own targets listed, got ${JSON.stringify(labels)}`); assert.ok(labels.every((l) => !l.includes(":")), `labels carry no project scope, got ${JSON.stringify(labels)}`); assert.ok(targets.every((t) => t.root === member("cli") && t.args?.[0] === t.label), "targets run in cli under their own name"); + assert.ok(labels.includes("index-local"), "undescribed targets stay alongside the other targets"); + assert.deepEqual(labels, [...labels].sort(), "targets retain inspect's name order"); + const rootTasks = (await explorer.getChildren(rootGroups.find((g) => g.group === "tasks"))) as Node[]; + const targetProjects = (await explorer.getChildren(rootTasks.find((g) => g.group === "buildTargets"))) as Node[]; + assert.deepEqual(targetProjects.map((n) => [n.kind, n.label]), + ["logstat", ...MEMBERS].map((name) => ["buildTargetProject", name]), "inspect project groups, root first then declaration order"); + for (const group of targetProjects) { + const rows = (await explorer.getChildren(group)) as Node[]; + const root = group.label === "logstat" ? sample : member(group.label); + assert.ok(rows.every((row) => row.kind === "buildTarget" && row.root === root && row.args?.[0] === row.label), + `${group.label}'s grouped targets execute in their own project`); + assert.ok(rows.some((row) => row.label === "index-local"), `${group.label}'s undescribed targets are not hidden`); + assert.deepEqual(rows.map((row) => row.label), rows.map((row) => row.label).sort(), `${group.label}'s inspect order`); + if (group.label === "cli") assert.deepEqual(rows.map((row) => row.label), labels, "workspace and member list the same targets"); + } log("sidebar ok"); // 5. Test explorer: each member's tests are discovered under that member. `elide test` at the root runs the whole diff --git a/packages/vscode/test/targetShortcuts.test.ts b/packages/vscode/test/targetShortcuts.test.ts new file mode 100644 index 0000000..addde79 --- /dev/null +++ b/packages/vscode/test/targetShortcuts.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from "bun:test"; +import { TargetShortcuts, USAGE_LIMIT, type TargetRef } from "../src/targetShortcuts.js"; + +/** A `Memento` that round-trips through JSON, as `workspaceState` does. */ +function memento() { + const values = new Map(); + return { + get: (key: string): T | undefined => (values.has(key) ? JSON.parse(values.get(key)!) : undefined), + update: async (key: string, value: unknown) => { + if (value === undefined) values.delete(key); + else values.set(key, JSON.stringify(value)); + }, + }; +} + +const target = (name: string, kind: TargetRef["kind"] = "buildTarget", root = "/app"): TargetRef => ({ kind, root, name }); + +test("frequent targets rank by runs, ties by the latest run, and leave pinned ones to the pinned section", async () => { + let clock = 0; + const shortcuts = new TargetShortcuts(memento(), () => ++clock); + for (const name of ["jar", "run", "jar", "test", "native"]) await shortcuts.record(target(name)); + // The same name in another project, or as another kind of row, is another target. + await shortcuts.record(target("jar", "buildTarget", "/lib")); + await shortcuts.record(target("test", "task")); + + expect(shortcuts.frequent().map((t) => `${t.kind}:${t.root}:${t.name}`)).toEqual([ + "buildTarget:/app:jar", + "task:/app:test", + "buildTarget:/lib:jar", + "buildTarget:/app:native", + "buildTarget:/app:test", + "buildTarget:/app:run", + ]); + + // A whole sidebar row is passed in; only its identity is kept. + await shortcuts.pin({ ...target("jar"), label: "jar", description: "the jar" } as TargetRef); + await shortcuts.pin(target("jar")); + expect(shortcuts.pinned()).toEqual([target("jar")]); + expect(shortcuts.frequent().map((t) => t.name)).toEqual(["test", "jar", "native", "test", "run"]); + + // Unpinned, it is back where its runs put it; forgotten, it starts over. + await shortcuts.unpin(target("jar")); + expect(shortcuts.frequent()[0]).toEqual(target("jar")); + await shortcuts.forget(target("jar")); + expect(shortcuts.frequent().some((t) => t.root === "/app" && t.name === "jar" && t.kind === "buildTarget")).toBe(false); + await shortcuts.clearUsage(); + expect(shortcuts.frequent()).toEqual([]); +}); + +test("a full usage record drops its least used target, never the one just run", async () => { + let clock = 0; + const shortcuts = new TargetShortcuts(memento(), () => ++clock); + for (let i = 0; i < USAGE_LIMIT; i++) { + await shortcuts.record(target(`t${i}`)); + await shortcuts.record(target(`t${i}`)); + } + await shortcuts.record(target("new")); + const names = shortcuts.frequent().map((t) => t.name); + expect(names).toHaveLength(USAGE_LIMIT); + expect(names.at(-1)).toBe("new"); + // Every other target ran twice; of those the one run longest ago went. + expect(names).not.toContain("t0"); + expect(names).toContain("t1"); +});