mirror of
https://github.com/tiennm99/ccstatusline.git
synced 2026-07-18 12:17:14 +00:00
* 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>
293 lines
11 KiB
TypeScript
293 lines
11 KiB
TypeScript
import {
|
|
Box,
|
|
Text,
|
|
useInput
|
|
} from 'ink';
|
|
import * as os from 'node:os';
|
|
import React, { useState } from 'react';
|
|
|
|
import type { RenderContext } from '../types/RenderContext';
|
|
import type { Settings } from '../types/Settings';
|
|
import type {
|
|
CustomKeybind,
|
|
Widget,
|
|
WidgetEditorDisplay,
|
|
WidgetEditorProps,
|
|
WidgetItem
|
|
} from '../types/Widget';
|
|
import { shouldInsertInput } from '../utils/input-guards';
|
|
|
|
export class CurrentWorkingDirWidget implements Widget {
|
|
getDefaultColor(): string { return 'blue'; }
|
|
getDescription(): string { return 'Shows the current working directory'; }
|
|
getDisplayName(): string { return 'Current Working Dir'; }
|
|
getCategory(): string { return 'Environment'; }
|
|
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) {
|
|
modifiers.push(`segments: ${segments}`);
|
|
}
|
|
|
|
return {
|
|
displayText: this.getDisplayName(),
|
|
modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined
|
|
};
|
|
}
|
|
|
|
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 and abbreviateHome (mutually exclusive)
|
|
const { segments, abbreviateHome, ...restMetadata } = item.metadata ?? {};
|
|
void segments;
|
|
void abbreviateHome;
|
|
return {
|
|
...item,
|
|
metadata: {
|
|
...restMetadata,
|
|
fishStyle: 'true'
|
|
}
|
|
};
|
|
} else {
|
|
// When disabling fish-style
|
|
const { fishStyle, ...restMetadata } = item.metadata ?? {};
|
|
void fishStyle;
|
|
|
|
return {
|
|
...item,
|
|
metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
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';
|
|
} else {
|
|
previewPath = '.../example/project';
|
|
}
|
|
} else {
|
|
previewPath = '/Users/example/Documents/Projects/my-project';
|
|
}
|
|
|
|
return item.rawValue ? previewPath : `cwd: ${previewPath}`;
|
|
}
|
|
|
|
const cwd = context.data?.cwd;
|
|
if (!cwd)
|
|
return null;
|
|
|
|
let displayPath = cwd;
|
|
|
|
if (fishStyle) {
|
|
displayPath = this.abbreviatePath(cwd);
|
|
} else {
|
|
// Apply home abbreviation first if enabled
|
|
if (abbreviateHome) {
|
|
displayPath = this.abbreviateHomeDir(displayPath);
|
|
}
|
|
|
|
// 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(/[\\/]+/);
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
return item.rawValue ? displayPath : `cwd: ${displayPath}`;
|
|
}
|
|
|
|
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' }
|
|
];
|
|
}
|
|
|
|
renderEditor(props: WidgetEditorProps): React.ReactElement {
|
|
return <CurrentWorkingDirEditor {...props} />;
|
|
}
|
|
|
|
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('/');
|
|
const sep = useBackslash ? '\\' : '/';
|
|
|
|
// Replace home directory with ~
|
|
let normalizedPath = path;
|
|
if (path.startsWith(homeDir)) {
|
|
normalizedPath = '~' + path.slice(homeDir.length);
|
|
}
|
|
|
|
// Split path into parts
|
|
const parts = normalizedPath.split(/[\\/]+/).filter(part => part !== '');
|
|
|
|
// Keep first and last parts full, abbreviate middle parts
|
|
const abbreviated = parts.map((part, index) => {
|
|
if (index === 0 || index === parts.length - 1) {
|
|
return part; // Keep full
|
|
}
|
|
|
|
// Hidden directories keep the dot
|
|
if (part.startsWith('.') && part.length > 1) {
|
|
return '.' + (part[1] ?? '');
|
|
}
|
|
|
|
return part[0]; // Only first letter for others
|
|
});
|
|
|
|
// Rebuild path
|
|
if (normalizedPath.startsWith('~')) {
|
|
return abbreviated.join(sep);
|
|
} else if (normalizedPath.startsWith('/')) {
|
|
return sep + abbreviated.join(sep);
|
|
} else {
|
|
return abbreviated.join(sep);
|
|
}
|
|
}
|
|
}
|
|
|
|
const CurrentWorkingDirEditor: React.FC<WidgetEditorProps> = ({ widget, onComplete, onCancel, action }) => {
|
|
const [segmentsInput, setSegmentsInput] = useState(widget.metadata?.segments ?? '');
|
|
|
|
useInput((input, key) => {
|
|
if (action === 'edit-segments') {
|
|
if (key.return) {
|
|
const segments = parseInt(segmentsInput, 10);
|
|
if (!isNaN(segments) && segments > 0) {
|
|
onComplete({
|
|
...widget,
|
|
metadata: {
|
|
...widget.metadata,
|
|
segments: segments.toString()
|
|
}
|
|
});
|
|
} else {
|
|
// Clear segments if blank or invalid
|
|
const { segments, ...restMetadata } = widget.metadata ?? {};
|
|
void segments; // Intentionally unused
|
|
onComplete({
|
|
...widget,
|
|
metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined
|
|
});
|
|
}
|
|
} else if (key.escape) {
|
|
onCancel();
|
|
} else if (key.backspace) {
|
|
setSegmentsInput(segmentsInput.slice(0, -1));
|
|
} else if (shouldInsertInput(input, key) && /\d/.test(input)) {
|
|
setSegmentsInput(segmentsInput + input);
|
|
}
|
|
}
|
|
});
|
|
|
|
if (action === 'edit-segments') {
|
|
return (
|
|
<Box flexDirection='column'>
|
|
<Box>
|
|
<Text>Enter number of segments to display (blank for full path): </Text>
|
|
<Text>{segmentsInput}</Text>
|
|
<Text backgroundColor='gray' color='black'>{' '}</Text>
|
|
</Box>
|
|
<Text dimColor>Press Enter to save, ESC to cancel</Text>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
return <Text>Unknown editor mode</Text>;
|
|
}; |