diff --git a/docs/USAGE.md b/docs/USAGE.md index 68e654eb..a0220824 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -176,6 +176,7 @@ The keybind footer in the TUI only shows shortcuts that apply to the currently s Widget-specific shortcuts: - **Git widgets with empty-state toggles**: `h` hide `no git` / empty output where supported +- **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 - **Git Branch**: `l` toggle clickable branch links (GitHub, GitLab, self-hosted) - **Git Root Dir**: `l` cycle IDE links (`off` → `VS Code` → `Cursor`) - **Git PR**: `h` hide empty/no-PR/MR output, `s` toggle review status, `t` toggle title (renders "MR" for GitLab origins) diff --git a/src/widgets/GitAheadBehind.ts b/src/widgets/GitAheadBehind.ts index e67085a8..b573acf0 100644 --- a/src/widgets/GitAheadBehind.ts +++ b/src/widgets/GitAheadBehind.ts @@ -4,6 +4,7 @@ import type { CustomKeybind, Widget, WidgetEditorDisplay, + WidgetEditorProps, WidgetItem } from '../types/Widget'; import { @@ -18,6 +19,15 @@ import { handleToggleNoGitAction, isHideNoGitEnabled } from './shared/git-no-git'; +import { + getSlotSymbol, + getSymbolKeybind, + renderSymbolSlotsEditor, + type SymbolSlot +} from './shared/symbol-override'; + +const AHEAD_SLOT: SymbolSlot = { id: 'symbolAhead', label: 'Ahead', defaultSymbol: '↑' }; +const BEHIND_SLOT: SymbolSlot = { id: 'symbolBehind', label: 'Behind', defaultSymbol: '↓' }; export class GitAheadBehindWidget implements Widget { getDefaultColor(): string { return 'cyan'; } @@ -43,11 +53,13 @@ export class GitAheadBehindWidget implements Widget { render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { const hideNoGit = isHideNoGitEnabled(item); + const aheadSymbol = getSlotSymbol(item, AHEAD_SLOT); + const behindSymbol = getSlotSymbol(item, BEHIND_SLOT); if (context.isPreview) { if (item.rawValue) return '2,3'; - return '↑2↓3'; + return `${aheadSymbol}2${behindSymbol}3`; } if (!isInsideGitWorkTree(context)) { @@ -70,15 +82,22 @@ export class GitAheadBehindWidget implements Widget { const parts: string[] = []; if (result.ahead > 0) - parts.push(`↑${result.ahead}`); + parts.push(`${aheadSymbol}${result.ahead}`); if (result.behind > 0) - parts.push(`↓${result.behind}`); + parts.push(`${behindSymbol}${result.behind}`); return parts.join(''); } getCustomKeybinds(): CustomKeybind[] { - return getHideNoGitKeybinds(); + return [ + ...getHideNoGitKeybinds(), + getSymbolKeybind() + ]; + } + + renderEditor(props: WidgetEditorProps) { + return renderSymbolSlotsEditor(props, [AHEAD_SLOT, BEHIND_SLOT]); } getNumericValue(context: RenderContext, _item: WidgetItem): number | null { diff --git a/src/widgets/GitBranch.ts b/src/widgets/GitBranch.ts index e934dc47..dc1397b0 100644 --- a/src/widgets/GitBranch.ts +++ b/src/widgets/GitBranch.ts @@ -4,6 +4,7 @@ import type { CustomKeybind, Widget, WidgetEditorDisplay, + WidgetEditorProps, WidgetItem } from '../types/Widget'; import { @@ -27,7 +28,13 @@ import { isHideNoGitEnabled } from './shared/git-no-git'; import { isMetadataFlagEnabled } from './shared/metadata'; +import { + formatSymbolPrefix, + getSymbolKeybind, + renderSymbolOverrideEditor +} from './shared/symbol-override'; +const DEFAULT_SYMBOL = '⎇'; const LINK_KEY = 'linkToRepo'; const LEGACY_LINK_KEY = 'linkToGitHub'; const TOGGLE_LINK_ACTION = 'toggle-link'; @@ -88,22 +95,23 @@ export class GitBranchWidget implements Widget { void settings; const hideNoGit = isHideNoGitEnabled(item); const isLink = isLinkEnabled(item); + const prefix = formatSymbolPrefix(item, DEFAULT_SYMBOL); if (context.isPreview) { - const text = item.rawValue ? 'main' : '⎇ main'; + const text = item.rawValue ? 'main' : `${prefix}main`; return isLink ? renderOsc8Link('https://github.com/owner/repo/tree/main', text) : text; } if (!isInsideGitWorkTree(context)) { - return hideNoGit ? null : '⎇ no git'; + return hideNoGit ? null : `${prefix}no git`; } const branch = this.getGitBranch(context); if (!branch) { - return hideNoGit ? null : '⎇ no git'; + return hideNoGit ? null : `${prefix}no git`; } - const displayText = item.rawValue ? branch : `⎇ ${branch}`; + const displayText = item.rawValue ? branch : `${prefix}${branch}`; if (isLink) { const origin = getRemoteInfo('origin', context); @@ -125,10 +133,15 @@ export class GitBranchWidget implements Widget { getCustomKeybinds(): CustomKeybind[] { return [ ...getHideNoGitKeybinds(), - { key: 'l', label: '(l)ink to repo', action: TOGGLE_LINK_ACTION } + { key: 'l', label: '(l)ink to repo', action: TOGGLE_LINK_ACTION }, + getSymbolKeybind() ]; } + renderEditor(props: WidgetEditorProps) { + return renderSymbolOverrideEditor(props, DEFAULT_SYMBOL); + } + supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } } diff --git a/src/widgets/GitConflicts.ts b/src/widgets/GitConflicts.ts index d6321c80..74d1121a 100644 --- a/src/widgets/GitConflicts.ts +++ b/src/widgets/GitConflicts.ts @@ -4,6 +4,7 @@ import type { CustomKeybind, Widget, WidgetEditorDisplay, + WidgetEditorProps, WidgetItem } from '../types/Widget'; import { @@ -18,6 +19,13 @@ import { handleToggleNoGitAction, isHideNoGitEnabled } from './shared/git-no-git'; +import { + formatSymbolPrefix, + getSymbolKeybind, + renderSymbolOverrideEditor +} from './shared/symbol-override'; + +const DEFAULT_SYMBOL = '⚠'; export class GitConflictsWidget implements Widget { getDefaultColor(): string { return 'red'; } @@ -43,11 +51,12 @@ export class GitConflictsWidget implements Widget { render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { const hideNoGit = isHideNoGitEnabled(item); + const prefix = formatSymbolPrefix(item, DEFAULT_SYMBOL); if (context.isPreview) { if (item.rawValue) return '2'; - return '⚠ 2'; + return `${prefix}2`; } if (!isInsideGitWorkTree(context)) { @@ -60,11 +69,18 @@ export class GitConflictsWidget implements Widget { return count.toString(); } - return `⚠ ${count}`; + return `${prefix}${count}`; } getCustomKeybinds(): CustomKeybind[] { - return getHideNoGitKeybinds(); + return [ + ...getHideNoGitKeybinds(), + getSymbolKeybind() + ]; + } + + renderEditor(props: WidgetEditorProps) { + return renderSymbolOverrideEditor(props, DEFAULT_SYMBOL); } getNumericValue(context: RenderContext, _item: WidgetItem): number | null { diff --git a/src/widgets/GitStaged.ts b/src/widgets/GitStaged.ts index ac548907..c9c91987 100644 --- a/src/widgets/GitStaged.ts +++ b/src/widgets/GitStaged.ts @@ -4,6 +4,7 @@ import type { CustomKeybind, Widget, WidgetEditorDisplay, + WidgetEditorProps, WidgetItem } from '../types/Widget'; import { @@ -18,6 +19,11 @@ import { handleToggleNoGitAction, isHideNoGitEnabled } from './shared/git-no-git'; +import { + getSymbol, + getSymbolKeybind, + renderSymbolOverrideEditor +} from './shared/symbol-override'; const DEFAULT_SYMBOL = '+'; @@ -47,7 +53,7 @@ export class GitStagedWidget implements Widget { const hideNoGit = isHideNoGitEnabled(item); if (context.isPreview) { - return item.rawValue ? 'true' : (item.character ?? DEFAULT_SYMBOL); + return item.rawValue ? 'true' : getSymbol(item, DEFAULT_SYMBOL); } if (!isInsideGitWorkTree(context)) { @@ -60,11 +66,18 @@ export class GitStagedWidget implements Widget { return null; } - return item.rawValue ? 'true' : (item.character ?? DEFAULT_SYMBOL); + return item.rawValue ? 'true' : getSymbol(item, DEFAULT_SYMBOL); } getCustomKeybinds(): CustomKeybind[] { - return getHideNoGitKeybinds(); + return [ + ...getHideNoGitKeybinds(), + getSymbolKeybind() + ]; + } + + renderEditor(props: WidgetEditorProps) { + return renderSymbolOverrideEditor(props, DEFAULT_SYMBOL); } getNumericValue(context: RenderContext, _item: WidgetItem): number | null { diff --git a/src/widgets/GitStatus.ts b/src/widgets/GitStatus.ts index dadf7e62..a6dae8ef 100644 --- a/src/widgets/GitStatus.ts +++ b/src/widgets/GitStatus.ts @@ -4,6 +4,7 @@ import type { CustomKeybind, Widget, WidgetEditorDisplay, + WidgetEditorProps, WidgetItem } from '../types/Widget'; import { @@ -18,6 +19,17 @@ import { handleToggleNoGitAction, isHideNoGitEnabled } from './shared/git-no-git'; +import { + getSlotSymbol, + getSymbolKeybind, + renderSymbolSlotsEditor, + type SymbolSlot +} from './shared/symbol-override'; + +const CONFLICTS_SLOT: SymbolSlot = { id: 'symbolConflicts', label: 'Conflicts', defaultSymbol: '!' }; +const STAGED_SLOT: SymbolSlot = { id: 'symbolStaged', label: 'Staged', defaultSymbol: '+' }; +const UNSTAGED_SLOT: SymbolSlot = { id: 'symbolUnstaged', label: 'Unstaged', defaultSymbol: '*' }; +const UNTRACKED_SLOT: SymbolSlot = { id: 'symbolUntracked', label: 'Untracked', defaultSymbol: '?' }; export class GitStatusWidget implements Widget { getDefaultColor(): string { return 'yellow'; } @@ -62,22 +74,29 @@ export class GitStatusWidget implements Widget { return this.formatStatus(item, status); } - private formatStatus(_item: WidgetItem, status: { staged: boolean; unstaged: boolean; untracked: boolean; conflicts: boolean }): string { + private formatStatus(item: WidgetItem, status: { staged: boolean; unstaged: boolean; untracked: boolean; conflicts: boolean }): string { const parts: string[] = []; if (status.conflicts) - parts.push('!'); + parts.push(getSlotSymbol(item, CONFLICTS_SLOT)); if (status.staged) - parts.push('+'); + parts.push(getSlotSymbol(item, STAGED_SLOT)); if (status.unstaged) - parts.push('*'); + parts.push(getSlotSymbol(item, UNSTAGED_SLOT)); if (status.untracked) - parts.push('?'); + parts.push(getSlotSymbol(item, UNTRACKED_SLOT)); return parts.join(''); } getCustomKeybinds(): CustomKeybind[] { - return getHideNoGitKeybinds(); + return [ + ...getHideNoGitKeybinds(), + getSymbolKeybind() + ]; + } + + renderEditor(props: WidgetEditorProps) { + return renderSymbolSlotsEditor(props, [CONFLICTS_SLOT, STAGED_SLOT, UNSTAGED_SLOT, UNTRACKED_SLOT]); } supportsRawValue(): boolean { return false; } diff --git a/src/widgets/GitUnstaged.ts b/src/widgets/GitUnstaged.ts index 324be0c0..f35d33df 100644 --- a/src/widgets/GitUnstaged.ts +++ b/src/widgets/GitUnstaged.ts @@ -4,6 +4,7 @@ import type { CustomKeybind, Widget, WidgetEditorDisplay, + WidgetEditorProps, WidgetItem } from '../types/Widget'; import { @@ -18,6 +19,11 @@ import { handleToggleNoGitAction, isHideNoGitEnabled } from './shared/git-no-git'; +import { + getSymbol, + getSymbolKeybind, + renderSymbolOverrideEditor +} from './shared/symbol-override'; const DEFAULT_SYMBOL = '*'; @@ -47,7 +53,7 @@ export class GitUnstagedWidget implements Widget { const hideNoGit = isHideNoGitEnabled(item); if (context.isPreview) { - return item.rawValue ? 'true' : (item.character ?? DEFAULT_SYMBOL); + return item.rawValue ? 'true' : getSymbol(item, DEFAULT_SYMBOL); } if (!isInsideGitWorkTree(context)) { @@ -60,11 +66,18 @@ export class GitUnstagedWidget implements Widget { return null; } - return item.rawValue ? 'true' : (item.character ?? DEFAULT_SYMBOL); + return item.rawValue ? 'true' : getSymbol(item, DEFAULT_SYMBOL); } getCustomKeybinds(): CustomKeybind[] { - return getHideNoGitKeybinds(); + return [ + ...getHideNoGitKeybinds(), + getSymbolKeybind() + ]; + } + + renderEditor(props: WidgetEditorProps) { + return renderSymbolOverrideEditor(props, DEFAULT_SYMBOL); } getNumericValue(context: RenderContext, _item: WidgetItem): number | null { diff --git a/src/widgets/GitUntracked.ts b/src/widgets/GitUntracked.ts index 2b9306fa..e92b83f8 100644 --- a/src/widgets/GitUntracked.ts +++ b/src/widgets/GitUntracked.ts @@ -4,6 +4,7 @@ import type { CustomKeybind, Widget, WidgetEditorDisplay, + WidgetEditorProps, WidgetItem } from '../types/Widget'; import { @@ -18,6 +19,11 @@ import { handleToggleNoGitAction, isHideNoGitEnabled } from './shared/git-no-git'; +import { + getSymbol, + getSymbolKeybind, + renderSymbolOverrideEditor +} from './shared/symbol-override'; const DEFAULT_SYMBOL = '?'; @@ -47,7 +53,7 @@ export class GitUntrackedWidget implements Widget { const hideNoGit = isHideNoGitEnabled(item); if (context.isPreview) { - return item.rawValue ? 'true' : (item.character ?? DEFAULT_SYMBOL); + return item.rawValue ? 'true' : getSymbol(item, DEFAULT_SYMBOL); } if (!isInsideGitWorkTree(context)) { @@ -60,11 +66,18 @@ export class GitUntrackedWidget implements Widget { return null; } - return item.rawValue ? 'true' : (item.character ?? DEFAULT_SYMBOL); + return item.rawValue ? 'true' : getSymbol(item, DEFAULT_SYMBOL); } getCustomKeybinds(): CustomKeybind[] { - return getHideNoGitKeybinds(); + return [ + ...getHideNoGitKeybinds(), + getSymbolKeybind() + ]; + } + + renderEditor(props: WidgetEditorProps) { + return renderSymbolOverrideEditor(props, DEFAULT_SYMBOL); } getNumericValue(context: RenderContext, _item: WidgetItem): number | null { diff --git a/src/widgets/GitWorktree.ts b/src/widgets/GitWorktree.ts index a4a2052f..0ac8d205 100644 --- a/src/widgets/GitWorktree.ts +++ b/src/widgets/GitWorktree.ts @@ -3,6 +3,7 @@ import type { CustomKeybind, Widget, WidgetEditorDisplay, + WidgetEditorProps, WidgetItem } from '../types/Widget'; import { @@ -16,6 +17,13 @@ import { handleToggleNoGitAction, isHideNoGitEnabled } from './shared/git-no-git'; +import { + formatSymbolPrefix, + getSymbolKeybind, + renderSymbolOverrideEditor +} from './shared/symbol-override'; + +const DEFAULT_SYMBOL = '𖠰'; export class GitWorktreeWidget implements Widget { getDefaultColor(): string { return 'blue'; } @@ -35,19 +43,20 @@ export class GitWorktreeWidget implements Widget { render(item: WidgetItem, context: RenderContext): string | null { const hideNoGit = isHideNoGitEnabled(item); + const prefix = formatSymbolPrefix(item, DEFAULT_SYMBOL); if (context.isPreview) - return item.rawValue ? 'main' : '𖠰 main'; + return item.rawValue ? 'main' : `${prefix}main`; if (!isInsideGitWorkTree(context)) { - return hideNoGit ? null : '𖠰 no git'; + return hideNoGit ? null : `${prefix}no git`; } const worktree = this.getGitWorktree(context); if (worktree) - return item.rawValue ? worktree : `𖠰 ${worktree}`; + return item.rawValue ? worktree : `${prefix}${worktree}`; - return hideNoGit ? null : '𖠰 no git'; + return hideNoGit ? null : `${prefix}no git`; } private getGitWorktree(context: RenderContext): string | null { @@ -80,7 +89,14 @@ export class GitWorktreeWidget implements Widget { } getCustomKeybinds(): CustomKeybind[] { - return getHideNoGitKeybinds(); + return [ + ...getHideNoGitKeybinds(), + getSymbolKeybind() + ]; + } + + renderEditor(props: WidgetEditorProps) { + return renderSymbolOverrideEditor(props, DEFAULT_SYMBOL); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/GitWorktreeMode.ts b/src/widgets/GitWorktreeMode.ts index 544317e5..9e2ada83 100644 --- a/src/widgets/GitWorktreeMode.ts +++ b/src/widgets/GitWorktreeMode.ts @@ -1,11 +1,21 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { + CustomKeybind, Widget, WidgetEditorDisplay, + WidgetEditorProps, WidgetItem } from '../types/Widget'; +import { + getSymbol, + getSymbolKeybind, + renderSymbolOverrideEditor +} from './shared/symbol-override'; + +const DEFAULT_SYMBOL = '⎇'; + export class GitWorktreeModeWidget implements Widget { getDefaultColor(): string { return 'yellow'; } getDescription(): string { return 'Shows indicator when Claude Code is in worktree mode'; } @@ -28,7 +38,16 @@ export class GitWorktreeModeWidget implements Widget { return null; } - return '⎇'; + const symbol = getSymbol(item, DEFAULT_SYMBOL); + return symbol.length > 0 ? symbol : null; + } + + getCustomKeybinds(): CustomKeybind[] { + return [getSymbolKeybind()]; + } + + renderEditor(props: WidgetEditorProps) { + return renderSymbolOverrideEditor(props, DEFAULT_SYMBOL); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/JjBookmarks.ts b/src/widgets/JjBookmarks.ts index bddc1938..b0e4ba8e 100644 --- a/src/widgets/JjBookmarks.ts +++ b/src/widgets/JjBookmarks.ts @@ -4,6 +4,7 @@ import type { CustomKeybind, Widget, WidgetEditorDisplay, + WidgetEditorProps, WidgetItem } from '../types/Widget'; import { @@ -11,6 +12,14 @@ import { runJjArgs } from '../utils/jj'; +import { + formatSymbolPrefix, + getSymbolKeybind, + renderSymbolOverrideEditor +} from './shared/symbol-override'; + +const DEFAULT_SYMBOL = '🔖'; + export class JjBookmarksWidget implements Widget { getDefaultColor(): string { return 'magenta'; } getDescription(): string { return 'Shows the current jujutsu bookmark(s)'; } @@ -46,21 +55,22 @@ export class JjBookmarksWidget implements Widget { render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { const hideNoJj = item.metadata?.hideNoJj === 'true'; + const prefix = formatSymbolPrefix(item, DEFAULT_SYMBOL); if (context.isPreview) { - return item.rawValue ? 'main' : '🔖 main'; + return item.rawValue ? 'main' : `${prefix}main`; } if (!isInsideJjRepo(context)) { - return hideNoJj ? null : '🔖 no jj'; + return hideNoJj ? null : `${prefix}no jj`; } const bookmarks = this.getJjBookmarks(context); if (bookmarks) { - return item.rawValue ? bookmarks : `🔖 ${bookmarks}`; + return item.rawValue ? bookmarks : `${prefix}${bookmarks}`; } - return hideNoJj ? null : '🔖 (none)'; + return hideNoJj ? null : `${prefix}(none)`; } private getJjBookmarks(context: RenderContext): string | null { @@ -86,10 +96,15 @@ export class JjBookmarksWidget implements Widget { getCustomKeybinds(): CustomKeybind[] { return [ - { key: 'h', label: '(h)ide \'no jj\' message', action: 'toggle-nojj' } + { key: 'h', label: '(h)ide \'no jj\' message', action: 'toggle-nojj' }, + getSymbolKeybind() ]; } + renderEditor(props: WidgetEditorProps) { + return renderSymbolOverrideEditor(props, DEFAULT_SYMBOL); + } + supportsRawValue(): boolean { return true; } supportsColors(): boolean { return true; } } diff --git a/src/widgets/JjWorkspace.ts b/src/widgets/JjWorkspace.ts index e1e5e278..5aac86bd 100644 --- a/src/widgets/JjWorkspace.ts +++ b/src/widgets/JjWorkspace.ts @@ -4,6 +4,7 @@ import type { CustomKeybind, Widget, WidgetEditorDisplay, + WidgetEditorProps, WidgetItem } from '../types/Widget'; import { @@ -11,7 +12,14 @@ import { runJjArgs } from '../utils/jj'; +import { + formatSymbolPrefix, + getSymbolKeybind, + renderSymbolOverrideEditor +} from './shared/symbol-override'; + const CURRENT_WORKSPACE_TEMPLATE = 'if(target.current_working_copy(), name ++ "\n")'; +const DEFAULT_SYMBOL = '◆'; export class JjWorkspaceWidget implements Widget { getDefaultColor(): string { return 'blue'; } @@ -48,21 +56,22 @@ export class JjWorkspaceWidget implements Widget { render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { const hideNoJj = item.metadata?.hideNoJj === 'true'; + const prefix = formatSymbolPrefix(item, DEFAULT_SYMBOL); if (context.isPreview) { - return item.rawValue ? 'default' : '◆ default'; + return item.rawValue ? 'default' : `${prefix}default`; } if (!isInsideJjRepo(context)) { - return hideNoJj ? null : '◆ no jj'; + return hideNoJj ? null : `${prefix}no jj`; } const workspace = this.getJjWorkspace(context); if (workspace) { - return item.rawValue ? workspace : `◆ ${workspace}`; + return item.rawValue ? workspace : `${prefix}${workspace}`; } - return hideNoJj ? null : '◆ no jj'; + return hideNoJj ? null : `${prefix}no jj`; } private getJjWorkspace(context: RenderContext): string | null { @@ -81,10 +90,15 @@ export class JjWorkspaceWidget implements Widget { getCustomKeybinds(): CustomKeybind[] { return [ - { key: 'h', label: '(h)ide \'no jj\' message', action: 'toggle-nojj' } + { key: 'h', label: '(h)ide \'no jj\' message', action: 'toggle-nojj' }, + getSymbolKeybind() ]; } + renderEditor(props: WidgetEditorProps) { + return renderSymbolOverrideEditor(props, DEFAULT_SYMBOL); + } + supportsRawValue(): boolean { return true; } supportsColors(): boolean { return true; } } diff --git a/src/widgets/__tests__/SymbolOverride.test.ts b/src/widgets/__tests__/SymbolOverride.test.ts new file mode 100644 index 00000000..b2f57965 --- /dev/null +++ b/src/widgets/__tests__/SymbolOverride.test.ts @@ -0,0 +1,148 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import type { + Widget, + WidgetItem +} from '../../types/Widget'; +import { GitAheadBehindWidget } from '../GitAheadBehind'; +import { GitBranchWidget } from '../GitBranch'; +import { GitConflictsWidget } from '../GitConflicts'; +import { GitStagedWidget } from '../GitStaged'; +import { GitStatusWidget } from '../GitStatus'; +import { GitUnstagedWidget } from '../GitUnstaged'; +import { GitUntrackedWidget } from '../GitUntracked'; +import { GitWorktreeWidget } from '../GitWorktree'; +import { GitWorktreeModeWidget } from '../GitWorktreeMode'; +import { JjBookmarksWidget } from '../JjBookmarks'; +import { JjWorkspaceWidget } from '../JjWorkspace'; +import { + formatSymbolPrefix, + getSymbol, + setSlotSymbol, + type SymbolSlot +} from '../shared/symbol-override'; + +interface SymbolCase { + name: string; + itemType: string; + widget: Widget; + defaultPreview: string; + overriddenPreview: string; + suppressedPreview: string | null; +} + +const cases: SymbolCase[] = [ + { name: 'GitBranchWidget', itemType: 'git-branch', widget: new GitBranchWidget(), defaultPreview: '⎇ main', overriddenPreview: '★ main', suppressedPreview: 'main' }, + { 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: '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: '' }, + { name: 'GitWorktreeModeWidget', itemType: 'worktree-mode', widget: new GitWorktreeModeWidget(), defaultPreview: '⎇', overriddenPreview: '★', suppressedPreview: null } +]; + +function makeItem(itemType: string, character?: string): WidgetItem { + return { + id: itemType, + type: itemType, + ...(character === undefined ? {} : { character }) + }; +} + +describe('symbol override rendering', () => { + it.each(cases)('$name renders its default symbol', ({ widget, itemType, defaultPreview }) => { + expect(widget.render(makeItem(itemType), { isPreview: true }, DEFAULT_SETTINGS)).toBe(defaultPreview); + }); + + it.each(cases)('$name renders a character override', ({ widget, itemType, overriddenPreview }) => { + expect(widget.render(makeItem(itemType, '★'), { isPreview: true }, DEFAULT_SETTINGS)).toBe(overriddenPreview); + }); + + it.each(cases)('$name renders without a symbol on an empty override', ({ widget, itemType, suppressedPreview }) => { + expect(widget.render(makeItem(itemType, ''), { isPreview: true }, DEFAULT_SETTINGS)).toBe(suppressedPreview); + }); + + it.each(cases)('$name exposes the shared glyph keybind and editor', ({ widget }) => { + const keys = (widget.getCustomKeybinds?.() ?? []).map(keybind => keybind.key); + expect(keys).toContain('g'); + expect(typeof widget.renderEditor).toBe('function'); + }); +}); + +describe('multi-slot symbol overrides', () => { + it('GitAheadBehindWidget renders ahead/behind symbol overrides', () => { + const widget = new GitAheadBehindWidget(); + + expect(widget.render(makeItem('git-ahead-behind'), { isPreview: true }, DEFAULT_SETTINGS)).toBe('↑2↓3'); + expect(widget.render({ + id: 'git-ahead-behind', + type: 'git-ahead-behind', + metadata: { symbolAhead: '▲', symbolBehind: '▼' } + }, { isPreview: true }, DEFAULT_SETTINGS)).toBe('▲2▼3'); + expect(widget.render({ + id: 'git-ahead-behind', + type: 'git-ahead-behind', + metadata: { symbolAhead: '', symbolBehind: '' } + }, { isPreview: true }, DEFAULT_SETTINGS)).toBe('23'); + }); + + it('GitStatusWidget renders per-part symbol overrides', () => { + const widget = new GitStatusWidget(); + + expect(widget.render(makeItem('git-status'), { isPreview: true }, DEFAULT_SETTINGS)).toBe('+*'); + expect(widget.render({ + id: 'git-status', + type: 'git-status', + metadata: { symbolStaged: '●' } + }, { isPreview: true }, DEFAULT_SETTINGS)).toBe('●*'); + }); + + it('exposes the shared glyph keybind and editor on both widgets', () => { + for (const widget of [new GitAheadBehindWidget(), new GitStatusWidget()] as Widget[]) { + const keys = (widget.getCustomKeybinds?.() ?? []).map(keybind => keybind.key); + expect(keys).toContain('g'); + expect(typeof widget.renderEditor).toBe('function'); + } + }); + + it('stores slot overrides in metadata and clears them on default', () => { + const slot: SymbolSlot = { id: 'symbolAhead', label: 'Ahead', defaultSymbol: '↑' }; + const overridden = setSlotSymbol(makeItem('git-ahead-behind'), slot, '▲'); + expect(overridden.metadata).toEqual({ symbolAhead: '▲' }); + + const cleared = setSlotSymbol(overridden, slot, '↑'); + expect(cleared.metadata).toBeUndefined(); + }); +}); + +describe('symbol override helpers', () => { + it('prefers the item character over the default', () => { + expect(getSymbol(makeItem('git-branch'), '⎇')).toBe('⎇'); + expect(getSymbol(makeItem('git-branch', '★'), '⎇')).toBe('★'); + expect(getSymbol(makeItem('git-branch', ''), '⎇')).toBe(''); + }); + + it('collapses the joining space for empty symbols', () => { + expect(formatSymbolPrefix(makeItem('git-branch'), '⎇')).toBe('⎇ '); + expect(formatSymbolPrefix(makeItem('git-branch', ''), '⎇')).toBe(''); + }); + + it('stores character overrides and removes them when matching the default', () => { + const characterSlot: SymbolSlot = { id: 'character', label: 'Glyph', defaultSymbol: '⎇' }; + const overridden = setSlotSymbol(makeItem('git-branch'), characterSlot, '★'); + expect(overridden.character).toBe('★'); + + const cleared = setSlotSymbol(overridden, characterSlot, '⎇'); + expect('character' in cleared).toBe(false); + + const suppressed = setSlotSymbol(makeItem('git-branch'), characterSlot, ''); + expect(suppressed.character).toBe(''); + }); +}); diff --git a/src/widgets/shared/__tests__/symbol-override-editor.test.tsx b/src/widgets/shared/__tests__/symbol-override-editor.test.tsx new file mode 100644 index 00000000..fdced6b5 --- /dev/null +++ b/src/widgets/shared/__tests__/symbol-override-editor.test.tsx @@ -0,0 +1,151 @@ +import { render } from 'ink'; +import { PassThrough } from 'node:stream'; +import stripAnsi from 'strip-ansi'; +import { + describe, + expect, + it, + vi +} from 'vitest'; + +import type { WidgetItem } from '../../../types/Widget'; +import { + renderSymbolSlotsEditor, + type SymbolSlot +} from '../symbol-override'; + +class MockTtyStream extends PassThrough { + isTTY = true; + columns = 120; + rows = 40; + + setRawMode() { + return this; + } + + ref() { + return this; + } + + unref() { + return this; + } +} + +interface CapturedWriteStream extends NodeJS.WriteStream { getOutput: () => string } + +function createMockStdin(): NodeJS.ReadStream { + return new MockTtyStream() as unknown as NodeJS.ReadStream; +} + +function createMockStdout(): CapturedWriteStream { + const stream = new MockTtyStream(); + const chunks: string[] = []; + + stream.on('data', (chunk: Buffer | string) => { + chunks.push(chunk.toString()); + }); + + return Object.assign(stream as unknown as NodeJS.WriteStream, { + getOutput() { + return chunks.join(''); + } + }); +} + +function flushInk() { + return new Promise((resolve) => { + setTimeout(resolve, 25); + }); +} + +const gitStatusSlots: SymbolSlot[] = [ + { id: 'symbolConflicts', label: 'Conflicts', defaultSymbol: '!' }, + { id: 'symbolStaged', label: 'Staged', defaultSymbol: '+' }, + { id: 'symbolUnstaged', label: 'Unstaged', defaultSymbol: '*' }, + { id: 'symbolUntracked', label: 'Untracked', defaultSymbol: '?' } +]; + +function renderEditor(widget: WidgetItem, slots: SymbolSlot[] = gitStatusSlots, onComplete = vi.fn(), onCancel = vi.fn()) { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + const instance = render( + renderSymbolSlotsEditor({ + widget, + onComplete, + onCancel, + action: 'edit-symbol-override' + }, slots), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + return { + instance, + stdin, + stdout, + stderr, + onComplete, + onCancel + }; +} + +function cleanupEditor(rendered: ReturnType): void { + rendered.instance.unmount(); + rendered.instance.cleanup(); + rendered.stdin.destroy(); + rendered.stdout.destroy(); + rendered.stderr.destroy(); +} + +function getPlainOutput(output: string): string { + return stripAnsi(output).replace(/\r\n/g, '\n'); +} + +describe('SymbolSlotsEditor', () => { + it('right-aligns labels so glyph values start in the same column', async () => { + const rendered = renderEditor({ id: 'git-status', type: 'git-status' }); + + try { + await flushInk(); + + const lines = getPlainOutput(rendered.stdout.getOutput()) + .split('\n') + .filter(line => gitStatusSlots.some(slot => line.includes(`${slot.label}:`))); + const colonColumns = lines.map(line => line.indexOf(':')); + + expect(lines).toHaveLength(gitStatusSlots.length); + expect(new Set(colonColumns)).toHaveLength(1); + } finally { + cleanupEditor(rendered); + } + }); + + it('resets the selected slot to default on Tab', async () => { + const rendered = renderEditor({ + id: 'git-status', + type: 'git-status', + metadata: { symbolConflicts: 'x' } + }); + + try { + await flushInk(); + rendered.stdin.write('\t'); + await flushInk(); + rendered.stdin.write('\r'); + await flushInk(); + + const updated = rendered.onComplete.mock.calls[0]?.[0] as WidgetItem | undefined; + expect(updated?.metadata).toBeUndefined(); + } finally { + cleanupEditor(rendered); + } + }); +}); diff --git a/src/widgets/shared/symbol-override.tsx b/src/widgets/shared/symbol-override.tsx new file mode 100644 index 00000000..4bb8ebda --- /dev/null +++ b/src/widgets/shared/symbol-override.tsx @@ -0,0 +1,170 @@ +import { + Box, + Text, + useInput +} from 'ink'; +import React, { useState } from 'react'; + +import type { + CustomKeybind, + WidgetEditorProps, + WidgetItem +} from '../../types/Widget'; +import { getVisibleWidth } from '../../utils/ansi'; +import { shouldInsertInput } from '../../utils/input-guards'; + +import { removeMetadataKeys } from './metadata'; + +export const SYMBOL_OVERRIDE_ACTION = 'edit-symbol-override'; + +const SYMBOL_KEYBIND: CustomKeybind = { + key: 'g', + label: '(g)lyph', + action: SYMBOL_OVERRIDE_ACTION +}; + +export function getSymbolKeybind(): CustomKeybind { + return SYMBOL_KEYBIND; +} + +// One editable symbol of a widget. id 'character' stores on the item's +// character field (the pre-existing override convention); any other id is a +// metadata key, which is how widgets with several symbols keep them apart. +export interface SymbolSlot { + id: string; + label: string; + defaultSymbol: string; +} + +/** The effective symbol for an item: its character override, or the widget default. */ +export function getSymbol(item: WidgetItem, defaultSymbol: string): string { + return item.character ?? defaultSymbol; +} + +/** The symbol plus its joining space; an empty override collapses the space too. */ +export function formatSymbolPrefix(item: WidgetItem, defaultSymbol: string): string { + const symbol = getSymbol(item, defaultSymbol); + return symbol.length > 0 ? `${symbol} ` : ''; +} + +export function getSlotSymbol(item: WidgetItem, slot: SymbolSlot): string { + if (slot.id === 'character') { + return getSymbol(item, slot.defaultSymbol); + } + + return item.metadata?.[slot.id] ?? slot.defaultSymbol; +} + +// Overrides matching the widget default are removed so untouched items stay +// minimal. Exported for tests. +export function setSlotSymbol(item: WidgetItem, slot: SymbolSlot, value: string): WidgetItem { + if (slot.id === 'character') { + if (value === slot.defaultSymbol) { + const { character, ...rest } = item; + void character; + return rest; + } + + return { ...item, character: value }; + } + + if (value === slot.defaultSymbol) { + return removeMetadataKeys(item, [slot.id]); + } + + return { + ...item, + metadata: { + ...item.metadata, + [slot.id]: value + } + }; +} + +export function renderSymbolOverrideEditor(props: WidgetEditorProps, defaultSymbol: string): React.ReactElement { + return renderSymbolSlotsEditor(props, [{ id: 'character', label: 'Glyph', defaultSymbol }]); +} + +export function renderSymbolSlotsEditor(props: WidgetEditorProps, slots: SymbolSlot[]): React.ReactElement { + return ; +} + +// Helper to get grapheme segments if Intl.Segmenter is available +function getFirstGrapheme(str: string): string { + if (str.length === 0) { + return ''; + } + + if ('Segmenter' in Intl) { + const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); + const segments = Array.from(segmenter.segment(str)); + return segments[0]?.segment ?? ''; + } + + // Fallback: just take first character + return Array.from(str)[0] ?? ''; +} + +const SymbolSlotsEditor: React.FC = ({ widget, slots, onComplete, onCancel }) => { + const [values, setValues] = useState(() => slots.map(slot => getSlotSymbol(widget, slot))); + const [selectedIndex, setSelectedIndex] = useState(0); + const labelWidth = Math.max(...slots.map(slot => getVisibleWidth(slot.label)), 0); + + useInput((input, key) => { + if (key.return) { + onComplete(slots.reduce((item, slot, index) => setSlotSymbol(item, slot, values[index] ?? ''), widget)); + } else if (key.escape) { + onCancel(); + } else if (key.upArrow && slots.length > 1) { + setSelectedIndex(selectedIndex - 1 < 0 ? slots.length - 1 : selectedIndex - 1); + } else if (key.downArrow && slots.length > 1) { + setSelectedIndex(selectedIndex + 1 > slots.length - 1 ? 0 : selectedIndex + 1); + } else if (key.tab) { + setValues(values.map((value, index) => ( + index === selectedIndex ? slots[selectedIndex]?.defaultSymbol ?? '' : value + ))); + } else if (key.backspace || key.delete) { + setValues(values.map((value, index) => (index === selectedIndex ? '' : value))); + } else if (shouldInsertInput(input, key)) { + // Take only the first grapheme (handles multi-byte emojis correctly) + const grapheme = getFirstGrapheme(input); + setValues(values.map((value, index) => (index === selectedIndex ? grapheme : value))); + } + }); + + return ( + + Glyphs + + {slots.length > 1 + ? '↑↓ row, type to set, Tab default, Backspace none, Enter save, ESC cancel' + : 'Type any character or emoji, Tab default, Backspace none, Enter save, ESC cancel'} + + + {slots.map((slot, index) => { + const isSelected = index === selectedIndex; + const value = values[index] ?? ''; + const labelPadding = ' '.repeat(Math.max(labelWidth - getVisibleWidth(slot.label), 0)); + return ( + + + + {isSelected ? '▶ ' : ' '} + + + + {`${labelPadding}${slot.label}: `} + + {value ? ( + {value} + ) : ( + (none) + )} + {` (default: ${slot.defaultSymbol})`} + + ); + })} + + + ); +};