blob: 9ffa58994502fc0858cd50dd994b8203baa6765d [file]
// 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.`
);
}
}
);
}