mirror of
https://github.com/tiennm99/ccstatusline.git
synced 2026-10-19 08:14:55 +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:
5 files changed
+182
-3
No files matched your search
@@ -391,9 +391,13 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
|||||||
onUpdate(newWidgets);
|
onUpdate(newWidgets);
|
||||||
}
|
}
|
||||||
} else if (input === 'r' && widgets.length > 0) {
|
} 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];
|
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];
|
const newWidgets = [...widgets];
|
||||||
newWidgets[selectedIndex] = { ...currentWidget, rawValue: !currentWidget.rawValue };
|
newWidgets[selectedIndex] = { ...currentWidget, rawValue: !currentWidget.rawValue };
|
||||||
onUpdate(newWidgets);
|
onUpdate(newWidgets);
|
||||||
@@ -767,6 +771,7 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
|||||||
const isSelected = index === selectedIndex;
|
const isSelected = index === selectedIndex;
|
||||||
const widgetImpl = widget.type !== 'separator' && widget.type !== 'flex-separator' ? getWidget(widget.type) : null;
|
const widgetImpl = widget.type !== 'separator' && widget.type !== 'flex-separator' ? getWidget(widget.type) : null;
|
||||||
const { displayText, modifierText } = widgetImpl?.getEditorDisplay(widget) ?? { displayText: getWidgetDisplay(widget) };
|
const { displayText, modifierText } = widgetImpl?.getEditorDisplay(widget) ?? { displayText: getWidgetDisplay(widget) };
|
||||||
|
const supportsRawValue = widgetImpl?.supportsRawValue() ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box key={widget.id} flexDirection='row' flexWrap='nowrap'>
|
<Box key={widget.id} flexDirection='row' flexWrap='nowrap'>
|
||||||
@@ -784,7 +789,7 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
|||||||
{modifierText}
|
{modifierText}
|
||||||
</Text>
|
</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 === true && <Text dimColor> (merged→)</Text>}
|
||||||
{widget.merge === 'no-padding' && <Text dimColor> (merged-no-pad→)</Text>}
|
{widget.merge === 'no-padding' && <Text dimColor> (merged-no-pad→)</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const widgetRegistry = new Map<WidgetItemType, Widget>([
|
|||||||
['output-style', new widgets.OutputStyleWidget()],
|
['output-style', new widgets.OutputStyleWidget()],
|
||||||
['git-branch', new widgets.GitBranchWidget()],
|
['git-branch', new widgets.GitBranchWidget()],
|
||||||
['git-changes', new widgets.GitChangesWidget()],
|
['git-changes', new widgets.GitChangesWidget()],
|
||||||
|
['git-root-dir', new widgets.GitRootDirWidget()],
|
||||||
['git-worktree', new widgets.GitWorktreeWidget()],
|
['git-worktree', new widgets.GitWorktreeWidget()],
|
||||||
['current-working-dir', new widgets.CurrentWorkingDirWidget()],
|
['current-working-dir', new widgets.CurrentWorkingDirWidget()],
|
||||||
['tokens-input', new widgets.TokensInputWidget()],
|
['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 { OutputStyleWidget } from './OutputStyle';
|
||||||
export { GitBranchWidget } from './GitBranch';
|
export { GitBranchWidget } from './GitBranch';
|
||||||
export { GitChangesWidget } from './GitChanges';
|
export { GitChangesWidget } from './GitChanges';
|
||||||
|
export { GitRootDirWidget } from './GitRootDir';
|
||||||
export { GitWorktreeWidget } from './GitWorktree';
|
export { GitWorktreeWidget } from './GitWorktree';
|
||||||
export { TokensInputWidget } from './TokensInput';
|
export { TokensInputWidget } from './TokensInput';
|
||||||
export { TokensOutputWidget } from './TokensOutput';
|
export { TokensOutputWidget } from './TokensOutput';
|
||||||
|
|||||||
Reference in new issue
Block a user