diff --git a/.gitignore b/.gitignore index 41f26ff..dff4e8f 100644 --- a/.gitignore +++ b/.gitignore @@ -66,4 +66,6 @@ badges/ .github/copilot-instructions.md -reference/ \ No newline at end of file +reference/ +# BlamePrompt staging (auto-generated) +.blameprompt/ diff --git a/apps/cli/README.md b/apps/cli/README.md new file mode 100644 index 0000000..8894164 --- /dev/null +++ b/apps/cli/README.md @@ -0,0 +1,32 @@ +# Copilot Usage Analytics + +`copilot-usage` is a local-first analytics tool for GitHub Copilot session data. It scans VS Code chat session files, stores structured results in DuckDB, and exposes both a CLI workflow and a local dashboard for exploring token usage, premium estimates, and workspace-level activity. + +## Highlights + +- Incremental scanning of Copilot chat session files +- Local DuckDB storage with no external data upload +- Workspace and model-level usage breakdowns +- Premium request estimation based on model multipliers +- Browser dashboard plus terminal-friendly workflows + +## Install + +```bash +pip install copilot-usage +``` + +## Run + +```bash +copilot-usage +copilot-usage analyze +copilot-usage dashboard +copilot-usage tui +``` + +## Project + +Source, documentation, and release notes live in the main repository: + +- https://github.com/SachiHarshitha/copilot-usage diff --git a/apps/cli/src/copilot_usage/__main__.py b/apps/cli/src/copilot_usage/__main__.py index 5ec9d09..5c7829b 100644 --- a/apps/cli/src/copilot_usage/__main__.py +++ b/apps/cli/src/copilot_usage/__main__.py @@ -231,7 +231,7 @@ def _interactive(): def _settings_menu(console): """Interactive settings sub-menu.""" from InquirerPy import inquirer - from copilot_usage.config import APP_DATA_DIR, DB_PATH, VSCODE_STORAGE_ROOT + from copilot_usage.config import APP_DATA_DIR, DB_PATH, VSCODE_STORAGE_ROOTS from copilot_usage.logging import LOG_DIR from copilot_usage import __version__ from rich.table import Table @@ -244,7 +244,9 @@ def _settings_menu(console): info.add_row("Database", str(DB_PATH)) info.add_row("App Data", str(APP_DATA_DIR)) info.add_row("Log Dir", str(LOG_DIR)) - info.add_row("VS Code Storage", str(VSCODE_STORAGE_ROOT)) + for i, p in enumerate(VSCODE_STORAGE_ROOTS): + label = "VS Code Storage" + (f" ({i + 1})" if len(VSCODE_STORAGE_ROOTS) > 1 else "") + info.add_row(label, str(p)) console.print(info) console.print() diff --git a/apps/cli/src/copilot_usage/config.py b/apps/cli/src/copilot_usage/config.py index 2906992..fea9b73 100644 --- a/apps/cli/src/copilot_usage/config.py +++ b/apps/cli/src/copilot_usage/config.py @@ -9,18 +9,25 @@ # VS Code workspace storage root # --------------------------------------------------------------------------- -def _default_vscode_storage() -> pathlib.Path: - sys = platform.system() - if sys == "Windows": +def _default_vscode_storage_roots() -> list[pathlib.Path]: + sys_name = platform.system() + if sys_name == "Windows": base = pathlib.Path(os.environ.get("APPDATA", "") or str(pathlib.Path.home() / "AppData" / "Roaming")) - elif sys == "Darwin": + elif sys_name == "Darwin": base = pathlib.Path.home() / "Library" / "Application Support" else: # Linux / other base = pathlib.Path(os.environ.get("XDG_CONFIG_HOME", "") or str(pathlib.Path.home() / ".config")) - return base / "Code" / "User" / "workspaceStorage" + candidates = [ + base / "Code" / "User" / "workspaceStorage", + base / "Code - Insiders" / "User" / "workspaceStorage", + ] + found = [p for p in candidates if p.exists()] + return found if found else [candidates[0]] -VSCODE_STORAGE_ROOT = _default_vscode_storage() +VSCODE_STORAGE_ROOTS: list[pathlib.Path] = _default_vscode_storage_roots() +# Backward-compat alias: first discovered root (stable Code path when neither exists yet) +VSCODE_STORAGE_ROOT: pathlib.Path = VSCODE_STORAGE_ROOTS[0] # --------------------------------------------------------------------------- # App data directory (writable; holds DuckDB, layout JSON, badge exports) diff --git a/apps/cli/src/copilot_usage/dashboard/pages/pipeline.py b/apps/cli/src/copilot_usage/dashboard/pages/pipeline.py index 1cf38ab..937c72f 100644 --- a/apps/cli/src/copilot_usage/dashboard/pages/pipeline.py +++ b/apps/cli/src/copilot_usage/dashboard/pages/pipeline.py @@ -9,7 +9,7 @@ import dash_bootstrap_components as dbc from dash import Input, Output, State, callback, ctx, dcc, html -from copilot_usage.config import VSCODE_STORAGE_ROOT +from copilot_usage.config import VSCODE_STORAGE_ROOTS dash.register_page(__name__, path="/pipeline", name="Pipeline", order=2) @@ -52,10 +52,17 @@ def _run_pipeline_thread(storage_path: str): from copilot_usage.pipeline import run_scan try: - _append_log(f"Storage path: {storage_path}", 0) - storage_root = Path(storage_path) if storage_path else None + if storage_path and storage_path.strip(): + storage_roots = [Path(p.strip()) for p in storage_path.split(";") if p.strip()] or None + if storage_roots: + _append_log(f"Storage paths: {'; '.join(str(p) for p in storage_roots)}", 0) + else: + _append_log(f"No valid paths parsed; using auto-detected roots: {'; '.join(str(p) for p in VSCODE_STORAGE_ROOTS)}", 0) + else: + storage_roots = None # use all auto-detected roots + _append_log(f"Storage paths: {'; '.join(str(p) for p in VSCODE_STORAGE_ROOTS)}", 0) con = get_connection() - stats = run_scan(con, storage_root=storage_root, on_progress=_append_log) + stats = run_scan(con, storage_roots=storage_roots, on_progress=_append_log) con.close() # Invalidate dashboard query cache so fresh data appears immediately from copilot_usage.dashboard.queries import invalidate_cache @@ -93,8 +100,8 @@ def _run_pipeline_thread(storage_path: str): dbc.Input( id="pl-storage-path", type="text", - value=str(VSCODE_STORAGE_ROOT), - placeholder="Auto-detected path…", + value="", + placeholder=f"Auto-detect ({'; '.join(str(p) for p in VSCODE_STORAGE_ROOTS)})", className="theme-input", ), dbc.Button( @@ -106,8 +113,9 @@ def _run_pipeline_thread(storage_path: str): ), ]), html.Small( - "Path to VS Code workspaceStorage directory. " - "Uses auto-detected default if left unchanged.", + "One or more workspaceStorage paths separated by semicolons. " + f"Leave blank to scan all auto-detected locations " + f"({'; '.join(str(p) for p in VSCODE_STORAGE_ROOTS)}).", className="text-muted", ), ], md=9), @@ -176,7 +184,9 @@ def _run_pipeline_thread(storage_path: str): prevent_initial_call=True, ) def _reset_path(_): - return str(VSCODE_STORAGE_ROOT) + # Set to joined VSCODE_STORAGE_ROOTS for user visibility + from copilot_usage.config import VSCODE_STORAGE_ROOTS + return ";".join(str(p) for p in VSCODE_STORAGE_ROOTS) @callback( diff --git a/apps/cli/src/copilot_usage/dashboard/pages/settings.py b/apps/cli/src/copilot_usage/dashboard/pages/settings.py index 38dfc18..d72e964 100644 --- a/apps/cli/src/copilot_usage/dashboard/pages/settings.py +++ b/apps/cli/src/copilot_usage/dashboard/pages/settings.py @@ -9,7 +9,7 @@ from dash_bootstrap_templates import ThemeChangerAIO from copilot_usage import __version__ -from copilot_usage.config import APP_DATA_DIR, DB_PATH, VSCODE_STORAGE_ROOT +from copilot_usage.config import APP_DATA_DIR, DB_PATH, VSCODE_STORAGE_ROOTS from copilot_usage.dashboard.app import THEME_OPTIONS from copilot_usage.logging import LOG_DIR @@ -78,7 +78,13 @@ def _about_row(label: str, value: str): _about_row("Database", str(DB_PATH)), _about_row("App Data", str(APP_DATA_DIR)), _about_row("Log Directory", str(LOG_DIR)), - _about_row("VS Code Storage", str(VSCODE_STORAGE_ROOT)), + *[ + _about_row( + "VS Code Storage" + (f" ({i + 1})" if len(VSCODE_STORAGE_ROOTS) > 1 else ""), + str(p), + ) + for i, p in enumerate(VSCODE_STORAGE_ROOTS) + ], ]) ], className="table table-dark table-sm mb-0", style={"--bs-table-bg": "transparent"}), diff --git a/apps/cli/src/copilot_usage/discovery.py b/apps/cli/src/copilot_usage/discovery.py index 5799ce2..0f87c8c 100644 --- a/apps/cli/src/copilot_usage/discovery.py +++ b/apps/cli/src/copilot_usage/discovery.py @@ -3,34 +3,41 @@ import json from pathlib import Path -from urllib.parse import unquote +from urllib.parse import unquote, urlparse +from urllib.request import url2pathname import duckdb from loguru import logger as log -from copilot_usage.config import VSCODE_STORAGE_ROOT +from copilot_usage.config import VSCODE_STORAGE_ROOT, VSCODE_STORAGE_ROOTS -def _uri_to_path(uri: str) -> str: +def _uri_to_path(uri: str, storage_root: Path | None = None) -> str: """Strip a VS Code URI scheme and decode percent-encoding → plain filesystem path. Handles: - - ``file:///c%3A/path`` → ``c:/path`` + - ``file:///c%3A/path`` → platform-native path (e.g. ``C:/path`` or ``C:\\path``) - ``vscode-userdata:///Code/...`` → ``{APPDATA}/Code/...`` - bare string → decoded as-is + Output is platform-dependent (Windows paths use backslashes, POSIX uses forward slashes). """ - if uri.startswith("file:///"): - return unquote(uri[len("file:///"):]) + if uri.startswith("file://"): + parsed = urlparse(uri) + parsed_path = parsed.path + if parsed.netloc: + parsed_path = f"//{parsed.netloc}{parsed_path}" + return url2pathname(unquote(parsed_path)) if uri.startswith("vscode-userdata:///"): rel = unquote(uri[len("vscode-userdata:///"):]) # vscode-userdata:/// is rooted at the VS Code user-data base (e.g. %APPDATA% on Windows), # which is three levels above workspaceStorage: .../Code/User/workspaceStorage - userdata_base = VSCODE_STORAGE_ROOT.parents[2] + root = storage_root or VSCODE_STORAGE_ROOT + userdata_base = root.parents[2] return str(userdata_base / rel) return unquote(uri) -def resolve_workspace(workspace_dir: Path) -> tuple[str, str]: +def resolve_workspace(workspace_dir: Path, storage_root: Path | None = None) -> tuple[str, str]: """Return (workspace_id, workspace_path) from a workspaceStorage subfolder. For single-folder workspaces the path is the decoded project folder. @@ -48,7 +55,7 @@ def resolve_workspace(workspace_dir: Path) -> tuple[str, str]: workspace_uri = data.get("workspace", "") raw = folder_uri or workspace_uri if raw: - resolved = _uri_to_path(raw) + resolved = _uri_to_path(raw, storage_root) if workspace_uri: # Multi-root workspace: try to read the referenced workspace file # and extract the actual folder paths for a readable workspace_path. @@ -57,7 +64,7 @@ def resolve_workspace(workspace_dir: Path) -> tuple[str, str]: try: ws_data = json.loads(ws_file.read_text(encoding="utf-8")) folder_paths = [ - _uri_to_path(f.get("uri", "") or f.get("path", "")) + _uri_to_path(f.get("uri", "") or f.get("path", ""), storage_root) for f in ws_data.get("folders", []) if isinstance(f, dict) ] @@ -75,34 +82,41 @@ def resolve_workspace(workspace_dir: Path) -> tuple[str, str]: def discover_all_session_files( - storage_root: Path | None = None, + storage_root: Path | None = None, # deprecated: use storage_roots + *, + storage_roots: list[Path] | Path | None = None, ) -> tuple[list[tuple[str, str, Path]], list[tuple[str, str, Path]]]: - """Single-pass discovery of both JSONL and legacy JSON session files. + """Single-pass discovery of both JSONL and legacy JSON session files across all roots. Returns (jsonl_files, legacy_json_files) where each item is (workspace_id, workspace_path, file_path). """ - root = storage_root or VSCODE_STORAGE_ROOT + if isinstance(storage_roots, Path): + storage_roots = [storage_roots] + if storage_root is not None and storage_roots is None: + storage_roots = [storage_root] + roots = storage_roots or VSCODE_STORAGE_ROOTS jsonl_results: list[tuple[str, str, Path]] = [] legacy_results: list[tuple[str, str, Path]] = [] - if not root.exists(): - log.warning("VS Code storage root not found: {}", root) - return jsonl_results, legacy_results - for workspace_dir in root.iterdir(): - if not workspace_dir.is_dir(): - continue - sessions_dir = workspace_dir / "chatSessions" - if not sessions_dir.is_dir(): + for root in roots: + if not root.exists(): + log.warning("VS Code storage root not found: {}", root) continue - workspace_id, workspace_path = resolve_workspace(workspace_dir) - for f in sessions_dir.iterdir(): - if not f.is_file(): + for workspace_dir in root.iterdir(): + if not workspace_dir.is_dir(): + continue + sessions_dir = workspace_dir / "chatSessions" + if not sessions_dir.is_dir(): continue - if f.suffix == ".jsonl": - jsonl_results.append((workspace_id, workspace_path, f)) - elif f.suffix == ".json": - legacy_results.append((workspace_id, workspace_path, f)) + workspace_id, workspace_path = resolve_workspace(workspace_dir, root) + for f in sessions_dir.iterdir(): + if not f.is_file(): + continue + if f.suffix == ".jsonl": + jsonl_results.append((workspace_id, workspace_path, f)) + elif f.suffix == ".json": + legacy_results.append((workspace_id, workspace_path, f)) log.info( "Discovered {} JSONL + {} legacy JSON files across {} workspaces", @@ -115,23 +129,35 @@ def discover_all_session_files( def discover_jsonl_files( storage_root: Path | None = None, + *, + storage_roots: list[Path] | Path | None = None, ) -> list[tuple[str, str, Path]]: """Find all chatSessions/*.jsonl files. Returns list of (workspace_id, workspace_path, jsonl_path). """ - jsonl, _ = discover_all_session_files(storage_root) + if isinstance(storage_roots, Path): + storage_roots = [storage_roots] + if storage_root is not None and storage_roots is None: + storage_roots = [storage_root] + jsonl, _ = discover_all_session_files(storage_roots=storage_roots) return jsonl def discover_legacy_json_files( storage_root: Path | None = None, + *, + storage_roots: list[Path] | Path | None = None, ) -> list[tuple[str, str, Path]]: """Find all chatSessions/*.json files (legacy, pre-Feb 2026). Returns list of (workspace_id, workspace_path, json_path). """ - _, legacy = discover_all_session_files(storage_root) + if isinstance(storage_roots, Path): + storage_roots = [storage_roots] + if storage_root is not None and storage_roots is None: + storage_roots = [storage_root] + _, legacy = discover_all_session_files(storage_roots=storage_roots) return legacy diff --git a/apps/cli/src/copilot_usage/pipeline.py b/apps/cli/src/copilot_usage/pipeline.py index 10e53e6..366ec29 100644 --- a/apps/cli/src/copilot_usage/pipeline.py +++ b/apps/cli/src/copilot_usage/pipeline.py @@ -24,10 +24,13 @@ def run_scan( con: duckdb.DuckDBPyConnection, *, - storage_root=None, + storage_roots=None, + storage_root=None, # deprecated: use storage_roots on_progress: ProgressCallback | None = None, ) -> dict: """Execute a full incremental scan pipeline. Returns stats dict.""" + if storage_root is not None and storage_roots is None: + storage_roots = [storage_root] t0 = time.perf_counter() def _emit(msg: str, pct: float | None = None): @@ -43,7 +46,7 @@ def _emit(msg: str, pct: float | None = None): # 2. Discover all session files (single directory walk) _emit("Discovering session files…", 5) - all_jsonl, all_legacy = discover_all_session_files(storage_root) + all_jsonl, all_legacy = discover_all_session_files(storage_roots=storage_roots) _emit(f" Found {len(all_jsonl)} JSONL + {len(all_legacy)} legacy JSON files", 15) all_files = all_jsonl + all_legacy diff --git a/apps/vscode-extension/package-lock.json b/apps/vscode-extension/package-lock.json index 9bda1b1..e5d1ea4 100644 --- a/apps/vscode-extension/package-lock.json +++ b/apps/vscode-extension/package-lock.json @@ -1,12 +1,12 @@ { "name": "copilot-usage", - "version": "0.0.1", + "version": "0.0.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "copilot-usage", - "version": "0.0.1", + "version": "0.0.10", "devDependencies": { "@types/mocha": "^10.0.10", "@types/node": "22.x", diff --git a/apps/vscode-extension/src/core/discovery.ts b/apps/vscode-extension/src/core/discovery.ts index 6c98a05..f587e80 100644 --- a/apps/vscode-extension/src/core/discovery.ts +++ b/apps/vscode-extension/src/core/discovery.ts @@ -5,18 +5,32 @@ import * as path from 'path'; import * as os from 'os'; import { WorkspaceInfo } from './types'; -/** Return platform-specific VS Code workspace storage root. */ -export function getWorkspaceStorageRoot(): string { +/** + * Return platform-specific VS Code workspace storage roots. + * + * When `appName` contains "Insiders" (e.g. `vscode.env.appName`), the Insiders + * root is returned first so that workspace lookups prefer the current host's + * storage over the stable installation's storage. + */ +export function getWorkspaceStorageRoots(appName?: string): string[] { + const isInsiders = appName?.toLowerCase().includes('insiders') ?? false; switch (process.platform) { case 'win32': { const appdata = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); - return path.join(appdata, 'Code', 'User', 'workspaceStorage'); + const stable = path.join(appdata, 'Code', 'User', 'workspaceStorage'); + const insiders = path.join(appdata, 'Code - Insiders', 'User', 'workspaceStorage'); + return isInsiders ? [insiders, stable] : [stable, insiders]; + } + case 'darwin': { + const stable = path.join(os.homedir(), 'Library', 'Application Support', 'Code', 'User', 'workspaceStorage'); + const insiders = path.join(os.homedir(), 'Library', 'Application Support', 'Code - Insiders', 'User', 'workspaceStorage'); + return isInsiders ? [insiders, stable] : [stable, insiders]; } - case 'darwin': - return path.join(os.homedir(), 'Library', 'Application Support', 'Code', 'User', 'workspaceStorage'); default: { const config = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); - return path.join(config, 'Code', 'User', 'workspaceStorage'); + const stable = path.join(config, 'Code', 'User', 'workspaceStorage'); + const insiders = path.join(config, 'Code - Insiders', 'User', 'workspaceStorage'); + return isInsiders ? [insiders, stable] : [stable, insiders]; } } } @@ -68,52 +82,77 @@ async function resolveWorkspace(workspaceDir: string): Promise<{ id: string; pat /** Discover all workspaces that have chatSessions with JSONL or JSON files. */ export async function discoverWorkspaces( - storageRoot?: string, + storageRoots?: string[], ): Promise { - const root = storageRoot || getWorkspaceStorageRoot(); + const roots = storageRoots || getWorkspaceStorageRoots(); const results: WorkspaceInfo[] = []; - let dirs: string[]; - try { - dirs = await fs.readdir(root); - } catch { - return results; - } - - for (const dirName of dirs) { - const wsDir = path.join(root, dirName); - const sessionsDir = path.join(wsDir, 'chatSessions'); + for (const root of roots) { + let dirs: string[]; try { - const stat = await fs.stat(sessionsDir); - if (!stat.isDirectory()) { continue; } + dirs = await fs.readdir(root); } catch { - continue; + continue; // root doesn't exist or is unreadable — skip it } - const ws = await resolveWorkspace(wsDir); - const files: string[] = []; - try { - for (const f of await fs.readdir(sessionsDir)) { - const ext = path.extname(f).toLowerCase(); - if (ext === '.jsonl' || ext === '.json') { - files.push(path.join(sessionsDir, f)); + for (const dirName of dirs) { + const wsDir = path.join(root, dirName); + const sessionsDir = path.join(wsDir, 'chatSessions'); + try { + const stat = await fs.stat(sessionsDir); + if (!stat.isDirectory()) { continue; } + } catch { + continue; + } + + const ws = await resolveWorkspace(wsDir); + const files: string[] = []; + try { + for (const f of await fs.readdir(sessionsDir)) { + const ext = path.extname(f).toLowerCase(); + if (ext === '.jsonl' || ext === '.json') { + files.push(path.join(sessionsDir, f)); + } } + } catch { + continue; } - } catch { - continue; - } - if (files.length > 0) { - results.push({ - workspaceId: ws.id, - workspacePath: ws.path, - referencedFolders: ws.referencedFolders, - sessionFiles: files, - }); + if (files.length > 0) { + results.push({ + workspaceId: ws.id, + workspacePath: ws.path, + referencedFolders: ws.referencedFolders, + sessionFiles: files, + }); + } } } - return results; + // De-duplicate by workspaceId: when both stable and Insiders roots contain + // entries for the same workspace, merge their session files and referenced + // folders so downstream sees a unified view across all installations. + const merged = new Map(); + for (const ws of results) { + const existing = merged.get(ws.workspaceId); + if (existing) { + existing.sessionFiles = [...new Set([...existing.sessionFiles, ...ws.sessionFiles])]; + if (!existing.workspacePath && ws.workspacePath) { + existing.workspacePath = ws.workspacePath; + } + if (ws.referencedFolders) { + existing.referencedFolders = [ + ...new Set([ + ...(existing.referencedFolders ?? []), + ...ws.referencedFolders, + ]), + ]; + } + } else { + merged.set(ws.workspaceId, { ...ws }); + } + } + return [...merged.values()]; } /** @@ -123,44 +162,46 @@ export async function discoverWorkspaces( * changes when the number of tracked files changes, file sizes change, or * newest modification time changes. */ -export async function computeChatSessionsSignature(storageRoot?: string): Promise { - const root = storageRoot || getWorkspaceStorageRoot(); - - let dirs: string[]; - try { - dirs = await fs.readdir(root); - } catch { - return '0:0:0'; - } +export async function computeChatSessionsSignature(storageRoots?: string[]): Promise { + const roots = storageRoots || getWorkspaceStorageRoots(); let fileCount = 0; let totalBytes = 0; let newestMtimeMs = 0; - for (const dirName of dirs) { - const sessionsDir = path.join(root, dirName, 'chatSessions'); - - let files: string[]; + for (const root of roots) { + let dirs: string[]; try { - files = await fs.readdir(sessionsDir); + dirs = await fs.readdir(root); } catch { - continue; + continue; // root doesn't exist — skip } - for (const fileName of files) { - const ext = path.extname(fileName).toLowerCase(); - if (ext !== '.jsonl' && ext !== '.json') { continue; } + for (const dirName of dirs) { + const sessionsDir = path.join(root, dirName, 'chatSessions'); + let files: string[]; try { - const stat = await fs.stat(path.join(sessionsDir, fileName)); - if (!stat.isFile()) { continue; } - - fileCount++; - totalBytes += stat.size; - const mtimeMs = Number(stat.mtimeMs) || 0; - if (mtimeMs > newestMtimeMs) { newestMtimeMs = mtimeMs; } + files = await fs.readdir(sessionsDir); } catch { - // File may disappear during scan; safe to ignore. + continue; + } + + for (const fileName of files) { + const ext = path.extname(fileName).toLowerCase(); + if (ext !== '.jsonl' && ext !== '.json') { continue; } + + try { + const stat = await fs.stat(path.join(sessionsDir, fileName)); + if (!stat.isFile()) { continue; } + + fileCount++; + totalBytes += stat.size; + const mtimeMs = Number(stat.mtimeMs) || 0; + if (mtimeMs > newestMtimeMs) { newestMtimeMs = mtimeMs; } + } catch { + // File may disappear during scan; safe to ignore. + } } } } @@ -171,9 +212,9 @@ export async function computeChatSessionsSignature(storageRoot?: string): Promis /** Find workspace info for a specific workspace folder path. */ export async function findWorkspaceByPath( folderPath: string, - storageRoot?: string, + storageRoots?: string[], ): Promise { - const workspaces = await discoverWorkspaces(storageRoot); + const workspaces = await discoverWorkspaces(storageRoots); const normTarget = normalizePath(folderPath); return workspaces.find(ws => normalizePath(ws.workspacePath) === normTarget); } @@ -185,9 +226,9 @@ export async function findWorkspaceByPath( */ export async function findWorkspaceByFile( workspaceFilePath: string, - storageRoot?: string, + storageRoots?: string[], ): Promise { - const workspaces = await discoverWorkspaces(storageRoot); + const workspaces = await discoverWorkspaces(storageRoots); const normTarget = normalizePath(workspaceFilePath); return workspaces.find(ws => normalizePath(ws.workspacePath) === normTarget); } @@ -201,9 +242,9 @@ export async function findWorkspaceByFile( export async function findCurrentWorkspace( workspaceFileUri: string | undefined, folderPaths: string[], - storageRoot?: string, + storageRoots?: string[], ): Promise { - const workspaces = await discoverWorkspaces(storageRoot); + const workspaces = await discoverWorkspaces(storageRoots); // Strategy 1: match by .code-workspace file path if (workspaceFileUri) { diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts index 879f949..c96a990 100644 --- a/apps/vscode-extension/src/extension.ts +++ b/apps/vscode-extension/src/extension.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import { WorkspacePanel, DashboardPanel } from './views/panels'; import { StatusBarManager } from './views/statusBar'; -import { getWorkspaceStorageRoot, computeChatSessionsSignature } from './core/discovery'; +import { getWorkspaceStorageRoots, computeChatSessionsSignature } from './core/discovery'; import { openCopilotDebugLogSettings } from './core/copilotDebugLog'; import { CostEstimatorPanel, enableCostEstimator } from './features/costEstimator'; @@ -14,9 +14,9 @@ export function activate(context: vscode.ExtensionContext) { /** Refresh status bar + any open panels. */ const refreshAll = async () => { const tasks: Promise[] = [ - statusBar.refresh(), - WorkspacePanel.refresh(), - DashboardPanel.refresh(), + statusBar.refresh(storageRoots), + WorkspacePanel.refresh(storageRoots), + DashboardPanel.refresh(storageRoots), ]; if (enableCostEstimator) { tasks.push(CostEstimatorPanel.refresh()); @@ -24,13 +24,13 @@ export function activate(context: vscode.ExtensionContext) { await Promise.all(tasks); }; - const storageRoot = getWorkspaceStorageRoot(); + const storageRoots = getWorkspaceStorageRoots(vscode.env.appName); let lastSignature: string | undefined; let refreshTimer: ReturnType | undefined; const updateSignature = async () => { try { - lastSignature = await computeChatSessionsSignature(storageRoot); + lastSignature = await computeChatSessionsSignature(storageRoots); } catch { // Ignore transient IO errors; next scan will retry. } @@ -59,33 +59,34 @@ export function activate(context: vscode.ExtensionContext) { } })); - // Watch the actual VS Code workspaceStorage directory for chat session changes. + // Watch each VS Code workspaceStorage directory (stable + Insiders) for chat session changes. // createFileSystemWatcher with RelativePattern(absolute path) works outside // the current workspace — this is the correct way to watch AppData files. - const watcherJsonl = vscode.workspace.createFileSystemWatcher( - new vscode.RelativePattern(vscode.Uri.file(storageRoot), '**/chatSessions/*.jsonl'), - ); - const watcherJson = vscode.workspace.createFileSystemWatcher( - new vscode.RelativePattern(vscode.Uri.file(storageRoot), '**/chatSessions/*.json'), - ); const onSessionFilesChanged = () => scheduleRefresh(); - - context.subscriptions.push( - watcherJsonl, - watcherJson, - watcherJsonl.onDidCreate(onSessionFilesChanged), - watcherJsonl.onDidChange(onSessionFilesChanged), - watcherJsonl.onDidDelete(onSessionFilesChanged), - watcherJson.onDidCreate(onSessionFilesChanged), - watcherJson.onDidChange(onSessionFilesChanged), - watcherJson.onDidDelete(onSessionFilesChanged), - ); + for (const storageRoot of storageRoots) { + const watcherJsonl = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(vscode.Uri.file(storageRoot), '**/chatSessions/*.jsonl'), + ); + const watcherJson = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(vscode.Uri.file(storageRoot), '**/chatSessions/*.json'), + ); + context.subscriptions.push( + watcherJsonl, + watcherJson, + watcherJsonl.onDidCreate(onSessionFilesChanged), + watcherJsonl.onDidChange(onSessionFilesChanged), + watcherJsonl.onDidDelete(onSessionFilesChanged), + watcherJson.onDidCreate(onSessionFilesChanged), + watcherJson.onDidChange(onSessionFilesChanged), + watcherJson.onDidDelete(onSessionFilesChanged), + ); + } // Fallback polling closes gaps on platforms/setups where external watcher // notifications are delayed or dropped. const pollForMissedChanges = async () => { try { - const nextSignature = await computeChatSessionsSignature(storageRoot); + const nextSignature = await computeChatSessionsSignature(storageRoots); if (lastSignature === undefined) { lastSignature = nextSignature; return; diff --git a/apps/vscode-extension/src/test/discovery.test.ts b/apps/vscode-extension/src/test/discovery.test.ts index 08ebff6..0e83fc6 100644 --- a/apps/vscode-extension/src/test/discovery.test.ts +++ b/apps/vscode-extension/src/test/discovery.test.ts @@ -3,7 +3,7 @@ import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; import { pathToFileURL } from 'url'; -import { findCurrentWorkspace, computeChatSessionsSignature } from '../core/discovery'; +import { getWorkspaceStorageRoots, findCurrentWorkspace, computeChatSessionsSignature, discoverWorkspaces } from '../core/discovery'; async function withTempStorage(run: (storageRoot: string, folderA: string, folderB: string, wsFilePath: string) => Promise): Promise { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'copilot-usage-discovery-')); @@ -58,7 +58,7 @@ async function withTempStorage(run: (storageRoot: string, folderA: string, folde suite('Discovery: current workspace matching', () => { test('prefers multi-root storage entry by referenced folders when workspaceFileUri is unavailable', async () => { await withTempStorage(async (storageRoot, folderA, folderB) => { - const match = await findCurrentWorkspace(undefined, [folderA, folderB], storageRoot); + const match = await findCurrentWorkspace(undefined, [folderA, folderB], [storageRoot]); assert.ok(match); assert.strictEqual(match!.workspaceId, 'multi-root-workspace-id'); }); @@ -66,7 +66,7 @@ suite('Discovery: current workspace matching', () => { test('still matches single-folder entry when only one folder is open', async () => { await withTempStorage(async (storageRoot, folderA) => { - const match = await findCurrentWorkspace(undefined, [folderA], storageRoot); + const match = await findCurrentWorkspace(undefined, [folderA], [storageRoot]); assert.ok(match); assert.strictEqual(match!.workspaceId, 'single-folder-workspace-id'); }); @@ -74,7 +74,7 @@ suite('Discovery: current workspace matching', () => { test('matches multi-root entry when workspaceFileUri is available', async () => { await withTempStorage(async (storageRoot, folderA, folderB, wsFilePath) => { - const match = await findCurrentWorkspace(pathToFileURL(wsFilePath).toString(), [folderA, folderB], storageRoot); + const match = await findCurrentWorkspace(pathToFileURL(wsFilePath).toString(), [folderA, folderB], [storageRoot]); assert.ok(match); assert.strictEqual(match!.workspaceId, 'multi-root-workspace-id'); }); @@ -82,17 +82,110 @@ suite('Discovery: current workspace matching', () => { test('chat session signature changes when tracked files change', async () => { await withTempStorage(async (storageRoot) => { - const before = await computeChatSessionsSignature(storageRoot); + const before = await computeChatSessionsSignature([storageRoot]); const jsonlPath = path.join(storageRoot, 'single-folder-workspace-id', 'chatSessions', 'single.jsonl'); await fs.appendFile(jsonlPath, '{"kind":1,"k":["requests",0,"completionTokens"],"v":42}\n', 'utf-8'); - const afterJsonlAppend = await computeChatSessionsSignature(storageRoot); + const afterJsonlAppend = await computeChatSessionsSignature([storageRoot]); assert.notStrictEqual(afterJsonlAppend, before); const jsonPath = path.join(storageRoot, 'single-folder-workspace-id', 'chatSessions', 'legacy.json'); await fs.writeFile(jsonPath, '{"requests":[]}', 'utf-8'); - const afterJsonCreate = await computeChatSessionsSignature(storageRoot); + const afterJsonCreate = await computeChatSessionsSignature([storageRoot]); assert.notStrictEqual(afterJsonCreate, afterJsonlAppend); }); }); }); + +suite('Discovery: getWorkspaceStorageRoots ordering', () => { + test('returns stable root first when appName is undefined', () => { + const roots = getWorkspaceStorageRoots(); + assert.strictEqual(roots.length, 2); + assert.ok(!roots[0].toLowerCase().includes('code - insiders'), `Expected stable root first, got: ${roots[0]}`); + assert.ok(roots[1].toLowerCase().includes('code - insiders'), `Expected Insiders root second, got: ${roots[1]}`); + }); + + test('returns stable root first when appName is "Visual Studio Code"', () => { + const roots = getWorkspaceStorageRoots('Visual Studio Code'); + assert.ok(!roots[0].toLowerCase().includes('code - insiders'), `Expected stable root first, got: ${roots[0]}`); + assert.ok(roots[1].toLowerCase().includes('code - insiders'), `Expected Insiders root second, got: ${roots[1]}`); + }); + + test('returns Insiders root first when appName contains "Insiders"', () => { + const roots = getWorkspaceStorageRoots('Visual Studio Code - Insiders'); + assert.ok(roots[0].toLowerCase().includes('code - insiders'), `Expected Insiders root first, got: ${roots[0]}`); + assert.ok(!roots[1].toLowerCase().includes('code - insiders'), `Expected stable root second, got: ${roots[1]}`); + }); +}); + +suite('Discovery: merged workspace entries across roots', () => { + test('merges same workspaceId from multiple storage roots and preserves matching', async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'copilot-usage-discovery-merge-')); + try { + const storageRootStable = path.join(tempRoot, 'stable', 'workspaceStorage'); + const storageRootInsiders = path.join(tempRoot, 'insiders', 'workspaceStorage'); + const workspaceId = 'shared-workspace-id'; + + const sharedFolderA = path.join(tempRoot, 'repo-shared-a'); + const sharedFolderB = path.join(tempRoot, 'repo-shared-b'); + await fs.mkdir(sharedFolderA, { recursive: true }); + await fs.mkdir(sharedFolderB, { recursive: true }); + + const wsFilePath = path.join(tempRoot, 'shared.code-workspace'); + await fs.writeFile( + wsFilePath, + JSON.stringify({ + folders: [ + { path: sharedFolderA }, + { path: sharedFolderB }, + ], + }), + 'utf-8', + ); + + const stableWsDir = path.join(storageRootStable, workspaceId); + await fs.mkdir(path.join(stableWsDir, 'chatSessions'), { recursive: true }); + await fs.writeFile( + path.join(stableWsDir, 'workspace.json'), + JSON.stringify({ workspace: pathToFileURL(wsFilePath).toString() }), + 'utf-8', + ); + await fs.writeFile(path.join(stableWsDir, 'chatSessions', 'stable.jsonl'), '{"kind":0}\n', 'utf-8'); + + const insidersWsDir = path.join(storageRootInsiders, workspaceId); + await fs.mkdir(path.join(insidersWsDir, 'chatSessions'), { recursive: true }); + await fs.writeFile( + path.join(insidersWsDir, 'workspace.json'), + JSON.stringify({ workspace: pathToFileURL(wsFilePath).toString() }), + 'utf-8', + ); + await fs.writeFile(path.join(insidersWsDir, 'chatSessions', 'insiders.jsonl'), '{"kind":0}\n', 'utf-8'); + + const workspaces = await discoverWorkspaces([storageRootStable, storageRootInsiders]); + assert.strictEqual(workspaces.length, 1, `Expected one merged workspace entry, got ${workspaces.length}`); + assert.strictEqual(workspaces[0].workspaceId, workspaceId); + assert.strictEqual(workspaces[0].sessionFiles.length, 2, 'Expected merged session files from both roots'); + assert.deepStrictEqual( + workspaces[0].referencedFolders, + [sharedFolderA, sharedFolderB], + 'Expected referenced folders to be de-duplicated while preserving order', + ); + assert.ok( + workspaces[0].sessionFiles.some((f) => f.endsWith(path.join('chatSessions', 'stable.jsonl'))), + 'Expected stable root session file to be preserved', + ); + assert.ok( + workspaces[0].sessionFiles.some((f) => f.endsWith(path.join('chatSessions', 'insiders.jsonl'))), + 'Expected Insiders root session file to be preserved', + ); + + const matched = await findCurrentWorkspace(undefined, [sharedFolderA, sharedFolderB], [storageRootStable, storageRootInsiders]); + assert.ok(matched); + assert.strictEqual(matched!.workspaceId, workspaceId); + assert.strictEqual(matched!.sessionFiles.length, 2); + assert.deepStrictEqual(matched!.referencedFolders, [sharedFolderA, sharedFolderB]); + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/vscode-extension/src/views/panels.ts b/apps/vscode-extension/src/views/panels.ts index 10b0491..555d356 100644 --- a/apps/vscode-extension/src/views/panels.ts +++ b/apps/vscode-extension/src/views/panels.ts @@ -1,7 +1,7 @@ /** Workspace-scoped analysis webview panel. */ import * as vscode from 'vscode'; -import { findCurrentWorkspace, discoverWorkspaces } from '../core/discovery'; +import { findCurrentWorkspace, discoverWorkspaces, getWorkspaceStorageRoots } from '../core/discovery'; import { parseAllFiles, flattenEvents, computeKpis, computeModelStats, computeDailyStats, computeWorkspaceStats } from '../core/aggregator'; import { computeRepoAttributionStats, discoverRepoDescriptors, RepoAttributionStats } from '../core/repoAttribution'; import { enableCostEstimator } from '../features/costEstimator/flags'; @@ -13,6 +13,7 @@ import { export class WorkspacePanel { public static currentPanel: WorkspacePanel | undefined; + private static storageRoots?: string[]; private readonly panel: vscode.WebviewPanel; private disposables: vscode.Disposable[] = []; private disposed = false; @@ -46,7 +47,8 @@ export class WorkspacePanel { ); } - public static async refresh(): Promise { + public static async refresh(storageRoots?: string[]): Promise { + WorkspacePanel.storageRoots = storageRoots ?? WorkspacePanel.storageRoots ?? getWorkspaceStorageRoots(vscode.env.appName); if (WorkspacePanel.currentPanel) { await WorkspacePanel.currentPanel.loadData(); } @@ -95,7 +97,8 @@ export class WorkspacePanel { const wsFileUri = vscode.workspace.workspaceFile?.toString(); const folderPaths = folders.map(f => f.uri.fsPath); - const ws = await findCurrentWorkspace(wsFileUri, folderPaths); + WorkspacePanel.storageRoots ??= getWorkspaceStorageRoots(vscode.env.appName); + const ws = await findCurrentWorkspace(wsFileUri, folderPaths, WorkspacePanel.storageRoots); if (!ws) { const searched = wsFileUri ? `workspace file: ${vscode.workspace.workspaceFile!.fsPath}` @@ -129,6 +132,7 @@ export class WorkspacePanel { /** Global dashboard webview panel. */ export class DashboardPanel { public static currentPanel: DashboardPanel | undefined; + private static storageRoots?: string[]; private readonly panel: vscode.WebviewPanel; private disposables: vscode.Disposable[] = []; private disposed = false; @@ -162,7 +166,8 @@ export class DashboardPanel { ); } - public static async refresh(): Promise { + public static async refresh(storageRoots?: string[]): Promise { + DashboardPanel.storageRoots = storageRoots ?? DashboardPanel.storageRoots ?? getWorkspaceStorageRoots(vscode.env.appName); if (DashboardPanel.currentPanel) { await DashboardPanel.currentPanel.loadData(); } @@ -203,7 +208,8 @@ export class DashboardPanel { const autoRefreshSeconds = cfg.get('dashboard.autoRefreshSeconds', 0); const showDebugLogBanner = !isCopilotDebugLogEnabled(); - const workspaces = await discoverWorkspaces(); + DashboardPanel.storageRoots ??= getWorkspaceStorageRoots(vscode.env.appName); + const workspaces = await discoverWorkspaces(DashboardPanel.storageRoots); if (workspaces.length === 0) { this.setHtml(getDashboardHtml(undefined, undefined, undefined, undefined, 'No Copilot session data found.', autoRefreshSeconds, 0, showDebugLogBanner)); return; diff --git a/apps/vscode-extension/src/views/statusBar.ts b/apps/vscode-extension/src/views/statusBar.ts index e82f170..99150dc 100644 --- a/apps/vscode-extension/src/views/statusBar.ts +++ b/apps/vscode-extension/src/views/statusBar.ts @@ -1,7 +1,7 @@ /** Status bar item showing workspace token count, split by input/output. */ import * as vscode from 'vscode'; -import { findCurrentWorkspace } from '../core/discovery'; +import { findCurrentWorkspace, getWorkspaceStorageRoots } from '../core/discovery'; import { parseAllFiles, flattenEvents } from '../core/aggregator'; import { RequestEvent } from '../core/types'; import { didAffectCopilotDebugLogSetting, isCopilotDebugLogEnabled } from '../core/copilotDebugLog'; @@ -12,6 +12,7 @@ export class StatusBarManager implements vscode.Disposable { private item: vscode.StatusBarItem; private debugLogItem: vscode.StatusBarItem; private disposables: vscode.Disposable[] = []; + private storageRoots?: string[]; constructor() { this.item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 50); @@ -30,11 +31,10 @@ export class StatusBarManager implements vscode.Disposable { if (e.affectsConfiguration('copilot-usage') || didAffectCopilotDebugLogSetting(e)) { this.refresh(); } }), ); - - this.refresh(); } - async refresh(): Promise { + async refresh(storageRoots?: string[]): Promise { + this.storageRoots = storageRoots ?? this.storageRoots ?? getWorkspaceStorageRoots(vscode.env.appName); if (isCopilotDebugLogEnabled()) { this.debugLogItem.hide(); } else { @@ -51,7 +51,7 @@ export class StatusBarManager implements vscode.Disposable { try { const wsFileUri = vscode.workspace.workspaceFile?.toString(); const folderPaths = folders.map(f => f.uri.fsPath); - const ws = await findCurrentWorkspace(wsFileUri, folderPaths); + const ws = await findCurrentWorkspace(wsFileUri, folderPaths, this.storageRoots); if (!ws) { this.item.text = '$(copilot) No data'; this.item.tooltip = 'Copilot Usage — No session data found for this workspace';