diff --git a/AGENTS.md b/AGENTS.md index 663d9c0f..074f029f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,7 +89,7 @@ All widgets must implement: - `render()`: Core rendering logic that produces the widget output - `supportsRawValue()`: Whether widget supports raw value mode - `supportsColors()`: Whether widget supports color customization -- Optional: `renderEditor()`, `getCustomKeybinds()`, `handleEditorAction()` +- Optional: `renderEditor()`, `getCustomKeybinds()`, `getHideableStates()`, `handleEditorAction()` **Widget Registry Pattern:** - Located in src/utils/widgets.ts diff --git a/docs/USAGE.md b/docs/USAGE.md index 9833488c..ebd2a9d9 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -192,31 +192,67 @@ Widget picker: The keybind footer in the TUI only shows shortcuts that apply to the currently selected widget. 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) -- **Git remote widgets** (`Git Origin*` / `Git Upstream*`): `h` hide when no remote, `l` toggle clickable repo links +- **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 Is Fork**: `h` hide when the repo is not a fork - **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**: `p` cycle percentage/full bar/medium bar/short bar/short bar only, `v` invert fill in progress mode, `t` 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 - **Block Reset Timer**: `p` cycle time/full bar/short bar, `s` toggle compact time/date, `t` toggle exact reset date/time, `h` toggle 12/24-hour display in date mode, `z` edit timezone in date mode, `l` edit locale in date mode, `v` invert fill in progress mode - **Weekly Reset Timer**: `p` cycle time/full bar/short bar, `s` toggle compact time/date, `t` toggle exact reset date/time, `h` toggle hours-only in time mode or 12/24-hour display in date mode, `z` edit timezone in date mode, `l` edit locale in date mode, `v` invert fill in progress mode - **Context Bar**: `p` cycle medium/full/short/short-only progress bar -- **Compaction Counter**: `f` cycle format, `n` toggle Nerd Font icon in icon mode, `s` toggle trigger split (auto/manual/unknown), `t` toggle tokens reclaimed, `h` hide when zero -- **Cache widgets** (Cache Hit Rate, Cache Read, Cache Write): `t` toggle turn/session scope, `h` hide when empty +- **Compaction Counter**: `f` cycle format, `n` toggle Nerd Font icon in icon mode, `s` toggle trigger split (auto/manual/unknown), `t` toggle tokens reclaimed +- **Cache widgets** (Cache Hit Rate, Cache Read, Cache Write): `t` toggle turn/session scope - **Voice Status**: `f` cycle format, `n` toggle Nerd Font microphone icons - **Current Working Dir**: `h` home abbreviation, `s` segment editor, `f` fish-style path -- **Skills**: `v` cycle view mode, `h` hide when empty, `l` edit list limit in list mode +- **Skills**: `v` cycle view mode, `l` edit list limit in list mode - **Input Speed / Output Speed / Total Speed**: `w` edit the rolling window in seconds - **Custom Text / Custom Symbol**: `e` edit text or symbol - **Custom Command**: `e` command, `w` max width, `t` timeout, `p` preserve ANSI colors - **Link**: `u` URL, `e` link text - **Vim Mode**: `f` cycle format, `n` toggle Nerd Font icons +## Hiding Widgets Conditionally + +Widgets that can hide themselves share a single `h` (`(h)ide…`) keybind in the +line editor. It opens a checklist of the hideable states the selected widget +supports; toggle entries with `Space` and confirm with `Enter`. Enabled states +appear in the editor as `(hide: no-git, zero)`. + +Supported states by widget family: +- **Git widgets**: `no-git` hides the `(no git)` placeholder outside a repo +- **Git count widgets** (`Git Changes`, `Git Insertions`, `Git Deletions`, `Git Staged/Unstaged/Untracked Files`, `Git Conflicts`): `zero` hides the widget while its count is zero +- **JJ widgets**: `no-jj` hides the `(no jj)` placeholder outside a jj repo +- **Git Origin widgets**: `no-remote` hides the `no remote` placeholder +- **Git Upstream widgets / Git Ahead/Behind**: `no-upstream` hides the `no upstream` placeholder +- **Git Ahead/Behind**: `zero` hides `↑0↓0` when the branch is not diverged (enabled by default; uncheck it to show zeros) +- **Git PR** (rendered as "MR" for GitLab origins): `no-data` hides the `(no PR)` placeholder, `status` and `title` hide those segments of the PR display +- **Git Is Fork**: `not-fork` hides the widget when the repo is not a fork +- **Token widgets**: `zero` hides `0` token counts at session start +- **Session Cost**: `zero` hides `$0.00` +- **Session Clock**: `zero` hides durations under one minute +- **Block Timer**: `no-data` hides the `0hr 0m` / empty-bar display when no block is active +- **Input/Output/Total Speed**: `no-data` hides the `—` placeholder when no speed data exists +- **Output Style**: `default-value` hides the widget when the style is `default` +- **Compaction Counter**: `zero` hides the counter before any compaction occurs +- **Skills**: `empty` hides the widget before any skill is used +- **Extra Usage widgets**: `disabled` hides the `n/a` display when extra usage is off, `no-data` hides the error placeholder when usage data is unavailable +- **Session / Weekly / Weekly Sonnet / Weekly Opus Usage**: `no-data` hides the error placeholder (`[No credentials]`, `[Timeout]`, `[Rate limited]`, `[API Error]`, `[Parse Error]`) when usage data is unavailable +- **Custom Text / Custom Symbol**: `merge-target-hidden` hides the item when the widget it is merged with renders nothing, so icon prefixes/suffixes disappear together with their widget + +In `settings.json`, the enabled states are stored as a comma-separated list in +the item's metadata, e.g. `"metadata": { "hide": "no-git,zero" }`. An empty +list (`"hide": ""`) opts out of default-enabled states such as Git +Ahead/Behind's `zero`. Configs from older versions that used per-widget +boolean keys (`hideNoGit`, `hideNoJj`, `hideNoRemote`, `hideZero`, +`hideWhenEmpty`, `hideIfDisabled`, `hideStatus`, `hideTitle`, +`hideWhenNotFork`) are converted to the unified key automatically by the +settings migration on first load. After that, downgrading to an older +ccstatusline keeps the config readable, but older versions ignore the `hide` +key, so previously hidden placeholders show again until you upgrade back. + ## Custom Widgets ### Custom Text Widget diff --git a/src/tui/components/HideStatesEditor.tsx b/src/tui/components/HideStatesEditor.tsx new file mode 100644 index 00000000..921a0318 --- /dev/null +++ b/src/tui/components/HideStatesEditor.tsx @@ -0,0 +1,75 @@ +import { + Box, + Text, + useInput +} from 'ink'; +import React, { useState } from 'react'; + +import type { + HideableState, + WidgetItem +} from '../../types/Widget'; +import { + MERGE_TARGET_HIDDEN_HIDEABLE_STATE, + getEnabledHideStates, + setEnabledHideStates +} from '../../widgets/shared/hideable'; + +export interface HideStatesEditorProps { + widget: WidgetItem; + states: HideableState[]; + onComplete: (updatedWidget: WidgetItem) => void; + onCancel: () => void; +} + +export const HideStatesEditor: React.FC = ({ widget, states, onComplete, onCancel }) => { + const [selectedIndex, setSelectedIndex] = useState(0); + const [enabledKeys, setEnabledKeys] = useState(() => getEnabledHideStates(widget, states)); + + useInput((input, key) => { + if (key.return) { + onComplete(setEnabledHideStates(widget, states, enabledKeys)); + } else if (key.escape) { + onCancel(); + } else if (key.upArrow && states.length > 0) { + setSelectedIndex(selectedIndex - 1 < 0 ? states.length - 1 : selectedIndex - 1); + } else if (key.downArrow && states.length > 0) { + setSelectedIndex(selectedIndex + 1 > states.length - 1 ? 0 : selectedIndex + 1); + } else if (input === ' ') { + const state = states[selectedIndex]; + if (state) { + setEnabledKeys(enabledKeys.includes(state.key) + ? enabledKeys.filter(enabledKey => enabledKey !== state.key) + : [...enabledKeys, state.key]); + } + } + }); + + return ( + + Hide + ↑↓ select, Space toggle, Enter save, ESC cancel + + {states.map((state, index) => { + const isSelected = index === selectedIndex; + const isEnabled = enabledKeys.includes(state.key); + return ( + + + + {isSelected ? '▶ ' : ' '} + + + + {`[${isEnabled ? 'x' : ' '}] ${state.label}`} + + {state.key === MERGE_TARGET_HIDDEN_HIDEABLE_STATE.key && !widget.merge && ( + (requires merge) + )} + + ); + })} + + + ); +}; diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index a349b260..9719e6c5 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -22,8 +22,14 @@ import { getWidgetCatalog, getWidgetCatalogCategories } from '../../utils/widgets'; +import { + EDIT_HIDE_STATES_ACTION, + getHideKeybind, + getHideModifierText +} from '../../widgets/shared/hideable'; import { ConfirmDialog } from './ConfirmDialog'; +import { HideStatesEditor } from './HideStatesEditor'; import { handleMoveInputMode, handleNormalInputMode, @@ -96,11 +102,18 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB }; const getCustomKeybindsForWidget = (widgetImpl: Widget, widget: WidgetItem): CustomKeybind[] => { - if (!widgetImpl.getCustomKeybinds) { - return []; + const keybinds = widgetImpl.getCustomKeybinds ? [...widgetImpl.getCustomKeybinds(widget)] : []; + + // Widgets declaring hideable states share a single (h)ide… keybind + // that opens the hide-state checklist instead of per-widget toggles. + // Such widgets must leave 'h' unbound: keybind matching takes the + // first hit, so a widget-level 'h' would shadow this one (enforced by + // a registry-wide test in utils/__tests__/widgets.test.ts) + if ((widgetImpl.getHideableStates?.().length ?? 0) > 0) { + keybinds.push(getHideKeybind()); } - return widgetImpl.getCustomKeybinds(widget); + return keybinds; }; const openWidgetPicker = (action: WidgetPickerAction) => { @@ -299,6 +312,19 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB ? 'Insert Widget' : 'Change Widget Type'; + // The hide-state checklist is shared across all widgets that declare + // hideable states, so it renders here rather than via widget renderEditor + if (customEditorWidget?.action === EDIT_HIDE_STATES_ACTION) { + return ( + + ); + } + // If custom editor is active, render it instead of the normal UI if (customEditorWidget?.impl.renderEditor) { return customEditorWidget.impl.renderEditor({ @@ -527,6 +553,7 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB const widgetImpl = widget.type !== 'separator' && widget.type !== 'flex-separator' ? getWidget(widget.type) : null; const { displayText, modifierText } = widgetImpl?.getEditorDisplay(widget) ?? { displayText: getWidgetDisplay(widget) }; const supportsRawValue = widgetImpl?.supportsRawValue() ?? false; + const hideModifierText = widgetImpl ? getHideModifierText(widget, widgetImpl.getHideableStates?.() ?? []) : undefined; return ( @@ -544,6 +571,12 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB {modifierText} )} + {hideModifierText && ( + + {' '} + {hideModifierText} + + )} {supportsRawValue && widget.rawValue && (raw value)} {widget.merge === true && (merged→)} {widget.merge === 'no-padding' && (merged-no-pad→)} diff --git a/src/tui/components/index.ts b/src/tui/components/index.ts index fc8583e5..db60e1de 100644 --- a/src/tui/components/index.ts +++ b/src/tui/components/index.ts @@ -2,6 +2,7 @@ export * from './ColorMenu'; export * from './ConfirmDialog'; export * from './GlobalOverridesMenu'; +export * from './HideStatesEditor'; export * from './InstallMenu'; export * from './ItemsEditor'; export * from './LineSelector'; diff --git a/src/tui/components/items-editor/__tests__/input-handlers.test.ts b/src/tui/components/items-editor/__tests__/input-handlers.test.ts index 45dd555c..48e808c7 100644 --- a/src/tui/components/items-editor/__tests__/input-handlers.test.ts +++ b/src/tui/components/items-editor/__tests__/input-handlers.test.ts @@ -7,6 +7,10 @@ import { import type { WidgetItem } from '../../../../types/Widget'; import type { WidgetCatalogEntry } from '../../../../utils/widgets'; +import { + EDIT_HIDE_STATES_ACTION, + getHideKeybind +} from '../../../../widgets/shared/hideable'; import { handleMoveInputMode, handleNormalInputMode, @@ -698,6 +702,40 @@ describe('items-editor input handlers', () => { expect(updated?.[0]?.metadata?.mode).toBe('count'); }); + it('opens the shared hide-states editor for the injected hide keybind', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'git-branch' } + ]; + const onUpdate = vi.fn(); + const setCustomEditorWidget = vi.fn(); + + handleNormalInputMode({ + input: 'h', + key: {}, + widgets, + selectedIndex: 0, + separatorChars: ['|', '-'], + onBack: vi.fn(), + onUpdate, + setSelectedIndex: vi.fn(), + setMoveMode: vi.fn(), + setShowClearConfirm: vi.fn(), + openWidgetPicker: vi.fn(), + getCustomKeybindsForWidget: (widgetImpl, widget) => [ + ...(widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : []), + getHideKeybind() + ], + setCustomEditorWidget + }); + + expect(onUpdate).not.toHaveBeenCalled(); + const customEditorState = setCustomEditorWidget.mock.calls[0]?.[0] as + | { action?: string; widget?: WidgetItem } + | undefined; + expect(customEditorState?.action).toBe(EDIT_HIDE_STATES_ACTION); + expect(customEditorState?.widget?.type).toBe('git-branch'); + }); + it('opens custom editor for skills list limit action', () => { const widgets: WidgetItem[] = [ { id: '1', type: 'skills', metadata: { mode: 'list' } } diff --git a/src/tui/components/items-editor/input-handlers.ts b/src/tui/components/items-editor/input-handlers.ts index 989b4c16..0d5ef2f3 100644 --- a/src/tui/components/items-editor/input-handlers.ts +++ b/src/tui/components/items-editor/input-handlers.ts @@ -10,6 +10,7 @@ import { getWidget, type WidgetCatalogEntry } from '../../../utils/widgets'; +import { EDIT_HIDE_STATES_ACTION } from '../../../widgets/shared/hideable'; export type WidgetPickerAction = 'change' | 'add' | 'insert'; export type WidgetPickerLevel = 'category' | 'widget'; @@ -461,7 +462,7 @@ export function handleNormalInputMode({ const currentWidget = widgets[selectedIndex]; if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') { const widgetImpl = getWidget(currentWidget.type); - if (!widgetImpl?.getCustomKeybinds) { + if (!widgetImpl) { return; } @@ -469,6 +470,14 @@ export function handleNormalInputMode({ const matchedKeybind = customKeybinds.find(kb => kb.key === input); if (matchedKeybind && !key.ctrl) { + // The hide-state checklist is rendered by the items editor for + // every widget that declares hideable states, so it bypasses + // widget-level action handling + if (matchedKeybind.action === EDIT_HIDE_STATES_ACTION) { + setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action }); + return; + } + if (widgetImpl.handleEditorAction) { const updatedWidget = widgetImpl.handleEditorAction(matchedKeybind.action, currentWidget); if (updatedWidget) { diff --git a/src/types/Settings.ts b/src/types/Settings.ts index efa2a31c..9b94421a 100644 --- a/src/types/Settings.ts +++ b/src/types/Settings.ts @@ -6,7 +6,7 @@ import { PowerlineConfigSchema } from './PowerlineConfig'; import { WidgetItemSchema } from './Widget'; // Current version - bump this when making breaking changes to the schema -export const CURRENT_VERSION = 3; +export const CURRENT_VERSION = 4; export const InstallationMetadataSchema = z.discriminatedUnion('method', [ z.object({ diff --git a/src/types/Widget.ts b/src/types/Widget.ts index 121c2a9b..4f9b2817 100644 --- a/src/types/Widget.ts +++ b/src/types/Widget.ts @@ -20,7 +20,6 @@ export const WidgetItemSchema = z.object({ preserveColors: z.boolean().optional(), timeout: z.number().optional(), merge: z.union([z.boolean(), z.literal('no-padding')]).optional(), - hide: z.boolean().optional(), metadata: z.record(z.string(), z.string()).optional() }); @@ -33,6 +32,15 @@ export interface WidgetEditorDisplay { modifierText?: string; } +// A condition under which a widget can hide instead of rendering placeholder +// output (e.g. 'no-git', 'zero'). Stored in metadata.hide as a comma-separated +// list of enabled state keys; defaultEnabled states apply when metadata.hide is absent. +export interface HideableState { + key: string; + label: string; + defaultEnabled?: boolean; +} + export interface Widget { getDefaultColor(): string; getDescription(): string; @@ -41,6 +49,7 @@ export interface Widget { getEditorDisplay(item: WidgetItem): WidgetEditorDisplay; render(item: WidgetItem, context: RenderContext, settings: Settings): string | null; getCustomKeybinds?(item?: WidgetItem): CustomKeybind[]; + getHideableStates?(): HideableState[]; renderEditor?(props: WidgetEditorProps): React.ReactElement | null; supportsRawValue(): boolean; supportsColors(item: WidgetItem): boolean; diff --git a/src/utils/__tests__/migrations.test.ts b/src/utils/__tests__/migrations.test.ts index 8384442a..fa92d579 100644 --- a/src/utils/__tests__/migrations.test.ts +++ b/src/utils/__tests__/migrations.test.ts @@ -77,11 +77,86 @@ describe('migrations', () => { lines: [[ { type: 'model' } ]] - }, 3) as Record; + }, 4) as Record; - expect(migrated.version).toBe(3); + expect(migrated.version).toBe(4); const updateMessage = migrated.updatemessage as { message?: string; remaining?: number }; expect(updateMessage.message).toContain('v2.0.2'); expect(updateMessage.remaining).toBe(12); }); }); + +describe('v3 to v4 hide flag migration', () => { + function migrateItem(item: Record): Record | undefined { + const migrated = migrateConfig({ + version: 3, + lines: [[item]] + }, 4) as { lines?: Record[][] }; + + return migrated.lines?.[0]?.[0]; + } + + it('bumps the version without touching unrelated data', () => { + const migrated = migrateConfig({ version: 3, flexMode: 'full' }, 4) as Record; + + expect(migrated.version).toBe(4); + expect(migrated.flexMode).toBe('full'); + expect(migrated.updatemessage).toBeUndefined(); + }); + + it.each([ + ['git-branch', { hideNoGit: 'true' }, 'no-git'], + ['jj-changes', { hideNoJj: 'true' }, 'no-jj'], + ['git-origin-owner', { hideNoRemote: 'true' }, 'no-remote'], + ['git-upstream-owner', { hideNoRemote: 'true' }, 'no-upstream'], + ['compaction-counter', { hideZero: 'true' }, 'zero'], + ['skills', { hideWhenEmpty: 'true' }, 'empty'], + ['extra-usage-remaining', { hideIfDisabled: 'true' }, 'disabled'], + ['extra-usage-utilization', { hideIfDisabled: 'true' }, 'disabled'], + ['git-is-fork', { hideWhenNotFork: 'true' }, 'not-fork'] + ])('converts %s legacy flags to the unified hide list', (type, metadata, expected) => { + const item = migrateItem({ id: '1', type, metadata }); + + expect(item?.metadata).toEqual({ hide: expected }); + }); + + it('expands hideNoGit to every state it covered on git-ahead-behind', () => { + const item = migrateItem({ id: '1', type: 'git-ahead-behind', metadata: { hideNoGit: 'true' } }); + + expect(item?.metadata).toEqual({ hide: 'no-git,no-upstream,zero' }); + }); + + it('expands hideNoGit to the no-data state on git-review and its legacy git-pr alias', () => { + for (const type of ['git-review', 'git-pr']) { + const item = migrateItem({ id: '1', type, metadata: { hideNoGit: 'true', hideStatus: 'true' } }); + + expect(item?.metadata).toEqual({ hide: 'no-git,no-data,status' }); + } + }); + + it('drops disabled legacy flags without writing a hide list', () => { + const item = migrateItem({ id: '1', type: 'git-branch', metadata: { hideNoGit: 'false' } }); + + expect(item?.metadata).toBeUndefined(); + }); + + it('keeps default-enabled states implicit when dropping disabled flags', () => { + const item = migrateItem({ id: '1', type: 'git-ahead-behind', metadata: { hideNoGit: 'false' } }); + + expect(item?.metadata).toBeUndefined(); + }); + + it('preserves unrelated metadata', () => { + const item = migrateItem({ id: '1', type: 'skills', metadata: { hideWhenEmpty: 'true', mode: 'list' } }); + + expect(item?.metadata).toEqual({ hide: 'empty', mode: 'list' }); + }); + + it('leaves unknown widget types and flag-free items untouched', () => { + const unknown = migrateItem({ id: '1', type: 'model', metadata: { hideNoGit: 'true' } }); + expect(unknown?.metadata).toEqual({ hideNoGit: 'true' }); + + const untouched = migrateItem({ id: '1', type: 'git-branch', metadata: { hide: 'no-git' } }); + expect(untouched?.metadata).toEqual({ hide: 'no-git' }); + }); +}); diff --git a/src/utils/__tests__/renderer-merge-target-hidden.test.ts b/src/utils/__tests__/renderer-merge-target-hidden.test.ts new file mode 100644 index 00000000..e89b9555 --- /dev/null +++ b/src/utils/__tests__/renderer-merge-target-hidden.test.ts @@ -0,0 +1,123 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { WidgetItem } from '../../types/Widget'; +import { + applyMergeTargetHiding, + type PreRenderedWidget +} from '../renderer'; + +function element( + type: string, + content: string, + overrides: Partial = {} +): PreRenderedWidget { + return { + content, + plainLength: content.length, + widget: { id: `${type}-${Math.random()}`, type, ...overrides } + }; +} + +function hidingSymbol(content: string, merge?: WidgetItem['merge']): PreRenderedWidget { + return element('custom-symbol', content, { + merge, + metadata: { hide: 'merge-target-hidden' } + }); +} + +describe('applyMergeTargetHiding', () => { + it('hides a merged decorative prefix when its target rendered nothing', () => { + const line = [hidingSymbol('★', true), element('git-branch', '')]; + + applyMergeTargetHiding(line); + + expect(line[0]?.content).toBe(''); + expect(line[0]?.plainLength).toBe(0); + }); + + it('keeps the decorative prefix when its target rendered content', () => { + const line = [hidingSymbol('★', true), element('git-branch', '⎇ main')]; + + applyMergeTargetHiding(line); + + expect(line[0]?.content).toBe('★'); + }); + + it('keeps decoratives that did not opt into merge-target-hidden', () => { + const line = [element('custom-symbol', '★', { merge: true }), element('git-branch', '')]; + + applyMergeTargetHiding(line); + + expect(line[0]?.content).toBe('★'); + }); + + it('hides a decorative suffix when the widget merging into it rendered nothing', () => { + const line = [element('git-branch', '', { merge: true }), hidingSymbol('★')]; + + applyMergeTargetHiding(line); + + expect(line[1]?.content).toBe(''); + }); + + it('collapses a fully merged chain as a unit', () => { + const line = [ + hidingSymbol('★', 'no-padding'), + element('git-branch', '', { merge: 'no-padding' }), + hidingSymbol('✦') + ]; + + applyMergeTargetHiding(line); + + expect(line.map(el => el.content)).toEqual(['', '', '']); + }); + + it('targets the nearest non-decorative widget in merge direction', () => { + const line = [ + hidingSymbol('★', true), + element('tokens-total', '', { merge: true }), + hidingSymbol('✦', true), + element('git-branch', '⎇ main') + ]; + + applyMergeTargetHiding(line); + + // ★ belongs to the hidden tokens widget; ✦ belongs to the visible branch + expect(line[0]?.content).toBe(''); + expect(line[2]?.content).toBe('✦'); + }); + + it('leaves unmerged decoratives alone even when the state is enabled', () => { + const line = [hidingSymbol('★'), element('git-branch', '')]; + + applyMergeTargetHiding(line); + + expect(line[0]?.content).toBe('★'); + }); + + it('honors the legacy-free unified metadata only for decorative types', () => { + const line = [ + element('git-sha', 'abc1234', { merge: true, metadata: { hide: 'merge-target-hidden' } }), + element('git-branch', '') + ]; + + applyMergeTargetHiding(line); + + expect(line[0]?.content).toBe('abc1234'); + }); + + it('skips separator elements when pairing merged items', () => { + const line = [ + hidingSymbol('★', true), + element('separator', ''), + element('git-branch', '') + ]; + + applyMergeTargetHiding(line); + + expect(line[0]?.content).toBe(''); + }); +}); diff --git a/src/utils/__tests__/widgets.test.ts b/src/utils/__tests__/widgets.test.ts index 1d59d1cf..21879c32 100644 --- a/src/utils/__tests__/widgets.test.ts +++ b/src/utils/__tests__/widgets.test.ts @@ -8,6 +8,7 @@ import { DEFAULT_SETTINGS, type Settings } from '../../types/Settings'; +import { getHideKeybind } from '../../widgets/shared/hideable'; import { filterWidgetCatalog, getAllWidgetTypes, @@ -151,6 +152,32 @@ describe('legacy widget type aliases', () => { }); }); +describe('hideable state keybind reservation', () => { + it('widgets declaring hideable states leave the shared hide key free', () => { + const reservedKey = getHideKeybind().key; + const settings: Settings = { + ...DEFAULT_SETTINGS, + powerline: { ...DEFAULT_SETTINGS.powerline } + }; + const runtimeTypes = getAllWidgetTypes(settings).filter( + type => type !== 'separator' && type !== 'flex-separator' + ); + + for (const type of runtimeTypes) { + const widget = getWidget(type); + if ((widget?.getHideableStates?.().length ?? 0) === 0) { + continue; + } + + // The items editor appends the shared hide keybind last and + // keybind matching takes the first hit, so a widget-level binding + // would shadow the hide editor + const keys = (widget?.getCustomKeybinds?.() ?? []).map(keybind => keybind.key); + expect(keys).not.toContain(reservedKey); + } + }); +}); + describe('widget catalog filtering', () => { const catalog = getWidgetCatalog({ ...DEFAULT_SETTINGS, diff --git a/src/utils/migrations.ts b/src/utils/migrations.ts index 23346efe..4da01382 100644 --- a/src/utils/migrations.ts +++ b/src/utils/migrations.ts @@ -120,6 +120,152 @@ function copyV1Fields(data: Record, target: Record v4: per-widget boolean hide flags become the unified metadata.hide +// list. The tables are a frozen snapshot of v3 semantics keyed by widget type +// (including the pre-existing 'git-pr' alias for 'git-review', which is only +// upgraded in memory at load time and can persist on disk). hideNoGit expands +// to several states where one flag covered multiple placeholders: Git +// Ahead/Behind also hid '(no upstream)' and Git PR also hid the no-PR +// placeholder. defaultEnabled lists states a widget hides without any +// metadata (Git Ahead/Behind's previously hardcoded 0/0 auto-hide); they are +// folded into a written hide list because a present list is authoritative and +// would otherwise turn them off. +interface HideFlagRule { + legacy: Record; + stateOrder: string[]; + defaultEnabled?: string[]; +} + +const NO_GIT_HIDE_RULE: HideFlagRule = { legacy: { hideNoGit: ['no-git'] }, stateOrder: ['no-git'] }; +const NO_JJ_HIDE_RULE: HideFlagRule = { legacy: { hideNoJj: ['no-jj'] }, stateOrder: ['no-jj'] }; +const NO_REMOTE_HIDE_RULE: HideFlagRule = { legacy: { hideNoRemote: ['no-remote'] }, stateOrder: ['no-remote'] }; +const NO_UPSTREAM_HIDE_RULE: HideFlagRule = { legacy: { hideNoRemote: ['no-upstream'] }, stateOrder: ['no-upstream'] }; +const GIT_REVIEW_HIDE_RULE: HideFlagRule = { + legacy: { + hideNoGit: ['no-git', 'no-data'], + hideStatus: ['status'], + hideTitle: ['title'] + }, + stateOrder: ['no-git', 'no-data', 'status', 'title'] +}; +const EXTRA_USAGE_HIDE_RULE: HideFlagRule = { legacy: { hideIfDisabled: ['disabled'] }, stateOrder: ['disabled'] }; +const CACHE_HIDE_RULE: HideFlagRule = { legacy: { hideWhenEmpty: ['empty'] }, stateOrder: ['empty'] }; + +const V4_HIDE_FLAG_RULES: Record = { + 'git-branch': NO_GIT_HIDE_RULE, + 'git-changes': NO_GIT_HIDE_RULE, + 'git-insertions': NO_GIT_HIDE_RULE, + 'git-deletions': NO_GIT_HIDE_RULE, + 'git-staged-files': NO_GIT_HIDE_RULE, + 'git-unstaged-files': NO_GIT_HIDE_RULE, + 'git-untracked-files': NO_GIT_HIDE_RULE, + 'git-clean-status': NO_GIT_HIDE_RULE, + 'git-root-dir': NO_GIT_HIDE_RULE, + 'git-worktree': NO_GIT_HIDE_RULE, + 'git-status': NO_GIT_HIDE_RULE, + 'git-staged': NO_GIT_HIDE_RULE, + 'git-unstaged': NO_GIT_HIDE_RULE, + 'git-untracked': NO_GIT_HIDE_RULE, + 'git-conflicts': NO_GIT_HIDE_RULE, + 'git-sha': NO_GIT_HIDE_RULE, + 'git-ahead-behind': { + legacy: { hideNoGit: ['no-git', 'no-upstream'] }, + stateOrder: ['no-git', 'no-upstream', 'zero'], + defaultEnabled: ['zero'] + }, + 'git-review': GIT_REVIEW_HIDE_RULE, + 'git-pr': GIT_REVIEW_HIDE_RULE, + 'git-origin-owner': NO_REMOTE_HIDE_RULE, + 'git-origin-repo': NO_REMOTE_HIDE_RULE, + 'git-origin-owner-repo': NO_REMOTE_HIDE_RULE, + 'git-upstream-owner': NO_UPSTREAM_HIDE_RULE, + 'git-upstream-repo': NO_UPSTREAM_HIDE_RULE, + 'git-upstream-owner-repo': NO_UPSTREAM_HIDE_RULE, + 'git-is-fork': { legacy: { hideWhenNotFork: ['not-fork'] }, stateOrder: ['not-fork'] }, + 'jj-bookmarks': NO_JJ_HIDE_RULE, + 'jj-workspace': NO_JJ_HIDE_RULE, + 'jj-root-dir': NO_JJ_HIDE_RULE, + 'jj-changes': NO_JJ_HIDE_RULE, + 'jj-insertions': NO_JJ_HIDE_RULE, + 'jj-deletions': NO_JJ_HIDE_RULE, + 'jj-description': NO_JJ_HIDE_RULE, + 'jj-revision': NO_JJ_HIDE_RULE, + 'compaction-counter': { legacy: { hideZero: ['zero'] }, stateOrder: ['zero'] }, + 'skills': { legacy: { hideWhenEmpty: ['empty'] }, stateOrder: ['empty'] }, + 'cache-read': CACHE_HIDE_RULE, + 'cache-write': CACHE_HIDE_RULE, + 'cache-hit-rate': CACHE_HIDE_RULE, + 'extra-usage-utilization': EXTRA_USAGE_HIDE_RULE, + 'extra-usage-remaining': EXTRA_USAGE_HIDE_RULE, + 'extra-usage-used': EXTRA_USAGE_HIDE_RULE +}; + +const V4_LEGACY_HIDE_KEYS = [ + 'hideNoGit', + 'hideNoJj', + 'hideNoRemote', + 'hideZero', + 'hideWhenEmpty', + 'hideIfDisabled', + 'hideStatus', + 'hideTitle', + 'hideWhenNotFork' +]; + +function migrateItemHideFlags(item: unknown): unknown { + if (!isRecord(item) || typeof item.type !== 'string' || !isRecord(item.metadata)) { + return item; + } + + const rule = V4_HIDE_FLAG_RULES[item.type]; + if (!rule) { + return item; + } + + const metadata = item.metadata; + const presentKeys = V4_LEGACY_HIDE_KEYS.filter(key => key in metadata); + if (presentKeys.length === 0) { + return item; + } + + const enabled = new Set(rule.defaultEnabled ?? []); + // A hand-written hide list takes part in the union so it is never lost + if (typeof metadata.hide === 'string') { + for (const key of metadata.hide.split(',')) { + if (key.trim().length > 0) { + enabled.add(key.trim()); + } + } + } + for (const key of presentKeys) { + if (metadata[key] === 'true') { + for (const state of rule.legacy[key] ?? []) { + enabled.add(state); + } + } + } + + const nextMetadata: Record = Object.fromEntries( + Object.entries(metadata).filter(([key]) => !V4_LEGACY_HIDE_KEYS.includes(key) && key !== 'hide') + ); + const orderedEnabled = rule.stateOrder.filter(state => enabled.has(state)); + const defaults = rule.defaultEnabled ?? []; + const matchesDefaults = orderedEnabled.length === defaults.length + && orderedEnabled.every(state => defaults.includes(state)); + if (!matchesDefaults) { + nextMetadata.hide = orderedEnabled.join(','); + } + + const migratedItem: Record = { ...item }; + if (Object.keys(nextMetadata).length > 0) { + migratedItem.metadata = nextMetadata; + } else { + delete migratedItem.metadata; + } + + return migratedItem; +} + // Define all migrations here export const migrations: Migration[] = [ { @@ -168,6 +314,27 @@ export const migrations: Migration[] = [ remaining: 12 }; + return migrated; + } + }, + { + fromVersion: 3, + toVersion: 4, + description: 'Migrate from v3 to v4', + migrate: (data) => { + const migrated: Record = { ...data }; + + // Convert per-widget hide flags to the unified metadata.hide list. + // No updatemessage: rendering is unchanged, so there is nothing + // for users to act on. + if (Array.isArray(data.lines)) { + migrated.lines = data.lines.map((line: unknown) => (Array.isArray(line) + ? line.map(migrateItemHideFlags) + : line)); + } + + migrated.version = 4; + return migrated; } } diff --git a/src/utils/renderer.ts b/src/utils/renderer.ts index 47ac3914..481356c7 100644 --- a/src/utils/renderer.ts +++ b/src/utils/renderer.ts @@ -9,6 +9,10 @@ import { type ColorLevel } from '../types/ColorLevel'; import type { Settings } from '../types/Settings'; +import { + MERGE_TARGET_HIDDEN_HIDEABLE_STATE, + isHidden +} from '../widgets/shared/hideable'; import { applyLineGradient, @@ -761,6 +765,56 @@ export function countPowerlineStartCapSlots( return renderedSegmentCount; } +// Decorative widget types that can opt into hiding alongside their merge target +const DECORATIVE_WIDGET_TYPES = new Set(['custom-text', 'custom-symbol']); + +function isSeparatorType(type: string): boolean { + return type === 'separator' || type === 'flex-separator'; +} + +// Collapses decorative items (custom-text/custom-symbol) that opted into the +// merge-target-hidden state when the widget they are merged with rendered +// nothing, so merged chains hide as a unit instead of leaving orphaned icons. +export function applyMergeTargetHiding(preRenderedLine: PreRenderedWidget[]): void { + // Merge flags pair adjacent non-separator items, so chains are detected on + // the separator-free view of the line (matching powerline merge handling) + const chainable = preRenderedLine.filter(element => !isSeparatorType(element.widget.type)); + + let chainStart = 0; + for (let i = 0; i < chainable.length; i++) { + const linksToNext = Boolean(chainable[i]?.widget.merge) && i < chainable.length - 1; + if (linksToNext) { + continue; + } + + const chain = chainable.slice(chainStart, i + 1); + chainStart = i + 1; + if (chain.length < 2) { + continue; + } + + for (let position = 0; position < chain.length; position++) { + const element = chain[position]; + if (!element + || !DECORATIVE_WIDGET_TYPES.has(element.widget.type) + || !isHidden(element.widget, MERGE_TARGET_HIDDEN_HIDEABLE_STATE.key)) { + continue; + } + + // The target is the nearest non-decorative widget in the chain, + // preferring the merge direction (forward), falling back to the + // widget merging into this one (backward) + const target = chain.slice(position + 1).find(candidate => !DECORATIVE_WIDGET_TYPES.has(candidate.widget.type)) + ?? chain.slice(0, position).reverse().find(candidate => !DECORATIVE_WIDGET_TYPES.has(candidate.widget.type)); + + if (target?.content === '') { + element.content = ''; + element.plainLength = 0; + } + } + } +} + // Pre-render all widgets once and cache the results export function preRenderAllWidgets( allLinesWidgets: WidgetItem[][], @@ -808,6 +862,7 @@ export function preRenderAllWidgets( }); } + applyMergeTargetHiding(preRenderedLine); preRenderedLines.push(preRenderedLine); } diff --git a/src/widgets/BlockTimer.ts b/src/widgets/BlockTimer.ts index 41a0ef45..117814cc 100644 --- a/src/widgets/BlockTimer.ts +++ b/src/widgets/BlockTimer.ts @@ -2,6 +2,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -11,6 +12,7 @@ import { resolveUsageWindowWithFallback } from '../utils/usage'; +import { isHidden } from './shared/hideable'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; import { cycleUsageDisplayMode, @@ -34,6 +36,8 @@ function makeTimerProgressBar(percent: number, width: number): string { return '█'.repeat(filledWidth) + '░'.repeat(emptyWidth); } +const NO_DATA_HIDEABLE_STATE: HideableState = { key: 'no-data', label: 'when there is no active block' }; + export class BlockTimerWidget implements Widget { getDefaultColor(): string { return 'yellow'; } getDescription(): string { return 'Shows current 5hr block elapsed time or progress'; } @@ -92,6 +96,10 @@ export class BlockTimerWidget implements Widget { const window = resolveUsageWindowWithFallback(usageData, context.blockMetrics); if (!window) { + if (isHidden(item, NO_DATA_HIDEABLE_STATE.key)) { + return null; + } + if (isUsageProgressMode(displayMode)) { const barWidth = getUsageProgressBarWidth(displayMode); const emptyBar = '░'.repeat(barWidth); @@ -134,6 +142,10 @@ export class BlockTimerWidget implements Widget { return getUsageTimerCustomKeybinds(item); } + getHideableStates(): HideableState[] { + return [NO_DATA_HIDEABLE_STATE]; + } + supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } } diff --git a/src/widgets/CacheHitRate.ts b/src/widgets/CacheHitRate.ts index 4544bb87..e78a5646 100644 --- a/src/widgets/CacheHitRate.ts +++ b/src/widgets/CacheHitRate.ts @@ -2,6 +2,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -12,12 +13,13 @@ import { getCacheTokens } from './shared/cache-metrics'; import { + CACHE_EMPTY_HIDEABLE_STATE, getCacheKeybinds, getCacheModifierText, handleCacheOptionsAction, - isCacheHideWhenEmptyEnabled, isCacheSessionScope } from './shared/cache-scope'; +import { isHidden } from './shared/hideable'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; export class CacheHitRateWidget implements Widget { @@ -29,6 +31,10 @@ export class CacheHitRateWidget implements Widget { return { displayText: this.getDisplayName(), modifierText: getCacheModifierText(item) }; } + getHideableStates(): HideableState[] { + return [CACHE_EMPTY_HIDEABLE_STATE]; + } + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { return handleCacheOptionsAction(action, item); } @@ -38,7 +44,7 @@ export class CacheHitRateWidget implements Widget { return formatRawOrLabeledValue(item, 'Cache Hit: ', '87.0%'); } - const hideWhenEmpty = isCacheHideWhenEmptyEnabled(item); + const hideWhenEmpty = isHidden(item, CACHE_EMPTY_HIDEABLE_STATE.key); const tokens = getCacheTokens(context, isCacheSessionScope(item)); if (!tokens) { return hideWhenEmpty ? null : formatRawOrLabeledValue(item, 'Cache Hit: ', 'n/a'); diff --git a/src/widgets/CacheRead.ts b/src/widgets/CacheRead.ts index b14b1afb..3ccf61c2 100644 --- a/src/widgets/CacheRead.ts +++ b/src/widgets/CacheRead.ts @@ -2,6 +2,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -13,12 +14,13 @@ import { getCacheTokens } from './shared/cache-metrics'; import { + CACHE_EMPTY_HIDEABLE_STATE, getCacheKeybinds, getCacheModifierText, handleCacheOptionsAction, - isCacheHideWhenEmptyEnabled, isCacheSessionScope } from './shared/cache-scope'; +import { isHidden } from './shared/hideable'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; export class CacheReadWidget implements Widget { @@ -30,6 +32,10 @@ export class CacheReadWidget implements Widget { return { displayText: this.getDisplayName(), modifierText: getCacheModifierText(item) }; } + getHideableStates(): HideableState[] { + return [CACHE_EMPTY_HIDEABLE_STATE]; + } + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { return handleCacheOptionsAction(action, item); } @@ -39,7 +45,7 @@ export class CacheReadWidget implements Widget { return formatRawOrLabeledValue(item, 'Cache Read: ', '12k (64.0%)'); } - const hideWhenEmpty = isCacheHideWhenEmptyEnabled(item); + const hideWhenEmpty = isHidden(item, CACHE_EMPTY_HIDEABLE_STATE.key); const tokens = getCacheTokens(context, isCacheSessionScope(item)); if (!tokens) { return hideWhenEmpty ? null : formatRawOrLabeledValue(item, 'Cache Read: ', 'n/a'); diff --git a/src/widgets/CacheWrite.ts b/src/widgets/CacheWrite.ts index 63807bbd..58fc76fd 100644 --- a/src/widgets/CacheWrite.ts +++ b/src/widgets/CacheWrite.ts @@ -2,6 +2,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -13,12 +14,13 @@ import { getCacheWritePercentage } from './shared/cache-metrics'; import { + CACHE_EMPTY_HIDEABLE_STATE, getCacheKeybinds, getCacheModifierText, handleCacheOptionsAction, - isCacheHideWhenEmptyEnabled, isCacheSessionScope } from './shared/cache-scope'; +import { isHidden } from './shared/hideable'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; export class CacheWriteWidget implements Widget { @@ -30,6 +32,10 @@ export class CacheWriteWidget implements Widget { return { displayText: this.getDisplayName(), modifierText: getCacheModifierText(item) }; } + getHideableStates(): HideableState[] { + return [CACHE_EMPTY_HIDEABLE_STATE]; + } + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { return handleCacheOptionsAction(action, item); } @@ -39,7 +45,7 @@ export class CacheWriteWidget implements Widget { return formatRawOrLabeledValue(item, 'Cache Write: ', '3k (16.0%)'); } - const hideWhenEmpty = isCacheHideWhenEmptyEnabled(item); + const hideWhenEmpty = isHidden(item, CACHE_EMPTY_HIDEABLE_STATE.key); const tokens = getCacheTokens(context, isCacheSessionScope(item)); if (!tokens) { return hideWhenEmpty ? null : formatRawOrLabeledValue(item, 'Cache Write: ', 'n/a'); diff --git a/src/widgets/CompactionCounter.ts b/src/widgets/CompactionCounter.ts index 5b6467be..93f9ac3f 100644 --- a/src/widgets/CompactionCounter.ts +++ b/src/widgets/CompactionCounter.ts @@ -5,6 +5,7 @@ import type { import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetEditorProps, @@ -13,6 +14,7 @@ import type { import { ZERO_COMPACTION_STATS } from '../utils/compaction'; import { formatTokens } from '../utils/format-tokens'; +import { isHidden } from './shared/hideable'; import { isMetadataFlagEnabled, toggleMetadataFlag @@ -31,15 +33,14 @@ type CompactionCounterFormat = typeof FORMATS[number]; const DEFAULT_FORMAT: CompactionCounterFormat = 'icon-space-number'; const CYCLE_FORMAT_ACTION = 'cycle-format'; -const TOGGLE_HIDE_ZERO_ACTION = 'toggle-hide-zero'; const TOGGLE_NERD_FONT_ACTION = 'toggle-nerd-font'; -const HIDE_ZERO_METADATA_KEY = 'hideZero'; const NERD_FONT_METADATA_KEY = 'nerdFont'; const TOGGLE_TRIGGERS_ACTION = 'toggle-triggers'; const SHOW_TRIGGERS_METADATA_KEY = 'showTriggers'; const TOGGLE_RECLAIMED_ACTION = 'toggle-reclaimed'; const SHOW_RECLAIMED_METADATA_KEY = 'showReclaimed'; const RECLAIMED_SLOT: SymbolSlot = { id: 'symbolReclaimed', label: 'Reclaimed', defaultSymbol: '↓' }; +const ZERO_HIDEABLE_STATE: HideableState = { key: 'zero', label: 'when count is zero' }; const SAMPLE_STATS: CompactionData = Object.freeze({ count: 2, byTrigger: Object.freeze({ auto: 1, manual: 1, unknown: 0 }), @@ -88,20 +89,6 @@ function isNerdFontEnabled(item: WidgetItem): boolean { return item.metadata?.[NERD_FONT_METADATA_KEY] === 'true' && getFormat(item) === DEFAULT_FORMAT; } -function isHideZeroEnabled(item: WidgetItem): boolean { - return item.metadata?.[HIDE_ZERO_METADATA_KEY] === 'true'; -} - -function toggleHideZero(item: WidgetItem): WidgetItem { - return { - ...item, - metadata: { - ...(item.metadata ?? {}), - [HIDE_ZERO_METADATA_KEY]: (!isHideZeroEnabled(item)).toString() - } - }; -} - function formatReclaimedSuffix(tokensReclaimed: number, item: WidgetItem): string { if (tokensReclaimed <= 0) { return ''; @@ -187,9 +174,6 @@ export class CompactionCounterWidget implements Widget { if (isMetadataFlagEnabled(item, SHOW_RECLAIMED_METADATA_KEY)) { modifiers.push('reclaimed'); } - if (isHideZeroEnabled(item)) { - modifiers.push('hide zero'); - } return { displayText: 'Compaction Counter', @@ -197,6 +181,10 @@ export class CompactionCounterWidget implements Widget { }; } + getHideableStates(): HideableState[] { + return [ZERO_HIDEABLE_STATE]; + } + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { if (action === CYCLE_FORMAT_ACTION) { const currentFormat = getFormat(item); @@ -205,10 +193,6 @@ export class CompactionCounterWidget implements Widget { return setFormat(item, nextFormat); } - if (action === TOGGLE_HIDE_ZERO_ACTION) { - return toggleHideZero(item); - } - if (action === TOGGLE_NERD_FONT_ACTION) { return toggleNerdFont(item); } @@ -233,7 +217,7 @@ export class CompactionCounterWidget implements Widget { } const data = context.compactionData ?? ZERO_COMPACTION_STATS; - if (data.count === 0 && isHideZeroEnabled(item)) + if (data.count === 0 && isHidden(item, ZERO_HIDEABLE_STATE.key)) return null; return formatStats(data, item, icon); @@ -250,7 +234,6 @@ export class CompactionCounterWidget implements Widget { keybinds.push({ key: 's', label: '(s)plit by trigger', action: TOGGLE_TRIGGERS_ACTION }); keybinds.push({ key: 't', label: '(t)okens reclaimed', action: TOGGLE_RECLAIMED_ACTION }); - keybinds.push({ key: 'h', label: '(h)ide when zero', action: TOGGLE_HIDE_ZERO_ACTION }); keybinds.push(getSymbolKeybind()); return keybinds; diff --git a/src/widgets/CustomSymbol.tsx b/src/widgets/CustomSymbol.tsx index 20b50051..ff388bf5 100644 --- a/src/widgets/CustomSymbol.tsx +++ b/src/widgets/CustomSymbol.tsx @@ -9,6 +9,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetEditorProps, @@ -16,6 +17,8 @@ import type { } from '../types/Widget'; import { shouldInsertInput } from '../utils/input-guards'; +import { MERGE_TARGET_HIDDEN_HIDEABLE_STATE } from './shared/hideable'; + export class CustomSymbolWidget implements Widget { getDefaultColor(): string { return 'white'; } getDescription(): string { return 'Displays a custom symbol or emoji (single character)'; } @@ -39,6 +42,12 @@ export class CustomSymbolWidget implements Widget { }]; } + // The actual hiding happens in the renderer, which resolves the merge + // target's rendered output (see applyMergeTargetHiding) + getHideableStates(): HideableState[] { + return [MERGE_TARGET_HIDDEN_HIDEABLE_STATE]; + } + renderEditor(props: WidgetEditorProps): React.ReactElement { return ; } diff --git a/src/widgets/CustomText.tsx b/src/widgets/CustomText.tsx index 2c0cf30f..03ba0172 100644 --- a/src/widgets/CustomText.tsx +++ b/src/widgets/CustomText.tsx @@ -9,6 +9,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetEditorProps, @@ -16,6 +17,8 @@ import type { } from '../types/Widget'; import { shouldInsertInput } from '../utils/input-guards'; +import { MERGE_TARGET_HIDDEN_HIDEABLE_STATE } from './shared/hideable'; + export class CustomTextWidget implements Widget { getDefaultColor(): string { return 'white'; } getDescription(): string { return 'Displays user-defined custom text'; } @@ -39,6 +42,12 @@ export class CustomTextWidget implements Widget { }]; } + // The actual hiding happens in the renderer, which resolves the merge + // target's rendered output (see applyMergeTargetHiding) + getHideableStates(): HideableState[] { + return [MERGE_TARGET_HIDDEN_HIDEABLE_STATE]; + } + renderEditor(props: WidgetEditorProps): React.ReactElement { return ; } diff --git a/src/widgets/ExtraUsageRemaining.ts b/src/widgets/ExtraUsageRemaining.ts index 6101de12..ff71f048 100644 --- a/src/widgets/ExtraUsageRemaining.ts +++ b/src/widgets/ExtraUsageRemaining.ts @@ -1,7 +1,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { - CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -9,13 +9,10 @@ import type { import { getUsageErrorMessage } from '../utils/usage'; import { formatUsageCurrency } from './shared/currency'; -import { - appendHideDisabledModifier, - getHideExtraUsageDisabledKeybind, - handleToggleExtraUsageDisabledAction, - isHideExtraUsageDisabledEnabled -} from './shared/extra-usage-disabled'; +import { EXTRA_USAGE_DISABLED_HIDEABLE_STATE } from './shared/extra-usage-disabled'; +import { isHidden } from './shared/hideable'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; +import { USAGE_NO_DATA_HIDEABLE_STATE } from './shared/usage-display'; export class ExtraUsageRemainingWidget implements Widget { getDefaultColor(): string { return 'green'; } @@ -24,14 +21,11 @@ export class ExtraUsageRemainingWidget implements Widget { getCategory(): string { return 'Usage'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - return { - displayText: this.getDisplayName(), - modifierText: appendHideDisabledModifier(undefined, item) - }; + return { displayText: this.getDisplayName() }; } - handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - return handleToggleExtraUsageDisabledAction(action, item); + getHideableStates(): HideableState[] { + return [EXTRA_USAGE_DISABLED_HIDEABLE_STATE, USAGE_NO_DATA_HIDEABLE_STATE]; } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { @@ -41,13 +35,16 @@ export class ExtraUsageRemainingWidget implements Widget { const data = context.usageData ?? {}; if (data.extraUsageEnabled === false) { - return isHideExtraUsageDisabledEnabled(item) + return isHidden(item, EXTRA_USAGE_DISABLED_HIDEABLE_STATE.key) ? null : formatRawOrLabeledValue(item, 'Overage Left: ', 'n/a'); } if (data.extraUsageEnabled !== true || data.extraUsageLimit === undefined || data.extraUsageUsed === undefined) { - if (data.error) - return getUsageErrorMessage(data.error); + if (data.error) { + return isHidden(item, USAGE_NO_DATA_HIDEABLE_STATE.key) + ? null + : getUsageErrorMessage(data.error); + } return null; } @@ -60,10 +57,6 @@ export class ExtraUsageRemainingWidget implements Widget { return formatRawOrLabeledValue(item, 'Overage Left: ', formatted); } - getCustomKeybinds(): CustomKeybind[] { - return [getHideExtraUsageDisabledKeybind()]; - } - supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } } diff --git a/src/widgets/ExtraUsageUsed.ts b/src/widgets/ExtraUsageUsed.ts index 07e05af0..1f8a48ba 100644 --- a/src/widgets/ExtraUsageUsed.ts +++ b/src/widgets/ExtraUsageUsed.ts @@ -1,7 +1,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { - CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -9,13 +9,10 @@ import type { import { getUsageErrorMessage } from '../utils/usage'; import { formatUsageCurrency } from './shared/currency'; -import { - appendHideDisabledModifier, - getHideExtraUsageDisabledKeybind, - handleToggleExtraUsageDisabledAction, - isHideExtraUsageDisabledEnabled -} from './shared/extra-usage-disabled'; +import { EXTRA_USAGE_DISABLED_HIDEABLE_STATE } from './shared/extra-usage-disabled'; +import { isHidden } from './shared/hideable'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; +import { USAGE_NO_DATA_HIDEABLE_STATE } from './shared/usage-display'; export class ExtraUsageUsedWidget implements Widget { getDefaultColor(): string { return 'green'; } @@ -24,14 +21,11 @@ export class ExtraUsageUsedWidget implements Widget { getCategory(): string { return 'Usage'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - return { - displayText: this.getDisplayName(), - modifierText: appendHideDisabledModifier(undefined, item) - }; + return { displayText: this.getDisplayName() }; } - handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - return handleToggleExtraUsageDisabledAction(action, item); + getHideableStates(): HideableState[] { + return [EXTRA_USAGE_DISABLED_HIDEABLE_STATE, USAGE_NO_DATA_HIDEABLE_STATE]; } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { @@ -41,13 +35,16 @@ export class ExtraUsageUsedWidget implements Widget { const data = context.usageData ?? {}; if (data.extraUsageEnabled === false) { - return isHideExtraUsageDisabledEnabled(item) + return isHidden(item, EXTRA_USAGE_DISABLED_HIDEABLE_STATE.key) ? null : formatRawOrLabeledValue(item, 'Overage Used: ', 'n/a'); } if (data.extraUsageEnabled !== true || data.extraUsageUsed === undefined) { - if (data.error) - return getUsageErrorMessage(data.error); + if (data.error) { + return isHidden(item, USAGE_NO_DATA_HIDEABLE_STATE.key) + ? null + : getUsageErrorMessage(data.error); + } return null; } @@ -58,10 +55,6 @@ export class ExtraUsageUsedWidget implements Widget { return formatRawOrLabeledValue(item, 'Overage Used: ', formatted); } - getCustomKeybinds(): CustomKeybind[] { - return [getHideExtraUsageDisabledKeybind()]; - } - supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } } diff --git a/src/widgets/ExtraUsageUtilization.ts b/src/widgets/ExtraUsageUtilization.ts index 71dc3780..a2d971bb 100644 --- a/src/widgets/ExtraUsageUtilization.ts +++ b/src/widgets/ExtraUsageUtilization.ts @@ -2,21 +2,19 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem } from '../types/Widget'; import { getUsageErrorMessage } from '../utils/usage'; -import { - appendHideDisabledModifier, - getHideExtraUsageDisabledKeybind, - handleToggleExtraUsageDisabledAction, - isHideExtraUsageDisabledEnabled -} from './shared/extra-usage-disabled'; +import { EXTRA_USAGE_DISABLED_HIDEABLE_STATE } from './shared/extra-usage-disabled'; +import { isHidden } from './shared/hideable'; import { makeTimerProgressBar } from './shared/progress-bar'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; import { + USAGE_NO_DATA_HIDEABLE_STATE, cycleUsageDisplayMode, getUsageDisplayMode, getUsageDisplayModifierText, @@ -38,16 +36,15 @@ export class ExtraUsageUtilizationWidget implements Widget { getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName(), - modifierText: appendHideDisabledModifier(getUsageDisplayModifierText(item), item) + modifierText: getUsageDisplayModifierText(item) }; } - handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - const hideDisabledItem = handleToggleExtraUsageDisabledAction(action, item); - if (hideDisabledItem) { - return hideDisabledItem; - } + getHideableStates(): HideableState[] { + return [EXTRA_USAGE_DISABLED_HIDEABLE_STATE, USAGE_NO_DATA_HIDEABLE_STATE]; + } + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { if (action === 'toggle-progress') { return cycleUsageDisplayMode(item, [], true); } @@ -84,13 +81,16 @@ export class ExtraUsageUtilizationWidget implements Widget { const data = context.usageData ?? {}; if (data.extraUsageEnabled === false) { - return isHideExtraUsageDisabledEnabled(item) + return isHidden(item, EXTRA_USAGE_DISABLED_HIDEABLE_STATE.key) ? null : formatRawOrLabeledValue(item, 'Overage: ', 'n/a'); } if (data.extraUsageEnabled !== true || data.extraUsageUtilization === undefined) { - if (data.error) - return getUsageErrorMessage(data.error); + if (data.error) { + return isHidden(item, USAGE_NO_DATA_HIDEABLE_STATE.key) + ? null + : getUsageErrorMessage(data.error); + } return null; } @@ -114,7 +114,7 @@ export class ExtraUsageUtilizationWidget implements Widget { } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { - return [...getUsagePercentCustomKeybinds(item), getHideExtraUsageDisabledKeybind()]; + return getUsagePercentCustomKeybinds(item); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/GitAheadBehind.ts b/src/widgets/GitAheadBehind.ts index b573acf0..b0dfb788 100644 --- a/src/widgets/GitAheadBehind.ts +++ b/src/widgets/GitAheadBehind.ts @@ -2,6 +2,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetEditorProps, @@ -12,13 +13,11 @@ import { isInsideGitWorkTree } from '../utils/git'; -import { makeModifierText } from './shared/editor-display'; import { - getHideNoGitKeybinds, - getHideNoGitModifierText, - handleToggleNoGitAction, - isHideNoGitEnabled -} from './shared/git-no-git'; + NO_GIT_HIDEABLE_STATE, + NO_UPSTREAM_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; import { getSlotSymbol, getSymbolKeybind, @@ -29,6 +28,8 @@ import { const AHEAD_SLOT: SymbolSlot = { id: 'symbolAhead', label: 'Ahead', defaultSymbol: '↑' }; const BEHIND_SLOT: SymbolSlot = { id: 'symbolBehind', label: 'Behind', defaultSymbol: '↓' }; +const ZERO_HIDEABLE_STATE: HideableState = { key: 'zero', label: 'when not diverged (↑0↓0)', defaultEnabled: true }; + export class GitAheadBehindWidget implements Widget { getDefaultColor(): string { return 'cyan'; } getDescription(): string { return 'Shows commits ahead/behind upstream (↑2↓3)'; } @@ -36,23 +37,14 @@ export class GitAheadBehindWidget implements Widget { getCategory(): string { return 'Git'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - const modifiers: string[] = []; - const noGitText = getHideNoGitModifierText(item); - if (noGitText) - modifiers.push('hide \'no git\''); - - return { - displayText: this.getDisplayName(), - modifierText: makeModifierText(modifiers) - }; + return { displayText: this.getDisplayName() }; } - handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - return handleToggleNoGitAction(action, item); + getHideableStates(): HideableState[] { + return [NO_GIT_HIDEABLE_STATE, NO_UPSTREAM_HIDEABLE_STATE, ZERO_HIDEABLE_STATE]; } 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); @@ -63,17 +55,20 @@ export class GitAheadBehindWidget implements Widget { } if (!isInsideGitWorkTree(context)) { - return hideNoGit ? null : '(no git)'; + return isHidden(item, NO_GIT_HIDEABLE_STATE.key) ? null : '(no git)'; } const result = getGitAheadBehind(context); if (!result) { - return hideNoGit ? null : '(no upstream)'; + return isHidden(item, NO_UPSTREAM_HIDEABLE_STATE.key) ? null : '(no upstream)'; } - // Hide if both are zero if (result.ahead === 0 && result.behind === 0) { - return null; + if (isHidden(item, ZERO_HIDEABLE_STATE.key, ZERO_HIDEABLE_STATE.defaultEnabled)) { + return null; + } + + return item.rawValue ? '0,0' : `${aheadSymbol}0${behindSymbol}0`; } if (item.rawValue) { @@ -90,10 +85,7 @@ export class GitAheadBehindWidget implements Widget { } getCustomKeybinds(): CustomKeybind[] { - return [ - ...getHideNoGitKeybinds(), - getSymbolKeybind() - ]; + return [getSymbolKeybind()]; } renderEditor(props: WidgetEditorProps) { diff --git a/src/widgets/GitBranch.ts b/src/widgets/GitBranch.ts index dc1397b0..417a007a 100644 --- a/src/widgets/GitBranch.ts +++ b/src/widgets/GitBranch.ts @@ -2,6 +2,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetEditorProps, @@ -22,11 +23,9 @@ import { import { makeModifierText } from './shared/editor-display'; import { - getHideNoGitKeybinds, - getHideNoGitModifierText, - handleToggleNoGitAction, - isHideNoGitEnabled -} from './shared/git-no-git'; + NO_GIT_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; import { isMetadataFlagEnabled } from './shared/metadata'; import { formatSymbolPrefix, @@ -73,9 +72,6 @@ export class GitBranchWidget implements Widget { getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { const isLink = isLinkEnabled(item); const modifiers: string[] = []; - const noGitText = getHideNoGitModifierText(item); - if (noGitText) - modifiers.push('hide \'no git\''); if (isLink) modifiers.push('repo link'); return { @@ -84,16 +80,20 @@ export class GitBranchWidget implements Widget { }; } + getHideableStates(): HideableState[] { + return [NO_GIT_HIDEABLE_STATE]; + } + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { if (action === TOGGLE_LINK_ACTION) { return toggleLink(item); } - return handleToggleNoGitAction(action, item); + return null; } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { void settings; - const hideNoGit = isHideNoGitEnabled(item); + const hideNoGit = isHidden(item, NO_GIT_HIDEABLE_STATE.key); const isLink = isLinkEnabled(item); const prefix = formatSymbolPrefix(item, DEFAULT_SYMBOL); @@ -132,7 +132,6 @@ export class GitBranchWidget implements Widget { getCustomKeybinds(): CustomKeybind[] { return [ - ...getHideNoGitKeybinds(), { key: 'l', label: '(l)ink to repo', action: TOGGLE_LINK_ACTION }, getSymbolKeybind() ]; diff --git a/src/widgets/GitChanges.ts b/src/widgets/GitChanges.ts index cd8e1142..879b7cd0 100644 --- a/src/widgets/GitChanges.ts +++ b/src/widgets/GitChanges.ts @@ -1,7 +1,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { - CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -12,11 +12,11 @@ import { } from '../utils/git'; import { - getHideNoGitKeybinds, - getHideNoGitModifierText, - handleToggleNoGitAction, - isHideNoGitEnabled -} from './shared/git-no-git'; + NO_GIT_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; + +const ZERO_HIDEABLE_STATE: HideableState = { key: 'zero', label: 'when there are no changes' }; export class GitChangesWidget implements Widget { getDefaultColor(): string { return 'yellow'; } @@ -24,18 +24,15 @@ export class GitChangesWidget implements Widget { getDisplayName(): string { return 'Git Changes'; } getCategory(): string { return 'Git'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - return { - displayText: this.getDisplayName(), - modifierText: getHideNoGitModifierText(item) - }; + return { displayText: this.getDisplayName() }; } - handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - return handleToggleNoGitAction(action, item); + getHideableStates(): HideableState[] { + return [NO_GIT_HIDEABLE_STATE, ZERO_HIDEABLE_STATE]; } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideNoGit = isHideNoGitEnabled(item); + const hideNoGit = isHidden(item, NO_GIT_HIDEABLE_STATE.key); if (context.isPreview) { return '(+42,-10)'; @@ -46,11 +43,11 @@ export class GitChangesWidget implements Widget { } const changes = getGitChangeCounts(context); - return `(+${changes.insertions},-${changes.deletions})`; - } + if (changes.insertions === 0 && changes.deletions === 0 && isHidden(item, ZERO_HIDEABLE_STATE.key)) { + return null; + } - getCustomKeybinds(): CustomKeybind[] { - return getHideNoGitKeybinds(); + return `(+${changes.insertions},-${changes.deletions})`; } supportsRawValue(): boolean { return false; } diff --git a/src/widgets/GitCleanStatus.ts b/src/widgets/GitCleanStatus.ts index cb788061..ca8e6db5 100644 --- a/src/widgets/GitCleanStatus.ts +++ b/src/widgets/GitCleanStatus.ts @@ -1,7 +1,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { - CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -12,11 +12,9 @@ import { } from '../utils/git'; import { - getHideNoGitKeybinds, - getHideNoGitModifierText, - handleToggleNoGitAction, - isHideNoGitEnabled -} from './shared/git-no-git'; + NO_GIT_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; export class GitCleanStatusWidget implements Widget { getDefaultColor(): string { return 'green'; } @@ -24,18 +22,15 @@ export class GitCleanStatusWidget implements Widget { getDisplayName(): string { return 'Git Clean Status'; } getCategory(): string { return 'Git'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - return { - displayText: this.getDisplayName(), - modifierText: getHideNoGitModifierText(item) - }; + return { displayText: this.getDisplayName() }; } - handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - return handleToggleNoGitAction(action, item); + getHideableStates(): HideableState[] { + return [NO_GIT_HIDEABLE_STATE]; } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideNoGit = isHideNoGitEnabled(item); + const hideNoGit = isHidden(item, NO_GIT_HIDEABLE_STATE.key); if (context.isPreview) { return item.rawValue ? 'clean' : '✓'; @@ -58,10 +53,6 @@ export class GitCleanStatusWidget implements Widget { return !status.staged && !status.unstaged && !status.untracked && !status.conflicts; } - getCustomKeybinds(): CustomKeybind[] { - return getHideNoGitKeybinds(); - } - supportsRawValue(): boolean { return true; } supportsColors(_item: WidgetItem): boolean { return true; } } diff --git a/src/widgets/GitConflicts.ts b/src/widgets/GitConflicts.ts index 74d1121a..520c62ff 100644 --- a/src/widgets/GitConflicts.ts +++ b/src/widgets/GitConflicts.ts @@ -2,6 +2,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetEditorProps, @@ -12,19 +13,17 @@ import { isInsideGitWorkTree } from '../utils/git'; -import { makeModifierText } from './shared/editor-display'; import { - getHideNoGitKeybinds, - getHideNoGitModifierText, - handleToggleNoGitAction, - isHideNoGitEnabled -} from './shared/git-no-git'; + NO_GIT_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; import { formatSymbolPrefix, getSymbolKeybind, renderSymbolOverrideEditor } from './shared/symbol-override'; +const ZERO_HIDEABLE_STATE: HideableState = { key: 'zero', label: 'when there are no conflicts' }; const DEFAULT_SYMBOL = '⚠'; export class GitConflictsWidget implements Widget { @@ -34,23 +33,15 @@ export class GitConflictsWidget implements Widget { getCategory(): string { return 'Git'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - const modifiers: string[] = []; - const noGitText = getHideNoGitModifierText(item); - if (noGitText) - modifiers.push('hide \'no git\''); - - return { - displayText: this.getDisplayName(), - modifierText: makeModifierText(modifiers) - }; + return { displayText: this.getDisplayName() }; } - handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - return handleToggleNoGitAction(action, item); + getHideableStates(): HideableState[] { + return [NO_GIT_HIDEABLE_STATE, ZERO_HIDEABLE_STATE]; } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideNoGit = isHideNoGitEnabled(item); + const hideNoGit = isHidden(item, NO_GIT_HIDEABLE_STATE.key); const prefix = formatSymbolPrefix(item, DEFAULT_SYMBOL); if (context.isPreview) { @@ -65,6 +56,10 @@ export class GitConflictsWidget implements Widget { const count = getGitConflictCount(context); + if (count === 0 && isHidden(item, ZERO_HIDEABLE_STATE.key)) { + return null; + } + if (item.rawValue) { return count.toString(); } @@ -73,10 +68,7 @@ export class GitConflictsWidget implements Widget { } getCustomKeybinds(): CustomKeybind[] { - return [ - ...getHideNoGitKeybinds(), - getSymbolKeybind() - ]; + return [getSymbolKeybind()]; } renderEditor(props: WidgetEditorProps) { diff --git a/src/widgets/GitDeletions.ts b/src/widgets/GitDeletions.ts index 0963c6d7..3c90d0f8 100644 --- a/src/widgets/GitDeletions.ts +++ b/src/widgets/GitDeletions.ts @@ -1,7 +1,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { - CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -12,11 +12,11 @@ import { } from '../utils/git'; import { - getHideNoGitKeybinds, - getHideNoGitModifierText, - handleToggleNoGitAction, - isHideNoGitEnabled -} from './shared/git-no-git'; + NO_GIT_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; + +const ZERO_HIDEABLE_STATE: HideableState = { key: 'zero', label: 'when the deletion count is zero' }; export class GitDeletionsWidget implements Widget { getDefaultColor(): string { return 'red'; } @@ -24,18 +24,15 @@ export class GitDeletionsWidget implements Widget { getDisplayName(): string { return 'Git Deletions'; } getCategory(): string { return 'Git'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - return { - displayText: this.getDisplayName(), - modifierText: getHideNoGitModifierText(item) - }; + return { displayText: this.getDisplayName() }; } - handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - return handleToggleNoGitAction(action, item); + getHideableStates(): HideableState[] { + return [NO_GIT_HIDEABLE_STATE, ZERO_HIDEABLE_STATE]; } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideNoGit = isHideNoGitEnabled(item); + const hideNoGit = isHidden(item, NO_GIT_HIDEABLE_STATE.key); if (context.isPreview) { return '-10'; @@ -46,11 +43,11 @@ export class GitDeletionsWidget implements Widget { } const changes = getGitChangeCounts(context); - return `-${changes.deletions}`; - } + if (changes.deletions === 0 && isHidden(item, ZERO_HIDEABLE_STATE.key)) { + return null; + } - getCustomKeybinds(): CustomKeybind[] { - return getHideNoGitKeybinds(); + return `-${changes.deletions}`; } supportsRawValue(): boolean { return false; } diff --git a/src/widgets/GitInsertions.ts b/src/widgets/GitInsertions.ts index 2eb3740a..216f425b 100644 --- a/src/widgets/GitInsertions.ts +++ b/src/widgets/GitInsertions.ts @@ -1,7 +1,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { - CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -12,11 +12,11 @@ import { } from '../utils/git'; import { - getHideNoGitKeybinds, - getHideNoGitModifierText, - handleToggleNoGitAction, - isHideNoGitEnabled -} from './shared/git-no-git'; + NO_GIT_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; + +const ZERO_HIDEABLE_STATE: HideableState = { key: 'zero', label: 'when the insertion count is zero' }; export class GitInsertionsWidget implements Widget { getDefaultColor(): string { return 'green'; } @@ -24,18 +24,15 @@ export class GitInsertionsWidget implements Widget { getDisplayName(): string { return 'Git Insertions'; } getCategory(): string { return 'Git'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - return { - displayText: this.getDisplayName(), - modifierText: getHideNoGitModifierText(item) - }; + return { displayText: this.getDisplayName() }; } - handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - return handleToggleNoGitAction(action, item); + getHideableStates(): HideableState[] { + return [NO_GIT_HIDEABLE_STATE, ZERO_HIDEABLE_STATE]; } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideNoGit = isHideNoGitEnabled(item); + const hideNoGit = isHidden(item, NO_GIT_HIDEABLE_STATE.key); if (context.isPreview) { return '+42'; @@ -46,11 +43,11 @@ export class GitInsertionsWidget implements Widget { } const changes = getGitChangeCounts(context); - return `+${changes.insertions}`; - } + if (changes.insertions === 0 && isHidden(item, ZERO_HIDEABLE_STATE.key)) { + return null; + } - getCustomKeybinds(): CustomKeybind[] { - return getHideNoGitKeybinds(); + return `+${changes.insertions}`; } supportsRawValue(): boolean { return false; } diff --git a/src/widgets/GitIsFork.ts b/src/widgets/GitIsFork.ts index 1a66483a..b978c809 100644 --- a/src/widgets/GitIsFork.ts +++ b/src/widgets/GitIsFork.ts @@ -1,21 +1,16 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { - CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem } from '../types/Widget'; import { getForkStatus } from '../utils/git-remote'; -import { makeModifierText } from './shared/editor-display'; -import { - isMetadataFlagEnabled, - toggleMetadataFlag -} from './shared/metadata'; +import { isHidden } from './shared/hideable'; -const HIDE_WHEN_NOT_FORK_KEY = 'hideWhenNotFork'; -const TOGGLE_HIDE_ACTION = 'toggle-hide'; +const NOT_FORK_HIDEABLE_STATE: HideableState = { key: 'not-fork', label: 'when repo is not a fork' }; export class GitIsForkWidget implements Widget { getDefaultColor(): string { return 'yellow'; } @@ -24,29 +19,14 @@ export class GitIsForkWidget implements Widget { getCategory(): string { return 'Git'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - const modifiers: string[] = []; - - if (isMetadataFlagEnabled(item, HIDE_WHEN_NOT_FORK_KEY)) { - modifiers.push('hide when not fork'); - } - - return { - displayText: this.getDisplayName(), - modifierText: makeModifierText(modifiers) - }; + return { displayText: this.getDisplayName() }; } - handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - if (action === TOGGLE_HIDE_ACTION) { - return toggleMetadataFlag(item, HIDE_WHEN_NOT_FORK_KEY); - } - - return null; + getHideableStates(): HideableState[] { + return [NOT_FORK_HIDEABLE_STATE]; } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideWhenNotFork = isMetadataFlagEnabled(item, HIDE_WHEN_NOT_FORK_KEY); - if (context.isPreview) { return item.rawValue ? 'true' : 'isFork: true'; } @@ -58,19 +38,13 @@ export class GitIsForkWidget implements Widget { } // Not a fork - if (hideWhenNotFork) { + if (isHidden(item, NOT_FORK_HIDEABLE_STATE.key)) { return null; } return item.rawValue ? 'false' : 'isFork: false'; } - getCustomKeybinds(): CustomKeybind[] { - return [ - { key: 'h', label: '(h)ide when not fork', action: TOGGLE_HIDE_ACTION } - ]; - } - supportsRawValue(): boolean { return true; } supportsColors(_item: WidgetItem): boolean { return true; } } diff --git a/src/widgets/GitOriginOwner.ts b/src/widgets/GitOriginOwner.ts index 1b6671e6..fc2814fa 100644 --- a/src/widgets/GitOriginOwner.ts +++ b/src/widgets/GitOriginOwner.ts @@ -2,6 +2,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -16,9 +17,12 @@ import { getRemoteWidgetKeybinds, getRemoteWidgetModifierText, handleRemoteWidgetAction, - isHideNoRemoteEnabled, isLinkToRepoEnabled } from './shared/git-remote'; +import { + NO_REMOTE_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; export class GitOriginOwnerWidget implements Widget { getDefaultColor(): string { return 'cyan'; } @@ -33,12 +37,16 @@ export class GitOriginOwnerWidget implements Widget { }; } + getHideableStates(): HideableState[] { + return [NO_REMOTE_HIDEABLE_STATE]; + } + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { return handleRemoteWidgetAction(action, item); } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideWhenEmpty = isHideNoRemoteEnabled(item); + const hideWhenEmpty = isHidden(item, NO_REMOTE_HIDEABLE_STATE.key); const linkEnabled = isLinkToRepoEnabled(item); if (context.isPreview) { diff --git a/src/widgets/GitOriginOwnerRepo.ts b/src/widgets/GitOriginOwnerRepo.ts index 2fec15d2..d3a0b20b 100644 --- a/src/widgets/GitOriginOwnerRepo.ts +++ b/src/widgets/GitOriginOwnerRepo.ts @@ -2,6 +2,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -17,9 +18,12 @@ import { makeModifierText } from './shared/editor-display'; import { getRemoteWidgetKeybinds, handleRemoteWidgetAction, - isHideNoRemoteEnabled, isLinkToRepoEnabled } from './shared/git-remote'; +import { + NO_REMOTE_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; import { isMetadataFlagEnabled, toggleMetadataFlag @@ -37,9 +41,6 @@ export class GitOriginOwnerRepoWidget implements Widget { getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { const modifiers: string[] = []; - if (isHideNoRemoteEnabled(item)) { - modifiers.push('hide when empty'); - } if (isLinkToRepoEnabled(item)) { modifiers.push('link'); } @@ -53,6 +54,10 @@ export class GitOriginOwnerRepoWidget implements Widget { }; } + getHideableStates(): HideableState[] { + return [NO_REMOTE_HIDEABLE_STATE]; + } + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { if (action === TOGGLE_OWNER_ONLY_ACTION) { return toggleMetadataFlag(item, OWNER_ONLY_WHEN_FORK_KEY); @@ -62,7 +67,7 @@ export class GitOriginOwnerRepoWidget implements Widget { } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideWhenEmpty = isHideNoRemoteEnabled(item); + const hideWhenEmpty = isHidden(item, NO_REMOTE_HIDEABLE_STATE.key); const linkEnabled = isLinkToRepoEnabled(item); const ownerOnlyWhenFork = isMetadataFlagEnabled(item, OWNER_ONLY_WHEN_FORK_KEY); diff --git a/src/widgets/GitOriginRepo.ts b/src/widgets/GitOriginRepo.ts index 67eb96cf..ebae7c64 100644 --- a/src/widgets/GitOriginRepo.ts +++ b/src/widgets/GitOriginRepo.ts @@ -2,6 +2,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -16,9 +17,12 @@ import { getRemoteWidgetKeybinds, getRemoteWidgetModifierText, handleRemoteWidgetAction, - isHideNoRemoteEnabled, isLinkToRepoEnabled } from './shared/git-remote'; +import { + NO_REMOTE_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; export class GitOriginRepoWidget implements Widget { getDefaultColor(): string { return 'cyan'; } @@ -33,12 +37,16 @@ export class GitOriginRepoWidget implements Widget { }; } + getHideableStates(): HideableState[] { + return [NO_REMOTE_HIDEABLE_STATE]; + } + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { return handleRemoteWidgetAction(action, item); } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideWhenEmpty = isHideNoRemoteEnabled(item); + const hideWhenEmpty = isHidden(item, NO_REMOTE_HIDEABLE_STATE.key); const linkEnabled = isLinkToRepoEnabled(item); if (context.isPreview) { diff --git a/src/widgets/GitPr.ts b/src/widgets/GitPr.ts index 66caabee..069b9364 100644 --- a/src/widgets/GitPr.ts +++ b/src/widgets/GitPr.ts @@ -1,7 +1,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { - CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -19,22 +19,14 @@ import { } from '../utils/git-review-cache'; import { renderOsc8Link } from '../utils/hyperlink'; -import { makeModifierText } from './shared/editor-display'; import { - getHideNoGitKeybinds, - getHideNoGitModifierText, - handleToggleNoGitAction, - isHideNoGitEnabled -} from './shared/git-no-git'; -import { - isMetadataFlagEnabled, - toggleMetadataFlag -} from './shared/metadata'; + NO_GIT_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; -const HIDE_STATUS_KEY = 'hideStatus'; -const HIDE_TITLE_KEY = 'hideTitle'; -const TOGGLE_STATUS_ACTION = 'toggle-status'; -const TOGGLE_TITLE_ACTION = 'toggle-title'; +const NO_DATA_HIDEABLE_STATE: HideableState = { key: 'no-data', label: 'when there is no PR/MR' }; +const STATUS_HIDEABLE_STATE: HideableState = { key: 'status', label: 'status segment' }; +const TITLE_HIDEABLE_STATE: HideableState = { key: 'title', label: 'title segment' }; export interface GitPrWidgetDeps { fetchGitReviewData: typeof fetchGitReviewData; @@ -114,61 +106,35 @@ export class GitPrWidget implements Widget { getCategory(): string { return 'Git'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - const modifiers: string[] = []; - const noGitText = getHideNoGitModifierText(item); - if (noGitText) - modifiers.push('hide \'no git\''); - if (isMetadataFlagEnabled(item, HIDE_STATUS_KEY)) - modifiers.push('no status'); - if (isMetadataFlagEnabled(item, HIDE_TITLE_KEY)) - modifiers.push('no title'); - return { - displayText: this.getDisplayName(), - modifierText: makeModifierText(modifiers) - }; + return { displayText: this.getDisplayName() }; } - handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - if (action === TOGGLE_STATUS_ACTION) { - return toggleMetadataFlag(item, HIDE_STATUS_KEY); - } - if (action === TOGGLE_TITLE_ACTION) { - return toggleMetadataFlag(item, HIDE_TITLE_KEY); - } - return handleToggleNoGitAction(action, item); + getHideableStates(): HideableState[] { + return [NO_GIT_HIDEABLE_STATE, NO_DATA_HIDEABLE_STATE, STATUS_HIDEABLE_STATE, TITLE_HIDEABLE_STATE]; } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { void settings; - const hideNoGit = isHideNoGitEnabled(item); - const showStatus = !isMetadataFlagEnabled(item, HIDE_STATUS_KEY); - const showTitle = !isMetadataFlagEnabled(item, HIDE_TITLE_KEY); + const showStatus = !isHidden(item, STATUS_HIDEABLE_STATE.key); + const showTitle = !isHidden(item, TITLE_HIDEABLE_STATE.key); if (context.isPreview) { return buildDisplay(item, PREVIEW_PR, showStatus, showTitle, resolvePrNoun(PREVIEW_PR, context, this.deps)); } if (!this.deps.isInsideGitWorkTree(context)) { - return hideNoGit ? null : `(no ${resolvePrNoun(null, context, this.deps)})`; + return isHidden(item, NO_GIT_HIDEABLE_STATE.key) ? null : `(no ${resolvePrNoun(null, context, this.deps)})`; } const cwd = this.deps.resolveGitCwd(context) ?? this.deps.getProcessCwd(); const prData = this.deps.fetchGitReviewData(cwd); if (!prData) { - return hideNoGit ? null : `(no ${resolvePrNoun(null, context, this.deps)})`; + return isHidden(item, NO_DATA_HIDEABLE_STATE.key) ? null : `(no ${resolvePrNoun(null, context, this.deps)})`; } return buildDisplay(item, prData, showStatus, showTitle, resolvePrNoun(prData, context, this.deps)); } - getCustomKeybinds(): CustomKeybind[] { - return [ - ...getHideNoGitKeybinds(), - { key: 's', label: '(s)tatus', action: TOGGLE_STATUS_ACTION }, - { key: 't', label: '(t)itle', action: TOGGLE_TITLE_ACTION } - ]; - } - supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } } diff --git a/src/widgets/GitRootDir.ts b/src/widgets/GitRootDir.ts index 99b6b228..329e5d0a 100644 --- a/src/widgets/GitRootDir.ts +++ b/src/widgets/GitRootDir.ts @@ -2,6 +2,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -19,11 +20,9 @@ import { import { makeModifierText } from './shared/editor-display'; import { - getHideNoGitKeybinds, - getHideNoGitModifierText, - handleToggleNoGitAction, - isHideNoGitEnabled -} from './shared/git-no-git'; + NO_GIT_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; import { isMetadataFlagEnabled } from './shared/metadata'; const IDE_LINK_KEY = 'linkToIDE'; @@ -42,9 +41,6 @@ export class GitRootDirWidget implements Widget { getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { const ideLinkMode = this.getIdeLinkMode(item); const modifiers: string[] = []; - const noGitText = getHideNoGitModifierText(item); - if (noGitText) - modifiers.push('hide \'no git\''); if (ideLinkMode) modifiers.push(IDE_LINK_LABELS[ideLinkMode]); return { @@ -53,15 +49,19 @@ export class GitRootDirWidget implements Widget { }; } + getHideableStates(): HideableState[] { + return [NO_GIT_HIDEABLE_STATE]; + } + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { if (action === TOGGLE_LINK_ACTION) { return this.cycleIdeLinkMode(item); } - return handleToggleNoGitAction(action, item); + return null; } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideNoGit = isHideNoGitEnabled(item); + const hideNoGit = isHidden(item, NO_GIT_HIDEABLE_STATE.key); const ideLinkMode = this.getIdeLinkMode(item); if (context.isPreview) { @@ -101,7 +101,6 @@ export class GitRootDirWidget implements Widget { getCustomKeybinds(): CustomKeybind[] { return [ - ...getHideNoGitKeybinds(), { key: 'l', label: '(l)ink to IDE', action: TOGGLE_LINK_ACTION } ]; } diff --git a/src/widgets/GitSha.ts b/src/widgets/GitSha.ts index f6bb9c9d..2af27b90 100644 --- a/src/widgets/GitSha.ts +++ b/src/widgets/GitSha.ts @@ -1,7 +1,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { - CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -12,11 +12,9 @@ import { } from '../utils/git'; import { - getHideNoGitKeybinds, - getHideNoGitModifierText, - handleToggleNoGitAction, - isHideNoGitEnabled -} from './shared/git-no-git'; + NO_GIT_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; export class GitShaWidget implements Widget { getDefaultColor(): string { return 'gray'; } @@ -25,18 +23,15 @@ export class GitShaWidget implements Widget { getCategory(): string { return 'Git'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - return { - displayText: this.getDisplayName(), - modifierText: getHideNoGitModifierText(item) - }; + return { displayText: this.getDisplayName() }; } - handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - return handleToggleNoGitAction(action, item); + getHideableStates(): HideableState[] { + return [NO_GIT_HIDEABLE_STATE]; } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideNoGit = isHideNoGitEnabled(item); + const hideNoGit = isHidden(item, NO_GIT_HIDEABLE_STATE.key); if (context.isPreview) { return 'a1b2c3d'; @@ -50,10 +45,6 @@ export class GitShaWidget implements Widget { return sha ?? (hideNoGit ? null : '(no commit)'); } - getCustomKeybinds(): CustomKeybind[] { - return getHideNoGitKeybinds(); - } - supportsRawValue(): boolean { return false; } supportsColors(_item: WidgetItem): boolean { return true; } } diff --git a/src/widgets/GitStaged.ts b/src/widgets/GitStaged.ts index c9c91987..097b34c1 100644 --- a/src/widgets/GitStaged.ts +++ b/src/widgets/GitStaged.ts @@ -2,6 +2,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetEditorProps, @@ -12,13 +13,10 @@ import { isInsideGitWorkTree } from '../utils/git'; -import { makeModifierText } from './shared/editor-display'; import { - getHideNoGitKeybinds, - getHideNoGitModifierText, - handleToggleNoGitAction, - isHideNoGitEnabled -} from './shared/git-no-git'; + NO_GIT_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; import { getSymbol, getSymbolKeybind, @@ -34,23 +32,15 @@ export class GitStagedWidget implements Widget { getCategory(): string { return 'Git'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - const modifiers: string[] = []; - const noGitText = getHideNoGitModifierText(item); - if (noGitText) - modifiers.push('hide \'no git\''); - - return { - displayText: this.getDisplayName(), - modifierText: makeModifierText(modifiers) - }; + return { displayText: this.getDisplayName() }; } - handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - return handleToggleNoGitAction(action, item); + getHideableStates(): HideableState[] { + return [NO_GIT_HIDEABLE_STATE]; } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideNoGit = isHideNoGitEnabled(item); + const hideNoGit = isHidden(item, NO_GIT_HIDEABLE_STATE.key); if (context.isPreview) { return item.rawValue ? 'true' : getSymbol(item, DEFAULT_SYMBOL); @@ -70,10 +60,7 @@ export class GitStagedWidget implements Widget { } getCustomKeybinds(): CustomKeybind[] { - return [ - ...getHideNoGitKeybinds(), - getSymbolKeybind() - ]; + return [getSymbolKeybind()]; } renderEditor(props: WidgetEditorProps) { diff --git a/src/widgets/GitStagedFiles.ts b/src/widgets/GitStagedFiles.ts index 71e53ce2..f3ea0539 100644 --- a/src/widgets/GitStagedFiles.ts +++ b/src/widgets/GitStagedFiles.ts @@ -1,7 +1,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { - CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -12,11 +12,11 @@ import { } from '../utils/git'; import { - getHideNoGitKeybinds, - getHideNoGitModifierText, - handleToggleNoGitAction, - isHideNoGitEnabled -} from './shared/git-no-git'; + NO_GIT_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; + +const ZERO_HIDEABLE_STATE: HideableState = { key: 'zero', label: 'when the staged file count is zero' }; export class GitStagedFilesWidget implements Widget { getDefaultColor(): string { return 'green'; } @@ -24,18 +24,15 @@ export class GitStagedFilesWidget implements Widget { getDisplayName(): string { return 'Git Staged Files'; } getCategory(): string { return 'Git'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - return { - displayText: this.getDisplayName(), - modifierText: getHideNoGitModifierText(item) - }; + return { displayText: this.getDisplayName() }; } - handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - return handleToggleNoGitAction(action, item); + getHideableStates(): HideableState[] { + return [NO_GIT_HIDEABLE_STATE, ZERO_HIDEABLE_STATE]; } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideNoGit = isHideNoGitEnabled(item); + const hideNoGit = isHidden(item, NO_GIT_HIDEABLE_STATE.key); if (context.isPreview) { return item.rawValue ? '3' : 'S:3'; @@ -46,11 +43,11 @@ export class GitStagedFilesWidget implements Widget { } const counts = getGitFileStatusCounts(context); - return item.rawValue ? `${counts.staged}` : `S:${counts.staged}`; - } + if (counts.staged === 0 && isHidden(item, ZERO_HIDEABLE_STATE.key)) { + return null; + } - getCustomKeybinds(): CustomKeybind[] { - return getHideNoGitKeybinds(); + return item.rawValue ? `${counts.staged}` : `S:${counts.staged}`; } getNumericValue(context: RenderContext, _item: WidgetItem): number | null { diff --git a/src/widgets/GitStatus.ts b/src/widgets/GitStatus.ts index a6dae8ef..57443d67 100644 --- a/src/widgets/GitStatus.ts +++ b/src/widgets/GitStatus.ts @@ -2,6 +2,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetEditorProps, @@ -12,13 +13,10 @@ import { isInsideGitWorkTree } from '../utils/git'; -import { makeModifierText } from './shared/editor-display'; import { - getHideNoGitKeybinds, - getHideNoGitModifierText, - handleToggleNoGitAction, - isHideNoGitEnabled -} from './shared/git-no-git'; + NO_GIT_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; import { getSlotSymbol, getSymbolKeybind, @@ -38,23 +36,15 @@ export class GitStatusWidget implements Widget { getCategory(): string { return 'Git'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - const modifiers: string[] = []; - const noGitText = getHideNoGitModifierText(item); - if (noGitText) - modifiers.push('hide \'no git\''); - - return { - displayText: this.getDisplayName(), - modifierText: makeModifierText(modifiers) - }; + return { displayText: this.getDisplayName() }; } - handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - return handleToggleNoGitAction(action, item); + getHideableStates(): HideableState[] { + return [NO_GIT_HIDEABLE_STATE]; } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideNoGit = isHideNoGitEnabled(item); + const hideNoGit = isHidden(item, NO_GIT_HIDEABLE_STATE.key); if (context.isPreview) { return this.formatStatus(item, { staged: true, unstaged: true, untracked: false, conflicts: false }); @@ -89,10 +79,7 @@ export class GitStatusWidget implements Widget { } getCustomKeybinds(): CustomKeybind[] { - return [ - ...getHideNoGitKeybinds(), - getSymbolKeybind() - ]; + return [getSymbolKeybind()]; } renderEditor(props: WidgetEditorProps) { diff --git a/src/widgets/GitUnstaged.ts b/src/widgets/GitUnstaged.ts index f35d33df..476eadc3 100644 --- a/src/widgets/GitUnstaged.ts +++ b/src/widgets/GitUnstaged.ts @@ -2,6 +2,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetEditorProps, @@ -12,13 +13,10 @@ import { isInsideGitWorkTree } from '../utils/git'; -import { makeModifierText } from './shared/editor-display'; import { - getHideNoGitKeybinds, - getHideNoGitModifierText, - handleToggleNoGitAction, - isHideNoGitEnabled -} from './shared/git-no-git'; + NO_GIT_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; import { getSymbol, getSymbolKeybind, @@ -34,23 +32,15 @@ export class GitUnstagedWidget implements Widget { getCategory(): string { return 'Git'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - const modifiers: string[] = []; - const noGitText = getHideNoGitModifierText(item); - if (noGitText) - modifiers.push('hide \'no git\''); - - return { - displayText: this.getDisplayName(), - modifierText: makeModifierText(modifiers) - }; + return { displayText: this.getDisplayName() }; } - handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - return handleToggleNoGitAction(action, item); + getHideableStates(): HideableState[] { + return [NO_GIT_HIDEABLE_STATE]; } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideNoGit = isHideNoGitEnabled(item); + const hideNoGit = isHidden(item, NO_GIT_HIDEABLE_STATE.key); if (context.isPreview) { return item.rawValue ? 'true' : getSymbol(item, DEFAULT_SYMBOL); @@ -70,10 +60,7 @@ export class GitUnstagedWidget implements Widget { } getCustomKeybinds(): CustomKeybind[] { - return [ - ...getHideNoGitKeybinds(), - getSymbolKeybind() - ]; + return [getSymbolKeybind()]; } renderEditor(props: WidgetEditorProps) { diff --git a/src/widgets/GitUnstagedFiles.ts b/src/widgets/GitUnstagedFiles.ts index 95dbf0e0..b4296a9d 100644 --- a/src/widgets/GitUnstagedFiles.ts +++ b/src/widgets/GitUnstagedFiles.ts @@ -1,7 +1,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { - CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -12,11 +12,11 @@ import { } from '../utils/git'; import { - getHideNoGitKeybinds, - getHideNoGitModifierText, - handleToggleNoGitAction, - isHideNoGitEnabled -} from './shared/git-no-git'; + NO_GIT_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; + +const ZERO_HIDEABLE_STATE: HideableState = { key: 'zero', label: 'when the unstaged file count is zero' }; export class GitUnstagedFilesWidget implements Widget { getDefaultColor(): string { return 'yellow'; } @@ -24,18 +24,15 @@ export class GitUnstagedFilesWidget implements Widget { getDisplayName(): string { return 'Git Unstaged Files'; } getCategory(): string { return 'Git'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - return { - displayText: this.getDisplayName(), - modifierText: getHideNoGitModifierText(item) - }; + return { displayText: this.getDisplayName() }; } - handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - return handleToggleNoGitAction(action, item); + getHideableStates(): HideableState[] { + return [NO_GIT_HIDEABLE_STATE, ZERO_HIDEABLE_STATE]; } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideNoGit = isHideNoGitEnabled(item); + const hideNoGit = isHidden(item, NO_GIT_HIDEABLE_STATE.key); if (context.isPreview) { return item.rawValue ? '2' : 'M:2'; @@ -46,11 +43,11 @@ export class GitUnstagedFilesWidget implements Widget { } const counts = getGitFileStatusCounts(context); - return item.rawValue ? `${counts.unstaged}` : `M:${counts.unstaged}`; - } + if (counts.unstaged === 0 && isHidden(item, ZERO_HIDEABLE_STATE.key)) { + return null; + } - getCustomKeybinds(): CustomKeybind[] { - return getHideNoGitKeybinds(); + return item.rawValue ? `${counts.unstaged}` : `M:${counts.unstaged}`; } getNumericValue(context: RenderContext, _item: WidgetItem): number | null { diff --git a/src/widgets/GitUntracked.ts b/src/widgets/GitUntracked.ts index e92b83f8..cd720f70 100644 --- a/src/widgets/GitUntracked.ts +++ b/src/widgets/GitUntracked.ts @@ -2,6 +2,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetEditorProps, @@ -12,13 +13,10 @@ import { isInsideGitWorkTree } from '../utils/git'; -import { makeModifierText } from './shared/editor-display'; import { - getHideNoGitKeybinds, - getHideNoGitModifierText, - handleToggleNoGitAction, - isHideNoGitEnabled -} from './shared/git-no-git'; + NO_GIT_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; import { getSymbol, getSymbolKeybind, @@ -34,23 +32,15 @@ export class GitUntrackedWidget implements Widget { getCategory(): string { return 'Git'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - const modifiers: string[] = []; - const noGitText = getHideNoGitModifierText(item); - if (noGitText) - modifiers.push('hide \'no git\''); - - return { - displayText: this.getDisplayName(), - modifierText: makeModifierText(modifiers) - }; + return { displayText: this.getDisplayName() }; } - handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - return handleToggleNoGitAction(action, item); + getHideableStates(): HideableState[] { + return [NO_GIT_HIDEABLE_STATE]; } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideNoGit = isHideNoGitEnabled(item); + const hideNoGit = isHidden(item, NO_GIT_HIDEABLE_STATE.key); if (context.isPreview) { return item.rawValue ? 'true' : getSymbol(item, DEFAULT_SYMBOL); @@ -70,10 +60,7 @@ export class GitUntrackedWidget implements Widget { } getCustomKeybinds(): CustomKeybind[] { - return [ - ...getHideNoGitKeybinds(), - getSymbolKeybind() - ]; + return [getSymbolKeybind()]; } renderEditor(props: WidgetEditorProps) { diff --git a/src/widgets/GitUntrackedFiles.ts b/src/widgets/GitUntrackedFiles.ts index f94d45a2..ece236a1 100644 --- a/src/widgets/GitUntrackedFiles.ts +++ b/src/widgets/GitUntrackedFiles.ts @@ -1,7 +1,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { - CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -12,11 +12,11 @@ import { } from '../utils/git'; import { - getHideNoGitKeybinds, - getHideNoGitModifierText, - handleToggleNoGitAction, - isHideNoGitEnabled -} from './shared/git-no-git'; + NO_GIT_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; + +const ZERO_HIDEABLE_STATE: HideableState = { key: 'zero', label: 'when the untracked file count is zero' }; export class GitUntrackedFilesWidget implements Widget { getDefaultColor(): string { return 'red'; } @@ -24,18 +24,15 @@ export class GitUntrackedFilesWidget implements Widget { getDisplayName(): string { return 'Git Untracked Files'; } getCategory(): string { return 'Git'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - return { - displayText: this.getDisplayName(), - modifierText: getHideNoGitModifierText(item) - }; + return { displayText: this.getDisplayName() }; } - handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - return handleToggleNoGitAction(action, item); + getHideableStates(): HideableState[] { + return [NO_GIT_HIDEABLE_STATE, ZERO_HIDEABLE_STATE]; } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideNoGit = isHideNoGitEnabled(item); + const hideNoGit = isHidden(item, NO_GIT_HIDEABLE_STATE.key); if (context.isPreview) { return item.rawValue ? '1' : '?:1'; @@ -46,11 +43,11 @@ export class GitUntrackedFilesWidget implements Widget { } const counts = getGitFileStatusCounts(context); - return item.rawValue ? `${counts.untracked}` : `?:${counts.untracked}`; - } + if (counts.untracked === 0 && isHidden(item, ZERO_HIDEABLE_STATE.key)) { + return null; + } - getCustomKeybinds(): CustomKeybind[] { - return getHideNoGitKeybinds(); + return item.rawValue ? `${counts.untracked}` : `?:${counts.untracked}`; } getNumericValue(context: RenderContext, _item: WidgetItem): number | null { diff --git a/src/widgets/GitUpstreamOwner.ts b/src/widgets/GitUpstreamOwner.ts index 300043c4..03204c55 100644 --- a/src/widgets/GitUpstreamOwner.ts +++ b/src/widgets/GitUpstreamOwner.ts @@ -2,6 +2,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -16,9 +17,12 @@ import { getRemoteWidgetKeybinds, getRemoteWidgetModifierText, handleRemoteWidgetAction, - isHideNoRemoteEnabled, isLinkToRepoEnabled } from './shared/git-remote'; +import { + NO_UPSTREAM_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; export class GitUpstreamOwnerWidget implements Widget { getDefaultColor(): string { return 'magenta'; } @@ -33,12 +37,16 @@ export class GitUpstreamOwnerWidget implements Widget { }; } + getHideableStates(): HideableState[] { + return [NO_UPSTREAM_HIDEABLE_STATE]; + } + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { return handleRemoteWidgetAction(action, item); } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideWhenEmpty = isHideNoRemoteEnabled(item); + const hideWhenEmpty = isHidden(item, NO_UPSTREAM_HIDEABLE_STATE.key); const linkEnabled = isLinkToRepoEnabled(item); if (context.isPreview) { diff --git a/src/widgets/GitUpstreamOwnerRepo.ts b/src/widgets/GitUpstreamOwnerRepo.ts index 4febdd81..5ae869f5 100644 --- a/src/widgets/GitUpstreamOwnerRepo.ts +++ b/src/widgets/GitUpstreamOwnerRepo.ts @@ -2,6 +2,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -16,9 +17,12 @@ import { getRemoteWidgetKeybinds, getRemoteWidgetModifierText, handleRemoteWidgetAction, - isHideNoRemoteEnabled, isLinkToRepoEnabled } from './shared/git-remote'; +import { + NO_UPSTREAM_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; export class GitUpstreamOwnerRepoWidget implements Widget { getDefaultColor(): string { return 'magenta'; } @@ -33,12 +37,16 @@ export class GitUpstreamOwnerRepoWidget implements Widget { }; } + getHideableStates(): HideableState[] { + return [NO_UPSTREAM_HIDEABLE_STATE]; + } + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { return handleRemoteWidgetAction(action, item); } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideWhenEmpty = isHideNoRemoteEnabled(item); + const hideWhenEmpty = isHidden(item, NO_UPSTREAM_HIDEABLE_STATE.key); const linkEnabled = isLinkToRepoEnabled(item); if (context.isPreview) { diff --git a/src/widgets/GitUpstreamRepo.ts b/src/widgets/GitUpstreamRepo.ts index 923b43fa..b9ef997c 100644 --- a/src/widgets/GitUpstreamRepo.ts +++ b/src/widgets/GitUpstreamRepo.ts @@ -2,6 +2,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -16,9 +17,12 @@ import { getRemoteWidgetKeybinds, getRemoteWidgetModifierText, handleRemoteWidgetAction, - isHideNoRemoteEnabled, isLinkToRepoEnabled } from './shared/git-remote'; +import { + NO_UPSTREAM_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; export class GitUpstreamRepoWidget implements Widget { getDefaultColor(): string { return 'magenta'; } @@ -33,12 +37,16 @@ export class GitUpstreamRepoWidget implements Widget { }; } + getHideableStates(): HideableState[] { + return [NO_UPSTREAM_HIDEABLE_STATE]; + } + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { return handleRemoteWidgetAction(action, item); } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideWhenEmpty = isHideNoRemoteEnabled(item); + const hideWhenEmpty = isHidden(item, NO_UPSTREAM_HIDEABLE_STATE.key); const linkEnabled = isLinkToRepoEnabled(item); if (context.isPreview) { diff --git a/src/widgets/GitWorktree.ts b/src/widgets/GitWorktree.ts index 0ac8d205..9dd3b074 100644 --- a/src/widgets/GitWorktree.ts +++ b/src/widgets/GitWorktree.ts @@ -1,6 +1,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetEditorProps, @@ -12,11 +13,9 @@ import { } from '../utils/git'; import { - getHideNoGitKeybinds, - getHideNoGitModifierText, - handleToggleNoGitAction, - isHideNoGitEnabled -} from './shared/git-no-git'; + NO_GIT_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; import { formatSymbolPrefix, getSymbolKeybind, @@ -31,18 +30,15 @@ export class GitWorktreeWidget implements Widget { getDisplayName(): string { return 'Git Worktree'; } getCategory(): string { return 'Git'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - return { - displayText: this.getDisplayName(), - modifierText: getHideNoGitModifierText(item) - }; + return { displayText: this.getDisplayName() }; } - handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - return handleToggleNoGitAction(action, item); + getHideableStates(): HideableState[] { + return [NO_GIT_HIDEABLE_STATE]; } render(item: WidgetItem, context: RenderContext): string | null { - const hideNoGit = isHideNoGitEnabled(item); + const hideNoGit = isHidden(item, NO_GIT_HIDEABLE_STATE.key); const prefix = formatSymbolPrefix(item, DEFAULT_SYMBOL); if (context.isPreview) @@ -89,10 +85,7 @@ export class GitWorktreeWidget implements Widget { } getCustomKeybinds(): CustomKeybind[] { - return [ - ...getHideNoGitKeybinds(), - getSymbolKeybind() - ]; + return [getSymbolKeybind()]; } renderEditor(props: WidgetEditorProps) { diff --git a/src/widgets/InputSpeed.ts b/src/widgets/InputSpeed.ts index f0f37f6a..a71f46d4 100644 --- a/src/widgets/InputSpeed.ts +++ b/src/widgets/InputSpeed.ts @@ -2,6 +2,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetEditorProps, @@ -13,6 +14,7 @@ import { getSpeedWidgetDescription, getSpeedWidgetDisplayName, getSpeedWidgetEditorDisplay, + getSpeedWidgetHideableStates, renderSpeedWidgetEditor, renderSpeedWidgetValue } from './shared/speed-widget'; @@ -35,6 +37,10 @@ export class InputSpeedWidget implements Widget { return getSpeedWidgetCustomKeybinds(); } + getHideableStates(): HideableState[] { + return getSpeedWidgetHideableStates(); + } + renderEditor(props: WidgetEditorProps) { return renderSpeedWidgetEditor(props); } diff --git a/src/widgets/JjBookmarks.ts b/src/widgets/JjBookmarks.ts index b0e4ba8e..d9af351f 100644 --- a/src/widgets/JjBookmarks.ts +++ b/src/widgets/JjBookmarks.ts @@ -2,6 +2,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetEditorProps, @@ -12,6 +13,10 @@ import { runJjArgs } from '../utils/jj'; +import { + NO_JJ_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; import { formatSymbolPrefix, getSymbolKeybind, @@ -26,35 +31,15 @@ export class JjBookmarksWidget implements Widget { getDisplayName(): string { return 'JJ Bookmarks'; } getCategory(): string { return 'Jujutsu'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - const hideNoJj = item.metadata?.hideNoJj === 'true'; - const modifiers: string[] = []; - - if (hideNoJj) { - modifiers.push('hide \'no jj\''); - } - - return { - displayText: this.getDisplayName(), - modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined - }; + return { displayText: this.getDisplayName() }; } - handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - if (action === 'toggle-nojj') { - const currentState = item.metadata?.hideNoJj === 'true'; - return { - ...item, - metadata: { - ...item.metadata, - hideNoJj: (!currentState).toString() - } - }; - } - return null; + getHideableStates(): HideableState[] { + return [NO_JJ_HIDEABLE_STATE]; } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideNoJj = item.metadata?.hideNoJj === 'true'; + const hideNoJj = isHidden(item, NO_JJ_HIDEABLE_STATE.key); const prefix = formatSymbolPrefix(item, DEFAULT_SYMBOL); if (context.isPreview) { @@ -95,10 +80,7 @@ export class JjBookmarksWidget implements Widget { } getCustomKeybinds(): CustomKeybind[] { - return [ - { key: 'h', label: '(h)ide \'no jj\' message', action: 'toggle-nojj' }, - getSymbolKeybind() - ]; + return [getSymbolKeybind()]; } renderEditor(props: WidgetEditorProps) { diff --git a/src/widgets/JjChanges.ts b/src/widgets/JjChanges.ts index 1670da2b..7497a65d 100644 --- a/src/widgets/JjChanges.ts +++ b/src/widgets/JjChanges.ts @@ -1,7 +1,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { - CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -11,41 +11,26 @@ import { runJjArgs } from '../utils/jj'; +import { + NO_JJ_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; + export class JjChangesWidget implements Widget { getDefaultColor(): string { return 'yellow'; } getDescription(): string { return 'Shows jujutsu changes count (+insertions, -deletions)'; } getDisplayName(): string { return 'JJ Changes'; } getCategory(): string { return 'Jujutsu'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - const hideNoJj = item.metadata?.hideNoJj === 'true'; - const modifiers: string[] = []; - - if (hideNoJj) { - modifiers.push('hide \'no jj\''); - } - - return { - displayText: this.getDisplayName(), - modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined - }; + return { displayText: this.getDisplayName() }; } - handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - if (action === 'toggle-nojj') { - const currentState = item.metadata?.hideNoJj === 'true'; - return { - ...item, - metadata: { - ...item.metadata, - hideNoJj: (!currentState).toString() - } - }; - } - return null; + getHideableStates(): HideableState[] { + return [NO_JJ_HIDEABLE_STATE]; } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideNoJj = item.metadata?.hideNoJj === 'true'; + const hideNoJj = isHidden(item, NO_JJ_HIDEABLE_STATE.key); if (context.isPreview) { return '(+42,-10)'; @@ -83,12 +68,6 @@ export class JjChangesWidget implements Widget { return { insertions: totalInsertions, deletions: totalDeletions }; } - getCustomKeybinds(): CustomKeybind[] { - return [ - { key: 'h', label: '(h)ide \'no jj\' message', action: 'toggle-nojj' } - ]; - } - supportsRawValue(): boolean { return false; } supportsColors(): boolean { return true; } } diff --git a/src/widgets/JjDeletions.ts b/src/widgets/JjDeletions.ts index 7f58ff77..b6a626c9 100644 --- a/src/widgets/JjDeletions.ts +++ b/src/widgets/JjDeletions.ts @@ -1,7 +1,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { - CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -11,41 +11,26 @@ import { isInsideJjRepo } from '../utils/jj'; +import { + NO_JJ_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; + export class JjDeletionsWidget implements Widget { getDefaultColor(): string { return 'red'; } getDescription(): string { return 'Shows jujutsu deletions count'; } getDisplayName(): string { return 'JJ Deletions'; } getCategory(): string { return 'Jujutsu'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - const hideNoJj = item.metadata?.hideNoJj === 'true'; - const modifiers: string[] = []; - - if (hideNoJj) { - modifiers.push('hide \'no jj\''); - } - - return { - displayText: this.getDisplayName(), - modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined - }; + return { displayText: this.getDisplayName() }; } - handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - if (action === 'toggle-nojj') { - const currentState = item.metadata?.hideNoJj === 'true'; - return { - ...item, - metadata: { - ...item.metadata, - hideNoJj: (!currentState).toString() - } - }; - } - return null; + getHideableStates(): HideableState[] { + return [NO_JJ_HIDEABLE_STATE]; } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideNoJj = item.metadata?.hideNoJj === 'true'; + const hideNoJj = isHidden(item, NO_JJ_HIDEABLE_STATE.key); if (context.isPreview) { return '-10'; @@ -59,12 +44,6 @@ export class JjDeletionsWidget implements Widget { return `-${changes.deletions}`; } - getCustomKeybinds(): CustomKeybind[] { - return [ - { key: 'h', label: '(h)ide \'no jj\' message', action: 'toggle-nojj' } - ]; - } - supportsRawValue(): boolean { return false; } supportsColors(item: WidgetItem): boolean { return true; } } diff --git a/src/widgets/JjDescription.ts b/src/widgets/JjDescription.ts index f968aca3..64130e64 100644 --- a/src/widgets/JjDescription.ts +++ b/src/widgets/JjDescription.ts @@ -1,7 +1,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { - CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -11,41 +11,26 @@ import { runJjArgs } from '../utils/jj'; +import { + NO_JJ_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; + export class JjDescriptionWidget implements Widget { getDefaultColor(): string { return 'white'; } getDescription(): string { return 'Shows the current jujutsu change description'; } getDisplayName(): string { return 'JJ Description'; } getCategory(): string { return 'Jujutsu'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - const hideNoJj = item.metadata?.hideNoJj === 'true'; - const modifiers: string[] = []; - - if (hideNoJj) { - modifiers.push('hide \'no jj\''); - } - - return { - displayText: this.getDisplayName(), - modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined - }; + return { displayText: this.getDisplayName() }; } - handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - if (action === 'toggle-nojj') { - const currentState = item.metadata?.hideNoJj === 'true'; - return { - ...item, - metadata: { - ...item.metadata, - hideNoJj: (!currentState).toString() - } - }; - } - return null; + getHideableStates(): HideableState[] { + return [NO_JJ_HIDEABLE_STATE]; } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideNoJj = item.metadata?.hideNoJj === 'true'; + const hideNoJj = isHidden(item, NO_JJ_HIDEABLE_STATE.key); if (context.isPreview) { return '(no description)'; @@ -70,12 +55,6 @@ export class JjDescriptionWidget implements Widget { return description.length > 0 ? description : '(no description)'; } - getCustomKeybinds(): CustomKeybind[] { - return [ - { key: 'h', label: '(h)ide \'no jj\' message', action: 'toggle-nojj' } - ]; - } - supportsRawValue(): boolean { return false; } supportsColors(item: WidgetItem): boolean { return true; } } diff --git a/src/widgets/JjInsertions.ts b/src/widgets/JjInsertions.ts index 6478eff0..34f1c3ad 100644 --- a/src/widgets/JjInsertions.ts +++ b/src/widgets/JjInsertions.ts @@ -1,7 +1,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { - CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -11,41 +11,26 @@ import { isInsideJjRepo } from '../utils/jj'; +import { + NO_JJ_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; + export class JjInsertionsWidget implements Widget { getDefaultColor(): string { return 'green'; } getDescription(): string { return 'Shows jujutsu insertions count'; } getDisplayName(): string { return 'JJ Insertions'; } getCategory(): string { return 'Jujutsu'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - const hideNoJj = item.metadata?.hideNoJj === 'true'; - const modifiers: string[] = []; - - if (hideNoJj) { - modifiers.push('hide \'no jj\''); - } - - return { - displayText: this.getDisplayName(), - modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined - }; + return { displayText: this.getDisplayName() }; } - handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - if (action === 'toggle-nojj') { - const currentState = item.metadata?.hideNoJj === 'true'; - return { - ...item, - metadata: { - ...item.metadata, - hideNoJj: (!currentState).toString() - } - }; - } - return null; + getHideableStates(): HideableState[] { + return [NO_JJ_HIDEABLE_STATE]; } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideNoJj = item.metadata?.hideNoJj === 'true'; + const hideNoJj = isHidden(item, NO_JJ_HIDEABLE_STATE.key); if (context.isPreview) { return '+42'; @@ -59,12 +44,6 @@ export class JjInsertionsWidget implements Widget { return `+${changes.insertions}`; } - getCustomKeybinds(): CustomKeybind[] { - return [ - { key: 'h', label: '(h)ide \'no jj\' message', action: 'toggle-nojj' } - ]; - } - supportsRawValue(): boolean { return false; } supportsColors(item: WidgetItem): boolean { return true; } } diff --git a/src/widgets/JjRevision.ts b/src/widgets/JjRevision.ts index 1e824ced..09c41f94 100644 --- a/src/widgets/JjRevision.ts +++ b/src/widgets/JjRevision.ts @@ -1,7 +1,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { - CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -11,41 +11,26 @@ import { runJjArgs } from '../utils/jj'; +import { + NO_JJ_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; + export class JjRevisionWidget implements Widget { getDefaultColor(): string { return 'green'; } getDescription(): string { return 'Shows the current jujutsu change ID (short)'; } getDisplayName(): string { return 'JJ Revision'; } getCategory(): string { return 'Jujutsu'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - const hideNoJj = item.metadata?.hideNoJj === 'true'; - const modifiers: string[] = []; - - if (hideNoJj) { - modifiers.push('hide \'no jj\''); - } - - return { - displayText: this.getDisplayName(), - modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined - }; + return { displayText: this.getDisplayName() }; } - handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - if (action === 'toggle-nojj') { - const currentState = item.metadata?.hideNoJj === 'true'; - return { - ...item, - metadata: { - ...item.metadata, - hideNoJj: (!currentState).toString() - } - }; - } - return null; + getHideableStates(): HideableState[] { + return [NO_JJ_HIDEABLE_STATE]; } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideNoJj = item.metadata?.hideNoJj === 'true'; + const hideNoJj = isHidden(item, NO_JJ_HIDEABLE_STATE.key); if (context.isPreview) { return item.rawValue ? 'kkmpptxz' : ' kkmpptxz'; @@ -74,12 +59,6 @@ export class JjRevisionWidget implements Widget { ], context); } - getCustomKeybinds(): CustomKeybind[] { - return [ - { key: 'h', label: '(h)ide \'no jj\' message', action: 'toggle-nojj' } - ]; - } - supportsRawValue(): boolean { return true; } supportsColors(): boolean { return true; } } diff --git a/src/widgets/JjRootDir.ts b/src/widgets/JjRootDir.ts index 8155ad5c..41a819f4 100644 --- a/src/widgets/JjRootDir.ts +++ b/src/widgets/JjRootDir.ts @@ -1,7 +1,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { - CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -11,41 +11,26 @@ import { runJjArgs } from '../utils/jj'; +import { + NO_JJ_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; + export class JjRootDirWidget implements Widget { getDefaultColor(): string { return 'cyan'; } getDescription(): string { return 'Shows the jujutsu repository root directory name'; } getDisplayName(): string { return 'JJ Root Dir'; } getCategory(): string { return 'Jujutsu'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - const hideNoJj = item.metadata?.hideNoJj === 'true'; - const modifiers: string[] = []; - - if (hideNoJj) { - modifiers.push('hide \'no jj\''); - } - - return { - displayText: this.getDisplayName(), - modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined - }; + return { displayText: this.getDisplayName() }; } - handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - if (action === 'toggle-nojj') { - const currentState = item.metadata?.hideNoJj === 'true'; - return { - ...item, - metadata: { - ...item.metadata, - hideNoJj: (!currentState).toString() - } - }; - } - return null; + getHideableStates(): HideableState[] { + return [NO_JJ_HIDEABLE_STATE]; } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideNoJj = item.metadata?.hideNoJj === 'true'; + const hideNoJj = isHidden(item, NO_JJ_HIDEABLE_STATE.key); if (context.isPreview) { return 'my-repo'; @@ -71,12 +56,6 @@ export class JjRootDirWidget implements Widget { return lastPart && lastPart.length > 0 ? lastPart : normalizedRootDir; } - getCustomKeybinds(): CustomKeybind[] { - return [ - { key: 'h', label: '(h)ide \'no jj\' message', action: 'toggle-nojj' } - ]; - } - supportsRawValue(): boolean { return false; } supportsColors(): boolean { return true; } } diff --git a/src/widgets/JjWorkspace.ts b/src/widgets/JjWorkspace.ts index 5aac86bd..adb6771c 100644 --- a/src/widgets/JjWorkspace.ts +++ b/src/widgets/JjWorkspace.ts @@ -2,6 +2,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetEditorProps, @@ -12,6 +13,10 @@ import { runJjArgs } from '../utils/jj'; +import { + NO_JJ_HIDEABLE_STATE, + isHidden +} from './shared/hideable'; import { formatSymbolPrefix, getSymbolKeybind, @@ -27,35 +32,15 @@ export class JjWorkspaceWidget implements Widget { getDisplayName(): string { return 'JJ Workspace'; } getCategory(): string { return 'Jujutsu'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - const hideNoJj = item.metadata?.hideNoJj === 'true'; - const modifiers: string[] = []; - - if (hideNoJj) { - modifiers.push('hide \'no jj\''); - } - - return { - displayText: this.getDisplayName(), - modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined - }; + return { displayText: this.getDisplayName() }; } - handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - if (action === 'toggle-nojj') { - const currentState = item.metadata?.hideNoJj === 'true'; - return { - ...item, - metadata: { - ...item.metadata, - hideNoJj: (!currentState).toString() - } - }; - } - return null; + getHideableStates(): HideableState[] { + return [NO_JJ_HIDEABLE_STATE]; } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { - const hideNoJj = item.metadata?.hideNoJj === 'true'; + const hideNoJj = isHidden(item, NO_JJ_HIDEABLE_STATE.key); const prefix = formatSymbolPrefix(item, DEFAULT_SYMBOL); if (context.isPreview) { @@ -89,10 +74,7 @@ export class JjWorkspaceWidget implements Widget { } getCustomKeybinds(): CustomKeybind[] { - return [ - { key: 'h', label: '(h)ide \'no jj\' message', action: 'toggle-nojj' }, - getSymbolKeybind() - ]; + return [getSymbolKeybind()]; } renderEditor(props: WidgetEditorProps) { diff --git a/src/widgets/OutputSpeed.ts b/src/widgets/OutputSpeed.ts index 66aa8ef0..1f00452f 100644 --- a/src/widgets/OutputSpeed.ts +++ b/src/widgets/OutputSpeed.ts @@ -2,6 +2,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetEditorProps, @@ -13,6 +14,7 @@ import { getSpeedWidgetDescription, getSpeedWidgetDisplayName, getSpeedWidgetEditorDisplay, + getSpeedWidgetHideableStates, renderSpeedWidgetEditor, renderSpeedWidgetValue } from './shared/speed-widget'; @@ -35,6 +37,10 @@ export class OutputSpeedWidget implements Widget { return getSpeedWidgetCustomKeybinds(); } + getHideableStates(): HideableState[] { + return getSpeedWidgetHideableStates(); + } + renderEditor(props: WidgetEditorProps) { return renderSpeedWidgetEditor(props); } diff --git a/src/widgets/OutputStyle.ts b/src/widgets/OutputStyle.ts index f93e2a21..5d907c07 100644 --- a/src/widgets/OutputStyle.ts +++ b/src/widgets/OutputStyle.ts @@ -1,11 +1,16 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { + HideableState, Widget, WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { isHidden } from './shared/hideable'; + +const DEFAULT_VALUE_HIDEABLE_STATE: HideableState = { key: 'default-value', label: 'when style is \'default\'' }; + export class OutputStyleWidget implements Widget { getDefaultColor(): string { return 'cyan'; } getDescription(): string { return 'Shows the current Claude Code output style'; } @@ -15,11 +20,19 @@ export class OutputStyleWidget implements Widget { return { displayText: this.getDisplayName() }; } + getHideableStates(): HideableState[] { + return [DEFAULT_VALUE_HIDEABLE_STATE]; + } + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { if (context.isPreview) { return item.rawValue ? 'default' : 'Style: default'; } else if (context.data?.output_style?.name) { - return item.rawValue ? context.data.output_style.name : `Style: ${context.data.output_style.name}`; + const styleName = context.data.output_style.name; + if (styleName === 'default' && isHidden(item, DEFAULT_VALUE_HIDEABLE_STATE.key)) { + return null; + } + return item.rawValue ? styleName : `Style: ${styleName}`; } return null; } diff --git a/src/widgets/SessionClock.ts b/src/widgets/SessionClock.ts index 6dfa8224..0e7d28fc 100644 --- a/src/widgets/SessionClock.ts +++ b/src/widgets/SessionClock.ts @@ -1,11 +1,16 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { + HideableState, Widget, WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { isHidden } from './shared/hideable'; + +const ZERO_HIDEABLE_STATE: HideableState = { key: 'zero', label: 'when under 1 minute' }; + function formatDurationFromMs(durationMs: number): string { const totalMinutes = Math.floor(durationMs / (1000 * 60)); @@ -35,18 +40,30 @@ export class SessionClockWidget implements Widget { return { displayText: this.getDisplayName() }; } + getHideableStates(): HideableState[] { + return [ZERO_HIDEABLE_STATE]; + } + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { if (context.isPreview) { return item.rawValue ? '2hr 15m' : 'Session: 2hr 15m'; } + const hideZero = isHidden(item, ZERO_HIDEABLE_STATE.key); + const durationMs = context.data?.cost?.total_duration_ms; if (typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0) { + if (durationMs < 60000 && hideZero) { + return null; + } const formatted = formatDurationFromMs(durationMs); return item.rawValue ? formatted : `Session: ${formatted}`; } const duration = context.sessionDuration ?? '0m'; + if (duration === '0m' && hideZero) { + return null; + } return item.rawValue ? duration : `Session: ${duration}`; } diff --git a/src/widgets/SessionCost.ts b/src/widgets/SessionCost.ts index e3dcdfed..71274078 100644 --- a/src/widgets/SessionCost.ts +++ b/src/widgets/SessionCost.ts @@ -1,11 +1,16 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { + HideableState, Widget, WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { isHidden } from './shared/hideable'; + +const ZERO_HIDEABLE_STATE: HideableState = { key: 'zero', label: 'when cost is $0.00' }; + export class SessionCostWidget implements Widget { getDefaultColor(): string { return 'green'; } getDescription(): string { return 'Shows the total session cost in USD'; } @@ -15,6 +20,10 @@ export class SessionCostWidget implements Widget { return { displayText: this.getDisplayName() }; } + getHideableStates(): HideableState[] { + return [ZERO_HIDEABLE_STATE]; + } + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { if (context.isPreview) { return item.rawValue ? '$2.45' : 'Cost: $2.45'; @@ -28,6 +37,10 @@ export class SessionCostWidget implements Widget { // Format the cost to 2 decimal places const formattedCost = `$${totalCost.toFixed(2)}`; + if (formattedCost === '$0.00' && isHidden(item, ZERO_HIDEABLE_STATE.key)) { + return null; + } + return item.rawValue ? formattedCost : `Cost: ${formattedCost}`; } diff --git a/src/widgets/SessionUsage.ts b/src/widgets/SessionUsage.ts index 0a554e6e..34283766 100644 --- a/src/widgets/SessionUsage.ts +++ b/src/widgets/SessionUsage.ts @@ -2,6 +2,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -11,9 +12,11 @@ import { resolveUsageWindowWithFallback } from '../utils/usage'; +import { isHidden } from './shared/hideable'; import { makeTimerProgressBar } from './shared/progress-bar'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; import { + USAGE_NO_DATA_HIDEABLE_STATE, cycleUsageDisplayMode, getUsageDisplayMode, getUsageDisplayModifierText, @@ -41,6 +44,10 @@ export class SessionUsageWidget implements Widget { }; } + getHideableStates(): HideableState[] { + return [USAGE_NO_DATA_HIDEABLE_STATE]; + } + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { if (action === 'toggle-progress') { return cycleUsageDisplayMode(item, [], true); @@ -84,8 +91,11 @@ export class SessionUsageWidget implements Widget { const data = context.usageData ?? {}; if (data.sessionUsage === undefined) { - if (data.error) - return getUsageErrorMessage(data.error); + if (data.error) { + return isHidden(item, USAGE_NO_DATA_HIDEABLE_STATE.key) + ? null + : getUsageErrorMessage(data.error); + } return null; } diff --git a/src/widgets/Skills.tsx b/src/widgets/Skills.tsx index ec469e94..9f525bad 100644 --- a/src/widgets/Skills.tsx +++ b/src/widgets/Skills.tsx @@ -9,6 +9,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetEditorProps, @@ -18,20 +19,17 @@ import type { WidgetHookDef } from '../utils/hooks'; import { shouldInsertInput } from '../utils/input-guards'; import { makeModifierText } from './shared/editor-display'; -import { - isMetadataFlagEnabled, - removeMetadataKeys, - toggleMetadataFlag -} from './shared/metadata'; +import { isHidden } from './shared/hideable'; +import { removeMetadataKeys } from './shared/metadata'; type Mode = 'current' | 'count' | 'list'; const MODES: Mode[] = ['current', 'count', 'list']; const MODE_LABELS: Record = { current: 'last used', count: 'total count', list: 'unique list' }; -const HIDE_WHEN_EMPTY_KEY = 'hideWhenEmpty'; const LIST_LIMIT_KEY = 'listLimit'; -const TOGGLE_HIDE_EMPTY_ACTION = 'toggle-hide-empty'; const EDIT_LIST_LIMIT_ACTION = 'edit-list-limit'; +const EMPTY_HIDEABLE_STATE: HideableState = { key: 'empty', label: 'when no skills have been used' }; + function parseListLimit(item: WidgetItem): number { const parsed = parseInt(item.metadata?.[LIST_LIMIT_KEY] ?? '0', 10); if (Number.isNaN(parsed) || parsed < 0) { @@ -76,8 +74,7 @@ export class SkillsWidget implements Widget { getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { const keybinds: CustomKeybind[] = [ - { key: 'v', label: '(v)iew: last/count/list', action: 'cycle-mode' }, - { key: 'h', label: '(h)ide when empty', action: TOGGLE_HIDE_EMPTY_ACTION } + { key: 'v', label: '(v)iew: last/count/list', action: 'cycle-mode' } ]; if (item && this.getMode(item) === 'list') { @@ -87,6 +84,10 @@ export class SkillsWidget implements Widget { return keybinds; } + getHideableStates(): HideableState[] { + return [EMPTY_HIDEABLE_STATE]; + } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { const modifiers = [MODE_LABELS[this.getMode(item)]]; if (this.getMode(item) === 'list') { @@ -95,9 +96,6 @@ export class SkillsWidget implements Widget { modifiers.push(`limit: ${limit}`); } } - if (this.isHideWhenEmptyEnabled(item)) { - modifiers.push('hide when empty'); - } return { displayText: 'Skills', modifierText: makeModifierText(modifiers) }; } @@ -107,9 +105,6 @@ export class SkillsWidget implements Widget { const nextItem = next === 'list' ? item : removeMetadataKeys(item, [LIST_LIMIT_KEY]); return { ...nextItem, metadata: { ...nextItem.metadata, mode: next } }; } - if (action === TOGGLE_HIDE_EMPTY_ACTION) { - return toggleMetadataFlag(item, HIDE_WHEN_EMPTY_KEY); - } return null; } @@ -120,7 +115,7 @@ export class SkillsWidget implements Widget { render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { const mode = this.getMode(item); const raw = item.rawValue; - const hideWhenEmpty = this.isHideWhenEmptyEnabled(item); + const hideWhenEmpty = isHidden(item, EMPTY_HIDEABLE_STATE.key); if (context.isPreview) { if (mode === 'current') { @@ -168,10 +163,6 @@ export class SkillsWidget implements Widget { const mode = item.metadata?.mode; return mode && MODES.includes(mode as Mode) ? mode as Mode : 'current'; } - - private isHideWhenEmptyEnabled(item: WidgetItem): boolean { - return isMetadataFlagEnabled(item, HIDE_WHEN_EMPTY_KEY); - } } const SkillsEditor: React.FC = ({ widget, onComplete, onCancel, action }) => { diff --git a/src/widgets/TokensCached.ts b/src/widgets/TokensCached.ts index c1fd8bfb..68ed6ea4 100644 --- a/src/widgets/TokensCached.ts +++ b/src/widgets/TokensCached.ts @@ -1,14 +1,18 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { + HideableState, Widget, WidgetEditorDisplay, WidgetItem } from '../types/Widget'; import { formatTokens } from '../utils/renderer'; +import { isHidden } from './shared/hideable'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; +const ZERO_HIDEABLE_STATE: HideableState = { key: 'zero', label: 'when token count is zero' }; + export class TokensCachedWidget implements Widget { getDefaultColor(): string { return 'cyan'; } getDescription(): string { return 'Shows cached token count for the current session'; } @@ -18,12 +22,19 @@ export class TokensCachedWidget implements Widget { return { displayText: this.getDisplayName() }; } + getHideableStates(): HideableState[] { + return [ZERO_HIDEABLE_STATE]; + } + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { if (context.isPreview) { return formatRawOrLabeledValue(item, 'Cached: ', '12k'); } if (context.tokenMetrics) { + if (context.tokenMetrics.cachedTokens === 0 && isHidden(item, ZERO_HIDEABLE_STATE.key)) { + return null; + } return formatRawOrLabeledValue(item, 'Cached: ', formatTokens(context.tokenMetrics.cachedTokens)); } return null; diff --git a/src/widgets/TokensInput.ts b/src/widgets/TokensInput.ts index dfebe6b4..19b04af3 100644 --- a/src/widgets/TokensInput.ts +++ b/src/widgets/TokensInput.ts @@ -1,6 +1,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -8,8 +9,11 @@ import type { import { getContextWindowInputTotalTokens } from '../utils/context-window'; import { formatTokens } from '../utils/renderer'; +import { isHidden } from './shared/hideable'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; +const ZERO_HIDEABLE_STATE: HideableState = { key: 'zero', label: 'when token count is zero' }; + export class TokensInputWidget implements Widget { getDefaultColor(): string { return 'blue'; } getDescription(): string { return 'Shows input token count for the current session'; } @@ -19,20 +23,27 @@ export class TokensInputWidget implements Widget { return { displayText: this.getDisplayName() }; } + getHideableStates(): HideableState[] { + return [ZERO_HIDEABLE_STATE]; + } + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { if (context.isPreview) { return formatRawOrLabeledValue(item, 'In: ', '15.2k'); } - if (context.tokenMetrics) { - return formatRawOrLabeledValue(item, 'In: ', formatTokens(context.tokenMetrics.inputTokens)); + const inputTotalTokens = context.tokenMetrics?.inputTokens + ?? getContextWindowInputTotalTokens(context.data) + ?? null; + if (inputTotalTokens === null) { + return null; } - const inputTotalTokens = getContextWindowInputTotalTokens(context.data); - if (inputTotalTokens !== null) { - return formatRawOrLabeledValue(item, 'In: ', formatTokens(inputTotalTokens)); + if (inputTotalTokens === 0 && isHidden(item, ZERO_HIDEABLE_STATE.key)) { + return null; } - return null; + + return formatRawOrLabeledValue(item, 'In: ', formatTokens(inputTotalTokens)); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/TokensOutput.ts b/src/widgets/TokensOutput.ts index a9e297a9..d80a9775 100644 --- a/src/widgets/TokensOutput.ts +++ b/src/widgets/TokensOutput.ts @@ -1,6 +1,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -8,8 +9,11 @@ import type { import { getContextWindowOutputTotalTokens } from '../utils/context-window'; import { formatTokens } from '../utils/renderer'; +import { isHidden } from './shared/hideable'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; +const ZERO_HIDEABLE_STATE: HideableState = { key: 'zero', label: 'when token count is zero' }; + export class TokensOutputWidget implements Widget { getDefaultColor(): string { return 'white'; } getDescription(): string { return 'Shows output token count for the current session'; } @@ -19,20 +23,27 @@ export class TokensOutputWidget implements Widget { return { displayText: this.getDisplayName() }; } + getHideableStates(): HideableState[] { + return [ZERO_HIDEABLE_STATE]; + } + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { if (context.isPreview) { return formatRawOrLabeledValue(item, 'Out: ', '3.4k'); } - if (context.tokenMetrics) { - return formatRawOrLabeledValue(item, 'Out: ', formatTokens(context.tokenMetrics.outputTokens)); + const outputTotalTokens = context.tokenMetrics?.outputTokens + ?? getContextWindowOutputTotalTokens(context.data) + ?? null; + if (outputTotalTokens === null) { + return null; } - const outputTotalTokens = getContextWindowOutputTotalTokens(context.data); - if (outputTotalTokens !== null) { - return formatRawOrLabeledValue(item, 'Out: ', formatTokens(outputTotalTokens)); + if (outputTotalTokens === 0 && isHidden(item, ZERO_HIDEABLE_STATE.key)) { + return null; } - return null; + + return formatRawOrLabeledValue(item, 'Out: ', formatTokens(outputTotalTokens)); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/TokensTotal.ts b/src/widgets/TokensTotal.ts index 5f6e57bd..7fe41b0a 100644 --- a/src/widgets/TokensTotal.ts +++ b/src/widgets/TokensTotal.ts @@ -1,14 +1,18 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { + HideableState, Widget, WidgetEditorDisplay, WidgetItem } from '../types/Widget'; import { formatTokens } from '../utils/renderer'; +import { isHidden } from './shared/hideable'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; +const ZERO_HIDEABLE_STATE: HideableState = { key: 'zero', label: 'when token count is zero' }; + export class TokensTotalWidget implements Widget { getDefaultColor(): string { return 'cyan'; } getDescription(): string { return 'Shows total token count (input + output + cache) for the current session'; } @@ -18,12 +22,19 @@ export class TokensTotalWidget implements Widget { return { displayText: this.getDisplayName() }; } + getHideableStates(): HideableState[] { + return [ZERO_HIDEABLE_STATE]; + } + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { if (context.isPreview) { return formatRawOrLabeledValue(item, 'Total: ', '30.6k'); } if (context.tokenMetrics) { + if (context.tokenMetrics.totalTokens === 0 && isHidden(item, ZERO_HIDEABLE_STATE.key)) { + return null; + } return formatRawOrLabeledValue(item, 'Total: ', formatTokens(context.tokenMetrics.totalTokens)); } return null; diff --git a/src/widgets/TotalSpeed.ts b/src/widgets/TotalSpeed.ts index 000d9dc8..004d66e1 100644 --- a/src/widgets/TotalSpeed.ts +++ b/src/widgets/TotalSpeed.ts @@ -2,6 +2,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetEditorProps, @@ -13,6 +14,7 @@ import { getSpeedWidgetDescription, getSpeedWidgetDisplayName, getSpeedWidgetEditorDisplay, + getSpeedWidgetHideableStates, renderSpeedWidgetEditor, renderSpeedWidgetValue } from './shared/speed-widget'; @@ -35,6 +37,10 @@ export class TotalSpeedWidget implements Widget { return getSpeedWidgetCustomKeybinds(); } + getHideableStates(): HideableState[] { + return getSpeedWidgetHideableStates(); + } + renderEditor(props: WidgetEditorProps) { return renderSpeedWidgetEditor(props); } diff --git a/src/widgets/WeeklyOpusUsage.ts b/src/widgets/WeeklyOpusUsage.ts index 0049e04c..6ff55437 100644 --- a/src/widgets/WeeklyOpusUsage.ts +++ b/src/widgets/WeeklyOpusUsage.ts @@ -2,6 +2,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -11,9 +12,11 @@ import { resolveWeeklyOpusUsageWindow } from '../utils/usage'; +import { isHidden } from './shared/hideable'; import { makeTimerProgressBar } from './shared/progress-bar'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; import { + USAGE_NO_DATA_HIDEABLE_STATE, cycleUsageDisplayMode, getUsageDisplayMode, getUsageDisplayModifierText, @@ -43,6 +46,10 @@ export class WeeklyOpusUsageWidget implements Widget { }; } + getHideableStates(): HideableState[] { + return [USAGE_NO_DATA_HIDEABLE_STATE]; + } + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { if (action === 'toggle-progress') { return cycleUsageDisplayMode(item, [], true); @@ -86,8 +93,11 @@ export class WeeklyOpusUsageWidget implements Widget { const data = context.usageData ?? {}; if (data.weeklyOpusUsage === undefined) { - if (data.error) - return getUsageErrorMessage(data.error); + if (data.error) { + return isHidden(item, USAGE_NO_DATA_HIDEABLE_STATE.key) + ? null + : getUsageErrorMessage(data.error); + } return null; } diff --git a/src/widgets/WeeklySonnetUsage.ts b/src/widgets/WeeklySonnetUsage.ts index 56be3a3e..9071fb05 100644 --- a/src/widgets/WeeklySonnetUsage.ts +++ b/src/widgets/WeeklySonnetUsage.ts @@ -2,6 +2,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -11,9 +12,11 @@ import { resolveWeeklySonnetUsageWindow } from '../utils/usage'; +import { isHidden } from './shared/hideable'; import { makeTimerProgressBar } from './shared/progress-bar'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; import { + USAGE_NO_DATA_HIDEABLE_STATE, cycleUsageDisplayMode, getUsageDisplayMode, getUsageDisplayModifierText, @@ -43,6 +46,10 @@ export class WeeklySonnetUsageWidget implements Widget { }; } + getHideableStates(): HideableState[] { + return [USAGE_NO_DATA_HIDEABLE_STATE]; + } + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { if (action === 'toggle-progress') { return cycleUsageDisplayMode(item, [], true); @@ -86,8 +93,11 @@ export class WeeklySonnetUsageWidget implements Widget { const data = context.usageData ?? {}; if (data.weeklySonnetUsage === undefined) { - if (data.error) - return getUsageErrorMessage(data.error); + if (data.error) { + return isHidden(item, USAGE_NO_DATA_HIDEABLE_STATE.key) + ? null + : getUsageErrorMessage(data.error); + } return null; } diff --git a/src/widgets/WeeklyUsage.ts b/src/widgets/WeeklyUsage.ts index a9fc928c..62e7d07e 100644 --- a/src/widgets/WeeklyUsage.ts +++ b/src/widgets/WeeklyUsage.ts @@ -2,6 +2,7 @@ import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { CustomKeybind, + HideableState, Widget, WidgetEditorDisplay, WidgetItem @@ -11,9 +12,11 @@ import { resolveWeeklyUsageWindow } from '../utils/usage'; +import { isHidden } from './shared/hideable'; import { makeTimerProgressBar } from './shared/progress-bar'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; import { + USAGE_NO_DATA_HIDEABLE_STATE, cycleUsageDisplayMode, getUsageDisplayMode, getUsageDisplayModifierText, @@ -41,6 +44,10 @@ export class WeeklyUsageWidget implements Widget { }; } + getHideableStates(): HideableState[] { + return [USAGE_NO_DATA_HIDEABLE_STATE]; + } + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { if (action === 'toggle-progress') { return cycleUsageDisplayMode(item, [], true); @@ -84,8 +91,11 @@ export class WeeklyUsageWidget implements Widget { const data = context.usageData ?? {}; if (data.weeklyUsage === undefined) { - if (data.error) - return getUsageErrorMessage(data.error); + if (data.error) { + return isHidden(item, USAGE_NO_DATA_HIDEABLE_STATE.key) + ? null + : getUsageErrorMessage(data.error); + } return null; } diff --git a/src/widgets/__tests__/BlockTimer.test.ts b/src/widgets/__tests__/BlockTimer.test.ts index 02ab1a42..d78caa22 100644 --- a/src/widgets/__tests__/BlockTimer.test.ts +++ b/src/widgets/__tests__/BlockTimer.test.ts @@ -85,6 +85,24 @@ describe('BlockTimerWidget', () => { }, { usageData: { error: 'timeout' } })).toBe(`Block [${'░'.repeat(32)}] 0.0%`); }); + it('hides empty values when the no-data hide state is enabled', () => { + const widget = new BlockTimerWidget(); + + mockResolveUsageWindowWithFallback.mockReturnValue(null); + + expect(widget.getHideableStates().map(state => state.key)).toEqual(['no-data']); + expect(render(widget, { + id: 'block', + type: 'block-timer', + metadata: { hide: 'no-data' } + }, { usageData: { error: 'timeout' } })).toBeNull(); + expect(render(widget, { + id: 'block', + type: 'block-timer', + metadata: { display: 'progress', hide: 'no-data' } + }, { usageData: { error: 'timeout' } })).toBeNull(); + }); + it('shows raw value without label in time mode', () => { const widget = new BlockTimerWidget(); diff --git a/src/widgets/__tests__/CacheWidgets.test.ts b/src/widgets/__tests__/CacheWidgets.test.ts index 822afc2a..f2e42580 100644 --- a/src/widgets/__tests__/CacheWidgets.test.ts +++ b/src/widgets/__tests__/CacheWidgets.test.ts @@ -131,7 +131,7 @@ describe('Cache widgets', () => { it('hides missing turn data when hide-when-empty is enabled', async () => { const w = await loadWidgets(); const context: RenderContext = {}; - const hidden = { metadata: { hideWhenEmpty: 'true' } }; + const hidden = { metadata: { hide: 'empty' } }; expect(new w.CacheHitRateWidget().render(turnItem('cache-hit-rate', hidden), context, DEFAULT_SETTINGS)).toBeNull(); expect(new w.CacheReadWidget().render(turnItem('cache-read', hidden), context, DEFAULT_SETTINGS)).toBeNull(); @@ -171,7 +171,7 @@ describe('Cache widgets', () => { } } }; - const hidden = { metadata: { hideWhenEmpty: 'true' } }; + const hidden = { metadata: { hide: 'empty' } }; expect(new w.CacheHitRateWidget().render(turnItem('cache-hit-rate', hidden), context, DEFAULT_SETTINGS)).toBeNull(); expect(new w.CacheReadWidget().render(turnItem('cache-read', hidden), context, DEFAULT_SETTINGS)).toBeNull(); @@ -191,30 +191,26 @@ describe('Cache widgets', () => { } } }; - const hidden = { metadata: { hideWhenEmpty: 'true' } }; + const hidden = { metadata: { hide: 'empty' } }; expect(new w.CacheHitRateWidget().render(turnItem('cache-hit-rate', hidden), context, DEFAULT_SETTINGS)).toBeNull(); expect(new w.CacheReadWidget().render(turnItem('cache-read', hidden), context, DEFAULT_SETTINGS)).toBeNull(); expect(new w.CacheWriteWidget().render(turnItem('cache-write', hidden), context, DEFAULT_SETTINGS)).toBe('Cache Write: fmt:2000 (80.0%)'); }); - it('toggles cache options via custom keybind actions', async () => { + it('exposes the scope keybind and the empty hideable state', async () => { const w = await loadWidgets(); const widget = new w.CacheHitRateWidget(); expect(widget.getCustomKeybinds()).toEqual([ - { key: 't', label: '(t)urn/session', action: 'toggle-cache-scope' }, - { key: 'h', label: '(h)ide when empty', action: 'toggle-hide-empty' } + { key: 't', label: '(t)urn/session', action: 'toggle-cache-scope' } ]); + expect(widget.getHideableStates().map(state => state.key)).toEqual(['empty']); const toggled = widget.handleEditorAction('toggle-cache-scope', turnItem('cache-hit-rate')); expect(toggled?.metadata?.cacheScopeSession).toBe('true'); - const hidden = widget.handleEditorAction('toggle-hide-empty', turnItem('cache-hit-rate')); - expect(hidden?.metadata?.hideWhenEmpty).toBe('true'); - expect(widget.getEditorDisplay(hidden ?? turnItem('cache-hit-rate')).modifierText).toBe('(hide when empty)'); - - const sessionHidden = sessionItem('cache-hit-rate', { metadata: { cacheScopeSession: 'true', hideWhenEmpty: 'true' } }); - expect(widget.getEditorDisplay(sessionHidden).modifierText).toBe('(session, hide when empty)'); + expect(widget.getEditorDisplay(turnItem('cache-hit-rate')).modifierText).toBeUndefined(); + expect(widget.getEditorDisplay(sessionItem('cache-hit-rate')).modifierText).toBe('(session)'); }); it('renders preview labels and raw values', async () => { diff --git a/src/widgets/__tests__/CompactionCounter.test.ts b/src/widgets/__tests__/CompactionCounter.test.ts index e3f9cef3..de7669fa 100644 --- a/src/widgets/__tests__/CompactionCounter.test.ts +++ b/src/widgets/__tests__/CompactionCounter.test.ts @@ -125,14 +125,14 @@ describe('CompactionCounterWidget', () => { it('returns null when count is 0 and hide zero is enabled', () => { expect(render({ compactionData: { count: 0 }, - item: { ...ITEM, metadata: { hideZero: 'true' } } + item: { ...ITEM, metadata: { hide: 'zero' } } })).toBeNull(); }); it('renders positive counts when hide zero is enabled', () => { expect(render({ compactionData: { count: 3 }, - item: { ...ITEM, metadata: { hideZero: 'true' } } + item: { ...ITEM, metadata: { hide: 'zero' } } })).toBe('↻ 3'); }); @@ -258,7 +258,6 @@ describe('CompactionCounterWidget', () => { { key: 'n', label: '(n)erd font', action: 'toggle-nerd-font' }, { key: 's', label: '(s)plit by trigger', action: 'toggle-triggers' }, { key: 't', label: '(t)okens reclaimed', action: 'toggle-reclaimed' }, - { key: 'h', label: '(h)ide when zero', action: 'toggle-hide-zero' }, { key: 'g', label: '(g)lyph', action: 'edit-symbol-override' } ]); }); @@ -271,7 +270,6 @@ describe('CompactionCounterWidget', () => { { key: 'f', label: '(f)ormat', action: 'cycle-format' }, { key: 's', label: '(s)plit by trigger', action: 'toggle-triggers' }, { key: 't', label: '(t)okens reclaimed', action: 'toggle-reclaimed' }, - { key: 'h', label: '(h)ide when zero', action: 'toggle-hide-zero' }, { key: 'g', label: '(g)lyph', action: 'edit-symbol-override' } ]); }); @@ -303,14 +301,8 @@ describe('CompactionCounterWidget', () => { }); }); - it('shows hide zero in the editor display when enabled', () => { - expect(new CompactionCounterWidget().getEditorDisplay({ - ...ITEM, - metadata: { hideZero: 'true' } - })).toEqual({ - displayText: 'Compaction Counter', - modifierText: '(icon-space-number, hide zero)' - }); + it('declares the zero hideable state', () => { + expect(new CompactionCounterWidget().getHideableStates().map(state => state.key)).toEqual(['zero']); }); it('ignores stale icon-number format metadata in the editor display', () => { @@ -352,15 +344,6 @@ describe('CompactionCounterWidget', () => { expect(disabled?.metadata?.nerdFont).toBeUndefined(); }); - it('toggles hide zero metadata on and off', () => { - const widget = new CompactionCounterWidget(); - const enabled = widget.handleEditorAction('toggle-hide-zero', ITEM); - const disabled = widget.handleEditorAction('toggle-hide-zero', enabled ?? ITEM); - - expect(enabled?.metadata?.hideZero).toBe('true'); - expect(disabled?.metadata?.hideZero).toBe('false'); - }); - it('does not enable nerd font for non-default formats', () => { const widget = new CompactionCounterWidget(); const enabled = widget.handleEditorAction('toggle-nerd-font', { diff --git a/src/widgets/__tests__/ExtraUsageRemaining.test.ts b/src/widgets/__tests__/ExtraUsageRemaining.test.ts index 6cd3cb81..a9bc306f 100644 --- a/src/widgets/__tests__/ExtraUsageRemaining.test.ts +++ b/src/widgets/__tests__/ExtraUsageRemaining.test.ts @@ -72,21 +72,24 @@ describe('ExtraUsageRemainingWidget', () => { })).toBe('Overage Left: $0.00'); }); - it('exposes and toggles hide-if-disabled configuration', () => { + it('declares the disabled and no-data hideable states', () => { const widget = new ExtraUsageRemainingWidget(); const baseItem: WidgetItem = { id: 'extra', type: 'extra-usage-remaining' }; - expect(widget.getCustomKeybinds()).toEqual([ - { key: 'h', label: '(h)ide if disabled', action: 'toggle-hide-disabled' } - ]); + expect(widget.getHideableStates().map(state => state.key)).toEqual(['disabled', 'no-data']); expect(widget.getEditorDisplay(baseItem).modifierText).toBeUndefined(); + }); - const hidden = widget.handleEditorAction('toggle-hide-disabled', baseItem); - expect(hidden?.metadata?.hideIfDisabled).toBe('true'); - expect(widget.getEditorDisplay(hidden ?? baseItem).modifierText).toBe('(hide if disabled)'); + it('hides when extra usage is disabled via unified hide metadata', () => { + const widget = new ExtraUsageRemainingWidget(); - const shown = widget.handleEditorAction('toggle-hide-disabled', hidden ?? baseItem); - expect(shown?.metadata?.hideIfDisabled).toBe('false'); + const hiddenItem: WidgetItem = { + id: 'extra', + metadata: { hide: 'disabled' }, + type: 'extra-usage-remaining' + }; + + expect(render(widget, hiddenItem, { usageData: { extraUsageEnabled: false } })).toBeNull(); }); it('renders available remaining budget before unrelated usage errors', () => { @@ -116,6 +119,18 @@ describe('ExtraUsageRemainingWidget', () => { })).toBeNull(); }); + it('hides usage errors when the no-data state is enabled', () => { + const widget = new ExtraUsageRemainingWidget(); + + mockGetUsageErrorMessage.mockReturnValue('[Timeout]'); + + expect(render(widget, { + id: 'extra', + metadata: { hide: 'no-data' }, + type: 'extra-usage-remaining' + }, { usageData: { error: 'timeout' } })).toBeNull(); + }); + it('renders n/a when extra usage is disabled', () => { const widget = new ExtraUsageRemainingWidget(); @@ -135,7 +150,7 @@ describe('ExtraUsageRemainingWidget', () => { const hiddenItem: WidgetItem = { id: 'extra', - metadata: { hideIfDisabled: 'true' }, + metadata: { hide: 'disabled' }, type: 'extra-usage-remaining' }; diff --git a/src/widgets/__tests__/ExtraUsageUsed.test.ts b/src/widgets/__tests__/ExtraUsageUsed.test.ts index 05fbc0e5..03481593 100644 --- a/src/widgets/__tests__/ExtraUsageUsed.test.ts +++ b/src/widgets/__tests__/ExtraUsageUsed.test.ts @@ -69,21 +69,11 @@ describe('ExtraUsageUsedWidget', () => { })).toBe('Overage Used: €5.42'); }); - it('exposes and toggles hide-if-disabled configuration', () => { + it('declares the disabled and no-data hideable states', () => { const widget = new ExtraUsageUsedWidget(); - const baseItem: WidgetItem = { id: 'extra', type: 'extra-usage-used' }; - expect(widget.getCustomKeybinds()).toEqual([ - { key: 'h', label: '(h)ide if disabled', action: 'toggle-hide-disabled' } - ]); - expect(widget.getEditorDisplay(baseItem).modifierText).toBeUndefined(); - - const hidden = widget.handleEditorAction('toggle-hide-disabled', baseItem); - expect(hidden?.metadata?.hideIfDisabled).toBe('true'); - expect(widget.getEditorDisplay(hidden ?? baseItem).modifierText).toBe('(hide if disabled)'); - - const shown = widget.handleEditorAction('toggle-hide-disabled', hidden ?? baseItem); - expect(shown?.metadata?.hideIfDisabled).toBe('false'); + expect(widget.getHideableStates().map(state => state.key)).toEqual(['disabled', 'no-data']); + expect(widget.getEditorDisplay({ id: 'extra', type: 'extra-usage-used' }).modifierText).toBeUndefined(); }); it('renders available used budget before unrelated usage errors', () => { @@ -125,7 +115,7 @@ describe('ExtraUsageUsedWidget', () => { const hiddenItem: WidgetItem = { id: 'extra', - metadata: { hideIfDisabled: 'true' }, + metadata: { hide: 'disabled' }, type: 'extra-usage-used' }; diff --git a/src/widgets/__tests__/ExtraUsageUtilization.test.ts b/src/widgets/__tests__/ExtraUsageUtilization.test.ts index 4c000255..9368346b 100644 --- a/src/widgets/__tests__/ExtraUsageUtilization.test.ts +++ b/src/widgets/__tests__/ExtraUsageUtilization.test.ts @@ -68,13 +68,12 @@ describe('ExtraUsageUtilizationWidget', () => { })).toBe('Overage: 2.6%'); }); - it('exposes and toggles hide-if-disabled configuration', () => { + it('declares the disabled and no-data hideable states alongside display keybinds', () => { const widget = new ExtraUsageUtilizationWidget(); const baseItem: WidgetItem = { id: 'extra', type: 'extra-usage-utilization' }; expect(widget.getCustomKeybinds(baseItem)).toEqual([ - { key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' }, - { key: 'h', label: '(h)ide if disabled', action: 'toggle-hide-disabled' } + { key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' } ]); expect(widget.getCustomKeybinds({ ...baseItem, @@ -82,21 +81,11 @@ describe('ExtraUsageUtilizationWidget', () => { })).toEqual([ { key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' }, { key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' }, - { key: 't', label: '(t)ime cursor', action: 'toggle-cursor' }, - { key: 'h', label: '(h)ide if disabled', action: 'toggle-hide-disabled' } + { key: 't', label: '(t)ime cursor', action: 'toggle-cursor' } ]); expect(widget.getEditorDisplay(baseItem).modifierText).toBeUndefined(); - const hidden = widget.handleEditorAction('toggle-hide-disabled', baseItem); - expect(hidden?.metadata?.hideIfDisabled).toBe('true'); - expect(widget.getEditorDisplay(hidden ?? baseItem).modifierText).toBe('(hide if disabled)'); - expect(widget.getEditorDisplay({ - ...baseItem, - metadata: { display: 'progress', hideIfDisabled: 'true' } - }).modifierText).toBe('(long bar, hide if disabled)'); - - const shown = widget.handleEditorAction('toggle-hide-disabled', hidden ?? baseItem); - expect(shown?.metadata?.hideIfDisabled).toBe('false'); + expect(widget.getHideableStates().map(state => state.key)).toEqual(['disabled', 'no-data']); }); it('shows usage errors only when required extra usage data is missing', () => { @@ -108,6 +97,18 @@ describe('ExtraUsageUtilizationWidget', () => { expect(render(widget, { id: 'extra', type: 'extra-usage-utilization' }, { usageData: { extraUsageEnabled: true } })).toBeNull(); }); + it('hides usage errors when the no-data state is enabled', () => { + const widget = new ExtraUsageUtilizationWidget(); + + mockGetUsageErrorMessage.mockReturnValue('[Timeout]'); + + expect(render(widget, { + id: 'extra', + metadata: { hide: 'no-data' }, + type: 'extra-usage-utilization' + }, { usageData: { error: 'timeout' } })).toBeNull(); + }); + it('renders n/a when extra usage is disabled', () => { const widget = new ExtraUsageUtilizationWidget(); @@ -133,7 +134,7 @@ describe('ExtraUsageUtilizationWidget', () => { const hiddenItem: WidgetItem = { id: 'extra', - metadata: { hideIfDisabled: 'true' }, + metadata: { hide: 'disabled' }, type: 'extra-usage-utilization' }; diff --git a/src/widgets/__tests__/GitAheadBehind.test.ts b/src/widgets/__tests__/GitAheadBehind.test.ts new file mode 100644 index 00000000..5230f0c8 --- /dev/null +++ b/src/widgets/__tests__/GitAheadBehind.test.ts @@ -0,0 +1,106 @@ +import { execFileSync } from 'child_process'; +import { + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import { clearGitCache } from '../../utils/git'; +import { GitAheadBehindWidget } from '../GitAheadBehind'; + +vi.mock('child_process', () => ({ + execSync: vi.fn(), + execFileSync: vi.fn(), + spawnSync: vi.fn() +})); + +const mockExecFileSync = execFileSync as unknown as { + mockReturnValue: (value: string) => void; + mockReturnValueOnce: (value: string) => void; +}; + +function render(options: { + metadata?: Record; + rawValue?: boolean; + isPreview?: boolean; +} = {}) { + const widget = new GitAheadBehindWidget(); + const context: RenderContext = { isPreview: options.isPreview }; + const item: WidgetItem = { + id: 'git-ahead-behind', + type: 'git-ahead-behind', + rawValue: options.rawValue, + metadata: options.metadata + }; + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('GitAheadBehindWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + clearGitCache(); + }); + + it('declares no-git, no-upstream, and a default-enabled zero state', () => { + const states = new GitAheadBehindWidget().getHideableStates(); + + expect(states.map(state => state.key)).toEqual(['no-git', 'no-upstream', 'zero']); + expect(states.find(state => state.key === 'zero')?.defaultEnabled).toBe(true); + }); + + it('renders preview', () => { + expect(render({ isPreview: true })).toBe('↑2↓3'); + expect(render({ isPreview: true, rawValue: true })).toBe('2,3'); + }); + + it('renders divergence counts', () => { + mockExecFileSync.mockReturnValueOnce('true\n'); + mockExecFileSync.mockReturnValueOnce('2\t3\n'); + + expect(render()).toBe('↑2↓3'); + }); + + it('renders no git outside a work tree and hides via the unified state', () => { + mockExecFileSync.mockReturnValue('false\n'); + expect(render()).toBe('(no git)'); + + clearGitCache(); + mockExecFileSync.mockReturnValue('false\n'); + expect(render({ metadata: { hide: 'no-git' } })).toBeNull(); + }); + + it('renders no upstream and hides via the unified state', () => { + mockExecFileSync.mockReturnValueOnce('true\n'); + mockExecFileSync.mockReturnValueOnce(''); + expect(render()).toBe('(no upstream)'); + + clearGitCache(); + mockExecFileSync.mockReturnValueOnce('true\n'); + mockExecFileSync.mockReturnValueOnce(''); + expect(render({ metadata: { hide: 'no-upstream' } })).toBeNull(); + }); + + it('hides when not diverged by default', () => { + mockExecFileSync.mockReturnValueOnce('true\n'); + mockExecFileSync.mockReturnValueOnce('0\t0\n'); + + expect(render()).toBeNull(); + }); + + it('shows zero divergence when the zero state is opted out', () => { + mockExecFileSync.mockReturnValueOnce('true\n'); + mockExecFileSync.mockReturnValueOnce('0\t0\n'); + expect(render({ metadata: { hide: '' } })).toBe('↑0↓0'); + + clearGitCache(); + mockExecFileSync.mockReturnValueOnce('true\n'); + mockExecFileSync.mockReturnValueOnce('0\t0\n'); + expect(render({ metadata: { hide: 'no-git' }, rawValue: true })).toBe('0,0'); + }); +}); diff --git a/src/widgets/__tests__/GitBranch.test.ts b/src/widgets/__tests__/GitBranch.test.ts index 33e5c91a..54cf015a 100644 --- a/src/widgets/__tests__/GitBranch.test.ts +++ b/src/widgets/__tests__/GitBranch.test.ts @@ -43,7 +43,7 @@ function render(options: { }; const metadata = { ...options.metadata, - ...(options.hideNoGit ? { hideNoGit: 'true' } : {}), + ...(options.hideNoGit ? { hide: 'no-git' } : {}), ...(options.linkToRepo ? { linkToRepo: 'true' } : {}) }; const item: WidgetItem = { @@ -204,12 +204,12 @@ describe('GitBranchWidget', () => { const item: WidgetItem = { id: 'git-branch', type: 'git-branch', - metadata: { linkToRepo: 'true', linkToGitHub: 'true', hideNoGit: 'true' } + metadata: { linkToRepo: 'true', linkToGitHub: 'true', hide: 'no-git' } }; const toggled = widget.handleEditorAction('toggle-link', item); - expect(toggled?.metadata).toEqual({ hideNoGit: 'true' }); + expect(toggled?.metadata).toEqual({ hide: 'no-git' }); }); }); }); diff --git a/src/widgets/__tests__/GitChanges.test.ts b/src/widgets/__tests__/GitChanges.test.ts index f142fc78..fe1ce407 100644 --- a/src/widgets/__tests__/GitChanges.test.ts +++ b/src/widgets/__tests__/GitChanges.test.ts @@ -29,6 +29,7 @@ const mockExecFileSync = execFileSync as unknown as { function render(options: { cwd?: string; + hide?: string; hideNoGit?: boolean; isPreview?: boolean; } = {}) { @@ -40,7 +41,7 @@ function render(options: { const item: WidgetItem = { id: 'git-changes', type: 'git-changes', - metadata: options.hideNoGit ? { hideNoGit: 'true' } : undefined + metadata: options.hide ? { hide: options.hide } : (options.hideNoGit ? { hide: 'no-git' } : undefined) }; return widget.render(item, context, DEFAULT_SETTINGS); @@ -75,6 +76,22 @@ describe('GitChangesWidget', () => { expect(render()).toBe('(+0,-0)'); }); + it('should hide zero changes when the zero state is enabled', () => { + mockExecFileSync.mockReturnValueOnce('true\n'); + mockExecFileSync.mockReturnValueOnce(''); + mockExecFileSync.mockReturnValueOnce(''); + + expect(render({ hide: 'zero' })).toBeNull(); + }); + + it('should keep non-zero changes visible with the zero state enabled', () => { + mockExecFileSync.mockReturnValueOnce('true\n'); + mockExecFileSync.mockReturnValueOnce('1 file changed, 2 insertions(+), 1 deletion(-)'); + mockExecFileSync.mockReturnValueOnce(''); + + expect(render({ hide: 'zero' })).toBe('(+2,-1)'); + }); + it('should render no git when probe returns false', () => { mockExecFileSync.mockReturnValue('false\n'); diff --git a/src/widgets/__tests__/GitCleanStatus.test.ts b/src/widgets/__tests__/GitCleanStatus.test.ts index 4a033cb5..c0dfbe17 100644 --- a/src/widgets/__tests__/GitCleanStatus.test.ts +++ b/src/widgets/__tests__/GitCleanStatus.test.ts @@ -42,7 +42,7 @@ function render(options: { id: 'git-clean-status', type: 'git-clean-status', rawValue: options.rawValue, - metadata: options.hideNoGit ? { hideNoGit: 'true' } : undefined + metadata: options.hideNoGit ? { hide: 'no-git' } : undefined }; return widget.render(item, context, DEFAULT_SETTINGS); diff --git a/src/widgets/__tests__/GitConflicts.test.ts b/src/widgets/__tests__/GitConflicts.test.ts index bdc36608..46a02073 100644 --- a/src/widgets/__tests__/GitConflicts.test.ts +++ b/src/widgets/__tests__/GitConflicts.test.ts @@ -29,6 +29,7 @@ const mockExecFileSync = execFileSync as unknown as { function render(options: { isPreview?: boolean; rawValue?: boolean; + hide?: string; hideNoGit?: boolean; } = {}) { const widget = new GitConflictsWidget(); @@ -37,7 +38,7 @@ function render(options: { id: 'git-conflicts', type: 'git-conflicts', rawValue: options.rawValue, - metadata: options.hideNoGit ? { hideNoGit: 'true' } : undefined + metadata: options.hide ? { hide: options.hide } : (options.hideNoGit ? { hide: 'no-git' } : undefined) }; return widget.render(item, context, DEFAULT_SETTINGS); @@ -83,6 +84,24 @@ describe('GitConflictsWidget', () => { expect(render({ rawValue: true })).toBe('0'); }); + it('hides zero conflicts when the zero state is enabled', () => { + mockExecFileSync.mockReturnValueOnce('true\n'); + mockExecFileSync.mockReturnValueOnce(''); + + 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')); + + expect(render({ hide: 'zero' })).toBe('⚠ 1'); + }); + it('renders the conflict count', () => { mockExecFileSync.mockReturnValueOnce('true\n'); mockExecFileSync.mockReturnValueOnce([ diff --git a/src/widgets/__tests__/GitDeletions.test.ts b/src/widgets/__tests__/GitDeletions.test.ts index 211ad231..51d9fb2e 100644 --- a/src/widgets/__tests__/GitDeletions.test.ts +++ b/src/widgets/__tests__/GitDeletions.test.ts @@ -29,6 +29,7 @@ const mockExecFileSync = execFileSync as unknown as { function render(options: { cwd?: string; + hide?: string; hideNoGit?: boolean; isPreview?: boolean; } = {}) { @@ -40,7 +41,7 @@ function render(options: { const item: WidgetItem = { id: 'git-deletions', type: 'git-deletions', - metadata: options.hideNoGit ? { hideNoGit: 'true' } : undefined + metadata: options.hide ? { hide: options.hide } : (options.hideNoGit ? { hide: 'no-git' } : undefined) }; return widget.render(item, context, DEFAULT_SETTINGS); @@ -75,6 +76,22 @@ describe('GitDeletionsWidget', () => { expect(render()).toBe('-0'); }); + it('should hide zero deletions when the zero state is enabled', () => { + mockExecFileSync.mockReturnValueOnce('true\n'); + mockExecFileSync.mockReturnValueOnce(''); + mockExecFileSync.mockReturnValueOnce(''); + + expect(render({ hide: 'zero' })).toBeNull(); + }); + + it('should keep non-zero deletions visible with the zero state enabled', () => { + mockExecFileSync.mockReturnValueOnce('true\n'); + mockExecFileSync.mockReturnValueOnce('1 file changed, 2 insertions(+), 1 deletion(-)'); + mockExecFileSync.mockReturnValueOnce(''); + + expect(render({ hide: 'zero' })).toBe('-1'); + }); + it('should render no git when probe returns false', () => { mockExecFileSync.mockReturnValue('false\n'); diff --git a/src/widgets/__tests__/GitInsertions.test.ts b/src/widgets/__tests__/GitInsertions.test.ts index 94a393bf..a7b57d6b 100644 --- a/src/widgets/__tests__/GitInsertions.test.ts +++ b/src/widgets/__tests__/GitInsertions.test.ts @@ -29,6 +29,7 @@ const mockExecFileSync = execFileSync as unknown as { function render(options: { cwd?: string; + hide?: string; hideNoGit?: boolean; isPreview?: boolean; } = {}) { @@ -40,7 +41,7 @@ function render(options: { const item: WidgetItem = { id: 'git-insertions', type: 'git-insertions', - metadata: options.hideNoGit ? { hideNoGit: 'true' } : undefined + metadata: options.hide ? { hide: options.hide } : (options.hideNoGit ? { hide: 'no-git' } : undefined) }; return widget.render(item, context, DEFAULT_SETTINGS); @@ -75,6 +76,22 @@ describe('GitInsertionsWidget', () => { expect(render()).toBe('+0'); }); + it('should hide zero insertions when the zero state is enabled', () => { + mockExecFileSync.mockReturnValueOnce('true\n'); + mockExecFileSync.mockReturnValueOnce(''); + mockExecFileSync.mockReturnValueOnce(''); + + expect(render({ hide: 'zero' })).toBeNull(); + }); + + it('should keep non-zero insertions visible with the zero state enabled', () => { + mockExecFileSync.mockReturnValueOnce('true\n'); + mockExecFileSync.mockReturnValueOnce('1 file changed, 2 insertions(+), 1 deletion(-)'); + mockExecFileSync.mockReturnValueOnce(''); + + expect(render({ hide: 'zero' })).toBe('+2'); + }); + it('should render no git when probe returns false', () => { mockExecFileSync.mockReturnValue('false\n'); diff --git a/src/widgets/__tests__/GitPr.test.ts b/src/widgets/__tests__/GitPr.test.ts index ace1261a..89f9356d 100644 --- a/src/widgets/__tests__/GitPr.test.ts +++ b/src/widgets/__tests__/GitPr.test.ts @@ -36,9 +36,7 @@ function createDeps(overrides: Partial = {}): GitPrWidgetDeps { function render( options: { cwd?: string; - hideNoGit?: boolean; - hideStatus?: boolean; - hideTitle?: boolean; + hide?: string; isPreview?: boolean; rawValue?: boolean; } = {}, @@ -50,12 +48,8 @@ function render( isPreview: options.isPreview }; const metadata: Record = {}; - if (options.hideNoGit) - metadata.hideNoGit = 'true'; - if (options.hideStatus) - metadata.hideStatus = 'true'; - if (options.hideTitle) - metadata.hideTitle = 'true'; + if (options.hide !== undefined) + metadata.hide = options.hide; const item: WidgetItem = { id: 'git-review', @@ -82,15 +76,15 @@ describe('GitPrWidget', () => { ); }); - it('should render preview without status when hideStatus enabled', () => { - const result = render({ isPreview: true, hideStatus: true }); + it('should render preview without status when the status state is hidden', () => { + const result = render({ isPreview: true, hide: 'status' }); expect(result).toBe( `${renderOsc8Link('https://github.com/owner/repo/pull/42', 'PR #42')} Example PR title` ); }); - it('should render preview without title when hideTitle enabled', () => { - const result = render({ isPreview: true, hideTitle: true }); + it('should render preview without title when the title state is hidden', () => { + const result = render({ isPreview: true, hide: 'title' }); expect(result).toBe( `${renderOsc8Link('https://github.com/owner/repo/pull/42', 'PR #42')} OPEN` ); @@ -107,8 +101,8 @@ describe('GitPrWidget', () => { expect(render({ cwd: '/tmp/not-a-repo' }, { isInsideGitWorkTree: () => false })).toBe('(no PR)'); }); - it('should return null when hideNoGit and not in git repo', () => { - expect(render({ cwd: '/tmp/not-a-repo', hideNoGit: true }, { isInsideGitWorkTree: () => false })).toBeNull(); + it('should return null when no-git is hidden and not in git repo', () => { + expect(render({ cwd: '/tmp/not-a-repo', hide: 'no-git' }, { isInsideGitWorkTree: () => false })).toBeNull(); }); it('should return (no PR) when PR lookup returns null', () => { @@ -118,6 +112,28 @@ describe('GitPrWidget', () => { })).toBe('(no PR)'); }); + it('should declare no-git, no-data, status, and title hideable states', () => { + expect(new GitPrWidget(createDeps()).getHideableStates().map(state => state.key)).toEqual([ + 'no-git', + 'no-data', + 'status', + 'title' + ]); + }); + + it('should hide segments via the unified hide metadata', () => { + expect(render({ isPreview: true, hide: 'status,title' })).toBe( + renderOsc8Link('https://github.com/owner/repo/pull/42', 'PR #42') + ); + }); + + it('should hide a missing PR via the no-data state', () => { + expect(render({ hide: 'no-data' }, { + fetchGitReviewData: () => null, + resolveGitCwd: () => undefined + })).toBeNull(); + }); + it('should use process cwd when repo paths are omitted', () => { const fetchGitReviewData = vi.fn(() => SAMPLE_PR); diff --git a/src/widgets/__tests__/GitRootDir.test.ts b/src/widgets/__tests__/GitRootDir.test.ts index 30dd101c..d548c82c 100644 --- a/src/widgets/__tests__/GitRootDir.test.ts +++ b/src/widgets/__tests__/GitRootDir.test.ts @@ -40,7 +40,7 @@ function render(options: { cwd?: string; hideNoGit?: boolean; isPreview?: boolea const item: WidgetItem = { id: 'git-root-dir', type: 'git-root-dir', - metadata: options.hideNoGit ? { hideNoGit: 'true' } : undefined + metadata: options.hideNoGit ? { hide: 'no-git' } : undefined }; return widget.render(item, context, DEFAULT_SETTINGS); diff --git a/src/widgets/__tests__/GitStagedFiles.test.ts b/src/widgets/__tests__/GitStagedFiles.test.ts index a68656fc..84c4830b 100644 --- a/src/widgets/__tests__/GitStagedFiles.test.ts +++ b/src/widgets/__tests__/GitStagedFiles.test.ts @@ -30,6 +30,7 @@ const widget = new GitStagedFilesWidget(); function render(options: { cwd?: string; + hide?: string; hideNoGit?: boolean; isPreview?: boolean; rawValue?: boolean; @@ -42,7 +43,7 @@ function render(options: { id: 'git-staged-files', type: 'git-staged-files', rawValue: options.rawValue, - metadata: options.hideNoGit ? { hideNoGit: 'true' } : undefined + metadata: options.hide ? { hide: options.hide } : (options.hideNoGit ? { hide: 'no-git' } : undefined) }; return widget.render(item, context, DEFAULT_SETTINGS); @@ -86,6 +87,20 @@ describe('GitStagedFilesWidget', () => { expect(render()).toBe('S:0'); }); + it('hides zero count when the zero state is enabled', () => { + mockExecFileSync.mockReturnValueOnce('true\n'); + mockExecFileSync.mockReturnValueOnce(''); + + expect(render({ hide: 'zero' })).toBeNull(); + }); + + it('keeps non-zero counts visible with the zero state enabled', () => { + mockExecFileSync.mockReturnValueOnce('true\n'); + mockExecFileSync.mockReturnValueOnce('M a.ts\0A b.ts\0'); + + expect(render({ hide: 'zero' })).toBe('S:2'); + }); + it('renders no git when probe returns false', () => { mockExecFileSync.mockReturnValue('false\n'); diff --git a/src/widgets/__tests__/GitUnstagedFiles.test.ts b/src/widgets/__tests__/GitUnstagedFiles.test.ts index 281fed12..1d210e60 100644 --- a/src/widgets/__tests__/GitUnstagedFiles.test.ts +++ b/src/widgets/__tests__/GitUnstagedFiles.test.ts @@ -30,6 +30,7 @@ const widget = new GitUnstagedFilesWidget(); function render(options: { cwd?: string; + hide?: string; hideNoGit?: boolean; isPreview?: boolean; rawValue?: boolean; @@ -42,7 +43,7 @@ function render(options: { id: 'git-unstaged-files', type: 'git-unstaged-files', rawValue: options.rawValue, - metadata: options.hideNoGit ? { hideNoGit: 'true' } : undefined + metadata: options.hide ? { hide: options.hide } : (options.hideNoGit ? { hide: 'no-git' } : undefined) }; return widget.render(item, context, DEFAULT_SETTINGS); @@ -85,6 +86,20 @@ describe('GitUnstagedFilesWidget', () => { expect(render()).toBe('M:0'); }); + it('hides zero count when the zero state is enabled', () => { + mockExecFileSync.mockReturnValueOnce('true\n'); + mockExecFileSync.mockReturnValueOnce(''); + + expect(render({ hide: 'zero' })).toBeNull(); + }); + + it('keeps non-zero counts visible with the zero state enabled', () => { + mockExecFileSync.mockReturnValueOnce('true\n'); + mockExecFileSync.mockReturnValueOnce(' M a.ts\0 D b.ts\0'); + + expect(render({ hide: 'zero' })).toBe('M:2'); + }); + it('renders no git when probe returns false', () => { mockExecFileSync.mockReturnValue('false\n'); diff --git a/src/widgets/__tests__/GitUntrackedFiles.test.ts b/src/widgets/__tests__/GitUntrackedFiles.test.ts index 2e7edf26..4b1a6248 100644 --- a/src/widgets/__tests__/GitUntrackedFiles.test.ts +++ b/src/widgets/__tests__/GitUntrackedFiles.test.ts @@ -30,6 +30,7 @@ const widget = new GitUntrackedFilesWidget(); function render(options: { cwd?: string; + hide?: string; hideNoGit?: boolean; isPreview?: boolean; rawValue?: boolean; @@ -42,7 +43,7 @@ function render(options: { id: 'git-untracked-files', type: 'git-untracked-files', rawValue: options.rawValue, - metadata: options.hideNoGit ? { hideNoGit: 'true' } : undefined + metadata: options.hide ? { hide: options.hide } : (options.hideNoGit ? { hide: 'no-git' } : undefined) }; return widget.render(item, context, DEFAULT_SETTINGS); @@ -85,6 +86,20 @@ describe('GitUntrackedFilesWidget', () => { expect(render()).toBe('?:0'); }); + it('hides zero count when the zero state is enabled', () => { + mockExecFileSync.mockReturnValueOnce('true\n'); + mockExecFileSync.mockReturnValueOnce(''); + + expect(render({ hide: 'zero' })).toBeNull(); + }); + + it('keeps non-zero counts visible with the zero state enabled', () => { + mockExecFileSync.mockReturnValueOnce('true\n'); + mockExecFileSync.mockReturnValueOnce('?? a.ts\0?? b.ts\0'); + + expect(render({ hide: 'zero' })).toBe('?:2'); + }); + it('renders no git when probe returns false', () => { mockExecFileSync.mockReturnValue('false\n'); diff --git a/src/widgets/__tests__/GitWidgetSharedBehavior.test.ts b/src/widgets/__tests__/GitWidgetSharedBehavior.test.ts index db5b27a8..d534c78c 100644 --- a/src/widgets/__tests__/GitWidgetSharedBehavior.test.ts +++ b/src/widgets/__tests__/GitWidgetSharedBehavior.test.ts @@ -5,7 +5,6 @@ import { } from 'vitest'; import type { - CustomKeybind, Widget, WidgetItem } from '../../types'; @@ -20,13 +19,9 @@ import { GitStagedFilesWidget } from '../GitStagedFiles'; import { GitUnstagedFilesWidget } from '../GitUnstagedFiles'; import { GitUntrackedFilesWidget } from '../GitUntrackedFiles'; import { GitWorktreeWidget } from '../GitWorktree'; +import { getEnabledHideStates } from '../shared/hideable'; -type GitWidget = Widget & { - getCustomKeybinds: () => CustomKeybind[]; - handleEditorAction: (action: string, item: WidgetItem) => WidgetItem | null; -}; - -const cases: { name: string; itemType: string; widget: GitWidget }[] = [ +const cases: { name: string; itemType: string; widget: Widget }[] = [ { name: 'GitBranchWidget', itemType: 'git-branch', widget: new GitBranchWidget() }, { name: 'GitChangesWidget', itemType: 'git-changes', widget: new GitChangesWidget() }, { name: 'GitInsertionsWidget', itemType: 'git-insertions', widget: new GitInsertionsWidget() }, @@ -41,28 +36,23 @@ const cases: { name: string; itemType: string; widget: GitWidget }[] = [ ]; describe('Git widget shared behavior', () => { - it.each(cases)('$name should expose hide-no-git keybind', ({ widget }) => { - expect(widget.getCustomKeybinds()).toContainEqual( - { key: 'h', label: '(h)ide \'no git\' message', action: 'toggle-nogit' } - ); + it.each(cases)('$name should declare the no-git hideable state', ({ widget }) => { + const states = widget.getHideableStates?.() ?? []; + expect(states.map(state => state.key)).toContain('no-git'); }); - it.each(cases)('$name should toggle hideNoGit metadata', ({ widget, itemType }) => { - const base: WidgetItem = { id: itemType, type: itemType }; - const toggledOn = widget.handleEditorAction('toggle-nogit', base); - const toggledOff = widget.handleEditorAction('toggle-nogit', toggledOn ?? base); - - expect(toggledOn?.metadata?.hideNoGit).toBe('true'); - expect(toggledOff?.metadata?.hideNoGit).toBe('false'); + it.each(cases)('$name should not declare per-widget hide keybinds', ({ widget }) => { + const keybinds = widget.getCustomKeybinds?.() ?? []; + expect(keybinds.find(kb => kb.key === 'h')).toBeUndefined(); }); - it.each(cases)('$name should show hide-no-git modifier in editor display', ({ widget, itemType }) => { - const display = widget.getEditorDisplay({ + it.each(cases)('$name should enable no-git via the unified hide metadata', ({ widget, itemType }) => { + const item: WidgetItem = { id: itemType, type: itemType, - metadata: { hideNoGit: 'true' } - }); + metadata: { hide: 'no-git' } + }; - expect(display.modifierText).toBe('(hide \'no git\')'); + expect(getEnabledHideStates(item, widget.getHideableStates?.() ?? [])).toContain('no-git'); }); }); diff --git a/src/widgets/__tests__/GitWorktree.test.ts b/src/widgets/__tests__/GitWorktree.test.ts index 5567fc63..1804a77a 100644 --- a/src/widgets/__tests__/GitWorktree.test.ts +++ b/src/widgets/__tests__/GitWorktree.test.ts @@ -43,7 +43,7 @@ function render(options: { id: 'git-worktree', type: 'git-worktree', rawValue: options.rawValue, - metadata: options.hideNoGit ? { hideNoGit: 'true' } : undefined + metadata: options.hideNoGit ? { hide: 'no-git' } : undefined }; return widget.render(item, context); diff --git a/src/widgets/__tests__/JjBookmarks.test.ts b/src/widgets/__tests__/JjBookmarks.test.ts index bceb8cd0..31560de3 100644 --- a/src/widgets/__tests__/JjBookmarks.test.ts +++ b/src/widgets/__tests__/JjBookmarks.test.ts @@ -36,7 +36,7 @@ function render(options: { id: 'jj-bookmarks', type: 'jj-bookmarks', rawValue: options.rawValue, - metadata: options.hideNoJj ? { hideNoJj: 'true' } : undefined + metadata: options.hideNoJj ? { hide: 'no-jj' } : undefined }; return widget.render(item, context, DEFAULT_SETTINGS); diff --git a/src/widgets/__tests__/JjChanges.test.ts b/src/widgets/__tests__/JjChanges.test.ts index 81c9f136..58f71bab 100644 --- a/src/widgets/__tests__/JjChanges.test.ts +++ b/src/widgets/__tests__/JjChanges.test.ts @@ -34,7 +34,7 @@ function render(options: { const item: WidgetItem = { id: 'jj-changes', type: 'jj-changes', - metadata: options.hideNoJj ? { hideNoJj: 'true' } : undefined + metadata: options.hideNoJj ? { hide: 'no-jj' } : undefined }; return widget.render(item, context, DEFAULT_SETTINGS); diff --git a/src/widgets/__tests__/JjDeletions.test.ts b/src/widgets/__tests__/JjDeletions.test.ts index 113800d9..f59ec298 100644 --- a/src/widgets/__tests__/JjDeletions.test.ts +++ b/src/widgets/__tests__/JjDeletions.test.ts @@ -33,7 +33,7 @@ function render(options: { const item: WidgetItem = { id: 'jj-deletions', type: 'jj-deletions', - metadata: options.hideNoJj ? { hideNoJj: 'true' } : undefined + metadata: options.hideNoJj ? { hide: 'no-jj' } : undefined }; return widget.render(item, context, DEFAULT_SETTINGS); diff --git a/src/widgets/__tests__/JjDescription.test.ts b/src/widgets/__tests__/JjDescription.test.ts index d12f7ed1..548b90d6 100644 --- a/src/widgets/__tests__/JjDescription.test.ts +++ b/src/widgets/__tests__/JjDescription.test.ts @@ -33,7 +33,7 @@ function render(options: { const item: WidgetItem = { id: 'jj-description', type: 'jj-description', - metadata: options.hideNoJj ? { hideNoJj: 'true' } : undefined + metadata: options.hideNoJj ? { hide: 'no-jj' } : undefined }; return widget.render(item, context, DEFAULT_SETTINGS); diff --git a/src/widgets/__tests__/JjInsertions.test.ts b/src/widgets/__tests__/JjInsertions.test.ts index 25bf9d2c..54712886 100644 --- a/src/widgets/__tests__/JjInsertions.test.ts +++ b/src/widgets/__tests__/JjInsertions.test.ts @@ -33,7 +33,7 @@ function render(options: { const item: WidgetItem = { id: 'jj-insertions', type: 'jj-insertions', - metadata: options.hideNoJj ? { hideNoJj: 'true' } : undefined + metadata: options.hideNoJj ? { hide: 'no-jj' } : undefined }; return widget.render(item, context, DEFAULT_SETTINGS); diff --git a/src/widgets/__tests__/JjRevision.test.ts b/src/widgets/__tests__/JjRevision.test.ts index 440ff844..12fea058 100644 --- a/src/widgets/__tests__/JjRevision.test.ts +++ b/src/widgets/__tests__/JjRevision.test.ts @@ -36,7 +36,7 @@ function render(options: { id: 'jj-revision', type: 'jj-revision', rawValue: options.rawValue, - metadata: options.hideNoJj ? { hideNoJj: 'true' } : undefined + metadata: options.hideNoJj ? { hide: 'no-jj' } : undefined }; return widget.render(item, context, DEFAULT_SETTINGS); diff --git a/src/widgets/__tests__/JjRootDir.test.ts b/src/widgets/__tests__/JjRootDir.test.ts index 9ef0c236..d79d67a3 100644 --- a/src/widgets/__tests__/JjRootDir.test.ts +++ b/src/widgets/__tests__/JjRootDir.test.ts @@ -34,7 +34,7 @@ function render(options: { const item: WidgetItem = { id: 'jj-root-dir', type: 'jj-root-dir', - metadata: options.hideNoJj ? { hideNoJj: 'true' } : undefined + metadata: options.hideNoJj ? { hide: 'no-jj' } : undefined }; return widget.render(item, context, DEFAULT_SETTINGS); diff --git a/src/widgets/__tests__/JjWidgetSharedBehavior.test.ts b/src/widgets/__tests__/JjWidgetSharedBehavior.test.ts index b225412b..ee6e4418 100644 --- a/src/widgets/__tests__/JjWidgetSharedBehavior.test.ts +++ b/src/widgets/__tests__/JjWidgetSharedBehavior.test.ts @@ -5,7 +5,6 @@ import { } from 'vitest'; import type { - CustomKeybind, Widget, WidgetItem } from '../../types'; @@ -17,13 +16,9 @@ import { JjInsertionsWidget } from '../JjInsertions'; import { JjRevisionWidget } from '../JjRevision'; import { JjRootDirWidget } from '../JjRootDir'; import { JjWorkspaceWidget } from '../JjWorkspace'; +import { getEnabledHideStates } from '../shared/hideable'; -type JjWidget = Widget & { - getCustomKeybinds: () => CustomKeybind[]; - handleEditorAction: (action: string, item: WidgetItem) => WidgetItem | null; -}; - -const cases: { name: string; itemType: string; widget: JjWidget }[] = [ +const cases: { name: string; itemType: string; widget: Widget }[] = [ { name: 'JjBookmarksWidget', itemType: 'jj-bookmarks', widget: new JjBookmarksWidget() }, { name: 'JjWorkspaceWidget', itemType: 'jj-workspace', widget: new JjWorkspaceWidget() }, { name: 'JjRootDirWidget', itemType: 'jj-root-dir', widget: new JjRootDirWidget() }, @@ -35,28 +30,23 @@ const cases: { name: string; itemType: string; widget: JjWidget }[] = [ ]; describe('JJ widget shared behavior', () => { - it.each(cases)('$name should expose hide-no-jj keybind', ({ widget }) => { - expect(widget.getCustomKeybinds()).toContainEqual( - { key: 'h', label: '(h)ide \'no jj\' message', action: 'toggle-nojj' } - ); + it.each(cases)('$name should declare the no-jj hideable state', ({ widget }) => { + const states = widget.getHideableStates?.() ?? []; + expect(states.map(state => state.key)).toContain('no-jj'); }); - it.each(cases)('$name should toggle hideNoJj metadata', ({ widget, itemType }) => { - const base: WidgetItem = { id: itemType, type: itemType }; - const toggledOn = widget.handleEditorAction('toggle-nojj', base); - const toggledOff = widget.handleEditorAction('toggle-nojj', toggledOn ?? base); - - expect(toggledOn?.metadata?.hideNoJj).toBe('true'); - expect(toggledOff?.metadata?.hideNoJj).toBe('false'); + it.each(cases)('$name should not declare per-widget hide keybinds', ({ widget }) => { + const keybinds = widget.getCustomKeybinds?.() ?? []; + expect(keybinds.find(kb => kb.key === 'h')).toBeUndefined(); }); - it.each(cases)('$name should show hide-no-jj modifier in editor display', ({ widget, itemType }) => { - const display = widget.getEditorDisplay({ + it.each(cases)('$name should enable no-jj via the unified hide metadata', ({ widget, itemType }) => { + const item: WidgetItem = { id: itemType, type: itemType, - metadata: { hideNoJj: 'true' } - }); + metadata: { hide: 'no-jj' } + }; - expect(display.modifierText).toBe('(hide \'no jj\')'); + expect(getEnabledHideStates(item, widget.getHideableStates?.() ?? [])).toContain('no-jj'); }); }); diff --git a/src/widgets/__tests__/JjWorkspace.test.ts b/src/widgets/__tests__/JjWorkspace.test.ts index fa0a4505..2d5a2a5a 100644 --- a/src/widgets/__tests__/JjWorkspace.test.ts +++ b/src/widgets/__tests__/JjWorkspace.test.ts @@ -36,7 +36,7 @@ function render(options: { id: 'jj-workspace', type: 'jj-workspace', rawValue: options.rawValue, - metadata: options.hideNoJj ? { hideNoJj: 'true' } : undefined + metadata: options.hideNoJj ? { hide: 'no-jj' } : undefined }; return widget.render(item, context, DEFAULT_SETTINGS); diff --git a/src/widgets/__tests__/OutputStyle.test.ts b/src/widgets/__tests__/OutputStyle.test.ts new file mode 100644 index 00000000..63c1cfaf --- /dev/null +++ b/src/widgets/__tests__/OutputStyle.test.ts @@ -0,0 +1,50 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { + RenderContext, + WidgetItem +} from '../../types'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import { OutputStyleWidget } from '../OutputStyle'; + +function render(item: WidgetItem, context: RenderContext = {}): string | null { + const widget = new OutputStyleWidget(); + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('OutputStyleWidget', () => { + it('renders the output style from status JSON', () => { + expect(render( + { id: 'output-style', type: 'output-style' }, + { data: { output_style: { name: 'Explanatory' } } } + )).toBe('Style: Explanatory'); + }); + + it('renders nothing when output style data is missing', () => { + expect(render({ id: 'output-style', type: 'output-style' }, {})).toBeNull(); + }); + + it('declares the default-value hideable state', () => { + expect(new OutputStyleWidget().getHideableStates().map(state => state.key)).toEqual(['default-value']); + }); + + it('hides the default style only when the default-value hide state is enabled', () => { + const context: RenderContext = { data: { output_style: { name: 'default' } } }; + + expect(render({ id: 'output-style', type: 'output-style' }, context)).toBe('Style: default'); + expect(render({ + id: 'output-style', + type: 'output-style', + metadata: { hide: 'default-value' } + }, context)).toBeNull(); + expect(render({ + id: 'output-style', + type: 'output-style', + metadata: { hide: 'default-value' } + }, { data: { output_style: { name: 'Explanatory' } } })).toBe('Style: Explanatory'); + }); +}); diff --git a/src/widgets/__tests__/SessionClock.test.ts b/src/widgets/__tests__/SessionClock.test.ts index 52c78bf5..20a60b69 100644 --- a/src/widgets/__tests__/SessionClock.test.ts +++ b/src/widgets/__tests__/SessionClock.test.ts @@ -37,4 +37,32 @@ describe('SessionClockWidget', () => { { sessionDuration: '3hr 20m' } )).toBe('Session: 3hr 20m'); }); + + it('declares the zero hideable state', () => { + expect(new SessionClockWidget().getHideableStates().map(state => state.key)).toEqual(['zero']); + }); + + it('hides sub-minute durations only when the zero hide state is enabled', () => { + const context: RenderContext = { data: { cost: { total_duration_ms: 30 * 1000 } } }; + + expect(render({ id: 'session-clock', type: 'session-clock' }, context)).toBe('Session: <1m'); + expect(render({ + id: 'session-clock', + type: 'session-clock', + metadata: { hide: 'zero' } + }, context)).toBeNull(); + expect(render({ + id: 'session-clock', + type: 'session-clock', + metadata: { hide: 'zero' } + }, { data: { cost: { total_duration_ms: 90 * 1000 } } })).toBe('Session: 1m'); + }); + + it('hides the 0m fallback duration when the zero hide state is enabled', () => { + expect(render({ + id: 'session-clock', + type: 'session-clock', + metadata: { hide: 'zero' } + }, {})).toBeNull(); + }); }); diff --git a/src/widgets/__tests__/SessionCost.test.ts b/src/widgets/__tests__/SessionCost.test.ts new file mode 100644 index 00000000..695e0389 --- /dev/null +++ b/src/widgets/__tests__/SessionCost.test.ts @@ -0,0 +1,58 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { + RenderContext, + WidgetItem +} from '../../types'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import { SessionCostWidget } from '../SessionCost'; + +function render(item: WidgetItem, context: RenderContext = {}): string | null { + const widget = new SessionCostWidget(); + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('SessionCostWidget', () => { + it('renders the session cost from status JSON', () => { + expect(render( + { id: 'session-cost', type: 'session-cost' }, + { data: { cost: { total_cost_usd: 2.456 } } } + )).toBe('Cost: $2.46'); + }); + + it('renders nothing when cost data is missing', () => { + expect(render({ id: 'session-cost', type: 'session-cost' }, {})).toBeNull(); + }); + + it('declares the zero hideable state', () => { + expect(new SessionCostWidget().getHideableStates().map(state => state.key)).toEqual(['zero']); + }); + + it('hides $0.00 only when the zero hide state is enabled', () => { + const context: RenderContext = { data: { cost: { total_cost_usd: 0 } } }; + + expect(render({ id: 'session-cost', type: 'session-cost' }, context)).toBe('Cost: $0.00'); + expect(render({ + id: 'session-cost', + type: 'session-cost', + metadata: { hide: 'zero' } + }, context)).toBeNull(); + expect(render({ + id: 'session-cost', + type: 'session-cost', + metadata: { hide: 'zero' } + }, { data: { cost: { total_cost_usd: 0.01 } } })).toBe('Cost: $0.01'); + }); + + it('treats sub-cent costs that display as $0.00 as zero', () => { + expect(render({ + id: 'session-cost', + type: 'session-cost', + metadata: { hide: 'zero' } + }, { data: { cost: { total_cost_usd: 0.001 } } })).toBeNull(); + }); +}); diff --git a/src/widgets/__tests__/Skills.test.ts b/src/widgets/__tests__/Skills.test.ts index 40276ae9..b3c6b5e8 100644 --- a/src/widgets/__tests__/Skills.test.ts +++ b/src/widgets/__tests__/Skills.test.ts @@ -19,8 +19,7 @@ describe('SkillsWidget', () => { it('uses v as the mode toggle keybind', () => { const widget = new SkillsWidget(); expect(widget.getCustomKeybinds({ id: 'skills', type: 'skills' })).toEqual([ - { key: 'v', label: '(v)iew: last/count/list', action: 'cycle-mode' }, - { key: 'h', label: '(h)ide when empty', action: 'toggle-hide-empty' } + { key: 'v', label: '(v)iew: last/count/list', action: 'cycle-mode' } ]); expect(widget.getCustomKeybinds({ id: 'skills', @@ -28,7 +27,6 @@ describe('SkillsWidget', () => { metadata: { mode: 'list' } })).toEqual([ { key: 'v', label: '(v)iew: last/count/list', action: 'cycle-mode' }, - { key: 'h', label: '(h)ide when empty', action: 'toggle-hide-empty' }, { key: 'l', label: '(l)imit', action: 'edit-list-limit' } ]); }); @@ -60,25 +58,10 @@ describe('SkillsWidget', () => { expect(updated?.metadata?.listLimit).toBeUndefined(); }); - it('toggles hide-when-empty metadata', () => { - const widget = new SkillsWidget(); - const base: WidgetItem = { id: 'skills', type: 'skills' }; - const hidden = widget.handleEditorAction('toggle-hide-empty', base); - const shown = widget.handleEditorAction('toggle-hide-empty', hidden ?? base); - - expect(hidden?.metadata?.hideWhenEmpty).toBe('true'); - expect(shown?.metadata?.hideWhenEmpty).toBe('false'); - }); - - it('shows hide-when-empty in editor modifier text when enabled', () => { + it('declares the empty hideable state', () => { const widget = new SkillsWidget(); - const display = widget.getEditorDisplay({ - id: 'skills', - type: 'skills', - metadata: { hideWhenEmpty: 'true' } - }); - expect(display.modifierText).toBe('(last used, hide when empty)'); + expect(widget.getHideableStates().map(state => state.key)).toEqual(['empty']); }); it('shows list limit in editor modifier text when configured', () => { @@ -134,17 +117,17 @@ describe('SkillsWidget', () => { expect(render({ id: 'skills', type: 'skills', - metadata: { hideWhenEmpty: 'true' } + metadata: { hide: 'empty' } }, context)).toBeNull(); expect(render({ id: 'skills', type: 'skills', - metadata: { mode: 'count', hideWhenEmpty: 'true' } + metadata: { mode: 'count', hide: 'empty' } }, context)).toBeNull(); expect(render({ id: 'skills', type: 'skills', - metadata: { mode: 'list', hideWhenEmpty: 'true' } + metadata: { mode: 'list', hide: 'empty' } }, context)).toBeNull(); }); }); diff --git a/src/widgets/__tests__/SpeedWidgets.test.ts b/src/widgets/__tests__/SpeedWidgets.test.ts index 9c77d968..18c3c0d2 100644 --- a/src/widgets/__tests__/SpeedWidgets.test.ts +++ b/src/widgets/__tests__/SpeedWidgets.test.ts @@ -86,6 +86,22 @@ describe('OutputSpeedWidget', () => { }); }); +describe('speed widget hideable states', () => { + it('should declare the no-data hideable state for all speed widgets', () => { + for (const widget of [new InputSpeedWidget(), new OutputSpeedWidget(), new TotalSpeedWidget()]) { + expect(widget.getHideableStates().map(state => state.key)).toEqual(['no-data']); + } + }); + + it('should render the em dash placeholder unless no-data hiding is enabled', () => { + const widget = new OutputSpeedWidget(); + const context: RenderContext = { speedMetrics: createSpeedMetrics({ totalDurationMs: 0 }) }; + + expect(widget.render(createItem('output-speed'), context, DEFAULT_SETTINGS)).toBe('Out: —'); + expect(widget.render(createItem('output-speed', { metadata: { hide: 'no-data' } }), context, DEFAULT_SETTINGS)).toBeNull(); + }); +}); + describe('InputSpeedWidget', () => { const widget = new InputSpeedWidget(); diff --git a/src/widgets/__tests__/TokensWidgets.test.ts b/src/widgets/__tests__/TokensWidgets.test.ts index ea1bc0ea..bdfde1cd 100644 --- a/src/widgets/__tests__/TokensWidgets.test.ts +++ b/src/widgets/__tests__/TokensWidgets.test.ts @@ -138,6 +138,33 @@ describe('Token widgets', () => { expect(new TokensTotalWidget().render({ id: 'total', type: 'tokens-total', rawValue: true }, context, DEFAULT_SETTINGS)).toBe('fmt:5160'); }); + it('hides zero counts only when the zero hide state is enabled', async () => { + const { TokensCachedWidget, TokensInputWidget, TokensOutputWidget, TokensTotalWidget } = await loadWidgets(); + const context: RenderContext = { + tokenMetrics: { + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + totalTokens: 0, + contextLength: 0 + } + }; + + expect(new TokensInputWidget().render({ id: 'in', type: 'tokens-input' }, context, DEFAULT_SETTINGS)).toBe('In: fmt:0'); + expect(new TokensInputWidget().render({ id: 'in', type: 'tokens-input', metadata: { hide: 'zero' } }, context, DEFAULT_SETTINGS)).toBeNull(); + expect(new TokensOutputWidget().render({ id: 'out', type: 'tokens-output', metadata: { hide: 'zero' } }, context, DEFAULT_SETTINGS)).toBeNull(); + expect(new TokensCachedWidget().render({ id: 'cached', type: 'tokens-cached', metadata: { hide: 'zero' } }, context, DEFAULT_SETTINGS)).toBeNull(); + expect(new TokensTotalWidget().render({ id: 'total', type: 'tokens-total', metadata: { hide: 'zero' } }, context, DEFAULT_SETTINGS)).toBeNull(); + }); + + it('declares the zero hideable state for all token widgets', async () => { + const { TokensCachedWidget, TokensInputWidget, TokensOutputWidget, TokensTotalWidget } = await loadWidgets(); + + for (const widget of [new TokensInputWidget(), new TokensOutputWidget(), new TokensCachedWidget(), new TokensTotalWidget()]) { + expect(widget.getHideableStates().map(state => state.key)).toEqual(['zero']); + } + }); + it('renders expected preview labels and raw values for all token widgets', async () => { const { TokensCachedWidget, TokensInputWidget, TokensOutputWidget, TokensTotalWidget } = await loadWidgets(); const context: RenderContext = { isPreview: true }; diff --git a/src/widgets/__tests__/helpers/usage-widget-suites.ts b/src/widgets/__tests__/helpers/usage-widget-suites.ts index e4d4be19..fbb1b54d 100644 --- a/src/widgets/__tests__/helpers/usage-widget-suites.ts +++ b/src/widgets/__tests__/helpers/usage-widget-suites.ts @@ -129,6 +129,16 @@ export function runUsagePercentWidgetSuite(conf expect(config.render(widget, config.baseItem, { usageData: { error: 'timeout' } })).toBe('[Timeout]'); }); + it('hides usage error text when the no-data state is enabled', () => { + const widget = config.createWidget(); + + config.errorMessageMock.mockReturnValue('[Timeout]'); + expect(config.render(widget, { + ...config.baseItem, + metadata: { hide: 'no-data' } + }, { usageData: { error: 'timeout' } })).toBeNull(); + }); + it('renders available usage data before unrelated usage errors', () => { const widget = config.createWidget(); const context: RenderContext = { diff --git a/src/widgets/shared/__tests__/hideable.test.ts b/src/widgets/shared/__tests__/hideable.test.ts new file mode 100644 index 00000000..07fe338c --- /dev/null +++ b/src/widgets/shared/__tests__/hideable.test.ts @@ -0,0 +1,126 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { + HideableState, + WidgetItem +} from '../../../types/Widget'; +import { + getEnabledHideStates, + getHideKeybind, + getHideModifierText, + isHidden, + parseHideStates, + setEnabledHideStates +} from '../hideable'; + +const STATES: HideableState[] = [ + { key: 'no-git', label: 'when not in a git repo' }, + { key: 'zero', label: 'when count is zero' } +]; + +const STATES_WITH_DEFAULT: HideableState[] = [ + { key: 'no-git', label: 'when not in a git repo' }, + { key: 'zero', label: 'when not diverged', defaultEnabled: true } +]; + +function makeItem(metadata?: Record): WidgetItem { + return { id: 'item', type: 'git-branch', metadata }; +} + +describe('parseHideStates', () => { + it('returns an empty list for undefined', () => { + expect(parseHideStates(undefined)).toEqual([]); + }); + + it('returns an empty list for an empty string', () => { + expect(parseHideStates('')).toEqual([]); + }); + + it('splits comma-separated keys and trims whitespace', () => { + expect(parseHideStates('no-git, zero ,empty')).toEqual(['no-git', 'zero', 'empty']); + }); +}); + +describe('isHidden', () => { + it('returns false when no hide metadata exists', () => { + expect(isHidden(makeItem(), 'no-git')).toBe(false); + }); + + it('returns true for keys in the hide list', () => { + const item = makeItem({ hide: 'no-git,zero' }); + expect(isHidden(item, 'no-git')).toBe(true); + expect(isHidden(item, 'zero')).toBe(true); + expect(isHidden(item, 'empty')).toBe(false); + }); + + it('applies the default when the hide key is absent', () => { + expect(isHidden(makeItem(), 'zero', true)).toBe(true); + expect(isHidden(makeItem(), 'zero', false)).toBe(false); + }); + + it('treats a present hide list as authoritative over defaults', () => { + expect(isHidden(makeItem({ hide: '' }), 'zero', true)).toBe(false); + expect(isHidden(makeItem({ hide: 'no-git' }), 'zero', true)).toBe(false); + }); +}); + +describe('getEnabledHideStates', () => { + it('returns enabled keys in declaration order', () => { + const item = makeItem({ hide: 'zero,no-git' }); + expect(getEnabledHideStates(item, STATES)).toEqual(['no-git', 'zero']); + }); + + it('includes default-enabled states when hide metadata is absent', () => { + expect(getEnabledHideStates(makeItem(), STATES_WITH_DEFAULT)).toEqual(['zero']); + }); +}); + +describe('setEnabledHideStates', () => { + it('writes the enabled keys as a comma-separated list', () => { + const updated = setEnabledHideStates(makeItem(), STATES, ['zero', 'no-git']); + expect(updated.metadata?.hide).toBe('no-git,zero'); + }); + + it('replaces an existing hide list while preserving other metadata', () => { + const item = makeItem({ + hide: 'zero', + mode: 'list' + }); + const updated = setEnabledHideStates(item, STATES, ['no-git']); + + expect(updated.metadata).toEqual({ hide: 'no-git', mode: 'list' }); + }); + + it('omits the hide key when the enabled set matches the defaults', () => { + const allOff = setEnabledHideStates(makeItem({ hide: 'no-git' }), STATES, []); + expect(allOff.metadata).toBeUndefined(); + + const defaultOn = setEnabledHideStates(makeItem({ hide: '' }), STATES_WITH_DEFAULT, ['zero']); + expect(defaultOn.metadata).toBeUndefined(); + }); + + it('writes an empty hide list to opt out of default-enabled states', () => { + const updated = setEnabledHideStates(makeItem(), STATES_WITH_DEFAULT, []); + expect(updated.metadata?.hide).toBe(''); + }); + + it('ignores enabled keys that are not declared by the widget', () => { + const updated = setEnabledHideStates(makeItem(), STATES, ['no-git', 'bogus']); + expect(updated.metadata?.hide).toBe('no-git'); + }); +}); + +describe('editor helpers', () => { + it('uses h for the shared hide keybind', () => { + expect(getHideKeybind()).toEqual({ key: 'h', label: '(h)ide…', action: 'edit-hide-states' }); + }); + + it('formats the hide modifier text from enabled state keys', () => { + expect(getHideModifierText(makeItem({ hide: 'no-git,zero' }), STATES)).toBe('(hide: no-git, zero)'); + expect(getHideModifierText(makeItem(), STATES)).toBeUndefined(); + }); +}); diff --git a/src/widgets/shared/cache-scope.ts b/src/widgets/shared/cache-scope.ts index f0fa322e..852bbf0e 100644 --- a/src/widgets/shared/cache-scope.ts +++ b/src/widgets/shared/cache-scope.ts @@ -1,5 +1,6 @@ import type { CustomKeybind, + HideableState, WidgetItem } from '../../types/Widget'; @@ -10,16 +11,14 @@ import { } from './metadata'; const SCOPE_SESSION_KEY = 'cacheScopeSession'; -const HIDE_WHEN_EMPTY_KEY = 'hideWhenEmpty'; const TOGGLE_CACHE_SCOPE_ACTION = 'toggle-cache-scope'; -const TOGGLE_HIDE_EMPTY_ACTION = 'toggle-hide-empty'; const CACHE_SCOPE_KEYBIND: CustomKeybind = { key: 't', label: '(t)urn/session', action: TOGGLE_CACHE_SCOPE_ACTION }; -const HIDE_WHEN_EMPTY_KEYBIND: CustomKeybind = { - key: 'h', - label: '(h)ide when empty', - action: TOGGLE_HIDE_EMPTY_ACTION -}; + +// Shared hideable state for cache widgets: hide when there is no cache +// activity. Hiding is handled by the unified hideable-state system; the +// per-turn/session scope toggle below is a separate display option. +export const CACHE_EMPTY_HIDEABLE_STATE: HideableState = { key: 'empty', label: 'when there is no cache activity' }; // Cache widgets default to per-turn ("last action") scope. When this flag is // enabled the widget reports cumulative session totals instead. @@ -27,18 +26,11 @@ export function isCacheSessionScope(item: WidgetItem): boolean { return isMetadataFlagEnabled(item, SCOPE_SESSION_KEY); } -export function isCacheHideWhenEmptyEnabled(item: WidgetItem): boolean { - return isMetadataFlagEnabled(item, HIDE_WHEN_EMPTY_KEY); -} - export function getCacheModifierText(item: WidgetItem): string | undefined { const modifiers: string[] = []; if (isCacheSessionScope(item)) { modifiers.push('session'); } - if (isCacheHideWhenEmptyEnabled(item)) { - modifiers.push('hide when empty'); - } return makeModifierText(modifiers); } @@ -52,10 +44,6 @@ export function handleCacheOptionsAction(action: string, item: WidgetItem): Widg return toggleMetadataFlag(item, SCOPE_SESSION_KEY); } - if (action === TOGGLE_HIDE_EMPTY_ACTION) { - return toggleMetadataFlag(item, HIDE_WHEN_EMPTY_KEY); - } - return null; } @@ -64,7 +52,7 @@ export function handleCacheScopeAction(action: string, item: WidgetItem): Widget } export function getCacheKeybinds(): CustomKeybind[] { - return [CACHE_SCOPE_KEYBIND, HIDE_WHEN_EMPTY_KEYBIND]; + return [CACHE_SCOPE_KEYBIND]; } export function getCacheScopeKeybind(): CustomKeybind { diff --git a/src/widgets/shared/extra-usage-disabled.ts b/src/widgets/shared/extra-usage-disabled.ts index 2a513c72..b596446f 100644 --- a/src/widgets/shared/extra-usage-disabled.ts +++ b/src/widgets/shared/extra-usage-disabled.ts @@ -1,46 +1,3 @@ -import type { - CustomKeybind, - WidgetItem -} from '../../types/Widget'; +import type { HideableState } from '../../types/Widget'; -import { - isMetadataFlagEnabled, - toggleMetadataFlag -} from './metadata'; - -const HIDE_DISABLED_KEY = 'hideIfDisabled'; -const TOGGLE_HIDE_DISABLED_ACTION = 'toggle-hide-disabled'; - -const HIDE_DISABLED_KEYBIND: CustomKeybind = { - key: 'h', - label: '(h)ide if disabled', - action: TOGGLE_HIDE_DISABLED_ACTION -}; - -export function isHideExtraUsageDisabledEnabled(item: WidgetItem): boolean { - return isMetadataFlagEnabled(item, HIDE_DISABLED_KEY); -} - -export function handleToggleExtraUsageDisabledAction(action: string, item: WidgetItem): WidgetItem | null { - if (action !== TOGGLE_HIDE_DISABLED_ACTION) { - return null; - } - - return toggleMetadataFlag(item, HIDE_DISABLED_KEY); -} - -export function getHideExtraUsageDisabledKeybind(): CustomKeybind { - return HIDE_DISABLED_KEYBIND; -} - -export function appendHideDisabledModifier(modifierText: string | undefined, item: WidgetItem): string | undefined { - if (!isHideExtraUsageDisabledEnabled(item)) { - return modifierText; - } - - if (!modifierText) { - return '(hide if disabled)'; - } - - return `${modifierText.slice(0, -1)}, hide if disabled)`; -} +export const EXTRA_USAGE_DISABLED_HIDEABLE_STATE: HideableState = { key: 'disabled', label: 'when extra usage is disabled' }; diff --git a/src/widgets/shared/git-no-git.ts b/src/widgets/shared/git-no-git.ts deleted file mode 100644 index 56ab89be..00000000 --- a/src/widgets/shared/git-no-git.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { - CustomKeybind, - WidgetItem -} from '../../types/Widget'; - -import { makeModifierText } from './editor-display'; -import { - isMetadataFlagEnabled, - toggleMetadataFlag -} from './metadata'; - -const HIDE_NO_GIT_KEY = 'hideNoGit'; -const TOGGLE_NO_GIT_ACTION = 'toggle-nogit'; - -const HIDE_NO_GIT_KEYBIND: CustomKeybind = { - key: 'h', - label: '(h)ide \'no git\' message', - action: TOGGLE_NO_GIT_ACTION -}; - -export function isHideNoGitEnabled(item: WidgetItem): boolean { - return isMetadataFlagEnabled(item, HIDE_NO_GIT_KEY); -} - -export function getHideNoGitModifierText(item: WidgetItem): string | undefined { - return makeModifierText(isHideNoGitEnabled(item) ? ['hide \'no git\''] : []); -} - -export function handleToggleNoGitAction(action: string, item: WidgetItem): WidgetItem | null { - if (action !== TOGGLE_NO_GIT_ACTION) { - return null; - } - - return toggleMetadataFlag(item, HIDE_NO_GIT_KEY); -} - -export function getHideNoGitKeybinds(): CustomKeybind[] { - return [HIDE_NO_GIT_KEYBIND]; -} diff --git a/src/widgets/shared/git-remote.ts b/src/widgets/shared/git-remote.ts index 149021ab..221e401a 100644 --- a/src/widgets/shared/git-remote.ts +++ b/src/widgets/shared/git-remote.ts @@ -9,49 +9,24 @@ import { toggleMetadataFlag } from './metadata'; -const HIDE_NO_REMOTE_KEY = 'hideNoRemote'; -const TOGGLE_NO_REMOTE_ACTION = 'toggle-no-remote'; - const LINK_TO_REPO_KEY = 'linkToRepo'; const TOGGLE_LINK_ACTION = 'toggle-link'; -const HIDE_NO_REMOTE_KEYBIND: CustomKeybind = { - key: 'h', - label: '(h)ide when no remote', - action: TOGGLE_NO_REMOTE_ACTION -}; - const LINK_TO_REPO_KEYBIND: CustomKeybind = { key: 'l', label: '(l)ink to repo', action: TOGGLE_LINK_ACTION }; -export function isHideNoRemoteEnabled(item: WidgetItem): boolean { - return isMetadataFlagEnabled(item, HIDE_NO_REMOTE_KEY); -} - export function isLinkToRepoEnabled(item: WidgetItem): boolean { return isMetadataFlagEnabled(item, LINK_TO_REPO_KEY); } export function getRemoteWidgetModifierText(item: WidgetItem): string | undefined { - const modifiers: string[] = []; - - if (isHideNoRemoteEnabled(item)) { - modifiers.push('hide when empty'); - } - if (isLinkToRepoEnabled(item)) { - modifiers.push('link'); - } - - return makeModifierText(modifiers); + return makeModifierText(isLinkToRepoEnabled(item) ? ['link'] : []); } export function handleRemoteWidgetAction(action: string, item: WidgetItem): WidgetItem | null { - if (action === TOGGLE_NO_REMOTE_ACTION) { - return toggleMetadataFlag(item, HIDE_NO_REMOTE_KEY); - } if (action === TOGGLE_LINK_ACTION) { return toggleMetadataFlag(item, LINK_TO_REPO_KEY); } @@ -60,9 +35,5 @@ export function handleRemoteWidgetAction(action: string, item: WidgetItem): Widg } export function getRemoteWidgetKeybinds(): CustomKeybind[] { - return [HIDE_NO_REMOTE_KEYBIND, LINK_TO_REPO_KEYBIND]; -} - -export function getHideNoRemoteKeybinds(): CustomKeybind[] { - return [HIDE_NO_REMOTE_KEYBIND]; + return [LINK_TO_REPO_KEYBIND]; } diff --git a/src/widgets/shared/hideable.ts b/src/widgets/shared/hideable.ts new file mode 100644 index 00000000..fa2e193d --- /dev/null +++ b/src/widgets/shared/hideable.ts @@ -0,0 +1,94 @@ +import type { + CustomKeybind, + HideableState, + WidgetItem +} from '../../types/Widget'; + +import { removeMetadataKeys } from './metadata'; + +const HIDE_METADATA_KEY = 'hide'; +export const EDIT_HIDE_STATES_ACTION = 'edit-hide-states'; + +const HIDE_KEYBIND: CustomKeybind = { + key: 'h', + label: '(h)ide…', + action: EDIT_HIDE_STATES_ACTION +}; + +// States shared verbatim by several widgets +export const NO_GIT_HIDEABLE_STATE: HideableState = { key: 'no-git', label: 'when not in a git repo' }; +export const NO_JJ_HIDEABLE_STATE: HideableState = { key: 'no-jj', label: 'when not in a jj repo' }; +export const NO_REMOTE_HIDEABLE_STATE: HideableState = { key: 'no-remote', label: 'when there is no remote' }; +export const NO_UPSTREAM_HIDEABLE_STATE: HideableState = { key: 'no-upstream', label: 'when there is no upstream' }; +export const MERGE_TARGET_HIDDEN_HIDEABLE_STATE: HideableState = { key: 'merge-target-hidden', label: 'when merge target is hidden' }; + +export function parseHideStates(value: string | undefined): string[] { + if (value === undefined) { + return []; + } + + return value + .split(',') + .map(key => key.trim()) + .filter(key => key.length > 0); +} + +/** + * Returns whether a hideable state is enabled for an item. + * + * The metadata.hide list is authoritative when present; otherwise the + * widget-declared default applies. Pre-v4 per-widget boolean flags are + * converted to this key by the v3 -> v4 settings migration. + */ +export function isHidden(item: WidgetItem, key: string, defaultEnabled = false): boolean { + const hideValue = item.metadata?.[HIDE_METADATA_KEY]; + if (hideValue === undefined) { + return defaultEnabled; + } + + return parseHideStates(hideValue).includes(key); +} + +export function getEnabledHideStates(item: WidgetItem, states: HideableState[]): string[] { + return states + .filter(state => isHidden(item, state.key, state.defaultEnabled ?? false)) + .map(state => state.key); +} + +/** + * Writes the canonical hide list for an item; the hide key is omitted when the + * enabled set matches the widget's defaults (so untouched items keep minimal + * metadata). + */ +export function setEnabledHideStates(item: WidgetItem, states: HideableState[], enabledKeys: string[]): WidgetItem { + const orderedEnabled = states + .filter(state => enabledKeys.includes(state.key)) + .map(state => state.key); + const defaults = states + .filter(state => state.defaultEnabled) + .map(state => state.key); + const matchesDefaults = orderedEnabled.length === defaults.length + && orderedEnabled.every(key => defaults.includes(key)); + + const cleaned = removeMetadataKeys(item, [HIDE_METADATA_KEY]); + if (matchesDefaults) { + return cleaned; + } + + return { + ...cleaned, + metadata: { + ...cleaned.metadata, + [HIDE_METADATA_KEY]: orderedEnabled.join(',') + } + }; +} + +export function getHideKeybind(): CustomKeybind { + return HIDE_KEYBIND; +} + +export function getHideModifierText(item: WidgetItem, states: HideableState[]): string | undefined { + const enabled = getEnabledHideStates(item, states); + return enabled.length > 0 ? `(hide: ${enabled.join(', ')})` : undefined; +} diff --git a/src/widgets/shared/speed-widget.tsx b/src/widgets/shared/speed-widget.tsx index efaf313f..9a63b2c3 100644 --- a/src/widgets/shared/speed-widget.tsx +++ b/src/widgets/shared/speed-widget.tsx @@ -9,6 +9,7 @@ import type { RenderContext } from '../../types/RenderContext'; import type { SpeedMetrics } from '../../types/SpeedMetrics'; import type { CustomKeybind, + HideableState, WidgetEditorDisplay, WidgetEditorProps, WidgetItem @@ -30,12 +31,15 @@ import { } from '../../utils/speed-window'; import { makeModifierText } from './editor-display'; +import { isHidden } from './hideable'; import { formatRawOrLabeledValue } from './raw-or-labeled'; export type SpeedWidgetKind = 'input' | 'output' | 'total'; const WINDOW_EDITOR_ACTION = 'edit-window'; +const NO_DATA_HIDEABLE_STATE: HideableState = { key: 'no-data', label: 'when there is no speed data (—)' }; + interface SpeedWidgetKindConfig { label: string; displayName: string; @@ -127,9 +131,17 @@ export function renderSpeedWidgetValue( } const speed = calculateSpeed(kind, metrics); + if (speed === null && isHidden(item, NO_DATA_HIDEABLE_STATE.key)) { + return null; + } + return formatRawOrLabeledValue(item, config.label, formatSpeed(speed)); } +export function getSpeedWidgetHideableStates(): HideableState[] { + return [NO_DATA_HIDEABLE_STATE]; +} + export function getSpeedWidgetCustomKeybinds(): CustomKeybind[] { return [{ key: 'w', diff --git a/src/widgets/shared/usage-display.ts b/src/widgets/shared/usage-display.ts index ae84d5f1..c97f33a1 100644 --- a/src/widgets/shared/usage-display.ts +++ b/src/widgets/shared/usage-display.ts @@ -1,5 +1,6 @@ import type { CustomKeybind, + HideableState, WidgetItem } from '../../types/Widget'; import { @@ -16,6 +17,11 @@ import { export type UsageDisplayMode = 'time' | 'progress' | 'progress-short' | 'slider' | 'slider-only'; +// Shared by the usage percentage widgets. The reset timers render the same +// error placeholders but cannot declare this state: they bind 'h' for the +// hour-format toggle, which would shadow the shared hide keybind +export const USAGE_NO_DATA_HIDEABLE_STATE: HideableState = { key: 'no-data', label: 'when usage data is unavailable' }; + const SLIDER_WIDTH = 10; const PROGRESS_TOGGLE_KEYBIND: CustomKeybind = { key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' };