Skip to content

Commit ab71143

Browse files
authored
Merge pull request #169 from rajbos/copilot/update-diagnostics-loading-behavior
Progressive loading for diagnostics view - eliminate 10-30s UI blocking
2 parents bf815ba + 8a013a6 commit ab71143

2 files changed

Lines changed: 223 additions & 93 deletions

File tree

src/extension.ts

Lines changed: 116 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,8 @@ class CopilotTokenTracker implements vscode.Disposable {
201201
private diagnosticsHasLoadedFiles: boolean = false;
202202
// Cache of the last loaded detailed session files for diagnostics view
203203
private diagnosticsCachedFiles: SessionFileDetails[] = [];
204+
// Cache of the last diagnostic report text for copy/issue operations
205+
private lastDiagnosticReport: string = '';
204206
private logViewerPanel?: vscode.WebviewPanel;
205207
private statusBarItem: vscode.StatusBarItem;
206208
private readonly extensionUri: vscode.Uri;
@@ -3469,91 +3471,16 @@ class CopilotTokenTracker implements vscode.Disposable {
34693471
public async showDiagnosticReport(): Promise<void> {
34703472
this.log('🔍 Opening Diagnostic Report');
34713473

3472-
// If panel already exists, just reveal it and update content
3474+
// If panel already exists, just reveal it and trigger a refresh in the background
34733475
if (this.diagnosticsPanel) {
34743476
this.diagnosticsPanel.reveal();
34753477
this.log('🔍 Diagnostic Report revealed (already exists)');
3476-
// Optionally, refresh content if needed
3477-
const report = await this.generateDiagnosticReport();
3478-
const sessionFiles = await this.getCopilotSessionFiles();
3479-
const sessionFileData: { file: string; size: number; modified: string }[] = [];
3480-
for (const file of sessionFiles.slice(0, 20)) {
3481-
try {
3482-
const stat = await fs.promises.stat(file);
3483-
sessionFileData.push({
3484-
file,
3485-
size: stat.size,
3486-
modified: stat.mtime.toISOString()
3487-
});
3488-
} catch {
3489-
// Skip inaccessible files
3490-
}
3491-
}
3492-
// Build folder counts grouped by top-level VS Code user folder (editor roots)
3493-
const dirCounts = new Map<string, number>();
3494-
const pathModule = require('path');
3495-
for (const file of sessionFiles) {
3496-
// Walk up the path to find the 'User' directory which is the canonical editor folder root
3497-
const parts = file.split(/[\\\/]/);
3498-
// Find index of 'User' folder in path parts (case-insensitive)
3499-
const userIdx = parts.findIndex(p => p.toLowerCase() === 'user');
3500-
let editorRoot = '';
3501-
if (userIdx > 0) {
3502-
// Reconstruct path including 'User' and the next folder (e.g., .../Roaming/Code/User/workspaceStorage)
3503-
// Include two extra levels after the 'User' segment so we can distinguish
3504-
// between 'User\\workspaceStorage' and 'User\\globalStorage'.
3505-
const rootParts = parts.slice(0, Math.min(parts.length, userIdx + 2));
3506-
editorRoot = pathModule.join(...rootParts);
3507-
} else {
3508-
// Fallback: use parent dir of the file
3509-
editorRoot = pathModule.dirname(file);
3510-
}
3511-
3512-
dirCounts.set(editorRoot, (dirCounts.get(editorRoot) || 0) + 1);
3513-
}
3514-
const sessionFolders = Array.from(dirCounts.entries()).map(([dir, count]) => ({ dir, count, editorName: this.getEditorTypeFromPath(dir) }));
3515-
const backendStorageInfo = await this.getBackendStorageInfo();
3516-
this.diagnosticsPanel.webview.html = this.getDiagnosticReportHtml(this.diagnosticsPanel.webview, report, sessionFileData, [], sessionFolders, backendStorageInfo);
3517-
this.loadSessionFilesInBackground(this.diagnosticsPanel, sessionFiles);
3478+
// Load data in background and update the webview
3479+
this.loadDiagnosticDataInBackground(this.diagnosticsPanel);
35183480
return;
35193481
}
35203482

3521-
const report = await this.generateDiagnosticReport();
3522-
const sessionFiles = await this.getCopilotSessionFiles();
3523-
const sessionFileData: { file: string; size: number; modified: string }[] = [];
3524-
for (const file of sessionFiles.slice(0, 20)) {
3525-
try {
3526-
const stat = await fs.promises.stat(file);
3527-
sessionFileData.push({
3528-
file,
3529-
size: stat.size,
3530-
modified: stat.mtime.toISOString()
3531-
});
3532-
} catch {
3533-
// Skip inaccessible files
3534-
}
3535-
}
3536-
3537-
// Build folder counts grouped by top-level VS Code user folder (editor roots)
3538-
const dirCounts = new Map<string, number>();
3539-
const pathModule = require('path');
3540-
for (const file of sessionFiles) {
3541-
const parts = file.split(/[\\\/]/);
3542-
const userIdx = parts.findIndex(p => p.toLowerCase() === 'user');
3543-
let editorRoot = '';
3544-
if (userIdx > 0) {
3545-
// Include 'User' plus one following folder (e.g., 'User\\workspaceStorage' or 'User\\globalStorage')
3546-
const rootParts = parts.slice(0, Math.min(parts.length, userIdx + 2));
3547-
editorRoot = pathModule.join(...rootParts);
3548-
} else {
3549-
editorRoot = pathModule.dirname(file);
3550-
}
3551-
dirCounts.set(editorRoot, (dirCounts.get(editorRoot) || 0) + 1);
3552-
}
3553-
const sessionFolders = Array.from(dirCounts.entries()).map(([dir, count]) => ({ dir, count, editorName: this.getEditorNameFromRoot(dir) }));
3554-
3555-
const backendStorageInfo = await this.getBackendStorageInfo();
3556-
3483+
// Create the panel immediately with loading state
35573484
this.diagnosticsPanel = vscode.window.createWebviewPanel(
35583485
'copilotTokenDiagnostics',
35593486
'Diagnostic Report',
@@ -3568,21 +3495,30 @@ class CopilotTokenTracker implements vscode.Disposable {
35683495
}
35693496
);
35703497

3571-
this.log('✅ Diagnostic Report created successfully');
3572-
3573-
// Set the HTML content immediately with empty session files (shows loading state)
3574-
this.diagnosticsPanel.webview.html = this.getDiagnosticReportHtml(this.diagnosticsPanel.webview, report, sessionFileData, [], sessionFolders, backendStorageInfo);
3498+
this.log('✅ Diagnostic Report panel created');
3499+
3500+
// Set the HTML content immediately with loading state
3501+
// Note: "Loading..." is the agreed contract between backend and frontend
3502+
// The webview checks for this value to show a loading indicator
3503+
this.diagnosticsPanel.webview.html = this.getDiagnosticReportHtml(
3504+
this.diagnosticsPanel.webview,
3505+
'Loading...', // Placeholder report
3506+
[], // Empty session files
3507+
[], // Empty detailed session files
3508+
[], // Empty session folders
3509+
null // No backend info yet
3510+
);
35753511

35763512
// Handle messages from the webview
35773513
this.diagnosticsPanel.webview.onDidReceiveMessage(async (message) => {
35783514
this.log(`DEBUG Diagnostics webview message: ${JSON.stringify(message)}`);
35793515
switch (message.command) {
35803516
case 'copyReport':
3581-
await vscode.env.clipboard.writeText(report);
3517+
await vscode.env.clipboard.writeText(this.lastDiagnosticReport);
35823518
vscode.window.showInformationMessage('Diagnostic report copied to clipboard');
35833519
break;
35843520
case 'openIssue':
3585-
await vscode.env.clipboard.writeText(report);
3521+
await vscode.env.clipboard.writeText(this.lastDiagnosticReport);
35863522
vscode.window.showInformationMessage('Diagnostic report copied to clipboard. Please paste it into the GitHub issue.');
35873523
const shortBody = encodeURIComponent('The diagnostic report has been copied to the clipboard. Please paste it below.');
35883524
const issueUrl = `${this.getRepositoryUrl()}/issues/new?body=${shortBody}`;
@@ -3676,8 +3612,100 @@ class CopilotTokenTracker implements vscode.Disposable {
36763612
this.diagnosticsPanel = undefined;
36773613
});
36783614

3679-
// Load detailed session files in the background and send to webview when ready
3680-
this.loadSessionFilesInBackground(this.diagnosticsPanel, sessionFiles);
3615+
// Load data in background and update the webview when ready
3616+
this.loadDiagnosticDataInBackground(this.diagnosticsPanel);
3617+
}
3618+
3619+
/**
3620+
* Load all diagnostic data in the background and update the webview progressively.
3621+
*/
3622+
private async loadDiagnosticDataInBackground(panel: vscode.WebviewPanel): Promise<void> {
3623+
try {
3624+
this.log('🔄 Loading diagnostic data in background...');
3625+
3626+
// Load the diagnostic report
3627+
const report = await this.generateDiagnosticReport();
3628+
this.lastDiagnosticReport = report;
3629+
3630+
// Get session files
3631+
const sessionFiles = await this.getCopilotSessionFiles();
3632+
3633+
// Get first 20 session files with stats (quick preview)
3634+
const sessionFileData: { file: string; size: number; modified: string }[] = [];
3635+
for (const file of sessionFiles.slice(0, 20)) {
3636+
try {
3637+
const stat = await fs.promises.stat(file);
3638+
sessionFileData.push({
3639+
file,
3640+
size: stat.size,
3641+
modified: stat.mtime.toISOString()
3642+
});
3643+
} catch {
3644+
// Skip inaccessible files
3645+
}
3646+
}
3647+
3648+
// Build folder counts grouped by top-level VS Code user folder (editor roots)
3649+
const dirCounts = new Map<string, number>();
3650+
const pathModule = require('path');
3651+
for (const file of sessionFiles) {
3652+
const parts = file.split(/[\\\/]/);
3653+
const userIdx = parts.findIndex((p: string) => p.toLowerCase() === 'user');
3654+
let editorRoot = '';
3655+
if (userIdx > 0) {
3656+
const rootParts = parts.slice(0, Math.min(parts.length, userIdx + 2));
3657+
editorRoot = pathModule.join(...rootParts);
3658+
} else {
3659+
editorRoot = pathModule.dirname(file);
3660+
}
3661+
dirCounts.set(editorRoot, (dirCounts.get(editorRoot) || 0) + 1);
3662+
}
3663+
const sessionFolders = Array.from(dirCounts.entries()).map(([dir, count]) => ({
3664+
dir,
3665+
count,
3666+
editorName: this.getEditorNameFromRoot(dir)
3667+
}));
3668+
3669+
// Get backend storage info
3670+
const backendStorageInfo = await this.getBackendStorageInfo();
3671+
3672+
// Check if panel is still open before updating
3673+
if (!this.isPanelOpen(panel)) {
3674+
this.log('Diagnostic panel closed during data load, aborting update');
3675+
return;
3676+
}
3677+
3678+
// Send the loaded data to the webview
3679+
panel.webview.postMessage({
3680+
command: 'diagnosticDataLoaded',
3681+
report,
3682+
sessionFiles: sessionFileData,
3683+
sessionFolders,
3684+
backendStorageInfo
3685+
});
3686+
3687+
this.log('✅ Diagnostic data loaded and sent to webview');
3688+
3689+
// Now load detailed session files in the background
3690+
this.loadSessionFilesInBackground(panel, sessionFiles);
3691+
} catch (error) {
3692+
this.error(`Failed to load diagnostic data: ${error}`);
3693+
// Send error to webview if panel is still open
3694+
if (this.isPanelOpen(panel)) {
3695+
panel.webview.postMessage({
3696+
command: 'diagnosticDataError',
3697+
error: String(error)
3698+
});
3699+
}
3700+
}
3701+
}
3702+
3703+
/**
3704+
* Check if a webview panel is still open and accessible.
3705+
* A panel is considered open if its viewColumn is defined.
3706+
*/
3707+
private isPanelOpen(panel: vscode.WebviewPanel): boolean {
3708+
return panel.viewColumn !== undefined;
36813709
}
36823710

36833711
/**
@@ -3711,7 +3739,7 @@ class CopilotTokenTracker implements vscode.Disposable {
37113739
// Process up to 500 most recent session files
37123740
for (const file of sortedFiles.slice(0, 500)) {
37133741
// Check if panel was disposed
3714-
if (!panel.visible && panel.viewColumn === undefined) {
3742+
if (!this.isPanelOpen(panel)) {
37153743
this.log('Diagnostic panel closed, stopping background load');
37163744
return;
37173745
}

src/webview/diagnostics/main.ts

Lines changed: 107 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
// Diagnostics Report webview with tabbed interface
22
import { buttonHtml } from '../shared/buttonConfig';
33

4+
// Constants
5+
const LOADING_PLACEHOLDER = 'Loading...';
6+
const SESSION_FILES_SECTION_REGEX = /Session File Locations \(first 20\):[\s\S]*?(?=\n\s*\n|={70})/;
7+
const LOADING_MESSAGE = `⏳ Loading diagnostic data...
8+
9+
This may take a few moments depending on the number of session files.
10+
The view will automatically update when data is ready.`;
11+
412
type ContextReferenceUsage = {
513
file: number;
614
selection: number;
@@ -88,6 +96,10 @@ function escapeHtml(text: string): string {
8896
.replace(/'/g, '&#039;');
8997
}
9098

99+
function removeSessionFilesSection(reportText: string): string {
100+
return reportText.replace(SESSION_FILES_SECTION_REGEX, '');
101+
}
102+
91103
function formatDate(isoString: string | null): string {
92104
if (!isoString) { return 'N/A'; }
93105
try {
@@ -495,10 +507,16 @@ function renderLayout(data: DiagnosticsData): void {
495507

496508
// Remove session files section from report text (it's shown separately as clickable links)
497509
let escapedReport = escapeHtml(data.report);
498-
// Remove the old session files list from the report text if present
499-
const sessionMatch = escapedReport.match(/Session File Locations \(first 20\):[\s\S]*?(?=\n\s*\n|={70})/);
500-
if (sessionMatch) {
501-
escapedReport = escapedReport.replace(sessionMatch[0], '');
510+
511+
// Check if we're in loading state for the report
512+
const reportIsLoading = data.report === LOADING_PLACEHOLDER;
513+
514+
if (!reportIsLoading) {
515+
// Remove the old session files list from the report text if present
516+
escapedReport = removeSessionFilesSection(escapedReport);
517+
} else {
518+
// Show a better loading message
519+
escapedReport = LOADING_MESSAGE.trim();
502520
}
503521

504522
// Build detailed session files table
@@ -874,7 +892,91 @@ function renderLayout(data: DiagnosticsData): void {
874892
// Listen for messages from the extension (background loading)
875893
window.addEventListener('message', (event) => {
876894
const message = event.data;
877-
if (message.command === 'sessionFilesLoaded' && message.detailedSessionFiles) {
895+
if (message.command === 'diagnosticDataLoaded') {
896+
// Initial diagnostic data has loaded (report, session folders, backend info)
897+
// Update the report text and folders
898+
if (message.report) {
899+
// Update the report tab content
900+
const reportTabContent = document.getElementById('tab-report');
901+
if (reportTabContent) {
902+
// Process the report text to remove session files section
903+
const processedReport = removeSessionFilesSection(message.report);
904+
const reportPre = reportTabContent.querySelector('.report-content');
905+
if (reportPre) {
906+
reportPre.textContent = processedReport;
907+
}
908+
}
909+
}
910+
911+
// Update session folders if provided
912+
if (message.sessionFolders && message.sessionFolders.length > 0) {
913+
const reportTabContent = document.getElementById('tab-report');
914+
if (reportTabContent) {
915+
const sorted = [...message.sessionFolders].sort((a: any, b: any) => b.count - a.count);
916+
let sessionFilesHtml = `
917+
<div class="session-folders-table">
918+
<h4>Main Session Folders (by editor root):</h4>
919+
<table class="session-table">
920+
<thead>
921+
<tr>
922+
<th>Folder</th>
923+
<th>Editor</th>
924+
<th># of Sessions</th>
925+
<th>Open</th>
926+
</tr>
927+
</thead>
928+
<tbody>`;
929+
sorted.forEach((sf: any) => {
930+
let display = sf.dir;
931+
const home = (window as any).process?.env?.HOME || (window as any).process?.env?.USERPROFILE || '';
932+
if (home && display.startsWith(home)) {
933+
display = display.replace(home, '~');
934+
}
935+
const editorName = sf.editorName || 'Unknown';
936+
sessionFilesHtml += `
937+
<tr>
938+
<td title="${escapeHtml(sf.dir)}">${escapeHtml(display)}</td>
939+
<td><span class="editor-badge">${escapeHtml(editorName)}</span></td>
940+
<td>${sf.count}</td>
941+
<td><a href="#" class="reveal-link" data-path="${encodeURIComponent(sf.dir)}">Open directory</a></td>
942+
</tr>`;
943+
});
944+
sessionFilesHtml += `
945+
</tbody>
946+
</table>
947+
</div>`;
948+
949+
// Find where to insert or replace the session folders table
950+
// It should be inserted after the report-content div but before the button-group
951+
const existingTable = reportTabContent.querySelector('.session-folders-table');
952+
if (existingTable) {
953+
existingTable.outerHTML = sessionFilesHtml;
954+
} else {
955+
// Insert after the report-content div
956+
const reportContent = reportTabContent.querySelector('.report-content');
957+
if (reportContent) {
958+
reportContent.insertAdjacentHTML('afterend', sessionFilesHtml);
959+
}
960+
}
961+
setupStorageLinkHandlers();
962+
}
963+
}
964+
965+
// Diagnostic data loaded successfully - no console needed as this is normal operation
966+
} else if (message.command === 'diagnosticDataError') {
967+
// Show error message
968+
console.error('Error loading diagnostic data:', message.error);
969+
const root = document.getElementById('root');
970+
if (root) {
971+
const errorDiv = document.createElement('div');
972+
errorDiv.style.cssText = 'color: #ff6b6b; padding: 20px; text-align: center;';
973+
errorDiv.innerHTML = `
974+
<h3>⚠️ Error Loading Diagnostic Data</h3>
975+
<p>${escapeHtml(message.error || 'Unknown error')}</p>
976+
`;
977+
root.insertBefore(errorDiv, root.firstChild);
978+
}
979+
} else if (message.command === 'sessionFilesLoaded' && message.detailedSessionFiles) {
878980
storedDetailedFiles = message.detailedSessionFiles;
879981
isLoading = false;
880982

0 commit comments

Comments
 (0)