diff --git a/docs/USAGE.md b/docs/USAGE.md index 0866ea01..1d5e2759 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -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 diff --git a/src/utils/__tests__/migrations.test.ts b/src/utils/__tests__/migrations.test.ts index b45527d8..d28d109d 100644 --- a/src/utils/__tests__/migrations.test.ts +++ b/src/utils/__tests__/migrations.test.ts @@ -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' } }); diff --git a/src/utils/migrations.ts b/src/utils/migrations.ts index df3de4e4..25700e9b 100644 --- a/src/utils/migrations.ts +++ b/src/utils/migrations.ts @@ -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; } @@ -266,9 +270,16 @@ function migrateItemHideFlags(item: unknown): unknown { } } } + if (hasLegacyHiddenZeroDisplay) { + enabled.add('zero'); + } const nextMetadata: Record = 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 ?? []; diff --git a/src/widgets/GitConflicts.ts b/src/widgets/GitConflicts.ts index 520c62ff..287cef9b 100644 --- a/src/widgets/GitConflicts.ts +++ b/src/widgets/GitConflicts.ts @@ -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'; } @@ -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)) { @@ -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 { diff --git a/src/widgets/__tests__/GitConflicts.test.ts b/src/widgets/__tests__/GitConflicts.test.ts index 46a02073..c5ffd3a8 100644 --- a/src/widgets/__tests__/GitConflicts.test.ts +++ b/src/widgets/__tests__/GitConflicts.test.ts @@ -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 = { + ...(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', () => { @@ -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'); + }); }); diff --git a/src/widgets/__tests__/SymbolOverride.test.ts b/src/widgets/__tests__/SymbolOverride.test.ts index b2f57965..7be63b7c 100644 --- a/src/widgets/__tests__/SymbolOverride.test.ts +++ b/src/widgets/__tests__/SymbolOverride.test.ts @@ -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: '' },