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 <sirmalloc@gmail.com>
This commit is contained in:
Joseph MearmanandMatthew Breedlove authored and GitHub committed 2026-02-02 16:03:52 -05:00
1 parent d457e55913
commit 879255523b
3 files changed
+73 -24

No files matched your search

+1
View File
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "ccstatusline",
+61 -16
View File
@@ -40,6 +40,7 @@ const LineSelector: React.FC<LineSelectorProps> = ({
}) => {
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<LineSelectorProps> = ({
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<LineSelectorProps> = ({
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<LineSelectorProps> = ({
return (
<>
<Box flexDirection='column'>
<Text bold>{title ?? 'Select Line to Edit'}</Text>
<Box>
<Text bold>
{title ?? 'Select Line to Edit'}
{' '}
</Text>
{moveMode && <Text color='blue'>[MOVE MODE]</Text>}
</Box>
<Text dimColor>
Choose which status line to configure
</Text>
<Text dimColor>
{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'}
</Text>
{moveMode ? (
<Text dimColor>↑↓ to move line, ESC or Enter to exit move mode</Text>
) : (
<Text dimColor>
{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'}
</Text>
)}
<Box marginTop={1} flexDirection='column'>
{localLines.map((line, index) => {
@@ -219,8 +262,8 @@ const LineSelector: React.FC<LineSelectorProps> = ({
return (
<Box key={index}>
<Text color={isSelected ? 'green' : undefined}>
<Text>{isSelected ? '▶ ' : ' '}</Text>
<Text color={isSelected ? (moveMode ? 'blue' : 'green') : undefined}>
<Text>{isSelected ? (moveMode ? '◆ ' : '▶ ') : ' '}</Text>
<Text>
<Text>
☰ Line
@@ -239,12 +282,14 @@ const LineSelector: React.FC<LineSelectorProps> = ({
);
})}
<Box marginTop={1}>
<Text color={selectedIndex === localLines.length ? 'green' : undefined}>
{selectedIndex === localLines.length ? '▶ ' : ' '}
← Back
</Text>
</Box>
{!moveMode && (
<Box marginTop={1}>
<Text color={selectedIndex === localLines.length ? 'green' : undefined}>
{selectedIndex === localLines.length ? '▶ ' : ' '}
← Back
</Text>
</Box>
)}
</Box>
</Box>
</>
+11 -8
View File
@@ -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');
});
});
});