diff --git a/AGENTS.md b/AGENTS.md index 663d9c0f..9568073f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,8 @@ All widgets must implement: **Available Widgets:** - Model, Version, OutputStyle, VoiceStatus - Claude Code metadata display - GitBranch, GitChanges, GitInsertions, GitDeletions, GitWorktree - Git repository status +- JjBookmarks, JjRevision, JjChanges, JjDescription, JjWorkspace - Jujutsu (jj) repository status +- AtomicView, AtomicChange, AtomicDescription, AtomicChanges, AtomicInsertions, AtomicDeletions, AtomicRootDir - Atomic VCS status (detects `.atomic` repo; shows configurable `no atomic` fallback). Utilities in src/utils/atomic.ts wrap the `atomic` CLI (`status --short` for repo detection, `diff --stat`, `view list`, `change`) and walk up for `.atomic` to find the repo root - TokensInput, TokensOutput, TokensCached, TokensTotal - Token usage metrics - ContextLength, ContextPercentage, ContextPercentageUsable - Context window metrics (uses dynamic model-based context windows: 1M for Sonnet 4.5 with [1m] suffix, 200k for all other models) - BlockTimer, SessionClock, SessionCost - Time and cost tracking diff --git a/README.md b/README.md index d5c6d2aa..42a07264 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,10 @@ ## 🆕 Recent Updates +### Unreleased - Atomic VCS widgets + +- **⚛️ Atomic version control support** - Added widgets for the [Atomic](https://atomic.dev) VCS: `Atomic View` (current view/branch), `Atomic Change` (current change hash), `Atomic Description` (change message), `Atomic Changes`/`Atomic Insertions`/`Atomic Deletions` (working-copy diff stats), and `Atomic Root Dir`. Each detects whether the working directory is inside an Atomic repository and shows a configurable `no atomic` message otherwise. + ### v2.2.22 - v2.2.23 - Powerline flex mode, layout controls, composable metrics, and safer config ![Powerline Flex Mode](https://raw.githubusercontent.com/sirmalloc/ccstatusline/main/screenshots/powerline-flex.png) diff --git a/src/utils/atomic.ts b/src/utils/atomic.ts new file mode 100644 index 00000000..bb09d558 --- /dev/null +++ b/src/utils/atomic.ts @@ -0,0 +1,76 @@ +import { execFileSync } from 'child_process'; +import { + existsSync, + statSync +} from 'fs'; +import { + dirname, + join +} from 'path'; + +import type { RenderContext } from '../types/RenderContext'; + +import { resolveGitCwd } from './git'; + +export interface AtomicChangeCounts { + insertions: number; + deletions: number; +} + +export function runAtomicArgs(args: string[], context: RenderContext, allowEmpty = false): string | null { + try { + const cwd = resolveGitCwd(context); + const output = execFileSync('atomic', args, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + windowsHide: true, + ...(cwd ? { cwd } : {}) + }).trimEnd(); + + return (allowEmpty || output.length > 0) ? output : null; + } catch { + return null; + } +} + +export function isInsideAtomicRepo(context: RenderContext): boolean { + // `atomic status --short` exits 0 inside a repository (even when clean, with + // empty output) and non-zero otherwise, so allow empty output here. + return runAtomicArgs(['status', '--short'], context, true) !== null; +} + +function parseDiffStat(stat: string): AtomicChangeCounts { + const insertMatch = /(\d+)\s+insertions?/.exec(stat); + const deleteMatch = /(\d+)\s+deletions?/.exec(stat); + + return { + insertions: insertMatch?.[1] ? parseInt(insertMatch[1], 10) : 0, + deletions: deleteMatch?.[1] ? parseInt(deleteMatch[1], 10) : 0 + }; +} + +export function getAtomicChangeCounts(context: RenderContext): AtomicChangeCounts { + return parseDiffStat(runAtomicArgs(['diff', '--stat'], context) ?? ''); +} + +// Atomic has no `root` subcommand, so walk up from the working directory looking +// for a `.atomic` directory, mirroring how git/jj resolve their repository root. +export function findAtomicRoot(context: RenderContext): string | null { + let current = resolveGitCwd(context) ?? process.cwd(); + + for (let parent = dirname(current); ; parent = dirname(current)) { + const marker = join(current, '.atomic'); + try { + if (existsSync(marker) && statSync(marker).isDirectory()) { + return current; + } + } catch { + // Ignore filesystem errors and keep walking upward. + } + + if (parent === current) { + return null; + } + current = parent; + } +} diff --git a/src/utils/widget-manifest.ts b/src/utils/widget-manifest.ts index 86cbad71..f43b012f 100644 --- a/src/utils/widget-manifest.ts +++ b/src/utils/widget-manifest.ts @@ -52,6 +52,13 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [ { type: 'jj-deletions', create: () => new widgets.JjDeletionsWidget() }, { type: 'jj-description', create: () => new widgets.JjDescriptionWidget() }, { type: 'jj-revision', create: () => new widgets.JjRevisionWidget() }, + { type: 'atomic-view', create: () => new widgets.AtomicViewWidget() }, + { type: 'atomic-root-dir', create: () => new widgets.AtomicRootDirWidget() }, + { type: 'atomic-changes', create: () => new widgets.AtomicChangesWidget() }, + { type: 'atomic-insertions', create: () => new widgets.AtomicInsertionsWidget() }, + { type: 'atomic-deletions', create: () => new widgets.AtomicDeletionsWidget() }, + { type: 'atomic-description', create: () => new widgets.AtomicDescriptionWidget() }, + { type: 'atomic-change', create: () => new widgets.AtomicChangeWidget() }, { type: 'current-working-dir', create: () => new widgets.CurrentWorkingDirWidget() }, { type: 'tokens-input', create: () => new widgets.TokensInputWidget() }, { type: 'tokens-output', create: () => new widgets.TokensOutputWidget() }, diff --git a/src/widgets/AtomicChange.ts b/src/widgets/AtomicChange.ts new file mode 100644 index 00000000..06f1bf7b --- /dev/null +++ b/src/widgets/AtomicChange.ts @@ -0,0 +1,86 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + isInsideAtomicRepo, + runAtomicArgs +} from '../utils/atomic'; + +export class AtomicChangeWidget implements Widget { + getDefaultColor(): string { return 'green'; } + getDescription(): string { return 'Shows the current atomic change hash'; } + getDisplayName(): string { return 'Atomic Change'; } + getCategory(): string { return 'Atomic'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const hideNoAtomic = item.metadata?.hideNoAtomic === 'true'; + const modifiers: string[] = []; + + if (hideNoAtomic) { + modifiers.push('hide \'no atomic\''); + } + + return { + displayText: this.getDisplayName(), + modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action === 'toggle-noatomic') { + const currentState = item.metadata?.hideNoAtomic === 'true'; + return { + ...item, + metadata: { + ...item.metadata, + hideNoAtomic: (!currentState).toString() + } + }; + } + return null; + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoAtomic = item.metadata?.hideNoAtomic === 'true'; + + if (context.isPreview) { + return item.rawValue ? 'KQNCPFYKX576' : ' KQNCPFYKX576'; + } + + if (!isInsideAtomicRepo(context)) { + return hideNoAtomic ? null : ' no atomic'; + } + + const hash = this.getAtomicChange(context); + if (hash) { + return item.rawValue ? hash : ` ${hash}`; + } + + return hideNoAtomic ? null : ' no atomic'; + } + + private getAtomicChange(context: RenderContext): string | null { + const output = runAtomicArgs(['change'], context); + if (!output) { + return null; + } + + // First line looks like: `change KQNCPFYKX576 (#2)` + const firstLine = output.split(/\r?\n/)[0] ?? ''; + const match = /^change\s+(\S+)/.exec(firstLine); + return match?.[1] ?? null; + } + + getCustomKeybinds(): CustomKeybind[] { + return [ + { key: 'h', label: '(h)ide \'no atomic\' message', action: 'toggle-noatomic' } + ]; + } + + supportsRawValue(): boolean { return true; } + supportsColors(): boolean { return true; } +} diff --git a/src/widgets/AtomicChanges.ts b/src/widgets/AtomicChanges.ts new file mode 100644 index 00000000..77cd04da --- /dev/null +++ b/src/widgets/AtomicChanges.ts @@ -0,0 +1,70 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + getAtomicChangeCounts, + isInsideAtomicRepo +} from '../utils/atomic'; + +export class AtomicChangesWidget implements Widget { + getDefaultColor(): string { return 'yellow'; } + getDescription(): string { return 'Shows atomic changes count (+insertions, -deletions)'; } + getDisplayName(): string { return 'Atomic Changes'; } + getCategory(): string { return 'Atomic'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const hideNoAtomic = item.metadata?.hideNoAtomic === 'true'; + const modifiers: string[] = []; + + if (hideNoAtomic) { + modifiers.push('hide \'no atomic\''); + } + + return { + displayText: this.getDisplayName(), + modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action === 'toggle-noatomic') { + const currentState = item.metadata?.hideNoAtomic === 'true'; + return { + ...item, + metadata: { + ...item.metadata, + hideNoAtomic: (!currentState).toString() + } + }; + } + return null; + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoAtomic = item.metadata?.hideNoAtomic === 'true'; + + if (context.isPreview) { + return '(+42,-10)'; + } + + if (!isInsideAtomicRepo(context)) { + return hideNoAtomic ? null : '(no atomic)'; + } + + const changes = getAtomicChangeCounts(context); + return `(+${changes.insertions},-${changes.deletions})`; + } + + getCustomKeybinds(): CustomKeybind[] { + return [ + { key: 'h', label: '(h)ide \'no atomic\' message', action: 'toggle-noatomic' } + ]; + } + + supportsRawValue(): boolean { return false; } + supportsColors(): boolean { return true; } +} diff --git a/src/widgets/AtomicDeletions.ts b/src/widgets/AtomicDeletions.ts new file mode 100644 index 00000000..76edbb3c --- /dev/null +++ b/src/widgets/AtomicDeletions.ts @@ -0,0 +1,70 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + getAtomicChangeCounts, + isInsideAtomicRepo +} from '../utils/atomic'; + +export class AtomicDeletionsWidget implements Widget { + getDefaultColor(): string { return 'red'; } + getDescription(): string { return 'Shows atomic deletions count'; } + getDisplayName(): string { return 'Atomic Deletions'; } + getCategory(): string { return 'Atomic'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const hideNoAtomic = item.metadata?.hideNoAtomic === 'true'; + const modifiers: string[] = []; + + if (hideNoAtomic) { + modifiers.push('hide \'no atomic\''); + } + + return { + displayText: this.getDisplayName(), + modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action === 'toggle-noatomic') { + const currentState = item.metadata?.hideNoAtomic === 'true'; + return { + ...item, + metadata: { + ...item.metadata, + hideNoAtomic: (!currentState).toString() + } + }; + } + return null; + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoAtomic = item.metadata?.hideNoAtomic === 'true'; + + if (context.isPreview) { + return '-10'; + } + + if (!isInsideAtomicRepo(context)) { + return hideNoAtomic ? null : '(no atomic)'; + } + + const changes = getAtomicChangeCounts(context); + return `-${changes.deletions}`; + } + + getCustomKeybinds(): CustomKeybind[] { + return [ + { key: 'h', label: '(h)ide \'no atomic\' message', action: 'toggle-noatomic' } + ]; + } + + supportsRawValue(): boolean { return false; } + supportsColors(item: WidgetItem): boolean { return true; } +} diff --git a/src/widgets/AtomicDescription.ts b/src/widgets/AtomicDescription.ts new file mode 100644 index 00000000..1e555c33 --- /dev/null +++ b/src/widgets/AtomicDescription.ts @@ -0,0 +1,94 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + isInsideAtomicRepo, + runAtomicArgs +} from '../utils/atomic'; + +export class AtomicDescriptionWidget implements Widget { + getDefaultColor(): string { return 'white'; } + getDescription(): string { return 'Shows the current atomic change message'; } + getDisplayName(): string { return 'Atomic Description'; } + getCategory(): string { return 'Atomic'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const hideNoAtomic = item.metadata?.hideNoAtomic === 'true'; + const modifiers: string[] = []; + + if (hideNoAtomic) { + modifiers.push('hide \'no atomic\''); + } + + return { + displayText: this.getDisplayName(), + modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action === 'toggle-noatomic') { + const currentState = item.metadata?.hideNoAtomic === 'true'; + return { + ...item, + metadata: { + ...item.metadata, + hideNoAtomic: (!currentState).toString() + } + }; + } + return null; + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoAtomic = item.metadata?.hideNoAtomic === 'true'; + + if (context.isPreview) { + return 'initial'; + } + + if (!isInsideAtomicRepo(context)) { + return hideNoAtomic ? null : 'no atomic'; + } + + const output = runAtomicArgs(['change'], context); + if (output === null) { + return hideNoAtomic ? null : 'no atomic'; + } + + const description = this.parseDescription(output); + return description.length > 0 ? description : '(no description)'; + } + + // `atomic change` renders the message as an indented block after the header + // (change/Author/Date) and a blank line. Return its first line. + private parseDescription(output: string): string { + const lines = output.split(/\r?\n/); + let seenBlank = false; + + for (const line of lines) { + if (line.trim().length === 0) { + seenBlank = true; + continue; + } + if (seenBlank && /^\s/.test(line)) { + return line.trim(); + } + } + + return ''; + } + + getCustomKeybinds(): CustomKeybind[] { + return [ + { key: 'h', label: '(h)ide \'no atomic\' message', action: 'toggle-noatomic' } + ]; + } + + supportsRawValue(): boolean { return false; } + supportsColors(item: WidgetItem): boolean { return true; } +} diff --git a/src/widgets/AtomicInsertions.ts b/src/widgets/AtomicInsertions.ts new file mode 100644 index 00000000..2a5ac2b6 --- /dev/null +++ b/src/widgets/AtomicInsertions.ts @@ -0,0 +1,70 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + getAtomicChangeCounts, + isInsideAtomicRepo +} from '../utils/atomic'; + +export class AtomicInsertionsWidget implements Widget { + getDefaultColor(): string { return 'green'; } + getDescription(): string { return 'Shows atomic insertions count'; } + getDisplayName(): string { return 'Atomic Insertions'; } + getCategory(): string { return 'Atomic'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const hideNoAtomic = item.metadata?.hideNoAtomic === 'true'; + const modifiers: string[] = []; + + if (hideNoAtomic) { + modifiers.push('hide \'no atomic\''); + } + + return { + displayText: this.getDisplayName(), + modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action === 'toggle-noatomic') { + const currentState = item.metadata?.hideNoAtomic === 'true'; + return { + ...item, + metadata: { + ...item.metadata, + hideNoAtomic: (!currentState).toString() + } + }; + } + return null; + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoAtomic = item.metadata?.hideNoAtomic === 'true'; + + if (context.isPreview) { + return '+42'; + } + + if (!isInsideAtomicRepo(context)) { + return hideNoAtomic ? null : '(no atomic)'; + } + + const changes = getAtomicChangeCounts(context); + return `+${changes.insertions}`; + } + + getCustomKeybinds(): CustomKeybind[] { + return [ + { key: 'h', label: '(h)ide \'no atomic\' message', action: 'toggle-noatomic' } + ]; + } + + supportsRawValue(): boolean { return false; } + supportsColors(item: WidgetItem): boolean { return true; } +} diff --git a/src/widgets/AtomicRootDir.ts b/src/widgets/AtomicRootDir.ts new file mode 100644 index 00000000..73981f06 --- /dev/null +++ b/src/widgets/AtomicRootDir.ts @@ -0,0 +1,75 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { findAtomicRoot } from '../utils/atomic'; + +export class AtomicRootDirWidget implements Widget { + getDefaultColor(): string { return 'cyan'; } + getDescription(): string { return 'Shows the atomic repository root directory name'; } + getDisplayName(): string { return 'Atomic Root Dir'; } + getCategory(): string { return 'Atomic'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const hideNoAtomic = item.metadata?.hideNoAtomic === 'true'; + const modifiers: string[] = []; + + if (hideNoAtomic) { + modifiers.push('hide \'no atomic\''); + } + + return { + displayText: this.getDisplayName(), + modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action === 'toggle-noatomic') { + const currentState = item.metadata?.hideNoAtomic === 'true'; + return { + ...item, + metadata: { + ...item.metadata, + hideNoAtomic: (!currentState).toString() + } + }; + } + return null; + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoAtomic = item.metadata?.hideNoAtomic === 'true'; + + if (context.isPreview) { + return 'my-repo'; + } + + const rootDir = findAtomicRoot(context); + if (rootDir) { + return this.getRootDirName(rootDir); + } + + return hideNoAtomic ? null : 'no atomic'; + } + + private getRootDirName(rootDir: string): string { + const trimmedRootDir = rootDir.replace(/[\\/]+$/, ''); + const normalizedRootDir = trimmedRootDir.length > 0 ? trimmedRootDir : rootDir; + const parts = normalizedRootDir.split(/[\\/]/).filter(Boolean); + const lastPart = parts[parts.length - 1]; + return lastPart && lastPart.length > 0 ? lastPart : normalizedRootDir; + } + + getCustomKeybinds(): CustomKeybind[] { + return [ + { key: 'h', label: '(h)ide \'no atomic\' message', action: 'toggle-noatomic' } + ]; + } + + supportsRawValue(): boolean { return false; } + supportsColors(): boolean { return true; } +} diff --git a/src/widgets/AtomicView.ts b/src/widgets/AtomicView.ts new file mode 100644 index 00000000..ca2de4dc --- /dev/null +++ b/src/widgets/AtomicView.ts @@ -0,0 +1,106 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetEditorProps, + WidgetItem +} from '../types/Widget'; +import { + isInsideAtomicRepo, + runAtomicArgs +} from '../utils/atomic'; + +import { + formatSymbolPrefix, + getSymbolKeybind, + renderSymbolOverrideEditor +} from './shared/symbol-override'; + +const DEFAULT_SYMBOL = '⎇'; + +export class AtomicViewWidget implements Widget { + getDefaultColor(): string { return 'magenta'; } + getDescription(): string { return 'Shows the current atomic view (branch equivalent)'; } + getDisplayName(): string { return 'Atomic View'; } + getCategory(): string { return 'Atomic'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const hideNoAtomic = item.metadata?.hideNoAtomic === 'true'; + const modifiers: string[] = []; + + if (hideNoAtomic) { + modifiers.push('hide \'no atomic\''); + } + + return { + displayText: this.getDisplayName(), + modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action === 'toggle-noatomic') { + const currentState = item.metadata?.hideNoAtomic === 'true'; + return { + ...item, + metadata: { + ...item.metadata, + hideNoAtomic: (!currentState).toString() + } + }; + } + return null; + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoAtomic = item.metadata?.hideNoAtomic === 'true'; + const prefix = formatSymbolPrefix(item, DEFAULT_SYMBOL); + + if (context.isPreview) { + return item.rawValue ? 'dev' : `${prefix}dev`; + } + + if (!isInsideAtomicRepo(context)) { + return hideNoAtomic ? null : `${prefix}no atomic`; + } + + const view = this.getAtomicView(context); + if (view) { + return item.rawValue ? view : `${prefix}${view}`; + } + + return hideNoAtomic ? null : `${prefix}no atomic`; + } + + private getAtomicView(context: RenderContext): string | null { + const output = runAtomicArgs(['view', 'list'], context); + if (!output) { + return null; + } + + // The current view is marked with a leading '* '; others are indented. + for (const line of output.split(/\r?\n/)) { + const match = /^\*\s+(\S+)/.exec(line); + if (match?.[1]) { + return match[1]; + } + } + + return null; + } + + getCustomKeybinds(): CustomKeybind[] { + return [ + { key: 'h', label: '(h)ide \'no atomic\' message', action: 'toggle-noatomic' }, + getSymbolKeybind() + ]; + } + + renderEditor(props: WidgetEditorProps) { + return renderSymbolOverrideEditor(props, DEFAULT_SYMBOL); + } + + supportsRawValue(): boolean { return true; } + supportsColors(): boolean { return true; } +} diff --git a/src/widgets/__tests__/AtomicChange.test.ts b/src/widgets/__tests__/AtomicChange.test.ts new file mode 100644 index 00000000..66284c9d --- /dev/null +++ b/src/widgets/__tests__/AtomicChange.test.ts @@ -0,0 +1,89 @@ +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 { AtomicChangeWidget } from '../AtomicChange'; + +vi.mock('child_process', () => ({ execFileSync: vi.fn() })); + +const mockExecFileSync = execFileSync as unknown as { + mock: { calls: unknown[][] }; + mockImplementation: (impl: () => never) => void; + mockReturnValueOnce: (value: string) => void; +}; + +const CHANGE_OUTPUT = [ + 'change KQNCPFYKX576 (#2)', + 'Author: bradley ', + 'Date: 2026-07-15 16:05:19', + '', + ' initial', + '', + 'Graph: +8 vertices, ~0 edges, 40 bytes' +].join('\n'); + +function render(options: { + cwd?: string; + hideNoAtomic?: boolean; + isPreview?: boolean; + rawValue?: boolean; +} = {}) { + const widget = new AtomicChangeWidget(); + const context: RenderContext = { + isPreview: options.isPreview, + data: options.cwd ? { cwd: options.cwd } : undefined + }; + const item: WidgetItem = { + id: 'atomic-change', + type: 'atomic-change', + rawValue: options.rawValue, + metadata: options.hideNoAtomic ? { hideNoAtomic: 'true' } : undefined + }; + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('AtomicChangeWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render preview', () => { + expect(render({ isPreview: true })).toBe(' KQNCPFYKX576'); + }); + + it('should render the current change hash', () => { + mockExecFileSync.mockReturnValueOnce(''); + mockExecFileSync.mockReturnValueOnce(CHANGE_OUTPUT); + + expect(render({ cwd: '/tmp/repo' })).toBe(' KQNCPFYKX576'); + expect(mockExecFileSync.mock.calls[1]?.[1]).toEqual(['change']); + }); + + it('should render raw value without leading space', () => { + mockExecFileSync.mockReturnValueOnce(''); + mockExecFileSync.mockReturnValueOnce(CHANGE_OUTPUT); + + expect(render({ rawValue: true })).toBe('KQNCPFYKX576'); + }); + + it('should render no atomic when not in atomic repo', () => { + mockExecFileSync.mockImplementation(() => { throw new Error('Not an atomic repo'); }); + + expect(render()).toBe(' no atomic'); + }); + + it('should hide no atomic when configured', () => { + mockExecFileSync.mockImplementation(() => { throw new Error('Not an atomic repo'); }); + + expect(render({ hideNoAtomic: true })).toBeNull(); + }); +}); diff --git a/src/widgets/__tests__/AtomicChanges.test.ts b/src/widgets/__tests__/AtomicChanges.test.ts new file mode 100644 index 00000000..47d96374 --- /dev/null +++ b/src/widgets/__tests__/AtomicChanges.test.ts @@ -0,0 +1,101 @@ +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 { AtomicChangesWidget } from '../AtomicChanges'; + +vi.mock('child_process', () => ({ execFileSync: vi.fn() })); + +const mockExecFileSync = execFileSync as unknown as { + mock: { calls: unknown[][] }; + mockImplementation: (impl: () => never) => void; + mockReturnValue: (value: string) => void; + mockReturnValueOnce: (value: string) => void; +}; + +function render(options: { + cwd?: string; + hideNoAtomic?: boolean; + isPreview?: boolean; +} = {}) { + const widget = new AtomicChangesWidget(); + const context: RenderContext = { + isPreview: options.isPreview, + data: options.cwd ? { cwd: options.cwd } : undefined + }; + const item: WidgetItem = { + id: 'atomic-changes', + type: 'atomic-changes', + metadata: options.hideNoAtomic ? { hideNoAtomic: 'true' } : undefined + }; + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('AtomicChangesWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render preview', () => { + expect(render({ isPreview: true })).toBe('(+42,-10)'); + }); + + it('should render changes from atomic diff --stat', () => { + mockExecFileSync.mockReturnValueOnce('M a.txt\n'); + mockExecFileSync.mockReturnValueOnce(' a.txt | 5 +++--\n 1 file changed, 3 insertions(+), 2 deletions(-)'); + + expect(render({ cwd: '/tmp/repo' })).toBe('(+3,-2)'); + expect(mockExecFileSync.mock.calls[0]?.[0]).toBe('atomic'); + expect(mockExecFileSync.mock.calls[0]?.[1]).toEqual(['status', '--short']); + expect(mockExecFileSync.mock.calls[0]?.[2]).toEqual({ + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + windowsHide: true, + cwd: '/tmp/repo' + }); + expect(mockExecFileSync.mock.calls[1]?.[0]).toBe('atomic'); + expect(mockExecFileSync.mock.calls[1]?.[1]).toEqual(['diff', '--stat']); + }); + + it('should render zero counts when repo is clean', () => { + mockExecFileSync.mockReturnValueOnce(''); + mockExecFileSync.mockReturnValueOnce(''); + + expect(render({ cwd: '/tmp/repo' })).toBe('(+0,-0)'); + }); + + it('should render no atomic when not in atomic repo', () => { + mockExecFileSync.mockImplementation(() => { throw new Error('Not an atomic repo'); }); + + expect(render()).toBe('(no atomic)'); + }); + + it('should hide no atomic when configured', () => { + mockExecFileSync.mockImplementation(() => { throw new Error('Not an atomic repo'); }); + + expect(render({ hideNoAtomic: true })).toBeNull(); + }); + + it('should handle insertions only', () => { + mockExecFileSync.mockReturnValueOnce('M a.txt\n'); + mockExecFileSync.mockReturnValueOnce(' a.txt | 5 +++++\n 1 file changed, 5 insertions(+), 0 deletions(-)'); + + expect(render()).toBe('(+5,-0)'); + }); + + it('should handle deletions only', () => { + mockExecFileSync.mockReturnValueOnce('M a.txt\n'); + mockExecFileSync.mockReturnValueOnce(' a.txt | 3 ---\n 1 file changed, 0 insertions(+), 3 deletions(-)'); + + expect(render()).toBe('(+0,-3)'); + }); +}); diff --git a/src/widgets/__tests__/AtomicDescription.test.ts b/src/widgets/__tests__/AtomicDescription.test.ts new file mode 100644 index 00000000..9e72b1c7 --- /dev/null +++ b/src/widgets/__tests__/AtomicDescription.test.ts @@ -0,0 +1,95 @@ +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 { AtomicDescriptionWidget } from '../AtomicDescription'; + +vi.mock('child_process', () => ({ execFileSync: vi.fn() })); + +const mockExecFileSync = execFileSync as unknown as { + mock: { calls: unknown[][] }; + mockImplementation: (impl: () => never) => void; + mockReturnValueOnce: (value: string) => void; +}; + +function changeOutput(message: string) { + return [ + 'change KQNCPFYKX576 (#2)', + 'Author: bradley ', + 'Date: 2026-07-15 16:05:19', + '', + ` ${message}`, + '', + 'Graph: +8 vertices' + ].join('\n'); +} + +function render(options: { + cwd?: string; + hideNoAtomic?: boolean; + isPreview?: boolean; +} = {}) { + const widget = new AtomicDescriptionWidget(); + const context: RenderContext = { + isPreview: options.isPreview, + data: options.cwd ? { cwd: options.cwd } : undefined + }; + const item: WidgetItem = { + id: 'atomic-description', + type: 'atomic-description', + metadata: options.hideNoAtomic ? { hideNoAtomic: 'true' } : undefined + }; + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('AtomicDescriptionWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render preview', () => { + expect(render({ isPreview: true })).toBe('initial'); + }); + + it('should render the change message first line', () => { + mockExecFileSync.mockReturnValueOnce(''); + mockExecFileSync.mockReturnValueOnce(changeOutput('Add atomic widgets')); + + expect(render({ cwd: '/tmp/repo' })).toBe('Add atomic widgets'); + expect(mockExecFileSync.mock.calls[1]?.[1]).toEqual(['change']); + }); + + it('should render no description placeholder when message is empty', () => { + mockExecFileSync.mockReturnValueOnce(''); + mockExecFileSync.mockReturnValueOnce([ + 'change KQNCPFYKX576 (#2)', + 'Author: bradley ', + 'Date: 2026-07-15 16:05:19', + '', + 'Graph: +8 vertices' + ].join('\n')); + + expect(render()).toBe('(no description)'); + }); + + it('should render no atomic when not in atomic repo', () => { + mockExecFileSync.mockImplementation(() => { throw new Error('Not an atomic repo'); }); + + expect(render()).toBe('no atomic'); + }); + + it('should hide no atomic when configured', () => { + mockExecFileSync.mockImplementation(() => { throw new Error('Not an atomic repo'); }); + + expect(render({ hideNoAtomic: true })).toBeNull(); + }); +}); diff --git a/src/widgets/__tests__/AtomicInsertions.test.ts b/src/widgets/__tests__/AtomicInsertions.test.ts new file mode 100644 index 00000000..48eefd32 --- /dev/null +++ b/src/widgets/__tests__/AtomicInsertions.test.ts @@ -0,0 +1,90 @@ +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 { AtomicDeletionsWidget } from '../AtomicDeletions'; +import { AtomicInsertionsWidget } from '../AtomicInsertions'; + +vi.mock('child_process', () => ({ execFileSync: vi.fn() })); + +const mockExecFileSync = execFileSync as unknown as { + mockImplementation: (impl: () => never) => void; + mockReturnValueOnce: (value: string) => void; +}; + +const DIFF_STAT = ' a.txt | 5 +++--\n 1 file changed, 3 insertions(+), 2 deletions(-)'; + +function render(widget: AtomicInsertionsWidget | AtomicDeletionsWidget, options: { + hideNoAtomic?: boolean; + isPreview?: boolean; +} = {}) { + const type = widget instanceof AtomicInsertionsWidget ? 'atomic-insertions' : 'atomic-deletions'; + const context: RenderContext = { isPreview: options.isPreview, data: { cwd: '/tmp/repo' } }; + const item: WidgetItem = { + id: type, + type, + metadata: options.hideNoAtomic ? { hideNoAtomic: 'true' } : undefined + }; + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('AtomicInsertionsWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render preview', () => { + expect(render(new AtomicInsertionsWidget(), { isPreview: true })).toBe('+42'); + }); + + it('should render insertions from diff --stat', () => { + mockExecFileSync.mockReturnValueOnce('M a.txt\n'); + mockExecFileSync.mockReturnValueOnce(DIFF_STAT); + + expect(render(new AtomicInsertionsWidget())).toBe('+3'); + }); + + it('should render no atomic when not in atomic repo', () => { + mockExecFileSync.mockImplementation(() => { throw new Error('nope'); }); + + expect(render(new AtomicInsertionsWidget())).toBe('(no atomic)'); + }); + + it('should hide no atomic when configured', () => { + mockExecFileSync.mockImplementation(() => { throw new Error('nope'); }); + + expect(render(new AtomicInsertionsWidget(), { hideNoAtomic: true })).toBeNull(); + }); +}); + +describe('AtomicDeletionsWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render preview', () => { + expect(render(new AtomicDeletionsWidget(), { isPreview: true })).toBe('-10'); + }); + + it('should render deletions from diff --stat', () => { + mockExecFileSync.mockReturnValueOnce('M a.txt\n'); + mockExecFileSync.mockReturnValueOnce(DIFF_STAT); + + expect(render(new AtomicDeletionsWidget())).toBe('-2'); + }); + + it('should render no atomic when not in atomic repo', () => { + mockExecFileSync.mockImplementation(() => { throw new Error('nope'); }); + + expect(render(new AtomicDeletionsWidget())).toBe('(no atomic)'); + }); +}); diff --git a/src/widgets/__tests__/AtomicRootDir.test.ts b/src/widgets/__tests__/AtomicRootDir.test.ts new file mode 100644 index 00000000..ca54fccd --- /dev/null +++ b/src/widgets/__tests__/AtomicRootDir.test.ts @@ -0,0 +1,72 @@ +import { + existsSync, + statSync +} from 'fs'; +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 { AtomicRootDirWidget } from '../AtomicRootDir'; + +vi.mock('fs', () => ({ + existsSync: vi.fn(), + statSync: vi.fn() +})); + +const mockExistsSync = existsSync as unknown as { mockImplementation: (impl: (p: string) => boolean) => void }; +const mockStatSync = statSync as unknown as { mockReturnValue: (value: unknown) => void }; + +function render(options: { + cwd?: string; + hideNoAtomic?: boolean; + isPreview?: boolean; +} = {}) { + const widget = new AtomicRootDirWidget(); + const context: RenderContext = { + isPreview: options.isPreview, + data: options.cwd ? { cwd: options.cwd } : undefined + }; + const item: WidgetItem = { + id: 'atomic-root-dir', + type: 'atomic-root-dir', + metadata: options.hideNoAtomic ? { hideNoAtomic: 'true' } : undefined + }; + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('AtomicRootDirWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockStatSync.mockReturnValue({ isDirectory: () => true }); + }); + + it('should render preview', () => { + expect(render({ isPreview: true })).toBe('my-repo'); + }); + + it('should render root directory name when .atomic is found', () => { + mockExistsSync.mockImplementation((p: string) => p === '/home/user/my-project/.atomic'); + + expect(render({ cwd: '/home/user/my-project/src' })).toBe('my-project'); + }); + + it('should render no atomic when no .atomic directory exists', () => { + mockExistsSync.mockImplementation(() => false); + + expect(render({ cwd: '/home/user/my-project' })).toBe('no atomic'); + }); + + it('should hide no atomic when configured', () => { + mockExistsSync.mockImplementation(() => false); + + expect(render({ cwd: '/home/user/my-project', hideNoAtomic: true })).toBeNull(); + }); +}); diff --git a/src/widgets/__tests__/AtomicView.test.ts b/src/widgets/__tests__/AtomicView.test.ts new file mode 100644 index 00000000..4899aaa2 --- /dev/null +++ b/src/widgets/__tests__/AtomicView.test.ts @@ -0,0 +1,79 @@ +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 { AtomicViewWidget } from '../AtomicView'; + +vi.mock('child_process', () => ({ execFileSync: vi.fn() })); + +const mockExecFileSync = execFileSync as unknown as { + mock: { calls: unknown[][] }; + mockImplementation: (impl: () => never) => void; + mockReturnValueOnce: (value: string) => void; +}; + +function render(options: { + cwd?: string; + hideNoAtomic?: boolean; + isPreview?: boolean; + rawValue?: boolean; +} = {}) { + const widget = new AtomicViewWidget(); + const context: RenderContext = { + isPreview: options.isPreview, + data: options.cwd ? { cwd: options.cwd } : undefined + }; + const item: WidgetItem = { + id: 'atomic-view', + type: 'atomic-view', + rawValue: options.rawValue, + metadata: options.hideNoAtomic ? { hideNoAtomic: 'true' } : undefined + }; + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('AtomicViewWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render preview', () => { + expect(render({ isPreview: true })).toBe('⎇ dev'); + }); + + it('should render the current view marked with an asterisk', () => { + mockExecFileSync.mockReturnValueOnce('M a.txt\n'); + mockExecFileSync.mockReturnValueOnce('* dev\n feature-x\n'); + + expect(render({ cwd: '/tmp/repo' })).toBe('⎇ dev'); + expect(mockExecFileSync.mock.calls[1]?.[1]).toEqual(['view', 'list']); + }); + + it('should render raw value without symbol', () => { + mockExecFileSync.mockReturnValueOnce(''); + mockExecFileSync.mockReturnValueOnce(' dev\n* feature-x\n'); + + expect(render({ rawValue: true })).toBe('feature-x'); + }); + + it('should render no atomic when not in atomic repo', () => { + mockExecFileSync.mockImplementation(() => { throw new Error('Not an atomic repo'); }); + + expect(render()).toBe('⎇ no atomic'); + }); + + it('should hide no atomic when configured', () => { + mockExecFileSync.mockImplementation(() => { throw new Error('Not an atomic repo'); }); + + expect(render({ hideNoAtomic: true })).toBeNull(); + }); +}); diff --git a/src/widgets/__tests__/AtomicWidgetSharedBehavior.test.ts b/src/widgets/__tests__/AtomicWidgetSharedBehavior.test.ts new file mode 100644 index 00000000..dfc8c2f7 --- /dev/null +++ b/src/widgets/__tests__/AtomicWidgetSharedBehavior.test.ts @@ -0,0 +1,60 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { + CustomKeybind, + Widget, + WidgetItem +} from '../../types'; +import { AtomicChangeWidget } from '../AtomicChange'; +import { AtomicChangesWidget } from '../AtomicChanges'; +import { AtomicDeletionsWidget } from '../AtomicDeletions'; +import { AtomicDescriptionWidget } from '../AtomicDescription'; +import { AtomicInsertionsWidget } from '../AtomicInsertions'; +import { AtomicRootDirWidget } from '../AtomicRootDir'; +import { AtomicViewWidget } from '../AtomicView'; + +type AtomicWidget = Widget & { + getCustomKeybinds: () => CustomKeybind[]; + handleEditorAction: (action: string, item: WidgetItem) => WidgetItem | null; +}; + +const cases: { name: string; itemType: string; widget: AtomicWidget }[] = [ + { name: 'AtomicViewWidget', itemType: 'atomic-view', widget: new AtomicViewWidget() }, + { name: 'AtomicRootDirWidget', itemType: 'atomic-root-dir', widget: new AtomicRootDirWidget() }, + { name: 'AtomicChangesWidget', itemType: 'atomic-changes', widget: new AtomicChangesWidget() }, + { name: 'AtomicInsertionsWidget', itemType: 'atomic-insertions', widget: new AtomicInsertionsWidget() }, + { name: 'AtomicDeletionsWidget', itemType: 'atomic-deletions', widget: new AtomicDeletionsWidget() }, + { name: 'AtomicDescriptionWidget', itemType: 'atomic-description', widget: new AtomicDescriptionWidget() }, + { name: 'AtomicChangeWidget', itemType: 'atomic-change', widget: new AtomicChangeWidget() } +]; + +describe('Atomic widget shared behavior', () => { + it.each(cases)('$name should expose hide-no-atomic keybind', ({ widget }) => { + expect(widget.getCustomKeybinds()).toContainEqual( + { key: 'h', label: '(h)ide \'no atomic\' message', action: 'toggle-noatomic' } + ); + }); + + it.each(cases)('$name should toggle hideNoAtomic metadata', ({ widget, itemType }) => { + const base: WidgetItem = { id: itemType, type: itemType }; + const toggledOn = widget.handleEditorAction('toggle-noatomic', base); + const toggledOff = widget.handleEditorAction('toggle-noatomic', toggledOn ?? base); + + expect(toggledOn?.metadata?.hideNoAtomic).toBe('true'); + expect(toggledOff?.metadata?.hideNoAtomic).toBe('false'); + }); + + it.each(cases)('$name should show hide-no-atomic modifier in editor display', ({ widget, itemType }) => { + const display = widget.getEditorDisplay({ + id: itemType, + type: itemType, + metadata: { hideNoAtomic: 'true' } + }); + + expect(display.modifierText).toBe('(hide \'no atomic\')'); + }); +}); diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 518e3c1d..b5d55800 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -54,6 +54,13 @@ export { JjInsertionsWidget } from './JjInsertions'; export { JjDeletionsWidget } from './JjDeletions'; export { JjDescriptionWidget } from './JjDescription'; export { JjRevisionWidget } from './JjRevision'; +export { AtomicViewWidget } from './AtomicView'; +export { AtomicRootDirWidget } from './AtomicRootDir'; +export { AtomicChangesWidget } from './AtomicChanges'; +export { AtomicInsertionsWidget } from './AtomicInsertions'; +export { AtomicDeletionsWidget } from './AtomicDeletions'; +export { AtomicDescriptionWidget } from './AtomicDescription'; +export { AtomicChangeWidget } from './AtomicChange'; export { ClaudeAccountEmailWidget } from './ClaudeAccountEmail'; export { InputSpeedWidget } from './InputSpeed'; export { OutputSpeedWidget } from './OutputSpeed';