From 4964be9b103022b7c5029b527df185f4573875e0 Mon Sep 17 00:00:00 2001 From: Dario Valdespino Date: Sat, 31 Oct 2026 17:56:21 -0300 Subject: [PATCH 1/4] feat: organize sidebar targets with pinned and frequent shortcuts Preserve CLI target order and workspace project grouping, including undescribed targets. Keep pinned targets and sidebar usage per workspace, including Android artifacts. Consolidate the preserved sidebar stash on top of all local Android, Komodo, deployment and build-extension work without rewriting existing branches. Verified BUILD metadata, full Bazel build/typecheck/package/tests, and a packaged VSIX smoke in an isolated VS Code server: pin/unpin, real install execution, usage tracking, build inspection and pinning an undescribed target. --- packages/vscode/BUILD.bazel | 1 + packages/vscode/README.md | 9 +- packages/vscode/package.json | 80 ++++- packages/vscode/src/androidTesting.ts | 7 +- packages/vscode/src/explorer.ts | 282 +++++++++++++++--- packages/vscode/src/extension.ts | 25 +- packages/vscode/src/targetShortcuts.ts | 98 ++++++ .../test/androidTestingLifecycle.test.ts | 2 + packages/vscode/test/integration/index.ts | 6 +- packages/vscode/test/integration/workspace.ts | 17 +- packages/vscode/test/targetShortcuts.test.ts | 64 ++++ 11 files changed, 515 insertions(+), 76 deletions(-) create mode 100644 packages/vscode/src/targetShortcuts.ts create mode 100644 packages/vscode/test/targetShortcuts.test.ts 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..f35ea41 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 @@ -421,7 +426,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"); +}); From 9775ec75c87fd17a21ec5612cc722dbad01927ec Mon Sep 17 00:00:00 2001 From: Dario Valdespino Date: Tue, 3 Nov 2026 13:36:21 -0300 Subject: [PATCH 2/4] build: refresh generated manifest cache --- .../npm_translate_lock_LTE4Nzc1MDcwNjU= | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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..0c3ba14 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=-1293100609 pnpm-lock.yaml=1115513514 pnpm-workspace.yaml=-1673009026 From b3952c7bea66059ff7160c9ce916f3e4804bcae2 Mon Sep 17 00:00:00 2001 From: Dario Valdespino Date: Tue, 3 Nov 2026 13:39:34 -0300 Subject: [PATCH 3/4] build: refresh integrated availability manifest cache --- .../npm_translate_lock_LTE4Nzc1MDcwNjU= | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0c3ba14..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=-1293100609 +packages/vscode/package.json=-323472392 pnpm-lock.yaml=1115513514 pnpm-workspace.yaml=-1673009026 From df43fa8af4bf06525e7b3f55599ce9440cebb73b Mon Sep 17 00:00:00 2001 From: Dario Valdespino Date: Tue, 3 Nov 2026 13:40:13 -0300 Subject: [PATCH 4/4] docs: explain unavailable native action visibility --- packages/vscode/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/vscode/README.md b/packages/vscode/README.md index f35ea41..77d7e68 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -236,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