diff --git a/src/utils/__tests__/widgets.test.ts b/src/utils/__tests__/widgets.test.ts index 076ab89..6e43c0d 100644 --- a/src/utils/__tests__/widgets.test.ts +++ b/src/utils/__tests__/widgets.test.ts @@ -73,6 +73,7 @@ describe('widget catalog', () => { expect(categories).toContain('Core'); expect(categories).toContain('Git'); + expect(categories).toContain('Jujutsu'); expect(categories).toContain('Context'); expect(categories).toContain('Tokens'); expect(categories).toContain('Session'); diff --git a/src/utils/jj.ts b/src/utils/jj.ts new file mode 100644 index 0000000..6147b3f --- /dev/null +++ b/src/utils/jj.ts @@ -0,0 +1,23 @@ +import { execSync } from 'child_process'; + +import type { RenderContext } from '../types/RenderContext'; +import { resolveGitCwd } from './git'; + +export function runJj(command: string, context: RenderContext): string | null { + try { + const cwd = resolveGitCwd(context); + const output = execSync(`jj ${command}`, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + ...(cwd ? { cwd } : {}) + }).trim(); + + return output.length > 0 ? output : null; + } catch { + return null; + } +} + +export function isInsideJjRepo(context: RenderContext): boolean { + return runJj('root', context) !== null; +} diff --git a/src/utils/widgets.ts b/src/utils/widgets.ts index 3b6ad36..300adcb 100644 --- a/src/utils/widgets.ts +++ b/src/utils/widgets.ts @@ -15,6 +15,11 @@ const widgetRegistry = new Map([ ['git-deletions', new widgets.GitDeletionsWidget()], ['git-root-dir', new widgets.GitRootDirWidget()], ['git-worktree', new widgets.GitWorktreeWidget()], + ['jj-bookmarks', new widgets.JjBookmarksWidget()], + ['jj-workspace', new widgets.JjWorkspaceWidget()], + ['jj-root-dir', new widgets.JjRootDirWidget()], + ['jj-changes', new widgets.JjChangesWidget()], + ['jj-revision', new widgets.JjRevisionWidget()], ['current-working-dir', new widgets.CurrentWorkingDirWidget()], ['tokens-input', new widgets.TokensInputWidget()], ['tokens-output', new widgets.TokensOutputWidget()], @@ -186,4 +191,4 @@ export function isKnownWidgetType(type: string): boolean { return widgetRegistry.has(type) || type === 'separator' || type === 'flex-separator'; -} \ No newline at end of file +} diff --git a/src/widgets/JjBookmarks.ts b/src/widgets/JjBookmarks.ts new file mode 100644 index 0000000..9fa67a4 --- /dev/null +++ b/src/widgets/JjBookmarks.ts @@ -0,0 +1,88 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + isInsideJjRepo, + runJj +} from '../utils/jj'; + +export class JjBookmarksWidget implements Widget { + getDefaultColor(): string { return 'magenta'; } + getDescription(): string { return 'Shows the current jujutsu bookmark(s)'; } + 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 + }; + } + + 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; + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoJj = item.metadata?.hideNoJj === 'true'; + + if (context.isPreview) { + return item.rawValue ? 'main' : '🔖 main'; + } + + if (!isInsideJjRepo(context)) { + return hideNoJj ? null : '🔖 no jj'; + } + + const bookmarks = this.getJjBookmarks(context); + if (bookmarks) { + return item.rawValue ? bookmarks : `🔖 ${bookmarks}`; + } + + return hideNoJj ? null : '🔖 (none)'; + } + + private getJjBookmarks(context: RenderContext): string | null { + const output = runJj('log --no-graph -r \'heads(::@ & bookmarks())\' --template bookmarks', context); + if (!output) { + return null; + } + + const bookmarks = output.split(/\s+/).filter(Boolean); + if (bookmarks.length === 0) { + return null; + } + + return bookmarks.join(', '); + } + + 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/JjChanges.ts b/src/widgets/JjChanges.ts new file mode 100644 index 0000000..6b6db89 --- /dev/null +++ b/src/widgets/JjChanges.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 { + isInsideJjRepo, + runJj +} from '../utils/jj'; + +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 + }; + } + + 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; + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoJj = item.metadata?.hideNoJj === 'true'; + + if (context.isPreview) { + return '(+42,-10)'; + } + + if (!isInsideJjRepo(context)) { + return hideNoJj ? null : '(no jj)'; + } + + const changes = this.getJjChanges(context); + if (changes) { + return `(+${changes.insertions},-${changes.deletions})`; + } + + return hideNoJj ? null : '(no jj)'; + } + + private getJjChanges(context: RenderContext): { insertions: number; deletions: number } | null { + const stat = runJj('diff --stat', context); + + let totalInsertions = 0; + let totalDeletions = 0; + + if (stat) { + const lines = stat.split('\n'); + const summaryLine = lines[lines.length - 1]; + if (summaryLine) { + const insertMatch = /(\d+) insertion/.exec(summaryLine); + const deleteMatch = /(\d+) deletion/.exec(summaryLine); + totalInsertions += insertMatch?.[1] ? parseInt(insertMatch[1], 10) : 0; + totalDeletions += deleteMatch?.[1] ? parseInt(deleteMatch[1], 10) : 0; + } + } + + 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/JjRevision.ts b/src/widgets/JjRevision.ts new file mode 100644 index 0000000..13258f7 --- /dev/null +++ b/src/widgets/JjRevision.ts @@ -0,0 +1,78 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + isInsideJjRepo, + runJj +} from '../utils/jj'; + +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 + }; + } + + 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; + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoJj = item.metadata?.hideNoJj === 'true'; + + if (context.isPreview) { + return item.rawValue ? 'kkmpptxz' : ' kkmpptxz'; + } + + if (!isInsideJjRepo(context)) { + return hideNoJj ? null : ' no jj'; + } + + const changeId = this.getJjRevision(context); + if (changeId) { + return item.rawValue ? changeId : ` ${changeId}`; + } + + return hideNoJj ? null : ' no jj'; + } + + private getJjRevision(context: RenderContext): string | null { + return runJj('log --no-graph -r @ -T \'change_id.shortest()\'', 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 new file mode 100644 index 0000000..3f65b12 --- /dev/null +++ b/src/widgets/JjRootDir.ts @@ -0,0 +1,82 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + isInsideJjRepo, + runJj +} from '../utils/jj'; + +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 + }; + } + + 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; + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoJj = item.metadata?.hideNoJj === 'true'; + + if (context.isPreview) { + return 'my-repo'; + } + + if (!isInsideJjRepo(context)) { + return hideNoJj ? null : 'no jj'; + } + + const rootDir = runJj('root', context); + if (rootDir) { + return this.getRootDirName(rootDir); + } + + return hideNoJj ? null : 'no jj'; + } + + 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 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 new file mode 100644 index 0000000..2815808 --- /dev/null +++ b/src/widgets/JjWorkspace.ts @@ -0,0 +1,88 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + isInsideJjRepo, + runJj +} from '../utils/jj'; + +export class JjWorkspaceWidget implements Widget { + getDefaultColor(): string { return 'blue'; } + getDescription(): string { return 'Shows the current jujutsu workspace name'; } + 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 + }; + } + + 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; + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoJj = item.metadata?.hideNoJj === 'true'; + + if (context.isPreview) { + return item.rawValue ? 'default' : '◆ default'; + } + + if (!isInsideJjRepo(context)) { + return hideNoJj ? null : '◆ no jj'; + } + + const workspace = this.getJjWorkspace(context); + if (workspace) { + return item.rawValue ? workspace : `◆ ${workspace}`; + } + + return hideNoJj ? null : '◆ no jj'; + } + + private getJjWorkspace(context: RenderContext): string | null { + const output = runJj('workspace list', context); + if (!output) { + return null; + } + + const activeMatch = /^(\S+):\s/.exec(output); + if (activeMatch?.[1]) { + return activeMatch[1]; + } + + return null; + } + + 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/__tests__/JjBookmarks.test.ts b/src/widgets/__tests__/JjBookmarks.test.ts new file mode 100644 index 0000000..a8be048 --- /dev/null +++ b/src/widgets/__tests__/JjBookmarks.test.ts @@ -0,0 +1,97 @@ +import { execSync } 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 { JjBookmarksWidget } from '../JjBookmarks'; + +vi.mock('child_process', () => ({ execSync: vi.fn() })); + +const mockExecSync = execSync as unknown as { + mock: { calls: unknown[][] }; + mockImplementation: (impl: () => never) => void; + mockReturnValue: (value: string) => void; + mockReturnValueOnce: (value: string) => void; +}; + +function render(options: { + cwd?: string; + hideNoJj?: boolean; + isPreview?: boolean; + rawValue?: boolean; +} = {}) { + const widget = new JjBookmarksWidget(); + const context: RenderContext = { + isPreview: options.isPreview, + data: options.cwd ? { cwd: options.cwd } : undefined + }; + const item: WidgetItem = { + id: 'jj-bookmarks', + type: 'jj-bookmarks', + rawValue: options.rawValue, + metadata: options.hideNoJj ? { hideNoJj: 'true' } : undefined + }; + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('JjBookmarksWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render preview', () => { + expect(render({ isPreview: true })).toBe('🔖 main'); + }); + + it('should render preview with raw value', () => { + expect(render({ isPreview: true, rawValue: true })).toBe('main'); + }); + + it('should render bookmark name', () => { + mockExecSync.mockReturnValueOnce('/tmp/repo\n'); + mockExecSync.mockReturnValueOnce('main'); + + expect(render({ cwd: '/tmp/repo' })).toBe('🔖 main'); + }); + + it('should render multiple bookmarks', () => { + mockExecSync.mockReturnValueOnce('/tmp/repo\n'); + mockExecSync.mockReturnValueOnce('main feature-branch'); + + expect(render()).toBe('🔖 main, feature-branch'); + }); + + it('should render raw bookmark value', () => { + mockExecSync.mockReturnValueOnce('/tmp/repo\n'); + mockExecSync.mockReturnValueOnce('main'); + + expect(render({ rawValue: true })).toBe('main'); + }); + + it('should render no jj when not in jj repo', () => { + mockExecSync.mockImplementation(() => { throw new Error('Not a jj repo'); }); + + expect(render()).toBe('🔖 no jj'); + }); + + it('should hide no jj when configured', () => { + mockExecSync.mockImplementation(() => { throw new Error('Not a jj repo'); }); + + expect(render({ hideNoJj: true })).toBeNull(); + }); + + it('should render none when no bookmarks at current change', () => { + mockExecSync.mockReturnValueOnce('/tmp/repo\n'); + mockExecSync.mockReturnValueOnce(''); + + expect(render()).toBe('🔖 (none)'); + }); +}); diff --git a/src/widgets/__tests__/JjChanges.test.ts b/src/widgets/__tests__/JjChanges.test.ts new file mode 100644 index 0000000..e4d6df8 --- /dev/null +++ b/src/widgets/__tests__/JjChanges.test.ts @@ -0,0 +1,91 @@ +import { execSync } 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 { JjChangesWidget } from '../JjChanges'; + +vi.mock('child_process', () => ({ execSync: vi.fn() })); + +const mockExecSync = execSync as unknown as { + mock: { calls: unknown[][] }; + mockImplementation: (impl: () => never) => void; + mockReturnValue: (value: string) => void; + mockReturnValueOnce: (value: string) => void; +}; + +function render(options: { + cwd?: string; + hideNoJj?: boolean; + isPreview?: boolean; +} = {}) { + const widget = new JjChangesWidget(); + const context: RenderContext = { + isPreview: options.isPreview, + data: options.cwd ? { cwd: options.cwd } : undefined + }; + const item: WidgetItem = { + id: 'jj-changes', + type: 'jj-changes', + metadata: options.hideNoJj ? { hideNoJj: 'true' } : undefined + }; + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('JjChangesWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render preview', () => { + expect(render({ isPreview: true })).toBe('(+42,-10)'); + }); + + it('should render changes from jj diff --stat', () => { + mockExecSync.mockReturnValueOnce('/tmp/repo\n'); + mockExecSync.mockReturnValueOnce('src/main.ts | 5 +++--\n1 file changed, 3 insertions(+), 2 deletions(-)'); + + expect(render({ cwd: '/tmp/repo' })).toBe('(+3,-2)'); + }); + + it('should render zero counts when repo is clean', () => { + mockExecSync.mockReturnValueOnce('/tmp/repo\n'); + mockExecSync.mockReturnValueOnce(''); + + expect(render()).toBe('(+0,-0)'); + }); + + it('should render no jj when not in jj repo', () => { + mockExecSync.mockImplementation(() => { throw new Error('Not a jj repo'); }); + + expect(render()).toBe('(no jj)'); + }); + + it('should hide no jj when configured', () => { + mockExecSync.mockImplementation(() => { throw new Error('Not a jj repo'); }); + + expect(render({ hideNoJj: true })).toBeNull(); + }); + + it('should handle insertions only', () => { + mockExecSync.mockReturnValueOnce('/tmp/repo\n'); + mockExecSync.mockReturnValueOnce('src/main.ts | 5 +++++\n1 file changed, 5 insertions(+)'); + + expect(render()).toBe('(+5,-0)'); + }); + + it('should handle deletions only', () => { + mockExecSync.mockReturnValueOnce('/tmp/repo\n'); + mockExecSync.mockReturnValueOnce('src/main.ts | 3 ---\n1 file changed, 3 deletions(-)'); + + expect(render()).toBe('(+0,-3)'); + }); +}); diff --git a/src/widgets/__tests__/JjRevision.test.ts b/src/widgets/__tests__/JjRevision.test.ts new file mode 100644 index 0000000..85cdc5a --- /dev/null +++ b/src/widgets/__tests__/JjRevision.test.ts @@ -0,0 +1,90 @@ +import { execSync } 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 { JjRevisionWidget } from '../JjRevision'; + +vi.mock('child_process', () => ({ execSync: vi.fn() })); + +const mockExecSync = execSync as unknown as { + mock: { calls: unknown[][] }; + mockImplementation: (impl: () => never) => void; + mockReturnValue: (value: string) => void; + mockReturnValueOnce: (value: string) => void; +}; + +function render(options: { + cwd?: string; + hideNoJj?: boolean; + isPreview?: boolean; + rawValue?: boolean; +} = {}) { + const widget = new JjRevisionWidget(); + const context: RenderContext = { + isPreview: options.isPreview, + data: options.cwd ? { cwd: options.cwd } : undefined + }; + const item: WidgetItem = { + id: 'jj-revision', + type: 'jj-revision', + rawValue: options.rawValue, + metadata: options.hideNoJj ? { hideNoJj: 'true' } : undefined + }; + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('JjRevisionWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render preview', () => { + expect(render({ isPreview: true })).toBe(' kkmpptxz'); + }); + + it('should render preview with raw value', () => { + expect(render({ isPreview: true, rawValue: true })).toBe('kkmpptxz'); + }); + + it('should render change id', () => { + mockExecSync.mockReturnValueOnce('/tmp/repo\n'); + mockExecSync.mockReturnValueOnce('kkmpptxz'); + + expect(render({ cwd: '/tmp/repo' })).toBe(' kkmpptxz'); + }); + + it('should render raw change id', () => { + mockExecSync.mockReturnValueOnce('/tmp/repo\n'); + mockExecSync.mockReturnValueOnce('kkmpptxz'); + + expect(render({ rawValue: true })).toBe('kkmpptxz'); + }); + + it('should render no jj when not in jj repo', () => { + mockExecSync.mockImplementation(() => { throw new Error('Not a jj repo'); }); + + expect(render()).toBe(' no jj'); + }); + + it('should hide no jj when configured', () => { + mockExecSync.mockImplementation(() => { throw new Error('Not a jj repo'); }); + + expect(render({ hideNoJj: true })).toBeNull(); + }); + + it('should render no jj when change id lookup is empty', () => { + mockExecSync.mockReturnValueOnce('/tmp/repo\n'); + mockExecSync.mockReturnValueOnce(''); + + expect(render()).toBe(' no jj'); + }); +}); diff --git a/src/widgets/__tests__/JjRootDir.test.ts b/src/widgets/__tests__/JjRootDir.test.ts new file mode 100644 index 0000000..86afd31 --- /dev/null +++ b/src/widgets/__tests__/JjRootDir.test.ts @@ -0,0 +1,77 @@ +import { execSync } 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 { JjRootDirWidget } from '../JjRootDir'; + +vi.mock('child_process', () => ({ execSync: vi.fn() })); + +const mockExecSync = execSync as unknown as { + mock: { calls: unknown[][] }; + mockImplementation: (impl: () => never) => void; + mockReturnValue: (value: string) => void; + mockReturnValueOnce: (value: string) => void; +}; + +function render(options: { + cwd?: string; + hideNoJj?: boolean; + isPreview?: boolean; +} = {}) { + const widget = new JjRootDirWidget(); + const context: RenderContext = { + isPreview: options.isPreview, + data: options.cwd ? { cwd: options.cwd } : undefined + }; + const item: WidgetItem = { + id: 'jj-root-dir', + type: 'jj-root-dir', + metadata: options.hideNoJj ? { hideNoJj: 'true' } : undefined + }; + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('JjRootDirWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render preview', () => { + expect(render({ isPreview: true })).toBe('my-repo'); + }); + + it('should render root directory name', () => { + mockExecSync.mockReturnValueOnce('/home/user/my-project\n'); + mockExecSync.mockReturnValueOnce('/home/user/my-project\n'); + + expect(render({ cwd: '/home/user/my-project' })).toBe('my-project'); + }); + + it('should render no jj when not in jj repo', () => { + mockExecSync.mockImplementation(() => { throw new Error('Not a jj repo'); }); + + expect(render()).toBe('no jj'); + }); + + it('should hide no jj when configured', () => { + mockExecSync.mockImplementation(() => { throw new Error('Not a jj repo'); }); + + expect(render({ hideNoJj: true })).toBeNull(); + }); + + it('should handle trailing slashes', () => { + mockExecSync.mockReturnValueOnce('/home/user/my-project/\n'); + mockExecSync.mockReturnValueOnce('/home/user/my-project/\n'); + + expect(render()).toBe('my-project'); + }); +}); diff --git a/src/widgets/__tests__/JjWorkspace.test.ts b/src/widgets/__tests__/JjWorkspace.test.ts new file mode 100644 index 0000000..fe727fd --- /dev/null +++ b/src/widgets/__tests__/JjWorkspace.test.ts @@ -0,0 +1,90 @@ +import { execSync } 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 { JjWorkspaceWidget } from '../JjWorkspace'; + +vi.mock('child_process', () => ({ execSync: vi.fn() })); + +const mockExecSync = execSync as unknown as { + mock: { calls: unknown[][] }; + mockImplementation: (impl: () => never) => void; + mockReturnValue: (value: string) => void; + mockReturnValueOnce: (value: string) => void; +}; + +function render(options: { + cwd?: string; + hideNoJj?: boolean; + isPreview?: boolean; + rawValue?: boolean; +} = {}) { + const widget = new JjWorkspaceWidget(); + const context: RenderContext = { + isPreview: options.isPreview, + data: options.cwd ? { cwd: options.cwd } : undefined + }; + const item: WidgetItem = { + id: 'jj-workspace', + type: 'jj-workspace', + rawValue: options.rawValue, + metadata: options.hideNoJj ? { hideNoJj: 'true' } : undefined + }; + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('JjWorkspaceWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render preview', () => { + expect(render({ isPreview: true })).toBe('◆ default'); + }); + + it('should render preview with raw value', () => { + expect(render({ isPreview: true, rawValue: true })).toBe('default'); + }); + + it('should render default workspace', () => { + mockExecSync.mockReturnValueOnce('/tmp/repo\n'); + mockExecSync.mockReturnValueOnce('default: abc12345 (no description set)'); + + expect(render({ cwd: '/tmp/repo' })).toBe('◆ default'); + }); + + it('should render named workspace', () => { + mockExecSync.mockReturnValueOnce('/tmp/repo\n'); + mockExecSync.mockReturnValueOnce('feature: abc12345 (no description set)'); + + expect(render()).toBe('◆ feature'); + }); + + it('should render raw workspace value', () => { + mockExecSync.mockReturnValueOnce('/tmp/repo\n'); + mockExecSync.mockReturnValueOnce('default: abc12345 (no description set)'); + + expect(render({ rawValue: true })).toBe('default'); + }); + + it('should render no jj when not in jj repo', () => { + mockExecSync.mockImplementation(() => { throw new Error('Not a jj repo'); }); + + expect(render()).toBe('◆ no jj'); + }); + + it('should hide no jj when configured', () => { + mockExecSync.mockImplementation(() => { throw new Error('Not a jj repo'); }); + + expect(render({ hideNoJj: true })).toBeNull(); + }); +}); diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 849dc1b..5a5a907 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -22,10 +22,15 @@ export { CustomCommandWidget } from './CustomCommand'; export { BlockTimerWidget } from './BlockTimer'; export { CurrentWorkingDirWidget } from './CurrentWorkingDir'; export { ClaudeSessionIdWidget } from './ClaudeSessionId'; +export { JjBookmarksWidget } from './JjBookmarks'; +export { JjWorkspaceWidget } from './JjWorkspace'; +export { JjRootDirWidget } from './JjRootDir'; +export { JjChangesWidget } from './JjChanges'; +export { JjRevisionWidget } from './JjRevision'; export { FreeMemoryWidget } from './FreeMemory'; export { SessionNameWidget } from './SessionName'; export { SessionUsageWidget } from './SessionUsage'; export { WeeklyUsageWidget } from './WeeklyUsage'; export { ResetTimerWidget } from './ResetTimer'; export { ContextBarWidget } from './ContextBar'; -export { LinkWidget } from './Link'; \ No newline at end of file +export { LinkWidget } from './Link';