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:
tim-watcha
2026-02-22 02:00:31 +09:00
committed by GitHub
parent 35b4c0caa0
commit f83e7d2f6d
5 changed files with 182 additions and 3 deletions
+8 -3
View File
@@ -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>
+1
View File
@@ -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()],
+88
View File
@@ -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; }
}
+84
View File
@@ -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);
});
});
+1
View File
@@ -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';