| // Copyright 2024 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 { Setup } from './extension'; |
| import { Ffx, FuchsiaDevice } from './ffx'; |
| import { type DebugTarget } from './zxdb'; |
| import { registerCommandWithAnalyticsEvent } from './analytics/vscode_events'; |
| |
| interface CommandOptions { |
| args: string[]; |
| requiresURL?: boolean; |
| refresh?: boolean; |
| } |
| |
| interface ComponentJson { |
| moniker: string; |
| url: string; |
| // eslint-disable-next-line @typescript-eslint/naming-convention |
| resolved_info?: { |
| // eslint-disable-next-line @typescript-eslint/naming-convention |
| execution_info?: unknown; |
| }; |
| } |
| |
| export interface ComponentListJson { |
| instances: ComponentJson[]; |
| } |
| |
| interface TaskJson { |
| koid: number; |
| // eslint-disable-next-line @typescript-eslint/naming-convention |
| parent_koid: number; |
| type: string; |
| name: string; |
| } |
| |
| export interface TaskListJson { |
| // eslint-disable-next-line @typescript-eslint/naming-convention |
| Tasks: TaskJson[]; |
| } |
| |
| // Setup commands to handle component lifecycle |
| const componentCommands: Record<string, CommandOptions> = { |
| /* eslint-disable @typescript-eslint/naming-convention */ |
| 'fuchsia.component.create': { |
| args: ['component', 'create'], |
| requiresURL: true, |
| refresh: true, |
| }, |
| 'fuchsia.component.destroy': { |
| args: ['component', 'destroy'], |
| refresh: true, |
| }, |
| 'fuchsia.component.recreate': { |
| args: ['component', 'run', '--recreate'], |
| requiresURL: true, |
| }, |
| 'fuchsia.component.reload': { |
| args: ['component', 'reload'], |
| }, |
| 'fuchsia.component.run': { |
| args: ['component', 'run'], |
| requiresURL: true, |
| refresh: true, |
| }, |
| 'fuchsia.component.start': { |
| args: ['component', 'start'], |
| }, |
| 'fuchsia.component.stop': { |
| args: ['component', 'stop'], |
| }, |
| /* eslint-enable @typescript-eslint/naming-convention */ |
| }; |
| |
| /** |
| * initialize the component interaction commands (explorer, etc) |
| */ |
| export function setUpComponentInteraction(_ctx: vscode.ExtensionContext, setup: Setup) { |
| const componentExplorer = new ComponentExplorerDataProvider(setup.ffx); |
| vscode.window.registerTreeDataProvider('vscode-fuchsia.componentExplorer', componentExplorer); |
| vscode.commands.registerCommand('fuchsia.refreshComponentExplorer', () => |
| componentExplorer.refresh(), |
| ); |
| |
| registerCommandWithAnalyticsEvent('fuchsia.component.show', async (moniker?: string) => { |
| if (moniker === undefined || moniker === '') { |
| moniker = await vscode.window.showInputBox({ |
| placeHolder: '(e.g. core/ui/scenic)', |
| value: '', |
| prompt: 'Enter a component moniker', |
| }); |
| } |
| if (!moniker) { |
| return; |
| } |
| await showComponent(setup.ffx, moniker); |
| }); |
| |
| for (const command in componentCommands) { |
| const commandOptions = componentCommands[command]; |
| registerCommandWithAnalyticsEvent( |
| command, async (info?: ComponentInfo) => { |
| const args = [...commandOptions.args]; |
| let moniker: string | undefined = info?.moniker.value; |
| let url: string | undefined = info?.url; |
| |
| if (moniker === undefined || moniker === '') { |
| moniker = await vscode.window.showInputBox({ |
| placeHolder: '(e.g. core/ui/scenic)', |
| value: '', |
| prompt: 'Enter a component moniker', |
| }); |
| } |
| if (!moniker) { |
| return; |
| } |
| args.push(moniker); |
| |
| if (commandOptions.requiresURL) { |
| if (url === undefined || url === '') { |
| url = await vscode.window.showInputBox({ |
| placeHolder: '(e.g. fuchsia-pkg://fuchsia.com/hello-world-cpp#meta/hello-world-cpp.cm)', |
| value: '', |
| prompt: 'Enter a component URL', |
| }); |
| } |
| if (!url) { |
| return; |
| } |
| args.push(url); |
| } |
| |
| await setup.ffx.runFfx(args); |
| |
| if (commandOptions.refresh) { |
| componentExplorer.refresh(); |
| } |
| }); |
| } |
| |
| const taskExplorer = new TaskExplorerDataProvider(setup.ffx); |
| vscode.window.registerTreeDataProvider('vscode-fuchsia.taskExplorer', taskExplorer); |
| vscode.commands.registerCommand('fuchsia.refreshTaskExplorer', () => |
| taskExplorer.refresh(), |
| ); |
| } |
| |
| class Moniker { |
| constructor(readonly value: string) { } |
| |
| get basename(): string { |
| const index = this.value.lastIndexOf('/'); |
| if (index === -1) { |
| return this.value; |
| } else { |
| return this.value.slice(index + 1); |
| } |
| } |
| |
| get parent(): string | null { |
| const index = this.value.lastIndexOf('/'); |
| if (index === -1) { |
| return null; |
| } else { |
| return this.value.slice(0, index); |
| } |
| } |
| } |
| |
| class ComponentInfo implements DebugTarget { |
| parent: ComponentInfo | null = null; |
| children: ComponentInfo[] = []; |
| |
| constructor( |
| readonly moniker: Moniker, |
| readonly url: string, |
| readonly isResolved: boolean, |
| readonly isRunning: boolean, |
| ) { } |
| |
| setParent(info: ComponentInfo) { |
| if (this.parent) { |
| const index = this.parent.children.indexOf(this); |
| if (index !== -1) { |
| this.parent.children.splice(index, 1); |
| } |
| } |
| this.parent = info; |
| info.children.push(this); |
| } |
| |
| get attachDescriptor() { return this.moniker.value; } |
| |
| static fromJSON(object: ComponentJson): ComponentInfo { |
| const moniker = new Moniker(object.moniker); |
| const url = object.url; |
| const isResolved = !!object.resolved_info; |
| const isRunning = isResolved && !!object.resolved_info?.execution_info; |
| return new ComponentInfo(moniker, url, isResolved, isRunning); |
| } |
| } |
| |
| class ComponentTree { |
| readonly map = new Map<string, ComponentInfo>(); |
| readonly roots: ComponentInfo[] = []; |
| |
| constructor(object: ComponentListJson) { |
| for (const entry of object.instances) { |
| const info = ComponentInfo.fromJSON(entry); |
| this.map.set(info.moniker.value, info); |
| } |
| for (const info of this.map.values()) { |
| const parentMoniker = info.moniker.parent; |
| if (parentMoniker) { |
| const parent = this.map.get(parentMoniker); |
| if (parent) { |
| info.setParent(parent); |
| } else { |
| this.roots.push(info); |
| } |
| } else { |
| this.roots.push(info); |
| } |
| } |
| } |
| } |
| |
| export class ComponentExplorerDataProvider implements vscode.TreeDataProvider<ComponentInfo> { |
| private currentDevice: FuchsiaDevice | null = null; |
| private tree: ComponentTree | null = null; |
| |
| constructor( |
| private readonly ffx: Ffx, |
| ) { |
| this.ffx.onSetTarget((device: FuchsiaDevice | null) => { |
| if (this.currentDevice?.nodeName !== device?.nodeName) { |
| this.currentDevice = device; |
| this.refresh(); |
| } |
| }); |
| } |
| |
| private _onDidChangeTreeData: vscode.EventEmitter<ComponentInfo | undefined | null | void> = |
| new vscode.EventEmitter<ComponentInfo | undefined | null | void>(); |
| |
| readonly onDidChangeTreeData: vscode.Event<ComponentInfo | undefined | null | void> = |
| this._onDidChangeTreeData.event; |
| |
| refresh(): void { |
| this.tree = null; |
| this._onDidChangeTreeData.fire(); |
| } |
| |
| getTreeItem(element: ComponentInfo): vscode.TreeItem { |
| const collapsibleState = element.children.length > 0 ? |
| vscode.TreeItemCollapsibleState.Expanded : |
| vscode.TreeItemCollapsibleState.None; |
| return new ComponentTreeItem(element, collapsibleState); |
| } |
| |
| async getChildren(element?: ComponentInfo): Promise<ComponentInfo[]> { |
| if (element) { |
| return element.children; |
| } else if (this.currentDevice) { |
| if (!this.tree) { |
| const json = await this.ffx.listComponents(this.currentDevice); |
| this.tree = new ComponentTree(json); |
| } |
| return this.tree.roots; |
| } else { |
| return []; |
| } |
| } |
| |
| getParent?(element: ComponentInfo): vscode.ProviderResult<ComponentInfo> { |
| return element.parent; |
| } |
| } |
| |
| class ComponentTreeItem extends vscode.TreeItem { |
| static readonly icon = new vscode.ThemeIcon('symbol-interface'); |
| |
| constructor( |
| public readonly info: ComponentInfo, |
| public readonly collapsibleState: vscode.TreeItemCollapsibleState, |
| ) { |
| super(info.moniker.basename, collapsibleState); |
| this.description = info.url; |
| this.tooltip = `Moniker: ${info.moniker.value}\nURL: ${info.url}\nComponent State: ${info.isResolved ? 'Resolved' : 'Unresolved'}\nExecution State: ${info.isRunning ? 'Running' : 'Stopped'}`; |
| this.iconPath = ComponentTreeItem.icon; |
| this.contextValue = info.isRunning ? 'running' : 'stopped'; |
| this.command = { |
| command: 'fuchsia.component.show', |
| title: 'Show details', |
| arguments: [info.moniker.value], |
| }; |
| } |
| } |
| |
| /** |
| * open a text document with the output of `ffx component show` for the given |
| * component moniker |
| */ |
| export async function showComponent(ffx: Ffx, moniker: string) { |
| const contents = await ffx.runFfx(['component', 'show', moniker]); |
| const document = await vscode.workspace.openTextDocument({ |
| language: 'plaintext', |
| content: contents, |
| }); |
| await vscode.window.showTextDocument(document); |
| } |
| |
| class TaskInfo implements DebugTarget { |
| parent: TaskInfo | null = null; |
| children: TaskInfo[] = []; |
| |
| constructor( |
| readonly koid: number, |
| readonly parentKoid: number, |
| readonly type: string, |
| readonly name: string, |
| ) { } |
| |
| setParent(info: TaskInfo) { |
| if (this.parent) { |
| const index = this.parent.children.indexOf(this); |
| if (index !== -1) { |
| this.parent.children.splice(index, 1); |
| } |
| } |
| this.parent = info; |
| info.children.push(this); |
| } |
| |
| get attachDescriptor() { return this.koid.toString(); } |
| |
| static fromJSON(object: TaskJson): TaskInfo { |
| const koid = object.koid; |
| const parentKoid = object.parent_koid; |
| const type = object.type; |
| const name = object.name; |
| return new TaskInfo(koid, parentKoid, type, name); |
| } |
| } |
| |
| class TaskTree { |
| readonly map = new Map<number, TaskInfo>(); |
| readonly roots: TaskInfo[] = []; |
| |
| constructor(object: TaskListJson) { |
| for (const entry of object.Tasks) { |
| const info = TaskInfo.fromJSON(entry); |
| this.map.set(info.koid, info); |
| } |
| for (const info of this.map.values()) { |
| const parentKoid = info.parentKoid; |
| const parent = this.map.get(parentKoid); |
| if (parent) { |
| info.setParent(parent); |
| } else { |
| this.roots.push(info); |
| } |
| } |
| } |
| } |
| |
| export class TaskExplorerDataProvider implements vscode.TreeDataProvider<TaskInfo> { |
| private currentDevice: FuchsiaDevice | null = null; |
| private tree: TaskTree | null = null; |
| |
| constructor( |
| private readonly ffx: Ffx, |
| ) { |
| this.ffx.onSetTarget((device: FuchsiaDevice | null) => { |
| if (this.currentDevice?.nodeName !== device?.nodeName) { |
| this.currentDevice = device; |
| this.refresh(); |
| } |
| }); |
| } |
| |
| private _onDidChangeTreeData: vscode.EventEmitter<TaskInfo | undefined | null | void> = |
| new vscode.EventEmitter<TaskInfo | undefined | null | void>(); |
| |
| readonly onDidChangeTreeData: vscode.Event<TaskInfo | undefined | null | void> = |
| this._onDidChangeTreeData.event; |
| |
| refresh(): void { |
| this.tree = null; |
| this._onDidChangeTreeData.fire(); |
| } |
| |
| getTreeItem(element: TaskInfo): vscode.TreeItem { |
| let collapsibleState = vscode.TreeItemCollapsibleState.None; |
| if (element.type === 'job') { |
| collapsibleState = vscode.TreeItemCollapsibleState.Expanded; |
| } else if (element.type === 'process') { |
| collapsibleState = vscode.TreeItemCollapsibleState.Collapsed; |
| } |
| return new TaskTreeItem(element, collapsibleState); |
| } |
| |
| async getChildren(element?: TaskInfo): Promise<TaskInfo[]> { |
| if (element) { |
| return element.children; |
| } else if (this.currentDevice) { |
| if (!this.tree) { |
| const json = await this.ffx.listProcesses(this.currentDevice); |
| this.tree = new TaskTree(json); |
| } |
| return this.tree.roots; |
| } else { |
| return []; |
| } |
| } |
| |
| getParent?(element: TaskInfo): vscode.ProviderResult<TaskInfo> { |
| return element.parent; |
| } |
| } |
| |
| class TaskTreeItem extends vscode.TreeItem { |
| static readonly jobIcon = new vscode.ThemeIcon('symbol-folder'); |
| static readonly processIcon = new vscode.ThemeIcon('server-process'); |
| static readonly threadIcon = new vscode.ThemeIcon('circle-outline'); |
| |
| constructor( |
| public readonly info: TaskInfo, |
| public readonly collapsibleState: vscode.TreeItemCollapsibleState, |
| ) { |
| super(info.name, collapsibleState); |
| this.description = info.koid.toString(); |
| this.tooltip = `Type: ${info.type}\nKoid: ${info.koid}\nName: ${info.name}`; |
| if (info.type === 'job') { |
| this.iconPath = TaskTreeItem.jobIcon; |
| } else if (info.type === 'process') { |
| this.iconPath = TaskTreeItem.processIcon; |
| } else if (info.type === 'thread') { |
| this.iconPath = TaskTreeItem.threadIcon; |
| } |
| this.contextValue = info.type; |
| } |
| } |