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