| // Copyright 2025 The Fuchsia Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import * as vscode from 'vscode'; |
| import { window } from 'vscode'; |
| import { Setup } from '../extension'; |
| import * as logger from '../logger'; |
| import { registerCommandWithAnalyticsEvent } from '../analytics/vscode_events'; |
| |
| const PRODUCT_PLACEHOLDERS = new Map([ |
| ['bringup', 'Minimal viable development target'], |
| ['core', 'Starting point for higher-level product configurations'], |
| ['minimal', 'Smallest thing which can be called Fuchsia'], |
| ['workbench_eng', 'Not consumer-oriented, to explore Fuchsia'], |
| ]); |
| |
| enum CompilationModes { |
| debug = '', |
| release = '--release', |
| balanced = '--balanced', |
| } |
| |
| export interface Packages { |
| title: string; |
| value: string[]; |
| notes: string; |
| } |
| |
| interface Build { |
| product: string; |
| board: string; |
| compilation: string; |
| packages: Packages[]; |
| } |
| |
| /** |
| * Converts a list of strings to a QuickPickItem array. |
| * @param output The list of items to display. |
| * @param currentPick The selected item, push to top of list and label as 'current'. |
| * @param placeholders Optional descriptions for specific items. |
| */ |
| export function toList( |
| output: string[], |
| currentPick?: string, |
| placeholders?: Map<string, string> |
| ): vscode.QuickPickItem[] { |
| const list = output |
| .filter((item) => item) |
| .map<vscode.QuickPickItem>((item) => ({ |
| label: item, |
| detail: placeholders?.get(item), |
| })); |
| |
| const currentPickIndex = list.findIndex((item) => item.label === currentPick); |
| if (currentPickIndex > -1) { |
| const [currentItem] = list.splice(currentPickIndex, 1); |
| currentItem.description = '(Current)'; |
| list.unshift(currentItem); |
| } |
| |
| return list; |
| } |
| |
| /** |
| * Manages the history of package selections within the session. |
| */ |
| export class PkgHistory { |
| private pkgHistory: Packages[] = []; |
| |
| constructor(private memento: vscode.Memento, pkgHistory?: Packages[]) { |
| this.pkgHistory = pkgHistory ?? memento.get<Packages[]>('pkgHistory') ?? []; |
| } |
| |
| /** Clears all package history. */ |
| clear(): void { |
| this.pkgHistory = []; |
| void this.memento.update('pkgHistory', []); |
| } |
| |
| /** |
| * Remove specific packages from history. |
| * @param packages List of packages to remove. |
| */ |
| remove(items: { title: string; value: string }[]): void { |
| const removePackageFromGroup = (title: string, value: string) => { |
| const group = this.pkgHistory.find((p) => p.title === title); |
| if (group) { |
| group.value = group.value.filter((v) => v !== value); |
| } |
| }; |
| items.forEach((item) => removePackageFromGroup(item.title, item.value)); |
| // Remove empty groups |
| this.pkgHistory = this.pkgHistory.filter((p) => p.value.length > 0); |
| |
| void this.memento.update('pkgHistory', this.pkgHistory); |
| } |
| |
| /** |
| * Generates a QuickPickItem list from the package history. |
| * @param lastSelected A map indicating which packages were previously selected. |
| */ |
| toList(lastSelected: Map<string, Set<string>>): vscode.QuickPickItem[] { |
| const list: vscode.QuickPickItem[] = []; |
| this.pkgHistory.forEach((pkg) => { |
| list.push({ |
| label: pkg.title, |
| kind: vscode.QuickPickItemKind.Separator, |
| }); |
| |
| const packageArg = pkg.notes.substring(0, pkg.notes.indexOf(' ')); |
| const lastSet = lastSelected.get(pkg.title) ?? new Set(); |
| |
| pkg.value.forEach((pkgItem) => { |
| list.push({ |
| label: pkgItem, |
| description: packageArg, |
| picked: lastSet.has(pkgItem), |
| }); |
| }); |
| }); |
| return list; |
| } |
| |
| /** Gets the current package history. */ |
| get(): Packages[] { |
| return this.pkgHistory; |
| } |
| |
| /** |
| * Updates the history with the latest packages from `fx status`. |
| * Merges new packages and returns a map of the packages that were selected. |
| * @param statusPkgs The list of packages from the most recent `fx status`. |
| * @returns A map where keys are package groups (e.g., '--with') and |
| * values are a Set of the package names that were selected. |
| */ |
| update(statusPkgs: Packages[]): Map<string, Set<string>> { |
| const lastSelected = new Map<string, Set<string>>(); |
| if (statusPkgs.length === 0) { |
| return lastSelected; |
| } |
| |
| // Record selection from fx status |
| statusPkgs.forEach((pkg) => { |
| lastSelected.set(pkg.title, new Set(pkg.value)); |
| }); |
| |
| // If history is empty, adopt the current status. |
| if (this.pkgHistory.length === 0) { |
| this.pkgHistory = statusPkgs; |
| void this.memento.update('pkgHistory', this.pkgHistory); |
| return lastSelected; |
| } |
| |
| // Merge status packages into history |
| const historyMap = new Map(this.pkgHistory.map((pkg) => [pkg.title, pkg])); |
| statusPkgs.forEach((statusPkg) => { |
| const existingPkg = historyMap.get(statusPkg.title); |
| // If the package group is new, add it to the history. |
| if (!existingPkg) { |
| this.pkgHistory.push(statusPkg); |
| return; |
| } |
| // Add new packages to the front and deduplicate |
| const combined = [...statusPkg.value, ...existingPkg.value]; |
| existingPkg.value = [...new Set(combined)]; |
| }); |
| |
| void this.memento.update('pkgHistory', this.pkgHistory); |
| return lastSelected; |
| } |
| } |
| |
| /** |
| * Retrieves the current build configuration by running `fx status`. |
| * TODO(fxbug.dev/390002761) side-panel with more complex build configs |
| */ |
| async function getStatus(setup: Setup): Promise<Build> { |
| const status: Build = { |
| product: '', |
| board: '', |
| compilation: '', |
| packages: [], |
| }; |
| |
| const process = setup.fx.runJsonStreaming( |
| ['status', '--format=json'], |
| (data: unknown) => { |
| const statusData = data as { |
| buildInfo: { |
| items: Record<string, |
| | { value: string; title?: string; notes?: string } |
| | Packages |
| >; |
| }; |
| }; |
| const buildInfo = statusData.buildInfo.items; |
| for (const item in buildInfo) { |
| switch (item) { |
| case 'boards': |
| status.board = buildInfo[item].value as string; |
| break; |
| case 'products': |
| status.product = buildInfo[item].value as string; |
| break; |
| case 'compilation_mode': |
| status.compilation = buildInfo[item].value as string; |
| break; |
| default: |
| status.packages.push(buildInfo[item] as Packages); |
| } |
| } |
| } |
| ); |
| await process.exitCode; |
| return status; |
| } |
| |
| /** |
| * Prompts the user to select a product. |
| */ |
| async function promptForProduct( |
| products: string[], |
| currentProduct: string |
| ): Promise<string | undefined> { |
| const list = toList(products, currentProduct, PRODUCT_PLACEHOLDERS); |
| const selected = await window.showQuickPick(list, { title: 'Select Product' }); |
| return selected?.label; |
| } |
| |
| /** |
| * Prompts the user to select a board. |
| */ |
| async function promptForBoard( |
| boards: string[], |
| currentBoard: string |
| ): Promise<string | undefined> { |
| const list = toList(boards, currentBoard); |
| const selected = await window.showQuickPick(list, { title: 'Select Board' }); |
| return selected?.label; |
| } |
| |
| /** |
| * Prompts the user to select a compilation mode. |
| */ |
| async function promptForCompilation( |
| currentCompilation: string |
| ): Promise<string | undefined> { |
| const list = toList(Object.keys(CompilationModes), currentCompilation); |
| const selected = await window.showQuickPick(list, { |
| title: 'Select Compilation Mode', |
| }); |
| return selected?.label; |
| } |
| |
| /** |
| * Prompts the user to select packages. |
| */ |
| async function promptForPackages( |
| pkgHistory: PkgHistory, |
| currentPackages: Packages[] |
| ): Promise<vscode.QuickPickItem[] | undefined> { |
| if (currentPackages.length === 0 && pkgHistory.get().length === 0) { |
| return []; |
| } |
| const prevPkg = pkgHistory.update(currentPackages); |
| const list = pkgHistory.toList(prevPkg); |
| return await window.showQuickPick(list, { |
| title: 'Select Packages', |
| canPickMany: true, |
| }); |
| } |
| |
| /** |
| * Normalizes and sorts a package list for reliable comparison. |
| */ |
| function getPackageArgs(packages: Packages[] | vscode.QuickPickItem[]): string[] { |
| const args: string[] = []; |
| if (!packages) { |
| return args; |
| } |
| |
| // Check if it's QuickPickItem[] based on shape |
| if (packages.length > 0 && 'label' in packages[0]) { |
| (packages as vscode.QuickPickItem[]).forEach((pkg) => { |
| if (pkg.description) { |
| args.push(`${pkg.description} ${pkg.label}`); |
| } |
| }); |
| return args.sort(); |
| } |
| |
| // Otherwise, it's Packages[] |
| (packages as Packages[]).forEach((pkg) => { |
| const pkgArg = pkg.notes.substring(0, pkg.notes.indexOf(' ')); |
| pkg.value.forEach((val: string) => { |
| args.push(`${pkgArg} ${val}`); |
| }); |
| }); |
| return args.sort(); |
| } |
| |
| /** |
| * Checks if the user's new selections are different from the previous configuration. |
| */ |
| function didArgsChange( |
| last: Build, |
| current: |
| { |
| product?: string; |
| board?: string; |
| compilation?: string; |
| packages?: vscode.QuickPickItem[]; |
| } |
| ): boolean { |
| const lastMode = |
| CompilationModes[last.compilation as keyof typeof CompilationModes]; |
| const lastSet = [ |
| `${last.product}.${last.board}`, |
| lastMode, |
| ...getPackageArgs(last.packages), |
| ] |
| .filter(Boolean) |
| .join(' '); |
| |
| const currentMode = |
| CompilationModes[current.compilation as keyof typeof CompilationModes]; |
| const currentSet = [ |
| `${current.product}.${current.board}`, |
| currentMode, |
| ...getPackageArgs(current.packages ?? []), |
| ] |
| .filter(Boolean) |
| .join(' '); |
| |
| return lastSet !== currentSet; |
| } |
| |
| /** |
| * Constructs the argument list for the `fx set` command. |
| */ |
| function buildFxSetArgs( |
| product: string, |
| board: string, |
| compilation: string, |
| packages: vscode.QuickPickItem[] |
| ): string[] { |
| const args = [ |
| 'set', |
| `${product.trimEnd()}.${board.trimEnd()}`, |
| CompilationModes[compilation as keyof typeof CompilationModes], |
| ]; |
| |
| packages.forEach((pkg) => { |
| if (pkg.description) { |
| args.push(pkg.description); |
| args.push(pkg.label); |
| } |
| }); |
| |
| return args.filter(Boolean); |
| } |
| |
| /** |
| * Runs the `fx set` command with a progress indicator. |
| */ |
| function runFuchsiaSet(setup: Setup, args: string[]) { |
| void window.withProgress( |
| { |
| location: vscode.ProgressLocation.Notification, |
| title: 'fx set', |
| cancellable: true, |
| }, |
| async (_progress, token) => { |
| const cmd = setup.fx.runAsync( |
| args, |
| (buffer) => logger.info(new TextDecoder().decode(buffer), 'fx set'), |
| (buffer) => logger.error(new TextDecoder().decode(buffer), 'fx set') |
| ); |
| |
| token.onCancellationRequested(() => { |
| logger.info('fx set stopped: user cancelled operation', 'fx set'); |
| cmd?.stop(); |
| }); |
| |
| const exitCode = await cmd?.exitCode; |
| if (exitCode === 0) { |
| void window.showInformationMessage('fx set completed'); |
| logger.info( |
| 'fx set process executed successfully [exit code = 0]', |
| 'fx set' |
| ); |
| } else { |
| logger.warn(`fx set stopped with exit code: ${exitCode}`, 'fx set'); |
| } |
| } |
| ); |
| } |
| |
| /** Registers the `fuchsia.fx.set` command. */ |
| export function registerSetCommand( |
| _ctx: vscode.ExtensionContext, |
| setup: Setup, |
| pkgHistory: PkgHistory |
| ) { |
| registerCommandWithAnalyticsEvent( |
| 'fuchsia.fx.set', |
| async ( |
| product?: string, |
| board?: string, |
| compilation?: string, |
| packages?: vscode.QuickPickItem[] |
| ) => { |
| const status = await getStatus(setup); |
| |
| // Pre-fetch lists for prompts in parallel to improve performance |
| const [products, boards] = await Promise.all([ |
| setup.fx.runFx(['list-products']), |
| setup.fx.runFx(['list-boards']), |
| ]); |
| |
| // If args aren't provided programmatically, prompt the user for them. |
| const selectedProduct = |
| product ?? (await promptForProduct(products.split('\n'), status.product)); |
| if (!selectedProduct) { |
| return; // User cancelled |
| } |
| const selectedBoard = |
| board ?? (await promptForBoard(boards.split('\n'), status.board)); |
| if (!selectedBoard) { |
| return; // User cancelled |
| } |
| const selectedCompilation = |
| compilation ?? (await promptForCompilation(status.compilation)); |
| if (!selectedCompilation) { |
| return; // User cancelled |
| } |
| const selectedPackages = |
| packages ?? (await promptForPackages(pkgHistory, status.packages)); |
| if (!selectedPackages) { |
| return; // User cancelled |
| } |
| const newArgs = { |
| product: selectedProduct, |
| board: selectedBoard, |
| compilation: selectedCompilation, |
| packages: selectedPackages, |
| }; |
| |
| if (!didArgsChange(status, newArgs)) { |
| logger.info('fx set arguments did not change. Skipping.', 'fx set'); |
| return; |
| } |
| |
| const args = buildFxSetArgs( |
| selectedProduct, |
| selectedBoard, |
| selectedCompilation, |
| selectedPackages |
| ); |
| |
| runFuchsiaSet(setup, args); |
| } |
| ); |
| } |
| |
| /** Registers the `fuchsia.clearPackageHistory` command. */ |
| export function registerClearPackageHistoryCommand( |
| _ctx: vscode.ExtensionContext, |
| pkgHistory: PkgHistory |
| ) { |
| registerCommandWithAnalyticsEvent( |
| 'fuchsia.clearPackageHistory', |
| () => { |
| pkgHistory.clear(); |
| void window.showInformationMessage('Fuchsia package history cleared.'); |
| } |
| ); |
| } |
| |
| /** Registers the `fuchsia.removePackageFromHistory` command. */ |
| export function registerRemovePackageCommand( |
| _ctx: vscode.ExtensionContext, |
| pkgHistory: PkgHistory |
| ) { |
| registerCommandWithAnalyticsEvent( |
| 'fuchsia.removePackageFromHistory', |
| async () => { |
| const history = pkgHistory.get(); |
| if (history.length === 0) { |
| void window.showInformationMessage('No package history to remove.'); |
| return; |
| } |
| |
| // Map QuickPickItems back to their source group and value |
| const itemMap = new Map< |
| vscode.QuickPickItem, |
| { title: string; value: string } |
| >(); |
| const list: vscode.QuickPickItem[] = []; |
| |
| history.forEach((pkg) => { |
| list.push({ |
| label: pkg.title, |
| kind: vscode.QuickPickItemKind.Separator, |
| }); |
| |
| const packageArg = pkg.notes.substring(0, pkg.notes.indexOf(' ')); |
| |
| pkg.value.forEach((pkgItem) => { |
| const item: vscode.QuickPickItem = { |
| label: pkgItem, |
| description: packageArg, |
| }; |
| list.push(item); |
| itemMap.set(item, { title: pkg.title, value: pkgItem }); |
| }); |
| }); |
| |
| const selected = await window.showQuickPick(list, { |
| title: 'Select packages to remove from history', |
| canPickMany: true, |
| }); |
| |
| if (selected && selected.length > 0) { |
| const itemsToRemove = selected |
| .map((item) => itemMap.get(item)) |
| .filter((i): i is { title: string; value: string } => !!i); |
| |
| pkgHistory.remove(itemsToRemove); |
| void window.showInformationMessage( |
| `Removed ${itemsToRemove.length} item(s) from package history.` |
| ); |
| } |
| } |
| ); |
| } |