@@ -8,6 +8,12 @@ import { escapeRobotGlobPatterns, filterAsync, Mutex, truncateAndReplaceNewlines
88import { CONFIG_SECTION } from "./config" ;
99import { Range , Diagnostic , DiagnosticSeverity } from "vscode-languageclient/node" ;
1010
11+ const ROOT_TEST_ITEM_KEY = "__root__" ;
12+
13+ function rootSnapshotKey ( folder : vscode . WorkspaceFolder ) : string {
14+ return `${ ROOT_TEST_ITEM_KEY } :${ folder . uri . toString ( ) } ` ;
15+ }
16+
1117function arraysEqual < T > ( a : T [ ] | undefined , b : T [ ] | undefined ) : boolean {
1218 if ( a === b ) return true ;
1319 if ( a === undefined || b === undefined ) return false ;
@@ -186,7 +192,7 @@ export class TestControllerManager {
186192 private readonly updateEditorsMutex = new Mutex ( ) ;
187193 private readonly debugSessions = new Set < vscode . DebugSession > ( ) ;
188194 private readonly didChangedTimer = new Map < string , DidChangeEntry > ( ) ;
189- private refreshWorkspaceChangeTimer : DidChangeEntry | undefined ;
195+ private readonly refreshWorkspaceChangeTimer = new Map < string , DidChangeEntry > ( ) ;
190196 private diagnosticCollection = vscode . languages . createDiagnosticCollection ( "robotCode discovery" ) ;
191197 private activeStepDecorationType : vscode . TextEditorDecorationType ;
192198 showEditorRunDecorations = false ;
@@ -197,9 +203,8 @@ export class TestControllerManager {
197203 private readonly testItemByUri = new Map < string , vscode . TestItem > ( ) ;
198204 // Cache of the last set tag IDs per TestItem ID. Used for tag diffing in addOrUpdateTestItem.
199205 private readonly lastSetTags = new Map < string , string [ ] > ( ) ;
200- // Deep snapshot of the last successfully processed children per TestItem ID
201- // (key "__root__" for the workspace top level). Used in refreshItem for the result
202- // compare — if the new children are structurally identical, no UI update is triggered.
206+ // Snapshot of the last processed children per TestItem id (plus a per-folder
207+ // "__root__:<uri>" key) for refreshItem's result compare.
203208 private readonly lastKnownChildren = new Map < string , RobotTestItem [ ] | undefined > ( ) ;
204209 // CTS for the config-change refresh path. Cancelled on the next config change so an
205210 // in-flight refresh terminates before the next refreshWorkspace queues behind it on
@@ -260,22 +265,20 @@ export class TestControllerManager {
260265 this . activeStepDecorationType ,
261266 this . languageClientsManager . onClientStateChanged ( async ( event ) => {
262267 const folder = vscode . workspace . getWorkspaceFolder ( event . uri ) ;
263- if ( folder ) {
264- this . invalidateProfilesCache ( folder ) ;
265- }
266268 switch ( event . state ) {
267269 case ClientState . Running : {
268- // refresh()'s single-inflight handles cancellation of any predecessor.
269- await this . refresh ( ) ;
270+ await this . refreshWorkspace ( folder ) ;
270271 break ;
271272 }
272- case ClientState . Stopped : {
273- if ( folder ) this . removeWorkspaceFolderItems ( folder ) ;
274-
273+ case ClientState . Refreshed : {
274+ if ( folder && ! this . languageClientsManager . clients . has ( folder . uri . toString ( ) ) ) {
275+ await this . refreshWorkspace ( folder ) ;
276+ }
277+ if ( folder ) this . invalidateProfilesCache ( folder ) ;
278+ await this . updateRunProfiles ( ) ;
275279 break ;
276280 }
277281 }
278- await this . updateRunProfiles ( ) ;
279282 } ) ,
280283 vscode . workspace . onDidChangeConfiguration ( async ( event ) => {
281284 let testExplorerChanged = false ;
@@ -687,16 +690,16 @@ export class TestControllerManager {
687690 this . testController . items . delete ( item . id ) ;
688691 }
689692 }
693+
694+ this . lastKnownChildren . delete ( rootSnapshotKey ( folder ) ) ;
690695 }
691696
692697 dispose ( ) : void {
693698 this . didChangedTimer . forEach ( ( entry ) => entry . cancel ( ) ) ;
694699 this . didChangedTimer . clear ( ) ;
695700
696- if ( this . refreshWorkspaceChangeTimer ) {
697- this . refreshWorkspaceChangeTimer . cancel ( ) ;
698- this . refreshWorkspaceChangeTimer = undefined ;
699- }
701+ this . refreshWorkspaceChangeTimer . forEach ( ( entry ) => entry . cancel ( ) ) ;
702+ this . refreshWorkspaceChangeTimer . clear ( ) ;
700703
701704 this . robotItemIndex . clear ( ) ;
702705 this . testItemByUri . clear ( ) ;
@@ -902,6 +905,11 @@ export class TestControllerManager {
902905 token ?: vscode . CancellationToken ,
903906 ) : Promise < RobotCodeDiscoverResult > {
904907 if ( ! ( await this . languageClientsManager . isValidRobotEnvironmentInFolder ( folder ) ) ) {
908+ if ( prune ) {
909+ this . diagnosticCollection . forEach ( ( uri , _diagnostics , collection ) => {
910+ if ( vscode . workspace . getWorkspaceFolder ( uri ) === folder ) collection . delete ( uri ) ;
911+ } ) ;
912+ }
905913 return { } ;
906914 }
907915
@@ -1013,7 +1021,9 @@ export class TestControllerManager {
10131021 token ,
10141022 ) ;
10151023
1016- this . lastDiscoverResults . set ( folder , result ) ;
1024+ if ( result ?. items !== undefined ) {
1025+ this . lastDiscoverResults . set ( folder , result ) ;
1026+ }
10171027 // Index the freshly discovered subtree for O(1) findRobotItem lookups.
10181028 this . indexRobotTree ( result ?. items ) ;
10191029
@@ -1197,44 +1207,55 @@ export class TestControllerManager {
11971207 item . busy = false ;
11981208 }
11991209 } else {
1200- const rootKey = "__root__" ;
1210+ const keptIds = new Set < string > ( ) ;
1211+ const liveFolderKeys = new Set < string > ( ) ;
12011212
1202- // Phase 1: collect the new root children (re-discover if needed) without UI calls.
1203- const newRootChildren : RobotTestItem [ ] = [ ] ;
12041213 for ( const folder of vscode . workspace . workspaceFolders ?? [ ] ) {
12051214 if ( token ?. isCancellationRequested ) return ;
12061215 if ( ! this . isTestExplorerEnabledForWorkspace ( folder ) ) continue ;
12071216
1217+ const folderKey = rootSnapshotKey ( folder ) ;
1218+ liveFolderKeys . add ( folderKey ) ;
1219+
12081220 if ( this . robotTestItems . get ( folder ) === undefined || ! this . robotTestItems . get ( folder ) ?. valid ) {
12091221 const items = await this . getTestsFromWorkspaceFolder ( folder , token ) ;
1210- if ( items === undefined ) continue ;
1222+ if ( items === undefined ) {
1223+ this . lastKnownChildren . delete ( folderKey ) ;
1224+ continue ;
1225+ }
12111226 this . robotTestItems . set ( folder , new WorkspaceFolderEntry ( true , items ) ) ;
12121227 }
1213- const tests = this . robotTestItems . get ( folder ) ?. items ;
1214- if ( tests ) {
1215- for ( const t of tests ) newRootChildren . push ( t ) ;
1228+
1229+ const folderChildren = this . robotTestItems . get ( folder ) ?. items ;
1230+ if ( folderChildren === undefined ) {
1231+ this . lastKnownChildren . delete ( folderKey ) ;
1232+ continue ;
12161233 }
1217- }
12181234
1219- // Phase 2: result compare against the last snapshot — if identical, no UI update.
1220- const lastKnown = this . lastKnownChildren . get ( rootKey ) ;
1221- if ( robotItemListsEqual ( lastKnown , newRootChildren ) ) return ;
1235+ if ( robotItemListsEqual ( this . lastKnownChildren . get ( folderKey ) , folderChildren ) ) {
1236+ for ( const t of folderChildren ) keptIds . add ( t . id ) ;
1237+ continue ;
1238+ }
12221239
1223- // Phase 3: actual UI updates.
1224- const addedIds = new Set < string > ( ) ;
1225- for ( const test of newRootChildren ) {
1226- if ( token ?. isCancellationRequested ) return ;
1227- addedIds . add ( test . id ) ;
1228- const newItem = this . addOrUpdateTestItem ( undefined , test ) ;
1229- // Recursion path from the workspace refresh → skip the per-document discover.
1230- await this . refreshItem ( newItem , token , true ) ;
1231- if ( newItem . canResolveChildren && newItem . children . size === 0 && newItem . error === undefined ) {
1232- addedIds . delete ( newItem . id ) ;
1240+ for ( const test of folderChildren ) {
1241+ if ( token ?. isCancellationRequested ) return ;
1242+ const newItem = this . addOrUpdateTestItem ( undefined , test ) ;
1243+ // Recursion path from the workspace refresh → skip the per-document discover.
1244+ await this . refreshItem ( newItem , token , true ) ;
1245+ if ( ! ( newItem . canResolveChildren && newItem . children . size === 0 && newItem . error === undefined ) ) {
1246+ keptIds . add ( test . id ) ;
1247+ }
12331248 }
1249+ this . lastKnownChildren . set ( folderKey , this . snapshotChildren ( folderChildren ) ) ;
12341250 }
12351251
1236- this . removeNotAddedTestItems ( undefined , addedIds ) ;
1237- this . lastKnownChildren . set ( rootKey , this . snapshotChildren ( newRootChildren ) ) ;
1252+ for ( const key of [ ...this . lastKnownChildren . keys ( ) ] ) {
1253+ if ( key . startsWith ( `${ ROOT_TEST_ITEM_KEY } :` ) && ! liveFolderKeys . has ( key ) ) {
1254+ this . lastKnownChildren . delete ( key ) ;
1255+ }
1256+ }
1257+
1258+ this . removeNotAddedTestItems ( undefined , keptIds ) ;
12381259 }
12391260 }
12401261
@@ -1293,7 +1314,7 @@ export class TestControllerManager {
12931314 testItem . description = newDescription ;
12941315 }
12951316
1296- if ( robotTestItem . error !== undefined && testItem . error !== robotTestItem . error ) {
1317+ if ( testItem . error !== robotTestItem . error ) {
12971318 testItem . error = robotTestItem . error ;
12981319 }
12991320
@@ -1337,6 +1358,7 @@ export class TestControllerManager {
13371358 }
13381359 this . lastSetTags . delete ( i ) ;
13391360 this . robotItemIndex . delete ( i ) ;
1361+ this . lastKnownChildren . delete ( i ) ;
13401362 } ) ;
13411363
13421364 return itemsToRemove . size > 0 ;
@@ -1420,27 +1442,27 @@ export class TestControllerManager {
14201442
14211443 if ( this . didChangedTimer . has ( uri . toString ( ) ) ) return ;
14221444
1423- if ( this . refreshWorkspaceChangeTimer ) {
1424- this . refreshWorkspaceChangeTimer . cancel ( ) ;
1425- this . refreshWorkspaceChangeTimer = undefined ;
1426- }
1445+ const workspaceKey = workspace . uri . toString ( ) ;
1446+ this . refreshWorkspaceChangeTimer . get ( workspaceKey ) ?. cancel ( ) ;
14271447
14281448 const cancelationTokenSource = new vscode . CancellationTokenSource ( ) ;
14291449
1430- this . refreshWorkspaceChangeTimer = new DidChangeEntry (
1431- setTimeout ( ( ) => {
1432- this . refreshWorkspace ( workspace , cancelationTokenSource . token ) . then (
1433- ( ) => undefined ,
1434- ( ) => undefined ,
1435- ) ;
1436- } , TestControllerManager . DEBOUNCE_MS ) ,
1437- cancelationTokenSource ,
1450+ this . refreshWorkspaceChangeTimer . set (
1451+ workspaceKey ,
1452+ new DidChangeEntry (
1453+ setTimeout ( ( ) => {
1454+ this . refreshWorkspaceChangeTimer . delete ( workspaceKey ) ;
1455+ this . refreshWorkspace ( workspace , cancelationTokenSource . token ) . then (
1456+ ( ) => undefined ,
1457+ ( ) => undefined ,
1458+ ) ;
1459+ } , TestControllerManager . DEBOUNCE_MS ) ,
1460+ cancelationTokenSource ,
1461+ ) ,
14381462 ) ;
14391463 } else {
1440- if ( this . refreshWorkspaceChangeTimer ) {
1441- this . refreshWorkspaceChangeTimer . cancel ( ) ;
1442- this . refreshWorkspaceChangeTimer = undefined ;
1443- }
1464+ this . refreshWorkspaceChangeTimer . forEach ( ( entry ) => entry . cancel ( ) ) ;
1465+ this . refreshWorkspaceChangeTimer . clear ( ) ;
14441466
14451467 // refresh()'s single-inflight handles cancellation of any predecessor.
14461468 this . refresh ( ) . then (
@@ -1524,17 +1546,23 @@ export class TestControllerManager {
15241546 const testRun = this . testController . createTestRun ( request , undefined ) ;
15251547 let run_started = false ;
15261548
1527- token . onCancellationRequested ( async ( _ ) => {
1528- for ( const e of this . testRunInfos . keys ( ) ) {
1529- for ( const session of this . debugSessions ) {
1530- if ( session . configuration . runId === e ) {
1531- await vscode . debug . stopDebugging ( session ) ;
1532- }
1549+ const myRunIds = new Set < string > ( ) ;
1550+ let cancelled = false ;
1551+ const stopMyRunSessions = async ( ) => {
1552+ for ( const session of this . debugSessions ) {
1553+ if ( myRunIds . has ( session . configuration . runId ) ) {
1554+ await vscode . debug . stopDebugging ( session ) ;
15331555 }
15341556 }
1557+ } ;
1558+
1559+ token . onCancellationRequested ( async ( _ ) => {
1560+ cancelled = true ;
1561+ await stopMyRunSessions ( ) ;
15351562 } ) ;
15361563
15371564 for ( const [ folder , testItems ] of included ) {
1565+ if ( cancelled ) break ;
15381566 if (
15391567 request . profile !== undefined &&
15401568 request . profile . tag !== undefined &&
@@ -1543,6 +1571,7 @@ export class TestControllerManager {
15431571 continue ;
15441572
15451573 const runId = TestControllerManager . nextRunId ( ) ;
1574+ myRunIds . add ( runId ) ;
15461575 this . testRunInfos . set ( runId , new TestRunInfo ( testRun ) ) ;
15471576
15481577 const options : vscode . DebugSessionOptions = {
@@ -1573,6 +1602,7 @@ export class TestControllerManager {
15731602 profiles ,
15741603 testConfiguration ,
15751604 ) ;
1605+ if ( ! started ) this . testRunInfos . delete ( runId ) ;
15761606 run_started = run_started || started ;
15771607 } else {
15781608 const includedInWs = testItems
@@ -1643,8 +1673,14 @@ export class TestControllerManager {
16431673 testConfiguration ,
16441674 ) ;
16451675
1676+ if ( ! started ) this . testRunInfos . delete ( runId ) ;
16461677 run_started = run_started || started ;
16471678 }
1679+
1680+ if ( cancelled ) {
1681+ await stopMyRunSessions ( ) ;
1682+ break ;
1683+ }
16481684 }
16491685
16501686 if ( ! run_started ) {
0 commit comments