feat(desktop): shared path display collapses home to ~

One paint helper for UI chrome: /Users/x/y → ~/y (also /home and
C:\Users). Copy/reveal still use the real absolute path.
This commit is contained in:
Brooklyn Nicholson 2026-08-01 02:32:28 -05:00
parent 57b1eb8c4d
commit 5b4c57a7ff
2 changed files with 204 additions and 0 deletions

View File

@ -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('/')
})
})

View File

@ -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/<name>`, `/home/<name>`, and `C:/Users/<name>` 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
}