diff --git a/src/extension.ts b/src/extension.ts index 6f82f50..3f7b4fa 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -201,6 +201,8 @@ class CopilotTokenTracker implements vscode.Disposable { private diagnosticsHasLoadedFiles: boolean = false; // Cache of the last loaded detailed session files for diagnostics view private diagnosticsCachedFiles: SessionFileDetails[] = []; + // Cache of the last diagnostic report text for copy/issue operations + private lastDiagnosticReport: string = ''; private logViewerPanel?: vscode.WebviewPanel; private statusBarItem: vscode.StatusBarItem; private readonly extensionUri: vscode.Uri; @@ -3469,91 +3471,16 @@ class CopilotTokenTracker implements vscode.Disposable { public async showDiagnosticReport(): Promise { this.log('🔍 Opening Diagnostic Report'); - // If panel already exists, just reveal it and update content + // If panel already exists, just reveal it and trigger a refresh in the background if (this.diagnosticsPanel) { this.diagnosticsPanel.reveal(); this.log('🔍 Diagnostic Report revealed (already exists)'); - // Optionally, refresh content if needed - const report = await this.generateDiagnosticReport(); - const sessionFiles = await this.getCopilotSessionFiles(); - const sessionFileData: { file: string; size: number; modified: string }[] = []; - for (const file of sessionFiles.slice(0, 20)) { - try { - const stat = await fs.promises.stat(file); - sessionFileData.push({ - file, - size: stat.size, - modified: stat.mtime.toISOString() - }); - } catch { - // Skip inaccessible files - } - } - // Build folder counts grouped by top-level VS Code user folder (editor roots) - const dirCounts = new Map(); - const pathModule = require('path'); - for (const file of sessionFiles) { - // Walk up the path to find the 'User' directory which is the canonical editor folder root - const parts = file.split(/[\\\/]/); - // Find index of 'User' folder in path parts (case-insensitive) - const userIdx = parts.findIndex(p => p.toLowerCase() === 'user'); - let editorRoot = ''; - if (userIdx > 0) { - // Reconstruct path including 'User' and the next folder (e.g., .../Roaming/Code/User/workspaceStorage) - // Include two extra levels after the 'User' segment so we can distinguish - // between 'User\\workspaceStorage' and 'User\\globalStorage'. - const rootParts = parts.slice(0, Math.min(parts.length, userIdx + 2)); - editorRoot = pathModule.join(...rootParts); - } else { - // Fallback: use parent dir of the file - editorRoot = pathModule.dirname(file); - } - - dirCounts.set(editorRoot, (dirCounts.get(editorRoot) || 0) + 1); - } - const sessionFolders = Array.from(dirCounts.entries()).map(([dir, count]) => ({ dir, count, editorName: this.getEditorTypeFromPath(dir) })); - const backendStorageInfo = await this.getBackendStorageInfo(); - this.diagnosticsPanel.webview.html = this.getDiagnosticReportHtml(this.diagnosticsPanel.webview, report, sessionFileData, [], sessionFolders, backendStorageInfo); - this.loadSessionFilesInBackground(this.diagnosticsPanel, sessionFiles); + // Load data in background and update the webview + this.loadDiagnosticDataInBackground(this.diagnosticsPanel); return; } - const report = await this.generateDiagnosticReport(); - const sessionFiles = await this.getCopilotSessionFiles(); - const sessionFileData: { file: string; size: number; modified: string }[] = []; - for (const file of sessionFiles.slice(0, 20)) { - try { - const stat = await fs.promises.stat(file); - sessionFileData.push({ - file, - size: stat.size, - modified: stat.mtime.toISOString() - }); - } catch { - // Skip inaccessible files - } - } - - // Build folder counts grouped by top-level VS Code user folder (editor roots) - const dirCounts = new Map(); - const pathModule = require('path'); - for (const file of sessionFiles) { - const parts = file.split(/[\\\/]/); - const userIdx = parts.findIndex(p => p.toLowerCase() === 'user'); - let editorRoot = ''; - if (userIdx > 0) { - // Include 'User' plus one following folder (e.g., 'User\\workspaceStorage' or 'User\\globalStorage') - const rootParts = parts.slice(0, Math.min(parts.length, userIdx + 2)); - editorRoot = pathModule.join(...rootParts); - } else { - editorRoot = pathModule.dirname(file); - } - dirCounts.set(editorRoot, (dirCounts.get(editorRoot) || 0) + 1); - } - const sessionFolders = Array.from(dirCounts.entries()).map(([dir, count]) => ({ dir, count, editorName: this.getEditorNameFromRoot(dir) })); - - const backendStorageInfo = await this.getBackendStorageInfo(); - + // Create the panel immediately with loading state this.diagnosticsPanel = vscode.window.createWebviewPanel( 'copilotTokenDiagnostics', 'Diagnostic Report', @@ -3568,21 +3495,30 @@ class CopilotTokenTracker implements vscode.Disposable { } ); - this.log('✅ Diagnostic Report created successfully'); - - // Set the HTML content immediately with empty session files (shows loading state) - this.diagnosticsPanel.webview.html = this.getDiagnosticReportHtml(this.diagnosticsPanel.webview, report, sessionFileData, [], sessionFolders, backendStorageInfo); + this.log('✅ Diagnostic Report panel created'); + + // Set the HTML content immediately with loading state + // Note: "Loading..." is the agreed contract between backend and frontend + // The webview checks for this value to show a loading indicator + this.diagnosticsPanel.webview.html = this.getDiagnosticReportHtml( + this.diagnosticsPanel.webview, + 'Loading...', // Placeholder report + [], // Empty session files + [], // Empty detailed session files + [], // Empty session folders + null // No backend info yet + ); // Handle messages from the webview this.diagnosticsPanel.webview.onDidReceiveMessage(async (message) => { this.log(`DEBUG Diagnostics webview message: ${JSON.stringify(message)}`); switch (message.command) { case 'copyReport': - await vscode.env.clipboard.writeText(report); + await vscode.env.clipboard.writeText(this.lastDiagnosticReport); vscode.window.showInformationMessage('Diagnostic report copied to clipboard'); break; case 'openIssue': - await vscode.env.clipboard.writeText(report); + await vscode.env.clipboard.writeText(this.lastDiagnosticReport); vscode.window.showInformationMessage('Diagnostic report copied to clipboard. Please paste it into the GitHub issue.'); const shortBody = encodeURIComponent('The diagnostic report has been copied to the clipboard. Please paste it below.'); const issueUrl = `${this.getRepositoryUrl()}/issues/new?body=${shortBody}`; @@ -3676,8 +3612,100 @@ class CopilotTokenTracker implements vscode.Disposable { this.diagnosticsPanel = undefined; }); - // Load detailed session files in the background and send to webview when ready - this.loadSessionFilesInBackground(this.diagnosticsPanel, sessionFiles); + // Load data in background and update the webview when ready + this.loadDiagnosticDataInBackground(this.diagnosticsPanel); + } + + /** + * Load all diagnostic data in the background and update the webview progressively. + */ + private async loadDiagnosticDataInBackground(panel: vscode.WebviewPanel): Promise { + try { + this.log('🔄 Loading diagnostic data in background...'); + + // Load the diagnostic report + const report = await this.generateDiagnosticReport(); + this.lastDiagnosticReport = report; + + // Get session files + const sessionFiles = await this.getCopilotSessionFiles(); + + // Get first 20 session files with stats (quick preview) + const sessionFileData: { file: string; size: number; modified: string }[] = []; + for (const file of sessionFiles.slice(0, 20)) { + try { + const stat = await fs.promises.stat(file); + sessionFileData.push({ + file, + size: stat.size, + modified: stat.mtime.toISOString() + }); + } catch { + // Skip inaccessible files + } + } + + // Build folder counts grouped by top-level VS Code user folder (editor roots) + const dirCounts = new Map(); + const pathModule = require('path'); + for (const file of sessionFiles) { + const parts = file.split(/[\\\/]/); + const userIdx = parts.findIndex((p: string) => p.toLowerCase() === 'user'); + let editorRoot = ''; + if (userIdx > 0) { + const rootParts = parts.slice(0, Math.min(parts.length, userIdx + 2)); + editorRoot = pathModule.join(...rootParts); + } else { + editorRoot = pathModule.dirname(file); + } + dirCounts.set(editorRoot, (dirCounts.get(editorRoot) || 0) + 1); + } + const sessionFolders = Array.from(dirCounts.entries()).map(([dir, count]) => ({ + dir, + count, + editorName: this.getEditorNameFromRoot(dir) + })); + + // Get backend storage info + const backendStorageInfo = await this.getBackendStorageInfo(); + + // Check if panel is still open before updating + if (!this.isPanelOpen(panel)) { + this.log('Diagnostic panel closed during data load, aborting update'); + return; + } + + // Send the loaded data to the webview + panel.webview.postMessage({ + command: 'diagnosticDataLoaded', + report, + sessionFiles: sessionFileData, + sessionFolders, + backendStorageInfo + }); + + this.log('✅ Diagnostic data loaded and sent to webview'); + + // Now load detailed session files in the background + this.loadSessionFilesInBackground(panel, sessionFiles); + } catch (error) { + this.error(`Failed to load diagnostic data: ${error}`); + // Send error to webview if panel is still open + if (this.isPanelOpen(panel)) { + panel.webview.postMessage({ + command: 'diagnosticDataError', + error: String(error) + }); + } + } + } + + /** + * Check if a webview panel is still open and accessible. + * A panel is considered open if its viewColumn is defined. + */ + private isPanelOpen(panel: vscode.WebviewPanel): boolean { + return panel.viewColumn !== undefined; } /** @@ -3711,7 +3739,7 @@ class CopilotTokenTracker implements vscode.Disposable { // Process up to 500 most recent session files for (const file of sortedFiles.slice(0, 500)) { // Check if panel was disposed - if (!panel.visible && panel.viewColumn === undefined) { + if (!this.isPanelOpen(panel)) { this.log('Diagnostic panel closed, stopping background load'); return; } diff --git a/src/webview/diagnostics/main.ts b/src/webview/diagnostics/main.ts index 6b46688..97e9a06 100644 --- a/src/webview/diagnostics/main.ts +++ b/src/webview/diagnostics/main.ts @@ -1,6 +1,14 @@ // Diagnostics Report webview with tabbed interface import { buttonHtml } from '../shared/buttonConfig'; +// Constants +const LOADING_PLACEHOLDER = 'Loading...'; +const SESSION_FILES_SECTION_REGEX = /Session File Locations \(first 20\):[\s\S]*?(?=\n\s*\n|={70})/; +const LOADING_MESSAGE = `⏳ Loading diagnostic data... + +This may take a few moments depending on the number of session files. +The view will automatically update when data is ready.`; + type ContextReferenceUsage = { file: number; selection: number; @@ -88,6 +96,10 @@ function escapeHtml(text: string): string { .replace(/'/g, '''); } +function removeSessionFilesSection(reportText: string): string { + return reportText.replace(SESSION_FILES_SECTION_REGEX, ''); +} + function formatDate(isoString: string | null): string { if (!isoString) { return 'N/A'; } try { @@ -495,10 +507,16 @@ function renderLayout(data: DiagnosticsData): void { // Remove session files section from report text (it's shown separately as clickable links) let escapedReport = escapeHtml(data.report); - // Remove the old session files list from the report text if present - const sessionMatch = escapedReport.match(/Session File Locations \(first 20\):[\s\S]*?(?=\n\s*\n|={70})/); - if (sessionMatch) { - escapedReport = escapedReport.replace(sessionMatch[0], ''); + + // Check if we're in loading state for the report + const reportIsLoading = data.report === LOADING_PLACEHOLDER; + + if (!reportIsLoading) { + // Remove the old session files list from the report text if present + escapedReport = removeSessionFilesSection(escapedReport); + } else { + // Show a better loading message + escapedReport = LOADING_MESSAGE.trim(); } // Build detailed session files table @@ -874,7 +892,91 @@ function renderLayout(data: DiagnosticsData): void { // Listen for messages from the extension (background loading) window.addEventListener('message', (event) => { const message = event.data; - if (message.command === 'sessionFilesLoaded' && message.detailedSessionFiles) { + if (message.command === 'diagnosticDataLoaded') { + // Initial diagnostic data has loaded (report, session folders, backend info) + // Update the report text and folders + if (message.report) { + // Update the report tab content + const reportTabContent = document.getElementById('tab-report'); + if (reportTabContent) { + // Process the report text to remove session files section + const processedReport = removeSessionFilesSection(message.report); + const reportPre = reportTabContent.querySelector('.report-content'); + if (reportPre) { + reportPre.textContent = processedReport; + } + } + } + + // Update session folders if provided + if (message.sessionFolders && message.sessionFolders.length > 0) { + const reportTabContent = document.getElementById('tab-report'); + if (reportTabContent) { + const sorted = [...message.sessionFolders].sort((a: any, b: any) => b.count - a.count); + let sessionFilesHtml = ` +
+

Main Session Folders (by editor root):

+ + + + + + + + + + `; + sorted.forEach((sf: any) => { + let display = sf.dir; + const home = (window as any).process?.env?.HOME || (window as any).process?.env?.USERPROFILE || ''; + if (home && display.startsWith(home)) { + display = display.replace(home, '~'); + } + const editorName = sf.editorName || 'Unknown'; + sessionFilesHtml += ` + + + + + + `; + }); + sessionFilesHtml += ` + +
FolderEditor# of SessionsOpen
${escapeHtml(display)}${escapeHtml(editorName)}${sf.count}Open directory
+
`; + + // Find where to insert or replace the session folders table + // It should be inserted after the report-content div but before the button-group + const existingTable = reportTabContent.querySelector('.session-folders-table'); + if (existingTable) { + existingTable.outerHTML = sessionFilesHtml; + } else { + // Insert after the report-content div + const reportContent = reportTabContent.querySelector('.report-content'); + if (reportContent) { + reportContent.insertAdjacentHTML('afterend', sessionFilesHtml); + } + } + setupStorageLinkHandlers(); + } + } + + // Diagnostic data loaded successfully - no console needed as this is normal operation + } else if (message.command === 'diagnosticDataError') { + // Show error message + console.error('Error loading diagnostic data:', message.error); + const root = document.getElementById('root'); + if (root) { + const errorDiv = document.createElement('div'); + errorDiv.style.cssText = 'color: #ff6b6b; padding: 20px; text-align: center;'; + errorDiv.innerHTML = ` +

⚠️ Error Loading Diagnostic Data

+

${escapeHtml(message.error || 'Unknown error')}

+ `; + root.insertBefore(errorDiv, root.firstChild); + } + } else if (message.command === 'sessionFilesLoaded' && message.detailedSessionFiles) { storedDetailedFiles = message.detailedSessionFiles; isLoading = false;