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