mirror of
https://github.com/tiennm99/ccstatusline.git
synced 2026-05-30 20:22:40 +00:00
Add Git Root Dir widget (#119)
* Add Git Root Dir widget Add a new widget that displays the git repository root directory name. - Shows directory name with 📁 icon (e.g., "📁 my-repo") - Supports rawValue option for icon-free display - Supports hideNoGit option to hide message when not in a git repo 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Change Git Root Dir widget icon to text label Replace 📁 emoji with "Repo:" text label for better compatibility. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Remove label from Git Root Dir widget Display only the repository name without any prefix. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix GitRootDir root path handling and raw mode --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
This commit is contained in:
@@ -391,9 +391,13 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
||||
onUpdate(newWidgets);
|
||||
}
|
||||
} else if (input === 'r' && widgets.length > 0) {
|
||||
// Toggle raw value for non-separator items
|
||||
// Toggle raw value for widgets that support it
|
||||
const currentWidget = widgets[selectedIndex];
|
||||
if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator' && currentWidget.type !== 'custom-text') {
|
||||
if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') {
|
||||
const widgetImpl = getWidget(currentWidget.type);
|
||||
if (!widgetImpl?.supportsRawValue()) {
|
||||
return;
|
||||
}
|
||||
const newWidgets = [...widgets];
|
||||
newWidgets[selectedIndex] = { ...currentWidget, rawValue: !currentWidget.rawValue };
|
||||
onUpdate(newWidgets);
|
||||
@@ -767,6 +771,7 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
||||
const isSelected = index === selectedIndex;
|
||||
const widgetImpl = widget.type !== 'separator' && widget.type !== 'flex-separator' ? getWidget(widget.type) : null;
|
||||
const { displayText, modifierText } = widgetImpl?.getEditorDisplay(widget) ?? { displayText: getWidgetDisplay(widget) };
|
||||
const supportsRawValue = widgetImpl?.supportsRawValue() ?? false;
|
||||
|
||||
return (
|
||||
<Box key={widget.id} flexDirection='row' flexWrap='nowrap'>
|
||||
@@ -784,7 +789,7 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
||||
{modifierText}
|
||||
</Text>
|
||||
)}
|
||||
{widget.rawValue && <Text dimColor> (raw value)</Text>}
|
||||
{supportsRawValue && widget.rawValue && <Text dimColor> (raw value)</Text>}
|
||||
{widget.merge === true && <Text dimColor> (merged→)</Text>}
|
||||
{widget.merge === 'no-padding' && <Text dimColor> (merged-no-pad→)</Text>}
|
||||
</Box>
|
||||
|
||||
@@ -11,6 +11,7 @@ const widgetRegistry = new Map<WidgetItemType, Widget>([
|
||||
['output-style', new widgets.OutputStyleWidget()],
|
||||
['git-branch', new widgets.GitBranchWidget()],
|
||||
['git-changes', new widgets.GitChangesWidget()],
|
||||
['git-root-dir', new widgets.GitRootDirWidget()],
|
||||
['git-worktree', new widgets.GitWorktreeWidget()],
|
||||
['current-working-dir', new widgets.CurrentWorkingDirWidget()],
|
||||
['tokens-input', new widgets.TokensInputWidget()],
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
import type { RenderContext } from '../types/RenderContext';
|
||||
import type { Settings } from '../types/Settings';
|
||||
import type {
|
||||
CustomKeybind,
|
||||
Widget,
|
||||
WidgetEditorDisplay,
|
||||
WidgetItem
|
||||
} from '../types/Widget';
|
||||
|
||||
export class GitRootDirWidget implements Widget {
|
||||
getDefaultColor(): string { return 'cyan'; }
|
||||
getDescription(): string { return 'Shows the git repository root directory name'; }
|
||||
getDisplayName(): string { return 'Git Root Dir'; }
|
||||
getCategory(): string { return 'Git'; }
|
||||
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
|
||||
const hideNoGit = item.metadata?.hideNoGit === 'true';
|
||||
const modifiers: string[] = [];
|
||||
|
||||
if (hideNoGit) {
|
||||
modifiers.push('hide \'no git\'');
|
||||
}
|
||||
|
||||
return {
|
||||
displayText: this.getDisplayName(),
|
||||
modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined
|
||||
};
|
||||
}
|
||||
|
||||
handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
|
||||
if (action === 'toggle-nogit') {
|
||||
const currentState = item.metadata?.hideNoGit === 'true';
|
||||
return {
|
||||
...item,
|
||||
metadata: {
|
||||
...item.metadata,
|
||||
hideNoGit: (!currentState).toString()
|
||||
}
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null {
|
||||
const hideNoGit = item.metadata?.hideNoGit === 'true';
|
||||
|
||||
if (context.isPreview) {
|
||||
return 'my-repo';
|
||||
}
|
||||
|
||||
const rootDir = this.getGitRootDir();
|
||||
if (rootDir) {
|
||||
return this.getRootDirName(rootDir);
|
||||
}
|
||||
|
||||
return hideNoGit ? null : 'no git';
|
||||
}
|
||||
|
||||
private getGitRootDir(): string | null {
|
||||
try {
|
||||
const rootDir = execSync('git rev-parse --show-toplevel', {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore']
|
||||
}).trim();
|
||||
return rootDir || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private getRootDirName(rootDir: string): string {
|
||||
const trimmedRootDir = rootDir.replace(/[\\/]+$/, '');
|
||||
const normalizedRootDir = trimmedRootDir.length > 0 ? trimmedRootDir : rootDir;
|
||||
const parts = normalizedRootDir.split(/[\\/]/).filter(Boolean);
|
||||
const lastPart = parts[parts.length - 1];
|
||||
return lastPart && lastPart.length > 0 ? lastPart : normalizedRootDir;
|
||||
}
|
||||
|
||||
getCustomKeybinds(): CustomKeybind[] {
|
||||
return [
|
||||
{ key: 'h', label: '(h)ide \'no git\' message', action: 'toggle-nogit' }
|
||||
];
|
||||
}
|
||||
|
||||
supportsRawValue(): boolean { return false; }
|
||||
supportsColors(item: WidgetItem): boolean { return true; }
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { execSync } from 'child_process';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import type { RenderContext } from '../../types/RenderContext';
|
||||
import { DEFAULT_SETTINGS } from '../../types/Settings';
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
import { GitRootDirWidget } from '../GitRootDir';
|
||||
|
||||
vi.mock('child_process', () => ({ execSync: vi.fn() }));
|
||||
|
||||
const mockExecSync = execSync as unknown as {
|
||||
mockReturnValue: (value: string) => void;
|
||||
mockImplementation: (impl: () => never) => void;
|
||||
};
|
||||
|
||||
function render(options: { isPreview?: boolean; hideNoGit?: boolean } = {}) {
|
||||
const widget = new GitRootDirWidget();
|
||||
const context: RenderContext = { isPreview: options.isPreview };
|
||||
const item: WidgetItem = {
|
||||
id: 'git-root-dir',
|
||||
type: 'git-root-dir',
|
||||
metadata: options.hideNoGit ? { hideNoGit: 'true' } : undefined
|
||||
};
|
||||
|
||||
return widget.render(item, context, DEFAULT_SETTINGS);
|
||||
}
|
||||
|
||||
describe('GitRootDirWidget', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render preview', () => {
|
||||
expect(render({ isPreview: true })).toBe('my-repo');
|
||||
});
|
||||
|
||||
it('should render root directory name', () => {
|
||||
mockExecSync.mockReturnValue('/some/path/my-repo');
|
||||
|
||||
expect(render()).toBe('my-repo');
|
||||
});
|
||||
|
||||
it('should handle trailing separators', () => {
|
||||
mockExecSync.mockReturnValue('/some/path/my-repo/');
|
||||
|
||||
expect(render()).toBe('my-repo');
|
||||
});
|
||||
|
||||
it('should render unix root path without returning empty output', () => {
|
||||
mockExecSync.mockReturnValue('/');
|
||||
|
||||
expect(render()).toBe('/');
|
||||
});
|
||||
|
||||
it('should render windows drive root without returning empty output', () => {
|
||||
mockExecSync.mockReturnValue('C:/');
|
||||
|
||||
expect(render()).toBe('C:');
|
||||
});
|
||||
|
||||
it('should render no git when command fails', () => {
|
||||
mockExecSync.mockImplementation(() => { throw new Error('No git'); });
|
||||
|
||||
expect(render()).toBe('no git');
|
||||
});
|
||||
|
||||
it('should hide no git when configured', () => {
|
||||
mockExecSync.mockImplementation(() => { throw new Error('No git'); });
|
||||
|
||||
expect(render({ hideNoGit: true })).toBeNull();
|
||||
});
|
||||
|
||||
it('should disable raw value support', () => {
|
||||
const widget = new GitRootDirWidget();
|
||||
|
||||
expect(widget.supportsRawValue()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ export { ModelWidget } from './Model';
|
||||
export { OutputStyleWidget } from './OutputStyle';
|
||||
export { GitBranchWidget } from './GitBranch';
|
||||
export { GitChangesWidget } from './GitChanges';
|
||||
export { GitRootDirWidget } from './GitRootDir';
|
||||
export { GitWorktreeWidget } from './GitWorktree';
|
||||
export { TokensInputWidget } from './TokensInput';
|
||||
export { TokensOutputWidget } from './TokensOutput';
|
||||
|
||||
Reference in New Issue
Block a user