Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions src/widgets/GitBranch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,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);
Expand All @@ -101,16 +103,26 @@ 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) {
return this.renderPreview(item, prefix, isLink);
return this.renderPreview(item, prefix, isLink, fishStyle);
}

if (!isInsideGitWorkTree(context)) {
Expand All @@ -127,8 +139,10 @@ export class GitBranchWidget implements Widget {
return hideNoGit ? null : `${prefix}no git`;
}

// A detached HEAD renders as "(sha)" and must not be fish-abbreviated
const value = branch ?? `(${ref})`;
const displayText = applyMaxWidth(item.rawValue ? value : `${prefix}${value}`, item.maxWidth);
const displayValue = branch === null ? value : this.abbreviateBranch(value, fishStyle);
const displayText = applyMaxWidth(item.rawValue ? displayValue : `${prefix}${displayValue}`, item.maxWidth);

if (isLink) {
const origin = getRemoteInfo('origin', context);
Expand All @@ -143,10 +157,11 @@ export class GitBranchWidget implements Widget {
return displayText;
}

private renderPreview(item: WidgetItem, prefix: string, isLink: boolean): string {
private renderPreview(item: WidgetItem, prefix: string, isLink: boolean, fishStyle: boolean): string {
// With a width limit, a sample long enough for the limit to show
const sample = item.maxWidth ? 'feature/long-branch-name' : 'main';
const text = applyMaxWidth(item.rawValue ? sample : `${prefix}${sample}`, item.maxWidth);
const display = this.abbreviateBranch(sample, fishStyle);
const text = applyMaxWidth(item.rawValue ? display : `${prefix}${display}`, item.maxWidth);
return isLink ? renderOsc8Link(`https://github.com/owner/repo/tree/${sample}`, text) : text;
}

Expand All @@ -157,6 +172,19 @@ export class GitBranchWidget implements Widget {
return ref?.startsWith(BRANCH_REF_PREFIX) ? ref.slice(BRANCH_REF_PREFIX.length) : ref;
}

// 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 Array.from(branch)[0] ?? '';
}
return parts.map((part, index) => (index === parts.length - 1 ? part : Array.from(part)[0] ?? '')).join('/');
}

// jj finds its workspace from the nearest .jj above, past a git repo's own
// root, so `jj root` also succeeds in a plain git repo anywhere under a jj
// workspace. The repo is colocated only when its work tree holds the .jj.
Expand All @@ -180,6 +208,7 @@ export class GitBranchWidget implements Widget {
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()
];
Expand Down
53 changes: 47 additions & 6 deletions src/widgets/GitWorktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
runGit
} from '../utils/git';

import { makeModifierText } from './shared/editor-display';
import {
NO_GIT_HIDEABLE_STATE,
isHidden
Expand All @@ -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 Array.from(worktree)[0] ?? '';
}
return parts.map((part, index) => (index === parts.length - 1 ? part : Array.from(part)[0] ?? '')).join('/');
}

private getGitWorktree(context: RenderContext): string | null {
const output = runGit('rev-parse --git-dir --git-common-dir', context);
const [gitDir, commonDir] = (output ?? '')
Expand All @@ -77,7 +115,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) {
Expand Down
36 changes: 31 additions & 5 deletions src/widgets/__tests__/GitBranch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,19 +151,19 @@ describe('GitBranchWidget', () => {
));
});

it('should render the short commit on a detached HEAD', () => {
it.each(['false', 'true'])('should render the short commit on a detached HEAD (fish style: %s)', (fishStyle) => {
mockExecFileSync.mockReturnValueOnce('true\n');
mockExecFileSync.mockImplementationOnce(() => { throw new Error('fatal: ref HEAD is not a symbolic ref'); });
mockExecFileSync.mockImplementationOnce(() => { throw new Error('Error: There is no jj repo in "."'); });
mockExecFileSync.mockReturnValueOnce('ddfb2de\n');

expect(render({ hideNoGit: true })).toBe('⎇ (ddfb2de)');
expect(render({ hideNoGit: true, metadata: { fishStyle } })).toBe('⎇ (ddfb2de)');
expect(mockExecFileSync.mock.calls[2]?.[0]).toBe('jj');
expect(mockExecFileSync.mock.calls[2]?.[1]).toEqual(['root']);
expect(mockExecFileSync.mock.calls[3]?.[1]).toEqual([...GIT_HARDENING_ARGS, 'rev-parse', '--short', 'HEAD']);
});

describe('in a jj workspace', () => {
describe.each(['false', 'true'])('in a jj workspace (fish style: %s)', (fishStyle) => {
// A jj workspace, with a plain git repo nested in it at code/app
let workspaceDir: string;

Expand All @@ -189,7 +189,7 @@ describe('GitBranchWidget', () => {
mockExecFileSync.mockReturnValueOnce(`${workspaceDir}\n`);
mockExecFileSync.mockReturnValueOnce(`${workspaceDir}\n`);

expect(render({ hideNoGit })).toBe(expected);
expect(render({ hideNoGit, metadata: { fishStyle } })).toBe(expected);
expect(mockExecFileSync.mock.calls[2]?.[0]).toBe('jj');
expect(mockExecFileSync.mock.calls[3]?.[1]).toEqual([...GIT_HARDENING_ARGS, 'rev-parse', '--show-toplevel']);
expect(mockExecFileSync.mock.calls).toHaveLength(4);
Expand All @@ -205,7 +205,7 @@ describe('GitBranchWidget', () => {
mockExecFileSync.mockReturnValueOnce(`${path.join(workspaceDir, 'code', 'app')}\n`);
mockExecFileSync.mockReturnValueOnce('ddfb2de\n');

expect(render({ hideNoGit: true })).toBe('⎇ (ddfb2de)');
expect(render({ hideNoGit: true, metadata: { fishStyle } })).toBe('⎇ (ddfb2de)');
expect(mockExecFileSync.mock.calls[4]?.[1]).toEqual([...GIT_HARDENING_ARGS, 'rev-parse', '--short', 'HEAD']);
});
});
Expand Down Expand Up @@ -352,4 +352,30 @@ 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: 'preserves a non-BMP character in a single segment', branch: '🚀launch', expected: '⎇ 🚀' },
{ name: 'preserves non-BMP characters in abbreviated segments', branch: '🚀launch/🧪tests/topic', expected: '⎇ 🚀/🧪/topic' }
])('$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'
});
});
});
});
33 changes: 32 additions & 1 deletion src/widgets/__tests__/GitWorktree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,23 @@ function render(options: {
cwd?: string;
hideNoGit?: boolean;
isPreview?: boolean;
metadata?: Record<string, string>;
rawValue?: boolean;
} = {}) {
const widget = new GitWorktreeWidget();
const context: RenderContext = {
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);
Expand Down Expand Up @@ -164,4 +169,30 @@ describe('GitWorktreeWidget', () => {

expect(render()).toBe('𖠰 some-worktree');
});

describe('fish style', () => {
it.each([
{ name: 'collapses the main worktree to its first letter', gitDir: '.git\n/some/path\n', expected: '𖠰 m' },
{ name: 'keeps the last segment full', gitDir: '/some/path/.git/worktrees/some-dir/some-worktree\n/some/path/.git\n', expected: '𖠰 s/some-worktree' },
{ name: 'preserves a non-BMP character in a single segment', gitDir: '/some/path/.git/worktrees/🚀launch\n/some/path/.git\n', expected: '𖠰 🚀' },
{ name: 'preserves non-BMP characters in abbreviated segments', gitDir: '/some/path/.git/worktrees/🚀launch/🧪tests/topic\n/some/path/.git\n', expected: '𖠰 🚀/🧪/topic' }
])('$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'
});
});
});
});
Loading