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
3 changes: 2 additions & 1 deletion docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,12 +220,13 @@ Widget picker:
The keybind footer in the TUI only shows shortcuts that apply to the currently selected widget.

Widget-specific shortcuts:
- **Glyph widgets** (Git Branch, Git Worktree, Git Worktree Mode, Git Staged, Git Unstaged, Git Untracked, Git Conflicts, Git Ahead/Behind, Git Status, JJ Bookmarks, JJ Workspace): `g` set custom glyphs for the widget's symbols; Backspace in the editor renders without one, and multi-symbol widgets (Ahead/Behind, Status) edit each part in one list
- **Glyph widgets** (Git Branch, Git Worktree, Git Worktree Mode, Git Staged, Git Unstaged, Git Untracked, Git Conflicts, Git Ahead/Behind, Git Status, JJ Bookmarks, JJ Workspace): `g` set custom glyphs for the widget's symbols; Backspace in the editor renders without one, and multi-symbol widgets (Ahead/Behind, Status, Conflicts) edit each part in one list
- **Git Branch**: `l` toggle clickable branch links (GitHub, GitLab, self-hosted), `w` set a maximum visible width (blank removes the limit)
- **Git Root Dir**: `l` cycle IDE links (`off` → `VS Code` → `Cursor`), `w` set a maximum visible width (blank removes the limit)
- **Git PR**: `s` toggle review status, `t` toggle title (renders "MR" for GitLab origins)
- **Git remote widgets** (`Git Origin*` / `Git Upstream*`): `l` toggle clickable repo links
- **Git Origin Owner/Repo**: `o` show only the owner when the repo is a fork
- **Git Conflicts**: `z` toggles how a visible conflict-free tree renders (`⚠0` or the clean glyph); `g` edits the conflict and clean glyphs
- **Context % widgets**: `u` toggle used vs remaining display, `p` cycle percentage/short bar/short bar only
- **Session Usage / Weekly Usage / Weekly Sonnet Usage / Weekly Opus Usage / Weekly Fable Usage / Extra Usage Utilization**: `p` cycle percentage/full bar/medium bar/short bar/short bar only and `u` switch between used and remaining percentage in every display mode. The editor row labels the current direction as `used` or `remaining`, while the `u` helper names the direction it will switch to. Session and weekly usage widgets use `t` to toggle the time cursor in bar modes.
- **Block Timer**: `p` cycle time/full bar/short bar, `s` toggle compact time, `v` invert fill in progress mode
Expand Down
20 changes: 20 additions & 0 deletions src/utils/__tests__/migrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,26 @@ describe('v3 to v4 hide flag migration', () => {
expect(item?.metadata).toEqual({ hide: expected });
});

it('converts Git Conflicts hidden-zero display to the unified zero hide state', () => {
const item = migrateItem({
id: '1',
type: 'git-conflicts',
metadata: { hideNoGit: 'true', zeroDisplay: 'hidden' }
});

expect(item?.metadata).toEqual({ hide: 'no-git,zero' });
});

it('preserves the non-hiding Git Conflicts clean display', () => {
const item = migrateItem({
id: '1',
type: 'git-conflicts',
metadata: { hideNoGit: 'true', zeroDisplay: 'clean' }
});

expect(item?.metadata).toEqual({ hide: 'no-git', zeroDisplay: 'clean' });
});

it('expands hideNoGit to every state it covered on git-ahead-behind', () => {
const item = migrateItem({ id: '1', type: 'git-ahead-behind', metadata: { hideNoGit: 'true' } });

Expand Down
15 changes: 13 additions & 2 deletions src/utils/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,11 @@ function migrateItemHideFlags(item: unknown): unknown {

const metadata = item.metadata;
const presentKeys = V4_LEGACY_HIDE_KEYS.filter(key => key in metadata);
if (presentKeys.length === 0) {
// PR #562 briefly represented Git Conflicts' hidden-zero choice as a
// display mode. Fold that choice into the shared zero hide state while
// preserving the non-hiding `clean` display mode.
const hasLegacyHiddenZeroDisplay = item.type === 'git-conflicts' && metadata.zeroDisplay === 'hidden';
if (presentKeys.length === 0 && !hasLegacyHiddenZeroDisplay) {
return item;
}

Expand All @@ -266,9 +270,16 @@ function migrateItemHideFlags(item: unknown): unknown {
}
}
}
if (hasLegacyHiddenZeroDisplay) {
enabled.add('zero');
}

const nextMetadata: Record<string, unknown> = Object.fromEntries(
Object.entries(metadata).filter(([key]) => !V4_LEGACY_HIDE_KEYS.includes(key) && key !== 'hide')
Object.entries(metadata).filter(([key]) => (
!V4_LEGACY_HIDE_KEYS.includes(key)
&& key !== 'hide'
&& !(hasLegacyHiddenZeroDisplay && key === 'zeroDisplay')
))
);
const orderedEnabled = rule.stateOrder.filter(state => enabled.has(state));
const defaults = rule.defaultEnabled ?? [];
Expand Down
74 changes: 63 additions & 11 deletions src/widgets/GitConflicts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,49 @@ import {
NO_GIT_HIDEABLE_STATE,
isHidden
} from './shared/hideable';
import { removeMetadataKeys } from './shared/metadata';
import {
formatSymbolPrefix,
getSlotSymbol,
getSymbolKeybind,
renderSymbolOverrideEditor
renderSymbolSlotsEditor,
type SymbolSlot
} from './shared/symbol-override';

const ZERO_HIDEABLE_STATE: HideableState = { key: 'zero', label: 'when there are no conflicts' };
const DEFAULT_SYMBOL = '⚠';
const CONFLICT_SLOT: SymbolSlot = { id: 'character', label: 'Conflicts', defaultSymbol: '⚠' };
const CLEAN_SLOT: SymbolSlot = { id: 'symbolClean', label: 'Clean', defaultSymbol: '✓' };

// Hiding the zero state is handled by the shared hideable-state system. This
// setting only controls how a visible conflict-free tree is represented.
const ZERO_DISPLAYS = ['count', 'clean'] as const;
type ZeroDisplay = typeof ZERO_DISPLAYS[number];

const DEFAULT_ZERO_DISPLAY: ZeroDisplay = 'count';
const ZERO_DISPLAY_METADATA_KEY = 'zeroDisplay';
const CYCLE_ZERO_DISPLAY_ACTION = 'cycle-zero-display';

function getZeroDisplay(item: WidgetItem): ZeroDisplay {
const value = item.metadata?.[ZERO_DISPLAY_METADATA_KEY];
return (ZERO_DISPLAYS as readonly string[]).includes(value ?? '') ? (value as ZeroDisplay) : DEFAULT_ZERO_DISPLAY;
}

// The default is stored as the absence of the key, so untouched items keep no metadata.
function cycleZeroDisplay(item: WidgetItem): WidgetItem {
const current = getZeroDisplay(item);
const next = ZERO_DISPLAYS[(ZERO_DISPLAYS.indexOf(current) + 1) % ZERO_DISPLAYS.length] ?? DEFAULT_ZERO_DISPLAY;

if (next === DEFAULT_ZERO_DISPLAY) {
return removeMetadataKeys(item, [ZERO_DISPLAY_METADATA_KEY]);
}

return {
...item,
metadata: {
...item.metadata,
[ZERO_DISPLAY_METADATA_KEY]: next
}
};
}

export class GitConflictsWidget implements Widget {
getDefaultColor(): string { return 'red'; }
Expand All @@ -33,21 +68,32 @@ export class GitConflictsWidget implements Widget {
getCategory(): string { return 'Git'; }

getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
return { displayText: this.getDisplayName() };
return {
displayText: this.getDisplayName(),
modifierText: getZeroDisplay(item) === 'clean' ? '(clean when zero)' : undefined
};
}

getHideableStates(): HideableState[] {
return [NO_GIT_HIDEABLE_STATE, ZERO_HIDEABLE_STATE];
}

handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
if (action === CYCLE_ZERO_DISPLAY_ACTION) {
return cycleZeroDisplay(item);
}

return null;
}

render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null {
const hideNoGit = isHidden(item, NO_GIT_HIDEABLE_STATE.key);
const prefix = formatSymbolPrefix(item, DEFAULT_SYMBOL);
const symbol = getSlotSymbol(item, CONFLICT_SLOT);

if (context.isPreview) {
if (item.rawValue)
return '2';
return `${prefix}2`;
return `${symbol}2`;
}

if (!isInsideGitWorkTree(context)) {
Expand All @@ -56,23 +102,29 @@ export class GitConflictsWidget implements Widget {

const count = getGitConflictCount(context);

if (count === 0 && isHidden(item, ZERO_HIDEABLE_STATE.key)) {
return null;
if (count === 0) {
if (isHidden(item, ZERO_HIDEABLE_STATE.key))
return null;
if (getZeroDisplay(item) === 'clean' && !item.rawValue)
return getSlotSymbol(item, CLEAN_SLOT);
}

if (item.rawValue) {
return count.toString();
}

return `${prefix}${count}`;
return `${symbol}${count}`;
}

getCustomKeybinds(): CustomKeybind[] {
return [getSymbolKeybind()];
return [
{ key: 'z', label: '(z)ero conflicts display', action: CYCLE_ZERO_DISPLAY_ACTION },
getSymbolKeybind()
];
}

renderEditor(props: WidgetEditorProps) {
return renderSymbolOverrideEditor(props, DEFAULT_SYMBOL);
return renderSymbolSlotsEditor(props, [CONFLICT_SLOT, CLEAN_SLOT]);
}

getNumericValue(context: RenderContext, _item: WidgetItem): number | null {
Expand Down
132 changes: 94 additions & 38 deletions src/widgets/__tests__/GitConflicts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,27 +31,46 @@ function render(options: {
rawValue?: boolean;
hide?: string;
hideNoGit?: boolean;
zeroDisplay?: string;
cleanSymbol?: string;
} = {}) {
const widget = new GitConflictsWidget();
const context: RenderContext = { isPreview: options.isPreview };
const hide = options.hide ?? (options.hideNoGit ? 'no-git' : undefined);
const metadata: Record<string, string> = {
...(hide !== undefined ? { hide } : {}),
...(options.zeroDisplay ? { zeroDisplay: options.zeroDisplay } : {}),
...(options.cleanSymbol ? { symbolClean: options.cleanSymbol } : {})
};
const item: WidgetItem = {
id: 'git-conflicts',
type: 'git-conflicts',
rawValue: options.rawValue,
metadata: options.hide ? { hide: options.hide } : (options.hideNoGit ? { hide: 'no-git' } : undefined)
metadata: Object.keys(metadata).length > 0 ? metadata : undefined
};

return widget.render(item, context, DEFAULT_SETTINGS);
}

function mockConflictCount(count: number) {
mockExecFileSync.mockReturnValueOnce('true\n');
mockExecFileSync.mockReturnValueOnce(
Array.from({ length: count }, (_, index) => [
`100644 hash 1\tconflict-${index}`,
`100644 hash 2\tconflict-${index}`,
`100644 hash 3\tconflict-${index}`
].join('\n')).join('\n')
);
}

describe('GitConflictsWidget', () => {
beforeEach(() => {
vi.clearAllMocks();
clearGitCache();
});

it('renders preview content', () => {
expect(render({ isPreview: true })).toBe('⚠ 2');
it('renders preview content without a space between the glyph and count', () => {
expect(render({ isPreview: true })).toBe('⚠2');
});

it('renders raw preview content as a count', () => {
Expand All @@ -64,66 +83,103 @@ describe('GitConflictsWidget', () => {
expect(render()).toBe('(no git)');
});

it('hides no git when configured', () => {
it('hides no git through the shared hide state', () => {
mockExecFileSync.mockReturnValue('false\n');

expect(render({ hideNoGit: true })).toBeNull();
});

it('renders zero conflicts instead of hiding the widget', () => {
mockExecFileSync.mockReturnValueOnce('true\n');
mockExecFileSync.mockReturnValueOnce('');
it('declares no-git and zero as hideable states', () => {
expect(new GitConflictsWidget().getHideableStates().map(state => state.key)).toEqual(['no-git', 'zero']);
});

it('renders zero conflicts instead of hiding the widget by default', () => {
mockConflictCount(0);

expect(render()).toBe('⚠ 0');
expect(render()).toBe('⚠0');
});

it('renders raw zero conflicts as a numeric count', () => {
mockExecFileSync.mockReturnValueOnce('true\n');
mockExecFileSync.mockReturnValueOnce('');
mockConflictCount(0);

expect(render({ rawValue: true })).toBe('0');
});

it('hides zero conflicts when the zero state is enabled', () => {
mockExecFileSync.mockReturnValueOnce('true\n');
mockExecFileSync.mockReturnValueOnce('');
it('hides zero conflicts through the shared hide state', () => {
mockConflictCount(0);

expect(render({ hide: 'zero' })).toBeNull();
});

it('keeps non-zero conflicts visible with the zero state enabled', () => {
mockExecFileSync.mockReturnValueOnce('true\n');
mockExecFileSync.mockReturnValueOnce([
'100644 hash 1\tconflict-a',
'100644 hash 2\tconflict-a',
'100644 hash 3\tconflict-a'
].join('\n'));
it('hides zero conflicts in raw value mode through the shared hide state', () => {
mockConflictCount(0);

expect(render({ hide: 'zero', rawValue: true })).toBeNull();
});

it('gives the shared hide state precedence over the clean display', () => {
mockConflictCount(0);

expect(render({ hide: 'zero' })).toBe('⚠ 1');
expect(render({ hide: 'zero', zeroDisplay: 'clean' })).toBeNull();
});

it('renders the conflict count', () => {
mockExecFileSync.mockReturnValueOnce('true\n');
mockExecFileSync.mockReturnValueOnce([
'100644 hash 1\tconflict-a',
'100644 hash 2\tconflict-a',
'100644 hash 3\tconflict-a',
'100644 hash 1\tconflict-b',
'100644 hash 2\tconflict-b',
'100644 hash 3\tconflict-b'
].join('\n'));
it('keeps non-zero conflicts visible with the zero hide state enabled', () => {
mockConflictCount(1);

expect(render()).toBe('⚠ 2');
expect(render({ hide: 'zero' })).toBe('⚠1');
});

it('renders the conflict count without a space', () => {
mockConflictCount(2);

expect(render()).toBe('⚠2');
});

it('renders raw conflicts as a numeric count', () => {
mockExecFileSync.mockReturnValueOnce('true\n');
mockExecFileSync.mockReturnValueOnce([
'100644 hash 1\tconflict-a',
'100644 hash 2\tconflict-a',
'100644 hash 3\tconflict-a'
].join('\n'));
mockConflictCount(1);

expect(render({ rawValue: true })).toBe('1');
});

it('renders the clean glyph when zero conflicts are configured as clean', () => {
mockConflictCount(0);

expect(render({ zeroDisplay: 'clean' })).toBe('✓');
});

it('renders a custom clean glyph', () => {
mockConflictCount(0);

expect(render({ zeroDisplay: 'clean', cleanSymbol: '★' })).toBe('★');
});

it('keeps raw value numeric in clean mode', () => {
mockConflictCount(0);

expect(render({ zeroDisplay: 'clean', rawValue: true })).toBe('0');
});

it('renders the conflict glyph and count for non-zero conflicts in clean mode', () => {
mockConflictCount(2);

expect(render({ zeroDisplay: 'clean' })).toBe('⚠2');
});

it('toggles the visible zero display back to the default', () => {
const widget = new GitConflictsWidget();
const item: WidgetItem = { id: 'git-conflicts', type: 'git-conflicts' };

const clean = widget.handleEditorAction('cycle-zero-display', item);
const back = widget.handleEditorAction('cycle-zero-display', clean ?? item);

expect(clean?.metadata?.zeroDisplay).toBe('clean');
expect(back?.metadata?.zeroDisplay).toBeUndefined();
});

it('keeps zero appearance on z and leaves h to the shared hide editor', () => {
const keys = new GitConflictsWidget().getCustomKeybinds().map(keybind => keybind.key);

expect(keys).toContain('z');
expect(keys).not.toContain('h');
});
});
2 changes: 1 addition & 1 deletion src/widgets/__tests__/SymbolOverride.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const cases: SymbolCase[] = [
{ name: 'GitWorktreeWidget', itemType: 'git-worktree', widget: new GitWorktreeWidget(), defaultPreview: '𖠰 main', overriddenPreview: '★ main', suppressedPreview: 'main' },
{ name: 'JjBookmarksWidget', itemType: 'jj-bookmarks', widget: new JjBookmarksWidget(), defaultPreview: '🔖 main', overriddenPreview: '★ main', suppressedPreview: 'main' },
{ name: 'JjWorkspaceWidget', itemType: 'jj-workspace', widget: new JjWorkspaceWidget(), defaultPreview: '◆ default', overriddenPreview: '★ default', suppressedPreview: 'default' },
{ name: 'GitConflictsWidget', itemType: 'git-conflicts', widget: new GitConflictsWidget(), defaultPreview: '⚠ 2', overriddenPreview: '★ 2', suppressedPreview: '2' },
{ name: 'GitConflictsWidget', itemType: 'git-conflicts', widget: new GitConflictsWidget(), defaultPreview: '⚠2', overriddenPreview: '★2', suppressedPreview: '2' },
{ name: 'GitStagedWidget', itemType: 'git-staged', widget: new GitStagedWidget(), defaultPreview: '+', overriddenPreview: '★', suppressedPreview: '' },
{ name: 'GitUnstagedWidget', itemType: 'git-unstaged', widget: new GitUnstagedWidget(), defaultPreview: '*', overriddenPreview: '★', suppressedPreview: '' },
{ name: 'GitUntrackedWidget', itemType: 'git-untracked', widget: new GitUntrackedWidget(), defaultPreview: '?', overriddenPreview: '★', suppressedPreview: '' },
Expand Down