mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
Merge tag 'v0.1.15' into feature/yiheng/sync-gemini-cli-0.1.15
This commit is contained in:
@@ -248,6 +248,89 @@ Line 3`);
|
||||
🐶`);
|
||||
});
|
||||
|
||||
it('falls back to an ellipsis when width is extremely small', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={2} maxHeight={2}>
|
||||
<Box>
|
||||
<Text>No</Text>
|
||||
<Text wrap="wrap">wrap</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).equals('N…');
|
||||
});
|
||||
|
||||
it('truncates long non-wrapping text with ellipsis', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={3} maxHeight={2}>
|
||||
<Box>
|
||||
<Text>ABCDE</Text>
|
||||
<Text wrap="wrap">wrap</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).equals('AB…');
|
||||
});
|
||||
|
||||
it('truncates non-wrapping text containing line breaks', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={3} maxHeight={2}>
|
||||
<Box>
|
||||
<Text>{'A\nBCDE'}</Text>
|
||||
<Text wrap="wrap">wrap</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).equals(`A\n…`);
|
||||
});
|
||||
|
||||
it('truncates emoji characters correctly with ellipsis', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={3} maxHeight={2}>
|
||||
<Box>
|
||||
<Text>🐶🐶🐶</Text>
|
||||
<Text wrap="wrap">wrap</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).equals(`🐶…`);
|
||||
});
|
||||
|
||||
it('shows ellipsis for multiple rows with long non-wrapping text', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<MaxSizedBox maxWidth={3} maxHeight={3}>
|
||||
<Box>
|
||||
<Text>AAA</Text>
|
||||
<Text wrap="wrap">first</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>BBB</Text>
|
||||
<Text wrap="wrap">second</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>CCC</Text>
|
||||
<Text wrap="wrap">third</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).equals(`AA…\nBB…\nCC…`);
|
||||
});
|
||||
|
||||
it('accounts for additionalHiddenLinesCount', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
|
||||
@@ -432,8 +432,85 @@ function layoutInkElementAsStyledText(
|
||||
const availableWidth = maxWidth - noWrappingWidth;
|
||||
|
||||
if (availableWidth < 1) {
|
||||
// No room to render the wrapping segments. TODO(jacob314): consider an alternative fallback strategy.
|
||||
output.push(nonWrappingContent);
|
||||
// No room to render the wrapping segments. Truncate the non-wrapping
|
||||
// content and append an ellipsis so the line always fits within maxWidth.
|
||||
|
||||
// Handle line breaks in non-wrapping content when truncating
|
||||
const lines: StyledText[][] = [];
|
||||
let currentLine: StyledText[] = [];
|
||||
let currentLineWidth = 0;
|
||||
|
||||
for (const segment of nonWrappingContent) {
|
||||
const textLines = segment.text.split('\n');
|
||||
textLines.forEach((text, index) => {
|
||||
if (index > 0) {
|
||||
// New line encountered, finish current line and start new one
|
||||
lines.push(currentLine);
|
||||
currentLine = [];
|
||||
currentLineWidth = 0;
|
||||
}
|
||||
|
||||
if (text) {
|
||||
const textWidth = stringWidth(text);
|
||||
|
||||
// When there's no room for wrapping content, be very conservative
|
||||
// For lines after the first line break, show only ellipsis if the text would be truncated
|
||||
if (index > 0 && textWidth > 0) {
|
||||
// This is content after a line break - just show ellipsis to indicate truncation
|
||||
currentLine.push({ text: '…', props: {} });
|
||||
currentLineWidth = stringWidth('…');
|
||||
} else {
|
||||
// This is the first line or a continuation, try to fit what we can
|
||||
const maxContentWidth = Math.max(0, maxWidth - stringWidth('…'));
|
||||
|
||||
if (textWidth <= maxContentWidth && currentLineWidth === 0) {
|
||||
// Text fits completely on this line
|
||||
currentLine.push({ text, props: segment.props });
|
||||
currentLineWidth += textWidth;
|
||||
} else {
|
||||
// Text needs truncation
|
||||
const codePoints = toCodePoints(text);
|
||||
let truncatedWidth = currentLineWidth;
|
||||
let sliceEndIndex = 0;
|
||||
|
||||
for (const char of codePoints) {
|
||||
const charWidth = stringWidth(char);
|
||||
if (truncatedWidth + charWidth > maxContentWidth) {
|
||||
break;
|
||||
}
|
||||
truncatedWidth += charWidth;
|
||||
sliceEndIndex++;
|
||||
}
|
||||
|
||||
const slice = codePoints.slice(0, sliceEndIndex).join('');
|
||||
if (slice) {
|
||||
currentLine.push({ text: slice, props: segment.props });
|
||||
}
|
||||
currentLine.push({ text: '…', props: {} });
|
||||
currentLineWidth = truncatedWidth + stringWidth('…');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add the last line if it has content or if the last segment ended with \n
|
||||
if (
|
||||
currentLine.length > 0 ||
|
||||
(nonWrappingContent.length > 0 &&
|
||||
nonWrappingContent[nonWrappingContent.length - 1].text.endsWith('\n'))
|
||||
) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
|
||||
// If we don't have any lines yet, add an ellipsis line
|
||||
if (lines.length === 0) {
|
||||
lines.push([{ text: '…', props: {} }]);
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
output.push(line);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
115
packages/cli/src/ui/components/shared/RadioButtonSelect.test.tsx
Normal file
115
packages/cli/src/ui/components/shared/RadioButtonSelect.test.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import {
|
||||
RadioButtonSelect,
|
||||
type RadioSelectItem,
|
||||
} from './RadioButtonSelect.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
const ITEMS: Array<RadioSelectItem<string>> = [
|
||||
{ label: 'Option 1', value: 'one' },
|
||||
{ label: 'Option 2', value: 'two' },
|
||||
{ label: 'Option 3', value: 'three', disabled: true },
|
||||
];
|
||||
|
||||
describe('<RadioButtonSelect />', () => {
|
||||
it('renders a list of items and matches snapshot', () => {
|
||||
const { lastFrame } = render(
|
||||
<RadioButtonSelect items={ITEMS} onSelect={() => {}} isFocused={true} />,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders with the second item selected and matches snapshot', () => {
|
||||
const { lastFrame } = render(
|
||||
<RadioButtonSelect
|
||||
items={ITEMS}
|
||||
initialIndex={1}
|
||||
onSelect={() => {}}
|
||||
isFocused={true}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders with numbers hidden and matches snapshot', () => {
|
||||
const { lastFrame } = render(
|
||||
<RadioButtonSelect
|
||||
items={ITEMS}
|
||||
onSelect={() => {}}
|
||||
isFocused={true}
|
||||
showNumbers={false}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders with scroll arrows and matches snapshot', () => {
|
||||
const manyItems = Array.from({ length: 20 }, (_, i) => ({
|
||||
label: `Item ${i + 1}`,
|
||||
value: `item-${i + 1}`,
|
||||
}));
|
||||
const { lastFrame } = render(
|
||||
<RadioButtonSelect
|
||||
items={manyItems}
|
||||
onSelect={() => {}}
|
||||
isFocused={true}
|
||||
showScrollArrows={true}
|
||||
maxItemsToShow={5}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders with special theme display and matches snapshot', () => {
|
||||
const themeItems: Array<RadioSelectItem<string>> = [
|
||||
{
|
||||
label: 'Theme A (Light)',
|
||||
value: 'a-light',
|
||||
themeNameDisplay: 'Theme A',
|
||||
themeTypeDisplay: '(Light)',
|
||||
},
|
||||
{
|
||||
label: 'Theme B (Dark)',
|
||||
value: 'b-dark',
|
||||
themeNameDisplay: 'Theme B',
|
||||
themeTypeDisplay: '(Dark)',
|
||||
},
|
||||
];
|
||||
const { lastFrame } = render(
|
||||
<RadioButtonSelect
|
||||
items={themeItems}
|
||||
onSelect={() => {}}
|
||||
isFocused={true}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders a list with >10 items and matches snapshot', () => {
|
||||
const manyItems = Array.from({ length: 12 }, (_, i) => ({
|
||||
label: `Item ${i + 1}`,
|
||||
value: `item-${i + 1}`,
|
||||
}));
|
||||
const { lastFrame } = render(
|
||||
<RadioButtonSelect
|
||||
items={manyItems}
|
||||
onSelect={() => {}}
|
||||
isFocused={true}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders nothing when no items are provided', () => {
|
||||
const { lastFrame } = render(
|
||||
<RadioButtonSelect items={[]} onSelect={() => {}} isFocused={true} />,
|
||||
);
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Text, Box, useInput } from 'ink';
|
||||
import { Colors } from '../../colors.js';
|
||||
|
||||
@@ -39,6 +39,8 @@ export interface RadioButtonSelectProps<T> {
|
||||
showScrollArrows?: boolean;
|
||||
/** The maximum number of items to show at once. */
|
||||
maxItemsToShow?: number;
|
||||
/** Whether to show numbers next to items. */
|
||||
showNumbers?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,23 +57,12 @@ export function RadioButtonSelect<T>({
|
||||
isFocused,
|
||||
showScrollArrows = false,
|
||||
maxItemsToShow = 10,
|
||||
showNumbers = true,
|
||||
}: RadioButtonSelectProps<T>): React.JSX.Element {
|
||||
// Ensure initialIndex is within bounds
|
||||
const safeInitialIndex =
|
||||
items.length > 0
|
||||
? Math.max(0, Math.min(initialIndex, items.length - 1))
|
||||
: 0;
|
||||
const [activeIndex, setActiveIndex] = useState(safeInitialIndex);
|
||||
const [activeIndex, setActiveIndex] = useState(initialIndex);
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
|
||||
// Ensure activeIndex is always within bounds when items change
|
||||
useEffect(() => {
|
||||
if (items.length === 0) {
|
||||
setActiveIndex(0);
|
||||
} else if (activeIndex >= items.length) {
|
||||
setActiveIndex(Math.max(0, items.length - 1));
|
||||
}
|
||||
}, [items.length, activeIndex]);
|
||||
const [numberInput, setNumberInput] = useState('');
|
||||
const numberInputTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const newScrollOffset = Math.max(
|
||||
@@ -85,55 +76,85 @@ export function RadioButtonSelect<T>({
|
||||
}
|
||||
}, [activeIndex, items.length, scrollOffset, maxItemsToShow]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (numberInputTimer.current) {
|
||||
clearTimeout(numberInputTimer.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (input === 'k' || key.upArrow) {
|
||||
if (items.length > 0) {
|
||||
const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
|
||||
setActiveIndex(newIndex);
|
||||
if (items[newIndex]) {
|
||||
onHighlight?.(items[newIndex].value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (input === 'j' || key.downArrow) {
|
||||
if (items.length > 0) {
|
||||
const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
|
||||
setActiveIndex(newIndex);
|
||||
if (items[newIndex]) {
|
||||
onHighlight?.(items[newIndex].value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (key.return) {
|
||||
// Add bounds check before accessing items[activeIndex]
|
||||
if (
|
||||
activeIndex >= 0 &&
|
||||
activeIndex < items.length &&
|
||||
items[activeIndex]
|
||||
) {
|
||||
onSelect(items[activeIndex].value);
|
||||
}
|
||||
const isNumeric = showNumbers && /^[0-9]$/.test(input);
|
||||
|
||||
// Any key press that is not a digit should clear the number input buffer.
|
||||
if (!isNumeric && numberInputTimer.current) {
|
||||
clearTimeout(numberInputTimer.current);
|
||||
setNumberInput('');
|
||||
}
|
||||
|
||||
// Enable selection directly from number keys.
|
||||
if (/^[1-9]$/.test(input)) {
|
||||
const targetIndex = Number.parseInt(input, 10) - 1;
|
||||
if (targetIndex >= 0 && targetIndex < visibleItems.length) {
|
||||
const selectedItem = visibleItems[targetIndex];
|
||||
if (selectedItem) {
|
||||
onSelect?.(selectedItem.value);
|
||||
if (input === 'k' || key.upArrow) {
|
||||
const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
|
||||
setActiveIndex(newIndex);
|
||||
onHighlight?.(items[newIndex]!.value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (input === 'j' || key.downArrow) {
|
||||
const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
|
||||
setActiveIndex(newIndex);
|
||||
onHighlight?.(items[newIndex]!.value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
onSelect(items[activeIndex]!.value);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle numeric input for selection.
|
||||
if (isNumeric) {
|
||||
if (numberInputTimer.current) {
|
||||
clearTimeout(numberInputTimer.current);
|
||||
}
|
||||
|
||||
const newNumberInput = numberInput + input;
|
||||
setNumberInput(newNumberInput);
|
||||
|
||||
const targetIndex = Number.parseInt(newNumberInput, 10) - 1;
|
||||
|
||||
// A single '0' is not a valid selection since items are 1-indexed.
|
||||
if (newNumberInput === '0') {
|
||||
numberInputTimer.current = setTimeout(() => setNumberInput(''), 350);
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetIndex >= 0 && targetIndex < items.length) {
|
||||
const targetItem = items[targetIndex]!;
|
||||
setActiveIndex(targetIndex);
|
||||
onHighlight?.(targetItem.value);
|
||||
|
||||
// If the typed number can't be a prefix for another valid number,
|
||||
// select it immediately. Otherwise, wait for more input.
|
||||
const potentialNextNumber = Number.parseInt(newNumberInput + '0', 10);
|
||||
if (potentialNextNumber > items.length) {
|
||||
onSelect(targetItem.value);
|
||||
setNumberInput('');
|
||||
} else {
|
||||
numberInputTimer.current = setTimeout(() => {
|
||||
onSelect(targetItem.value);
|
||||
setNumberInput('');
|
||||
}, 350); // Debounce time for multi-digit input.
|
||||
}
|
||||
} else {
|
||||
// The typed number is out of bounds, clear the buffer
|
||||
setNumberInput('');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
isActive:
|
||||
isFocused &&
|
||||
items.length > 0 &&
|
||||
activeIndex >= 0 &&
|
||||
activeIndex < items.length,
|
||||
},
|
||||
{ isActive: isFocused && items.length > 0 },
|
||||
);
|
||||
|
||||
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
|
||||
@@ -150,19 +171,38 @@ export function RadioButtonSelect<T>({
|
||||
const isSelected = activeIndex === itemIndex;
|
||||
|
||||
let textColor = Colors.Foreground;
|
||||
let numberColor = Colors.Foreground;
|
||||
if (isSelected) {
|
||||
textColor = Colors.AccentGreen;
|
||||
numberColor = Colors.AccentGreen;
|
||||
} else if (item.disabled) {
|
||||
textColor = Colors.Gray;
|
||||
numberColor = Colors.Gray;
|
||||
}
|
||||
|
||||
if (!showNumbers) {
|
||||
numberColor = Colors.Gray;
|
||||
}
|
||||
|
||||
const numberColumnWidth = String(items.length).length;
|
||||
const itemNumberText = `${String(itemIndex + 1).padStart(
|
||||
numberColumnWidth,
|
||||
)}.`;
|
||||
|
||||
return (
|
||||
<Box key={item.label}>
|
||||
<Box key={item.label} alignItems="center">
|
||||
<Box minWidth={2} flexShrink={0}>
|
||||
<Text color={isSelected ? Colors.AccentGreen : Colors.Foreground}>
|
||||
{isSelected ? '●' : '○'}
|
||||
{isSelected ? '●' : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
marginRight={1}
|
||||
flexShrink={0}
|
||||
minWidth={itemNumberText.length}
|
||||
>
|
||||
<Text color={numberColor}>{itemNumberText}</Text>
|
||||
</Box>
|
||||
{item.themeNameDisplay && item.themeTypeDisplay ? (
|
||||
<Text color={textColor} wrap="truncate">
|
||||
{item.themeNameDisplay}{' '}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<RadioButtonSelect /> > renders a list of items and matches snapshot 1`] = `
|
||||
"● 1. Option 1
|
||||
2. Option 2
|
||||
3. Option 3"
|
||||
`;
|
||||
|
||||
exports[`<RadioButtonSelect /> > renders a list with >10 items and matches snapshot 1`] = `
|
||||
"● 1. Item 1
|
||||
2. Item 2
|
||||
3. Item 3
|
||||
4. Item 4
|
||||
5. Item 5
|
||||
6. Item 6
|
||||
7. Item 7
|
||||
8. Item 8
|
||||
9. Item 9
|
||||
10. Item 10"
|
||||
`;
|
||||
|
||||
exports[`<RadioButtonSelect /> > renders with numbers hidden and matches snapshot 1`] = `
|
||||
"● 1. Option 1
|
||||
2. Option 2
|
||||
3. Option 3"
|
||||
`;
|
||||
|
||||
exports[`<RadioButtonSelect /> > renders with scroll arrows and matches snapshot 1`] = `
|
||||
"▲
|
||||
● 1. Item 1
|
||||
2. Item 2
|
||||
3. Item 3
|
||||
4. Item 4
|
||||
5. Item 5
|
||||
▼"
|
||||
`;
|
||||
|
||||
exports[`<RadioButtonSelect /> > renders with special theme display and matches snapshot 1`] = `
|
||||
"● 1. Theme A (Light)
|
||||
2. Theme B (Dark)"
|
||||
`;
|
||||
|
||||
exports[`<RadioButtonSelect /> > renders with the second item selected and matches snapshot 1`] = `
|
||||
" 1. Option 1
|
||||
● 2. Option 2
|
||||
3. Option 3"
|
||||
`;
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Viewport,
|
||||
TextBuffer,
|
||||
offsetToLogicalPos,
|
||||
logicalPosToOffset,
|
||||
textBufferReducer,
|
||||
TextBufferState,
|
||||
TextBufferAction,
|
||||
@@ -407,8 +408,8 @@ describe('useTextBuffer', () => {
|
||||
useTextBuffer({ viewport, isValidPath: () => true }),
|
||||
);
|
||||
const filePath = '/path/to/a/valid/file.txt';
|
||||
act(() => result.current.insert(filePath));
|
||||
expect(getBufferState(result).text).toBe(`@${filePath}`);
|
||||
act(() => result.current.insert(filePath, { paste: true }));
|
||||
expect(getBufferState(result).text).toBe(`@${filePath} `);
|
||||
});
|
||||
|
||||
it('should not prepend @ to an invalid file path on insert', () => {
|
||||
@@ -416,7 +417,7 @@ describe('useTextBuffer', () => {
|
||||
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||
);
|
||||
const notAPath = 'this is just some long text';
|
||||
act(() => result.current.insert(notAPath));
|
||||
act(() => result.current.insert(notAPath, { paste: true }));
|
||||
expect(getBufferState(result).text).toBe(notAPath);
|
||||
});
|
||||
|
||||
@@ -425,8 +426,8 @@ describe('useTextBuffer', () => {
|
||||
useTextBuffer({ viewport, isValidPath: () => true }),
|
||||
);
|
||||
const filePath = "'/path/to/a/valid/file.txt'";
|
||||
act(() => result.current.insert(filePath));
|
||||
expect(getBufferState(result).text).toBe(`@/path/to/a/valid/file.txt`);
|
||||
act(() => result.current.insert(filePath, { paste: true }));
|
||||
expect(getBufferState(result).text).toBe(`@/path/to/a/valid/file.txt `);
|
||||
});
|
||||
|
||||
it('should not prepend @ to short text that is not a path', () => {
|
||||
@@ -434,7 +435,7 @@ describe('useTextBuffer', () => {
|
||||
useTextBuffer({ viewport, isValidPath: () => true }),
|
||||
);
|
||||
const shortText = 'ab';
|
||||
act(() => result.current.insert(shortText));
|
||||
act(() => result.current.insert(shortText, { paste: true }));
|
||||
expect(getBufferState(result).text).toBe(shortText);
|
||||
});
|
||||
});
|
||||
@@ -449,7 +450,7 @@ describe('useTextBuffer', () => {
|
||||
}),
|
||||
);
|
||||
const filePath = '/path/to/a/valid/file.txt';
|
||||
act(() => result.current.insert(filePath));
|
||||
act(() => result.current.insert(filePath, { paste: true }));
|
||||
expect(getBufferState(result).text).toBe(filePath); // No @ prefix
|
||||
});
|
||||
|
||||
@@ -462,7 +463,7 @@ describe('useTextBuffer', () => {
|
||||
}),
|
||||
);
|
||||
const quotedFilePath = "'/path/to/a/valid/file.txt'";
|
||||
act(() => result.current.insert(quotedFilePath));
|
||||
act(() => result.current.insert(quotedFilePath, { paste: true }));
|
||||
expect(getBufferState(result).text).toBe(quotedFilePath); // No @ prefix, keeps quotes
|
||||
});
|
||||
|
||||
@@ -475,7 +476,7 @@ describe('useTextBuffer', () => {
|
||||
}),
|
||||
);
|
||||
const notAPath = 'this is just some text';
|
||||
act(() => result.current.insert(notAPath));
|
||||
act(() => result.current.insert(notAPath, { paste: true }));
|
||||
expect(getBufferState(result).text).toBe(notAPath);
|
||||
});
|
||||
|
||||
@@ -488,7 +489,7 @@ describe('useTextBuffer', () => {
|
||||
}),
|
||||
);
|
||||
const shortText = 'ls';
|
||||
act(() => result.current.insert(shortText));
|
||||
act(() => result.current.insert(shortText, { paste: true }));
|
||||
expect(getBufferState(result).text).toBe(shortText); // No @ prefix for short text
|
||||
});
|
||||
});
|
||||
@@ -849,6 +850,7 @@ describe('useTextBuffer', () => {
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: '\x7f',
|
||||
});
|
||||
result.current.handleInput({
|
||||
@@ -856,6 +858,7 @@ describe('useTextBuffer', () => {
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: '\x7f',
|
||||
});
|
||||
result.current.handleInput({
|
||||
@@ -863,6 +866,7 @@ describe('useTextBuffer', () => {
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: '\x7f',
|
||||
});
|
||||
});
|
||||
@@ -990,9 +994,9 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||
|
||||
// Simulate pasting the long text multiple times
|
||||
act(() => {
|
||||
result.current.insert(longText);
|
||||
result.current.insert(longText);
|
||||
result.current.insert(longText);
|
||||
result.current.insert(longText, { paste: true });
|
||||
result.current.insert(longText, { paste: true });
|
||||
result.current.insert(longText, { paste: true });
|
||||
});
|
||||
|
||||
const state = getBufferState(result);
|
||||
@@ -1338,3 +1342,216 @@ describe('offsetToLogicalPos', () => {
|
||||
expect(offsetToLogicalPos(text, 2)).toEqual([0, 2]); // After 🐱
|
||||
});
|
||||
});
|
||||
|
||||
describe('logicalPosToOffset', () => {
|
||||
it('should convert row/col position to offset correctly', () => {
|
||||
const lines = ['hello', 'world', '123'];
|
||||
|
||||
// Line 0: "hello" (5 chars)
|
||||
expect(logicalPosToOffset(lines, 0, 0)).toBe(0); // Start of 'hello'
|
||||
expect(logicalPosToOffset(lines, 0, 3)).toBe(3); // 'l' in 'hello'
|
||||
expect(logicalPosToOffset(lines, 0, 5)).toBe(5); // End of 'hello'
|
||||
|
||||
// Line 1: "world" (5 chars), offset starts at 6 (5 + 1 for newline)
|
||||
expect(logicalPosToOffset(lines, 1, 0)).toBe(6); // Start of 'world'
|
||||
expect(logicalPosToOffset(lines, 1, 2)).toBe(8); // 'r' in 'world'
|
||||
expect(logicalPosToOffset(lines, 1, 5)).toBe(11); // End of 'world'
|
||||
|
||||
// Line 2: "123" (3 chars), offset starts at 12 (5 + 1 + 5 + 1)
|
||||
expect(logicalPosToOffset(lines, 2, 0)).toBe(12); // Start of '123'
|
||||
expect(logicalPosToOffset(lines, 2, 1)).toBe(13); // '2' in '123'
|
||||
expect(logicalPosToOffset(lines, 2, 3)).toBe(15); // End of '123'
|
||||
});
|
||||
|
||||
it('should handle empty lines', () => {
|
||||
const lines = ['a', '', 'c'];
|
||||
|
||||
expect(logicalPosToOffset(lines, 0, 0)).toBe(0); // 'a'
|
||||
expect(logicalPosToOffset(lines, 0, 1)).toBe(1); // End of 'a'
|
||||
expect(logicalPosToOffset(lines, 1, 0)).toBe(2); // Empty line
|
||||
expect(logicalPosToOffset(lines, 2, 0)).toBe(3); // 'c'
|
||||
expect(logicalPosToOffset(lines, 2, 1)).toBe(4); // End of 'c'
|
||||
});
|
||||
|
||||
it('should handle single empty line', () => {
|
||||
const lines = [''];
|
||||
|
||||
expect(logicalPosToOffset(lines, 0, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('should be inverse of offsetToLogicalPos', () => {
|
||||
const lines = ['hello', 'world', '123'];
|
||||
const text = lines.join('\n');
|
||||
|
||||
// Test round-trip conversion
|
||||
for (let offset = 0; offset <= text.length; offset++) {
|
||||
const [row, col] = offsetToLogicalPos(text, offset);
|
||||
const convertedOffset = logicalPosToOffset(lines, row, col);
|
||||
expect(convertedOffset).toBe(offset);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle out-of-bounds positions', () => {
|
||||
const lines = ['hello'];
|
||||
|
||||
// Beyond end of line
|
||||
expect(logicalPosToOffset(lines, 0, 10)).toBe(5); // Clamps to end of line
|
||||
|
||||
// Beyond array bounds - should clamp to the last line
|
||||
expect(logicalPosToOffset(lines, 5, 0)).toBe(0); // Clamps to start of last line (row 0)
|
||||
expect(logicalPosToOffset(lines, 5, 10)).toBe(5); // Clamps to end of last line
|
||||
});
|
||||
});
|
||||
|
||||
describe('textBufferReducer vim operations', () => {
|
||||
describe('vim_delete_line', () => {
|
||||
it('should delete a single line including newline in multi-line text', () => {
|
||||
const initialState: TextBufferState = {
|
||||
lines: ['line1', 'line2', 'line3'],
|
||||
cursorRow: 1,
|
||||
cursorCol: 2,
|
||||
preferredCol: null,
|
||||
visualLines: [['line1'], ['line2'], ['line3']],
|
||||
visualScrollRow: 0,
|
||||
visualCursor: { row: 1, col: 2 },
|
||||
viewport: { width: 10, height: 5 },
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
};
|
||||
|
||||
const action: TextBufferAction = {
|
||||
type: 'vim_delete_line',
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = textBufferReducer(initialState, action);
|
||||
|
||||
// After deleting line2, we should have line1 and line3, with cursor on line3 (now at index 1)
|
||||
expect(result.lines).toEqual(['line1', 'line3']);
|
||||
expect(result.cursorRow).toBe(1);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should delete multiple lines when count > 1', () => {
|
||||
const initialState: TextBufferState = {
|
||||
lines: ['line1', 'line2', 'line3', 'line4'],
|
||||
cursorRow: 1,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
visualLines: [['line1'], ['line2'], ['line3'], ['line4']],
|
||||
visualScrollRow: 0,
|
||||
visualCursor: { row: 1, col: 0 },
|
||||
viewport: { width: 10, height: 5 },
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
};
|
||||
|
||||
const action: TextBufferAction = {
|
||||
type: 'vim_delete_line',
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = textBufferReducer(initialState, action);
|
||||
|
||||
// Should delete line2 and line3, leaving line1 and line4
|
||||
expect(result.lines).toEqual(['line1', 'line4']);
|
||||
expect(result.cursorRow).toBe(1);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should clear single line content when only one line exists', () => {
|
||||
const initialState: TextBufferState = {
|
||||
lines: ['only line'],
|
||||
cursorRow: 0,
|
||||
cursorCol: 5,
|
||||
preferredCol: null,
|
||||
visualLines: [['only line']],
|
||||
visualScrollRow: 0,
|
||||
visualCursor: { row: 0, col: 5 },
|
||||
viewport: { width: 10, height: 5 },
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
};
|
||||
|
||||
const action: TextBufferAction = {
|
||||
type: 'vim_delete_line',
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = textBufferReducer(initialState, action);
|
||||
|
||||
// Should clear the line content but keep the line
|
||||
expect(result.lines).toEqual(['']);
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle deleting the last line properly', () => {
|
||||
const initialState: TextBufferState = {
|
||||
lines: ['line1', 'line2'],
|
||||
cursorRow: 1,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
visualLines: [['line1'], ['line2']],
|
||||
visualScrollRow: 0,
|
||||
visualCursor: { row: 1, col: 0 },
|
||||
viewport: { width: 10, height: 5 },
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
};
|
||||
|
||||
const action: TextBufferAction = {
|
||||
type: 'vim_delete_line',
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = textBufferReducer(initialState, action);
|
||||
|
||||
// Should delete the last line completely, not leave empty line
|
||||
expect(result.lines).toEqual(['line1']);
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle deleting all lines and maintain valid state for subsequent paste', () => {
|
||||
const initialState: TextBufferState = {
|
||||
lines: ['line1', 'line2', 'line3', 'line4'],
|
||||
cursorRow: 0,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
visualLines: [['line1'], ['line2'], ['line3'], ['line4']],
|
||||
visualScrollRow: 0,
|
||||
visualCursor: { row: 0, col: 0 },
|
||||
viewport: { width: 10, height: 5 },
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
};
|
||||
|
||||
// Delete all 4 lines with 4dd
|
||||
const deleteAction: TextBufferAction = {
|
||||
type: 'vim_delete_line',
|
||||
payload: { count: 4 },
|
||||
};
|
||||
|
||||
const afterDelete = textBufferReducer(initialState, deleteAction);
|
||||
|
||||
// After deleting all lines, should have one empty line
|
||||
expect(afterDelete.lines).toEqual(['']);
|
||||
expect(afterDelete.cursorRow).toBe(0);
|
||||
expect(afterDelete.cursorCol).toBe(0);
|
||||
|
||||
// Now paste multiline content - this should work correctly
|
||||
const pasteAction: TextBufferAction = {
|
||||
type: 'insert',
|
||||
payload: 'new1\nnew2\nnew3\nnew4',
|
||||
};
|
||||
|
||||
const afterPaste = textBufferReducer(afterDelete, pasteAction);
|
||||
|
||||
// All lines including the first one should be present
|
||||
expect(afterPaste.lines).toEqual(['new1', 'new2', 'new3', 'new4']);
|
||||
expect(afterPaste.cursorRow).toBe(3);
|
||||
expect(afterPaste.cursorCol).toBe(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
796
packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts
Normal file
796
packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts
Normal file
@@ -0,0 +1,796 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { handleVimAction } from './vim-buffer-actions.js';
|
||||
import type { TextBufferState } from './text-buffer.js';
|
||||
|
||||
// Helper to create test state
|
||||
const createTestState = (
|
||||
lines: string[] = ['hello world'],
|
||||
cursorRow = 0,
|
||||
cursorCol = 0,
|
||||
): TextBufferState => ({
|
||||
lines,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
preferredCol: null,
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
clipboard: null,
|
||||
selectionAnchor: null,
|
||||
viewportWidth: 80,
|
||||
});
|
||||
|
||||
describe('vim-buffer-actions', () => {
|
||||
describe('Movement commands', () => {
|
||||
describe('vim_move_left', () => {
|
||||
it('should move cursor left by count', () => {
|
||||
const state = createTestState(['hello world'], 0, 5);
|
||||
const action = {
|
||||
type: 'vim_move_left' as const,
|
||||
payload: { count: 3 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(2);
|
||||
expect(result.preferredCol).toBeNull();
|
||||
});
|
||||
|
||||
it('should not move past beginning of line', () => {
|
||||
const state = createTestState(['hello'], 0, 2);
|
||||
const action = {
|
||||
type: 'vim_move_left' as const,
|
||||
payload: { count: 5 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should wrap to previous line when at beginning', () => {
|
||||
const state = createTestState(['line1', 'line2'], 1, 0);
|
||||
const action = {
|
||||
type: 'vim_move_left' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(4); // On last character '1' of 'line1'
|
||||
});
|
||||
|
||||
it('should handle multiple line wrapping', () => {
|
||||
const state = createTestState(['abc', 'def', 'ghi'], 2, 0);
|
||||
const action = {
|
||||
type: 'vim_move_left' as const,
|
||||
payload: { count: 5 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(1); // On 'b' after 5 left movements
|
||||
});
|
||||
|
||||
it('should correctly handle h/l movement between lines', () => {
|
||||
// Start at end of first line at 'd' (position 10)
|
||||
let state = createTestState(['hello world', 'foo bar'], 0, 10);
|
||||
|
||||
// Move right - should go to beginning of next line
|
||||
state = handleVimAction(state, {
|
||||
type: 'vim_move_right' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(state.cursorRow).toBe(1);
|
||||
expect(state.cursorCol).toBe(0); // Should be on 'f'
|
||||
|
||||
// Move left - should go back to end of previous line on 'd'
|
||||
state = handleVimAction(state, {
|
||||
type: 'vim_move_left' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(state.cursorRow).toBe(0);
|
||||
expect(state.cursorCol).toBe(10); // Should be on 'd', not past it
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_move_right', () => {
|
||||
it('should move cursor right by count', () => {
|
||||
const state = createTestState(['hello world'], 0, 2);
|
||||
const action = {
|
||||
type: 'vim_move_right' as const,
|
||||
payload: { count: 3 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(5);
|
||||
});
|
||||
|
||||
it('should not move past last character of line', () => {
|
||||
const state = createTestState(['hello'], 0, 3);
|
||||
const action = {
|
||||
type: 'vim_move_right' as const,
|
||||
payload: { count: 5 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(4); // Last character of 'hello'
|
||||
});
|
||||
|
||||
it('should wrap to next line when at end', () => {
|
||||
const state = createTestState(['line1', 'line2'], 0, 4); // At end of 'line1'
|
||||
const action = {
|
||||
type: 'vim_move_right' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(1);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_move_up', () => {
|
||||
it('should move cursor up by count', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 2, 3);
|
||||
const action = { type: 'vim_move_up' as const, payload: { count: 2 } };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(3);
|
||||
});
|
||||
|
||||
it('should not move past first line', () => {
|
||||
const state = createTestState(['line1', 'line2'], 1, 3);
|
||||
const action = { type: 'vim_move_up' as const, payload: { count: 5 } };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
});
|
||||
|
||||
it('should adjust column for shorter lines', () => {
|
||||
const state = createTestState(['short', 'very long line'], 1, 10);
|
||||
const action = { type: 'vim_move_up' as const, payload: { count: 1 } };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(5); // End of 'short'
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_move_down', () => {
|
||||
it('should move cursor down by count', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 0, 2);
|
||||
const action = {
|
||||
type: 'vim_move_down' as const,
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(2);
|
||||
expect(result.cursorCol).toBe(2);
|
||||
});
|
||||
|
||||
it('should not move past last line', () => {
|
||||
const state = createTestState(['line1', 'line2'], 0, 2);
|
||||
const action = {
|
||||
type: 'vim_move_down' as const,
|
||||
payload: { count: 5 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_move_word_forward', () => {
|
||||
it('should move to start of next word', () => {
|
||||
const state = createTestState(['hello world test'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_move_word_forward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(6); // Start of 'world'
|
||||
});
|
||||
|
||||
it('should handle multiple words', () => {
|
||||
const state = createTestState(['hello world test'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_move_word_forward' as const,
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(12); // Start of 'test'
|
||||
});
|
||||
|
||||
it('should handle punctuation correctly', () => {
|
||||
const state = createTestState(['hello, world!'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_move_word_forward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(5); // Start of ','
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_move_word_backward', () => {
|
||||
it('should move to start of previous word', () => {
|
||||
const state = createTestState(['hello world test'], 0, 12);
|
||||
const action = {
|
||||
type: 'vim_move_word_backward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(6); // Start of 'world'
|
||||
});
|
||||
|
||||
it('should handle multiple words', () => {
|
||||
const state = createTestState(['hello world test'], 0, 12);
|
||||
const action = {
|
||||
type: 'vim_move_word_backward' as const,
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(0); // Start of 'hello'
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_move_word_end', () => {
|
||||
it('should move to end of current word', () => {
|
||||
const state = createTestState(['hello world'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_move_word_end' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(4); // End of 'hello'
|
||||
});
|
||||
|
||||
it('should move to end of next word if already at word end', () => {
|
||||
const state = createTestState(['hello world'], 0, 4);
|
||||
const action = {
|
||||
type: 'vim_move_word_end' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(10); // End of 'world'
|
||||
});
|
||||
});
|
||||
|
||||
describe('Position commands', () => {
|
||||
it('vim_move_to_line_start should move to column 0', () => {
|
||||
const state = createTestState(['hello world'], 0, 5);
|
||||
const action = { type: 'vim_move_to_line_start' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('vim_move_to_line_end should move to last character', () => {
|
||||
const state = createTestState(['hello world'], 0, 0);
|
||||
const action = { type: 'vim_move_to_line_end' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(10); // Last character of 'hello world'
|
||||
});
|
||||
|
||||
it('vim_move_to_first_nonwhitespace should skip leading whitespace', () => {
|
||||
const state = createTestState([' hello world'], 0, 0);
|
||||
const action = { type: 'vim_move_to_first_nonwhitespace' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(3); // Position of 'h'
|
||||
});
|
||||
|
||||
it('vim_move_to_first_line should move to row 0', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 2, 5);
|
||||
const action = { type: 'vim_move_to_first_line' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('vim_move_to_last_line should move to last row', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 0, 5);
|
||||
const action = { type: 'vim_move_to_last_line' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(2);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('vim_move_to_line should move to specific line', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 0, 5);
|
||||
const action = {
|
||||
type: 'vim_move_to_line' as const,
|
||||
payload: { lineNumber: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(1); // 0-indexed
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('vim_move_to_line should clamp to valid range', () => {
|
||||
const state = createTestState(['line1', 'line2'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_move_to_line' as const,
|
||||
payload: { lineNumber: 10 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(1); // Last line
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edit commands', () => {
|
||||
describe('vim_delete_char', () => {
|
||||
it('should delete single character', () => {
|
||||
const state = createTestState(['hello'], 0, 1);
|
||||
const action = {
|
||||
type: 'vim_delete_char' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hllo');
|
||||
expect(result.cursorCol).toBe(1);
|
||||
});
|
||||
|
||||
it('should delete multiple characters', () => {
|
||||
const state = createTestState(['hello'], 0, 1);
|
||||
const action = {
|
||||
type: 'vim_delete_char' as const,
|
||||
payload: { count: 3 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('ho');
|
||||
expect(result.cursorCol).toBe(1);
|
||||
});
|
||||
|
||||
it('should not delete past end of line', () => {
|
||||
const state = createTestState(['hello'], 0, 3);
|
||||
const action = {
|
||||
type: 'vim_delete_char' as const,
|
||||
payload: { count: 5 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hel');
|
||||
expect(result.cursorCol).toBe(3);
|
||||
});
|
||||
|
||||
it('should do nothing at end of line', () => {
|
||||
const state = createTestState(['hello'], 0, 5);
|
||||
const action = {
|
||||
type: 'vim_delete_char' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hello');
|
||||
expect(result.cursorCol).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_delete_word_forward', () => {
|
||||
it('should delete from cursor to next word start', () => {
|
||||
const state = createTestState(['hello world test'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_delete_word_forward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('world test');
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should delete multiple words', () => {
|
||||
const state = createTestState(['hello world test'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_delete_word_forward' as const,
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('test');
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should delete to end if no more words', () => {
|
||||
const state = createTestState(['hello world'], 0, 6);
|
||||
const action = {
|
||||
type: 'vim_delete_word_forward' as const,
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hello ');
|
||||
expect(result.cursorCol).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_delete_word_backward', () => {
|
||||
it('should delete from cursor to previous word start', () => {
|
||||
const state = createTestState(['hello world test'], 0, 12);
|
||||
const action = {
|
||||
type: 'vim_delete_word_backward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hello test');
|
||||
expect(result.cursorCol).toBe(6);
|
||||
});
|
||||
|
||||
it('should delete multiple words backward', () => {
|
||||
const state = createTestState(['hello world test'], 0, 12);
|
||||
const action = {
|
||||
type: 'vim_delete_word_backward' as const,
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('test');
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_delete_line', () => {
|
||||
it('should delete current line', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 1, 2);
|
||||
const action = {
|
||||
type: 'vim_delete_line' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines).toEqual(['line1', 'line3']);
|
||||
expect(result.cursorRow).toBe(1);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should delete multiple lines', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 0, 2);
|
||||
const action = {
|
||||
type: 'vim_delete_line' as const,
|
||||
payload: { count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines).toEqual(['line3']);
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should leave empty line when deleting all lines', () => {
|
||||
const state = createTestState(['only line'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_delete_line' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines).toEqual(['']);
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_delete_to_end_of_line', () => {
|
||||
it('should delete from cursor to end of line', () => {
|
||||
const state = createTestState(['hello world'], 0, 5);
|
||||
const action = { type: 'vim_delete_to_end_of_line' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hello');
|
||||
expect(result.cursorCol).toBe(5);
|
||||
});
|
||||
|
||||
it('should do nothing at end of line', () => {
|
||||
const state = createTestState(['hello'], 0, 5);
|
||||
const action = { type: 'vim_delete_to_end_of_line' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hello');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Insert mode commands', () => {
|
||||
describe('vim_insert_at_cursor', () => {
|
||||
it('should not change cursor position', () => {
|
||||
const state = createTestState(['hello'], 0, 2);
|
||||
const action = { type: 'vim_insert_at_cursor' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_append_at_cursor', () => {
|
||||
it('should move cursor right by one', () => {
|
||||
const state = createTestState(['hello'], 0, 2);
|
||||
const action = { type: 'vim_append_at_cursor' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(3);
|
||||
});
|
||||
|
||||
it('should not move past end of line', () => {
|
||||
const state = createTestState(['hello'], 0, 5);
|
||||
const action = { type: 'vim_append_at_cursor' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_append_at_line_end', () => {
|
||||
it('should move cursor to end of line', () => {
|
||||
const state = createTestState(['hello world'], 0, 3);
|
||||
const action = { type: 'vim_append_at_line_end' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(11);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_insert_at_line_start', () => {
|
||||
it('should move to first non-whitespace character', () => {
|
||||
const state = createTestState([' hello world'], 0, 5);
|
||||
const action = { type: 'vim_insert_at_line_start' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(2);
|
||||
});
|
||||
|
||||
it('should move to column 0 for line with only whitespace', () => {
|
||||
const state = createTestState([' '], 0, 1);
|
||||
const action = { type: 'vim_insert_at_line_start' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_open_line_below', () => {
|
||||
it('should insert newline at end of current line', () => {
|
||||
const state = createTestState(['hello world'], 0, 5);
|
||||
const action = { type: 'vim_open_line_below' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
// The implementation inserts newline at end of current line and cursor moves to column 0
|
||||
expect(result.lines[0]).toBe('hello world\n');
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(0); // Cursor position after replaceRangeInternal
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_open_line_above', () => {
|
||||
it('should insert newline before current line', () => {
|
||||
const state = createTestState(['hello', 'world'], 1, 2);
|
||||
const action = { type: 'vim_open_line_above' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
// The implementation inserts newline at beginning of current line
|
||||
expect(result.lines).toEqual(['hello', '\nworld']);
|
||||
expect(result.cursorRow).toBe(1);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_escape_insert_mode', () => {
|
||||
it('should move cursor left', () => {
|
||||
const state = createTestState(['hello'], 0, 3);
|
||||
const action = { type: 'vim_escape_insert_mode' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(2);
|
||||
});
|
||||
|
||||
it('should not move past beginning of line', () => {
|
||||
const state = createTestState(['hello'], 0, 0);
|
||||
const action = { type: 'vim_escape_insert_mode' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Change commands', () => {
|
||||
describe('vim_change_word_forward', () => {
|
||||
it('should delete from cursor to next word start', () => {
|
||||
const state = createTestState(['hello world test'], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_change_word_forward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('world test');
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_change_line', () => {
|
||||
it('should delete entire line content', () => {
|
||||
const state = createTestState(['hello world'], 0, 5);
|
||||
const action = {
|
||||
type: 'vim_change_line' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('');
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_change_movement', () => {
|
||||
it('should change characters to the left', () => {
|
||||
const state = createTestState(['hello world'], 0, 5);
|
||||
const action = {
|
||||
type: 'vim_change_movement' as const,
|
||||
payload: { movement: 'h', count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hel world');
|
||||
expect(result.cursorCol).toBe(3);
|
||||
});
|
||||
|
||||
it('should change characters to the right', () => {
|
||||
const state = createTestState(['hello world'], 0, 5);
|
||||
const action = {
|
||||
type: 'vim_change_movement' as const,
|
||||
payload: { movement: 'l', count: 3 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.lines[0]).toBe('hellorld'); // Deletes ' wo' (3 chars to the right)
|
||||
expect(result.cursorCol).toBe(5);
|
||||
});
|
||||
|
||||
it('should change multiple lines down', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 0, 2);
|
||||
const action = {
|
||||
type: 'vim_change_movement' as const,
|
||||
payload: { movement: 'j', count: 2 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
// The movement 'j' with count 2 changes 2 lines starting from cursor row
|
||||
// Since we're at cursor position 2, it changes lines starting from current row
|
||||
expect(result.lines).toEqual(['line1', 'line2', 'line3']); // No change because count > available lines
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle empty text', () => {
|
||||
const state = createTestState([''], 0, 0);
|
||||
const action = {
|
||||
type: 'vim_move_word_forward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle single character line', () => {
|
||||
const state = createTestState(['a'], 0, 0);
|
||||
const action = { type: 'vim_move_to_line_end' as const };
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.cursorCol).toBe(0); // Should be last character position
|
||||
});
|
||||
|
||||
it('should handle empty lines in multi-line text', () => {
|
||||
const state = createTestState(['line1', '', 'line3'], 1, 0);
|
||||
const action = {
|
||||
type: 'vim_move_word_forward' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
// Should move to next line with content
|
||||
expect(result.cursorRow).toBe(2);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should preserve undo stack in operations', () => {
|
||||
const state = createTestState(['hello'], 0, 0);
|
||||
state.undoStack = [{ lines: ['previous'], cursorRow: 0, cursorCol: 0 }];
|
||||
|
||||
const action = {
|
||||
type: 'vim_delete_char' as const,
|
||||
payload: { count: 1 },
|
||||
};
|
||||
|
||||
const result = handleVimAction(state, action);
|
||||
|
||||
expect(result.undoStack).toHaveLength(2); // Original plus new snapshot
|
||||
});
|
||||
});
|
||||
});
|
||||
887
packages/cli/src/ui/components/shared/vim-buffer-actions.ts
Normal file
887
packages/cli/src/ui/components/shared/vim-buffer-actions.ts
Normal file
@@ -0,0 +1,887 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
TextBufferState,
|
||||
TextBufferAction,
|
||||
findNextWordStart,
|
||||
findPrevWordStart,
|
||||
findWordEnd,
|
||||
getOffsetFromPosition,
|
||||
getPositionFromOffsets,
|
||||
getLineRangeOffsets,
|
||||
replaceRangeInternal,
|
||||
pushUndo,
|
||||
} from './text-buffer.js';
|
||||
import { cpLen } from '../../utils/textUtils.js';
|
||||
|
||||
export type VimAction = Extract<
|
||||
TextBufferAction,
|
||||
| { type: 'vim_delete_word_forward' }
|
||||
| { type: 'vim_delete_word_backward' }
|
||||
| { type: 'vim_delete_word_end' }
|
||||
| { type: 'vim_change_word_forward' }
|
||||
| { type: 'vim_change_word_backward' }
|
||||
| { type: 'vim_change_word_end' }
|
||||
| { type: 'vim_delete_line' }
|
||||
| { type: 'vim_change_line' }
|
||||
| { type: 'vim_delete_to_end_of_line' }
|
||||
| { type: 'vim_change_to_end_of_line' }
|
||||
| { type: 'vim_change_movement' }
|
||||
| { type: 'vim_move_left' }
|
||||
| { type: 'vim_move_right' }
|
||||
| { type: 'vim_move_up' }
|
||||
| { type: 'vim_move_down' }
|
||||
| { type: 'vim_move_word_forward' }
|
||||
| { type: 'vim_move_word_backward' }
|
||||
| { type: 'vim_move_word_end' }
|
||||
| { type: 'vim_delete_char' }
|
||||
| { type: 'vim_insert_at_cursor' }
|
||||
| { type: 'vim_append_at_cursor' }
|
||||
| { type: 'vim_open_line_below' }
|
||||
| { type: 'vim_open_line_above' }
|
||||
| { type: 'vim_append_at_line_end' }
|
||||
| { type: 'vim_insert_at_line_start' }
|
||||
| { type: 'vim_move_to_line_start' }
|
||||
| { type: 'vim_move_to_line_end' }
|
||||
| { type: 'vim_move_to_first_nonwhitespace' }
|
||||
| { type: 'vim_move_to_first_line' }
|
||||
| { type: 'vim_move_to_last_line' }
|
||||
| { type: 'vim_move_to_line' }
|
||||
| { type: 'vim_escape_insert_mode' }
|
||||
>;
|
||||
|
||||
export function handleVimAction(
|
||||
state: TextBufferState,
|
||||
action: VimAction,
|
||||
): TextBufferState {
|
||||
const { lines, cursorRow, cursorCol } = state;
|
||||
// Cache text join to avoid repeated calculations for word operations
|
||||
let text: string | null = null;
|
||||
const getText = () => text ?? (text = lines.join('\n'));
|
||||
|
||||
switch (action.type) {
|
||||
case 'vim_delete_word_forward': {
|
||||
const { count } = action.payload;
|
||||
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
let endOffset = currentOffset;
|
||||
let searchOffset = currentOffset;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const nextWordOffset = findNextWordStart(getText(), searchOffset);
|
||||
if (nextWordOffset > searchOffset) {
|
||||
searchOffset = nextWordOffset;
|
||||
endOffset = nextWordOffset;
|
||||
} else {
|
||||
// If no next word, delete to end of current word
|
||||
const wordEndOffset = findWordEnd(getText(), searchOffset);
|
||||
endOffset = Math.min(wordEndOffset + 1, getText().length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (endOffset > currentOffset) {
|
||||
const nextState = pushUndo(state);
|
||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
||||
currentOffset,
|
||||
endOffset,
|
||||
nextState.lines,
|
||||
);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_delete_word_backward': {
|
||||
const { count } = action.payload;
|
||||
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
let startOffset = currentOffset;
|
||||
let searchOffset = currentOffset;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const prevWordOffset = findPrevWordStart(getText(), searchOffset);
|
||||
if (prevWordOffset < searchOffset) {
|
||||
searchOffset = prevWordOffset;
|
||||
startOffset = prevWordOffset;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (startOffset < currentOffset) {
|
||||
const nextState = pushUndo(state);
|
||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
||||
startOffset,
|
||||
currentOffset,
|
||||
nextState.lines,
|
||||
);
|
||||
const newState = replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
// Cursor is already at the correct position after deletion
|
||||
return newState;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_delete_word_end': {
|
||||
const { count } = action.payload;
|
||||
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
let offset = currentOffset;
|
||||
let endOffset = currentOffset;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const wordEndOffset = findWordEnd(getText(), offset);
|
||||
if (wordEndOffset >= offset) {
|
||||
endOffset = wordEndOffset + 1; // Include the character at word end
|
||||
// For next iteration, move to start of next word
|
||||
if (i < count - 1) {
|
||||
const nextWordStart = findNextWordStart(
|
||||
getText(),
|
||||
wordEndOffset + 1,
|
||||
);
|
||||
offset = nextWordStart;
|
||||
if (nextWordStart <= wordEndOffset) {
|
||||
break; // No more words
|
||||
}
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
endOffset = Math.min(endOffset, getText().length);
|
||||
|
||||
if (endOffset > currentOffset) {
|
||||
const nextState = pushUndo(state);
|
||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
||||
currentOffset,
|
||||
endOffset,
|
||||
nextState.lines,
|
||||
);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_change_word_forward': {
|
||||
const { count } = action.payload;
|
||||
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
let searchOffset = currentOffset;
|
||||
let endOffset = currentOffset;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const nextWordOffset = findNextWordStart(getText(), searchOffset);
|
||||
if (nextWordOffset > searchOffset) {
|
||||
searchOffset = nextWordOffset;
|
||||
endOffset = nextWordOffset;
|
||||
} else {
|
||||
// If no next word, change to end of current word
|
||||
const wordEndOffset = findWordEnd(getText(), searchOffset);
|
||||
endOffset = Math.min(wordEndOffset + 1, getText().length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (endOffset > currentOffset) {
|
||||
const nextState = pushUndo(state);
|
||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
||||
currentOffset,
|
||||
endOffset,
|
||||
nextState.lines,
|
||||
);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_change_word_backward': {
|
||||
const { count } = action.payload;
|
||||
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
let startOffset = currentOffset;
|
||||
let searchOffset = currentOffset;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const prevWordOffset = findPrevWordStart(getText(), searchOffset);
|
||||
if (prevWordOffset < searchOffset) {
|
||||
searchOffset = prevWordOffset;
|
||||
startOffset = prevWordOffset;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (startOffset < currentOffset) {
|
||||
const nextState = pushUndo(state);
|
||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
||||
startOffset,
|
||||
currentOffset,
|
||||
nextState.lines,
|
||||
);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_change_word_end': {
|
||||
const { count } = action.payload;
|
||||
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
let offset = currentOffset;
|
||||
let endOffset = currentOffset;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const wordEndOffset = findWordEnd(getText(), offset);
|
||||
if (wordEndOffset >= offset) {
|
||||
endOffset = wordEndOffset + 1; // Include the character at word end
|
||||
// For next iteration, move to start of next word
|
||||
if (i < count - 1) {
|
||||
const nextWordStart = findNextWordStart(
|
||||
getText(),
|
||||
wordEndOffset + 1,
|
||||
);
|
||||
offset = nextWordStart;
|
||||
if (nextWordStart <= wordEndOffset) {
|
||||
break; // No more words
|
||||
}
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
endOffset = Math.min(endOffset, getText().length);
|
||||
|
||||
if (endOffset !== currentOffset) {
|
||||
const nextState = pushUndo(state);
|
||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
||||
Math.min(currentOffset, endOffset),
|
||||
Math.max(currentOffset, endOffset),
|
||||
nextState.lines,
|
||||
);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_delete_line': {
|
||||
const { count } = action.payload;
|
||||
if (lines.length === 0) return state;
|
||||
|
||||
const linesToDelete = Math.min(count, lines.length - cursorRow);
|
||||
const totalLines = lines.length;
|
||||
|
||||
if (totalLines === 1 || linesToDelete >= totalLines) {
|
||||
// If there's only one line, or we're deleting all remaining lines,
|
||||
// clear the content but keep one empty line (text editors should never be completely empty)
|
||||
const nextState = pushUndo(state);
|
||||
return {
|
||||
...nextState,
|
||||
lines: [''],
|
||||
cursorRow: 0,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
const nextState = pushUndo(state);
|
||||
const newLines = [...nextState.lines];
|
||||
newLines.splice(cursorRow, linesToDelete);
|
||||
|
||||
// Adjust cursor position
|
||||
const newCursorRow = Math.min(cursorRow, newLines.length - 1);
|
||||
const newCursorCol = 0; // Vim places cursor at beginning of line after dd
|
||||
|
||||
return {
|
||||
...nextState,
|
||||
lines: newLines,
|
||||
cursorRow: newCursorRow,
|
||||
cursorCol: newCursorCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_change_line': {
|
||||
const { count } = action.payload;
|
||||
if (lines.length === 0) return state;
|
||||
|
||||
const linesToChange = Math.min(count, lines.length - cursorRow);
|
||||
const nextState = pushUndo(state);
|
||||
|
||||
const { startOffset, endOffset } = getLineRangeOffsets(
|
||||
cursorRow,
|
||||
linesToChange,
|
||||
nextState.lines,
|
||||
);
|
||||
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
|
||||
startOffset,
|
||||
endOffset,
|
||||
nextState.lines,
|
||||
);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
case 'vim_delete_to_end_of_line': {
|
||||
const currentLine = lines[cursorRow] || '';
|
||||
if (cursorCol < currentLine.length) {
|
||||
const nextState = pushUndo(state);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
cursorRow,
|
||||
currentLine.length,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_change_to_end_of_line': {
|
||||
const currentLine = lines[cursorRow] || '';
|
||||
if (cursorCol < currentLine.length) {
|
||||
const nextState = pushUndo(state);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
cursorRow,
|
||||
currentLine.length,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_change_movement': {
|
||||
const { movement, count } = action.payload;
|
||||
const totalLines = lines.length;
|
||||
|
||||
switch (movement) {
|
||||
case 'h': {
|
||||
// Left
|
||||
// Change N characters to the left
|
||||
const startCol = Math.max(0, cursorCol - count);
|
||||
return replaceRangeInternal(
|
||||
pushUndo(state),
|
||||
cursorRow,
|
||||
startCol,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
case 'j': {
|
||||
// Down
|
||||
const linesToChange = Math.min(count, totalLines - cursorRow);
|
||||
if (linesToChange > 0) {
|
||||
if (totalLines === 1) {
|
||||
const currentLine = state.lines[0] || '';
|
||||
return replaceRangeInternal(
|
||||
pushUndo(state),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
cpLen(currentLine),
|
||||
'',
|
||||
);
|
||||
} else {
|
||||
const nextState = pushUndo(state);
|
||||
const { startOffset, endOffset } = getLineRangeOffsets(
|
||||
cursorRow,
|
||||
linesToChange,
|
||||
nextState.lines,
|
||||
);
|
||||
const { startRow, startCol, endRow, endCol } =
|
||||
getPositionFromOffsets(startOffset, endOffset, nextState.lines);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'k': {
|
||||
// Up
|
||||
const upLines = Math.min(count, cursorRow + 1);
|
||||
if (upLines > 0) {
|
||||
if (state.lines.length === 1) {
|
||||
const currentLine = state.lines[0] || '';
|
||||
return replaceRangeInternal(
|
||||
pushUndo(state),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
cpLen(currentLine),
|
||||
'',
|
||||
);
|
||||
} else {
|
||||
const startRow = Math.max(0, cursorRow - count + 1);
|
||||
const linesToChange = cursorRow - startRow + 1;
|
||||
const nextState = pushUndo(state);
|
||||
const { startOffset, endOffset } = getLineRangeOffsets(
|
||||
startRow,
|
||||
linesToChange,
|
||||
nextState.lines,
|
||||
);
|
||||
const {
|
||||
startRow: newStartRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
} = getPositionFromOffsets(
|
||||
startOffset,
|
||||
endOffset,
|
||||
nextState.lines,
|
||||
);
|
||||
const resultState = replaceRangeInternal(
|
||||
nextState,
|
||||
newStartRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
return {
|
||||
...resultState,
|
||||
cursorRow: startRow,
|
||||
cursorCol: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'l': {
|
||||
// Right
|
||||
// Change N characters to the right
|
||||
return replaceRangeInternal(
|
||||
pushUndo(state),
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
cursorRow,
|
||||
Math.min(cpLen(lines[cursorRow] || ''), cursorCol + count),
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
case 'vim_move_left': {
|
||||
const { count } = action.payload;
|
||||
const { cursorRow, cursorCol, lines } = state;
|
||||
let newRow = cursorRow;
|
||||
let newCol = cursorCol;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (newCol > 0) {
|
||||
newCol--;
|
||||
} else if (newRow > 0) {
|
||||
// Move to end of previous line
|
||||
newRow--;
|
||||
const prevLine = lines[newRow] || '';
|
||||
const prevLineLength = cpLen(prevLine);
|
||||
// Position on last character, or column 0 for empty lines
|
||||
newCol = prevLineLength === 0 ? 0 : prevLineLength - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorRow: newRow,
|
||||
cursorCol: newCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_right': {
|
||||
const { count } = action.payload;
|
||||
const { cursorRow, cursorCol, lines } = state;
|
||||
let newRow = cursorRow;
|
||||
let newCol = cursorCol;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const currentLine = lines[newRow] || '';
|
||||
const lineLength = cpLen(currentLine);
|
||||
// Don't move past the last character of the line
|
||||
// For empty lines, stay at column 0; for non-empty lines, don't go past last character
|
||||
if (lineLength === 0) {
|
||||
// Empty line - try to move to next line
|
||||
if (newRow < lines.length - 1) {
|
||||
newRow++;
|
||||
newCol = 0;
|
||||
}
|
||||
} else if (newCol < lineLength - 1) {
|
||||
newCol++;
|
||||
} else if (newRow < lines.length - 1) {
|
||||
// At end of line - move to beginning of next line
|
||||
newRow++;
|
||||
newCol = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorRow: newRow,
|
||||
cursorCol: newCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_up': {
|
||||
const { count } = action.payload;
|
||||
const { cursorRow, cursorCol, lines } = state;
|
||||
const newRow = Math.max(0, cursorRow - count);
|
||||
const newCol = Math.min(cursorCol, cpLen(lines[newRow] || ''));
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorRow: newRow,
|
||||
cursorCol: newCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_down': {
|
||||
const { count } = action.payload;
|
||||
const { cursorRow, cursorCol, lines } = state;
|
||||
const newRow = Math.min(lines.length - 1, cursorRow + count);
|
||||
const newCol = Math.min(cursorCol, cpLen(lines[newRow] || ''));
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorRow: newRow,
|
||||
cursorCol: newCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_word_forward': {
|
||||
const { count } = action.payload;
|
||||
let offset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const nextWordOffset = findNextWordStart(getText(), offset);
|
||||
if (nextWordOffset > offset) {
|
||||
offset = nextWordOffset;
|
||||
} else {
|
||||
// No more words to move to
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const { startRow, startCol } = getPositionFromOffsets(
|
||||
offset,
|
||||
offset,
|
||||
lines,
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
cursorRow: startRow,
|
||||
cursorCol: startCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_word_backward': {
|
||||
const { count } = action.payload;
|
||||
let offset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
offset = findPrevWordStart(getText(), offset);
|
||||
}
|
||||
|
||||
const { startRow, startCol } = getPositionFromOffsets(
|
||||
offset,
|
||||
offset,
|
||||
lines,
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
cursorRow: startRow,
|
||||
cursorCol: startCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_word_end': {
|
||||
const { count } = action.payload;
|
||||
let offset = getOffsetFromPosition(cursorRow, cursorCol, lines);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
offset = findWordEnd(getText(), offset);
|
||||
}
|
||||
|
||||
const { startRow, startCol } = getPositionFromOffsets(
|
||||
offset,
|
||||
offset,
|
||||
lines,
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
cursorRow: startRow,
|
||||
cursorCol: startCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_delete_char': {
|
||||
const { count } = action.payload;
|
||||
const { cursorRow, cursorCol, lines } = state;
|
||||
const currentLine = lines[cursorRow] || '';
|
||||
const lineLength = cpLen(currentLine);
|
||||
|
||||
if (cursorCol < lineLength) {
|
||||
const deleteCount = Math.min(count, lineLength - cursorCol);
|
||||
const nextState = pushUndo(state);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
cursorRow,
|
||||
cursorCol + deleteCount,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_insert_at_cursor': {
|
||||
// Just return state - mode change is handled elsewhere
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_append_at_cursor': {
|
||||
const { cursorRow, cursorCol, lines } = state;
|
||||
const currentLine = lines[cursorRow] || '';
|
||||
const newCol = cursorCol < cpLen(currentLine) ? cursorCol + 1 : cursorCol;
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorCol: newCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_open_line_below': {
|
||||
const { cursorRow, lines } = state;
|
||||
const nextState = pushUndo(state);
|
||||
|
||||
// Insert newline at end of current line
|
||||
const endOfLine = cpLen(lines[cursorRow] || '');
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
endOfLine,
|
||||
cursorRow,
|
||||
endOfLine,
|
||||
'\n',
|
||||
);
|
||||
}
|
||||
|
||||
case 'vim_open_line_above': {
|
||||
const { cursorRow } = state;
|
||||
const nextState = pushUndo(state);
|
||||
|
||||
// Insert newline at beginning of current line
|
||||
const resultState = replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
0,
|
||||
cursorRow,
|
||||
0,
|
||||
'\n',
|
||||
);
|
||||
|
||||
// Move cursor to the new line above
|
||||
return {
|
||||
...resultState,
|
||||
cursorRow,
|
||||
cursorCol: 0,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_append_at_line_end': {
|
||||
const { cursorRow, lines } = state;
|
||||
const lineLength = cpLen(lines[cursorRow] || '');
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorCol: lineLength,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_insert_at_line_start': {
|
||||
const { cursorRow, lines } = state;
|
||||
const currentLine = lines[cursorRow] || '';
|
||||
let col = 0;
|
||||
|
||||
// Find first non-whitespace character using proper Unicode handling
|
||||
const lineCodePoints = [...currentLine]; // Proper Unicode iteration
|
||||
while (col < lineCodePoints.length && /\s/.test(lineCodePoints[col])) {
|
||||
col++;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorCol: col,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_to_line_start': {
|
||||
return {
|
||||
...state,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_to_line_end': {
|
||||
const { cursorRow, lines } = state;
|
||||
const lineLength = cpLen(lines[cursorRow] || '');
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorCol: lineLength > 0 ? lineLength - 1 : 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_to_first_nonwhitespace': {
|
||||
const { cursorRow, lines } = state;
|
||||
const currentLine = lines[cursorRow] || '';
|
||||
let col = 0;
|
||||
|
||||
// Find first non-whitespace character using proper Unicode handling
|
||||
const lineCodePoints = [...currentLine]; // Proper Unicode iteration
|
||||
while (col < lineCodePoints.length && /\s/.test(lineCodePoints[col])) {
|
||||
col++;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorCol: col,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_to_first_line': {
|
||||
return {
|
||||
...state,
|
||||
cursorRow: 0,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_to_last_line': {
|
||||
const { lines } = state;
|
||||
const lastRow = lines.length - 1;
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorRow: lastRow,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_move_to_line': {
|
||||
const { lineNumber } = action.payload;
|
||||
const { lines } = state;
|
||||
const targetRow = Math.min(Math.max(0, lineNumber - 1), lines.length - 1);
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorRow: targetRow,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_escape_insert_mode': {
|
||||
// Move cursor left if not at beginning of line (vim behavior when exiting insert mode)
|
||||
const { cursorCol } = state;
|
||||
const newCol = cursorCol > 0 ? cursorCol - 1 : 0;
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorCol: newCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
default: {
|
||||
// This should never happen if TypeScript is working correctly
|
||||
const _exhaustiveCheck: never = action;
|
||||
return state;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user