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:
parent
57b1eb8c4d
commit
5b4c57a7ff
|
|
@ -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('/')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue