From f83e7d2f6d3808d61ec2a5eb54a2f2c6eeeef09f Mon Sep 17 00:00:00 2001 From: tim-watcha <92134765+tim-watcha@users.noreply.github.com> Date: Sun, 22 Feb 2026 02:00:31 +0900 Subject: [PATCH] Add Git Root Dir widget (#119) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * Fix GitRootDir root path handling and raw mode --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: Matthew Breedlove --- src/tui/components/ItemsEditor.tsx | 11 ++- src/utils/widgets.ts | 1 + src/widgets/GitRootDir.ts | 88 ++++++++++++++++++++++++ src/widgets/__tests__/GitRootDir.test.ts | 84 ++++++++++++++++++++++ src/widgets/index.ts | 1 + 5 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 src/widgets/GitRootDir.ts create mode 100644 src/widgets/__tests__/GitRootDir.test.ts diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 419e67f..320fb39 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -391,9 +391,13 @@ export const ItemsEditor: React.FC = ({ 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 = ({ 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 ( @@ -784,7 +789,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB {modifierText} )} - {widget.rawValue && (raw value)} + {supportsRawValue && widget.rawValue && (raw value)} {widget.merge === true && (mergedβ†’)} {widget.merge === 'no-padding' && (merged-no-padβ†’)} diff --git a/src/utils/widgets.ts b/src/utils/widgets.ts index 123d3a7..9d013ac 100644 --- a/src/utils/widgets.ts +++ b/src/utils/widgets.ts @@ -11,6 +11,7 @@ const widgetRegistry = new Map([ ['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()], diff --git a/src/widgets/GitRootDir.ts b/src/widgets/GitRootDir.ts new file mode 100644 index 0000000..5c1c511 --- /dev/null +++ b/src/widgets/GitRootDir.ts @@ -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; } +} \ No newline at end of file diff --git a/src/widgets/__tests__/GitRootDir.test.ts b/src/widgets/__tests__/GitRootDir.test.ts new file mode 100644 index 0000000..5266cb4 --- /dev/null +++ b/src/widgets/__tests__/GitRootDir.test.ts @@ -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); + }); +}); \ No newline at end of file diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 70003ef..aa7e7b4 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -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';