| // Copyright 2023 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import * as Common from '../../core/common/common.js'; |
| import * as Platform from '../../core/platform/platform.js'; |
| import * as Trace from '../../models/trace/trace.js'; |
| import * as TimelineComponents from '../../panels/timeline/components/components.js'; |
| |
| import * as AnnotationHelpers from './AnnotationHelpers.js'; |
| import {EntriesFilter} from './EntriesFilter.js'; |
| |
| const modificationsManagerByTraceIndex: ModificationsManager[] = []; |
| let activeManager: ModificationsManager|null; |
| |
| export type UpdateAction = |
| 'Remove'|'Add'|'UpdateLabel'|'UpdateTimeRange'|'UpdateLinkToEntry'|'EnterLabelEditState'|'LabelBringForward'; |
| |
| /** |
| * Event dispatched after an annotation was added, removed or updated. |
| * The event argument is the Overlay that needs to be created,removed |
| * or updated by `Overlays.ts` and the action that needs to be applied to it. |
| **/ |
| export class AnnotationModifiedEvent extends Event { |
| static readonly eventName = 'annotationmodifiedevent'; |
| |
| constructor( |
| public overlay: Trace.Types.Overlays.Overlay, public action: UpdateAction, public muteAriaNotifications = false) { |
| super(AnnotationModifiedEvent.eventName); |
| } |
| } |
| |
| interface ModificationsManagerData { |
| parsedTrace: Trace.TraceModel.ParsedTrace; |
| traceBounds: Trace.Types.Timing.TraceWindowMicro; |
| rawTraceEvents: readonly Trace.Types.Events.Event[]; |
| syntheticEvents: Trace.Types.Events.SyntheticBased[]; |
| modifications?: Trace.Types.File.Modifications; |
| } |
| |
| export class ModificationsManager extends EventTarget { |
| #entriesFilter: EntriesFilter; |
| #timelineBreadcrumbs: TimelineComponents.Breadcrumbs.Breadcrumbs; |
| #modifications: Trace.Types.File.Modifications|null = null; |
| #parsedTrace: Trace.TraceModel.ParsedTrace; |
| #eventsSerializer: Trace.EventsSerializer.EventsSerializer; |
| #overlayForAnnotation: Map<Trace.Types.File.Annotation, Trace.Types.Overlays.Overlay>; |
| readonly #annotationsHiddenSetting: Common.Settings.Setting<boolean>; |
| |
| /** |
| * Gets the ModificationsManager instance corresponding to a trace |
| * given its index used in Model#traces. If no index is passed gets |
| * the manager instance for the last trace. If no instance is found, |
| * throws. |
| */ |
| static activeManager(): ModificationsManager|null { |
| return activeManager; |
| } |
| |
| static reset(): void { |
| modificationsManagerByTraceIndex.length = 0; |
| activeManager = null; |
| } |
| |
| /** |
| * Initializes a ModificationsManager instance for a parsed trace or changes the active manager for an existing one. |
| * This needs to be called if and a trace has been parsed or switched to. |
| */ |
| static initAndActivateModificationsManager(traceModel: Trace.TraceModel.Model, traceIndex: number): |
| ModificationsManager|null { |
| // If a manager for a given index has already been created, active it. |
| if (modificationsManagerByTraceIndex[traceIndex]) { |
| if (activeManager === modificationsManagerByTraceIndex[traceIndex]) { |
| return activeManager; |
| } |
| |
| activeManager = modificationsManagerByTraceIndex[traceIndex]; |
| ModificationsManager.activeManager()?.applyModificationsIfPresent(); |
| } |
| |
| const parsedTrace = traceModel.parsedTrace(traceIndex); |
| if (!parsedTrace) { |
| throw new Error('ModificationsManager was initialized without a corresponding trace data'); |
| } |
| |
| const traceBounds = parsedTrace.data.Meta.traceBounds; |
| const newModificationsManager = new ModificationsManager({ |
| parsedTrace, |
| traceBounds, |
| rawTraceEvents: parsedTrace.traceEvents, |
| modifications: parsedTrace.metadata.modifications, |
| syntheticEvents: parsedTrace.syntheticEventsManager.getSyntheticTraces(), |
| }); |
| modificationsManagerByTraceIndex[traceIndex] = newModificationsManager; |
| activeManager = newModificationsManager; |
| ModificationsManager.activeManager()?.applyModificationsIfPresent(); |
| return this.activeManager(); |
| } |
| |
| private constructor({parsedTrace, traceBounds, modifications}: ModificationsManagerData) { |
| super(); |
| this.#entriesFilter = new EntriesFilter(parsedTrace); |
| // Create first breadcrumb from the initial full window |
| this.#timelineBreadcrumbs = new TimelineComponents.Breadcrumbs.Breadcrumbs(traceBounds); |
| this.#modifications = modifications || null; |
| this.#parsedTrace = parsedTrace; |
| this.#eventsSerializer = new Trace.EventsSerializer.EventsSerializer(); |
| // This method is also called in SidebarAnnotationsTab, but calling this multiple times doesn't recreate the setting. |
| // Instead, after the second call, the cached setting is returned. |
| this.#annotationsHiddenSetting = Common.Settings.Settings.instance().moduleSetting('annotations-hidden'); |
| // TODO: Assign annotations loaded from the trace file |
| this.#overlayForAnnotation = new Map(); |
| } |
| |
| getEntriesFilter(): EntriesFilter { |
| return this.#entriesFilter; |
| } |
| |
| getTimelineBreadcrumbs(): TimelineComponents.Breadcrumbs.Breadcrumbs { |
| return this.#timelineBreadcrumbs; |
| } |
| |
| deleteEmptyRangeAnnotations(): void { |
| for (const annotation of this.#overlayForAnnotation.keys()) { |
| if (annotation.type === 'TIME_RANGE' && annotation.label.length === 0) { |
| this.removeAnnotation(annotation); |
| } |
| } |
| } |
| |
| /** |
| * Stores the annotation and creates its overlay. |
| * @returns the Overlay that gets created and associated with this annotation. |
| */ |
| createAnnotation(newAnnotation: Trace.Types.File.Annotation, opts: { |
| loadedFromFile: boolean, |
| muteAriaNotifications: boolean, |
| }): Trace.Types.Overlays.Overlay { |
| // If a label already exists on an entry and a user is trying to create a new one, start editing an existing label instead. |
| if (newAnnotation.type === 'ENTRY_LABEL') { |
| const overlay = this.#findLabelOverlayForEntry(newAnnotation.entry); |
| if (overlay) { |
| this.dispatchEvent(new AnnotationModifiedEvent(overlay, 'EnterLabelEditState')); |
| return overlay; |
| } |
| } |
| |
| // If the new annotation created was not loaded from the file, set the annotations visibility setting to true. That way we make sure |
| // the annotations are on when a new one is created. |
| if (!opts.loadedFromFile) { |
| // Time range annotation could also be used to check the length of a selection in the timeline. Therefore, only set the annotations |
| // hidden to true if annotations label is added. This is done in OverlaysImpl. |
| if (newAnnotation.type !== 'TIME_RANGE') { |
| this.#annotationsHiddenSetting.set(false); |
| } |
| } |
| const newOverlay = this.#createOverlayFromAnnotation(newAnnotation); |
| this.#overlayForAnnotation.set(newAnnotation, newOverlay); |
| this.dispatchEvent(new AnnotationModifiedEvent(newOverlay, 'Add', opts.muteAriaNotifications)); |
| return newOverlay; |
| } |
| |
| linkAnnotationBetweenEntriesExists(entryFrom: Trace.Types.Events.Event, entryTo: Trace.Types.Events.Event): boolean { |
| for (const annotation of this.#overlayForAnnotation.keys()) { |
| if (annotation.type === 'ENTRIES_LINK' && |
| ((annotation.entryFrom === entryFrom && annotation.entryTo === entryTo) || |
| (annotation.entryFrom === entryTo && annotation.entryTo === entryFrom))) { |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| #findLabelOverlayForEntry(entry: Trace.Types.Events.Event): Trace.Types.Overlays.Overlay|null { |
| for (const [annotation, overlay] of this.#overlayForAnnotation.entries()) { |
| if (annotation.type === 'ENTRY_LABEL' && annotation.entry === entry) { |
| return overlay; |
| } |
| } |
| |
| return null; |
| } |
| |
| bringEntryLabelForwardIfExists(entry: Trace.Types.Events.Event): void { |
| const overlay = this.#findLabelOverlayForEntry(entry); |
| if (overlay?.type === 'ENTRY_LABEL') { |
| this.dispatchEvent(new AnnotationModifiedEvent(overlay, 'LabelBringForward')); |
| } |
| } |
| |
| #createOverlayFromAnnotation(annotation: Trace.Types.File.Annotation): Trace.Types.Overlays.EntryLabel |
| |Trace.Types.Overlays.TimeRangeLabel|Trace.Types.Overlays.EntriesLink { |
| switch (annotation.type) { |
| case 'ENTRY_LABEL': |
| return { |
| type: 'ENTRY_LABEL', |
| entry: annotation.entry, |
| label: annotation.label, |
| }; |
| case 'TIME_RANGE': |
| return { |
| type: 'TIME_RANGE', |
| label: annotation.label, |
| showDuration: true, |
| bounds: annotation.bounds, |
| }; |
| case 'ENTRIES_LINK': |
| return { |
| type: 'ENTRIES_LINK', |
| state: annotation.state, |
| entryFrom: annotation.entryFrom, |
| entryTo: annotation.entryTo, |
| }; |
| default: |
| Platform.assertNever(annotation, 'Overlay for provided annotation cannot be created'); |
| } |
| } |
| |
| removeAnnotation(removedAnnotation: Trace.Types.File.Annotation): void { |
| const overlayToRemove = this.#overlayForAnnotation.get(removedAnnotation); |
| if (!overlayToRemove) { |
| console.warn('Overlay for deleted Annotation does not exist', removedAnnotation); |
| return; |
| } |
| this.#overlayForAnnotation.delete(removedAnnotation); |
| this.dispatchEvent(new AnnotationModifiedEvent(overlayToRemove, 'Remove')); |
| } |
| |
| removeAnnotationOverlay(removedOverlay: Trace.Types.Overlays.Overlay): void { |
| const annotationForRemovedOverlay = this.getAnnotationByOverlay(removedOverlay); |
| if (!annotationForRemovedOverlay) { |
| console.warn('Annotation for deleted Overlay does not exist', removedOverlay); |
| return; |
| } |
| this.removeAnnotation(annotationForRemovedOverlay); |
| } |
| |
| updateAnnotation(updatedAnnotation: Trace.Types.File.Annotation): void { |
| const overlay = this.#overlayForAnnotation.get(updatedAnnotation); |
| |
| if (overlay && AnnotationHelpers.isTimeRangeLabel(overlay) && |
| Trace.Types.File.isTimeRangeAnnotation(updatedAnnotation)) { |
| overlay.label = updatedAnnotation.label; |
| overlay.bounds = updatedAnnotation.bounds; |
| this.dispatchEvent(new AnnotationModifiedEvent(overlay, 'UpdateTimeRange')); |
| |
| } else if ( |
| overlay && AnnotationHelpers.isEntriesLink(overlay) && |
| Trace.Types.File.isEntriesLinkAnnotation(updatedAnnotation)) { |
| overlay.state = updatedAnnotation.state; |
| overlay.entryFrom = updatedAnnotation.entryFrom; |
| overlay.entryTo = updatedAnnotation.entryTo; |
| this.dispatchEvent(new AnnotationModifiedEvent(overlay, 'UpdateLinkToEntry')); |
| |
| } else { |
| console.error('Annotation could not be updated'); |
| } |
| } |
| |
| updateAnnotationOverlay(updatedOverlay: Trace.Types.Overlays.Overlay): void { |
| const annotationForUpdatedOverlay = this.getAnnotationByOverlay(updatedOverlay); |
| if (!annotationForUpdatedOverlay) { |
| console.warn('Annotation for updated Overlay does not exist'); |
| return; |
| } |
| |
| if ((updatedOverlay.type === 'ENTRY_LABEL' && annotationForUpdatedOverlay.type === 'ENTRY_LABEL') || |
| (updatedOverlay.type === 'TIME_RANGE' && annotationForUpdatedOverlay.type === 'TIME_RANGE')) { |
| this.#annotationsHiddenSetting.set(false); |
| annotationForUpdatedOverlay.label = updatedOverlay.label; |
| this.dispatchEvent(new AnnotationModifiedEvent(updatedOverlay, 'UpdateLabel')); |
| } |
| |
| if ((updatedOverlay.type === 'ENTRIES_LINK' && annotationForUpdatedOverlay.type === 'ENTRIES_LINK')) { |
| this.#annotationsHiddenSetting.set(false); |
| annotationForUpdatedOverlay.state = updatedOverlay.state; |
| } |
| } |
| |
| getAnnotationByOverlay(overlay: Trace.Types.Overlays.Overlay): Trace.Types.File.Annotation|null { |
| for (const [annotation, currOverlay] of this.#overlayForAnnotation.entries()) { |
| if (currOverlay === overlay) { |
| return annotation; |
| } |
| } |
| return null; |
| } |
| |
| getOverlaybyAnnotation(annotation: Trace.Types.File.Annotation): Trace.Types.Overlays.Overlay|null { |
| return this.#overlayForAnnotation.get(annotation) || null; |
| } |
| |
| getAnnotations(): Trace.Types.File.Annotation[] { |
| return [...this.#overlayForAnnotation.keys()]; |
| } |
| |
| getOverlays(): Trace.Types.Overlays.Overlay[] { |
| return [...this.#overlayForAnnotation.values()]; |
| } |
| |
| applyAnnotationsFromCache(opts: {muteAriaNotifications: boolean}): void { |
| this.#modifications = this.toJSON(); |
| // The cache is filled by applyModificationsIfPresent, so we clear |
| // it beforehand to prevent duplicate entries. |
| this.#overlayForAnnotation.clear(); |
| this.#applyStoredAnnotations(this.#modifications.annotations, opts); |
| } |
| |
| /** |
| * Builds all modifications into a serializable object written into |
| * the 'modifications' trace file metadata field. |
| */ |
| toJSON(): Trace.Types.File.Modifications { |
| const hiddenEntries = this.#entriesFilter.invisibleEntries() |
| .map(entry => this.#eventsSerializer.keyForEvent(entry)) |
| .filter(entry => entry !== null); |
| const expandableEntries = this.#entriesFilter.expandableEntries() |
| .map(entry => this.#eventsSerializer.keyForEvent(entry)) |
| .filter(entry => entry !== null); |
| this.#modifications = { |
| entriesModifications: { |
| hiddenEntries, |
| expandableEntries, |
| }, |
| initialBreadcrumb: this.#timelineBreadcrumbs.initialBreadcrumb, |
| annotations: this.#annotationsJSON(), |
| }; |
| return this.#modifications; |
| } |
| |
| #annotationsJSON(): Trace.Types.File.SerializedAnnotations { |
| const annotations = this.getAnnotations(); |
| const entryLabelsSerialized: Trace.Types.File.EntryLabelAnnotationSerialized[] = []; |
| const labelledTimeRangesSerialized: Trace.Types.File.TimeRangeAnnotationSerialized[] = []; |
| const linksBetweenEntriesSerialized: Trace.Types.File.EntriesLinkAnnotationSerialized[] = []; |
| |
| for (let i = 0; i < annotations.length; i++) { |
| const currAnnotation = annotations[i]; |
| if (Trace.Types.File.isEntryLabelAnnotation(currAnnotation)) { |
| const serializedEvent = this.#eventsSerializer.keyForEvent(currAnnotation.entry); |
| if (serializedEvent) { |
| entryLabelsSerialized.push({ |
| entry: serializedEvent, |
| label: currAnnotation.label, |
| }); |
| } |
| } else if (Trace.Types.File.isTimeRangeAnnotation(currAnnotation)) { |
| labelledTimeRangesSerialized.push({ |
| bounds: currAnnotation.bounds, |
| label: currAnnotation.label, |
| }); |
| } else if (Trace.Types.File.isEntriesLinkAnnotation(currAnnotation)) { |
| // Only save the links between entries that are fully created and have the entry that it is pointing to set |
| if (currAnnotation.entryTo) { |
| const serializedFromEvent = this.#eventsSerializer.keyForEvent(currAnnotation.entryFrom); |
| const serializedToEvent = this.#eventsSerializer.keyForEvent(currAnnotation.entryTo); |
| if (serializedFromEvent && serializedToEvent) { |
| linksBetweenEntriesSerialized.push({ |
| entryFrom: serializedFromEvent, |
| entryTo: serializedToEvent, |
| }); |
| } |
| } |
| } |
| } |
| |
| return { |
| entryLabels: entryLabelsSerialized, |
| labelledTimeRanges: labelledTimeRangesSerialized, |
| linksBetweenEntries: linksBetweenEntriesSerialized, |
| }; |
| } |
| |
| applyModificationsIfPresent(): void { |
| if (!this.#modifications || !this.#modifications.annotations) { |
| return; |
| } |
| |
| const hiddenEntries = this.#modifications.entriesModifications.hiddenEntries; |
| const expandableEntries = this.#modifications.entriesModifications.expandableEntries; |
| |
| this.#timelineBreadcrumbs.setInitialBreadcrumbFromLoadedModifications(this.#modifications.initialBreadcrumb); |
| this.#applyEntriesFilterModifications(hiddenEntries, expandableEntries); |
| this.#applyStoredAnnotations(this.#modifications.annotations, { |
| muteAriaNotifications: false, |
| }); |
| } |
| |
| #applyStoredAnnotations(annotations: Trace.Types.File.SerializedAnnotations, opts: {muteAriaNotifications: boolean}): |
| void { |
| try { |
| // Assign annotations to an empty array if they don't exist to not |
| // break the traces that were saved before those annotations were implemented |
| const entryLabels = annotations.entryLabels ?? []; |
| entryLabels.forEach(entryLabel => { |
| this.createAnnotation( |
| { |
| type: 'ENTRY_LABEL', |
| entry: this.#eventsSerializer.eventForKey(entryLabel.entry, this.#parsedTrace), |
| label: entryLabel.label, |
| }, |
| { |
| loadedFromFile: true, |
| muteAriaNotifications: opts.muteAriaNotifications, |
| }); |
| }); |
| |
| const timeRanges = annotations.labelledTimeRanges ?? []; |
| timeRanges.forEach(timeRange => { |
| this.createAnnotation( |
| { |
| type: 'TIME_RANGE', |
| bounds: timeRange.bounds, |
| label: timeRange.label, |
| }, |
| { |
| loadedFromFile: true, |
| muteAriaNotifications: opts.muteAriaNotifications, |
| }); |
| }); |
| |
| const linksBetweenEntries = annotations.linksBetweenEntries ?? []; |
| linksBetweenEntries.forEach(linkBetweenEntries => { |
| this.createAnnotation( |
| { |
| type: 'ENTRIES_LINK', |
| state: Trace.Types.File.EntriesLinkState.CONNECTED, |
| entryFrom: this.#eventsSerializer.eventForKey(linkBetweenEntries.entryFrom, this.#parsedTrace), |
| entryTo: this.#eventsSerializer.eventForKey(linkBetweenEntries.entryTo, this.#parsedTrace), |
| }, |
| { |
| loadedFromFile: true, |
| muteAriaNotifications: opts.muteAriaNotifications, |
| }); |
| }); |
| } catch (err) { |
| // This function is wrapped in a try/catch just in case we get any incoming |
| // trace files with broken event keys. Shouldn't happen of course, but if |
| // it does, we can discard all the data and then continue loading the |
| // trace, rather than have the panel entirely break. This also prevents any |
| // issue where we accidentally break the event serializer and break people |
| // loading traces; let's at least make sure they can load the panel, even |
| // if their annotations are gone. |
| console.warn('Failed to apply stored annotations', err); |
| } |
| } |
| |
| #applyEntriesFilterModifications( |
| hiddenEntriesKeys: Trace.Types.File.SerializableKey[], |
| expandableEntriesKeys: Trace.Types.File.SerializableKey[]): void { |
| try { |
| const hiddenEntries = hiddenEntriesKeys.map(key => this.#eventsSerializer.eventForKey(key, this.#parsedTrace)); |
| const expandableEntries = |
| expandableEntriesKeys.map(key => this.#eventsSerializer.eventForKey(key, this.#parsedTrace)); |
| this.#entriesFilter.setHiddenAndExpandableEntries(hiddenEntries, expandableEntries); |
| } catch (err) { |
| console.warn('Failed to apply entriesFilter modifications', err); |
| // If there was some invalid data, let's just back out and clear it |
| // entirely. This is better than applying a subset of all the hidden |
| // entries, which could cause an odd state in the flamechart. |
| this.#entriesFilter.setHiddenAndExpandableEntries([], []); |
| } |
| } |
| } |