diff --git a/src/utils/__tests__/jj.test.ts b/src/utils/__tests__/jj.test.ts new file mode 100644 index 0000000..8b1b6a0 --- /dev/null +++ b/src/utils/__tests__/jj.test.ts @@ -0,0 +1,222 @@ +import { execSync } from 'child_process'; +import { + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { + getJjChangeCounts, + getJjCurrentWorkspace, + 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(); + }); + + 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', () => { + 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('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(-)'); + + 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..f2c9b88 --- /dev/null +++ b/src/utils/jj.ts @@ -0,0 +1,76 @@ +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; +} + +// 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}`, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + ...(cwd ? { cwd } : {}) + }).trim(); + + return (allowEmpty || 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 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) ?? ''; + + return parseDiffStat(stat); +} \ No newline at end of file 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/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/JjBookmark.ts b/src/widgets/JjBookmark.ts new file mode 100644 index 0000000..667163b --- /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, + runJj +} 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 = runJj('log --no-graph -r @ -T \'bookmarks.join(",")\'', context, true); + 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/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..b5447ce --- /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, + runJj +} 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)'; + } + + if (!isInsideJjWorkspace(context)) { + return hideNoJj ? null : 'no jj'; + } + + const description = runJj('log --no-graph -r @ -T \'description.first_line()\'', context, true); + if (description === null) { + return hideNoJj ? null : 'no jj'; + } + + return description.length > 0 ? description : '(no 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..adaba12 --- /dev/null +++ b/src/widgets/JjRootDir.ts @@ -0,0 +1,63 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { runJj } from '../utils/jj'; + +import { + getHideNoJjKeybinds, + getHideNoJjModifierText, + handleToggleNoJjAction, + 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'; } + 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'; + } + + const rootDir = runJj('workspace root', context); + if (!rootDir) { + return hideNoJj ? null : 'no jj'; + } + + return getRootDirName(rootDir); + } + + 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/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__/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/__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 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..15fd748 --- /dev/null +++ b/src/widgets/__tests__/JjDescription.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 { 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)'); + }); + + 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 fails', () => { + mockExecSync.mockReturnValueOnce('/my/project\n'); + mockExecSync.mockImplementation(() => { throw new Error('Failed'); }); + + 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__/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..7f3e5f3 --- /dev/null +++ b/src/widgets/__tests__/JjRootDir.test.ts @@ -0,0 +1,75 @@ +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'); + + 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'); + + expect(render({ cwd: '/home/user/my-project' })).toBe('my-project'); + }); +}); \ No newline at end of file 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/__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 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 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 { 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