Web Inspector: Canvas tab: allow recording processing to be stopped midway
authordrousso@apple.com <drousso@apple.com@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Mon, 20 Aug 2018 21:31:55 +0000 (21:31 +0000)
committerdrousso@apple.com <drousso@apple.com@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Mon, 20 Aug 2018 21:31:55 +0000 (21:31 +0000)
https://bugs.webkit.org/show_bug.cgi?id=185152

Reviewed by Joseph Pecoraro.

Previously, `WI.Recording` used a `WI.YieldableTask` to process every action in such a way
as to not block the UI. The downside to this approach was that it used a message view to
indicate the progress of this process, and prevented the user from viewing the `WI.Recording`
until that process was completed.

This patch changes `WI.Recording` to instead use `async/await` and fire events whenever a
`WI.RecordingAction` (and `WI.RecordingFrame`) finished processing, allowing it to be added
to the recording `WI.TreeOutline` and selected by the user. Additionally, a pause/resume
button is added to the `WI.CanvasSidebarPanel` so the user has greater control over what
how much of the `WI.Recording` they want to process.

* Localizations/en.lproj/localizedStrings.js:

* UserInterface/Base/Utilities.js:
(Promise.delay)
Utility function for promisifying `setTimeout`.

* UserInterface/Models/Recording.js:
(WI.Recording):
(WI.Recording.prototype.get processing): Added.
(WI.Recording.prototype.get ready): Added.
(WI.Recording.prototype.startProcessing): Added.
(WI.Recording.prototype.stopProcessing): Added.
(WI.Recording.prototype.async._process): Added.
(WI.Recording.prototype.process): Deleted.
(WI.Recording.prototype.async.yieldableTaskWillProcessItem): Deleted.
(WI.Recording.prototype.async.yieldableTaskDidFinish): Deleted.

* UserInterface/Models/RecordingAction.js:
(WI.RecordingAction):
(WI.RecordingAction.prototype.get ready): Added.
(WI.RecordingAction.prototype.async.swizzle):
(WI.RecordingAction.prototype.apply):

* UserInterface/Models/RecordingInitialStateAction.js:
(WI.RecordingInitialStateAction):

* UserInterface/Views/CanvasSidebarPanel.js:
(WI.CanvasSidebarPanel):
(WI.CanvasSidebarPanel.prototype.set recording):
(WI.CanvasSidebarPanel.prototype.set action):
(WI.CanvasSidebarPanel.prototype._recordingAdded):
(WI.CanvasSidebarPanel.prototype._recordingRemoved):
(WI.CanvasSidebarPanel.prototype._currentRepresentedObjectsDidChange):
(WI.CanvasSidebarPanel.prototype._treeOutlineSelectionDidChange):
(WI.CanvasSidebarPanel.prototype._recordingChanged):
(WI.CanvasSidebarPanel.prototype._recordingChanged.createPauseButton): Added.
(WI.CanvasSidebarPanel.prototype._recordingChanged.createResumeButton): Added.
(WI.CanvasSidebarPanel.prototype._createRecordingFrameTreeElement): Added.
(WI.CanvasSidebarPanel.prototype._createRecordingActionTreeElement): Added.
(WI.CanvasSidebarPanel.prototype._handleRecordingProcessedAction): Added.
(WI.CanvasSidebarPanel.prototype._handleRecordingStartProcessingFrame): Added.
* UserInterface/Views/CanvasSidebarPanel.css:
(.sidebar > .panel.navigation.canvas > .content > .recording-content > .tree-outline .item.processing .subtitle > progress): Added.
(.sidebar > .panel.navigation.canvas > .content > .recording-content > .tree-outline:matches(:focus, .force-focus) .item.processing.selected .subtitle > progress): Added.
(.sidebar > .panel.navigation.canvas > .content > .recording-content > .tree-outline .item.processing .subtitle::before): Added.
(.sidebar > .panel.navigation.canvas > .content > .recording-content > .recording-processing-options): Added.
(.sidebar > .panel.navigation.canvas > .content > .recording-content > .recording-processing-options > .indeterminate-progress-spinner): Added.
(.sidebar > .panel.navigation.canvas > .content > .recording-content > .indeterminate-progress-spinner): Deleted.

* UserInterface/Views/RecordingContentView.js:
(WI.RecordingContentView):
(WI.RecordingContentView.prototype.updateActionIndex):
(WI.RecordingContentView.prototype.initialLayout):
(WI.RecordingContentView.prototype._updateCanvasPath):
(WI.RecordingContentView.prototype._handleRecordingProcessedAction): Added.
(WI.RecordingContentView.prototype._updateProcessProgress): Deleted.
(WI.RecordingContentView.prototype._handleRecordingProcessedActionSwizzle): Deleted.
(WI.RecordingContentView.prototype._handleRecordingProcessedActionApply): Deleted.
* UserInterface/Views/RecordingContentView.css:
(.content-view:not(.tab).recording > header > .slider-container > .slider-value): Added.

* UserInterface/Views/FolderTreeElement.js:
(WI.FolderTreeElement):

* UserInterface/Views/GeneralTreeElement.js:
(WI.GeneralTreeElement.prototype.get statusElement): Added.
(WI.GeneralTreeElement.prototype._updateTitleElements):

* UserInterface/Views/RecordingContentView.js:
(WI.CanvasContentView.prototype._handleViewShaderButtonClicked):
(WI.CanvasContentView.prototype._handleViewRecordingButtonClicked):
Drive-by: `WI.Collection` doesn't have a `values()` accessor for the underlying `Set`.
* UserInterface/Views/RecordingNavigationSidebarPanel.css: Removed.
* UserInterface/Views/RecordingNavigationSidebarPanel.js: Removed.
These files are no longer used since they were "merged" into `WI.CanvasSidebarPanel`.

git-svn-id: https://svn.webkit.org/repository/webkit/trunk@235095 268f45cc-cd09-0410-ab3c-d52691b4dbfc

15 files changed:
Source/WebInspectorUI/ChangeLog
Source/WebInspectorUI/Localizations/en.lproj/localizedStrings.js
Source/WebInspectorUI/UserInterface/Base/Utilities.js
Source/WebInspectorUI/UserInterface/Models/Recording.js
Source/WebInspectorUI/UserInterface/Models/RecordingAction.js
Source/WebInspectorUI/UserInterface/Models/RecordingInitialStateAction.js
Source/WebInspectorUI/UserInterface/Views/CanvasContentView.js
Source/WebInspectorUI/UserInterface/Views/CanvasSidebarPanel.css
Source/WebInspectorUI/UserInterface/Views/CanvasSidebarPanel.js
Source/WebInspectorUI/UserInterface/Views/FolderTreeElement.js
Source/WebInspectorUI/UserInterface/Views/GeneralTreeElement.js
Source/WebInspectorUI/UserInterface/Views/RecordingContentView.css
Source/WebInspectorUI/UserInterface/Views/RecordingContentView.js
Source/WebInspectorUI/UserInterface/Views/RecordingNavigationSidebarPanel.css [deleted file]
Source/WebInspectorUI/UserInterface/Views/RecordingNavigationSidebarPanel.js [deleted file]

index d6e2796..a479084 100644 (file)
@@ -1,3 +1,98 @@
+2018-08-20  Devin Rousso  <drousso@apple.com>
+
+        Web Inspector: Canvas tab: allow recording processing to be stopped midway
+        https://bugs.webkit.org/show_bug.cgi?id=185152
+
+        Reviewed by Joseph Pecoraro.
+
+        Previously, `WI.Recording` used a `WI.YieldableTask` to process every action in such a way
+        as to not block the UI. The downside to this approach was that it used a message view to
+        indicate the progress of this process, and prevented the user from viewing the `WI.Recording`
+        until that process was completed.
+
+        This patch changes `WI.Recording` to instead use `async/await` and fire events whenever a
+        `WI.RecordingAction` (and `WI.RecordingFrame`) finished processing, allowing it to be added
+        to the recording `WI.TreeOutline` and selected by the user. Additionally, a pause/resume
+        button is added to the `WI.CanvasSidebarPanel` so the user has greater control over what
+        how much of the `WI.Recording` they want to process.
+
+        * Localizations/en.lproj/localizedStrings.js:
+
+        * UserInterface/Base/Utilities.js:
+        (Promise.delay)
+        Utility function for promisifying `setTimeout`.
+
+        * UserInterface/Models/Recording.js:
+        (WI.Recording):
+        (WI.Recording.prototype.get processing): Added.
+        (WI.Recording.prototype.get ready): Added.
+        (WI.Recording.prototype.startProcessing): Added.
+        (WI.Recording.prototype.stopProcessing): Added.
+        (WI.Recording.prototype.async._process): Added.
+        (WI.Recording.prototype.process): Deleted.
+        (WI.Recording.prototype.async.yieldableTaskWillProcessItem): Deleted.
+        (WI.Recording.prototype.async.yieldableTaskDidFinish): Deleted.
+
+        * UserInterface/Models/RecordingAction.js:
+        (WI.RecordingAction):
+        (WI.RecordingAction.prototype.get ready): Added.
+        (WI.RecordingAction.prototype.async.swizzle):
+        (WI.RecordingAction.prototype.apply):
+
+        * UserInterface/Models/RecordingInitialStateAction.js:
+        (WI.RecordingInitialStateAction):
+
+        * UserInterface/Views/CanvasSidebarPanel.js:
+        (WI.CanvasSidebarPanel):
+        (WI.CanvasSidebarPanel.prototype.set recording):
+        (WI.CanvasSidebarPanel.prototype.set action):
+        (WI.CanvasSidebarPanel.prototype._recordingAdded):
+        (WI.CanvasSidebarPanel.prototype._recordingRemoved):
+        (WI.CanvasSidebarPanel.prototype._currentRepresentedObjectsDidChange):
+        (WI.CanvasSidebarPanel.prototype._treeOutlineSelectionDidChange):
+        (WI.CanvasSidebarPanel.prototype._recordingChanged):
+        (WI.CanvasSidebarPanel.prototype._recordingChanged.createPauseButton): Added.
+        (WI.CanvasSidebarPanel.prototype._recordingChanged.createResumeButton): Added.
+        (WI.CanvasSidebarPanel.prototype._createRecordingFrameTreeElement): Added.
+        (WI.CanvasSidebarPanel.prototype._createRecordingActionTreeElement): Added.
+        (WI.CanvasSidebarPanel.prototype._handleRecordingProcessedAction): Added.
+        (WI.CanvasSidebarPanel.prototype._handleRecordingStartProcessingFrame): Added.
+        * UserInterface/Views/CanvasSidebarPanel.css:
+        (.sidebar > .panel.navigation.canvas > .content > .recording-content > .tree-outline .item.processing .subtitle > progress): Added.
+        (.sidebar > .panel.navigation.canvas > .content > .recording-content > .tree-outline:matches(:focus, .force-focus) .item.processing.selected .subtitle > progress): Added.
+        (.sidebar > .panel.navigation.canvas > .content > .recording-content > .tree-outline .item.processing .subtitle::before): Added.
+        (.sidebar > .panel.navigation.canvas > .content > .recording-content > .recording-processing-options): Added.
+        (.sidebar > .panel.navigation.canvas > .content > .recording-content > .recording-processing-options > .indeterminate-progress-spinner): Added.
+        (.sidebar > .panel.navigation.canvas > .content > .recording-content > .indeterminate-progress-spinner): Deleted.
+
+        * UserInterface/Views/RecordingContentView.js:
+        (WI.RecordingContentView):
+        (WI.RecordingContentView.prototype.updateActionIndex):
+        (WI.RecordingContentView.prototype.initialLayout):
+        (WI.RecordingContentView.prototype._updateCanvasPath):
+        (WI.RecordingContentView.prototype._handleRecordingProcessedAction): Added.
+        (WI.RecordingContentView.prototype._updateProcessProgress): Deleted.
+        (WI.RecordingContentView.prototype._handleRecordingProcessedActionSwizzle): Deleted.
+        (WI.RecordingContentView.prototype._handleRecordingProcessedActionApply): Deleted.
+        * UserInterface/Views/RecordingContentView.css:
+        (.content-view:not(.tab).recording > header > .slider-container > .slider-value): Added.
+
+        * UserInterface/Views/FolderTreeElement.js:
+        (WI.FolderTreeElement):
+
+        * UserInterface/Views/GeneralTreeElement.js:
+        (WI.GeneralTreeElement.prototype.get statusElement): Added.
+        (WI.GeneralTreeElement.prototype._updateTitleElements):
+
+        * UserInterface/Views/RecordingContentView.js:
+        (WI.CanvasContentView.prototype._handleViewShaderButtonClicked):
+        (WI.CanvasContentView.prototype._handleViewRecordingButtonClicked):
+        Drive-by: `WI.Collection` doesn't have a `values()` accessor for the underlying `Set`.
+
+        * UserInterface/Views/RecordingNavigationSidebarPanel.css: Removed.
+        * UserInterface/Views/RecordingNavigationSidebarPanel.js: Removed.
+        These files are no longer used since they were "merged" into `WI.CanvasSidebarPanel`.
+
 2018-08-20  Devin Rousso  <webkit@devinrousso.com>
 
         Web Inspector: Canvas tab: create icons for recordings/shaders in the preview tile
index 3014fe8..d498f50 100644 (file)
@@ -568,7 +568,6 @@ localizedStrings["Live"] = "Live";
 localizedStrings["Live Size"] = "Live Size";
 localizedStrings["Load \u2014 %s"] = "Load \u2014 %s";
 localizedStrings["Load cancelled"] = "Load cancelled";
-localizedStrings["Loading Recording"] = "Loading Recording";
 localizedStrings["Local File"] = "Local File";
 localizedStrings["Local Storage"] = "Local Storage";
 localizedStrings["Local Variables"] = "Local Variables";
@@ -641,7 +640,6 @@ localizedStrings["No Preview Available"] = "No Preview Available";
 localizedStrings["No Properties"] = "No Properties";
 localizedStrings["No Properties \u2014 Click to Edit"] = "No Properties \u2014 Click to Edit";
 localizedStrings["No Query Parameters"] = "No Query Parameters";
-localizedStrings["No Recording Data"] = "No Recording Data";
 localizedStrings["No Request Headers"] = "No Request Headers";
 localizedStrings["No Response Headers"] = "No Response Headers";
 localizedStrings["No Results Found"] = "No Results Found";
@@ -704,6 +702,7 @@ localizedStrings["Parent"] = "Parent";
 localizedStrings["Partial Garbage Collection"] = "Partial Garbage Collection";
 localizedStrings["Passive"] = "Passive";
 localizedStrings["Path"] = "Path";
+localizedStrings["Pause Processing"] = "Pause Processing";
 localizedStrings["Pause Reason"] = "Pause Reason";
 localizedStrings["Pause script execution (%s or %s)"] = "Pause script execution (%s or %s)";
 localizedStrings["Ping"] = "Ping";
@@ -730,7 +729,6 @@ localizedStrings["Probe Expression"] = "Probe Expression";
 localizedStrings["Probe Sample Recorded"] = "Probe Sample Recorded";
 localizedStrings["Probes"] = "Probes";
 localizedStrings["Processing Instruction"] = "Processing Instruction";
-localizedStrings["Processing Recording"] = "Processing Recording";
 localizedStrings["Program %d"] = "Program %d";
 localizedStrings["Properties"] = "Properties";
 localizedStrings["Property"] = "Property";
@@ -798,6 +796,7 @@ localizedStrings["Response Headers"] = "Response Headers";
 localizedStrings["Response:"] = "Response:";
 localizedStrings["Restart (%s)"] = "Restart (%s)";
 localizedStrings["Restart animation"] = "Restart animation";
+localizedStrings["Resume Processing"] = "Resume Processing";
 localizedStrings["Resume Thread"] = "Resume Thread";
 localizedStrings["Retained Size"] = "Retained Size";
 localizedStrings["Return type for anonymous function"] = "Return type for anonymous function";
index 8072ce1..6cc4618 100644 (file)
@@ -1337,6 +1337,14 @@ Object.defineProperty(Array.prototype, "binaryIndexOf",
     }
 });
 
+Object.defineProperty(Promise, "delay",
+{
+    value(delay)
+    {
+        return new Promise((resolve) => setTimeout(resolve, delay || 0));
+    }
+});
+
 (function() {
     // The `debounce` function lets you call any function on an object with a delay
     // and if the function keeps getting called, the delay gets reset. Since `debounce`
index 23f9fd5..fb3c876 100644 (file)
@@ -41,10 +41,8 @@ WI.Recording = class Recording extends WI.Object
         this._visualActionIndexes = [];
         this._source = null;
 
-        this._swizzleTask = null;
-        this._applyTask = null;
         this._processContext = null;
-        this._processPromise = null;
+        this._processing = false;
     }
 
     static fromPayload(payload, frames)
@@ -162,18 +160,33 @@ WI.Recording = class Recording extends WI.Object
     get source() { return this._source; }
     set source(source) { this._source = source; }
 
-    process()
+    get processing() { return this._processing; }
+
+    get ready()
     {
-        if (!this._processPromise) {
-            this._processPromise = new WI.WrappedPromise;
+        return this._actions.lastValue.ready;
+    }
 
-            let items = this._actions.map((action, index) => { return {action, index} });
-            this._swizzleTask = new WI.YieldableTask(this, items);
-            this._applyTask = new WI.YieldableTask(this, items);
+    startProcessing()
+    {
+        console.assert(!this._processing, "Cannot start an already started process().");
+        console.assert(!this.ready, "Cannot start a completed process().");
+        if (this._processing || this.ready)
+            return;
 
-            this._swizzleTask.start();
-        }
-        return this._processPromise.promise;
+        this._processing = true;
+
+        this._process();
+    }
+
+    stopProcessing()
+    {
+        console.assert(this._processing, "Cannot stop an already stopped process().");
+        console.assert(!this.ready, "Cannot stop a completed process().");
+        if (!this._processing || this.ready)
+            return;
+
+        this._processing = false;
     }
 
     createDisplayName(suggestedName)
@@ -332,29 +345,11 @@ WI.Recording = class Recording extends WI.Object
         };
     }
 
-    // YieldableTask delegate
+    // Private
 
-    async yieldableTaskWillProcessItem(task, item)
+    async _process()
     {
-        if (task === this._swizzleTask) {
-            await item.action.swizzle(this);
-
-            this.dispatchEventToListeners(WI.Recording.Event.ProcessedActionSwizzle, {index: item.index});
-        } else if (task === this._applyTask) {
-            item.action.process(this, this._processContext);
-
-            if (item.action.isVisual)
-                this._visualActionIndexes.push(item.index);
-
-            this.dispatchEventToListeners(WI.Recording.Event.ProcessedActionApply, {index: item.index});
-        }
-    }
-
-    async yieldableTaskDidFinish(task)
-    {
-        if (task === this._swizzleTask) {
-            this._swizzleTask = null;
-
+        if (!this._processContext) {
             this._processContext = this.createContext();
 
             if (this._type === WI.Recording.Type.Canvas2D) {
@@ -411,19 +406,68 @@ WI.Recording = class Recording extends WI.Object
                     } catch { }
                 }
             }
+        }
+
+        // The first action is always a WI.RecordingInitialStateAction, which doesn't need to swizzle().
+        // Since it is not associated with a WI.RecordingFrame, it has to manually process().
+        if (!this._actions[0].ready) {
+            this._actions[0].process(this, this._processContext);
+            this.dispatchEventToListeners(WI.Recording.Event.ProcessedAction, {action: this._actions[0], index: 0});
+        }
+
+        const workInterval = 10;
+        let startTime = Date.now();
+
+        let cumulativeActionIndex = 0;
+        for (let frameIndex = 0; frameIndex < this._frames.length; ++frameIndex) {
+            let frame = this._frames[frameIndex];
+
+            if (frame.actions.lastValue.ready) {
+                cumulativeActionIndex += frame.actions.length;
+                continue;
+            }
+
+            for (let actionIndex = 0; actionIndex < frame.actions.length; ++actionIndex) {
+                ++cumulativeActionIndex;
+
+                let action = frame.actions[actionIndex];
+                if (action.ready)
+                    continue;
+
+                await action.swizzle(this);
+
+                action.process(this, this._processContext);
+
+                if (action.isVisual)
+                    this._visualActionIndexes.push(cumulativeActionIndex);
+
+                if (!actionIndex)
+                    this.dispatchEventToListeners(WI.Recording.Event.StartProcessingFrame, {frame, index: frameIndex});
+
+                this.dispatchEventToListeners(WI.Recording.Event.ProcessedAction, {action, index: cumulativeActionIndex});
+
+                if (Date.now() - startTime > workInterval) {
+                    await Promise.delay(); // yield
+
+                    startTime = Date.now();
+                }
+
+                if (!this._processing)
+                    return;
+            }
 
-            this._applyTask.start();
-        } else if (task === this._applyTask) {
-            this._applyTask = null;
-            this._processContext = null;
-            this._processPromise.resolve();
+            if (!this._processing)
+                return;
         }
+
+        this._processContext = null;
+        this._processing = false;
     }
 };
 
 WI.Recording.Event = {
-    ProcessedActionApply: "recording-processed-action-apply",
-    ProcessedActionSwizzle: "recording-processed-action-swizzle",
+    ProcessedAction: "recording-processed-action",
+    StartProcessingFrame: "recording-start-processing-frame",
 };
 
 WI.Recording._importedRecordingNameSet = new Set;
index 0705d12..238d2aa 100644 (file)
@@ -48,6 +48,9 @@ WI.RecordingAction = class RecordingAction extends WI.Object
 
         this._state = null;
         this._stateModifiers = new Set;
+
+        this._swizzled = false;
+        this._processed = false;
     }
 
     // Static
@@ -108,8 +111,18 @@ WI.RecordingAction = class RecordingAction extends WI.Object
     get state() { return this._state; }
     get stateModifiers() { return this._stateModifiers; }
 
+    get ready()
+    {
+        return this._swizzled && this._processed;
+    }
+
     process(recording, context)
     {
+        console.assert(this._swizzled, "You must swizzle() before you can process().");
+        console.assert(!this._processed, "You should only process() once.");
+
+        this._processed = true;
+
         if (recording.type === WI.Recording.Type.CanvasWebGL) {
             // We add each RecordingAction to the list of visualActionIndexes after it is processed.
             if (this._valid && this._isVisual) {
@@ -187,8 +200,12 @@ WI.RecordingAction = class RecordingAction extends WI.Object
 
     async swizzle(recording)
     {
-        if (!this._valid)
+        console.assert(!this._swizzled, "You should only swizzle() once.");
+
+        if (!this._valid) {
+            this._swizzled = true;
             return;
+        }
 
         let swizzleParameter = (item, index) => {
             return recording.swizzle(item, this._payloadSwizzleTypes[index]);
@@ -254,10 +271,15 @@ WI.RecordingAction = class RecordingAction extends WI.Object
                     this._stateModifiers.add(item);
             }
         }
+
+        this._swizzled = true;
     }
 
     apply(context, options = {})
     {
+        console.assert(this._swizzled, "You must swizzle() before you can apply().");
+        console.assert(this._processed, "You must process() before you can apply().");
+
         if (!this.valid)
             return;
 
index d549854..af4afcd 100644 (file)
@@ -32,5 +32,7 @@ WI.RecordingInitialStateAction = class RecordingInitialStateAction extends WI.Re
         this._name = WI.UIString("Initial State");
 
         this._valid = false;
+
+        this._swizzled = true;
     }
 };
index 0c01e23..192267e 100644 (file)
@@ -435,7 +435,7 @@ WI.CanvasContentView = class CanvasContentView extends WI.ContentView
             return;
 
         if (shaderPrograms.size === 1) {
-            WI.showRepresentedObject(shaderPrograms.values().next().value);
+            WI.showRepresentedObject(Array.from(shaderPrograms)[0]);
             return;
         }
 
@@ -458,7 +458,7 @@ WI.CanvasContentView = class CanvasContentView extends WI.ContentView
             return;
 
         if (recordings.size === 1) {
-            WI.showRepresentedObject(recordings.values().next().value);
+            WI.showRepresentedObject(Array.from(recordings)[0]);
             return;
         }
 
index cab3dbc..0d1a5e4 100644 (file)
     line-height: 16px;
 }
 
-.sidebar > .panel.navigation.canvas > .content > .recording-content > .indeterminate-progress-spinner {
-    margin: 16px auto;
+.sidebar > .panel.navigation.canvas > .content > .recording-content > .tree-outline .item.processing .subtitle > progress {
+    width: 100%;
+    max-width: 100px;
+    margin: 2px 4px 0;
+}
+
+.sidebar > .panel.navigation.canvas > .content > .recording-content > .tree-outline:matches(:focus, .force-focus) .item.processing.selected .subtitle > progress {
+    filter: brightness(10);
+}
+
+.sidebar > .panel.navigation.canvas > .content > .recording-content > .tree-outline .item.processing .subtitle::before {
+    content: "";
+}
+
+.sidebar > .panel.navigation.canvas > .content > .recording-content > .recording-processing-options {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    margin: 16px 0;
+}
+
+.sidebar > .panel.navigation.canvas > .content > .recording-content > .recording-processing-options > .indeterminate-progress-spinner {
+    margin-bottom: 4px;
 }
index 784b7c1..68b1817 100644 (file)
@@ -68,8 +68,9 @@ WI.CanvasSidebarPanel = class CanvasSidebarPanel extends WI.NavigationSidebarPan
         WI.canvasManager.addEventListener(WI.CanvasManager.Event.RecordingStarted, this._updateRecordNavigationItem, this);
         WI.canvasManager.addEventListener(WI.CanvasManager.Event.RecordingStopped, this._updateRecordNavigationItem, this);
 
-        this._recordingProcessPromise = null;
-        this._recordingProcessSpinner = null;
+        this._recordingProcessingOptionsContainer = null;
+
+        this._selectedRecordingActionIndex = NaN;
     }
 
     // Public
@@ -103,16 +104,30 @@ WI.CanvasSidebarPanel = class CanvasSidebarPanel extends WI.NavigationSidebarPan
         if (recording === this._recording)
             return;
 
+        if (this._recording) {
+            this._recording.removeEventListener(WI.Recording.Event.ProcessedAction, this._handleRecordingProcessedAction, this);
+            this._recording.removeEventListener(WI.Recording.Event.StartProcessingFrame, this._handleRecordingStartProcessingFrame, this);
+        }
+
         if (recording)
             this.canvas = recording.source;
 
         this._recording = recording;
+
+        if (this._recording) {
+            this._recording.addEventListener(WI.Recording.Event.ProcessedAction, this._handleRecordingProcessedAction, this);
+            this._recording.addEventListener(WI.Recording.Event.StartProcessingFrame, this._handleRecordingStartProcessingFrame, this);
+        }
+
         this._recordingChanged();
     }
 
     set action(action)
     {
-        if (!this._recording || this._recordingProcessPromise)
+        if (!this._recording)
+            return;
+
+        if (action === this._recording.actions[this._selectedRecordingActionIndex])
             return;
 
         let selectedTreeElement = this._recordingTreeOutline.selectedTreeElement;
@@ -138,6 +153,8 @@ WI.CanvasSidebarPanel = class CanvasSidebarPanel extends WI.NavigationSidebarPan
         const omitFocus = false;
         const selectedByUser = false;
         treeElement.revealAndSelect(omitFocus, selectedByUser);
+
+        this._selectedRecordingActionIndex = this._recording.actions.indexOf(action);
     }
 
     shown()
@@ -204,19 +221,19 @@ WI.CanvasSidebarPanel = class CanvasSidebarPanel extends WI.NavigationSidebarPan
 
     _recordingAdded(event)
     {
-        this.recording = event.data.item;
-
         this._updateRecordNavigationItem();
         this._updateRecordingScopeBar();
+
+        this.recording = event.data.item;
     }
 
     _recordingRemoved(event)
     {
+        this._updateRecordingScopeBar();
+
         let recording = event.data.item;
         if (recording === this.recording)
             this.recording = this._canvas ? Array.from(this._canvas.recordingCollection).lastValue : null;
-
-        this._updateRecordingScopeBar();
     }
 
     _scopeBarSelectionChanged()
@@ -260,11 +277,12 @@ WI.CanvasSidebarPanel = class CanvasSidebarPanel extends WI.NavigationSidebarPan
 
         let recording = objects.find((object) => object instanceof WI.Recording);
         if (recording) {
+            this.recording = recording;
+
             let recordingAction = objects.find((object) => object instanceof WI.RecordingAction);
             if (recordingAction !== recording[WI.CanvasSidebarPanel.SelectedActionSymbol])
                 this.action = recordingAction;
 
-            this.recording = recording;
             return;
         }
 
@@ -297,8 +315,11 @@ WI.CanvasSidebarPanel = class CanvasSidebarPanel extends WI.NavigationSidebarPan
 
         const onlyExisting = true;
         let recordingContentView = this.contentBrowser.contentViewForRepresentedObject(this._recording, onlyExisting);
-        if (recordingContentView)
-            recordingContentView.updateActionIndex(treeElement.index);
+        if (!recordingContentView)
+            return;
+
+        this._selectedRecordingActionIndex = treeElement.index;
+        recordingContentView.updateActionIndex(this._selectedRecordingActionIndex);
     }
 
     _canvasChanged()
@@ -329,66 +350,86 @@ WI.CanvasSidebarPanel = class CanvasSidebarPanel extends WI.NavigationSidebarPan
     {
         this._recordingTreeOutline.removeChildren();
 
-        if (!this._recording)
+        if (!this._recording) {
+            if (this._recordingProcessingOptionsContainer) {
+                this._recordingProcessingOptionsContainer.remove();
+                this._recordingProcessingOptionsContainer = null;
+            }
             return;
-
-        if (!this._recordingProcessSpinner) {
-            this._recordingProcessSpinner = new WI.IndeterminateProgressSpinner;
-            this._recordingContentContainer.appendChild(this._recordingProcessSpinner.element);
         }
 
-        this.contentBrowser.showContentViewForRepresentedObject(this._recording);
-
-        let recording = this._recording;
-
-        let promise = this._recording.process().then(() => {
-            if (recording !== this._recording || promise !== this._recordingProcessPromise)
-                return;
-
-            this._recordingProcessPromise = null;
-
-            if (this._recordingProcessSpinner) {
-                this._recordingProcessSpinner.element.remove();
-                this._recordingProcessSpinner = null;
+        if (!this._recording.ready) {
+            if (!this._recording.processing)
+                this._recording.startProcessing();
+
+            if (!this._recordingProcessingOptionsContainer) {
+                this._recordingProcessingOptionsContainer = this._recordingContentContainer.appendChild(document.createElement("div"));
+                this._recordingProcessingOptionsContainer.classList.add("recording-processing-options");
+
+                let createPauseButton = () => {
+                    let spinner = new WI.IndeterminateProgressSpinner;
+                    this._recordingProcessingOptionsContainer.appendChild(spinner.element);
+
+                    let pauseButton = this._recordingProcessingOptionsContainer.appendChild(document.createElement("button"));
+                    pauseButton.textContent = WI.UIString("Pause Processing");
+                    pauseButton.addEventListener("click", (event) => {
+                        this._recording.stopProcessing();
+
+                        spinner.element.remove();
+                        pauseButton.remove();
+                        createResumeButton();
+                    });
+                };
+
+                let createResumeButton = () => {
+                    let resumeButton = this._recordingProcessingOptionsContainer.appendChild(document.createElement("button"));
+                    resumeButton.textContent = WI.UIString("Resume Processing");
+                    resumeButton.addEventListener("click", (event) => {
+                        this._recording.startProcessing();
+
+                        resumeButton.remove();
+                        createPauseButton();
+                    });
+                };
+
+                if (this._recording.processing)
+                    createPauseButton();
+                else
+                    createResumeButton();
             }
+        }
 
-            this._recordingTreeOutline.element.dataset.indent = Number.countDigits(this._recording.actions.length);
+        this.contentBrowser.showContentViewForRepresentedObject(this._recording);
 
-            if (this._recording.actions[0] instanceof WI.RecordingInitialStateAction)
-                this._recordingTreeOutline.appendChild(new WI.RecordingActionTreeElement(this._recording.actions[0], 0, this._recording.type));
+        if (this._scopeBar) {
+            let scopeBarItem = this._scopeBar.item(this._recording.displayName);
+            console.assert(scopeBarItem, "Missing scopeBarItem for recording.", this._recording);
+            scopeBarItem.selected = true;
+        }
 
-            let cumulativeActionIndex = 1;
-            this._recording.frames.forEach((frame, frameIndex) => {
-                let folder = new WI.FolderTreeElement(WI.UIString("Frame %d").format((frameIndex + 1).toLocaleString()));
-                this._recordingTreeOutline.appendChild(folder);
+        if (this._recording.actions[0].ready) {
+            this._recordingTreeOutline.appendChild(new WI.RecordingActionTreeElement(this._recording.actions[0], 0, this._recording.type));
 
-                for (let i = 0; i < frame.actions.length; ++i)
-                    folder.appendChild(new WI.RecordingActionTreeElement(frame.actions[i], cumulativeActionIndex + i, this._recording.type));
+            if (!this._recording[WI.CanvasSidebarPanel.SelectedActionSymbol])
+                this.action = this._recording.actions[0];
+        }
 
-                if (!isNaN(frame.duration)) {
-                    const higherResolution = true;
-                    folder.status = Number.secondsToString(frame.duration / 1000, higherResolution);
-                }
+        let cumulativeActionIndex = 0;
+        this._recording.frames.forEach((frame, frameIndex) => {
+            if (!frame.actions[0].ready)
+                return;
 
-                if (frame.incomplete)
-                    folder.subtitle = WI.UIString("Incomplete");
+            let folder = this._createRecordingFrameTreeElement(frame, frameIndex, this._recordingTreeOutline);
 
-                if (this._recording.frames.length === 1)
-                    folder.expand();
+            for (let action of frame.actions) {
+                if (!action.ready)
+                    break;
 
-                cumulativeActionIndex += frame.actions.length;
-            });
+                ++cumulativeActionIndex;
 
-            if (this._scopeBar) {
-                let scopeBarItem = this._scopeBar.item(this._recording.displayName);
-                console.assert(scopeBarItem, "Missing scopeBarItem for recording.", this._recording);
-                scopeBarItem.selected = true;
+                this._createRecordingActionTreeElement(action, cumulativeActionIndex, folder);
             }
-
-            this.action = this._recording[WI.CanvasSidebarPanel.SelectedActionSymbol] || this._recording.actions[0];
         });
-
-        this._recordingProcessPromise = promise;
     }
 
     _updateRecordNavigationItem()
@@ -447,6 +488,79 @@ WI.CanvasSidebarPanel = class CanvasSidebarPanel extends WI.NavigationSidebarPan
         this._scopeBar.addEventListener(WI.ScopeBar.Event.SelectionChanged, this._scopeBarSelectionChanged, this);
         this._recordingNavigationBar.insertNavigationItem(this._scopeBar, 0);
     }
+
+    _createRecordingFrameTreeElement(frame, index, parent)
+    {
+        let folder = new WI.FolderTreeElement(WI.UIString("Frame %d").format((index + 1).toLocaleString()), frame);
+
+        if (!isNaN(frame.duration)) {
+            const higherResolution = true;
+            folder.status = Number.secondsToString(frame.duration / 1000, higherResolution);
+        }
+
+        parent.appendChild(folder);
+
+        return folder;
+    }
+
+    _createRecordingActionTreeElement(action, index, parent)
+    {
+        let treeElement = new WI.RecordingActionTreeElement(action, index, this._recording.type);
+
+        parent.appendChild(treeElement);
+
+        if (parent instanceof WI.FolderTreeElement && parent.representedObject instanceof WI.RecordingFrame) {
+            if (action !== parent.representedObject.actions.lastValue) {
+                parent.addClassName("processing");
+
+                if (!(parent.subtitle instanceof HTMLProgressElement))
+                    parent.subtitle = document.createElement("progress");
+
+                if (parent.statusElement)
+                    parent.subtitle.style.setProperty("width", `calc(100% - ${parent.statusElement.offsetWidth + 4}px`);
+
+                parent.subtitle.value = parent.representedObject.actions.indexOf(action) / parent.representedObject.actions.length;
+            } else {
+                parent.removeClassName("processing");
+                if (parent.representedObject.incomplete)
+                    parent.subtitle = WI.UIString("Incomplete");
+                else
+                    parent.subtitle = "";
+            }
+        }
+
+        if (action === this._recording[WI.CanvasSidebarPanel.SelectedActionSymbol])
+            this.action = action;
+
+        return treeElement;
+    }
+
+    _handleRecordingProcessedAction(event)
+    {
+        let {action, index} = event.data;
+
+        this._recordingTreeOutline.element.dataset.indent = Number.countDigits(index);
+
+        let isInitialStateAction = !index;
+
+        console.assert(isInitialStateAction || this._recordingTreeOutline.children.lastValue instanceof WI.FolderTreeElement, "There should be a WI.FolderTreeElement for the frame for this action.");
+        this._createRecordingActionTreeElement(action, index, isInitialStateAction ? this._recordingTreeOutline : this._recordingTreeOutline.children.lastValue);
+
+        if (isInitialStateAction && !this._recording[WI.CanvasSidebarPanel.SelectedActionSymbol])
+            this.action = action;
+
+        if (action === this._recording.actions.lastValue && this._recordingProcessingOptionsContainer) {
+            this._recordingProcessingOptionsContainer.remove();
+            this._recordingProcessingOptionsContainer = null;
+        }
+    }
+
+    _handleRecordingStartProcessingFrame(event)
+    {
+        let {frame, index} = event.data;
+
+        this._createRecordingFrameTreeElement(frame, index, this._recordingTreeOutline);
+    }
 };
 
 WI.CanvasSidebarPanel.SelectedActionSymbol = Symbol("selected-action");
index db099f3..e05e2e0 100644 (file)
@@ -27,8 +27,6 @@ WI.FolderTreeElement = class FolderTreeElement extends WI.GeneralTreeElement
 {
     constructor(title, representedObject)
     {
-        console.assert(!representedObject || representedObject instanceof WI.Collection);
-
         const classNames = [WI.FolderTreeElement.FolderIconStyleClassName];
         const subtitle = null;
         super(classNames, title, subtitle, representedObject, {hasChildren: true});
index 4eb53d3..8626e83 100644 (file)
@@ -50,6 +50,11 @@ WI.GeneralTreeElement = class GeneralTreeElement extends WI.TreeElement
         return this._iconElement;
     }
 
+    get statusElement()
+    {
+        return this._statusElement;
+    }
+
     get titlesElement()
     {
         this._createElementsIfNeeded();
@@ -301,6 +306,7 @@ WI.GeneralTreeElement = class GeneralTreeElement extends WI.TreeElement
             this._createSubtitleElementIfNeeded();
             this._subtitleElement.removeChildren();
             this._subtitleElement.appendChild(this._subtitle);
+            this._titlesElement.classList.remove(WI.GeneralTreeElement.NoSubtitleStyleClassName);
         } else {
             if (this._subtitleElement)
                 this._subtitleElement.textContent = "";
index 7c9b4aa..e53d94c 100644 (file)
     margin: 4px 8px;
 }
 
+.content-view:not(.tab).recording > header > .slider-container > .slider-value {
+    font-family: Menlo, monospace;
+    font-family: 11px;
+}
+
 .content-view:not(.tab).recording > header > .slider-container > input[type=range] {
     flex: 1;
     width: 100%;
index 97d6c7b..4a8e010 100644 (file)
@@ -61,9 +61,6 @@ WI.RecordingContentView = class RecordingContentView extends WI.ContentView
             this._exportButtonNavigationItem.visibilityPriority = WI.NavigationItem.VisibilityPriority.High;
             this._exportButtonNavigationItem.addEventListener(WI.ButtonNavigationItem.Event.Clicked, () => { this._exportRecording(); });
         }
-
-        this._processing = true;
-        this._processMessageTextView = null;
     }
 
     // Static
@@ -123,9 +120,6 @@ WI.RecordingContentView = class RecordingContentView extends WI.ContentView
 
         this._index = index;
 
-        if (this._processing)
-            return;
-
         this._updateSliderValue();
 
         if (this.representedObject.type === WI.Recording.Type.Canvas2D)
@@ -178,7 +172,7 @@ WI.RecordingContentView = class RecordingContentView extends WI.ContentView
         let previewHeader = this.element.appendChild(document.createElement("header"));
 
         let sliderContainer = previewHeader.appendChild(document.createElement("div"));
-        sliderContainer.className = "slider-container hidden";
+        sliderContainer.className = "slider-container";
 
         this._previewContainer = this.element.appendChild(document.createElement("div"));
         this._previewContainer.className = "preview-container";
@@ -192,25 +186,7 @@ WI.RecordingContentView = class RecordingContentView extends WI.ContentView
         this._sliderElement.min = 0;
         this._sliderElement.max = 0;
 
-        this.representedObject.addEventListener(WI.Recording.Event.ProcessedActionSwizzle, this._handleRecordingProcessedActionSwizzle, this);
-        this.representedObject.addEventListener(WI.Recording.Event.ProcessedActionApply, this._handleRecordingProcessedActionApply, this);
-
-        this.representedObject.process().then(() => {
-            if (this._processMessageTextView)
-                this._processMessageTextView.remove();
-
-            sliderContainer.classList.remove("hidden");
-            this._sliderElement.max = this.representedObject.visualActionIndexes.length;
-            this._updateSliderValue();
-
-            this._processing = false;
-
-            let index = this._index;
-            if (!isNaN(index)) {
-                this._index = NaN;
-                this.updateActionIndex(index);
-            }
-        });
+        this.representedObject.addEventListener(WI.Recording.Event.ProcessedAction, this._handleRecordingProcessedAction, this);
     }
 
     // Private
@@ -454,7 +430,7 @@ WI.RecordingContentView = class RecordingContentView extends WI.ContentView
     {
         let activated = WI.settings.showCanvasPath.value;
 
-        if (this._showPathButtonNavigationItem.activated !== activated && !this._processing)
+        if (this._showPathButtonNavigationItem.activated !== activated)
             this._generateContentCanvas2D(this._index);
 
         this._showPathButtonNavigationItem.activated = activated;
@@ -486,18 +462,6 @@ WI.RecordingContentView = class RecordingContentView extends WI.ContentView
         this._sliderValueElement.textContent = WI.UIString("%d of %d").format(visualActionIndex, visualActionIndexes.length);
     }
 
-    _updateProcessProgress(message, index)
-    {
-        if (this._processMessageTextView)
-            this._processMessageTextView.remove();
-
-        this._processMessageTextView = WI.createMessageTextView(message);
-        this.element.appendChild(this._processMessageTextView);
-
-        this._processProgressElement = this._processMessageTextView.appendChild(document.createElement("progress"));
-        this._processProgressElement.value = index / this.representedObject.actions.length;
-    }
-
     _showPathButtonClicked(event)
     {
         WI.settings.showCanvasPath.value = !this._showPathButtonNavigationItem.activated;
@@ -523,14 +487,10 @@ WI.RecordingContentView = class RecordingContentView extends WI.ContentView
         this.updateActionIndex(index);
     }
 
-    _handleRecordingProcessedActionSwizzle(event)
+    _handleRecordingProcessedAction(event)
     {
-        this._updateProcessProgress(WI.UIString("Loading Recording"), event.data.index);
-    }
-
-    _handleRecordingProcessedActionApply(event)
-    {
-        this._updateProcessProgress(WI.UIString("Processing Recording"), event.data.index);
+        this._sliderElement.max = this.representedObject.visualActionIndexes.length;
+        this._updateSliderValue();
     }
 };
 
diff --git a/Source/WebInspectorUI/UserInterface/Views/RecordingNavigationSidebarPanel.css b/Source/WebInspectorUI/UserInterface/Views/RecordingNavigationSidebarPanel.css
deleted file mode 100644 (file)
index 9b6970e..0000000
+++ /dev/null
@@ -1,40 +0,0 @@
-/*
- * Copyright (C) 2017 Apple Inc. All rights reserved.
- *
- * Redistribution and use in source and binary forms, with or without
- * modification, are permitted provided that the following conditions
- * are met:
- * 1. Redistributions of source code must retain the above copyright
- *    notice, this list of conditions and the following disclaimer.
- * 2. Redistributions in binary form must reproduce the above copyright
- *    notice, this list of conditions and the following disclaimer in the
- *    documentation and/or other materials provided with the distribution.
- *
- * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
- * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
- * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
- * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
- * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
- * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
- * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
- * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
- * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
- * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
- * THE POSSIBILITY OF SUCH DAMAGE.
- */
-
-.sidebar > .panel.navigation.recording > .content {
-    top: var(--navigation-bar-height);
-}
-
-.sidebar > .panel.navigation.recording > .content > .tree-outline {
-    min-height: 100%;
-}
-
-.sidebar > .panel.navigation.recording > .content > .tree-outline .item.folder-icon > .icon {
-    content: url(../Images/RenderingFrame.svg);
-}
-
-.sidebar > .panel.navigation.recording > .content > .tree-outline .item.folder-icon > .status {
-    line-height: 16px;
-}
diff --git a/Source/WebInspectorUI/UserInterface/Views/RecordingNavigationSidebarPanel.js b/Source/WebInspectorUI/UserInterface/Views/RecordingNavigationSidebarPanel.js
deleted file mode 100644 (file)
index ee21753..0000000
+++ /dev/null
@@ -1,188 +0,0 @@
-/*
- * Copyright (C) 2017 Apple Inc. All rights reserved.
- *
- * Redistribution and use in source and binary forms, with or without
- * modification, are permitted provided that the following conditions
- * are met:
- * 1. Redistributions of source code must retain the above copyright
- *    notice, this list of conditions and the following disclaimer.
- * 2. Redistributions in binary form must reproduce the above copyright
- *    notice, this list of conditions and the following disclaimer in the
- *    documentation and/or other materials provided with the distribution.
- *
- * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
- * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
- * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
- * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
- * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
- * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
- * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
- * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
- * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
- * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
- * THE POSSIBILITY OF SUCH DAMAGE.
- */
-
-WI.RecordingNavigationSidebarPanel = class RecordingNavigationSidebarPanel extends WI.NavigationSidebarPanel
-{
-    constructor()
-    {
-        super("recording", WI.UIString("Recording"));
-
-        this.contentTreeOutline.customIndent = true;
-        this.contentTreeOutline.registerScrollVirtualizer(this.contentView.element, 20);
-
-        this.recording = null;
-
-        this._importButton = null;
-        this._exportButton = null;
-    }
-
-    // Public
-
-    set recording(recording)
-    {
-        if (recording === this._recording)
-            return;
-
-        this.contentTreeOutline.removeChildren();
-
-        this._recording = recording;
-
-        this.updateEmptyContentPlaceholder(WI.UIString("No Recording Data"));
-
-        if (!this._recording) {
-            if (this._exportButton)
-                this._exportButton.disabled = true;
-            return;
-        }
-
-        this._recording.actions.then((actions) => {
-            if (recording !== this._recording)
-                return;
-
-            this.contentTreeOutline.element.dataset.indent = Number.countDigits(actions.length);
-
-            if (actions[0] instanceof WI.RecordingInitialStateAction)
-                this.contentTreeOutline.appendChild(new WI.RecordingActionTreeElement(actions[0], 0, this._recording.type));
-
-            let cumulativeActionIndex = 1;
-            this._recording.frames.forEach((frame, frameIndex) => {
-                let folder = new WI.FolderTreeElement(WI.UIString("Frame %d").format((frameIndex + 1).toLocaleString()));
-                this.contentTreeOutline.appendChild(folder);
-
-                for (let i = 0; i < frame.actions.length; ++i)
-                    folder.appendChild(new WI.RecordingActionTreeElement(frame.actions[i], cumulativeActionIndex + i, this._recording.type));
-
-                if (!isNaN(frame.duration)) {
-                    const higherResolution = true;
-                    folder.status = Number.secondsToString(frame.duration / 1000, higherResolution);
-                }
-
-                if (frame.incomplete)
-                    folder.subtitle = WI.UIString("Incomplete");
-
-                if (this._recording.frames.length === 1)
-                    folder.expand();
-
-                cumulativeActionIndex += frame.actions.length;
-            });
-
-            this._exportButton.disabled = !actions.length;
-
-            let index = this._recording[WI.RecordingNavigationSidebarPanel.SelectedActionIndexSymbol] || 0;
-            this.updateActionIndex(index);
-        });
-    }
-
-    updateActionIndex(index, options = {})
-    {
-        if (!this._recording)
-            return;
-
-        this._recording.actions.then((actions) => {
-            let recordingAction = actions[index];
-            console.assert(recordingAction, "Invalid recording action index.", index);
-            if (!recordingAction)
-                return;
-
-            let treeElement = this.contentTreeOutline.findTreeElement(recordingAction);
-            console.assert(treeElement, "Missing tree element for recording action.", recordingAction);
-            if (!treeElement)
-                return;
-
-            this._recording[WI.RecordingNavigationSidebarPanel.SelectedActionIndexSymbol] = index;
-
-            const omitFocus = false;
-            const selectedByUser = false;
-            const suppressOnSelect = true;
-            const suppressOnDeselect = true;
-            treeElement.revealAndSelect(omitFocus, selectedByUser, suppressOnSelect, suppressOnDeselect);
-        });
-    }
-
-    // Protected
-
-    initialLayout()
-    {
-        super.initialLayout();
-
-        const role = "button";
-
-        const importLabel = WI.UIString("Import");
-        let importNavigationItem = new WI.NavigationItem("recording-import", role, importLabel);
-
-        this._importButton = importNavigationItem.element.appendChild(document.createElement("button"));
-        this._importButton.textContent = importLabel;
-        this._importButton.addEventListener("click", () => { WI.canvasManager.importRecording(); });
-
-        const exportLabel = WI.UIString("Export");
-        let exportNavigationItem = new WI.NavigationItem("recording-export", role, exportLabel);
-
-        this._exportButton = exportNavigationItem.element.appendChild(document.createElement("button"));
-        this._exportButton.textContent = exportLabel;
-        this._exportButton.disabled = !this.contentTreeOutline.children.length;
-        this._exportButton.addEventListener("click", this._exportNavigationItemClicked.bind(this));
-
-        const element = null;
-        this.addSubview(new WI.NavigationBar(element, [importNavigationItem, exportNavigationItem]));
-
-        let filterFunction = (treeElement) => {
-            if (!(treeElement instanceof WI.RecordingActionTreeElement))
-                return false;
-
-            return treeElement.representedObject.isVisual;
-        };
-
-        const activatedByDefault = false;
-        const defaultToolTip = WI.UIString("Only show visual actions");
-        const activatedToolTip = WI.UIString("Show all actions");
-        this.filterBar.addFilterBarButton("recording-show-visual-only", filterFunction, activatedByDefault, defaultToolTip, activatedToolTip, "Images/Paint.svg", 15, 15);
-    }
-
-    matchTreeElementAgainstCustomFilters(treeElement)
-    {
-        // Keep recording frame tree elements.
-        if (treeElement instanceof WI.FolderTreeElement)
-            return true;
-
-        return super.matchTreeElementAgainstCustomFilters(treeElement);
-    }
-
-    // Private
-
-    _exportNavigationItemClicked(event)
-    {
-        if (!this._recording || !this.contentBrowser || !this.contentBrowser.currentContentView || !this.contentBrowser.currentContentView.supportsSave)
-            return;
-
-        const forceSaveAs = true;
-        WI.saveDataToFile(this.contentBrowser.currentContentView.saveData, forceSaveAs);
-    }
-};
-
-WI.RecordingNavigationSidebarPanel.SelectedActionIndexSymbol = Symbol("selected-action-index");
-
-WI.RecordingNavigationSidebarPanel.Event = {
-    Import: "recording-navigation-sidebar-panel-import",
-};