diff --git a/artifacts/perf/issue38-refresh-spinner.md b/artifacts/perf/issue38-refresh-spinner.md
new file mode 100644
index 00000000..914a118c
--- /dev/null
+++ b/artifacts/perf/issue38-refresh-spinner.md
@@ -0,0 +1,62 @@
+# Runtime Benchmarks
+
+- Baseline ref: `a6f60e0ce830c4649ac34fc05e5a1799ec91d151`
+- Current source: working tree
+- Node: `v25.2.0`
+- Selection mode: `scenario-list`
+- Declared suite: `user-flow`
+- Result-count validation: `1 rows, suite-consistent=true, all-user-flow=true`
+
+## Machine Profile
+
+| Category | Field | Value |
+| --- | --- | --- |
+| Host | Hostname | n00ne-AERO-17-YD |
+| Host | OS | Ubuntu 22.04.5 LTS |
+| Host | Kernel | 6.8.0-124-generic |
+| Host | Architecture | x64 |
+| Host | Load Average | 4.41, 4.11, 4.36 |
+| Host | Available Parallelism | - |
+| CPU | Model | Intel(R) Core(TM) i9-14900HX |
+| CPU | Vendor | GenuineIntel |
+| CPU | Topology | 16 logical CPU(s), 2 thread(s)/core, 8 core(s)/socket, 1 socket(s), 1 NUMA node(s) |
+| CPU | Frequency | 800 MHz to 5,800 MHz |
+| CPU | Cache | L1d 384 KiB (8 instances), L1i 256 KiB (8 instances), L2 16 MiB (8 instances), L3 36 MiB (1 instance) |
+| Memory | Total RAM | 62.51 GiB (`67,119,767,552 bytes`) |
+| Memory | Available At Collection | 11.59 GiB (`12,448,333,824 bytes`) |
+| Memory | Online Physical RAM | 66.00 GiB (`70,866,960,384 bytes`) |
+| Memory | Swap | total 120 GiB (`128,848,973,824 bytes`); free 99.16 GiB (`106,472,206,336 bytes`) |
+| Memory | DMI / SPD | Unavailable: /sys/firmware/dmi/tables/smbios_entry_point: Permission denied /dev/mem: Permission denied |
+| Storage | Root Device | nvme1n1 (Samsung SSD 9100 PRO 4TB), 3.64 TiB (`4,000,787,030,016 bytes`), transport nvme, rotational=false, readOnly=false |
+
+## Scenario Model
+
+| Scenario | Kind | User flow | Measurement scope | Input model |
+| --- | --- | --- | --- | --- |
+| tree-view-repeat-click-burst | user-flow | Repeatedly click the same view button while the tree state is still mutating. | Overlapping command handling and workspace-state writes. | Command burst against the extension command handlers in a VS Code event harness. |
+
+## Metric Model
+
+| Table | Value model | Accuracy model |
+| --- | --- | --- |
+| Latency | Wall-clock elapsed time around each harness flow iteration, summarized as min/p50/p90/p95/max. | Exact for each sampled iteration in this run. |
+| Profiled RSS Burst | Difference between the isolated scenario worker RSS measured immediately before the flow and that worker iteration's OS high-water-mark peak RSS. | Exact for the measured worker iteration, using `process.memoryUsage().rss` at flow start and `process.resourceUsage().maxRSS` for the peak. |
+| Profiled Peak RSS | Highest process RSS reached by each isolated scenario worker iteration. | Exact worker-process high-water mark from `process.resourceUsage().maxRSS`. |
+
+## Latency
+
+| Scenario | Kind | Baseline p50 ms | Current p50 ms | Baseline p90 ms | Current p90 ms | Baseline p95 ms | Current p95 ms |
+| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
+| tree-view-repeat-click-burst | user-flow | 0.25 | 0.3 | 0.35 | 0.53 | 0.41 | 0.61 |
+
+## Profiled RSS Burst
+
+| Scenario | Kind | Baseline p50 MiB | Current p50 MiB | Baseline p90 MiB | Current p90 MiB | Baseline p95 MiB | Current p95 MiB | Baseline Max MiB | Current Max MiB |
+| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
+| tree-view-repeat-click-burst | user-flow | 1.13 | 1.63 | 1.38 | 1.88 | 1.5 | 2.13 | 1.5 | 2.13 |
+
+## Profiled Peak RSS
+
+| Scenario | Kind | Baseline p50 RSS MiB | Current p50 RSS MiB | Baseline p90 RSS MiB | Current p90 RSS MiB | Baseline p95 RSS MiB | Current p95 RSS MiB | Baseline Max RSS MiB | Current Max RSS MiB |
+| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
+| tree-view-repeat-click-burst | user-flow | 56.55 | 57.12 | 56.93 | 57.61 | 57 | 57.69 | 57 | 57.69 |
diff --git a/package.json b/package.json
index 71a0ad1b..1447f100 100644
--- a/package.json
+++ b/package.json
@@ -383,13 +383,19 @@
"command": "better-todo-tree.treeStateBusy",
"title": "%better-todo-tree.command.treeStateBusy.title%",
"category": "%better-todo-tree.command.category%",
- "icon": "$(loading)"
+ "icon": {
+ "light": "resources/button-icons/refresh-spin-light.svg",
+ "dark": "resources/button-icons/refresh-spin-dark.svg"
+ }
},
{
"command": "better-todo-tree.scanBusy",
"title": "%better-todo-tree.command.scanBusy.title%",
"category": "%better-todo-tree.command.category%",
- "icon": "$(loading)"
+ "icon": {
+ "light": "resources/button-icons/refresh-spin-light.svg",
+ "dark": "resources/button-icons/refresh-spin-dark.svg"
+ }
},
{
"command": "better-todo-tree.filter",
diff --git a/resources/button-icons/refresh-spin-dark.svg b/resources/button-icons/refresh-spin-dark.svg
new file mode 100644
index 00000000..0b320575
--- /dev/null
+++ b/resources/button-icons/refresh-spin-dark.svg
@@ -0,0 +1,7 @@
+
diff --git a/resources/button-icons/refresh-spin-light.svg b/resources/button-icons/refresh-spin-light.svg
new file mode 100644
index 00000000..0cc8f08b
--- /dev/null
+++ b/resources/button-icons/refresh-spin-light.svg
@@ -0,0 +1,7 @@
+
diff --git a/src/extension.js b/src/extension.js
index 0fd5b332..5b6256cb 100644
--- a/src/extension.js
+++ b/src/extension.js
@@ -562,7 +562,7 @@ function activate( context )
{
var message = buildScanProgressMessage( state, snapshot );
- statusBarIndicator.text = "$(loading~spin) " + identity.DISPLAY_NAME + " " + snapshot.percent + "%";
+ statusBarIndicator.text = identity.STATUS_BUSY_ICON_LABEL + " " + identity.DISPLAY_NAME + " " + snapshot.percent + "%";
statusBarIndicator.show();
statusBarIndicator.command = identity.COMMANDS.stopScan;
statusBarIndicator.tooltip = message || 'Click to interrupt scan';
@@ -1119,7 +1119,7 @@ function activate( context )
interrupted = false;
cancelledScanGenerations.delete( activeScanGeneration );
- statusBarIndicator.text = identity.DISPLAY_NAME + ": Scanning...";
+ statusBarIndicator.text = identity.STATUS_BUSY_ICON_LABEL + " " + identity.DISPLAY_NAME + ": Scanning...";
statusBarIndicator.show();
statusBarIndicator.command = identity.COMMANDS.stopScan;
statusBarIndicator.tooltip = "Click to interrupt scan";
diff --git a/src/extensionIdentity.js b/src/extensionIdentity.js
index 9d36334b..cadd7302 100644
--- a/src/extensionIdentity.js
+++ b/src/extensionIdentity.js
@@ -5,6 +5,8 @@ var LEGACY_NAMESPACE = 'todo-tree';
var DISPLAY_NAME = 'Better Todo Tree';
var LEGACY_DISPLAY_NAME = 'Todo Tree';
+var STATUS_BUSY_ICON = 'sync~spin';
+var STATUS_BUSY_ICON_LABEL = '$(' + STATUS_BUSY_ICON + ')';
var VIEW_CONTAINER_ID = 'todo-tree-container';
var VIEW_ID = 'todo-tree-view';
@@ -242,6 +244,8 @@ module.exports.CURRENT_NAMESPACE = CURRENT_NAMESPACE;
module.exports.LEGACY_NAMESPACE = LEGACY_NAMESPACE;
module.exports.DISPLAY_NAME = DISPLAY_NAME;
module.exports.LEGACY_DISPLAY_NAME = LEGACY_DISPLAY_NAME;
+module.exports.STATUS_BUSY_ICON = STATUS_BUSY_ICON;
+module.exports.STATUS_BUSY_ICON_LABEL = STATUS_BUSY_ICON_LABEL;
module.exports.VIEW_CONTAINER_ID = VIEW_CONTAINER_ID;
module.exports.VIEW_ID = VIEW_ID;
module.exports.EXPORT_SCHEME = EXPORT_SCHEME;
diff --git a/test/extension.scan-parity.test.js b/test/extension.scan-parity.test.js
index 8c052716..8ae812c2 100644
--- a/test/extension.scan-parity.test.js
+++ b/test/extension.scan-parity.test.js
@@ -3585,6 +3585,7 @@ QUnit.test( "workspace scans publish progress, current target, and clear the tre
return typeof report.message === 'string' && report.message.indexOf( 'tracked.js' ) >= 0;
} ), true );
assert.equal( harness.vscode.treeViews[ 0 ].message.indexOf( 'tracked.js' ) >= 0, true );
+ assert.equal( harness.vscode.statusBarItems[ 0 ].text.indexOf( '$(sync~spin) Better Todo Tree' ), 0 );
assert.equal( harness.vscode.statusBarItems[ 0 ].text.indexOf( 'Better Todo Tree' ) >= 0, true );
assert.equal( harness.vscode.statusBarItems[ 0 ].text.indexOf( '100%' ) === -1, true );
@@ -3617,6 +3618,7 @@ QUnit.test( "scan progress cancellation interrupts the active scan and surfaces
return matrixHelpers.flushAsyncWork().then( function()
{
assert.equal( harness.vscode.progressSessions.length, 1 );
+ assert.equal( harness.vscode.statusBarItems[ 0 ].text.indexOf( '$(sync~spin) Better Todo Tree' ), 0 );
harness.vscode.progressSessions[ 0 ].cancel();
return matrixHelpers.flushAsyncWork();
} ).then( function()
diff --git a/test/package.manifest.test.js b/test/package.manifest.test.js
index a037b394..3f54c98e 100644
--- a/test/package.manifest.test.js
+++ b/test/package.manifest.test.js
@@ -12,6 +12,19 @@ function readPackageNls( fileName )
return JSON.parse( fs.readFileSync( path.join( __dirname, '..', fileName ), 'utf8' ) );
}
+function readWorkspaceText( relativePath )
+{
+ return fs.readFileSync( path.join( __dirname, '..', relativePath ), 'utf8' );
+}
+
+function findCommandContribution( packageJson, commandId )
+{
+ return packageJson.contributes.commands.find( function( entry )
+ {
+ return entry.command === commandId;
+ } );
+}
+
function getConfigurationProperty( propertyName )
{
return languageMatrix.findConfigurationProperty( propertyName );
@@ -193,24 +206,39 @@ QUnit.test( 'busy and composite tree commands have localization entries in both
assert.notOk( chinese[ 'better-todo-tree.command.treeStateBusy.title' ].indexOf( '$(' ) >= 0 );
} );
-QUnit.test( 'busy placeholder commands use static product icons with plain localized titles', function( assert )
+QUnit.test( 'issue #38 busy placeholder commands animate only their svg glyphs', function( assert )
{
var packageJson = readPackageJson();
- var treeStateBusy = packageJson.contributes.commands.find( function( entry )
- {
- return entry.command === 'better-todo-tree.treeStateBusy';
- } );
- var scanBusy = packageJson.contributes.commands.find( function( entry )
- {
- return entry.command === 'better-todo-tree.scanBusy';
- } );
-
- assert.equal( treeStateBusy.icon, '$(loading)' );
- assert.equal( scanBusy.icon, '$(loading)' );
- assert.equal( treeStateBusy.icon.indexOf( '~spin' ), -1 );
- assert.equal( scanBusy.icon.indexOf( '~spin' ), -1 );
+ var expectedBusyIcon = {
+ light: 'resources/button-icons/refresh-spin-light.svg',
+ dark: 'resources/button-icons/refresh-spin-dark.svg'
+ };
+ var treeStateBusy = findCommandContribution( packageJson, 'better-todo-tree.treeStateBusy' );
+ var scanBusy = findCommandContribution( packageJson, 'better-todo-tree.scanBusy' );
+
+ assert.deepEqual( treeStateBusy.icon, expectedBusyIcon );
+ assert.deepEqual( scanBusy.icon, expectedBusyIcon );
+ assert.equal( JSON.stringify( treeStateBusy.icon ).indexOf( '~spin' ), -1 );
+ assert.equal( JSON.stringify( scanBusy.icon ).indexOf( '~spin' ), -1 );
+ assert.equal( JSON.stringify( treeStateBusy.icon ).indexOf( 'loading' ), -1 );
+ assert.equal( JSON.stringify( scanBusy.icon ).indexOf( 'loading' ), -1 );
assert.equal( treeStateBusy.title, '%better-todo-tree.command.treeStateBusy.title%' );
assert.equal( scanBusy.title, '%better-todo-tree.command.scanBusy.title%' );
+
+ Object.keys( expectedBusyIcon ).forEach( function( theme )
+ {
+ var svg = readWorkspaceText( expectedBusyIcon[ theme ] );
+ var groupStart = svg.indexOf( '' );
+ var groupEnd = svg.indexOf( '' );
+ var animateTransform = svg.indexOf( '= 0, theme + ' icon has a glyph group' );
+ assert.ok( groupEnd > groupStart, theme + ' icon closes the glyph group' );
+ assert.ok( animateTransform > groupStart, theme + ' icon animates inside the glyph group' );
+ assert.ok( animateTransform < groupEnd, theme + ' icon animation stays inside the glyph group' );
+ assert.equal( svg.substring( 0, groupStart ).indexOf( '