diff --git a/src/core/parser-harnesses.test.ts b/src/core/parser-harnesses.test.ts new file mode 100644 index 0000000..413bc04 --- /dev/null +++ b/src/core/parser-harnesses.test.ts @@ -0,0 +1,66 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Tests for external-harness source discovery used by the dashboard load gate, + * so a host with only non-VS Code logs (e.g. Claude Code on a headless + * Remote-SSH box, with no VS Code/Copilot directories) still loads. */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { describe, it, expect } from 'vitest'; +import { hasExternalHarnessSources } from './parser-harnesses'; + +function setEnv(key: 'HOME' | 'USERPROFILE', value: string | undefined): void { + if (value === undefined) delete process.env[key]; else process.env[key] = value; +} + +/** Run `body` with HOME/USERPROFILE pointed at a fresh temp dir, restoring the + * previous values (and removing the temp dir) afterwards. Self-contained so it + * leaks no env state across tests. */ +function withHome(setup: (home: string) => void, body: () => void): void { + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-home-')); + process.env.HOME = home; + process.env.USERPROFILE = home; + try { + setup(home); + body(); + } finally { + setEnv('HOME', prevHome); + setEnv('USERPROFILE', prevUserProfile); + fs.rmSync(home, { recursive: true, force: true }); + } +} + +describe('hasExternalHarnessSources', () => { + it('returns false when no external-harness directories exist', () => { + withHome(() => { /* empty home */ }, () => { + expect(hasExternalHarnessSources()).toBe(false); + }); + }); + + it('returns true when a Claude Code projects directory exists', () => { + withHome(home => { + fs.mkdirSync(path.join(home, '.claude', 'projects'), { recursive: true }); + }, () => { + expect(hasExternalHarnessSources()).toBe(true); + }); + }); + + it('returns false when no home directory is set (avoids relative-path probing)', () => { + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + setEnv('HOME', undefined); + setEnv('USERPROFILE', undefined); + try { + expect(hasExternalHarnessSources()).toBe(false); + } finally { + setEnv('HOME', prevHome); + setEnv('USERPROFILE', prevUserProfile); + } + }); +}); diff --git a/src/core/parser-harnesses.ts b/src/core/parser-harnesses.ts index a09adac..76b33e6 100644 --- a/src/core/parser-harnesses.ts +++ b/src/core/parser-harnesses.ts @@ -78,6 +78,19 @@ export interface ExternalHarnessProgressHandlers { yieldToLoop?: () => Promise; } +/** Returns true if any external-harness (Claude Code, Codex, OpenCode) session + * source exists on disk. The dashboard uses this so it does not abort when the + * only available logs come from a non-VS Code harness — e.g. a headless + * Remote-SSH host that has Claude Code sessions under `~/.claude/projects` but + * no VS Code workspace storage or Copilot directories. */ +export function hasExternalHarnessSources(): boolean { + // Without a home directory the find* helpers would join against an empty + // string and probe relative paths (e.g. `.claude/projects`) under the current + // working directory, which could report false positives. Bail out instead. + if (!process.env.HOME && !process.env.USERPROFILE) return false; + return findClaudeDirs().length > 0 || findCodexDirs().length > 0 || findOpenCodeDirs().length > 0; +} + export function collectExternalHarnessesSync(workspaces: WorkspaceMap, sessions: Session[]): void { const ctx: HarnessCollectionContext = { workspaces, sessions }; for (const harness of EXTERNAL_HARNESSES) { diff --git a/src/webview/panel.ts b/src/webview/panel.ts index 4d73624..485b7f7 100644 --- a/src/webview/panel.ts +++ b/src/webview/panel.ts @@ -9,6 +9,7 @@ import * as vscode from 'vscode'; import { Analyzer } from '../core/analyzer'; import { saveSidebarStats } from '../core/cache'; import { clearCache, findLogsDirs, parseAllLogsViaWorker, ParseResult } from '../core/parser'; +import { hasExternalHarnessSources } from '../core/parser-harnesses'; import { runtimeDebug } from '../core/runtime-debug'; import { WebviewMessage } from '../core/types'; import { panelCache } from './panel-cache'; @@ -203,11 +204,16 @@ export class DashboardPanel { if (this.disposed) return; const dirs = findLogsDirs(); - runtimeDebug('panel', 'logs-dirs-found', `count=${dirs.length}`); - if (dirs.length === 0) { + const hasExternal = hasExternalHarnessSources(); + runtimeDebug('panel', 'logs-dirs-found', `count=${dirs.length} external=${hasExternal}`); + // External harnesses (Claude Code, Codex, OpenCode) are collected by the + // parse worker independently of `dirs`, so only abort when no source of + // any kind is present. Otherwise a host with e.g. only Claude Code logs + // (and no VS Code/Copilot directories) would never load. + if (dirs.length === 0 && !hasExternal) { runtimeDebug('panel', 'loadData-no-dirs'); if (!this.disposed) { - try { this.panel.webview.html = getErrorHtml('No Copilot chat log directories found.'); } catch { /* disposed */ } + try { this.panel.webview.html = getErrorHtml('No AI coding session logs found. Looked for VS Code, GitHub Copilot (CLI and Xcode), Claude Code, Codex, and OpenCode sessions.'); } catch { /* disposed */ } } return; }