From 5b4c57a7ffed913ee69ba00c487f0fbe137a1c14 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 1 Aug 2026 02:32:28 -0500 Subject: [PATCH] feat(desktop): shared path display collapses home to ~ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One paint helper for UI chrome: /Users/x/y → ~/y (also /home and C:\Users). Copy/reveal still use the real absolute path. --- apps/desktop/src/lib/display-path.test.ts | 47 +++++++ apps/desktop/src/lib/display-path.ts | 157 ++++++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 apps/desktop/src/lib/display-path.test.ts create mode 100644 apps/desktop/src/lib/display-path.ts diff --git a/apps/desktop/src/lib/display-path.test.ts b/apps/desktop/src/lib/display-path.test.ts new file mode 100644 index 0000000000000..426d8905467cc --- /dev/null +++ b/apps/desktop/src/lib/display-path.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' + +import { displayPath, normalizeDisplayPath, pathLeaf } from './display-path' + +describe('displayPath', () => { + it('collapses a macOS home prefix to ~', () => { + expect(displayPath('/Users/brooklyn/www/hermes-agent')).toBe('~/www/hermes-agent') + expect(displayPath('/Users/brooklyn')).toBe('~') + }) + + it('collapses a Linux home prefix to ~', () => { + expect(displayPath('/home/alice/src/app')).toBe('~/src/app') + }) + + it('collapses a Windows user profile to ~', () => { + expect(displayPath('C:\\Users\\brooklyn\\src')).toBe('~/src') + expect(displayPath('C:/Users/brooklyn')).toBe('~') + }) + + it('honours an explicit home override', () => { + expect(displayPath('/opt/work/repo', { home: '/opt/work' })).toBe('~/repo') + expect(displayPath('/elsewhere/repo', { home: '/opt/work' })).toBe('/elsewhere/repo') + }) + + it('leaves non-home absolute paths alone', () => { + expect(displayPath('/var/log/system.log')).toBe('/var/log/system.log') + expect(displayPath('/Users')).toBe('/Users') + }) + + it('normalizes separators and trailing slashes', () => { + expect(normalizeDisplayPath('C:\\Users\\me\\src\\')).toBe('C:/Users/me/src') + expect(displayPath('/Users/me/src/')).toBe('~/src') + }) + + it('keeps an already-tildified path', () => { + expect(displayPath('~/www/app')).toBe('~/www/app') + expect(displayPath('~')).toBe('~') + }) +}) + +describe('pathLeaf', () => { + it('returns the last segment', () => { + expect(pathLeaf('/Users/me/www/hermes-agent')).toBe('hermes-agent') + expect(pathLeaf('~/www/hermes-agent')).toBe('hermes-agent') + expect(pathLeaf('/')).toBe('/') + }) +}) diff --git a/apps/desktop/src/lib/display-path.ts b/apps/desktop/src/lib/display-path.ts new file mode 100644 index 0000000000000..d065a00d06be6 --- /dev/null +++ b/apps/desktop/src/lib/display-path.ts @@ -0,0 +1,157 @@ +/** + * One place to format filesystem paths for DISPLAY. + * + * Industry standard (shells, VS Code `tildify`, Finder path bar): collapse the + * user's home directory to `~`, keep forward slashes, leave everything else + * alone. Copy/reveal/IPC still use the real absolute path — this is paint only. + * + * When `home` is unknown (renderer has no `os.homedir()` and remote cwd may + * not match the local machine), a conservative heuristic still collapses the + * common `/Users/`, `/home/`, and `C:/Users/` prefixes so a + * long absolute path never paints raw in chrome. + */ + +export interface DisplayPathOptions { + /** Explicit home directory to collapse (local machine home, remote $HOME). */ + home?: null | string +} + +/** Normalize separators and drop a trailing slash (except root / drive root). */ +export function normalizeDisplayPath(raw: string): string { + let path = (raw || '').trim().replace(/\\/g, '/') + + if (!path) { + return '' + } + + // Collapse repeated slashes, but keep a leading UNC `//server/...` pair. + if (path.startsWith('//')) { + path = `//${path.slice(2).replace(/\/{2,}/g, '/')}` + } else { + path = path.replace(/\/{2,}/g, '/') + } + + // Drop trailing slash except bare `/` or `C:/`. + if (path.length > 1 && path.endsWith('/') && !/^[A-Za-z]:\/$/.test(path)) { + path = path.replace(/\/+$/, '') + } + + return path +} + +function normalizeHome(home: string): string { + const normalized = normalizeDisplayPath(home) + + if (!normalized) { + return '' + } + + // Home itself should not keep a trailing slash for prefix checks. + return normalized.replace(/\/+$/, '') +} + +function startsWithHome(path: string, home: string, caseInsensitive: boolean): boolean { + if (!home) { + return false + } + + if (path === home) { + return true + } + + const prefix = `${home}/` + + return caseInsensitive + ? path.toLowerCase().startsWith(prefix.toLowerCase()) || path.toLowerCase() === home.toLowerCase() + : path.startsWith(prefix) || path === home +} + +/** + * Best-effort home prefix when callers don't pass one. Matches the usual + * single-user layouts; never collapses `/Users` or `/home` alone. + */ +function inferredHomePrefix(path: string): string { + // macOS: /Users/name[/...] + let match = path.match(/^(\/Users\/[^/]+)(?:\/|$)/) + + if (match) { + return match[1] + } + + // Linux (and most UNIX): /home/name[/...] + match = path.match(/^(\/home\/[^/]+)(?:\/|$)/) + + if (match) { + return match[1] + } + + // Windows user profile: C:/Users/name[/...] (also works after \ → /) + match = path.match(/^([A-Za-z]:\/Users\/[^/]+)(?:\/|$)/) + + if (match) { + return match[1] + } + + return '' +} + +/** + * Format a filesystem path for UI chrome. + * + * /Users/brooklyn/www/hermes-agent → ~/www/hermes-agent + * /Users/brooklyn → ~ + * C:\Users\brooklyn\src → ~/src + * /var/log → /var/log + * already/relative → already/relative + */ +export function displayPath(raw: null | string | undefined, options: DisplayPathOptions = {}): string { + const path = normalizeDisplayPath(raw || '') + + if (!path) { + return '' + } + + // Already tildified — normalize only. + if (path === '~' || path.startsWith('~/')) { + return path + } + + const explicitHome = options.home ? normalizeHome(options.home) : '' + // Windows paths are case-insensitive; POSIX paths with an explicit home keep + // case-sensitive matching (Linux). Heuristic homes on mac/win ignore case. + const home = explicitHome || inferredHomePrefix(path) + + if (!home) { + return path + } + + const caseInsensitive = !explicitHome || /^[A-Za-z]:\//.test(home) || home.startsWith('/Users/') + + if (!startsWithHome(path, home, caseInsensitive)) { + return path + } + + if (path.length === home.length) { + return '~' + } + + return `~${path.slice(home.length)}` +} + +/** Last path segment for compact labels (statusbar leaf, settings rows). */ +export function pathLeaf(raw: null | string | undefined): string { + const path = normalizeDisplayPath(raw || '') + + if (!path || path === '/' || path === '~') { + return path + } + + // `C:/` drive root + if (/^[A-Za-z]:\/$/.test(path) || /^[A-Za-z]:$/.test(path)) { + return path.endsWith('/') ? path : `${path}/` + } + + const leaf = path.split('/').filter(Boolean).pop() + + return leaf || path +}