mirror of
https://github.com/tiennm99/ccstatusline.git
synced 2026-10-13 08:15:18 +00:00
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:
2 files changed
+289
-13
No files matched your search
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user