Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 116 additions & 88 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -3469,91 +3471,16 @@ class CopilotTokenTracker implements vscode.Disposable {
public async showDiagnosticReport(): Promise<void> {
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<string, number>();
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<string, number>();
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',
Expand All @@ -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}`;
Expand Down Expand Up @@ -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<void> {
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<string, number>();
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;
}

/**
Expand Down Expand Up @@ -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;
}
Expand Down
112 changes: 107 additions & 5 deletions src/webview/diagnostics/main.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -88,6 +96,10 @@
.replace(/'/g, '&#039;');
}

function removeSessionFilesSection(reportText: string): string {
return reportText.replace(SESSION_FILES_SECTION_REGEX, '');
}

function formatDate(isoString: string | null): string {
if (!isoString) { return 'N/A'; }
try {
Expand Down Expand Up @@ -495,10 +507,16 @@

// 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
Expand Down Expand Up @@ -874,7 +892,91 @@
// 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 = `
<div class="session-folders-table">
<h4>Main Session Folders (by editor root):</h4>
<table class="session-table">
<thead>
<tr>
<th>Folder</th>
<th>Editor</th>
<th># of Sessions</th>
<th>Open</th>
</tr>
</thead>
<tbody>`;
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 += `
<tr>
<td title="${escapeHtml(sf.dir)}">${escapeHtml(display)}</td>
<td><span class="editor-badge">${escapeHtml(editorName)}</span></td>
<td>${sf.count}</td>
<td><a href="#" class="reveal-link" data-path="${encodeURIComponent(sf.dir)}">Open directory</a></td>
</tr>`;
});
sessionFilesHtml += `
</tbody>
</table>
</div>`;

// 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;

Check failure

Code scanning / CodeQL

Client-side cross-site scripting High

Cross-site scripting vulnerability due to
user-provided value
.

Copilot Autofix

AI 18 days ago

In general, the fix is to ensure that every user-controlled value included in HTML that is passed to innerHTML, outerHTML, insertAdjacentHTML, or similar is properly escaped for the HTML context, or to avoid building HTML strings in favor of creating DOM nodes and assigning text via textContent. Since this code already uses an escapeHtml helper and string-based templating, the minimal, non-breaking fix is to escape the remaining unescaped value and ensure we only inject escaped data into the HTML string.

Concretely, within src/webview/diagnostics/main.ts, in the message handler where sessionFilesHtml is constructed, the sf.count interpolation on line 940 must be escaped using the existing escapeHtml helper. This guarantees that even if sf.count is tainted or unexpectedly becomes a string, special characters like <, >, ", and ' are neutralized before being inserted into the table cell. The rest of the fields (sf.dir, display, editorName, and the data-path attribute) are already encoded appropriately, so no other functional changes are necessary. No new imports or helper methods are required if escapeHtml already exists elsewhere in this file; if it does not, such a helper would need to be defined, but that is outside the shown snippet, so here we only adjust the existing usage pattern.

Suggested changeset 1
src/webview/diagnostics/main.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/webview/diagnostics/main.ts b/src/webview/diagnostics/main.ts
--- a/src/webview/diagnostics/main.ts
+++ b/src/webview/diagnostics/main.ts
@@ -937,7 +937,7 @@
 							<tr>
 								<td title="${escapeHtml(sf.dir)}">${escapeHtml(display)}</td>
 								<td><span class="editor-badge">${escapeHtml(editorName)}</span></td>
-								<td>${sf.count}</td>
+								<td>${escapeHtml(String(sf.count))}</td>
 								<td><a href="#" class="reveal-link" data-path="${encodeURIComponent(sf.dir)}">Open directory</a></td>
 							</tr>`;
 					});
EOF
@@ -937,7 +937,7 @@
<tr>
<td title="${escapeHtml(sf.dir)}">${escapeHtml(display)}</td>
<td><span class="editor-badge">${escapeHtml(editorName)}</span></td>
<td>${sf.count}</td>
<td>${escapeHtml(String(sf.count))}</td>
<td><a href="#" class="reveal-link" data-path="${encodeURIComponent(sf.dir)}">Open directory</a></td>
</tr>`;
});
Copilot is powered by AI and may make mistakes. Always verify output.
} else {
// Insert after the report-content div
const reportContent = reportTabContent.querySelector('.report-content');
if (reportContent) {
reportContent.insertAdjacentHTML('afterend', sessionFilesHtml);

Check failure

Code scanning / CodeQL

Client-side cross-site scripting High

Cross-site scripting vulnerability due to
user-provided value
.

Copilot Autofix

AI 18 days ago

In general, to fix DOM-based XSS issues, all data derived from untrusted sources must be properly encoded for the specific context in which it is inserted (HTML body, attribute, URL, etc.), or else inserted as text nodes via APIs like textContent/createTextNode rather than via HTML-parsing sinks such as innerHTML/insertAdjacentHTML. For this snippet, the risk arises because sessionFilesHtml is built via string concatenation with template literals and then passed to insertAdjacentHTML.

The best targeted fix here, without changing existing functionality or structure, is to ensure that every untrusted piece of data interpolated into sessionFilesHtml is run through an appropriate escaping function. The code already uses escapeHtml for sf.dir, display, and editorName, and encodeURIComponent for the data-path attribute. We should apply the same escapeHtml to sf.count before inserting it into the HTML so that, even if it were to become a string or otherwise attacker-controlled, any special characters would be safely encoded as text. Concretely, in src/webview/diagnostics/main.ts, in the loop where sessionFilesHtml += \...${sf.count}...`;is constructed (around line 940), change that interpolation to useescapeHtml(String(sf.count))`. This reuses the existing helper, requires no new imports, and keeps behavior effectively identical for numeric counts while protecting against unexpected malicious strings.

Suggested changeset 1
src/webview/diagnostics/main.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/webview/diagnostics/main.ts b/src/webview/diagnostics/main.ts
--- a/src/webview/diagnostics/main.ts
+++ b/src/webview/diagnostics/main.ts
@@ -937,7 +937,7 @@
 							<tr>
 								<td title="${escapeHtml(sf.dir)}">${escapeHtml(display)}</td>
 								<td><span class="editor-badge">${escapeHtml(editorName)}</span></td>
-								<td>${sf.count}</td>
+								<td>${escapeHtml(String(sf.count))}</td>
 								<td><a href="#" class="reveal-link" data-path="${encodeURIComponent(sf.dir)}">Open directory</a></td>
 							</tr>`;
 					});
EOF
@@ -937,7 +937,7 @@
<tr>
<td title="${escapeHtml(sf.dir)}">${escapeHtml(display)}</td>
<td><span class="editor-badge">${escapeHtml(editorName)}</span></td>
<td>${sf.count}</td>
<td>${escapeHtml(String(sf.count))}</td>
<td><a href="#" class="reveal-link" data-path="${encodeURIComponent(sf.dir)}">Open directory</a></td>
</tr>`;
});
Copilot is powered by AI and may make mistakes. Always verify output.
}
}
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 = `
<h3>⚠️ Error Loading Diagnostic Data</h3>
<p>${escapeHtml(message.error || 'Unknown error')}</p>
`;
root.insertBefore(errorDiv, root.firstChild);
}
} else if (message.command === 'sessionFilesLoaded' && message.detailedSessionFiles) {
storedDetailedFiles = message.detailedSessionFiles;
isLoading = false;

Expand Down
Loading