blob: a2bab1f3bd97e8252ae3d7889085e5b228da13c5 [file] [log] [blame]
// Copyright (C) 2024 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {assertTrue} from '../base/logging';
export interface WorkspaceManager {
// This is the same of ctx.workspace, exposed for consistency also here.
readonly currentWorkspace: Workspace;
readonly all: ReadonlyArray<Workspace>;
createEmptyWorkspace(displayName: string): Workspace;
switchWorkspace(workspace: Workspace): void;
}
let sessionUniqueIdCounter = 0;
/**
* Creates a short ID which is unique to this instance of the UI.
*
* The advantage of using this over uuidv4() is that the ids produced are
* significantly shorter, saving memory and making them more human
* read/write-able which helps when debugging.
*
* Note: The ID range will reset every time the UI is restarted, so be careful
* not rely on these IDs in any medium that can survive between UI instances.
*
* TODO(stevegolton): We could possibly move this into its own module and use it
* everywhere where session-unique ids are required.
*/
function createSessionUniqueId(): string {
// Return the counter in base36 (0-z) to keep the string as short as possible
// but still human readable.
return (sessionUniqueIdCounter++).toString(36);
}
/**
* Describes generic parent track node functionality - i.e. any entity that can
* contain child TrackNodes, providing methods to add, remove, and access child
* nodes.
*
* This class is abstract because, while it can technically be instantiated on
* its own (no abstract methods/properties), it can't and shouldn't be
* instantiated anywhere in practice - all APIs require either a TrackNode or a
* Workspace.
*
* Thus, it serves two purposes:
* 1. Avoiding duplication between Workspace and TrackNode, which is an internal
* implementation detail of this module.
* 2. Providing a typescript interface for a generic TrackNode container class,
* which otherwise you might have to achieve using `Workspace | TrackNode`
* which is uglier.
*
* If you find yourself using this as a Javascript class in external code, e.g.
* `instance of TrackNodeContainer`, you're probably doing something wrong.
*/
export abstract class TrackNodeContainer {
protected _children: Array<TrackNode> = [];
protected readonly tracksById = new Map<string, TrackNode>();
protected abstract fireOnChangeListener(): void;
/**
* True if this node has children, false otherwise.
*/
get hasChildren(): boolean {
return this._children.length > 0;
}
/**
* The ordered list of children belonging to this node.
*/
get children(): ReadonlyArray<TrackNode> {
return this._children;
}
/**
* Inserts a new child node considering it's sortOrder.
*
* The child will be added before the first child whose |sortOrder| is greater
* than the child node's sort order, or at the end if one does not exist. If
* |sortOrder| is omitted on either node in the comparison it is assumed to be
* 0.
*
* @param child - The child node to add.
*/
addChildInOrder(child: TrackNode): void {
const insertPoint = this._children.find(
(n) => (n.sortOrder ?? 0) > (child.sortOrder ?? 0),
);
if (insertPoint) {
this.addChildBefore(child, insertPoint);
} else {
this.addChildLast(child);
}
}
/**
* Add a new child node at the start of the list of children.
*
* @param child The new child node to add.
*/
addChildLast(child: TrackNode): void {
this.adopt(child);
this._children.push(child);
this.fireOnChangeListener();
}
/**
* Add a new child node at the end of the list of children.
*
* @param child The child node to add.
*/
addChildFirst(child: TrackNode): void {
this.adopt(child);
this._children.unshift(child);
this.fireOnChangeListener();
}
/**
* Add a new child node before an existing child node.
*
* @param child The child node to add.
* @param referenceNode An existing child node. The new node will be added
* before this node.
*/
addChildBefore(child: TrackNode, referenceNode: TrackNode): void {
if (child === referenceNode) return;
assertTrue(this.children.includes(referenceNode));
this.adopt(child);
const indexOfReference = this.children.indexOf(referenceNode);
this._children.splice(indexOfReference, 0, child);
this.fireOnChangeListener();
}
/**
* Add a new child node after an existing child node.
*
* @param child The child node to add.
* @param referenceNode An existing child node. The new node will be added
* after this node.
*/
addChildAfter(child: TrackNode, referenceNode: TrackNode): void {
if (child === referenceNode) return;
assertTrue(this.children.includes(referenceNode));
this.adopt(child);
const indexOfReference = this.children.indexOf(referenceNode);
this._children.splice(indexOfReference + 1, 0, child);
this.fireOnChangeListener();
}
/**
* Remove a child node from this node.
*
* @param child The child node to remove.
*/
removeChild(child: TrackNode): void {
this._children = this.children.filter((x) => child !== x);
child.parent = undefined;
child.id && this.tracksById.delete(child.id);
this.fireOnChangeListener();
}
/**
* The flattened list of all descendent nodes.
*/
get flatTracks(): ReadonlyArray<TrackNode> {
return this.children.flatMap((node) => {
return [node, ...node.flatTracks];
});
}
/**
* Remove all children from this node.
*/
clear(): void {
this._children = [];
this.tracksById.clear();
this.fireOnChangeListener();
}
/**
* Find a track node by its id.
*
* Node: This is an O(N) operation where N is the depth of the target node.
* I.e. this is more efficient than findTrackByURI().
*
* @param id The id of the node we want to find.
* @returns The node or undefined if no such node exists.
*/
getTrackById(id: string): TrackNode | undefined {
const foundNode = this.tracksById.get(id);
if (foundNode) {
return foundNode;
} else {
// Recurse our children
for (const child of this._children) {
const foundNode = child.getTrackById(id);
if (foundNode) return foundNode;
}
}
return undefined;
}
private adopt(child: TrackNode): void {
if (child.parent) {
child.parent.removeChild(child);
}
child.parent = this;
child.id && this.tracksById.set(child.id, child);
}
}
export interface TrackNodeArgs {
title: string;
id: string;
uri: string;
headless: boolean;
sortOrder: number;
collapsed: boolean;
isSummary: boolean;
removable: boolean;
}
/**
* A base class for any node with children (i.e. a group or a workspace).
*/
export class TrackNode extends TrackNodeContainer {
// Immutable unique (within the workspace) ID of this track node. Used for
// efficiently retrieving this node object from a workspace. Note: This is
// different to |uri| which is used to reference a track to render on the
// track. If this means nothing to you, don't bother using it.
public readonly id: string;
// Parent node - could be the workspace or another node.
public parent?: TrackNodeContainer;
// A human readable string for this track - displayed in the track shell.
// TODO(stevegolton): Make this optional, so that if we implement a string for
// this track then we can implement it here as well.
public title: string;
// The URI of the track content to display here.
public uri?: string;
// Optional sort order, which workspaces may or may not take advantage of for
// sorting when displaying the workspace.
public sortOrder?: number;
// Don't show the header at all for this track, just show its un-nested
// children. This is helpful to group together tracks that logically belong to
// the same group (e.g. all ftrace cpu tracks) and ease the job of
// sorting/grouping plugins.
public headless: boolean;
// If true, this track is to be used as a summary for its children. When the
// group is expanded the track will become sticky to the top of the viewport
// to provide context for the tracks within, and the content of this track
// shall be omitted. It will also be squashed down to a smaller height to save
// vertical space.
public isSummary: boolean;
// If true, this node will be removable by the user. It will show a little
// close button in the track shell which the user can press to remove the
// track from the workspace.
public removable: boolean;
protected _collapsed = true;
constructor(args?: Partial<TrackNodeArgs>) {
super();
const {
title = '',
id = createSessionUniqueId(),
uri,
headless = false,
sortOrder,
collapsed = true,
isSummary = false,
removable = false,
} = args ?? {};
this.id = id;
this.uri = uri;
this.headless = headless;
this.title = title;
this.sortOrder = sortOrder;
this.isSummary = isSummary;
this._collapsed = collapsed;
this.removable = removable;
}
/**
* Remove this track from it's parent & unpin from the workspace if pinned.
*/
remove(): void {
this.workspace?.unpinTrack(this);
this.parent?.removeChild(this);
}
/**
* Add this track to the list of pinned tracks in its parent workspace.
*
* Has no effect if this track is not added to a workspace.
*/
pin(): void {
this.workspace?.pinTrack(this);
}
/**
* Remove this track from the list of pinned tracks in its parent workspace.
*
* Has no effect if this track is not added to a workspace.
*/
unpin(): void {
this.workspace?.unpinTrack(this);
}
/**
* Returns true if this node is added to a workspace as is in the pinned track
* list of that workspace.
*/
get isPinned(): boolean {
return Boolean(this.workspace?.hasPinnedTrack(this));
}
/**
* Find the closest visible ancestor TrackNode.
*
* Given the path from the root workspace to this node, find the fist one,
* starting from the root, which is collapsed. This will be, from the user's
* point of view, the closest ancestor of this node.
*
* Returns undefined if this node is actually visible.
*
* TODO(stevegolton): Should it return itself in this case?
*/
findClosestVisibleAncestor(): TrackNode {
// Build a path from the root workspace to this node
const path: TrackNode[] = [];
let node = this.parent;
while (node && node instanceof TrackNode) {
path.unshift(node);
node = node.parent;
}
// Find the first collapsed track in the path starting from the root. This
// is effectively the closest we can get to this node without expanding any
// groups.
return path.find((node) => node.collapsed) ?? this;
}
/**
* Expand all ancestor nodes.
*/
reveal(): void {
let parent = this.parent;
while (parent && parent instanceof TrackNode) {
parent.expand();
parent = parent.parent;
}
}
/**
* Find this node's root node - this may be a workspace or another node.
*/
get rootNode(): TrackNodeContainer | undefined {
// Travel upwards through the tree to find the root node.
let parent: TrackNodeContainer | undefined = this;
while (parent && parent instanceof TrackNode) {
parent = parent.parent;
}
return parent;
}
/**
* Find this node's parent workspace if it is attached to one.
*/
get workspace(): Workspace | undefined {
// Find the root node and return it if it's a Workspace instance
const rootNode = this.rootNode;
if (rootNode instanceof Workspace) {
return rootNode;
}
return undefined;
}
/**
* Mark this node as un-collapsed, indicating its children should be rendered.
*/
expand(): void {
this._collapsed = false;
this.fireOnChangeListener();
}
/**
* Mark this node as collapsed, indicating its children should not be
* rendered.
*/
collapse(): void {
this._collapsed = true;
this.fireOnChangeListener();
}
/**
* Toggle the collapsed state.
*/
toggleCollapsed(): void {
this._collapsed = !this._collapsed;
this.fireOnChangeListener();
}
/**
* Whether this node is collapsed, indicating its children should be rendered.
*/
get collapsed(): boolean {
return this._collapsed;
}
/**
* Whether this node is expanded - i.e. not collapsed, indicating its children
* should be rendered.
*/
get expanded(): boolean {
return !this._collapsed;
}
/**
* Returns the list of titles representing the full path from the root node to
* the current node. This path consists only of node titles, workspaces are
* omitted.
*/
get fullPath(): ReadonlyArray<string> {
let fullPath = [this.title];
let parent = this.parent;
while (parent && parent instanceof TrackNode) {
// Ignore headless containers as they don't appear in the tree...
if (!parent.headless) {
fullPath = [parent.title, ...fullPath];
}
parent = parent.parent;
}
return fullPath;
}
protected override fireOnChangeListener(): void {
this.workspace?.onchange(this.workspace);
}
}
/**
* Defines a workspace containing a track tree and a pinned area.
*/
export class Workspace extends TrackNodeContainer {
public title = '<untitled-workspace>';
public readonly id: string;
onchange: (w: Workspace) => void = () => {};
// Dummy node to contain the pinned tracks
private pinnedRoot = new TrackNode();
get pinnedTracks(): ReadonlyArray<TrackNode> {
return this.pinnedRoot.children;
}
constructor() {
super();
this.id = createSessionUniqueId();
this.pinnedRoot.parent = this;
}
/**
* Reset the entire workspace including the pinned tracks.
*/
override clear(): void {
super.clear();
this.pinnedRoot.clear();
}
/**
* Adds a track node to this workspace's pinned area.
*/
pinTrack(track: TrackNode): void {
// Make a lightweight clone of this track - just the uri and the title.
const cloned = new TrackNode({
uri: track.uri,
title: track.title,
removable: track.removable,
});
this.pinnedRoot.addChildLast(cloned);
}
/**
* Removes a track node from this workspace's pinned area.
*/
unpinTrack(track: TrackNode): void {
const foundNode = this.pinnedRoot.children.find((t) => t.uri === track.uri);
if (foundNode) {
this.pinnedRoot.removeChild(foundNode);
}
}
/**
* Check if this workspace has a pinned track with the same URI as |track|.
*/
hasPinnedTrack(track: TrackNode): boolean {
return this.pinnedTracks.some((p) => p.uri === track.uri);
}
/**
* Find a track node via its URI.
*
* Note: This in an O(N) operation where N is the number of nodes in the
* workspace.
*
* @param uri The uri of the track to find.
* @returns A reference to the track node if it exists in this workspace,
* otherwise undefined.
*/
findTrackByUri(uri: string): TrackNode | undefined {
return this.flatTracks.find((t) => t.uri === uri);
}
/**
* Find a track by ID, also searching pinned tracks.
*/
override getTrackById(id: string): TrackNode | undefined {
// Also search the pinned tracks
return this.pinnedRoot.getTrackById(id) || super.getTrackById(id);
}
protected override fireOnChangeListener(): void {
this.onchange(this);
}
}