diff --git a/src/core/parser-claude.test.ts b/src/core/parser-claude.test.ts index 9c4442d0..5569c71d 100644 --- a/src/core/parser-claude.test.ts +++ b/src/core/parser-claude.test.ts @@ -431,6 +431,40 @@ describe('parseClaudeSessions', () => { } }); + // ---- workspace name resolution with characters outside [A-Za-z0-9] ---- + + it.each(['my_home_dir', 'my.app', '.config', 'café'])( + 'resolves workspace name when a parent folder is %s', { timeout: 30_000 }, (parentName) => { + // Claude Code replaces every character outside [A-Za-z0-9] with a hyphen, + // so the encoded name only matches readdirSync output if we apply the same rule. + const tmpBase = fs.mkdtempSync(path.join(longTmpDir(), 'claude-enc-')); + const targetDir = path.join(tmpBase, parentName, 'proj'); + fs.mkdirSync(targetDir, { recursive: true }); + + const encodedDirName = targetDir + .replace(/^([a-zA-Z])(?=:)/, d => d.toLowerCase()) + .replaceAll(/[^a-zA-Z0-9]/g, '-'); + + const root = fs.mkdtempSync(path.join(longTmpDir(), 'claude-proj-')); + const projectsDir = path.join(root, 'projects'); + const projDir = path.join(projectsDir, encodedDirName); + fs.mkdirSync(projDir, { recursive: true }); + fs.writeFileSync( + path.join(projDir, 's.jsonl'), + [makeUser('hello'), makeAssistant('hi')].map(l => JSON.stringify(l)).join('\n'), + ); + + try { + const result = parseClaudeSessions(projectsDir); + expect(result).toHaveLength(1); + expect(result[0].workspaceName).toBe('proj'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(tmpBase, { recursive: true, force: true }); + } + }, + ); + // ---- workspace name resolution with spaces ---- it('resolves workspace name when path contains a folder with spaces', { timeout: 30_000 }, () => { diff --git a/src/core/parser-claude.ts b/src/core/parser-claude.ts index 18e1a316..500ba74e 100644 --- a/src/core/parser-claude.ts +++ b/src/core/parser-claude.ts @@ -452,19 +452,20 @@ export function findClaudeDirs(): string[] { } /** Encode a single filesystem component the way Claude Code does: - * replace whitespace with hyphens. Path separators never appear in a single + * replace every character outside A-Z, a-z and 0-9 with a hyphen (one per + * UTF-16 code unit). Path separators never appear in a single * component name, and colons are not handled here because Windows component * names cannot contain them. */ function encodeComponentForMatch(name: string): string { - return name.replace(/\s/g, '-'); + return name.replaceAll(/[^a-zA-Z0-9]/g, '-'); } /** * Resolve an encoded Claude project directory name back to the real folder name. * - * Claude encodes workspace paths by replacing `/`, `\`, `:`, and whitespace - * with `-`. For example `c--dev-AI-Engineering-Coach` represents - * `C:\dev\AI-Engineering-Coach`. Because the encoding is lossy (hyphens, spaces, + * Claude encodes workspace paths by replacing every character outside + * A-Z, a-z and 0-9 (one per UTF-16 code unit) with `-`. For example + * `c--dev-AI-Engineering-Coach` represents `C:\dev\AI-Engineering-Coach`. Because the encoding is lossy (hyphens, spaces, * and path separators all become `-`) we resolve the name by listing each * directory level and matching entries against the remaining encoded string. *