xref: /aosp_15_r20/external/perfetto/ui/src/public/workspace.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
1// Copyright (C) 2024 The Android Open Source Project
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15import {assertTrue} from '../base/logging';
16
17export interface WorkspaceManager {
18  // This is the same of ctx.workspace, exposed for consistency also here.
19  readonly currentWorkspace: Workspace;
20  readonly all: ReadonlyArray<Workspace>;
21  createEmptyWorkspace(displayName: string): Workspace;
22  switchWorkspace(workspace: Workspace): void;
23}
24
25let sessionUniqueIdCounter = 0;
26
27/**
28 * Creates a short ID which is unique to this instance of the UI.
29 *
30 * The advantage of using this over uuidv4() is that the ids produced are
31 * significantly shorter, saving memory and making them more human
32 * read/write-able which helps when debugging.
33 *
34 * Note: The ID range will reset every time the UI is restarted, so be careful
35 * not rely on these IDs in any medium that can survive between UI instances.
36 *
37 * TODO(stevegolton): We could possibly move this into its own module and use it
38 * everywhere where session-unique ids are required.
39 */
40function createSessionUniqueId(): string {
41  // Return the counter in base36 (0-z) to keep the string as short as possible
42  // but still human readable.
43  return (sessionUniqueIdCounter++).toString(36);
44}
45
46/**
47 * Describes generic parent track node functionality - i.e. any entity that can
48 * contain child TrackNodes, providing methods to add, remove, and access child
49 * nodes.
50 *
51 * This class is abstract because, while it can technically be instantiated on
52 * its own (no abstract methods/properties), it can't and shouldn't be
53 * instantiated anywhere in practice - all APIs require either a TrackNode or a
54 * Workspace.
55 *
56 * Thus, it serves two purposes:
57 * 1. Avoiding duplication between Workspace and TrackNode, which is an internal
58 *    implementation detail of this module.
59 * 2. Providing a typescript interface for a generic TrackNode container class,
60 *    which otherwise you might have to achieve using `Workspace | TrackNode`
61 *    which is uglier.
62 *
63 * If you find yourself using this as a Javascript class in external code, e.g.
64 * `instance of TrackNodeContainer`, you're probably doing something wrong.
65 */
66
67export interface TrackNodeArgs {
68  title: string;
69  id: string;
70  uri: string;
71  headless: boolean;
72  sortOrder: number;
73  collapsed: boolean;
74  isSummary: boolean;
75  removable: boolean;
76}
77
78/**
79 * A base class for any node with children (i.e. a group or a workspace).
80 */
81export class TrackNode {
82  // Immutable unique (within the workspace) ID of this track node. Used for
83  // efficiently retrieving this node object from a workspace. Note: This is
84  // different to |uri| which is used to reference a track to render on the
85  // track. If this means nothing to you, don't bother using it.
86  public readonly id: string;
87
88  // A human readable string for this track - displayed in the track shell.
89  // TODO(stevegolton): Make this optional, so that if we implement a string for
90  // this track then we can implement it here as well.
91  public title: string;
92
93  // The URI of the track content to display here.
94  public uri?: string;
95
96  // Optional sort order, which workspaces may or may not take advantage of for
97  // sorting when displaying the workspace.
98  public sortOrder?: number;
99
100  // Don't show the header at all for this track, just show its un-nested
101  // children. This is helpful to group together tracks that logically belong to
102  // the same group (e.g. all ftrace cpu tracks) and ease the job of
103  // sorting/grouping plugins.
104  public headless: boolean;
105
106  // If true, this track is to be used as a summary for its children. When the
107  // group is expanded the track will become sticky to the top of the viewport
108  // to provide context for the tracks within, and the content of this track
109  // shall be omitted. It will also be squashed down to a smaller height to save
110  // vertical space.
111  public isSummary: boolean;
112
113  // If true, this node will be removable by the user. It will show a little
114  // close button in the track shell which the user can press to remove the
115  // track from the workspace.
116  public removable: boolean;
117
118  protected _collapsed = true;
119  protected _children: Array<TrackNode> = [];
120  protected readonly tracksById = new Map<string, TrackNode>();
121  protected readonly tracksByUri = new Map<string, TrackNode>();
122  private _parent?: TrackNode;
123  public _workspace?: Workspace;
124
125  get parent(): TrackNode | undefined {
126    return this._parent;
127  }
128
129  constructor(args?: Partial<TrackNodeArgs>) {
130    const {
131      title = '',
132      id = createSessionUniqueId(),
133      uri,
134      headless = false,
135      sortOrder,
136      collapsed = true,
137      isSummary = false,
138      removable = false,
139    } = args ?? {};
140
141    this.id = id;
142    this.uri = uri;
143    this.headless = headless;
144    this.title = title;
145    this.sortOrder = sortOrder;
146    this.isSummary = isSummary;
147    this._collapsed = collapsed;
148    this.removable = removable;
149  }
150
151  /**
152   * Remove this track from it's parent & unpin from the workspace if pinned.
153   */
154  remove(): void {
155    this.workspace?.unpinTrack(this);
156    this.parent?.removeChild(this);
157  }
158
159  /**
160   * Add this track to the list of pinned tracks in its parent workspace.
161   *
162   * Has no effect if this track is not added to a workspace.
163   */
164  pin(): void {
165    this.workspace?.pinTrack(this);
166  }
167
168  /**
169   * Remove this track from the list of pinned tracks in its parent workspace.
170   *
171   * Has no effect if this track is not added to a workspace.
172   */
173  unpin(): void {
174    this.workspace?.unpinTrack(this);
175  }
176
177  /**
178   * Returns true if this node is added to a workspace as is in the pinned track
179   * list of that workspace.
180   */
181  get isPinned(): boolean {
182    return Boolean(this.workspace?.hasPinnedTrack(this));
183  }
184
185  /**
186   * Find the closest visible ancestor TrackNode.
187   *
188   * Given the path from the root workspace to this node, find the fist one,
189   * starting from the root, which is collapsed. This will be, from the user's
190   * point of view, the closest ancestor of this node.
191   *
192   * Returns undefined if this node is actually visible.
193   *
194   * TODO(stevegolton): Should it return itself in this case?
195   */
196  findClosestVisibleAncestor(): TrackNode {
197    // Build a path from the root workspace to this node
198    const path: TrackNode[] = [];
199    let node = this.parent;
200    while (node) {
201      path.unshift(node);
202      node = node.parent;
203    }
204
205    // Find the first collapsed track in the path starting from the root. This
206    // is effectively the closest we can get to this node without expanding any
207    // groups.
208    return path.find((node) => node.collapsed) ?? this;
209  }
210
211  /**
212   * Expand all ancestor nodes.
213   */
214  reveal(): void {
215    let parent = this.parent;
216    while (parent) {
217      parent.expand();
218      parent = parent.parent;
219    }
220  }
221
222  /**
223   * Find this node's root node - this may be a workspace or another node.
224   */
225  get rootNode(): TrackNode {
226    let node: TrackNode = this;
227    while (node.parent) {
228      node = node.parent;
229    }
230    return node;
231  }
232
233  /**
234   * Find this node's workspace if it is attached to one.
235   */
236  get workspace(): Workspace | undefined {
237    return this.rootNode._workspace;
238  }
239
240  /**
241   * Mark this node as un-collapsed, indicating its children should be rendered.
242   */
243  expand(): void {
244    this._collapsed = false;
245    this.fireOnChangeListener();
246  }
247
248  /**
249   * Mark this node as collapsed, indicating its children should not be
250   * rendered.
251   */
252  collapse(): void {
253    this._collapsed = true;
254    this.fireOnChangeListener();
255  }
256
257  /**
258   * Toggle the collapsed state.
259   */
260  toggleCollapsed(): void {
261    this._collapsed = !this._collapsed;
262    this.fireOnChangeListener();
263  }
264
265  /**
266   * Whether this node is collapsed, indicating its children should be rendered.
267   */
268  get collapsed(): boolean {
269    return this._collapsed;
270  }
271
272  /**
273   * Whether this node is expanded - i.e. not collapsed, indicating its children
274   * should be rendered.
275   */
276  get expanded(): boolean {
277    return !this._collapsed;
278  }
279
280  /**
281   * Returns the list of titles representing the full path from the root node to
282   * the current node. This path consists only of node titles, workspaces are
283   * omitted.
284   */
285  get fullPath(): ReadonlyArray<string> {
286    let fullPath = [this.title];
287    let parent = this.parent;
288    while (parent) {
289      // Ignore headless containers as they don't appear in the tree...
290      if (!parent.headless && parent.title !== '') {
291        fullPath = [parent.title, ...fullPath];
292      }
293      parent = parent.parent;
294    }
295    return fullPath;
296  }
297
298  protected fireOnChangeListener(): void {
299    this.workspace?.onchange(this.workspace);
300  }
301
302  /**
303   * True if this node has children, false otherwise.
304   */
305  get hasChildren(): boolean {
306    return this._children.length > 0;
307  }
308
309  /**
310   * The ordered list of children belonging to this node.
311   */
312  get children(): ReadonlyArray<TrackNode> {
313    return this._children;
314  }
315
316  /**
317   * Inserts a new child node considering it's sortOrder.
318   *
319   * The child will be added before the first child whose |sortOrder| is greater
320   * than the child node's sort order, or at the end if one does not exist. If
321   * |sortOrder| is omitted on either node in the comparison it is assumed to be
322   * 0.
323   *
324   * @param child - The child node to add.
325   */
326  addChildInOrder(child: TrackNode): void {
327    const insertPoint = this._children.find(
328      (n) => (n.sortOrder ?? 0) > (child.sortOrder ?? 0),
329    );
330    if (insertPoint) {
331      this.addChildBefore(child, insertPoint);
332    } else {
333      this.addChildLast(child);
334    }
335  }
336
337  /**
338   * Add a new child node at the start of the list of children.
339   *
340   * @param child The new child node to add.
341   */
342  addChildLast(child: TrackNode): void {
343    this.adopt(child);
344    this._children.push(child);
345    this.fireOnChangeListener();
346  }
347
348  /**
349   * Add a new child node at the end of the list of children.
350   *
351   * @param child The child node to add.
352   */
353  addChildFirst(child: TrackNode): void {
354    this.adopt(child);
355    this._children.unshift(child);
356    this.fireOnChangeListener();
357  }
358
359  /**
360   * Add a new child node before an existing child node.
361   *
362   * @param child The child node to add.
363   * @param referenceNode An existing child node. The new node will be added
364   * before this node.
365   */
366  addChildBefore(child: TrackNode, referenceNode: TrackNode): void {
367    if (child === referenceNode) return;
368
369    assertTrue(this.children.includes(referenceNode));
370
371    this.adopt(child);
372
373    const indexOfReference = this.children.indexOf(referenceNode);
374    this._children.splice(indexOfReference, 0, child);
375    this.fireOnChangeListener();
376  }
377
378  /**
379   * Add a new child node after an existing child node.
380   *
381   * @param child The child node to add.
382   * @param referenceNode An existing child node. The new node will be added
383   * after this node.
384   */
385  addChildAfter(child: TrackNode, referenceNode: TrackNode): void {
386    if (child === referenceNode) return;
387
388    assertTrue(this.children.includes(referenceNode));
389
390    this.adopt(child);
391
392    const indexOfReference = this.children.indexOf(referenceNode);
393    this._children.splice(indexOfReference + 1, 0, child);
394    this.fireOnChangeListener();
395  }
396
397  /**
398   * Remove a child node from this node.
399   *
400   * @param child The child node to remove.
401   */
402  removeChild(child: TrackNode): void {
403    this._children = this.children.filter((x) => child !== x);
404    child._parent = undefined;
405    this.removeFromIndex(child);
406    this.propagateRemoval(child);
407    this.fireOnChangeListener();
408  }
409
410  /**
411   * The flattened list of all descendent nodes in depth first order.
412   *
413   * Use flatTracksUnordered if you don't care about track order, as it's more
414   * efficient.
415   */
416  get flatTracksOrdered(): ReadonlyArray<TrackNode> {
417    const tracks: TrackNode[] = [];
418    this.collectFlatTracks(tracks);
419    return tracks;
420  }
421
422  private collectFlatTracks(tracks: TrackNode[]): void {
423    for (let i = 0; i < this.children.length; ++i) {
424      tracks.push(this.children[i]); // Push the current node before its children
425      this.children[i].collectFlatTracks(tracks); // Recurse to collect child tracks
426    }
427  }
428
429  /**
430   * The flattened list of all descendent nodes in no particular order.
431   */
432  get flatTracks(): ReadonlyArray<TrackNode> {
433    return Array.from(this.tracksById.values());
434  }
435
436  /**
437   * Remove all children from this node.
438   */
439  clear(): void {
440    this._children = [];
441    this.tracksById.clear();
442    this.fireOnChangeListener();
443  }
444
445  /**
446   * Find a track node by its id.
447   *
448   * Node: This is an O(1) operation.
449   *
450   * @param id The id of the node we want to find.
451   * @returns The node or undefined if no such node exists.
452   */
453  getTrackById(id: string): TrackNode | undefined {
454    return this.tracksById.get(id);
455  }
456
457  /**
458   * Find a track node via its URI.
459   *
460   * Node: This is an O(1) operation.
461   *
462   * @param uri The uri of the track to find.
463   * @returns The node or undefined if no such node exists with this URI.
464   */
465  findTrackByUri(uri: string): TrackNode | undefined {
466    return this.tracksByUri.get(uri);
467  }
468
469  private adopt(child: TrackNode): void {
470    if (child.parent) {
471      child.parent.removeChild(child);
472    }
473    child._parent = this;
474    this.addToIndex(child);
475    this.propagateAddition(child);
476  }
477
478  private addToIndex(child: TrackNode) {
479    this.tracksById.set(child.id, child);
480    for (const [id, node] of child.tracksById) {
481      this.tracksById.set(id, node);
482    }
483
484    child.uri && this.tracksByUri.set(child.uri, child);
485    for (const [uri, node] of child.tracksByUri) {
486      this.tracksByUri.set(uri, node);
487    }
488  }
489
490  private removeFromIndex(child: TrackNode) {
491    this.tracksById.delete(child.id);
492    for (const [id] of child.tracksById) {
493      this.tracksById.delete(id);
494    }
495
496    child.uri && this.tracksByUri.delete(child.uri);
497    for (const [uri] of child.tracksByUri) {
498      this.tracksByUri.delete(uri);
499    }
500  }
501
502  private propagateAddition(node: TrackNode): void {
503    if (this.parent) {
504      this.parent.addToIndex(node);
505      this.parent.propagateAddition(node);
506    }
507  }
508
509  private propagateRemoval(node: TrackNode): void {
510    if (this.parent) {
511      this.parent.removeFromIndex(node);
512      this.parent.propagateRemoval(node);
513    }
514  }
515}
516
517/**
518 * Defines a workspace containing a track tree and a pinned area.
519 */
520export class Workspace {
521  public title = '<untitled-workspace>';
522  public readonly id: string;
523  onchange: (w: Workspace) => void = () => {};
524
525  // Dummy node to contain the pinned tracks
526  public readonly pinnedTracksNode = new TrackNode();
527  public readonly tracks = new TrackNode();
528
529  get pinnedTracks(): ReadonlyArray<TrackNode> {
530    return this.pinnedTracksNode.children;
531  }
532
533  constructor() {
534    this.id = createSessionUniqueId();
535    this.pinnedTracksNode._workspace = this;
536    this.tracks._workspace = this;
537
538    // Expanding these nodes makes the logic work
539    this.pinnedTracksNode.expand();
540    this.tracks.expand();
541  }
542
543  /**
544   * Reset the entire workspace including the pinned tracks.
545   */
546  clear(): void {
547    this.pinnedTracksNode.clear();
548    this.tracks.clear();
549  }
550
551  /**
552   * Adds a track node to this workspace's pinned area.
553   */
554  pinTrack(track: TrackNode): void {
555    // Make a lightweight clone of this track - just the uri and the title.
556    const cloned = new TrackNode({
557      uri: track.uri,
558      title: track.title,
559      removable: track.removable,
560    });
561    this.pinnedTracksNode.addChildLast(cloned);
562  }
563
564  /**
565   * Removes a track node from this workspace's pinned area.
566   */
567  unpinTrack(track: TrackNode): void {
568    const foundNode = this.pinnedTracksNode.children.find(
569      (t) => t.uri === track.uri,
570    );
571    if (foundNode) {
572      this.pinnedTracksNode.removeChild(foundNode);
573    }
574  }
575
576  /**
577   * Check if this workspace has a pinned track with the same URI as |track|.
578   */
579  hasPinnedTrack(track: TrackNode): boolean {
580    return this.pinnedTracksNode.flatTracks.some((p) => p.uri === track.uri);
581  }
582
583  /**
584   * Find a track node via its URI.
585   *
586   * Note: This in an O(N) operation where N is the number of nodes in the
587   * workspace.
588   *
589   * @param uri The uri of the track to find.
590   * @returns A reference to the track node if it exists in this workspace,
591   * otherwise undefined.
592   */
593  findTrackByUri(uri: string): TrackNode | undefined {
594    return this.tracks.flatTracks.find((t) => t.uri === uri);
595  }
596
597  /**
598   * Find a track by ID, also searching pinned tracks.
599   */
600  getTrackById(id: string): TrackNode | undefined {
601    return (
602      this.tracks.getTrackById(id) || this.pinnedTracksNode.getTrackById(id)
603    );
604  }
605
606  /**
607   * The ordered list of children belonging to this node.
608   */
609  get children(): ReadonlyArray<TrackNode> {
610    return this.tracks.children;
611  }
612
613  /**
614   * Inserts a new child node considering it's sortOrder.
615   *
616   * The child will be added before the first child whose |sortOrder| is greater
617   * than the child node's sort order, or at the end if one does not exist. If
618   * |sortOrder| is omitted on either node in the comparison it is assumed to be
619   * 0.
620   *
621   * @param child - The child node to add.
622   */
623  addChildInOrder(child: TrackNode): void {
624    this.tracks.addChildInOrder(child);
625  }
626
627  /**
628   * Add a new child node at the start of the list of children.
629   *
630   * @param child The new child node to add.
631   */
632  addChildLast(child: TrackNode): void {
633    this.tracks.addChildLast(child);
634  }
635
636  /**
637   * Add a new child node at the end of the list of children.
638   *
639   * @param child The child node to add.
640   */
641  addChildFirst(child: TrackNode): void {
642    this.tracks.addChildFirst(child);
643  }
644
645  /**
646   * Add a new child node before an existing child node.
647   *
648   * @param child The child node to add.
649   * @param referenceNode An existing child node. The new node will be added
650   * before this node.
651   */
652  addChildBefore(child: TrackNode, referenceNode: TrackNode): void {
653    this.tracks.addChildBefore(child, referenceNode);
654  }
655
656  /**
657   * Add a new child node after an existing child node.
658   *
659   * @param child The child node to add.
660   * @param referenceNode An existing child node. The new node will be added
661   * after this node.
662   */
663  addChildAfter(child: TrackNode, referenceNode: TrackNode): void {
664    this.tracks.addChildAfter(child, referenceNode);
665  }
666
667  /**
668   * Remove a child node from this node.
669   *
670   * @param child The child node to remove.
671   */
672  removeChild(child: TrackNode): void {
673    this.tracks.removeChild(child);
674  }
675
676  /**
677   * The flattened list of all descendent nodes in depth first order.
678   *
679   * Use flatTracksUnordered if you don't care about track order, as it's more
680   * efficient.
681   */
682  get flatTracksOrdered() {
683    return this.tracks.flatTracksOrdered;
684  }
685
686  /**
687   * The flattened list of all descendent nodes in no particular order.
688   */
689  get flatTracks() {
690    return this.tracks.flatTracks;
691  }
692}
693