From bcc4b2bd6ba19f2e1cb69750a0b8c7ac1c49959e Mon Sep 17 00:00:00 2001 From: seanb4t <4678+seanb4t@users.noreply.github.com> Date: Sat, 7 Mar 2026 15:04:48 -0500 Subject: [PATCH 1/7] feat(jj): add jj utility functions and shared hide-no-jj feature Add jj VCS utility module mirroring the existing git utilities with workspace detection, command execution, and diff stat parsing. Add shared jj-no-jj toggle for hiding 'no jj' messages in jj widgets. Co-Authored-By: Claude Opus 4.6 --- src/utils/__tests__/jj.test.ts | 177 +++++++++++++++++++++++++++++++++ src/utils/jj.ts | 59 +++++++++++ src/widgets/shared/jj-no-jj.ts | 39 ++++++++ 3 files changed, 275 insertions(+) create mode 100644 src/utils/__tests__/jj.test.ts create mode 100644 src/utils/jj.ts create mode 100644 src/widgets/shared/jj-no-jj.ts diff --git a/src/utils/__tests__/jj.test.ts b/src/utils/__tests__/jj.test.ts new file mode 100644 index 0000000..f36cde4 --- /dev/null +++ b/src/utils/__tests__/jj.test.ts @@ -0,0 +1,177 @@ +import { execSync } from 'child_process'; +import { + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { + getJjChangeCounts, + isInsideJjWorkspace, + resolveJjCwd, + runJj +} from '../jj'; + +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; +}; + +describe('jj utils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('resolveJjCwd', () => { + it('prefers context.data.cwd when available', () => { + const context: RenderContext = { + data: { + cwd: '/repo/from/cwd', + workspace: { + current_dir: '/repo/from/current-dir', + project_dir: '/repo/from/project-dir' + } + } + }; + + expect(resolveJjCwd(context)).toBe('/repo/from/cwd'); + }); + + it('falls back to workspace.current_dir', () => { + const context: RenderContext = { + data: { + workspace: { + current_dir: '/repo/from/current-dir', + project_dir: '/repo/from/project-dir' + } + } + }; + + expect(resolveJjCwd(context)).toBe('/repo/from/current-dir'); + }); + + it('falls back to workspace.project_dir', () => { + const context: RenderContext = { data: { workspace: { project_dir: '/repo/from/project-dir' } } }; + + expect(resolveJjCwd(context)).toBe('/repo/from/project-dir'); + }); + + it('skips empty candidate values', () => { + const context: RenderContext = { + data: { + cwd: ' ', + workspace: { + current_dir: '', + project_dir: '/repo/from/project-dir' + } + } + }; + + expect(resolveJjCwd(context)).toBe('/repo/from/project-dir'); + }); + + it('returns undefined when no candidates are available', () => { + expect(resolveJjCwd({})).toBeUndefined(); + }); + }); + + describe('runJj', () => { + it('runs jj command with resolved cwd and trims output', () => { + mockExecSync.mockReturnValue(' some-output \n'); + const context: RenderContext = { data: { cwd: '/tmp/repo' } }; + + const result = runJj('log --limit 1', context); + + expect(result).toBe('some-output'); + expect(mockExecSync.mock.calls[0]?.[0]).toBe('jj log --limit 1'); + expect(mockExecSync.mock.calls[0]?.[1]).toEqual({ + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + cwd: '/tmp/repo' + }); + }); + + it('runs jj command without cwd when no context directory exists', () => { + mockExecSync.mockReturnValue('/tmp/repo\n'); + + const result = runJj('workspace root', {}); + + expect(result).toBe('/tmp/repo'); + expect(mockExecSync.mock.calls[0]?.[1]).toEqual({ + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'] + }); + }); + + it('returns null when output is empty', () => { + mockExecSync.mockReturnValue(' \n'); + + expect(runJj('workspace root', {})).toBeNull(); + }); + + it('returns null when the command fails', () => { + mockExecSync.mockImplementation(() => { throw new Error('jj failed'); }); + + expect(runJj('status', {})).toBeNull(); + }); + }); + + describe('isInsideJjWorkspace', () => { + it('returns true when jj workspace root succeeds', () => { + mockExecSync.mockReturnValue('/tmp/repo\n'); + + expect(isInsideJjWorkspace({})).toBe(true); + }); + + it('returns false when jj workspace root fails', () => { + mockExecSync.mockImplementation(() => { throw new Error('jj failed'); }); + + expect(isInsideJjWorkspace({})).toBe(false); + }); + }); + + describe('getJjChangeCounts', () => { + it('parses insertions and deletions from jj diff --stat', () => { + mockExecSync.mockReturnValue('2 files changed, 5 insertions(+), 3 deletions(-)'); + + expect(getJjChangeCounts({})).toEqual({ + insertions: 5, + deletions: 3 + }); + }); + + it('handles singular insertion/deletion forms', () => { + mockExecSync.mockReturnValue('1 file changed, 1 insertion(+), 1 deletion(-)'); + + expect(getJjChangeCounts({})).toEqual({ + insertions: 1, + deletions: 1 + }); + }); + + it('returns zero counts when jj diff --stat returns empty', () => { + mockExecSync.mockReturnValue('\n'); + + expect(getJjChangeCounts({})).toEqual({ + insertions: 0, + deletions: 0 + }); + }); + + it('returns zero counts when jj diff command fails', () => { + mockExecSync.mockImplementation(() => { throw new Error('jj failed'); }); + + expect(getJjChangeCounts({})).toEqual({ + insertions: 0, + deletions: 0 + }); + }); + }); +}); \ No newline at end of file diff --git a/src/utils/jj.ts b/src/utils/jj.ts new file mode 100644 index 0000000..1adf194 --- /dev/null +++ b/src/utils/jj.ts @@ -0,0 +1,59 @@ +import { execSync } from 'child_process'; + +import type { RenderContext } from '../types/RenderContext'; + +export interface JjChangeCounts { + insertions: number; + deletions: number; +} + +export function resolveJjCwd(context: RenderContext): string | undefined { + const candidates = [ + context.data?.cwd, + context.data?.workspace?.current_dir, + context.data?.workspace?.project_dir + ]; + + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.trim().length > 0) { + return candidate; + } + } + + return undefined; +} + +export function runJj(command: string, context: RenderContext): string | null { + try { + const cwd = resolveJjCwd(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 isInsideJjWorkspace(context: RenderContext): boolean { + return runJj('workspace root', context) !== null; +} + +function parseDiffStat(stat: string): JjChangeCounts { + 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 getJjChangeCounts(context: RenderContext): JjChangeCounts { + const stat = runJj('diff --stat', context) ?? ''; + + return parseDiffStat(stat); +} \ No newline at end of file diff --git a/src/widgets/shared/jj-no-jj.ts b/src/widgets/shared/jj-no-jj.ts new file mode 100644 index 0000000..50cdc77 --- /dev/null +++ b/src/widgets/shared/jj-no-jj.ts @@ -0,0 +1,39 @@ +import type { + CustomKeybind, + WidgetItem +} from '../../types/Widget'; + +import { makeModifierText } from './editor-display'; +import { + isMetadataFlagEnabled, + toggleMetadataFlag +} from './metadata'; + +const HIDE_NO_JJ_KEY = 'hideNoJj'; +const TOGGLE_NO_JJ_ACTION = 'toggle-nojj'; + +const HIDE_NO_JJ_KEYBIND: CustomKeybind = { + key: 'h', + label: '(h)ide \'no jj\' message', + action: TOGGLE_NO_JJ_ACTION +}; + +export function isHideNoJjEnabled(item: WidgetItem): boolean { + return isMetadataFlagEnabled(item, HIDE_NO_JJ_KEY); +} + +export function getHideNoJjModifierText(item: WidgetItem): string | undefined { + return makeModifierText(isHideNoJjEnabled(item) ? ['hide \'no jj\''] : []); +} + +export function handleToggleNoJjAction(action: string, item: WidgetItem): WidgetItem | null { + if (action !== TOGGLE_NO_JJ_ACTION) { + return null; + } + + return toggleMetadataFlag(item, HIDE_NO_JJ_KEY); +} + +export function getHideNoJjKeybinds(): CustomKeybind[] { + return [HIDE_NO_JJ_KEYBIND]; +} \ No newline at end of file From 72cf30cd26b61285f9015aa7af2ca19522e6039c Mon Sep 17 00:00:00 2001 From: seanb4t <4678+seanb4t@users.noreply.github.com> Date: Sat, 7 Mar 2026 15:08:28 -0500 Subject: [PATCH 2/7] feat(jj): add JjChange and JjBookmark widgets Add two new Jujutsu VCS widgets mirroring the GitBranch pattern: - JjChange: displays current jj change ID with `jj:` prefix - JjBookmark: displays current jj bookmark name(s) with `@` prefix, showing `(none)` when no bookmarks are set - Add `runJjRaw` utility to distinguish empty output from errors Both widgets support raw value mode, hide-no-jj configuration, and include full test coverage (17 tests). Co-Authored-By: Claude Opus 4.6 --- src/utils/jj.ts | 15 +++ src/widgets/JjBookmark.ts | 65 +++++++++++++ src/widgets/JjChange.ts | 61 ++++++++++++ src/widgets/__tests__/JjBookmark.test.ts | 114 +++++++++++++++++++++++ src/widgets/__tests__/JjChange.test.ts | 107 +++++++++++++++++++++ 5 files changed, 362 insertions(+) create mode 100644 src/widgets/JjBookmark.ts create mode 100644 src/widgets/JjChange.ts create mode 100644 src/widgets/__tests__/JjBookmark.test.ts create mode 100644 src/widgets/__tests__/JjChange.test.ts diff --git a/src/utils/jj.ts b/src/utils/jj.ts index 1adf194..9e69100 100644 --- a/src/utils/jj.ts +++ b/src/utils/jj.ts @@ -38,6 +38,21 @@ export function runJj(command: string, context: RenderContext): string | null { } } +export function runJjRaw(command: string, context: RenderContext): string | null { + try { + const cwd = resolveJjCwd(context); + const output = execSync(`jj ${command}`, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + ...(cwd ? { cwd } : {}) + }).trim(); + + return output; + } catch { + return null; + } +} + export function isInsideJjWorkspace(context: RenderContext): boolean { return runJj('workspace root', context) !== null; } diff --git a/src/widgets/JjBookmark.ts b/src/widgets/JjBookmark.ts new file mode 100644 index 0000000..013aa1d --- /dev/null +++ b/src/widgets/JjBookmark.ts @@ -0,0 +1,65 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + isInsideJjWorkspace, + runJjRaw +} from '../utils/jj'; + +import { + getHideNoJjKeybinds, + getHideNoJjModifierText, + handleToggleNoJjAction, + isHideNoJjEnabled +} from './shared/jj-no-jj'; + +export class JjBookmarkWidget implements Widget { + getDefaultColor(): string { return 'cyan'; } + getDescription(): string { return 'Shows the current jj bookmark name(s)'; } + getDisplayName(): string { return 'JJ Bookmark'; } + getCategory(): string { return 'Jujutsu'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getHideNoJjModifierText(item) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleNoJjAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const hideNoJj = isHideNoJjEnabled(item); + + if (context.isPreview) { + return item.rawValue ? 'main' : '@ main'; + } + + if (!isInsideJjWorkspace(context)) { + return hideNoJj ? null : '@ no jj'; + } + + const bookmarks = runJjRaw('log --no-graph -r @ -T \'bookmarks.join(",")\'', context); + if (bookmarks === null) { + return hideNoJj ? null : '@ no jj'; + } + + if (bookmarks.length > 0) + return item.rawValue ? bookmarks : `@ ${bookmarks}`; + + return item.rawValue ? '(none)' : '@ (none)'; + } + + getCustomKeybinds(): CustomKeybind[] { + return getHideNoJjKeybinds(); + } + + supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/JjChange.ts b/src/widgets/JjChange.ts new file mode 100644 index 0000000..118f5a4 --- /dev/null +++ b/src/widgets/JjChange.ts @@ -0,0 +1,61 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + isInsideJjWorkspace, + runJj +} from '../utils/jj'; + +import { + getHideNoJjKeybinds, + getHideNoJjModifierText, + handleToggleNoJjAction, + isHideNoJjEnabled +} from './shared/jj-no-jj'; + +export class JjChangeWidget implements Widget { + getDefaultColor(): string { return 'magenta'; } + getDescription(): string { return 'Shows the current jj change ID'; } + getDisplayName(): string { return 'JJ Change'; } + getCategory(): string { return 'Jujutsu'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getHideNoJjModifierText(item) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleNoJjAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const hideNoJj = isHideNoJjEnabled(item); + + if (context.isPreview) { + return item.rawValue ? 'kpqxywon' : 'jj: kpqxywon'; + } + + if (!isInsideJjWorkspace(context)) { + return hideNoJj ? null : 'jj: no jj'; + } + + const changeId = runJj('log --no-graph -r @ -T \'change_id.short()\'', context); + if (changeId) + return item.rawValue ? changeId : `jj: ${changeId}`; + + return hideNoJj ? null : 'jj: no jj'; + } + + getCustomKeybinds(): CustomKeybind[] { + return getHideNoJjKeybinds(); + } + + supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/__tests__/JjBookmark.test.ts b/src/widgets/__tests__/JjBookmark.test.ts new file mode 100644 index 0000000..01f7b48 --- /dev/null +++ b/src/widgets/__tests__/JjBookmark.test.ts @@ -0,0 +1,114 @@ +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 { JjBookmarkWidget } from '../JjBookmark'; + +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 JjBookmarkWidget(); + const context: RenderContext = { + isPreview: options.isPreview, + data: options.cwd ? { cwd: options.cwd } : undefined + }; + const item: WidgetItem = { + id: 'jj-bookmark', + type: 'jj-bookmark', + rawValue: options.rawValue, + metadata: options.hideNoJj ? { hideNoJj: 'true' } : undefined + }; + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('JjBookmarkWidget', () => { + 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/workspace\n'); + mockExecSync.mockReturnValueOnce('main'); + + expect(render({ cwd: '/tmp/workspace' })).toBe('@ main'); + expect(mockExecSync.mock.calls[0]?.[1]).toEqual({ + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + cwd: '/tmp/workspace' + }); + expect(mockExecSync.mock.calls[1]?.[1]).toEqual({ + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + cwd: '/tmp/workspace' + }); + }); + + it('should render raw bookmark value', () => { + mockExecSync.mockReturnValueOnce('/tmp/workspace\n'); + mockExecSync.mockReturnValueOnce('main'); + + expect(render({ rawValue: true })).toBe('main'); + }); + + it('should render no jj when not in workspace', () => { + mockExecSync.mockImplementation(() => { throw new Error('No jj'); }); + + expect(render()).toBe('@ no jj'); + }); + + it('should hide no jj when configured', () => { + mockExecSync.mockImplementation(() => { throw new Error('No jj'); }); + + expect(render({ hideNoJj: true })).toBeNull(); + }); + + it('should render (none) when bookmarks is empty', () => { + mockExecSync.mockReturnValueOnce('/tmp/workspace\n'); + mockExecSync.mockReturnValueOnce(''); + + expect(render()).toBe('@ (none)'); + }); + + it('should render raw (none) when bookmarks is empty', () => { + mockExecSync.mockReturnValueOnce('/tmp/workspace\n'); + mockExecSync.mockReturnValueOnce(''); + + expect(render({ rawValue: true })).toBe('(none)'); + }); + + it('should render no jj when command fails', () => { + mockExecSync.mockReturnValueOnce('/tmp/workspace\n'); + mockExecSync.mockImplementation(() => { throw new Error('Command failed'); }); + + expect(render()).toBe('@ no jj'); + }); +}); \ No newline at end of file diff --git a/src/widgets/__tests__/JjChange.test.ts b/src/widgets/__tests__/JjChange.test.ts new file mode 100644 index 0000000..483a1f2 --- /dev/null +++ b/src/widgets/__tests__/JjChange.test.ts @@ -0,0 +1,107 @@ +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 { JjChangeWidget } from '../JjChange'; + +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 JjChangeWidget(); + const context: RenderContext = { + isPreview: options.isPreview, + data: options.cwd ? { cwd: options.cwd } : undefined + }; + const item: WidgetItem = { + id: 'jj-change', + type: 'jj-change', + rawValue: options.rawValue, + metadata: options.hideNoJj ? { hideNoJj: 'true' } : undefined + }; + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('JjChangeWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render preview', () => { + expect(render({ isPreview: true })).toBe('jj: kpqxywon'); + }); + + it('should render preview with raw value', () => { + expect(render({ isPreview: true, rawValue: true })).toBe('kpqxywon'); + }); + + it('should render change id', () => { + mockExecSync.mockReturnValueOnce('/tmp/workspace\n'); + mockExecSync.mockReturnValueOnce('kpqxywon'); + + expect(render({ cwd: '/tmp/workspace' })).toBe('jj: kpqxywon'); + expect(mockExecSync.mock.calls[0]?.[1]).toEqual({ + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + cwd: '/tmp/workspace' + }); + expect(mockExecSync.mock.calls[1]?.[1]).toEqual({ + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + cwd: '/tmp/workspace' + }); + }); + + it('should render raw change id', () => { + mockExecSync.mockReturnValueOnce('/tmp/workspace\n'); + mockExecSync.mockReturnValueOnce('kpqxywon'); + + expect(render({ rawValue: true })).toBe('kpqxywon'); + }); + + it('should render no jj when not in workspace', () => { + mockExecSync.mockImplementation(() => { throw new Error('No jj'); }); + + expect(render()).toBe('jj: no jj'); + }); + + it('should hide no jj when configured', () => { + mockExecSync.mockImplementation(() => { throw new Error('No jj'); }); + + expect(render({ hideNoJj: true })).toBeNull(); + }); + + it('should render no jj when change id is empty', () => { + mockExecSync.mockReturnValueOnce('/tmp/workspace\n'); + mockExecSync.mockReturnValueOnce(''); + + expect(render()).toBe('jj: no jj'); + }); + + it('should render no jj when command fails', () => { + mockExecSync.mockReturnValueOnce('/tmp/workspace\n'); + mockExecSync.mockImplementation(() => { throw new Error('Command failed'); }); + + expect(render()).toBe('jj: no jj'); + }); +}); \ No newline at end of file From 691738274248154c9489061934affc084e0c2915 Mon Sep 17 00:00:00 2001 From: seanb4t <4678+seanb4t@users.noreply.github.com> Date: Sat, 7 Mar 2026 15:12:16 -0500 Subject: [PATCH 3/7] feat(jj): add JjChanges, JjInsertions, JjDeletions, JjRootDir, and JjDescription widgets Add five new Jujutsu VCS widgets with full test coverage: - JjChanges: combined insertions/deletions count (+ins,-del) - JjInsertions: insertion count from jj diff --stat - JjDeletions: deletion count from jj diff --stat - JjRootDir: workspace root directory name extraction - JjDescription: current change description via jj log Co-Authored-By: Claude Opus 4.6 --- src/widgets/JjChanges.ts | 58 ++++++++++++++ src/widgets/JjDeletions.ts | 58 ++++++++++++++ src/widgets/JjDescription.ts | 62 +++++++++++++++ src/widgets/JjInsertions.ts | 58 ++++++++++++++ src/widgets/JjRootDir.ts | 70 +++++++++++++++++ src/widgets/__tests__/JjChanges.test.ts | 77 +++++++++++++++++++ src/widgets/__tests__/JjDeletions.test.ts | 77 +++++++++++++++++++ src/widgets/__tests__/JjDescription.test.ts | 84 +++++++++++++++++++++ src/widgets/__tests__/JjInsertions.test.ts | 77 +++++++++++++++++++ src/widgets/__tests__/JjRootDir.test.ts | 84 +++++++++++++++++++++ 10 files changed, 705 insertions(+) create mode 100644 src/widgets/JjChanges.ts create mode 100644 src/widgets/JjDeletions.ts create mode 100644 src/widgets/JjDescription.ts create mode 100644 src/widgets/JjInsertions.ts create mode 100644 src/widgets/JjRootDir.ts create mode 100644 src/widgets/__tests__/JjChanges.test.ts create mode 100644 src/widgets/__tests__/JjDeletions.test.ts create mode 100644 src/widgets/__tests__/JjDescription.test.ts create mode 100644 src/widgets/__tests__/JjInsertions.test.ts create mode 100644 src/widgets/__tests__/JjRootDir.test.ts diff --git a/src/widgets/JjChanges.ts b/src/widgets/JjChanges.ts new file mode 100644 index 0000000..9636ba7 --- /dev/null +++ b/src/widgets/JjChanges.ts @@ -0,0 +1,58 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + getJjChangeCounts, + isInsideJjWorkspace +} from '../utils/jj'; + +import { + getHideNoJjKeybinds, + getHideNoJjModifierText, + handleToggleNoJjAction, + isHideNoJjEnabled +} from './shared/jj-no-jj'; + +export class JjChangesWidget implements Widget { + getDefaultColor(): string { return 'yellow'; } + getDescription(): string { return 'Shows jj changes count (+insertions, -deletions)'; } + getDisplayName(): string { return 'JJ Changes'; } + getCategory(): string { return 'Jujutsu'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getHideNoJjModifierText(item) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleNoJjAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoJj = isHideNoJjEnabled(item); + + if (context.isPreview) { + return '(+42,-10)'; + } + + if (!isInsideJjWorkspace(context)) { + return hideNoJj ? null : '(no jj)'; + } + + const changes = getJjChangeCounts(context); + return `(+${changes.insertions},-${changes.deletions})`; + } + + getCustomKeybinds(): CustomKeybind[] { + return getHideNoJjKeybinds(); + } + + supportsRawValue(): boolean { return false; } + supportsColors(item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/JjDeletions.ts b/src/widgets/JjDeletions.ts new file mode 100644 index 0000000..61f7670 --- /dev/null +++ b/src/widgets/JjDeletions.ts @@ -0,0 +1,58 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + getJjChangeCounts, + isInsideJjWorkspace +} from '../utils/jj'; + +import { + getHideNoJjKeybinds, + getHideNoJjModifierText, + handleToggleNoJjAction, + isHideNoJjEnabled +} from './shared/jj-no-jj'; + +export class JjDeletionsWidget implements Widget { + getDefaultColor(): string { return 'red'; } + getDescription(): string { return 'Shows jj deletions count'; } + getDisplayName(): string { return 'JJ Deletions'; } + getCategory(): string { return 'Jujutsu'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getHideNoJjModifierText(item) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleNoJjAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoJj = isHideNoJjEnabled(item); + + if (context.isPreview) { + return '-10'; + } + + if (!isInsideJjWorkspace(context)) { + return hideNoJj ? null : '(no jj)'; + } + + const changes = getJjChangeCounts(context); + return `-${changes.deletions}`; + } + + getCustomKeybinds(): CustomKeybind[] { + return getHideNoJjKeybinds(); + } + + supportsRawValue(): boolean { return false; } + supportsColors(item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/JjDescription.ts b/src/widgets/JjDescription.ts new file mode 100644 index 0000000..3f47a0e --- /dev/null +++ b/src/widgets/JjDescription.ts @@ -0,0 +1,62 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + isInsideJjWorkspace, + runJjRaw +} from '../utils/jj'; + +import { + getHideNoJjKeybinds, + getHideNoJjModifierText, + handleToggleNoJjAction, + isHideNoJjEnabled +} from './shared/jj-no-jj'; + +export class JjDescriptionWidget implements Widget { + getDefaultColor(): string { return 'white'; } + getDescription(): string { return 'Shows the current jj change description'; } + getDisplayName(): string { return 'JJ Description'; } + getCategory(): string { return 'Jujutsu'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getHideNoJjModifierText(item) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleNoJjAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoJj = isHideNoJjEnabled(item); + + if (context.isPreview) { + return '(no description set)'; + } + + if (!isInsideJjWorkspace(context)) { + return hideNoJj ? null : 'no jj'; + } + + const description = runJjRaw('log --no-graph -r @ -T \'description.first_line()\'', context); + if (description === null || description.length === 0) { + return '(no description)'; + } + + return description; + } + + getCustomKeybinds(): CustomKeybind[] { + return getHideNoJjKeybinds(); + } + + supportsRawValue(): boolean { return false; } + supportsColors(item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/JjInsertions.ts b/src/widgets/JjInsertions.ts new file mode 100644 index 0000000..84c671b --- /dev/null +++ b/src/widgets/JjInsertions.ts @@ -0,0 +1,58 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + getJjChangeCounts, + isInsideJjWorkspace +} from '../utils/jj'; + +import { + getHideNoJjKeybinds, + getHideNoJjModifierText, + handleToggleNoJjAction, + isHideNoJjEnabled +} from './shared/jj-no-jj'; + +export class JjInsertionsWidget implements Widget { + getDefaultColor(): string { return 'green'; } + getDescription(): string { return 'Shows jj insertions count'; } + getDisplayName(): string { return 'JJ Insertions'; } + getCategory(): string { return 'Jujutsu'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getHideNoJjModifierText(item) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleNoJjAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoJj = isHideNoJjEnabled(item); + + if (context.isPreview) { + return '+42'; + } + + if (!isInsideJjWorkspace(context)) { + return hideNoJj ? null : '(no jj)'; + } + + const changes = getJjChangeCounts(context); + return `+${changes.insertions}`; + } + + getCustomKeybinds(): CustomKeybind[] { + return getHideNoJjKeybinds(); + } + + supportsRawValue(): boolean { return false; } + supportsColors(item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/JjRootDir.ts b/src/widgets/JjRootDir.ts new file mode 100644 index 0000000..de78388 --- /dev/null +++ b/src/widgets/JjRootDir.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 { + isInsideJjWorkspace, + runJj +} from '../utils/jj'; + +import { + getHideNoJjKeybinds, + getHideNoJjModifierText, + handleToggleNoJjAction, + isHideNoJjEnabled +} from './shared/jj-no-jj'; + +export class JjRootDirWidget implements Widget { + getDefaultColor(): string { return 'cyan'; } + getDescription(): string { return 'Shows the jj workspace root directory name'; } + getDisplayName(): string { return 'JJ Root Dir'; } + getCategory(): string { return 'Jujutsu'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getHideNoJjModifierText(item) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleNoJjAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoJj = isHideNoJjEnabled(item); + + if (context.isPreview) { + return 'my-repo'; + } + + if (!isInsideJjWorkspace(context)) { + return hideNoJj ? null : 'no jj'; + } + + const rootDir = runJj('workspace 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 getHideNoJjKeybinds(); + } + + supportsRawValue(): boolean { return false; } + supportsColors(item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/__tests__/JjChanges.test.ts b/src/widgets/__tests__/JjChanges.test.ts new file mode 100644 index 0000000..a7eb3a0 --- /dev/null +++ b/src/widgets/__tests__/JjChanges.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 { 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', () => { + mockExecSync.mockReturnValueOnce('/my/project\n'); + mockExecSync.mockReturnValueOnce('2 files changed, 5 insertions(+), 3 deletions(-)'); + + expect(render({ cwd: '/my/project' })).toBe('(+5,-3)'); + }); + + it('should render no jj when not in workspace', () => { + mockExecSync.mockImplementation(() => { throw new Error('No jj'); }); + + expect(render()).toBe('(no jj)'); + }); + + it('should hide no jj when configured', () => { + mockExecSync.mockImplementation(() => { throw new Error('No jj'); }); + + expect(render({ hideNoJj: true })).toBeNull(); + }); + + it('should render zero changes when no diff output', () => { + mockExecSync.mockReturnValueOnce('/my/project\n'); + mockExecSync.mockReturnValueOnce(''); + + expect(render({ cwd: '/my/project' })).toBe('(+0,-0)'); + }); +}); \ No newline at end of file diff --git a/src/widgets/__tests__/JjDeletions.test.ts b/src/widgets/__tests__/JjDeletions.test.ts new file mode 100644 index 0000000..c399d23 --- /dev/null +++ b/src/widgets/__tests__/JjDeletions.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 { JjDeletionsWidget } from '../JjDeletions'; + +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 JjDeletionsWidget(); + const context: RenderContext = { + isPreview: options.isPreview, + data: options.cwd ? { cwd: options.cwd } : undefined + }; + const item: WidgetItem = { + id: 'jj-deletions', + type: 'jj-deletions', + metadata: options.hideNoJj ? { hideNoJj: 'true' } : undefined + }; + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('JjDeletionsWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render preview', () => { + expect(render({ isPreview: true })).toBe('-10'); + }); + + it('should render deletions', () => { + mockExecSync.mockReturnValueOnce('/my/project\n'); + mockExecSync.mockReturnValueOnce('2 files changed, 5 insertions(+), 8 deletions(-)'); + + expect(render({ cwd: '/my/project' })).toBe('-8'); + }); + + it('should render no jj when not in workspace', () => { + mockExecSync.mockImplementation(() => { throw new Error('No jj'); }); + + expect(render()).toBe('(no jj)'); + }); + + it('should hide no jj when configured', () => { + mockExecSync.mockImplementation(() => { throw new Error('No jj'); }); + + expect(render({ hideNoJj: true })).toBeNull(); + }); + + it('should render zero deletions when no diff output', () => { + mockExecSync.mockReturnValueOnce('/my/project\n'); + mockExecSync.mockReturnValueOnce(''); + + expect(render({ cwd: '/my/project' })).toBe('-0'); + }); +}); \ No newline at end of file diff --git a/src/widgets/__tests__/JjDescription.test.ts b/src/widgets/__tests__/JjDescription.test.ts new file mode 100644 index 0000000..9e7c2e4 --- /dev/null +++ b/src/widgets/__tests__/JjDescription.test.ts @@ -0,0 +1,84 @@ +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 { JjDescriptionWidget } from '../JjDescription'; + +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 JjDescriptionWidget(); + const context: RenderContext = { + isPreview: options.isPreview, + data: options.cwd ? { cwd: options.cwd } : undefined + }; + const item: WidgetItem = { + id: 'jj-description', + type: 'jj-description', + metadata: options.hideNoJj ? { hideNoJj: 'true' } : undefined + }; + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('JjDescriptionWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render preview', () => { + expect(render({ isPreview: true })).toBe('(no description set)'); + }); + + it('should render description', () => { + mockExecSync.mockReturnValueOnce('/my/project\n'); + mockExecSync.mockReturnValueOnce('fix: update readme'); + + expect(render({ cwd: '/my/project' })).toBe('fix: update readme'); + }); + + it('should render no jj when not in workspace', () => { + mockExecSync.mockImplementation(() => { throw new Error('No jj'); }); + + expect(render()).toBe('no jj'); + }); + + it('should hide no jj when configured', () => { + mockExecSync.mockImplementation(() => { throw new Error('No jj'); }); + + expect(render({ hideNoJj: true })).toBeNull(); + }); + + it('should render no description when description is empty', () => { + mockExecSync.mockReturnValueOnce('/my/project\n'); + mockExecSync.mockReturnValueOnce(''); + + expect(render({ cwd: '/my/project' })).toBe('(no description)'); + }); + + it('should render no description when command returns null', () => { + mockExecSync.mockReturnValueOnce('/my/project\n'); + mockExecSync.mockImplementation(() => { throw new Error('Failed'); }); + + expect(render({ cwd: '/my/project' })).toBe('(no description)'); + }); +}); \ No newline at end of file diff --git a/src/widgets/__tests__/JjInsertions.test.ts b/src/widgets/__tests__/JjInsertions.test.ts new file mode 100644 index 0000000..6877504 --- /dev/null +++ b/src/widgets/__tests__/JjInsertions.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 { JjInsertionsWidget } from '../JjInsertions'; + +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 JjInsertionsWidget(); + const context: RenderContext = { + isPreview: options.isPreview, + data: options.cwd ? { cwd: options.cwd } : undefined + }; + const item: WidgetItem = { + id: 'jj-insertions', + type: 'jj-insertions', + metadata: options.hideNoJj ? { hideNoJj: 'true' } : undefined + }; + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('JjInsertionsWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render preview', () => { + expect(render({ isPreview: true })).toBe('+42'); + }); + + it('should render insertions', () => { + mockExecSync.mockReturnValueOnce('/my/project\n'); + mockExecSync.mockReturnValueOnce('2 files changed, 7 insertions(+), 3 deletions(-)'); + + expect(render({ cwd: '/my/project' })).toBe('+7'); + }); + + it('should render no jj when not in workspace', () => { + mockExecSync.mockImplementation(() => { throw new Error('No jj'); }); + + expect(render()).toBe('(no jj)'); + }); + + it('should hide no jj when configured', () => { + mockExecSync.mockImplementation(() => { throw new Error('No jj'); }); + + expect(render({ hideNoJj: true })).toBeNull(); + }); + + it('should render zero insertions when no diff output', () => { + mockExecSync.mockReturnValueOnce('/my/project\n'); + mockExecSync.mockReturnValueOnce(''); + + expect(render({ cwd: '/my/project' })).toBe('+0'); + }); +}); \ No newline at end of file diff --git a/src/widgets/__tests__/JjRootDir.test.ts b/src/widgets/__tests__/JjRootDir.test.ts new file mode 100644 index 0000000..96c2fde --- /dev/null +++ b/src/widgets/__tests__/JjRootDir.test.ts @@ -0,0 +1,84 @@ +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 dir 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 workspace', () => { + mockExecSync.mockImplementation(() => { throw new Error('No jj'); }); + + expect(render()).toBe('no jj'); + }); + + it('should hide no jj when configured', () => { + mockExecSync.mockImplementation(() => { throw new Error('No jj'); }); + + 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({ cwd: '/home/user/my-project' })).toBe('my-project'); + }); + + it('should render no jj when workspace root returns null', () => { + mockExecSync.mockReturnValueOnce('/home/user/project\n'); + mockExecSync.mockImplementation(() => { throw new Error('Failed'); }); + + expect(render({ cwd: '/home/user/project' })).toBe('no jj'); + }); +}); \ No newline at end of file From bbd561114eeb32b1a5b61757f0d8239e275bac6a Mon Sep 17 00:00:00 2001 From: seanb4t <4678+seanb4t@users.noreply.github.com> Date: Sat, 7 Mar 2026 15:15:49 -0500 Subject: [PATCH 4/7] feat(jj): add JjWorkspace widget for displaying current workspace name Add getJjCurrentWorkspace() utility that parses `jj workspace list` output to extract the current workspace name from the first line. Implement JjWorkspaceWidget with blue color, W: prefix, raw value support, and hide-no-jj toggle. Include tests for both the utility function and widget. Co-Authored-By: Claude Opus 4.6 --- src/utils/__tests__/jj.test.ts | 27 ++++++ src/utils/jj.ts | 15 ++++ src/widgets/JjWorkspace.ts | 61 +++++++++++++ src/widgets/__tests__/JjWorkspace.test.ts | 104 ++++++++++++++++++++++ 4 files changed, 207 insertions(+) create mode 100644 src/widgets/JjWorkspace.ts create mode 100644 src/widgets/__tests__/JjWorkspace.test.ts diff --git a/src/utils/__tests__/jj.test.ts b/src/utils/__tests__/jj.test.ts index f36cde4..843a3f0 100644 --- a/src/utils/__tests__/jj.test.ts +++ b/src/utils/__tests__/jj.test.ts @@ -10,6 +10,7 @@ import { import type { RenderContext } from '../../types/RenderContext'; import { getJjChangeCounts, + getJjCurrentWorkspace, isInsideJjWorkspace, resolveJjCwd, runJj @@ -137,6 +138,32 @@ describe('jj utils', () => { }); }); + describe('getJjCurrentWorkspace', () => { + it('returns the workspace name from the first line', () => { + mockExecSync.mockReturnValue('default: kpqxywon 2f73e05c (no description set)\nfeature-work: spzqtmlo abc12345 (no description set)'); + + expect(getJjCurrentWorkspace({})).toBe('default'); + }); + + it('returns non-default workspace name', () => { + mockExecSync.mockReturnValue('feature-work: spzqtmlo abc12345 (no description set)'); + + expect(getJjCurrentWorkspace({})).toBe('feature-work'); + }); + + it('returns null when command fails', () => { + mockExecSync.mockImplementation(() => { throw new Error('jj failed'); }); + + expect(getJjCurrentWorkspace({})).toBeNull(); + }); + + it('returns null when output is empty', () => { + mockExecSync.mockReturnValue(' \n'); + + expect(getJjCurrentWorkspace({})).toBeNull(); + }); + }); + describe('getJjChangeCounts', () => { it('parses insertions and deletions from jj diff --stat', () => { mockExecSync.mockReturnValue('2 files changed, 5 insertions(+), 3 deletions(-)'); diff --git a/src/utils/jj.ts b/src/utils/jj.ts index 9e69100..bad7f32 100644 --- a/src/utils/jj.ts +++ b/src/utils/jj.ts @@ -67,6 +67,21 @@ function parseDiffStat(stat: string): JjChangeCounts { }; } +export function getJjCurrentWorkspace(context: RenderContext): string | null { + const output = runJj('workspace list', context); + if (!output) + return null; + + const firstLine = output.split('\n')[0] ?? ''; + const colonIndex = firstLine.indexOf(':'); + if (colonIndex === -1) + return null; + + const name = firstLine.slice(0, colonIndex).trim(); + + return name.length > 0 ? name : null; +} + export function getJjChangeCounts(context: RenderContext): JjChangeCounts { const stat = runJj('diff --stat', context) ?? ''; diff --git a/src/widgets/JjWorkspace.ts b/src/widgets/JjWorkspace.ts new file mode 100644 index 0000000..d08722e --- /dev/null +++ b/src/widgets/JjWorkspace.ts @@ -0,0 +1,61 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + getJjCurrentWorkspace, + isInsideJjWorkspace +} from '../utils/jj'; + +import { + getHideNoJjKeybinds, + getHideNoJjModifierText, + handleToggleNoJjAction, + isHideNoJjEnabled +} from './shared/jj-no-jj'; + +export class JjWorkspaceWidget implements Widget { + getDefaultColor(): string { return 'blue'; } + getDescription(): string { return 'Shows the current jj workspace name'; } + getDisplayName(): string { return 'JJ Workspace'; } + getCategory(): string { return 'Jujutsu'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getHideNoJjModifierText(item) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + return handleToggleNoJjAction(action, item); + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const hideNoJj = isHideNoJjEnabled(item); + + if (context.isPreview) { + return item.rawValue ? 'default' : 'W: default'; + } + + if (!isInsideJjWorkspace(context)) { + return hideNoJj ? null : 'W: no jj'; + } + + const workspace = getJjCurrentWorkspace(context); + if (workspace) + return item.rawValue ? workspace : `W: ${workspace}`; + + return hideNoJj ? null : 'W: no jj'; + } + + getCustomKeybinds(): CustomKeybind[] { + return getHideNoJjKeybinds(); + } + + supportsRawValue(): boolean { return true; } + supportsColors(_item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/__tests__/JjWorkspace.test.ts b/src/widgets/__tests__/JjWorkspace.test.ts new file mode 100644 index 0000000..691fe12 --- /dev/null +++ b/src/widgets/__tests__/JjWorkspace.test.ts @@ -0,0 +1,104 @@ +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('W: default'); + }); + + it('should render preview with raw value', () => { + expect(render({ isPreview: true, rawValue: true })).toBe('default'); + }); + + it('should render workspace name', () => { + mockExecSync.mockReturnValueOnce('/tmp/workspace\n'); + mockExecSync.mockReturnValueOnce('default: kpqxywon 2f73e05c (no description set)\n'); + + expect(render({ cwd: '/tmp/workspace' })).toBe('W: default'); + }); + + it('should render raw workspace name', () => { + mockExecSync.mockReturnValueOnce('/tmp/workspace\n'); + mockExecSync.mockReturnValueOnce('feature-work: spzqtmlo abc12345 (no description set)\n'); + + expect(render({ rawValue: true })).toBe('feature-work'); + }); + + it('should render non-default workspace name', () => { + mockExecSync.mockReturnValueOnce('/tmp/workspace\n'); + mockExecSync.mockReturnValueOnce('feature-work: spzqtmlo abc12345 (no description set)\ndefault: kpqxywon 2f73e05c (no description set)\n'); + + expect(render()).toBe('W: feature-work'); + }); + + it('should render no jj when not in workspace', () => { + mockExecSync.mockImplementation(() => { throw new Error('No jj'); }); + + expect(render()).toBe('W: no jj'); + }); + + it('should hide no jj when configured', () => { + mockExecSync.mockImplementation(() => { throw new Error('No jj'); }); + + expect(render({ hideNoJj: true })).toBeNull(); + }); + + it('should render no jj when workspace list is empty', () => { + mockExecSync.mockReturnValueOnce('/tmp/workspace\n'); + mockExecSync.mockReturnValueOnce(''); + + expect(render()).toBe('W: no jj'); + }); + + it('should render no jj when command fails', () => { + mockExecSync.mockReturnValueOnce('/tmp/workspace\n'); + mockExecSync.mockImplementation(() => { throw new Error('Command failed'); }); + + expect(render()).toBe('W: no jj'); + }); +}); \ No newline at end of file From 689f5e27a96bf240eb2339945e52706454297985 Mon Sep 17 00:00:00 2001 From: seanb4t <4678+seanb4t@users.noreply.github.com> Date: Sat, 7 Mar 2026 15:18:08 -0500 Subject: [PATCH 5/7] feat(jj): register all jj widgets in manifest and add shared behavior tests Add all 8 jj widgets (JjChange, JjBookmark, JjChanges, JjInsertions, JjDeletions, JjRootDir, JjDescription, JjWorkspace) to the widget exports and manifest registry. Add shared behavior test suite validating hide-no-jj keybind, metadata toggling, editor display, and Jujutsu category across all jj widgets. Co-Authored-By: Claude Opus 4.6 --- src/utils/widget-manifest.ts | 8 +++ .../__tests__/JjWidgetSharedBehavior.test.ts | 67 +++++++++++++++++++ src/widgets/index.ts | 10 ++- 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 src/widgets/__tests__/JjWidgetSharedBehavior.test.ts diff --git a/src/utils/widget-manifest.ts b/src/utils/widget-manifest.ts index ef3011b..c711791 100644 --- a/src/utils/widget-manifest.ts +++ b/src/utils/widget-manifest.ts @@ -25,6 +25,14 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [ { type: 'git-deletions', create: () => new widgets.GitDeletionsWidget() }, { type: 'git-root-dir', create: () => new widgets.GitRootDirWidget() }, { type: 'git-worktree', create: () => new widgets.GitWorktreeWidget() }, + { type: 'jj-change', create: () => new widgets.JjChangeWidget() }, + { type: 'jj-bookmark', create: () => new widgets.JjBookmarkWidget() }, + { type: 'jj-changes', create: () => new widgets.JjChangesWidget() }, + { type: 'jj-insertions', create: () => new widgets.JjInsertionsWidget() }, + { type: 'jj-deletions', create: () => new widgets.JjDeletionsWidget() }, + { type: 'jj-root-dir', create: () => new widgets.JjRootDirWidget() }, + { type: 'jj-description', create: () => new widgets.JjDescriptionWidget() }, + { type: 'jj-workspace', create: () => new widgets.JjWorkspaceWidget() }, { 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/__tests__/JjWidgetSharedBehavior.test.ts b/src/widgets/__tests__/JjWidgetSharedBehavior.test.ts new file mode 100644 index 0000000..bde8a36 --- /dev/null +++ b/src/widgets/__tests__/JjWidgetSharedBehavior.test.ts @@ -0,0 +1,67 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { + CustomKeybind, + Widget, + WidgetItem +} from '../../types'; +import { JjBookmarkWidget } from '../JjBookmark'; +import { JjChangeWidget } from '../JjChange'; +import { JjChangesWidget } from '../JjChanges'; +import { JjDeletionsWidget } from '../JjDeletions'; +import { JjDescriptionWidget } from '../JjDescription'; +import { JjInsertionsWidget } from '../JjInsertions'; +import { JjRootDirWidget } from '../JjRootDir'; +import { JjWorkspaceWidget } from '../JjWorkspace'; + +type JjWidget = Widget & { + getCustomKeybinds: () => CustomKeybind[]; + handleEditorAction: (action: string, item: WidgetItem) => WidgetItem | null; + getCategory: () => string; +}; + +const cases: { name: string; itemType: string; widget: JjWidget }[] = [ + { name: 'JjChangeWidget', itemType: 'jj-change', widget: new JjChangeWidget() }, + { name: 'JjBookmarkWidget', itemType: 'jj-bookmark', widget: new JjBookmarkWidget() }, + { name: 'JjChangesWidget', itemType: 'jj-changes', widget: new JjChangesWidget() }, + { name: 'JjInsertionsWidget', itemType: 'jj-insertions', widget: new JjInsertionsWidget() }, + { name: 'JjDeletionsWidget', itemType: 'jj-deletions', widget: new JjDeletionsWidget() }, + { name: 'JjRootDirWidget', itemType: 'jj-root-dir', widget: new JjRootDirWidget() }, + { name: 'JjDescriptionWidget', itemType: 'jj-description', widget: new JjDescriptionWidget() }, + { name: 'JjWorkspaceWidget', itemType: 'jj-workspace', widget: new JjWorkspaceWidget() } +]; + +describe('Jj widget shared behavior', () => { + it.each(cases)('$name should expose hide-no-jj keybind', ({ widget }) => { + expect(widget.getCustomKeybinds()).toEqual([ + { key: 'h', label: '(h)ide \'no jj\' message', action: 'toggle-nojj' } + ]); + }); + + 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 show hide-no-jj modifier in editor display', ({ widget, itemType }) => { + const display = widget.getEditorDisplay({ + id: itemType, + type: itemType, + metadata: { hideNoJj: 'true' } + }); + + expect(display.modifierText).toBe('(hide \'no jj\')'); + }); + + it.each(cases)('$name should have category Jujutsu', ({ widget }) => { + expect(widget.getCategory()).toBe('Jujutsu'); + }); +}); \ No newline at end of file diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 13fc932..9835621 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -33,4 +33,12 @@ export { BlockResetTimerWidget } from './BlockResetTimer'; export { WeeklyResetTimerWidget } from './WeeklyResetTimer'; export { ContextBarWidget } from './ContextBar'; export { LinkWidget } from './Link'; -export { SkillsWidget } from './Skills'; \ No newline at end of file +export { SkillsWidget } from './Skills'; +export { JjChangeWidget } from './JjChange'; +export { JjBookmarkWidget } from './JjBookmark'; +export { JjChangesWidget } from './JjChanges'; +export { JjInsertionsWidget } from './JjInsertions'; +export { JjDeletionsWidget } from './JjDeletions'; +export { JjRootDirWidget } from './JjRootDir'; +export { JjDescriptionWidget } from './JjDescription'; +export { JjWorkspaceWidget } from './JjWorkspace'; \ No newline at end of file From f131440e1ddbb0e69bb2b769d1f28502cb6795fd Mon Sep 17 00:00:00 2001 From: seanb4t <4678+seanb4t@users.noreply.github.com> Date: Sat, 7 Mar 2026 15:43:08 -0500 Subject: [PATCH 6/7] fix(jj): consolidate runJj/runJjRaw, fix double subprocess and hideNoJj invariant - Merge runJjRaw into runJj with allowEmpty parameter to eliminate duplication while preserving semantic distinction between empty output and command failure - Fix JjRootDir calling jj workspace root twice per render by removing redundant isInsideJjWorkspace guard - Extract getRootDirName to module-level function (matches project convention of no private helper methods) - Fix JjDescription ignoring hideNoJj flag when command fails after workspace check passes - Fix JjDescription preview string inconsistency ('(no description set)' vs '(no description)') - Add tests for allowEmpty behavior and JjDescription hideNoJj command failure case Co-Authored-By: Claude Opus 4.6 --- src/utils/__tests__/jj.test.ts | 18 ++++++++++++ src/utils/jj.ts | 21 +++----------- src/widgets/JjBookmark.ts | 4 +-- src/widgets/JjDescription.ts | 12 ++++---- src/widgets/JjRootDir.ts | 31 ++++++++------------- src/widgets/__tests__/JjDescription.test.ts | 13 +++++++-- src/widgets/__tests__/JjRootDir.test.ts | 9 ------ 7 files changed, 52 insertions(+), 56 deletions(-) diff --git a/src/utils/__tests__/jj.test.ts b/src/utils/__tests__/jj.test.ts index 843a3f0..8b1b6a0 100644 --- a/src/utils/__tests__/jj.test.ts +++ b/src/utils/__tests__/jj.test.ts @@ -122,6 +122,24 @@ describe('jj utils', () => { expect(runJj('status', {})).toBeNull(); }); + + it('returns empty string when allowEmpty is true and output is empty', () => { + mockExecSync.mockReturnValue(' \n'); + + expect(runJj('workspace root', {}, true)).toBe(''); + }); + + it('returns non-empty string when allowEmpty is true and output has content', () => { + mockExecSync.mockReturnValue(' some-output \n'); + + expect(runJj('log', {}, true)).toBe('some-output'); + }); + + it('returns null when allowEmpty is true and command fails', () => { + mockExecSync.mockImplementation(() => { throw new Error('jj failed'); }); + + expect(runJj('status', {}, true)).toBeNull(); + }); }); describe('isInsideJjWorkspace', () => { diff --git a/src/utils/jj.ts b/src/utils/jj.ts index bad7f32..f2c9b88 100644 --- a/src/utils/jj.ts +++ b/src/utils/jj.ts @@ -23,7 +23,9 @@ export function resolveJjCwd(context: RenderContext): string | undefined { return undefined; } -export function runJj(command: string, context: RenderContext): string | null { +// Returns trimmed stdout, or null if empty or on error. +// Pass allowEmpty=true when an empty result is semantically distinct from failure. +export function runJj(command: string, context: RenderContext, allowEmpty = false): string | null { try { const cwd = resolveJjCwd(context); const output = execSync(`jj ${command}`, { @@ -32,22 +34,7 @@ export function runJj(command: string, context: RenderContext): string | null { ...(cwd ? { cwd } : {}) }).trim(); - return output.length > 0 ? output : null; - } catch { - return null; - } -} - -export function runJjRaw(command: string, context: RenderContext): string | null { - try { - const cwd = resolveJjCwd(context); - const output = execSync(`jj ${command}`, { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'ignore'], - ...(cwd ? { cwd } : {}) - }).trim(); - - return output; + return (allowEmpty || output.length > 0) ? output : null; } catch { return null; } diff --git a/src/widgets/JjBookmark.ts b/src/widgets/JjBookmark.ts index 013aa1d..667163b 100644 --- a/src/widgets/JjBookmark.ts +++ b/src/widgets/JjBookmark.ts @@ -8,7 +8,7 @@ import type { } from '../types/Widget'; import { isInsideJjWorkspace, - runJjRaw + runJj } from '../utils/jj'; import { @@ -45,7 +45,7 @@ export class JjBookmarkWidget implements Widget { return hideNoJj ? null : '@ no jj'; } - const bookmarks = runJjRaw('log --no-graph -r @ -T \'bookmarks.join(",")\'', context); + const bookmarks = runJj('log --no-graph -r @ -T \'bookmarks.join(",")\'', context, true); if (bookmarks === null) { return hideNoJj ? null : '@ no jj'; } diff --git a/src/widgets/JjDescription.ts b/src/widgets/JjDescription.ts index 3f47a0e..b5447ce 100644 --- a/src/widgets/JjDescription.ts +++ b/src/widgets/JjDescription.ts @@ -8,7 +8,7 @@ import type { } from '../types/Widget'; import { isInsideJjWorkspace, - runJjRaw + runJj } from '../utils/jj'; import { @@ -38,19 +38,19 @@ export class JjDescriptionWidget implements Widget { const hideNoJj = isHideNoJjEnabled(item); if (context.isPreview) { - return '(no description set)'; + return '(no description)'; } if (!isInsideJjWorkspace(context)) { return hideNoJj ? null : 'no jj'; } - const description = runJjRaw('log --no-graph -r @ -T \'description.first_line()\'', context); - if (description === null || description.length === 0) { - return '(no description)'; + const description = runJj('log --no-graph -r @ -T \'description.first_line()\'', context, true); + if (description === null) { + return hideNoJj ? null : 'no jj'; } - return description; + return description.length > 0 ? description : '(no description)'; } getCustomKeybinds(): CustomKeybind[] { diff --git a/src/widgets/JjRootDir.ts b/src/widgets/JjRootDir.ts index de78388..adaba12 100644 --- a/src/widgets/JjRootDir.ts +++ b/src/widgets/JjRootDir.ts @@ -6,10 +6,7 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; -import { - isInsideJjWorkspace, - runJj -} from '../utils/jj'; +import { runJj } from '../utils/jj'; import { getHideNoJjKeybinds, @@ -18,6 +15,14 @@ import { isHideNoJjEnabled } from './shared/jj-no-jj'; +function 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; +} + export class JjRootDirWidget implements Widget { getDefaultColor(): string { return 'cyan'; } getDescription(): string { return 'Shows the jj workspace root directory name'; } @@ -41,24 +46,12 @@ export class JjRootDirWidget implements Widget { return 'my-repo'; } - if (!isInsideJjWorkspace(context)) { - return hideNoJj ? null : 'no jj'; - } - const rootDir = runJj('workspace root', context); - if (rootDir) { - return this.getRootDirName(rootDir); + if (!rootDir) { + return hideNoJj ? null : 'no jj'; } - 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; + return getRootDirName(rootDir); } getCustomKeybinds(): CustomKeybind[] { diff --git a/src/widgets/__tests__/JjDescription.test.ts b/src/widgets/__tests__/JjDescription.test.ts index 9e7c2e4..15fd748 100644 --- a/src/widgets/__tests__/JjDescription.test.ts +++ b/src/widgets/__tests__/JjDescription.test.ts @@ -46,7 +46,7 @@ describe('JjDescriptionWidget', () => { }); it('should render preview', () => { - expect(render({ isPreview: true })).toBe('(no description set)'); + expect(render({ isPreview: true })).toBe('(no description)'); }); it('should render description', () => { @@ -75,10 +75,17 @@ describe('JjDescriptionWidget', () => { expect(render({ cwd: '/my/project' })).toBe('(no description)'); }); - it('should render no description when command returns null', () => { + it('should render no description when command fails', () => { mockExecSync.mockReturnValueOnce('/my/project\n'); mockExecSync.mockImplementation(() => { throw new Error('Failed'); }); - expect(render({ cwd: '/my/project' })).toBe('(no description)'); + expect(render({ cwd: '/my/project' })).toBe('no jj'); + }); + + it('should hide when command fails and hideNoJj enabled', () => { + mockExecSync.mockReturnValueOnce('/my/project\n'); + mockExecSync.mockImplementation(() => { throw new Error('Failed'); }); + + expect(render({ cwd: '/my/project', hideNoJj: true })).toBeNull(); }); }); \ No newline at end of file diff --git a/src/widgets/__tests__/JjRootDir.test.ts b/src/widgets/__tests__/JjRootDir.test.ts index 96c2fde..7f3e5f3 100644 --- a/src/widgets/__tests__/JjRootDir.test.ts +++ b/src/widgets/__tests__/JjRootDir.test.ts @@ -51,7 +51,6 @@ describe('JjRootDirWidget', () => { it('should render root dir 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'); }); @@ -70,15 +69,7 @@ describe('JjRootDirWidget', () => { it('should handle trailing slashes', () => { 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 workspace root returns null', () => { - mockExecSync.mockReturnValueOnce('/home/user/project\n'); - mockExecSync.mockImplementation(() => { throw new Error('Failed'); }); - - expect(render({ cwd: '/home/user/project' })).toBe('no jj'); - }); }); \ No newline at end of file From 74ff0a8844e74ac80368e7327414a782444d34d7 Mon Sep 17 00:00:00 2001 From: seanb4t <4678+seanb4t@users.noreply.github.com> Date: Sat, 7 Mar 2026 16:00:25 -0500 Subject: [PATCH 7/7] feat(git): add hideWhenJj toggle to suppress git widgets in jj workspaces Add a new per-widget toggle that hides git widgets when a jj workspace is detected, enabling clean colocated repo support. Users can press 'j' in the TUI items editor to enable this on any git widget. - Add git-hide-when-jj.ts shared module with metadata flag, keybind, and editor display helpers - Update all 6 git widgets (Branch, Changes, Insertions, Deletions, RootDir, Worktree) with the new toggle - Compose modifier text from both hideNoGit and hideWhenJj flags - Chain action handlers via nullish coalescing - Add shared behavior tests for toggle, keybind, and modifier display Co-Authored-By: Claude Opus 4.6 --- src/widgets/GitBranch.ts | 20 ++++++++-- src/widgets/GitChanges.ts | 20 ++++++++-- src/widgets/GitDeletions.ts | 20 ++++++++-- src/widgets/GitInsertions.ts | 20 ++++++++-- src/widgets/GitRootDir.ts | 20 ++++++++-- src/widgets/GitWorktree.ts | 20 ++++++++-- .../__tests__/GitWidgetSharedBehavior.test.ts | 34 +++++++++++++++- src/widgets/shared/git-hide-when-jj.ts | 39 +++++++++++++++++++ src/widgets/shared/git-no-git.ts | 6 ++- 9 files changed, 172 insertions(+), 27 deletions(-) create mode 100644 src/widgets/shared/git-hide-when-jj.ts diff --git a/src/widgets/GitBranch.ts b/src/widgets/GitBranch.ts index 4239289..a945db9 100644 --- a/src/widgets/GitBranch.ts +++ b/src/widgets/GitBranch.ts @@ -10,13 +10,21 @@ import { isInsideGitWorkTree, runGit } from '../utils/git'; +import { isInsideJjWorkspace } from '../utils/jj'; +import { makeModifierText } from './shared/editor-display'; import { getHideNoGitKeybinds, - getHideNoGitModifierText, + getHideNoGitModifiers, handleToggleNoGitAction, isHideNoGitEnabled } from './shared/git-no-git'; +import { + getHideWhenJjKeybinds, + getHideWhenJjModifierText, + handleToggleHideWhenJjAction, + isHideWhenJjEnabled +} from './shared/git-hide-when-jj'; export class GitBranchWidget implements Widget { getDefaultColor(): string { return 'magenta'; } @@ -26,12 +34,12 @@ export class GitBranchWidget implements Widget { getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName(), - modifierText: getHideNoGitModifierText(item) + modifierText: makeModifierText([...getHideNoGitModifiers(item), ...getHideWhenJjModifierText(item)]) }; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - return handleToggleNoGitAction(action, item); + return handleToggleNoGitAction(action, item) ?? handleToggleHideWhenJjAction(action, item); } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { @@ -41,6 +49,10 @@ export class GitBranchWidget implements Widget { return item.rawValue ? 'main' : '⎇ main'; } + if (isHideWhenJjEnabled(item) && isInsideJjWorkspace(context)) { + return null; + } + if (!isInsideGitWorkTree(context)) { return hideNoGit ? null : '⎇ no git'; } @@ -57,7 +69,7 @@ export class GitBranchWidget implements Widget { } getCustomKeybinds(): CustomKeybind[] { - return getHideNoGitKeybinds(); + return [...getHideNoGitKeybinds(), ...getHideWhenJjKeybinds()]; } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/GitChanges.ts b/src/widgets/GitChanges.ts index 5ee4aa3..fb72635 100644 --- a/src/widgets/GitChanges.ts +++ b/src/widgets/GitChanges.ts @@ -10,13 +10,21 @@ import { getGitChangeCounts, isInsideGitWorkTree } from '../utils/git'; +import { isInsideJjWorkspace } from '../utils/jj'; +import { makeModifierText } from './shared/editor-display'; import { getHideNoGitKeybinds, - getHideNoGitModifierText, + getHideNoGitModifiers, handleToggleNoGitAction, isHideNoGitEnabled } from './shared/git-no-git'; +import { + getHideWhenJjKeybinds, + getHideWhenJjModifierText, + handleToggleHideWhenJjAction, + isHideWhenJjEnabled +} from './shared/git-hide-when-jj'; export class GitChangesWidget implements Widget { getDefaultColor(): string { return 'yellow'; } @@ -26,12 +34,12 @@ export class GitChangesWidget implements Widget { getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName(), - modifierText: getHideNoGitModifierText(item) + modifierText: makeModifierText([...getHideNoGitModifiers(item), ...getHideWhenJjModifierText(item)]) }; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - return handleToggleNoGitAction(action, item); + return handleToggleNoGitAction(action, item) ?? handleToggleHideWhenJjAction(action, item); } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { @@ -41,6 +49,10 @@ export class GitChangesWidget implements Widget { return '(+42,-10)'; } + if (isHideWhenJjEnabled(item) && isInsideJjWorkspace(context)) { + return null; + } + if (!isInsideGitWorkTree(context)) { return hideNoGit ? null : '(no git)'; } @@ -50,7 +62,7 @@ export class GitChangesWidget implements Widget { } getCustomKeybinds(): CustomKeybind[] { - return getHideNoGitKeybinds(); + return [...getHideNoGitKeybinds(), ...getHideWhenJjKeybinds()]; } supportsRawValue(): boolean { return false; } diff --git a/src/widgets/GitDeletions.ts b/src/widgets/GitDeletions.ts index 1c58194..e4700d7 100644 --- a/src/widgets/GitDeletions.ts +++ b/src/widgets/GitDeletions.ts @@ -10,13 +10,21 @@ import { getGitChangeCounts, isInsideGitWorkTree } from '../utils/git'; +import { isInsideJjWorkspace } from '../utils/jj'; +import { makeModifierText } from './shared/editor-display'; import { getHideNoGitKeybinds, - getHideNoGitModifierText, + getHideNoGitModifiers, handleToggleNoGitAction, isHideNoGitEnabled } from './shared/git-no-git'; +import { + getHideWhenJjKeybinds, + getHideWhenJjModifierText, + handleToggleHideWhenJjAction, + isHideWhenJjEnabled +} from './shared/git-hide-when-jj'; export class GitDeletionsWidget implements Widget { getDefaultColor(): string { return 'red'; } @@ -26,12 +34,12 @@ export class GitDeletionsWidget implements Widget { getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName(), - modifierText: getHideNoGitModifierText(item) + modifierText: makeModifierText([...getHideNoGitModifiers(item), ...getHideWhenJjModifierText(item)]) }; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - return handleToggleNoGitAction(action, item); + return handleToggleNoGitAction(action, item) ?? handleToggleHideWhenJjAction(action, item); } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { @@ -41,6 +49,10 @@ export class GitDeletionsWidget implements Widget { return '-10'; } + if (isHideWhenJjEnabled(item) && isInsideJjWorkspace(context)) { + return null; + } + if (!isInsideGitWorkTree(context)) { return hideNoGit ? null : '(no git)'; } @@ -50,7 +62,7 @@ export class GitDeletionsWidget implements Widget { } getCustomKeybinds(): CustomKeybind[] { - return getHideNoGitKeybinds(); + return [...getHideNoGitKeybinds(), ...getHideWhenJjKeybinds()]; } supportsRawValue(): boolean { return false; } diff --git a/src/widgets/GitInsertions.ts b/src/widgets/GitInsertions.ts index 3988552..07466ab 100644 --- a/src/widgets/GitInsertions.ts +++ b/src/widgets/GitInsertions.ts @@ -10,13 +10,21 @@ import { getGitChangeCounts, isInsideGitWorkTree } from '../utils/git'; +import { isInsideJjWorkspace } from '../utils/jj'; +import { makeModifierText } from './shared/editor-display'; import { getHideNoGitKeybinds, - getHideNoGitModifierText, + getHideNoGitModifiers, handleToggleNoGitAction, isHideNoGitEnabled } from './shared/git-no-git'; +import { + getHideWhenJjKeybinds, + getHideWhenJjModifierText, + handleToggleHideWhenJjAction, + isHideWhenJjEnabled +} from './shared/git-hide-when-jj'; export class GitInsertionsWidget implements Widget { getDefaultColor(): string { return 'green'; } @@ -26,12 +34,12 @@ export class GitInsertionsWidget implements Widget { getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName(), - modifierText: getHideNoGitModifierText(item) + modifierText: makeModifierText([...getHideNoGitModifiers(item), ...getHideWhenJjModifierText(item)]) }; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - return handleToggleNoGitAction(action, item); + return handleToggleNoGitAction(action, item) ?? handleToggleHideWhenJjAction(action, item); } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { @@ -41,6 +49,10 @@ export class GitInsertionsWidget implements Widget { return '+42'; } + if (isHideWhenJjEnabled(item) && isInsideJjWorkspace(context)) { + return null; + } + if (!isInsideGitWorkTree(context)) { return hideNoGit ? null : '(no git)'; } @@ -50,7 +62,7 @@ export class GitInsertionsWidget implements Widget { } getCustomKeybinds(): CustomKeybind[] { - return getHideNoGitKeybinds(); + return [...getHideNoGitKeybinds(), ...getHideWhenJjKeybinds()]; } supportsRawValue(): boolean { return false; } diff --git a/src/widgets/GitRootDir.ts b/src/widgets/GitRootDir.ts index 56bce64..428f11e 100644 --- a/src/widgets/GitRootDir.ts +++ b/src/widgets/GitRootDir.ts @@ -10,13 +10,21 @@ import { isInsideGitWorkTree, runGit } from '../utils/git'; +import { isInsideJjWorkspace } from '../utils/jj'; +import { makeModifierText } from './shared/editor-display'; import { getHideNoGitKeybinds, - getHideNoGitModifierText, + getHideNoGitModifiers, handleToggleNoGitAction, isHideNoGitEnabled } from './shared/git-no-git'; +import { + getHideWhenJjKeybinds, + getHideWhenJjModifierText, + handleToggleHideWhenJjAction, + isHideWhenJjEnabled +} from './shared/git-hide-when-jj'; export class GitRootDirWidget implements Widget { getDefaultColor(): string { return 'cyan'; } @@ -26,12 +34,12 @@ export class GitRootDirWidget implements Widget { getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName(), - modifierText: getHideNoGitModifierText(item) + modifierText: makeModifierText([...getHideNoGitModifiers(item), ...getHideWhenJjModifierText(item)]) }; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - return handleToggleNoGitAction(action, item); + return handleToggleNoGitAction(action, item) ?? handleToggleHideWhenJjAction(action, item); } render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { @@ -41,6 +49,10 @@ export class GitRootDirWidget implements Widget { return 'my-repo'; } + if (isHideWhenJjEnabled(item) && isInsideJjWorkspace(context)) { + return null; + } + if (!isInsideGitWorkTree(context)) { return hideNoGit ? null : 'no git'; } @@ -66,7 +78,7 @@ export class GitRootDirWidget implements Widget { } getCustomKeybinds(): CustomKeybind[] { - return getHideNoGitKeybinds(); + return [...getHideNoGitKeybinds(), ...getHideWhenJjKeybinds()]; } supportsRawValue(): boolean { return false; } diff --git a/src/widgets/GitWorktree.ts b/src/widgets/GitWorktree.ts index fb39382..043690b 100644 --- a/src/widgets/GitWorktree.ts +++ b/src/widgets/GitWorktree.ts @@ -9,13 +9,21 @@ import { isInsideGitWorkTree, runGit } from '../utils/git'; +import { isInsideJjWorkspace } from '../utils/jj'; +import { makeModifierText } from './shared/editor-display'; import { getHideNoGitKeybinds, - getHideNoGitModifierText, + getHideNoGitModifiers, handleToggleNoGitAction, isHideNoGitEnabled } from './shared/git-no-git'; +import { + getHideWhenJjKeybinds, + getHideWhenJjModifierText, + handleToggleHideWhenJjAction, + isHideWhenJjEnabled +} from './shared/git-hide-when-jj'; export class GitWorktreeWidget implements Widget { getDefaultColor(): string { return 'blue'; } @@ -25,12 +33,12 @@ export class GitWorktreeWidget implements Widget { getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName(), - modifierText: getHideNoGitModifierText(item) + modifierText: makeModifierText([...getHideNoGitModifiers(item), ...getHideWhenJjModifierText(item)]) }; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - return handleToggleNoGitAction(action, item); + return handleToggleNoGitAction(action, item) ?? handleToggleHideWhenJjAction(action, item); } render(item: WidgetItem, context: RenderContext): string | null { @@ -39,6 +47,10 @@ export class GitWorktreeWidget implements Widget { if (context.isPreview) return item.rawValue ? 'main' : '𖠰 main'; + if (isHideWhenJjEnabled(item) && isInsideJjWorkspace(context)) { + return null; + } + if (!isInsideGitWorkTree(context)) { return hideNoGit ? null : '𖠰 no git'; } @@ -80,7 +92,7 @@ export class GitWorktreeWidget implements Widget { } getCustomKeybinds(): CustomKeybind[] { - return getHideNoGitKeybinds(); + return [...getHideNoGitKeybinds(), ...getHideWhenJjKeybinds()]; } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/__tests__/GitWidgetSharedBehavior.test.ts b/src/widgets/__tests__/GitWidgetSharedBehavior.test.ts index b90ef0c..8e16ede 100644 --- a/src/widgets/__tests__/GitWidgetSharedBehavior.test.ts +++ b/src/widgets/__tests__/GitWidgetSharedBehavior.test.ts @@ -31,9 +31,10 @@ const cases: { name: string; itemType: string; widget: GitWidget }[] = [ ]; describe('Git widget shared behavior', () => { - it.each(cases)('$name should expose hide-no-git keybind', ({ widget }) => { + it.each(cases)('$name should expose hide-no-git and hide-when-jj keybinds', ({ widget }) => { expect(widget.getCustomKeybinds()).toEqual([ - { key: 'h', label: '(h)ide \'no git\' message', action: 'toggle-nogit' } + { key: 'h', label: '(h)ide \'no git\' message', action: 'toggle-nogit' }, + { key: 'j', label: 'hide when (j)j present', action: 'toggle-hide-when-jj' } ]); }); @@ -46,6 +47,15 @@ describe('Git widget shared behavior', () => { expect(toggledOff?.metadata?.hideNoGit).toBe('false'); }); + it.each(cases)('$name should toggle hideWhenJj metadata', ({ widget, itemType }) => { + const base: WidgetItem = { id: itemType, type: itemType }; + const toggledOn = widget.handleEditorAction('toggle-hide-when-jj', base); + const toggledOff = widget.handleEditorAction('toggle-hide-when-jj', toggledOn ?? base); + + expect(toggledOn?.metadata?.hideWhenJj).toBe('true'); + expect(toggledOff?.metadata?.hideWhenJj).toBe('false'); + }); + it.each(cases)('$name should show hide-no-git modifier in editor display', ({ widget, itemType }) => { const display = widget.getEditorDisplay({ id: itemType, @@ -55,4 +65,24 @@ describe('Git widget shared behavior', () => { expect(display.modifierText).toBe('(hide \'no git\')'); }); + + it.each(cases)('$name should show hide-when-jj modifier in editor display', ({ widget, itemType }) => { + const display = widget.getEditorDisplay({ + id: itemType, + type: itemType, + metadata: { hideWhenJj: 'true' } + }); + + expect(display.modifierText).toBe('(hide when jj)'); + }); + + it.each(cases)('$name should show combined modifiers in editor display', ({ widget, itemType }) => { + const display = widget.getEditorDisplay({ + id: itemType, + type: itemType, + metadata: { hideNoGit: 'true', hideWhenJj: 'true' } + }); + + expect(display.modifierText).toBe('(hide \'no git\', hide when jj)'); + }); }); \ No newline at end of file diff --git a/src/widgets/shared/git-hide-when-jj.ts b/src/widgets/shared/git-hide-when-jj.ts new file mode 100644 index 0000000..c5a2dac --- /dev/null +++ b/src/widgets/shared/git-hide-when-jj.ts @@ -0,0 +1,39 @@ +import type { + CustomKeybind, + WidgetItem +} from '../../types/Widget'; + +import { makeModifierText } from './editor-display'; +import { + isMetadataFlagEnabled, + toggleMetadataFlag +} from './metadata'; + +const HIDE_WHEN_JJ_KEY = 'hideWhenJj'; +const TOGGLE_HIDE_WHEN_JJ_ACTION = 'toggle-hide-when-jj'; + +const HIDE_WHEN_JJ_KEYBIND: CustomKeybind = { + key: 'j', + label: 'hide when (j)j present', + action: TOGGLE_HIDE_WHEN_JJ_ACTION +}; + +export function isHideWhenJjEnabled(item: WidgetItem): boolean { + return isMetadataFlagEnabled(item, HIDE_WHEN_JJ_KEY); +} + +export function getHideWhenJjModifierText(item: WidgetItem): string[] { + return isHideWhenJjEnabled(item) ? ['hide when jj'] : []; +} + +export function handleToggleHideWhenJjAction(action: string, item: WidgetItem): WidgetItem | null { + if (action !== TOGGLE_HIDE_WHEN_JJ_ACTION) { + return null; + } + + return toggleMetadataFlag(item, HIDE_WHEN_JJ_KEY); +} + +export function getHideWhenJjKeybinds(): CustomKeybind[] { + return [HIDE_WHEN_JJ_KEYBIND]; +} \ No newline at end of file diff --git a/src/widgets/shared/git-no-git.ts b/src/widgets/shared/git-no-git.ts index 826df2e..e90c96c 100644 --- a/src/widgets/shared/git-no-git.ts +++ b/src/widgets/shared/git-no-git.ts @@ -22,8 +22,12 @@ export function isHideNoGitEnabled(item: WidgetItem): boolean { return isMetadataFlagEnabled(item, HIDE_NO_GIT_KEY); } +export function getHideNoGitModifiers(item: WidgetItem): string[] { + return isHideNoGitEnabled(item) ? ['hide \'no git\''] : []; +} + export function getHideNoGitModifierText(item: WidgetItem): string | undefined { - return makeModifierText(isHideNoGitEnabled(item) ? ['hide \'no git\''] : []); + return makeModifierText(getHideNoGitModifiers(item)); } export function handleToggleNoGitAction(action: string, item: WidgetItem): WidgetItem | null {