| // 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'; |
| import { FuchsiaDiagnostics } from './problem_matcher'; |
| |
| export const BUILD_MATCHER = /^\[.*?\(\d+\)/; |
| 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: Array<string>, |
| notes: string |
| } |
| |
| interface Build { |
| product: string, |
| board: string, |
| compilation: string, |
| packages: Array<Packages> |
| } |
| |
| // TODO(fxbug.dev/390002761) Add vscode state tracking |
| // TODO(fxbug.dev/390002761) side-panel with more complex build configs |
| |
| /** |
| * initialize fuchsia workflow interaction commands |
| */ |
| export function setUpWorkflowInteraction(ctx: vscode.ExtensionContext, setup: Setup) { |
| let collection = new FuchsiaDiagnostics(); |
| |
| /** Registers fx.set with analytics */ |
| registerCommandWithAnalyticsEvent('fuchsia.fx.set', |
| async (product?: string, |
| board?: string, |
| compilation?: string, |
| packages?: Array<vscode.QuickPickItem> |
| ) => { |
| let status: Build = { |
| product: '', |
| board: '', |
| compilation: '', |
| packages: [], |
| }; |
| await getStatus(status); |
| |
| if (!product) { |
| await setup.fx.runFx(['list-products']).then(async (data) => { |
| const list = toList(data, status.product, PRODUCT_PLACEHOLDERS); |
| const selected = await window.showQuickPick(list, { |
| title: 'Select Product' |
| }); |
| product = selected?.label; |
| }); |
| } |
| |
| if (!board) { |
| await setup.fx.runFx(['list-boards']).then(async (data) => { |
| const list = toList(data, status.board); |
| const selected = await window.showQuickPick(list, { |
| title: 'Select Board' |
| }); |
| board = selected?.label; |
| }); |
| } |
| |
| if (!compilation) { |
| const list = toList(Object.keys(CompilationModes), status.compilation); |
| const selected = await window.showQuickPick(list, { |
| title: 'Select Compilation Mode' |
| }); |
| compilation = selected?.label; |
| } |
| |
| if (status.packages?.length > 0) { |
| const list = packageToList(status.packages); |
| const selected = await window.showQuickPick(list, { |
| title: 'Select Packages', |
| canPickMany: true |
| }); |
| packages = selected; |
| } |
| |
| if (!product || !board || !compilation) { |
| return; |
| } |
| |
| if (didArgsChange()) { |
| return; |
| } |
| |
| runFuchsiaSet(getArgs()); |
| |
| /** Helper that checks fx set args did change */ |
| function didArgsChange(): Boolean { |
| let mode = status.compilation as keyof typeof CompilationModes; |
| let lastSet = `${status.product}.${status.board} ${CompilationModes[mode]}`; |
| mode = compilation as keyof typeof CompilationModes; |
| let set = `${product}.${board} ${CompilationModes[mode]}`; |
| return lastSet === set ? true : false; |
| } |
| |
| /** Helper to convert args into array of strings */ |
| function getArgs(): Array<string> { |
| let args = [ |
| 'set', |
| `${product?.trimEnd()}.${board?.trimEnd()}`, |
| CompilationModes[compilation as keyof typeof CompilationModes] |
| ]; |
| packages?.forEach((pkg) => { |
| if (pkg.picked) { |
| args.push(`${pkg.description}`); |
| args.push(pkg.label); |
| } |
| }); |
| args = args.filter(Boolean); |
| return args; |
| } |
| |
| /** Get current target configuration from `fx status`*/ |
| async function getStatus(status: Build) { |
| const process = setup.fx.runJsonStreaming(['status', '--format=json'], (data: any) => { |
| const buildInfo = data.buildInfo.items; |
| for (let item in buildInfo) { |
| switch (item) { |
| case 'boards': |
| status.board = buildInfo[item].value; |
| break; |
| case 'products': |
| status.product = buildInfo[item].value; |
| break; |
| case 'compilation_mode': |
| status.compilation = buildInfo[item].value; |
| break; |
| default: |
| status.packages.push(buildInfo[item]); |
| } |
| } |
| }); |
| await process.exitCode; |
| } |
| |
| /** Run fx set */ |
| function runFuchsiaSet(args: string[]) { |
| void window.withProgress({ |
| location: vscode.ProgressLocation.Notification, |
| title: 'fx set', |
| cancellable: true |
| }, async (progress, token) => { |
| let cmd = setup.fx.runAsync( |
| args, |
| (buffer) => { |
| let msg = new TextDecoder().decode(buffer); |
| logger.info(msg, 'fx set'); |
| }, |
| (buffer) => { |
| const msg = new TextDecoder().decode(buffer); |
| logger.error(msg, 'fx set'); |
| } |
| ); |
| |
| token.onCancellationRequested(() => { |
| cmd?.stop(); |
| }); |
| |
| let 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'); |
| return Promise.resolve(); |
| } |
| if (exitCode !== 0) { |
| logger.warn('fx set stopped', 'fx set'); |
| return Promise.reject(exitCode); |
| } |
| }); |
| } |
| } |
| ); |
| |
| /** |
| * Registers fx.build with analytics |
| */ |
| registerCommandWithAnalyticsEvent('fuchsia.fx.build', () => { |
| collection.clear(); |
| void window.withProgress({ |
| location: vscode.ProgressLocation.Notification, |
| title: 'Building Fuchsia', |
| cancellable: true |
| }, async (progress, token) => { |
| let percentage: number; // updated in callback |
| let cmd = setup.fx.runAsync(['build'], handleData, handleData); |
| |
| token.onCancellationRequested(() => { |
| logger.info('fx build stopped: user cancelled operation', 'fx build'); |
| cmd?.stop(); |
| }); |
| |
| let exitCode = await cmd?.exitCode; |
| if (exitCode === 0) { |
| logger.info('fx build complete: success [code=0]', 'fx build'); |
| return Promise.resolve(); |
| } |
| if (exitCode === 1) { |
| logger.error('fx build stopped: failed [code=1]', 'fx build'); |
| void window.showErrorMessage( |
| 'Build stopped, [see output for details](command:fuchsia.showOutput)' |
| ); |
| return Promise.reject(exitCode); |
| } |
| |
| /** |
| * Callback to handle buffer to be displayed in vscode UI, sends msg to log |
| * in output window and updates percent in the progress window. |
| */ |
| function handleData(buffer: Buffer) { |
| const msg = new TextDecoder().decode(buffer); |
| let match = msg.match(BUILD_MATCHER); |
| match ? |
| logOutput({ message: msg, category: 'fx build', inMatcher: true }) : |
| logOutput({ message: msg, category: 'fx build', inMatcher: false }); |
| |
| collection.match(msg); |
| |
| // Surface to user since msg can get lost in output window |
| if (msg.includes('Locked')) { |
| void window.showWarningMessage(msg); |
| } |
| |
| if (msg.includes('%')) { |
| const percent = msg.match(/\d+%/); |
| const lastPercent = percentage ?? 0; |
| percentage = parseInt(percent![0]); |
| progress.report({ |
| increment: percentage - lastPercent, |
| message: `[(details)](command:fuchsia.showOutput) ${match![0]}` |
| }); |
| } |
| } |
| }); |
| }); |
| |
| /** Registers fx.serve with analytics */ |
| registerCommandWithAnalyticsEvent('fuchsia.fx.serve', () => { |
| void window.withProgress({ |
| location: vscode.ProgressLocation.Notification, |
| title: 'fx serve', |
| cancellable: true |
| }, async (progress, token) => { |
| let cmd = setup.fx.runAsync(['serve', '--background'], handleData, handleData); |
| |
| token.onCancellationRequested(() => { |
| console.log('User canceled fx serve operation'); |
| cmd?.stop(); |
| }); |
| |
| let exitCode = await cmd?.exitCode; |
| return exitCode === 0 ? Promise.resolve() : Promise.reject(exitCode); |
| |
| /** callback to handle buffer from process */ |
| function handleData(buffer: Buffer) { |
| const msg = new TextDecoder().decode(buffer); |
| logger.info(msg, 'fx serve'); |
| if (msg.includes('resolve')) { |
| let match = msg.match(/(.*) (\[\w+\])\s+(.*)/); |
| if (match) { |
| progress.report({ message: `${match[match.length - 1]}` }); |
| } |
| } |
| } |
| }); |
| }); |
| |
| /** Registers repository.server.stop with analytics */ |
| registerCommandWithAnalyticsEvent('fuchsia.repository.server.stop', () => { |
| setup.ffx.runFfx(['repository', 'server', 'stop']) |
| .then(() => { |
| logger.info('Stopped the repository server', 'fx serve'); |
| }) |
| .catch((err) => { |
| logger.error(err); |
| }); |
| }); |
| |
| /** Registers fx.ota with analytics */ |
| registerCommandWithAnalyticsEvent('fuchsia.fx.ota', () => { |
| void window.withProgress({ |
| location: vscode.ProgressLocation.Notification, |
| title: 'fx ota', |
| cancellable: true |
| }, async (progress, token) => { |
| let percentage: number; |
| let cmd = setup.fx.runAsync(['ota', '--build'], handleData, handleData); |
| |
| token.onCancellationRequested(() => { |
| logger.info('fx ota stopped: user cancelled operation', 'fx ota'); |
| cmd?.stop(); |
| }); |
| |
| progress.report({ message: '[(details)](command:fuchsia.showOutput)' }); |
| |
| let exitCode = await cmd?.exitCode; |
| return exitCode === 0 ? Promise.resolve() : Promise.reject(exitCode); |
| |
| /** |
| * Callback to handle buffer to be displayed in vscode UI, sends msg to log |
| * in output window and updates percent in the progress window. |
| */ |
| function handleData(buffer: Buffer) { |
| const msg = new TextDecoder().decode(buffer); |
| let match = msg.match(BUILD_MATCHER); |
| match ? |
| logOutput({ message: msg, category: 'fx ota', inMatcher: true }) : |
| logOutput({ message: msg, category: 'fx ota', inMatcher: false }); |
| |
| // Surface to user since msg can get lost in output window |
| if (msg.includes('Locked')) { |
| void window.showWarningMessage(msg); |
| } |
| |
| if (msg.includes('%')) { |
| let percent = msg.match(/\d+%/); |
| let match = msg.match(BUILD_MATCHER); |
| let lastPercent = percentage ?? 0; |
| percentage = parseInt(percent![0]); |
| progress.report({ |
| increment: percentage - lastPercent, |
| message: `[(details)](command:fuchsia.showOutput) ${match![0]}` |
| }); |
| } |
| } |
| }); |
| }); |
| |
| /** Registers fx.ota.nobuild with analytics */ |
| registerCommandWithAnalyticsEvent('fuchsia.fx.ota.nobuild', () => { |
| void window.withProgress({ |
| location: vscode.ProgressLocation.Notification, |
| title: 'fx ota', |
| cancellable: true |
| }, async (progress, token) => { |
| let cmd = setup.fx.runAsync(['ota', '--no-build'], handleData, handleData); |
| |
| token.onCancellationRequested(() => { |
| logger.info('fx ota stopped: user cancelled operation', 'fx ota'); |
| cmd?.stop(); |
| }); |
| |
| progress.report({ message: '[(details)](command:fuchsia.showOutput)' }); |
| |
| let exitCode = await cmd?.exitCode; |
| return exitCode === 0 ? Promise.resolve() : Promise.reject(exitCode); |
| |
| /** |
| * Callback to handle buffer to be displayed in vscode UI, sends msg to log |
| * in output window and updates percent in the progress window. |
| */ |
| function handleData(buffer: Buffer) { |
| const msg = new TextDecoder().decode(buffer); |
| let match = msg.match(BUILD_MATCHER); |
| match ? |
| logOutput({ message: msg, category: 'fx ota', inMatcher: true }) : |
| logOutput({ message: msg, category: 'fx ota', inMatcher: false }); |
| |
| // Surface to user since msg can get lost in output window |
| if (msg.includes('Locked')) { |
| void window.showWarningMessage(msg); |
| } |
| } |
| }); |
| }); |
| } |
| |
| /** |
| * Parameters for the logOutput function |
| */ |
| type OutputParams = { |
| message: string, |
| category: string, |
| inMatcher: boolean, |
| logChannel?: logger.Logger |
| }; |
| |
| /** |
| * Determine log level to log output messages for fx build. |
| * @param output to sort into log level |
| * @param inMatcher If output is in build targets. There are cases |
| * where it appears in the file directory and should not output as an error. |
| * @param logChannel Defaults to the extension logOutputChannel. |
| */ |
| export function logOutput(options: OutputParams) { |
| let msg = options.message.toLowerCase(); |
| let outputChannel = options.logChannel ? options.logChannel : logger; |
| |
| if (msg.includes('failed: [code=1]')) { |
| outputChannel.error(options.message, options.category); |
| return; |
| } |
| if (msg.includes('error') || msg.includes('failed')) { |
| options.inMatcher ? |
| outputChannel.info(options.message, options.category) : |
| outputChannel.error(options.message, options.category); |
| return; |
| } |
| if (msg.includes('warn')) { |
| outputChannel.warn(options.message, options.category); |
| return; |
| } |
| outputChannel.info(options.message, options.category); |
| } |
| |
| /** |
| * Convert output to QuickPickItem[] list |
| * @param output Array or newline separate string to parse |
| * @param lastPick Last selection |
| */ |
| export function toList(output: string | string[], lastPick?: string, |
| placeholders?: Map<string, string>): vscode.QuickPickItem[] { |
| let list: vscode.QuickPickItem[] = []; |
| let items = Array.isArray(output) ? output : output.split('\n').filter(item => item); |
| items.forEach((item) => { |
| if (item === lastPick) { |
| list.unshift({ |
| label: item, |
| description: '(Current)', |
| detail: placeholders?.get(item) |
| }); |
| return; |
| } |
| list.push({ |
| label: item, |
| detail: placeholders?.get(item) |
| }); |
| }); |
| return list; |
| } |
| |
| /** |
| * Convert Fuchsia packages to QuickPickItem[] list |
| * @param output Fuchsia package object to parse |
| */ |
| export function packageToList(output: Packages[]): vscode.QuickPickItem[] { |
| let list: vscode.QuickPickItem[] = []; |
| output.forEach((item) => { |
| let packageItems = item.value; |
| let packageArg = item.notes.substring(0, item.notes.indexOf(' ')); |
| list.push({ |
| label: item.title, |
| kind: vscode.QuickPickItemKind.Separator, |
| }); |
| packageItems.forEach((packageItem) => { |
| list.push({ |
| label: packageItem, |
| description: packageArg, |
| picked: true, |
| }); |
| }); |
| }); |
| return list; |
| } |