diff --git a/src/widgets/CurrentWorkingDir.tsx b/src/widgets/CurrentWorkingDir.tsx index a2cb53e..8c9a307 100644 --- a/src/widgets/CurrentWorkingDir.tsx +++ b/src/widgets/CurrentWorkingDir.tsx @@ -25,8 +25,13 @@ export class CurrentWorkingDirWidget implements Widget { getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { const segments = item.metadata?.segments ? parseInt(item.metadata.segments, 10) : undefined; const fishStyle = item.metadata?.fishStyle === 'true'; + const abbreviateHome = item.metadata?.abbreviateHome === 'true'; const modifiers: string[] = []; + if (abbreviateHome) { + modifiers.push('~'); + } + if (fishStyle) { modifiers.push('fish-style'); } else if (segments && segments > 0) { @@ -40,15 +45,43 @@ export class CurrentWorkingDirWidget implements Widget { } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action === 'toggle-abbreviate-home') { + const currentAbbreviateHome = item.metadata?.abbreviateHome === 'true'; + const newAbbreviateHome = !currentAbbreviateHome; + + if (newAbbreviateHome) { + // When enabling abbreviateHome, disable fishStyle (mutually exclusive) + const { fishStyle, ...restMetadata } = item.metadata ?? {}; + void fishStyle; + return { + ...item, + metadata: { + ...restMetadata, + abbreviateHome: 'true' + } + }; + } else { + // When disabling abbreviateHome + const { abbreviateHome, ...restMetadata } = item.metadata ?? {}; + void abbreviateHome; + + return { + ...item, + metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined + }; + } + } + if (action === 'toggle-fish-style') { const currentFishStyle = item.metadata?.fishStyle === 'true'; const newFishStyle = !currentFishStyle; // Toggle fish style and clear segments if (newFishStyle) { - // When enabling fish-style, clear segments - const { segments, ...restMetadata } = item.metadata ?? {}; + // When enabling fish-style, clear segments and abbreviateHome (mutually exclusive) + const { segments, abbreviateHome, ...restMetadata } = item.metadata ?? {}; void segments; + void abbreviateHome; return { ...item, metadata: { @@ -74,12 +107,21 @@ export class CurrentWorkingDirWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { const segments = item.metadata?.segments ? parseInt(item.metadata.segments, 10) : undefined; const fishStyle = item.metadata?.fishStyle === 'true'; + const abbreviateHome = item.metadata?.abbreviateHome === 'true'; if (context.isPreview) { let previewPath: string; if (fishStyle) { previewPath = '~/D/P/my-project'; + } else if (abbreviateHome && segments && segments > 0) { + if (segments === 1) { + previewPath = '~/.../my-project'; + } else { + previewPath = '~/.../Projects/my-project'; + } + } else if (abbreviateHome) { + previewPath = '~/Documents/Projects/my-project'; } else if (segments && segments > 0) { if (segments === 1) { previewPath = '.../project'; @@ -101,19 +143,29 @@ export class CurrentWorkingDirWidget implements Widget { if (fishStyle) { displayPath = this.abbreviatePath(cwd); - } else if (segments && segments > 0) { - // Support both POSIX ('/') and Windows ('\\') separators; preserve original separator in output - const useBackslash = cwd.includes('\\') && !cwd.includes('/'); - const outSep = useBackslash ? '\\' : '/'; - const pathParts = cwd.split(/[\\/]+/); + } else { + // Apply home abbreviation first if enabled + if (abbreviateHome) { + displayPath = this.abbreviateHomeDir(displayPath); + } - // Remove empty strings from splitting (e.g., leading slash or UNC leading separators) - const filteredParts = pathParts.filter(part => part !== ''); + // Then apply segments truncation + if (segments && segments > 0) { + // Support both POSIX ('/') and Windows ('\\') separators; preserve original separator in output + const useBackslash = displayPath.includes('\\') && !displayPath.includes('/'); + const outSep = useBackslash ? '\\' : '/'; + const pathParts = displayPath.split(/[\\/]+/); - if (filteredParts.length > segments) { - // Take the last N segments and join with the detected separator - const selectedSegments = filteredParts.slice(-segments); - displayPath = '...' + outSep + selectedSegments.join(outSep); + // Remove empty strings from splitting (e.g., leading slash or UNC leading separators) + const filteredParts = pathParts.filter(part => part !== ''); + + if (filteredParts.length > segments) { + // Take the last N segments and join with the detected separator + const selectedSegments = filteredParts.slice(-segments); + // Preserve ~ prefix when combined with segments + const prefix = displayPath.startsWith('~') ? `~${outSep}` : ''; + displayPath = prefix + '...' + outSep + selectedSegments.join(outSep); + } } } @@ -122,6 +174,7 @@ export class CurrentWorkingDirWidget implements Widget { getCustomKeybinds(): CustomKeybind[] { return [ + { key: 'h', label: '(h)ome ~', action: 'toggle-abbreviate-home' }, { key: 's', label: '(s)egments', action: 'edit-segments' }, { key: 'f', label: '(f)ish style', action: 'toggle-fish-style' } ]; @@ -134,6 +187,22 @@ export class CurrentWorkingDirWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + private abbreviateHomeDir(path: string): string { + const homeDir = os.homedir(); + if (path === homeDir) { + return '~'; + } + + if (path.startsWith(homeDir)) { + const boundaryChar = path[homeDir.length]; + if (boundaryChar !== '/' && boundaryChar !== '\\') { + return path; + } + return '~' + path.slice(homeDir.length); + } + return path; + } + private abbreviatePath(path: string): string { const homeDir = os.homedir(); const useBackslash = path.includes('\\') && !path.includes('/'); diff --git a/src/widgets/__tests__/CurrentWorkingDir.test.ts b/src/widgets/__tests__/CurrentWorkingDir.test.ts new file mode 100644 index 0000000..a75c802 --- /dev/null +++ b/src/widgets/__tests__/CurrentWorkingDir.test.ts @@ -0,0 +1,207 @@ +import * as os from 'node:os'; +import { + afterEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import type { Settings } from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import { CurrentWorkingDirWidget } from '../CurrentWorkingDir'; + +describe('CurrentWorkingDirWidget', () => { + const widget = new CurrentWorkingDirWidget(); + const homeDir = os.homedir(); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const createContext = (cwd?: string, isPreview = false): RenderContext => ({ + data: cwd ? { cwd } : undefined, + isPreview + }); + + const defaultSettings: Settings = { + version: 3, + lines: [], + flexMode: 'full', + compactThreshold: 60, + colorLevel: 2, + defaultPadding: ' ', + inheritSeparatorColors: false, + globalBold: false, + powerline: { + enabled: false, + separators: [], + separatorInvertBackground: [], + startCaps: [], + endCaps: [], + autoAlign: false + } + }; + + const createItem = ( + metadata?: Record, + rawValue = false + ): WidgetItem => ({ + id: 'test', + type: 'current-working-dir', + backgroundColor: 'bgBlue', + rawValue, + metadata + }); + + describe('abbreviateHome', () => { + it('should replace home directory with ~ when enabled', () => { + const item = createItem({ abbreviateHome: 'true' }, true); + const result = widget.render( + item, + createContext(`${homeDir}/Documents/Projects`), + defaultSettings + ); + expect(result).toBe('~/Documents/Projects'); + }); + + it('should not replace home directory when disabled', () => { + const item = createItem(undefined, true); + const result = widget.render( + item, + createContext(`${homeDir}/Documents/Projects`), + defaultSettings + ); + expect(result).toBe(`${homeDir}/Documents/Projects`); + }); + + it('should not modify paths outside home directory', () => { + const item = createItem({ abbreviateHome: 'true' }, true); + const result = widget.render( + item, + createContext('/var/log/app'), + defaultSettings + ); + expect(result).toBe('/var/log/app'); + }); + + it('should not abbreviate non-home sibling paths with shared prefix', () => { + vi.spyOn(os, 'homedir').mockReturnValue('/Users/al'); + + const item = createItem({ abbreviateHome: 'true' }, true); + const result = widget.render( + item, + createContext('/Users/alex/project'), + defaultSettings + ); + + expect(result).toBe('/Users/alex/project'); + }); + + it('should combine with segments option', () => { + const item = createItem({ abbreviateHome: 'true', segments: '2' }, true); + const result = widget.render( + item, + createContext(`${homeDir}/Documents/Projects/my-project`), + defaultSettings + ); + expect(result).toBe('~/.../Projects/my-project'); + }); + + it('should show correct preview when abbreviateHome is enabled', () => { + const item = createItem({ abbreviateHome: 'true' }, true); + const result = widget.render( + item, + createContext(undefined, true), + defaultSettings + ); + expect(result).toBe('~/Documents/Projects/my-project'); + }); + + it('should show correct preview when abbreviateHome and segments are enabled', () => { + const item = createItem({ abbreviateHome: 'true', segments: '2' }, true); + const result = widget.render( + item, + createContext(undefined, true), + defaultSettings + ); + expect(result).toBe('~/.../Projects/my-project'); + }); + + it('should show correct preview when abbreviateHome and one segment are enabled', () => { + const item = createItem({ abbreviateHome: 'true', segments: '1' }, true); + const result = widget.render( + item, + createContext(undefined, true), + defaultSettings + ); + expect(result).toBe('~/.../my-project'); + }); + + it('should preserve windows path separators when combining home abbreviation and segments', () => { + vi.spyOn(os, 'homedir').mockReturnValue('C:\\Users\\alice'); + + const item = createItem({ abbreviateHome: 'true', segments: '2' }, true); + const result = widget.render( + item, + createContext('C:\\Users\\alice\\Documents\\Projects\\app'), + defaultSettings + ); + + expect(result).toBe('~\\...\\Projects\\app'); + }); + }); + + describe('getEditorDisplay', () => { + it('should show ~ modifier when abbreviateHome is enabled', () => { + const item = createItem({ abbreviateHome: 'true' }); + const display = widget.getEditorDisplay(item); + expect(display.modifierText).toBe('(~)'); + }); + + it('should show ~ and segments modifiers combined', () => { + const item = createItem({ abbreviateHome: 'true', segments: '2' }); + const display = widget.getEditorDisplay(item); + expect(display.modifierText).toBe('(~, segments: 2)'); + }); + + it('should not show ~ modifier when fishStyle is enabled', () => { + const item = createItem({ fishStyle: 'true' }); + const display = widget.getEditorDisplay(item); + expect(display.modifierText).toBe('(fish-style)'); + }); + }); + + describe('handleEditorAction', () => { + it('should enable abbreviateHome and disable fishStyle', () => { + const item = createItem({ fishStyle: 'true' }); + const result = widget.handleEditorAction('toggle-abbreviate-home', item); + expect(result?.metadata?.abbreviateHome).toBe('true'); + expect(result?.metadata?.fishStyle).toBeUndefined(); + }); + + it('should disable abbreviateHome when toggled off', () => { + const item = createItem({ abbreviateHome: 'true' }); + const result = widget.handleEditorAction('toggle-abbreviate-home', item); + expect(result?.metadata?.abbreviateHome).toBeUndefined(); + }); + + it('should enable fishStyle and disable abbreviateHome', () => { + const item = createItem({ abbreviateHome: 'true' }); + const result = widget.handleEditorAction('toggle-fish-style', item); + expect(result?.metadata?.fishStyle).toBe('true'); + expect(result?.metadata?.abbreviateHome).toBeUndefined(); + }); + }); + + describe('getCustomKeybinds', () => { + it('should include home ~ keybind', () => { + const keybinds = widget.getCustomKeybinds(); + const homeKeybind = keybinds.find(k => k.key === 'h'); + expect(homeKeybind).toBeDefined(); + expect(homeKeybind?.label).toBe('(h)ome ~'); + expect(homeKeybind?.action).toBe('toggle-abbreviate-home'); + }); + }); +}); \ No newline at end of file