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