blob: ed5879e896d7d383a6264dcbc2078e0dc54e2584 [file] [log] [blame]
// 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;
}
}