feat: add abbreviate home option to Current Working Directory widget (#144)

* feat: add abbreviate home option to Current Working Directory widget

Add a new 'abbreviate home' toggle (h key) that replaces the home
directory path with ~ (e.g., /Users/matt/Projects → ~/Projects).

- Can be combined with segments for paths like ~/.../Projects/my-project
- Mutually exclusive with fish-style abbreviation
- Includes tests for the new functionality

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: correct cwd home abbreviation edge cases

Preserve platform path separators when combining home abbreviation with segments.
Require exact home-directory boundary matching to avoid false substitutions.
Align one-segment abbreviateHome preview with runtime output.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-02-21 11:17:41 -05:00
1 parent 7cf85dffb1
commit 0092c241a6
2 files changed
+289 -13

No files matched your search

+82 -13
View File
@@ -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('/');
@@ -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<string, string>,
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');
});
});
});