diff --git a/src/widgets/GitBranch.ts b/src/widgets/GitBranch.ts index 97cdf1a4a..fc80cfd0d 100644 --- a/src/widgets/GitBranch.ts +++ b/src/widgets/GitBranch.ts @@ -78,6 +78,8 @@ export class GitBranchWidget implements Widget { const modifiers: string[] = []; if (isLink) modifiers.push('repo link'); + if (item.metadata?.fishStyle === 'true') + modifiers.push('fish-style'); const maxWidthText = getMaxWidthModifier(item); if (maxWidthText) modifiers.push(maxWidthText); @@ -95,16 +97,27 @@ export class GitBranchWidget implements Widget { if (action === TOGGLE_LINK_ACTION) { return toggleLink(item); } + if (action === 'toggle-fish-style') { + const enabled = item.metadata?.fishStyle === 'true'; + const { fishStyle, ...restMetadata } = item.metadata ?? {}; + const nextMetadata = enabled ? restMetadata : { ...restMetadata, fishStyle: 'true' }; + return { + ...item, + metadata: Object.keys(nextMetadata).length > 0 ? nextMetadata : undefined + }; + } return null; } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { const hideNoGit = isHidden(item, NO_GIT_HIDEABLE_STATE.key); const isLink = isLinkEnabled(item); + const fishStyle = item.metadata?.fishStyle === 'true'; const prefix = formatSymbolPrefix(item, DEFAULT_SYMBOL); if (context.isPreview) { - const text = item.rawValue ? 'main' : `${prefix}main`; + const preview = this.abbreviateBranch('main', fishStyle); + const text = item.rawValue ? preview : `${prefix}${preview}`; return isLink ? renderOsc8Link('https://github.com/owner/repo/tree/main', text) : text; } @@ -117,7 +130,8 @@ export class GitBranchWidget implements Widget { return hideNoGit ? null : `${prefix}no git`; } - const displayText = applyMaxWidth(item.rawValue ? branch : `${prefix}${branch}`, item.maxWidth); + const displayBranch = this.abbreviateBranch(branch, fishStyle); + const displayText = applyMaxWidth(item.rawValue ? displayBranch : `${prefix}${displayBranch}`, item.maxWidth); if (isLink) { const origin = getRemoteInfo('origin', context); @@ -136,9 +150,23 @@ export class GitBranchWidget implements Widget { return runGit('symbolic-ref --short HEAD', context); } + // Fish-style branch abbreviation: every segment but the last collapses to + // its first character, so 'main' -> 'm' and 'fix/np/story' -> 'f/p/story'. + private abbreviateBranch(branch: string, fishStyle: boolean): string { + if (!fishStyle) { + return branch; + } + const parts = branch.split('/'); + if (parts.length === 1) { + return branch[0] ?? ''; + } + return parts.map((part, index) => (index === parts.length - 1 ? part : part[0] ?? '')).join('/'); + } + getCustomKeybinds(): CustomKeybind[] { return [ { key: 'l', label: '(l)ink to repo', action: TOGGLE_LINK_ACTION }, + { key: 'f', label: '(f)ish style', action: 'toggle-fish-style' }, getMaxWidthKeybind(), getSymbolKeybind() ]; diff --git a/src/widgets/GitWorktree.ts b/src/widgets/GitWorktree.ts index 9dd3b074b..f470725c7 100644 --- a/src/widgets/GitWorktree.ts +++ b/src/widgets/GitWorktree.ts @@ -12,6 +12,7 @@ import { runGit } from '../utils/git'; +import { makeModifierText } from './shared/editor-display'; import { NO_GIT_HIDEABLE_STATE, isHidden @@ -30,31 +31,68 @@ export class GitWorktreeWidget implements Widget { getDisplayName(): string { return 'Git Worktree'; } getCategory(): string { return 'Git'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - return { displayText: this.getDisplayName() }; + const modifiers: string[] = []; + if (item.metadata?.fishStyle === 'true') + modifiers.push('fish-style'); + return { + displayText: this.getDisplayName(), + modifierText: makeModifierText(modifiers) + }; } getHideableStates(): HideableState[] { return [NO_GIT_HIDEABLE_STATE]; } + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action === 'toggle-fish-style') { + const enabled = item.metadata?.fishStyle === 'true'; + const { fishStyle, ...restMetadata } = item.metadata ?? {}; + const nextMetadata = enabled ? restMetadata : { ...restMetadata, fishStyle: 'true' }; + return { + ...item, + metadata: Object.keys(nextMetadata).length > 0 ? nextMetadata : undefined + }; + } + return null; + } + render(item: WidgetItem, context: RenderContext): string | null { const hideNoGit = isHidden(item, NO_GIT_HIDEABLE_STATE.key); + const fishStyle = item.metadata?.fishStyle === 'true'; const prefix = formatSymbolPrefix(item, DEFAULT_SYMBOL); - if (context.isPreview) - return item.rawValue ? 'main' : `${prefix}main`; + if (context.isPreview) { + const preview = this.abbreviateWorktree('main', fishStyle); + return item.rawValue ? preview : `${prefix}${preview}`; + } if (!isInsideGitWorkTree(context)) { return hideNoGit ? null : `${prefix}no git`; } const worktree = this.getGitWorktree(context); - if (worktree) - return item.rawValue ? worktree : `${prefix}${worktree}`; + if (worktree) { + const displayWorktree = this.abbreviateWorktree(worktree, fishStyle); + return item.rawValue ? displayWorktree : `${prefix}${displayWorktree}`; + } return hideNoGit ? null : `${prefix}no git`; } + // Same fish-style rule as Git Branch: every segment but the last collapses + // to its first character, so 'main' -> 'm' and 'dir/wt' -> 'd/wt'. + private abbreviateWorktree(worktree: string, fishStyle: boolean): string { + if (!fishStyle) { + return worktree; + } + const parts = worktree.split('/'); + if (parts.length === 1) { + return worktree[0] ?? ''; + } + return parts.map((part, index) => (index === parts.length - 1 ? part : part[0] ?? '')).join('/'); + } + private getGitWorktree(context: RenderContext): string | null { const worktreeDir = runGit('rev-parse --git-dir', context); if (!worktreeDir) @@ -85,7 +123,10 @@ export class GitWorktreeWidget implements Widget { } getCustomKeybinds(): CustomKeybind[] { - return [getSymbolKeybind()]; + return [ + { key: 'f', label: '(f)ish style', action: 'toggle-fish-style' }, + getSymbolKeybind() + ]; } renderEditor(props: WidgetEditorProps) { diff --git a/src/widgets/__tests__/GitBranch.test.ts b/src/widgets/__tests__/GitBranch.test.ts index 6f131b50f..544f26d3d 100644 --- a/src/widgets/__tests__/GitBranch.test.ts +++ b/src/widgets/__tests__/GitBranch.test.ts @@ -252,4 +252,28 @@ describe('GitBranchWidget', () => { expect(toggled?.metadata).toEqual({ hide: 'no-git' }); }); }); + + describe('fish style', () => { + it.each([ + { name: 'collapses a plain branch to its first letter', branch: 'main', expected: '⎇ m' }, + { name: 'keeps the last segment full', branch: 'feature/worktree', expected: '⎇ f/worktree' } + ])('$name', ({ branch, expected }) => { + mockExecFileSync.mockReturnValueOnce('true\n'); + mockExecFileSync.mockReturnValueOnce(branch); + + expect(render({ metadata: { fishStyle: 'true' } })).toBe(expected); + }); + + it('shows the fish-style modifier and keybind in the editor', () => { + const widget = new GitBranchWidget(); + + expect(widget.getEditorDisplay({ id: 'git-branch', type: 'git-branch', metadata: { fishStyle: 'true' } }).modifierText) + .toBe('(fish-style)'); + expect(widget.getCustomKeybinds()).toContainEqual({ + key: 'f', + label: '(f)ish style', + action: 'toggle-fish-style' + }); + }); + }); }); diff --git a/src/widgets/__tests__/GitWorktree.test.ts b/src/widgets/__tests__/GitWorktree.test.ts index 1804a77a6..14153070d 100644 --- a/src/widgets/__tests__/GitWorktree.test.ts +++ b/src/widgets/__tests__/GitWorktree.test.ts @@ -32,6 +32,7 @@ function render(options: { cwd?: string; hideNoGit?: boolean; isPreview?: boolean; + metadata?: Record; rawValue?: boolean; } = {}) { const widget = new GitWorktreeWidget(); @@ -39,11 +40,15 @@ function render(options: { isPreview: options.isPreview, data: options.cwd ? { cwd: options.cwd } : undefined }; + const metadata = { + ...options.metadata, + ...(options.hideNoGit ? { hide: 'no-git' } : {}) + }; const item: WidgetItem = { id: 'git-worktree', type: 'git-worktree', rawValue: options.rawValue, - metadata: options.hideNoGit ? { hide: 'no-git' } : undefined + metadata: Object.keys(metadata).length > 0 ? metadata : undefined }; return widget.render(item, context); @@ -142,4 +147,28 @@ describe('GitWorktreeWidget', () => { expect(render()).toBe('𖠰 some-worktree'); }); + + describe('fish style', () => { + it.each([ + { name: 'collapses the main worktree to its first letter', gitDir: '.git', expected: '𖠰 m' }, + { name: 'keeps the last segment full', gitDir: '/some/path/.git/worktrees/some-dir/some-worktree', expected: '𖠰 s/some-worktree' } + ])('$name', ({ gitDir, expected }) => { + mockExecFileSync.mockReturnValueOnce('true\n'); + mockExecFileSync.mockReturnValueOnce(gitDir); + + expect(render({ metadata: { fishStyle: 'true' } })).toBe(expected); + }); + + it('shows the fish-style modifier and keybind in the editor', () => { + const widget = new GitWorktreeWidget(); + + expect(widget.getEditorDisplay({ id: 'git-worktree', type: 'git-worktree', metadata: { fishStyle: 'true' } }).modifierText) + .toBe('(fish-style)'); + expect(widget.getCustomKeybinds()).toContainEqual({ + key: 'f', + label: '(f)ish style', + action: 'toggle-fish-style' + }); + }); + }); });