| // Copyright (C) 2018 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| import {assertExists, assertUnreachable} from '../base/logging'; |
| import {createStore, Store} from '../base/store'; |
| import {duration, Time, time, TimeSpan} from '../base/time'; |
| import {Actions, DeferredAction} from '../common/actions'; |
| import {AggregateData} from '../common/aggregation_data'; |
| import {Args} from '../common/arg_types'; |
| import {CommandManagerImpl} from '../core/command_manager'; |
| import { |
| ConversionJobName, |
| ConversionJobStatus, |
| } from '../common/conversion_jobs'; |
| import {createEmptyState} from '../common/empty_state'; |
| import {CurrentSearchResults} from '../common/search_data'; |
| import {EngineConfig, State} from '../common/state'; |
| import {TabManagerImpl} from '../core/tab_manager'; |
| import {TimestampFormat, timestampFormat} from '../core/timestamp_format'; |
| import {TrackManagerImpl} from '../core/track_manager'; |
| import {setPerfHooks} from '../core/perf'; |
| import {raf} from '../core/raf_scheduler'; |
| import {ServiceWorkerController} from './service_worker_controller'; |
| import {Engine, EngineBase} from '../trace_processor/engine'; |
| import {HttpRpcState} from '../trace_processor/http_rpc_engine'; |
| import type {Analytics} from './analytics'; |
| import {TimelineImpl} from '../core/timeline'; |
| import {SliceSqlId} from '../trace_processor/sql_utils/core_types'; |
| import {SelectionManagerImpl} from '../core/selection_manager'; |
| import {Selection, SelectionOpts} from '../public/selection'; |
| import {Optional, exists} from '../base/utils'; |
| import {OmniboxManagerImpl} from '../core/omnibox_manager'; |
| import {SerializedAppState} from '../common/state_serialization_schema'; |
| import {getServingRoot} from '../base/http_utils'; |
| import { |
| createSearchOverviewTrack, |
| SearchOverviewTrack, |
| } from './search_overview_track'; |
| import {AppContext} from './app_context'; |
| import {TraceInfo} from '../public/trace_info'; |
| import {Registry} from '../base/registry'; |
| import {SidebarMenuItem} from '../public/sidebar'; |
| import {Workspace} from '../public/workspace'; |
| import {ratelimit} from './rate_limiters'; |
| import {NoteManagerImpl} from '../core/note_manager'; |
| |
| const INSTANT_FOCUS_DURATION = 1n; |
| const INCOMPLETE_SLICE_DURATION = 30_000n; |
| |
| type DispatchMultiple = (actions: DeferredAction[]) => void; |
| type TrackDataStore = Map<string, {}>; |
| type AggregateDataStore = Map<string, AggregateData>; |
| type Description = Map<string, string>; |
| |
| export interface SliceDetails { |
| ts?: time; |
| absTime?: string; |
| dur?: duration; |
| threadTs?: time; |
| threadDur?: duration; |
| priority?: number; |
| endState?: string | null; |
| cpu?: number; |
| id?: number; |
| threadStateId?: number; |
| utid?: number; |
| wakeupTs?: time; |
| wakerUtid?: number; |
| wakerCpu?: number; |
| category?: string; |
| name?: string; |
| tid?: number; |
| threadName?: string; |
| pid?: number; |
| processName?: string; |
| uid?: number; |
| packageName?: string; |
| versionCode?: number; |
| args?: Args; |
| description?: Description; |
| } |
| |
| export interface FlowPoint { |
| trackId: number; |
| |
| sliceName: string; |
| sliceCategory: string; |
| sliceId: SliceSqlId; |
| sliceStartTs: time; |
| sliceEndTs: time; |
| // Thread and process info. Only set in sliceSelected not in areaSelected as |
| // the latter doesn't display per-flow info and it'd be a waste to join |
| // additional tables for undisplayed info in that case. Nothing precludes |
| // adding this in a future iteration however. |
| threadName: string; |
| processName: string; |
| |
| depth: number; |
| |
| // TODO(altimin): Ideally we should have a generic mechanism for allowing to |
| // customise the name here, but for now we are hardcording a few |
| // Chrome-specific bits in the query here. |
| sliceChromeCustomName?: string; |
| } |
| |
| export interface Flow { |
| id: number; |
| |
| begin: FlowPoint; |
| end: FlowPoint; |
| dur: duration; |
| |
| // Whether this flow connects a slice with its descendant. |
| flowToDescendant: boolean; |
| |
| category?: string; |
| name?: string; |
| } |
| |
| export interface ThreadStateDetails { |
| ts?: time; |
| dur?: duration; |
| } |
| |
| export interface QuantizedLoad { |
| start: time; |
| end: time; |
| load: number; |
| } |
| type OverviewStore = Map<string, QuantizedLoad[]>; |
| |
| export interface ThreadDesc { |
| utid: number; |
| tid: number; |
| threadName: string; |
| pid?: number; |
| procName?: string; |
| cmdline?: string; |
| } |
| type ThreadMap = Map<number, ThreadDesc>; |
| |
| export const defaultTraceContext: TraceInfo = { |
| traceTitle: '', |
| traceUrl: '', |
| start: Time.ZERO, |
| end: Time.fromSeconds(10), |
| realtimeOffset: Time.ZERO, |
| utcOffset: Time.ZERO, |
| traceTzOffset: Time.ZERO, |
| cpus: [], |
| gpuCount: 0, |
| }; |
| |
| interface SqlModule { |
| readonly name: string; |
| readonly sql: string; |
| } |
| |
| interface SqlPackage { |
| readonly name: string; |
| readonly modules: SqlModule[]; |
| } |
| |
| const DEFAULT_WORKSPACE_NAME = 'Default Workspace'; |
| |
| /** |
| * Global accessors for state/dispatch in the frontend. |
| */ |
| class Globals implements AppContext { |
| readonly root = getServingRoot(); |
| |
| private _testing = false; |
| private _dispatchMultiple?: DispatchMultiple = undefined; |
| private _store = createStore<State>(createEmptyState()); |
| private _timeline?: TimelineImpl = undefined; |
| private _serviceWorkerController?: ServiceWorkerController = undefined; |
| private _logging?: Analytics = undefined; |
| private _isInternalUser: boolean | undefined = undefined; |
| |
| // TODO(hjd): Unify trackDataStore, queryResults, overviewStore, threads. |
| private _trackDataStore?: TrackDataStore = undefined; |
| private _overviewStore?: OverviewStore = undefined; |
| private _aggregateDataStore?: AggregateDataStore = undefined; |
| private _threadMap?: ThreadMap = undefined; |
| private _sliceDetails?: SliceDetails = undefined; |
| private _threadStateDetails?: ThreadStateDetails = undefined; |
| private _connectedFlows?: Flow[] = undefined; |
| private _selectedFlows?: Flow[] = undefined; |
| private _visibleFlowCategories?: Map<string, boolean> = undefined; |
| private _numQueriesQueued = 0; |
| private _bufferUsage?: number = undefined; |
| private _recordingLog?: string = undefined; |
| private _traceErrors?: number = undefined; |
| private _metricError?: string = undefined; |
| private _jobStatus?: Map<ConversionJobName, ConversionJobStatus> = undefined; |
| private _embeddedMode?: boolean = undefined; |
| private _hideSidebar?: boolean = undefined; |
| private _cmdManager = new CommandManagerImpl(); |
| private _tabManager = new TabManagerImpl(); |
| private _trackManager = new TrackManagerImpl(); |
| private _selectionManager = new SelectionManagerImpl(); |
| private _noteManager = new NoteManagerImpl(this._selectionManager); |
| private _hasFtrace: boolean = false; |
| private _searchOverviewTrack?: SearchOverviewTrack; |
| readonly workspaces: Workspace[] = []; |
| private _currentWorkspace: Workspace; |
| |
| omnibox = new OmniboxManagerImpl(); |
| |
| scrollToTrackUri?: string; |
| httpRpcState: HttpRpcState = {connected: false}; |
| showPanningHint = false; |
| permalinkHash?: string; |
| showTraceErrorPopup = true; |
| extraSqlPackages: SqlPackage[] = []; |
| |
| traceContext = defaultTraceContext; |
| |
| readonly sidebarMenuItems = new Registry<SidebarMenuItem>((m) => m.commandId); |
| |
| get workspace(): Workspace { |
| return this._currentWorkspace; |
| } |
| |
| resetWorkspaces(): void { |
| this.workspaces.length = 0; |
| const defaultWorkspace = new Workspace(DEFAULT_WORKSPACE_NAME); |
| this.workspaces.push(defaultWorkspace); |
| this._currentWorkspace = defaultWorkspace; |
| } |
| |
| switchWorkspace(workspace: Workspace): void { |
| this._currentWorkspace = workspace; |
| } |
| |
| // This is the app's equivalent of a plugin's onTraceLoad() function. |
| // TODO(stevegolton): Eventually initialization that should be done on trace |
| // load should be moved into here, and then we can remove TraceController |
| // entirely |
| async onTraceLoad(engine: Engine, traceCtx: TraceInfo): Promise<void> { |
| this.traceContext = traceCtx; |
| |
| const {start, end} = traceCtx; |
| this._timeline = new TimelineImpl(new TimeSpan(start, end)); |
| this._timeline.retriggerControllersOnChange = () => |
| ratelimit(() => this.store.edit(() => {}), 50); |
| |
| // TODO(stevegolton): Even though createSearchOverviewTrack() returns a |
| // disposable, we completely ignore it as we assume the dispose action |
| // includes just dropping some tables, and seeing as this object will live |
| // for the duration of the trace/engine, there's no need to drop anything as |
| // the tables will be dropped along with the trace anyway. |
| // |
| // Note that this is no worse than a lot of the rest of the app where tables |
| // are created with no way to drop them. |
| // |
| // Once we fix the story around loading new traces, we should tidy this up. |
| // We could for example have a matching globals.onTraceUnload() that |
| // performs any tear-down before the old engine is dropped. This might seem |
| // pointless, but it could at least block until any currently running update |
| // cycles complete, to avoid leaving promises open on old engines that will |
| // never resolve. |
| // |
| // Alternatively we could decide that we don't want to support switching |
| // traces at all, in which case we can ignore tear down entirely. |
| this._searchOverviewTrack = await createSearchOverviewTrack(engine, this); |
| |
| // Reset the trackManager - this clears out the cache and any registered |
| // tracks |
| this._trackManager = new TrackManagerImpl(); |
| } |
| |
| // Used for permalink load by trace_controller.ts. |
| restoreAppStateAfterTraceLoad?: SerializedAppState; |
| |
| // TODO(hjd): Remove once we no longer need to update UUID on redraw. |
| private _publishRedraw?: () => void = undefined; |
| |
| private _currentSearchResults: CurrentSearchResults = { |
| eventIds: new Float64Array(0), |
| tses: new BigInt64Array(0), |
| utids: new Float64Array(0), |
| trackUris: [], |
| sources: [], |
| totalResults: 0, |
| }; |
| |
| engines = new Map<string, EngineBase>(); |
| |
| constructor() { |
| const {start, end} = defaultTraceContext; |
| this._timeline = new TimelineImpl(new TimeSpan(start, end)); |
| const defaultWorkspace = new Workspace(DEFAULT_WORKSPACE_NAME); |
| this.workspaces.push(defaultWorkspace); |
| this._currentWorkspace = defaultWorkspace; |
| this._selectionManager.onSelectionChange = ( |
| _s: Selection, |
| opts: SelectionOpts, |
| ) => this.handleSelectionOpts(opts); |
| } |
| |
| initialize( |
| dispatchMultiple: DispatchMultiple, |
| initAnalytics: () => Analytics, |
| ) { |
| this._dispatchMultiple = dispatchMultiple; |
| |
| setPerfHooks( |
| () => this.state.perfDebug, |
| () => this.dispatch(Actions.togglePerfDebug({})), |
| ); |
| |
| this._serviceWorkerController = new ServiceWorkerController( |
| getServingRoot(), |
| ); |
| this._testing = |
| /* eslint-disable @typescript-eslint/strict-boolean-expressions */ |
| self.location && self.location.search.indexOf('testing=1') >= 0; |
| /* eslint-enable */ |
| |
| // TODO(stevegolton): This is a mess. We should just inject this object in, |
| // instead of passing in a function. The only reason this is done like this |
| // is because the current implementation of initAnalytics depends on the |
| // state of globals.testing, so this needs to be set before we run the |
| // function. |
| this._logging = initAnalytics(); |
| |
| // TODO(hjd): Unify trackDataStore, queryResults, overviewStore, threads. |
| this._trackDataStore = new Map<string, {}>(); |
| this._overviewStore = new Map<string, QuantizedLoad[]>(); |
| this._aggregateDataStore = new Map<string, AggregateData>(); |
| this._threadMap = new Map<number, ThreadDesc>(); |
| this._sliceDetails = {}; |
| this._connectedFlows = []; |
| this._selectedFlows = []; |
| this._visibleFlowCategories = new Map<string, boolean>(); |
| this._threadStateDetails = {}; |
| this.engines.clear(); |
| this._selectionManager.clear(); |
| } |
| |
| // Only initialises the store - useful for testing. |
| initStore(initialState: State) { |
| this._store = createStore(initialState); |
| } |
| |
| get publishRedraw(): () => void { |
| return this._publishRedraw || (() => {}); |
| } |
| |
| set publishRedraw(f: () => void) { |
| this._publishRedraw = f; |
| } |
| |
| get state(): State { |
| return assertExists(this._store).state; |
| } |
| |
| get store(): Store<State> { |
| return assertExists(this._store); |
| } |
| |
| dispatch(action: DeferredAction) { |
| this.dispatchMultiple([action]); |
| } |
| |
| dispatchMultiple(actions: DeferredAction[]) { |
| assertExists(this._dispatchMultiple)(actions); |
| } |
| |
| get timeline() { |
| return assertExists(this._timeline); |
| } |
| |
| get logging() { |
| return assertExists(this._logging); |
| } |
| |
| get serviceWorkerController() { |
| return assertExists(this._serviceWorkerController); |
| } |
| |
| // TODO(hjd): Unify trackDataStore, queryResults, overviewStore, threads. |
| get overviewStore(): OverviewStore { |
| return assertExists(this._overviewStore); |
| } |
| |
| get trackDataStore(): TrackDataStore { |
| return assertExists(this._trackDataStore); |
| } |
| |
| get threads() { |
| return assertExists(this._threadMap); |
| } |
| |
| get sliceDetails() { |
| return assertExists(this._sliceDetails); |
| } |
| |
| set sliceDetails(click: SliceDetails) { |
| this._sliceDetails = assertExists(click); |
| } |
| |
| get threadStateDetails() { |
| return assertExists(this._threadStateDetails); |
| } |
| |
| set threadStateDetails(click: ThreadStateDetails) { |
| this._threadStateDetails = assertExists(click); |
| } |
| |
| get connectedFlows() { |
| return assertExists(this._connectedFlows); |
| } |
| |
| set connectedFlows(connectedFlows: Flow[]) { |
| this._connectedFlows = assertExists(connectedFlows); |
| } |
| |
| get selectedFlows() { |
| return assertExists(this._selectedFlows); |
| } |
| |
| set selectedFlows(selectedFlows: Flow[]) { |
| this._selectedFlows = assertExists(selectedFlows); |
| } |
| |
| get visibleFlowCategories() { |
| return assertExists(this._visibleFlowCategories); |
| } |
| |
| set visibleFlowCategories(visibleFlowCategories: Map<string, boolean>) { |
| this._visibleFlowCategories = assertExists(visibleFlowCategories); |
| } |
| |
| get aggregateDataStore(): AggregateDataStore { |
| return assertExists(this._aggregateDataStore); |
| } |
| |
| get traceErrors() { |
| return this._traceErrors; |
| } |
| |
| setTraceErrors(arg: number) { |
| this._traceErrors = arg; |
| } |
| |
| get metricError() { |
| return this._metricError; |
| } |
| |
| setMetricError(arg: string) { |
| this._metricError = arg; |
| } |
| |
| set numQueuedQueries(value: number) { |
| this._numQueriesQueued = value; |
| } |
| |
| get numQueuedQueries() { |
| return this._numQueriesQueued; |
| } |
| |
| get bufferUsage() { |
| return this._bufferUsage; |
| } |
| |
| get recordingLog() { |
| return this._recordingLog; |
| } |
| |
| get currentSearchResults() { |
| return this._currentSearchResults; |
| } |
| |
| set currentSearchResults(results: CurrentSearchResults) { |
| this._currentSearchResults = results; |
| } |
| |
| set hasFtrace(value: boolean) { |
| this._hasFtrace = value; |
| } |
| |
| get hasFtrace(): boolean { |
| return this._hasFtrace; |
| } |
| |
| get searchOverviewTrack() { |
| return this._searchOverviewTrack; |
| } |
| |
| getConversionJobStatus(name: ConversionJobName): ConversionJobStatus { |
| return this.getJobStatusMap().get(name) ?? ConversionJobStatus.NotRunning; |
| } |
| |
| setConversionJobStatus(name: ConversionJobName, status: ConversionJobStatus) { |
| const map = this.getJobStatusMap(); |
| if (status === ConversionJobStatus.NotRunning) { |
| map.delete(name); |
| } else { |
| map.set(name, status); |
| } |
| } |
| |
| private getJobStatusMap(): Map<ConversionJobName, ConversionJobStatus> { |
| if (!this._jobStatus) { |
| this._jobStatus = new Map(); |
| } |
| return this._jobStatus; |
| } |
| |
| get embeddedMode(): boolean { |
| return !!this._embeddedMode; |
| } |
| |
| set embeddedMode(value: boolean) { |
| this._embeddedMode = value; |
| } |
| |
| get hideSidebar(): boolean { |
| return !!this._hideSidebar; |
| } |
| |
| set hideSidebar(value: boolean) { |
| this._hideSidebar = value; |
| } |
| |
| setBufferUsage(bufferUsage: number) { |
| this._bufferUsage = bufferUsage; |
| } |
| |
| setTrackData(id: string, data: {}) { |
| this.trackDataStore.set(id, data); |
| } |
| |
| setRecordingLog(recordingLog: string) { |
| this._recordingLog = recordingLog; |
| } |
| |
| setAggregateData(kind: string, data: AggregateData) { |
| this.aggregateDataStore.set(kind, data); |
| } |
| |
| getCurrentEngine(): EngineConfig | undefined { |
| return this.state.engine; |
| } |
| |
| private handleSelectionOpts(opts: SelectionOpts = {}): void { |
| const { |
| clearSearch = true, |
| switchToCurrentSelectionTab = true, |
| pendingScrollId = undefined, |
| } = opts; |
| if (clearSearch) { |
| globals.dispatch(Actions.setSearchIndex({index: -1})); |
| } |
| if (pendingScrollId !== undefined) { |
| globals.dispatch( |
| Actions.setPendingScrollId({ |
| pendingScrollId, |
| }), |
| ); |
| } |
| if (switchToCurrentSelectionTab) { |
| globals.tabManager.showCurrentSelectionTab(); |
| } |
| } |
| |
| // This variable is set by the is_internal_user.js script if the user is a |
| // googler. This is used to avoid exposing features that are not ready yet |
| // for public consumption. The gated features themselves are not secret. |
| // If a user has been detected as a Googler once, make that sticky in |
| // localStorage, so that we keep treating them as such when they connect over |
| // public networks. |
| get isInternalUser() { |
| if (this._isInternalUser === undefined) { |
| this._isInternalUser = localStorage.getItem('isInternalUser') === '1'; |
| } |
| return this._isInternalUser; |
| } |
| |
| set isInternalUser(value: boolean) { |
| localStorage.setItem('isInternalUser', value ? '1' : '0'); |
| this._isInternalUser = value; |
| raf.scheduleFullRedraw(); |
| } |
| |
| get testing() { |
| return this._testing; |
| } |
| |
| // Used when switching to the legacy TraceViewer UI. |
| // Most resources are cleaned up by replacing the current |window| object, |
| // however pending RAFs and workers seem to outlive the |window| and need to |
| // be cleaned up explicitly. |
| shutdown() { |
| raf.shutdown(); |
| } |
| |
| get commandManager(): CommandManagerImpl { |
| return assertExists(this._cmdManager); |
| } |
| |
| get tabManager() { |
| return this._tabManager; |
| } |
| |
| get trackManager() { |
| return this._trackManager; |
| } |
| |
| get selectionManager() { |
| return this._selectionManager; |
| } |
| |
| get noteManager() { |
| return this._noteManager; |
| } |
| |
| // Offset between t=0 and the configured time domain. |
| timestampOffset(): time { |
| const fmt = timestampFormat(); |
| switch (fmt) { |
| case TimestampFormat.Timecode: |
| case TimestampFormat.Seconds: |
| return this.traceContext.start; |
| case TimestampFormat.Raw: |
| case TimestampFormat.RawLocale: |
| return Time.ZERO; |
| case TimestampFormat.UTC: |
| return this.traceContext.utcOffset; |
| case TimestampFormat.TraceTz: |
| return this.traceContext.traceTzOffset; |
| default: |
| const x: never = fmt; |
| throw new Error(`Unsupported format ${x}`); |
| } |
| } |
| |
| // Convert absolute time to domain time. |
| toDomainTime(ts: time): time { |
| return Time.sub(ts, this.timestampOffset()); |
| } |
| |
| async findTimeRangeOfSelection(): Promise< |
| Optional<{start: time; end: time}> |
| > { |
| const sel = globals.selectionManager.selection; |
| if (sel.kind === 'area') { |
| return sel; |
| } else if (sel.kind === 'note') { |
| const selectedNote = this.noteManager.getNote(sel.id); |
| if (selectedNote !== undefined) { |
| const kind = selectedNote.noteType; |
| switch (kind) { |
| case 'SPAN': |
| return { |
| start: selectedNote.start, |
| end: selectedNote.end, |
| }; |
| case 'DEFAULT': |
| return { |
| start: selectedNote.timestamp, |
| end: Time.add(selectedNote.timestamp, INSTANT_FOCUS_DURATION), |
| }; |
| default: |
| assertUnreachable(kind); |
| } |
| } |
| } else if (sel.kind === 'single') { |
| const uri = sel.trackUri; |
| const bounds = await globals.trackManager |
| .getTrack(uri) |
| ?.getEventBounds?.(sel.eventId); |
| if (bounds) { |
| return { |
| start: bounds.ts, |
| end: Time.add(bounds.ts, bounds.dur), |
| }; |
| } |
| return undefined; |
| } |
| |
| const selection = globals.selectionManager.legacySelection; |
| if (selection === null) { |
| return undefined; |
| } |
| |
| if (selection.kind === 'SCHED_SLICE' || selection.kind === 'SLICE') { |
| const slice = this.sliceDetails; |
| return findTimeRangeOfSlice(slice); |
| } else if (selection.kind === 'THREAD_STATE') { |
| const threadState = this.threadStateDetails; |
| return findTimeRangeOfSlice(threadState); |
| } else if (selection.kind === 'LOG') { |
| // TODO(hjd): Make focus selection work for logs. |
| } else if (selection.kind === 'GENERIC_SLICE') { |
| return findTimeRangeOfSlice({ |
| ts: selection.start, |
| dur: selection.duration, |
| }); |
| } |
| |
| return undefined; |
| } |
| } |
| |
| interface SliceLike { |
| ts: time; |
| dur: duration; |
| } |
| |
| // Returns the start and end points of a slice-like object If slice is instant |
| // or incomplete, dummy time will be returned which instead. |
| function findTimeRangeOfSlice(slice: Partial<SliceLike>): { |
| start: time; |
| end: time; |
| } { |
| if (exists(slice.ts) && exists(slice.dur)) { |
| if (slice.dur === -1n) { |
| return { |
| start: slice.ts, |
| end: Time.add(slice.ts, INCOMPLETE_SLICE_DURATION), |
| }; |
| } else if (slice.dur === 0n) { |
| return { |
| start: slice.ts, |
| end: Time.add(slice.ts, INSTANT_FOCUS_DURATION), |
| }; |
| } else { |
| return {start: slice.ts, end: Time.add(slice.ts, slice.dur)}; |
| } |
| } else { |
| return {start: Time.INVALID, end: Time.INVALID}; |
| } |
| } |
| |
| // Returns the time span of the current selection, or the visible window if |
| // there is no current selection. |
| export async function getTimeSpanOfSelectionOrVisibleWindow(): Promise<TimeSpan> { |
| const range = await globals.findTimeRangeOfSelection(); |
| if (exists(range)) { |
| return new TimeSpan(range.start, range.end); |
| } else { |
| return globals.timeline.visibleWindow.toTimeSpan(); |
| } |
| } |
| |
| export const globals = new Globals(); |