From 879255523bef1631618528518fc0ab0453b0e577 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 2 Feb 2026 21:03:52 +0000 Subject: [PATCH] Add line reorder move mode and fix GitWorktree tests (#154) * fix(test): replace vi.mocked with direct mock reference in GitWorktree tests vi.mocked(execSync) was undefined because the vi.mock factory runs before imports resolve. Use a top-level vi.fn() reference instead. * feat(tui): add move mode to LineSelector for reordering lines Press 'm' to enter move mode, use arrow keys to swap lines, Enter/Escape to exit. Visual feedback mirrors ItemsEditor pattern with blue diamond marker and [MOVE MODE] label. * chore: update bun.lock * Fix for model in statusLine JSON being passed as string vs object, closes #157, version bump * fix(test): make GitWorktree execSync mock hoist-safe --------- Co-authored-by: Matthew Breedlove --- bun.lock | 1 + src/tui/components/LineSelector.tsx | 77 ++++++++++++++++++----- src/widgets/__tests__/GitWorktree.test.ts | 19 +++--- 3 files changed, 73 insertions(+), 24 deletions(-) diff --git a/bun.lock b/bun.lock index bdc89ca..32c3593 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "ccstatusline", diff --git a/src/tui/components/LineSelector.tsx b/src/tui/components/LineSelector.tsx index 1733337..11dfe0e 100644 --- a/src/tui/components/LineSelector.tsx +++ b/src/tui/components/LineSelector.tsx @@ -40,6 +40,7 @@ const LineSelector: React.FC = ({ }) => { const [selectedIndex, setSelectedIndex] = useState(initialSelection); const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [moveMode, setMoveMode] = useState(false); const [localLines, setLocalLines] = useState(lines); useEffect(() => { @@ -90,6 +91,33 @@ const LineSelector: React.FC = ({ return; } + if (moveMode) { + if (key.upArrow && selectedIndex > 0) { + const newLines = [...localLines]; + const temp = newLines[selectedIndex]; + const prev = newLines[selectedIndex - 1]; + if (temp && prev) { + [newLines[selectedIndex], newLines[selectedIndex - 1]] = [prev, temp]; + } + setLocalLines(newLines); + onLinesUpdate(newLines); + setSelectedIndex(selectedIndex - 1); + } else if (key.downArrow && selectedIndex < localLines.length - 1) { + const newLines = [...localLines]; + const temp = newLines[selectedIndex]; + const next = newLines[selectedIndex + 1]; + if (temp && next) { + [newLines[selectedIndex], newLines[selectedIndex + 1]] = [next, temp]; + } + setLocalLines(newLines); + onLinesUpdate(newLines); + setSelectedIndex(selectedIndex + 1); + } else if (key.escape || key.return) { + setMoveMode(false); + } + return; + } + switch (input) { case 'a': if (allowEditing) { @@ -101,6 +129,11 @@ const LineSelector: React.FC = ({ setShowDeleteDialog(true); } return; + case 'm': + if (allowEditing && localLines.length > 1 && selectedIndex < localLines.length) { + setMoveMode(true); + } + return; } if (key.escape) { @@ -198,17 +231,27 @@ const LineSelector: React.FC = ({ return ( <> - {title ?? 'Select Line to Edit'} + + + {title ?? 'Select Line to Edit'} + {' '} + + {moveMode && [MOVE MODE]} + Choose which status line to configure - - {allowEditing ? ( - localLines.length > 1 - ? '(a) to append new line, (d) to delete line, ESC to go back' - : '(a) to append new line, ESC to go back' - ) : 'ESC to go back'} - + {moveMode ? ( + ↑↓ to move line, ESC or Enter to exit move mode + ) : ( + + {allowEditing ? ( + localLines.length > 1 + ? '(a) to append new line, (d) to delete line, (m) to move line, ESC to go back' + : '(a) to append new line, ESC to go back' + ) : 'ESC to go back'} + + )} {localLines.map((line, index) => { @@ -219,8 +262,8 @@ const LineSelector: React.FC = ({ return ( - - {isSelected ? '▶ ' : ' '} + + {isSelected ? (moveMode ? '◆ ' : '▶ ') : ' '} ☰ Line @@ -239,12 +282,14 @@ const LineSelector: React.FC = ({ ); })} - - - {selectedIndex === localLines.length ? '▶ ' : ' '} - ← Back - - + {!moveMode && ( + + + {selectedIndex === localLines.length ? '▶ ' : ' '} + ← Back + + + )} diff --git a/src/widgets/__tests__/GitWorktree.test.ts b/src/widgets/__tests__/GitWorktree.test.ts index 88294b3..79a43d8 100644 --- a/src/widgets/__tests__/GitWorktree.test.ts +++ b/src/widgets/__tests__/GitWorktree.test.ts @@ -1,4 +1,3 @@ -import { execSync } from 'child_process'; import { beforeEach, describe, @@ -7,13 +6,17 @@ import { vi } from 'vitest'; +vi.mock('child_process', () => ({ execSync: vi.fn() })); + +import { execSync } from 'child_process'; + import type { RenderContext, WidgetItem } from '../../types'; import { GitWorktreeWidget } from '../GitWorktree'; -vi.mock('child_process', () => ({ execSync: vi.fn() })); +const mockExecSync = vi.mocked(execSync); function render(rawValue = false, isPreview = false) { const widget = new GitWorktreeWidget(); @@ -47,32 +50,32 @@ describe('GitWorktreeWidget', () => { }); it('should render with worktree', () => { - vi.mocked(execSync).mockReturnValue('/some/path/.git/worktrees/some-worktree'); + mockExecSync.mockReturnValue('/some/path/.git/worktrees/some-worktree'); expect(render()).toBe('𖠰 some-worktree'); }); it('should render with nested worktree', () => { - vi.mocked(execSync).mockReturnValue('/some/path/.git/worktrees/some-dir/some-worktree'); + mockExecSync.mockReturnValue('/some/path/.git/worktrees/some-dir/some-worktree'); expect(render()).toBe('𖠰 some-dir/some-worktree'); }); it('should render with no worktree', () => { - vi.mocked(execSync).mockReturnValue('.git'); + mockExecSync.mockReturnValue('.git'); expect(render()).toBe('𖠰 main'); }); it('should render with no git', () => { - vi.mocked(execSync).mockRejectedValue(new Error('No git')); + mockExecSync.mockImplementation(() => { throw new Error('No git'); }); expect(render()).toBe('𖠰 no git'); }); it('should render with invalid git dir', () => { - vi.mocked(execSync).mockReturnValue(''); + mockExecSync.mockReturnValue(''); expect(render()).toBe('𖠰 no git'); }); -}); \ No newline at end of file +});