mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-21 01:07:46 +00:00
Sync upstream Gemini-CLI v0.8.2 (#838)
This commit is contained in:
534
packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx
Normal file
534
packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx
Normal file
@@ -0,0 +1,534 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import {
|
||||
BaseSelectionList,
|
||||
type BaseSelectionListProps,
|
||||
type RenderItemContext,
|
||||
} from './BaseSelectionList.js';
|
||||
import { useSelectionList } from '../../hooks/useSelectionList.js';
|
||||
import { Text } from 'ink';
|
||||
import type { theme } from '../../semantic-colors.js';
|
||||
|
||||
vi.mock('../../hooks/useSelectionList.js');
|
||||
|
||||
const mockTheme = {
|
||||
text: { primary: 'COLOR_PRIMARY', secondary: 'COLOR_SECONDARY' },
|
||||
status: { success: 'COLOR_SUCCESS' },
|
||||
} as typeof theme;
|
||||
|
||||
vi.mock('../../semantic-colors.js', () => ({
|
||||
theme: {
|
||||
text: { primary: 'COLOR_PRIMARY', secondary: 'COLOR_SECONDARY' },
|
||||
status: { success: 'COLOR_SUCCESS' },
|
||||
},
|
||||
}));
|
||||
|
||||
describe('BaseSelectionList', () => {
|
||||
const mockOnSelect = vi.fn();
|
||||
const mockOnHighlight = vi.fn();
|
||||
const mockRenderItem = vi.fn();
|
||||
|
||||
const items = [
|
||||
{ value: 'A', label: 'Item A', key: 'A' },
|
||||
{ value: 'B', label: 'Item B', disabled: true, key: 'B' },
|
||||
{ value: 'C', label: 'Item C', key: 'C' },
|
||||
];
|
||||
|
||||
// Helper to render the component with default props
|
||||
const renderComponent = (
|
||||
props: Partial<
|
||||
BaseSelectionListProps<
|
||||
string,
|
||||
{ value: string; label: string; disabled?: boolean; key: string }
|
||||
>
|
||||
> = {},
|
||||
activeIndex: number = 0,
|
||||
) => {
|
||||
vi.mocked(useSelectionList).mockReturnValue({
|
||||
activeIndex,
|
||||
setActiveIndex: vi.fn(),
|
||||
});
|
||||
|
||||
mockRenderItem.mockImplementation(
|
||||
(
|
||||
item: { value: string; label: string; disabled?: boolean; key: string },
|
||||
context: RenderItemContext,
|
||||
) => <Text color={context.titleColor}>{item.label}</Text>,
|
||||
);
|
||||
|
||||
const defaultProps: BaseSelectionListProps<
|
||||
string,
|
||||
{ value: string; label: string; disabled?: boolean; key: string }
|
||||
> = {
|
||||
items,
|
||||
onSelect: mockOnSelect,
|
||||
onHighlight: mockOnHighlight,
|
||||
renderItem: mockRenderItem,
|
||||
...props,
|
||||
};
|
||||
|
||||
return renderWithProviders(<BaseSelectionList {...defaultProps} />);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering and Structure', () => {
|
||||
it('should render all items using the renderItem prop', () => {
|
||||
const { lastFrame } = renderComponent();
|
||||
|
||||
expect(lastFrame()).toContain('Item A');
|
||||
expect(lastFrame()).toContain('Item B');
|
||||
expect(lastFrame()).toContain('Item C');
|
||||
|
||||
expect(mockRenderItem).toHaveBeenCalledTimes(3);
|
||||
expect(mockRenderItem).toHaveBeenCalledWith(items[0], expect.any(Object));
|
||||
});
|
||||
|
||||
it('should render the selection indicator (● or space) and layout', () => {
|
||||
const { lastFrame } = renderComponent({}, 0);
|
||||
const output = lastFrame();
|
||||
|
||||
// Use regex to assert the structure: Indicator + Whitespace + Number + Label
|
||||
expect(output).toMatch(/●\s+1\.\s+Item A/);
|
||||
expect(output).toMatch(/\s+2\.\s+Item B/);
|
||||
expect(output).toMatch(/\s+3\.\s+Item C/);
|
||||
});
|
||||
|
||||
it('should handle an empty list gracefully', () => {
|
||||
const { lastFrame } = renderComponent({ items: [] });
|
||||
expect(mockRenderItem).not.toHaveBeenCalled();
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSelectionList Integration', () => {
|
||||
it('should pass props correctly to useSelectionList', () => {
|
||||
const initialIndex = 1;
|
||||
const isFocused = false;
|
||||
const showNumbers = false;
|
||||
|
||||
renderComponent({ initialIndex, isFocused, showNumbers });
|
||||
|
||||
expect(useSelectionList).toHaveBeenCalledWith({
|
||||
items,
|
||||
initialIndex,
|
||||
onSelect: mockOnSelect,
|
||||
onHighlight: mockOnHighlight,
|
||||
isFocused,
|
||||
showNumbers,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use the activeIndex returned by the hook', () => {
|
||||
renderComponent({}, 2); // Active index is C
|
||||
|
||||
expect(mockRenderItem).toHaveBeenCalledWith(
|
||||
items[0],
|
||||
expect.objectContaining({ isSelected: false }),
|
||||
);
|
||||
expect(mockRenderItem).toHaveBeenCalledWith(
|
||||
items[2],
|
||||
expect.objectContaining({ isSelected: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Styling and Colors', () => {
|
||||
it('should apply success color to the selected item', () => {
|
||||
renderComponent({}, 0); // Item A selected
|
||||
|
||||
// Check renderItem context colors against the mocked theme
|
||||
expect(mockRenderItem).toHaveBeenCalledWith(
|
||||
items[0],
|
||||
expect.objectContaining({
|
||||
titleColor: mockTheme.status.success,
|
||||
numberColor: mockTheme.status.success,
|
||||
isSelected: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply primary color to unselected, enabled items', () => {
|
||||
renderComponent({}, 0); // Item A selected, Item C unselected/enabled
|
||||
|
||||
// Check renderItem context colors for Item C
|
||||
expect(mockRenderItem).toHaveBeenCalledWith(
|
||||
items[2],
|
||||
expect.objectContaining({
|
||||
titleColor: mockTheme.text.primary,
|
||||
numberColor: mockTheme.text.primary,
|
||||
isSelected: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply secondary color to disabled items (when not selected)', () => {
|
||||
renderComponent({}, 0); // Item A selected, Item B disabled
|
||||
|
||||
// Check renderItem context colors for Item B
|
||||
expect(mockRenderItem).toHaveBeenCalledWith(
|
||||
items[1],
|
||||
expect.objectContaining({
|
||||
titleColor: mockTheme.text.secondary,
|
||||
numberColor: mockTheme.text.secondary,
|
||||
isSelected: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply success color to disabled items if they are selected', () => {
|
||||
// The component should visually reflect the selection even if the item is disabled.
|
||||
renderComponent({}, 1); // Item B (disabled) selected
|
||||
|
||||
// Check renderItem context colors for Item B
|
||||
expect(mockRenderItem).toHaveBeenCalledWith(
|
||||
items[1],
|
||||
expect.objectContaining({
|
||||
titleColor: mockTheme.status.success,
|
||||
numberColor: mockTheme.status.success,
|
||||
isSelected: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Numbering (showNumbers)', () => {
|
||||
it('should show numbers by default with correct formatting', () => {
|
||||
const { lastFrame } = renderComponent();
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('1.');
|
||||
expect(output).toContain('2.');
|
||||
expect(output).toContain('3.');
|
||||
});
|
||||
|
||||
it('should hide numbers when showNumbers is false', () => {
|
||||
const { lastFrame } = renderComponent({ showNumbers: false });
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).not.toContain('1.');
|
||||
expect(output).not.toContain('2.');
|
||||
expect(output).not.toContain('3.');
|
||||
});
|
||||
|
||||
it('should apply correct padding for alignment in long lists', () => {
|
||||
const longList = Array.from({ length: 15 }, (_, i) => ({
|
||||
value: `Item ${i + 1}`,
|
||||
label: `Item ${i + 1}`,
|
||||
key: `Item ${i + 1}`,
|
||||
}));
|
||||
|
||||
// We must increase maxItemsToShow (default 10) to see the 10th item and beyond
|
||||
const { lastFrame } = renderComponent({
|
||||
items: longList,
|
||||
maxItemsToShow: 15,
|
||||
});
|
||||
const output = lastFrame();
|
||||
|
||||
// Check formatting for single and double digits.
|
||||
// The implementation uses padStart, resulting in " 1." and "10.".
|
||||
expect(output).toContain(' 1.');
|
||||
expect(output).toContain('10.');
|
||||
});
|
||||
|
||||
it('should apply secondary color to numbers if showNumbers is false (internal logic check)', () => {
|
||||
renderComponent({ showNumbers: false }, 0);
|
||||
|
||||
expect(mockRenderItem).toHaveBeenCalledWith(
|
||||
items[0],
|
||||
expect.objectContaining({
|
||||
isSelected: true,
|
||||
titleColor: mockTheme.status.success,
|
||||
numberColor: mockTheme.text.secondary,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scrolling and Pagination (maxItemsToShow)', () => {
|
||||
const longList = Array.from({ length: 10 }, (_, i) => ({
|
||||
value: `Item ${i + 1}`,
|
||||
label: `Item ${i + 1}`,
|
||||
key: `Item ${i + 1}`,
|
||||
}));
|
||||
const MAX_ITEMS = 3;
|
||||
|
||||
const renderScrollableList = (initialActiveIndex: number = 0) => {
|
||||
// Define the props used for the initial render and subsequent rerenders
|
||||
const componentProps: BaseSelectionListProps<
|
||||
string,
|
||||
{ value: string; label: string; key: string }
|
||||
> = {
|
||||
items: longList,
|
||||
maxItemsToShow: MAX_ITEMS,
|
||||
onSelect: mockOnSelect,
|
||||
onHighlight: mockOnHighlight,
|
||||
renderItem: mockRenderItem,
|
||||
};
|
||||
|
||||
vi.mocked(useSelectionList).mockReturnValue({
|
||||
activeIndex: initialActiveIndex,
|
||||
setActiveIndex: vi.fn(),
|
||||
});
|
||||
|
||||
mockRenderItem.mockImplementation(
|
||||
(item: (typeof longList)[0], context: RenderItemContext) => (
|
||||
<Text color={context.titleColor}>{item.label}</Text>
|
||||
),
|
||||
);
|
||||
|
||||
const { rerender, lastFrame } = renderWithProviders(
|
||||
<BaseSelectionList {...componentProps} />,
|
||||
);
|
||||
|
||||
// Function to simulate the activeIndex changing over time
|
||||
const updateActiveIndex = async (newIndex: number) => {
|
||||
vi.mocked(useSelectionList).mockReturnValue({
|
||||
activeIndex: newIndex,
|
||||
setActiveIndex: vi.fn(),
|
||||
});
|
||||
|
||||
rerender(<BaseSelectionList {...componentProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toBeTruthy();
|
||||
});
|
||||
};
|
||||
|
||||
return { updateActiveIndex, lastFrame };
|
||||
};
|
||||
|
||||
it('should only show maxItemsToShow items initially', () => {
|
||||
const { lastFrame } = renderScrollableList(0);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Item 1');
|
||||
expect(output).toContain('Item 3');
|
||||
expect(output).not.toContain('Item 4');
|
||||
});
|
||||
|
||||
it('should scroll down when activeIndex moves beyond the visible window', async () => {
|
||||
const { updateActiveIndex, lastFrame } = renderScrollableList(0);
|
||||
|
||||
// Move to index 3 (Item 4). Should trigger scroll.
|
||||
// New visible window should be Items 2, 3, 4 (scroll offset 1).
|
||||
await updateActiveIndex(3);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('Item 1');
|
||||
expect(output).toContain('Item 2');
|
||||
expect(output).toContain('Item 4');
|
||||
expect(output).not.toContain('Item 5');
|
||||
});
|
||||
|
||||
it('should scroll up when activeIndex moves before the visible window', async () => {
|
||||
const { updateActiveIndex, lastFrame } = renderScrollableList(0);
|
||||
|
||||
await updateActiveIndex(4);
|
||||
|
||||
let output = lastFrame();
|
||||
expect(output).toContain('Item 3'); // Should see items 3, 4, 5
|
||||
expect(output).toContain('Item 5');
|
||||
expect(output).not.toContain('Item 2');
|
||||
|
||||
// Now test scrolling up: move to index 1 (Item 2)
|
||||
// This should trigger scroll up to show items 2, 3, 4
|
||||
await updateActiveIndex(1);
|
||||
|
||||
output = lastFrame();
|
||||
expect(output).toContain('Item 2');
|
||||
expect(output).toContain('Item 4');
|
||||
expect(output).not.toContain('Item 5'); // Item 5 should no longer be visible
|
||||
});
|
||||
|
||||
it('should pin the scroll offset to the end if selection starts near the end', async () => {
|
||||
// List length 10. Max items 3. Active index 9 (last item).
|
||||
// Scroll offset should be 10 - 3 = 7.
|
||||
// Visible items: 8, 9, 10.
|
||||
const { lastFrame } = renderScrollableList(9);
|
||||
|
||||
await waitFor(() => {
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Item 10');
|
||||
expect(output).toContain('Item 8');
|
||||
expect(output).not.toContain('Item 7');
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle dynamic scrolling through multiple activeIndex changes', async () => {
|
||||
const { updateActiveIndex, lastFrame } = renderScrollableList(0);
|
||||
|
||||
expect(lastFrame()).toContain('Item 1');
|
||||
expect(lastFrame()).toContain('Item 3');
|
||||
|
||||
// Scroll down gradually
|
||||
await updateActiveIndex(2); // Still within window
|
||||
expect(lastFrame()).toContain('Item 1');
|
||||
|
||||
await updateActiveIndex(3); // Should trigger scroll
|
||||
let output = lastFrame();
|
||||
expect(output).toContain('Item 2');
|
||||
expect(output).toContain('Item 4');
|
||||
expect(output).not.toContain('Item 1');
|
||||
|
||||
await updateActiveIndex(5); // Scroll further
|
||||
output = lastFrame();
|
||||
expect(output).toContain('Item 4');
|
||||
expect(output).toContain('Item 6');
|
||||
expect(output).not.toContain('Item 3');
|
||||
});
|
||||
|
||||
it('should correctly identify the selected item within the visible window', () => {
|
||||
renderScrollableList(1); // activeIndex 1 = Item 2
|
||||
|
||||
expect(mockRenderItem).toHaveBeenCalledTimes(MAX_ITEMS);
|
||||
|
||||
expect(mockRenderItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ value: 'Item 1' }),
|
||||
expect.objectContaining({ isSelected: false }),
|
||||
);
|
||||
|
||||
expect(mockRenderItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ value: 'Item 2' }),
|
||||
expect.objectContaining({ isSelected: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should correctly identify the selected item when scrolled (high index)', async () => {
|
||||
renderScrollableList(5);
|
||||
|
||||
await waitFor(() => {
|
||||
// Item 6 (index 5) should be selected
|
||||
expect(mockRenderItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ value: 'Item 6' }),
|
||||
expect.objectContaining({ isSelected: true }),
|
||||
);
|
||||
});
|
||||
|
||||
// Item 4 (index 3) should not be selected
|
||||
expect(mockRenderItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ value: 'Item 4' }),
|
||||
expect.objectContaining({ isSelected: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle maxItemsToShow larger than the list length', () => {
|
||||
// Test edge case where maxItemsToShow exceeds available items
|
||||
const { lastFrame } = renderComponent(
|
||||
{ items: longList, maxItemsToShow: 15 },
|
||||
0,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
// Should show all available items (10 items)
|
||||
expect(output).toContain('Item 1');
|
||||
expect(output).toContain('Item 10');
|
||||
expect(mockRenderItem).toHaveBeenCalledTimes(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scroll Arrows (showScrollArrows)', () => {
|
||||
const longList = Array.from({ length: 10 }, (_, i) => ({
|
||||
value: `Item ${i + 1}`,
|
||||
label: `Item ${i + 1}`,
|
||||
key: `Item ${i + 1}`,
|
||||
}));
|
||||
const MAX_ITEMS = 3;
|
||||
|
||||
it('should not show arrows by default', () => {
|
||||
const { lastFrame } = renderComponent({
|
||||
items: longList,
|
||||
maxItemsToShow: MAX_ITEMS,
|
||||
});
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).not.toContain('▲');
|
||||
expect(output).not.toContain('▼');
|
||||
});
|
||||
|
||||
it('should show arrows with correct colors when enabled (at the top)', async () => {
|
||||
const { lastFrame } = renderComponent(
|
||||
{
|
||||
items: longList,
|
||||
maxItemsToShow: MAX_ITEMS,
|
||||
showScrollArrows: true,
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const output = lastFrame();
|
||||
// At the top, should show first 3 items
|
||||
expect(output).toContain('Item 1');
|
||||
expect(output).toContain('Item 3');
|
||||
expect(output).not.toContain('Item 4');
|
||||
// Both arrows should be visible
|
||||
expect(output).toContain('▲');
|
||||
expect(output).toContain('▼');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show arrows and correct items when scrolled to the middle', async () => {
|
||||
const { lastFrame } = renderComponent(
|
||||
{ items: longList, maxItemsToShow: MAX_ITEMS, showScrollArrows: true },
|
||||
5,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const output = lastFrame();
|
||||
// After scrolling to middle, should see items around index 5
|
||||
expect(output).toContain('Item 4');
|
||||
expect(output).toContain('Item 6');
|
||||
expect(output).not.toContain('Item 3');
|
||||
expect(output).not.toContain('Item 7');
|
||||
// Both scroll arrows should be visible
|
||||
expect(output).toContain('▲');
|
||||
expect(output).toContain('▼');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show arrows and correct items when scrolled to the end', async () => {
|
||||
const { lastFrame } = renderComponent(
|
||||
{ items: longList, maxItemsToShow: MAX_ITEMS, showScrollArrows: true },
|
||||
9,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const output = lastFrame();
|
||||
// At the end, should show last 3 items
|
||||
expect(output).toContain('Item 8');
|
||||
expect(output).toContain('Item 10');
|
||||
expect(output).not.toContain('Item 7');
|
||||
// Both arrows should be visible
|
||||
expect(output).toContain('▲');
|
||||
expect(output).toContain('▼');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show both arrows dimmed when list fits entirely', () => {
|
||||
const { lastFrame } = renderComponent({
|
||||
items,
|
||||
maxItemsToShow: 5,
|
||||
showScrollArrows: true,
|
||||
});
|
||||
|
||||
const output = lastFrame();
|
||||
// Should show all items since maxItemsToShow > items.length
|
||||
expect(output).toContain('Item A');
|
||||
expect(output).toContain('Item B');
|
||||
expect(output).toContain('Item C');
|
||||
// Both arrows should be visible but dimmed (this test doesn't need waitFor since no scrolling occurs)
|
||||
expect(output).toContain('▲');
|
||||
expect(output).toContain('▼');
|
||||
});
|
||||
});
|
||||
});
|
||||
179
packages/cli/src/ui/components/shared/BaseSelectionList.tsx
Normal file
179
packages/cli/src/ui/components/shared/BaseSelectionList.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useSelectionList } from '../../hooks/useSelectionList.js';
|
||||
|
||||
import type { SelectionListItem } from '../../hooks/useSelectionList.js';
|
||||
|
||||
export interface RenderItemContext {
|
||||
isSelected: boolean;
|
||||
titleColor: string;
|
||||
numberColor: string;
|
||||
}
|
||||
|
||||
export interface BaseSelectionListProps<
|
||||
T,
|
||||
TItem extends SelectionListItem<T> = SelectionListItem<T>,
|
||||
> {
|
||||
items: TItem[];
|
||||
initialIndex?: number;
|
||||
onSelect: (value: T) => void;
|
||||
onHighlight?: (value: T) => void;
|
||||
isFocused?: boolean;
|
||||
showNumbers?: boolean;
|
||||
showScrollArrows?: boolean;
|
||||
maxItemsToShow?: number;
|
||||
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base component for selection lists that provides common UI structure
|
||||
* and keyboard navigation logic via the useSelectionList hook.
|
||||
*
|
||||
* This component handles:
|
||||
* - Radio button indicators
|
||||
* - Item numbering
|
||||
* - Scrolling for long lists
|
||||
* - Color theming based on selection/disabled state
|
||||
* - Keyboard navigation and numeric selection
|
||||
*
|
||||
* Specific components should use this as a base and provide
|
||||
* their own renderItem implementation for custom content.
|
||||
*/
|
||||
export function BaseSelectionList<
|
||||
T,
|
||||
TItem extends SelectionListItem<T> = SelectionListItem<T>,
|
||||
>({
|
||||
items,
|
||||
initialIndex = 0,
|
||||
onSelect,
|
||||
onHighlight,
|
||||
isFocused = true,
|
||||
showNumbers = true,
|
||||
showScrollArrows = false,
|
||||
maxItemsToShow = 10,
|
||||
renderItem,
|
||||
}: BaseSelectionListProps<T, TItem>): React.JSX.Element {
|
||||
const { activeIndex } = useSelectionList({
|
||||
items,
|
||||
initialIndex,
|
||||
onSelect,
|
||||
onHighlight,
|
||||
isFocused,
|
||||
showNumbers,
|
||||
});
|
||||
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
|
||||
// Handle scrolling for long lists
|
||||
useEffect(() => {
|
||||
const newScrollOffset = Math.max(
|
||||
0,
|
||||
Math.min(activeIndex - maxItemsToShow + 1, items.length - maxItemsToShow),
|
||||
);
|
||||
if (activeIndex < scrollOffset) {
|
||||
setScrollOffset(activeIndex);
|
||||
} else if (activeIndex >= scrollOffset + maxItemsToShow) {
|
||||
setScrollOffset(newScrollOffset);
|
||||
}
|
||||
}, [activeIndex, items.length, scrollOffset, maxItemsToShow]);
|
||||
|
||||
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
|
||||
const numberColumnWidth = String(items.length).length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Use conditional coloring instead of conditional rendering */}
|
||||
{showScrollArrows && (
|
||||
<Text
|
||||
color={scrollOffset > 0 ? theme.text.primary : theme.text.secondary}
|
||||
>
|
||||
▲
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{visibleItems.map((item, index) => {
|
||||
const itemIndex = scrollOffset + index;
|
||||
const isSelected = activeIndex === itemIndex;
|
||||
|
||||
// Determine colors based on selection and disabled state
|
||||
let titleColor = theme.text.primary;
|
||||
let numberColor = theme.text.primary;
|
||||
|
||||
if (isSelected) {
|
||||
titleColor = theme.status.success;
|
||||
numberColor = theme.status.success;
|
||||
} else if (item.disabled) {
|
||||
titleColor = theme.text.secondary;
|
||||
numberColor = theme.text.secondary;
|
||||
}
|
||||
|
||||
if (!isFocused && !item.disabled) {
|
||||
numberColor = theme.text.secondary;
|
||||
}
|
||||
|
||||
if (!showNumbers) {
|
||||
numberColor = theme.text.secondary;
|
||||
}
|
||||
|
||||
const itemNumberText = `${String(itemIndex + 1).padStart(
|
||||
numberColumnWidth,
|
||||
)}.`;
|
||||
|
||||
return (
|
||||
<Box key={item.key} alignItems="flex-start">
|
||||
{/* Radio button indicator */}
|
||||
<Box minWidth={2} flexShrink={0}>
|
||||
<Text
|
||||
color={isSelected ? theme.status.success : theme.text.primary}
|
||||
aria-hidden
|
||||
>
|
||||
{isSelected ? '●' : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Item number */}
|
||||
{showNumbers && (
|
||||
<Box
|
||||
marginRight={1}
|
||||
flexShrink={0}
|
||||
minWidth={itemNumberText.length}
|
||||
aria-state={{ checked: isSelected }}
|
||||
>
|
||||
<Text color={numberColor}>{itemNumberText}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Custom content via render prop */}
|
||||
<Box flexGrow={1}>
|
||||
{renderItem(item, {
|
||||
isSelected,
|
||||
titleColor,
|
||||
numberColor,
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{showScrollArrows && (
|
||||
<Text
|
||||
color={
|
||||
scrollOffset + maxItemsToShow < items.length
|
||||
? theme.text.primary
|
||||
: theme.text.secondary
|
||||
}
|
||||
>
|
||||
▼
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import {
|
||||
DescriptiveRadioButtonSelect,
|
||||
type DescriptiveRadioSelectItem,
|
||||
type DescriptiveRadioButtonSelectProps,
|
||||
} from './DescriptiveRadioButtonSelect.js';
|
||||
|
||||
vi.mock('./BaseSelectionList.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('./BaseSelectionList.js')>();
|
||||
return {
|
||||
...actual,
|
||||
BaseSelectionList: vi.fn(({ children, ...props }) => (
|
||||
<actual.BaseSelectionList {...props}>{children}</actual.BaseSelectionList>
|
||||
)),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../semantic-colors.js', () => ({
|
||||
theme: {
|
||||
text: {
|
||||
primary: 'COLOR_PRIMARY',
|
||||
secondary: 'COLOR_SECONDARY',
|
||||
},
|
||||
status: {
|
||||
success: 'COLOR_SUCCESS',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('DescriptiveRadioButtonSelect', () => {
|
||||
const mockOnSelect = vi.fn();
|
||||
const mockOnHighlight = vi.fn();
|
||||
|
||||
const ITEMS: Array<DescriptiveRadioSelectItem<string>> = [
|
||||
{
|
||||
title: 'Foo Title',
|
||||
description: 'This is Foo.',
|
||||
value: 'foo',
|
||||
key: 'foo',
|
||||
},
|
||||
{
|
||||
title: 'Bar Title',
|
||||
description: 'This is Bar.',
|
||||
value: 'bar',
|
||||
key: 'bar',
|
||||
},
|
||||
{
|
||||
title: 'Baz Title',
|
||||
description: 'This is Baz.',
|
||||
value: 'baz',
|
||||
disabled: true,
|
||||
key: 'baz',
|
||||
},
|
||||
];
|
||||
|
||||
const renderComponent = (
|
||||
props: Partial<DescriptiveRadioButtonSelectProps<string>> = {},
|
||||
) => {
|
||||
const defaultProps: DescriptiveRadioButtonSelectProps<string> = {
|
||||
items: ITEMS,
|
||||
onSelect: mockOnSelect,
|
||||
...props,
|
||||
};
|
||||
return renderWithProviders(
|
||||
<DescriptiveRadioButtonSelect {...defaultProps} />,
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render correctly with default props', () => {
|
||||
const { lastFrame } = renderComponent();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render correctly with custom props', () => {
|
||||
const { lastFrame } = renderComponent({
|
||||
initialIndex: 1,
|
||||
isFocused: false,
|
||||
showScrollArrows: true,
|
||||
maxItemsToShow: 5,
|
||||
showNumbers: true,
|
||||
onHighlight: mockOnHighlight,
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { BaseSelectionList } from './BaseSelectionList.js';
|
||||
import type { SelectionListItem } from '../../hooks/useSelectionList.js';
|
||||
|
||||
export interface DescriptiveRadioSelectItem<T> extends SelectionListItem<T> {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface DescriptiveRadioButtonSelectProps<T> {
|
||||
/** An array of items to display as descriptive radio options. */
|
||||
items: Array<DescriptiveRadioSelectItem<T>>;
|
||||
/** The initial index selected */
|
||||
initialIndex?: number;
|
||||
/** Function called when an item is selected. Receives the `value` of the selected item. */
|
||||
onSelect: (value: T) => void;
|
||||
/** Function called when an item is highlighted. Receives the `value` of the selected item. */
|
||||
onHighlight?: (value: T) => void;
|
||||
/** Whether this select input is currently focused and should respond to input. */
|
||||
isFocused?: boolean;
|
||||
/** Whether to show numbers next to items. */
|
||||
showNumbers?: boolean;
|
||||
/** Whether to show the scroll arrows. */
|
||||
showScrollArrows?: boolean;
|
||||
/** The maximum number of items to show at once. */
|
||||
maxItemsToShow?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A radio button select component that displays items with title and description.
|
||||
*
|
||||
* @template T The type of the value associated with each descriptive radio item.
|
||||
*/
|
||||
export function DescriptiveRadioButtonSelect<T>({
|
||||
items,
|
||||
initialIndex = 0,
|
||||
onSelect,
|
||||
onHighlight,
|
||||
isFocused = true,
|
||||
showNumbers = false,
|
||||
showScrollArrows = false,
|
||||
maxItemsToShow = 10,
|
||||
}: DescriptiveRadioButtonSelectProps<T>): React.JSX.Element {
|
||||
return (
|
||||
<BaseSelectionList<T, DescriptiveRadioSelectItem<T>>
|
||||
items={items}
|
||||
initialIndex={initialIndex}
|
||||
onSelect={onSelect}
|
||||
onHighlight={onHighlight}
|
||||
isFocused={isFocused}
|
||||
showNumbers={showNumbers}
|
||||
showScrollArrows={showScrollArrows}
|
||||
maxItemsToShow={maxItemsToShow}
|
||||
renderItem={(item, { titleColor }) => (
|
||||
<Box flexDirection="column" key={item.key}>
|
||||
<Text color={titleColor}>{item.title}</Text>
|
||||
<Text color={theme.text.secondary}>{item.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
152
packages/cli/src/ui/components/shared/EnumSelector.test.tsx
Normal file
152
packages/cli/src/ui/components/shared/EnumSelector.test.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { EnumSelector } from './EnumSelector.js';
|
||||
import type { SettingEnumOption } from '../../../config/settingsSchema.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
const LANGUAGE_OPTIONS: readonly SettingEnumOption[] = [
|
||||
{ label: 'English', value: 'en' },
|
||||
{ label: '中文 (简体)', value: 'zh' },
|
||||
{ label: 'Español', value: 'es' },
|
||||
{ label: 'Français', value: 'fr' },
|
||||
];
|
||||
|
||||
const NUMERIC_OPTIONS: readonly SettingEnumOption[] = [
|
||||
{ label: 'Low', value: 1 },
|
||||
{ label: 'Medium', value: 2 },
|
||||
{ label: 'High', value: 3 },
|
||||
];
|
||||
|
||||
describe('<EnumSelector />', () => {
|
||||
it('renders with string options and matches snapshot', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<EnumSelector
|
||||
options={LANGUAGE_OPTIONS}
|
||||
currentValue="en"
|
||||
isActive={true}
|
||||
onValueChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders with numeric options and matches snapshot', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<EnumSelector
|
||||
options={NUMERIC_OPTIONS}
|
||||
currentValue={2}
|
||||
isActive={true}
|
||||
onValueChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders inactive state and matches snapshot', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<EnumSelector
|
||||
options={LANGUAGE_OPTIONS}
|
||||
currentValue="zh"
|
||||
isActive={false}
|
||||
onValueChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders with single option and matches snapshot', () => {
|
||||
const singleOption: readonly SettingEnumOption[] = [
|
||||
{ label: 'Only Option', value: 'only' },
|
||||
];
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<EnumSelector
|
||||
options={singleOption}
|
||||
currentValue="only"
|
||||
isActive={true}
|
||||
onValueChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders nothing when no options are provided', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<EnumSelector
|
||||
options={[]}
|
||||
currentValue=""
|
||||
isActive={true}
|
||||
onValueChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
|
||||
it('handles currentValue not found in options', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<EnumSelector
|
||||
options={LANGUAGE_OPTIONS}
|
||||
currentValue="invalid"
|
||||
isActive={true}
|
||||
onValueChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
// Should default to first option
|
||||
expect(lastFrame()).toContain('English');
|
||||
});
|
||||
|
||||
it('updates when currentValue changes externally', () => {
|
||||
const { rerender, lastFrame } = renderWithProviders(
|
||||
<EnumSelector
|
||||
options={LANGUAGE_OPTIONS}
|
||||
currentValue="en"
|
||||
isActive={true}
|
||||
onValueChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toContain('English');
|
||||
|
||||
rerender(
|
||||
<EnumSelector
|
||||
options={LANGUAGE_OPTIONS}
|
||||
currentValue="zh"
|
||||
isActive={true}
|
||||
onValueChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toContain('中文 (简体)');
|
||||
});
|
||||
|
||||
it('shows navigation arrows when multiple options available', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<EnumSelector
|
||||
options={LANGUAGE_OPTIONS}
|
||||
currentValue="en"
|
||||
isActive={true}
|
||||
onValueChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toContain('←');
|
||||
expect(lastFrame()).toContain('→');
|
||||
});
|
||||
|
||||
it('hides navigation arrows when single option available', () => {
|
||||
const singleOption: readonly SettingEnumOption[] = [
|
||||
{ label: 'Only Option', value: 'only' },
|
||||
];
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<EnumSelector
|
||||
options={singleOption}
|
||||
currentValue="only"
|
||||
isActive={true}
|
||||
onValueChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).not.toContain('←');
|
||||
expect(lastFrame()).not.toContain('→');
|
||||
});
|
||||
});
|
||||
87
packages/cli/src/ui/components/shared/EnumSelector.tsx
Normal file
87
packages/cli/src/ui/components/shared/EnumSelector.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Colors } from '../../colors.js';
|
||||
import type { SettingEnumOption } from '../../../config/settingsSchema.js';
|
||||
|
||||
interface EnumSelectorProps {
|
||||
options: readonly SettingEnumOption[];
|
||||
currentValue: string | number;
|
||||
isActive: boolean;
|
||||
onValueChange: (value: string | number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A left-right scrolling selector for enum values
|
||||
*/
|
||||
export function EnumSelector({
|
||||
options,
|
||||
currentValue,
|
||||
isActive,
|
||||
onValueChange: _onValueChange,
|
||||
}: EnumSelectorProps): React.JSX.Element {
|
||||
const [currentIndex, setCurrentIndex] = useState(() => {
|
||||
// Guard against empty options array
|
||||
if (!options || options.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const index = options.findIndex((option) => option.value === currentValue);
|
||||
return index >= 0 ? index : 0;
|
||||
});
|
||||
|
||||
// Update index when currentValue changes externally
|
||||
useEffect(() => {
|
||||
// Guard against empty options array
|
||||
if (!options || options.length === 0) {
|
||||
return;
|
||||
}
|
||||
const index = options.findIndex((option) => option.value === currentValue);
|
||||
// Always update index, defaulting to 0 if value not found
|
||||
setCurrentIndex(index >= 0 ? index : 0);
|
||||
}, [currentValue, options]);
|
||||
|
||||
// Guard against empty options array
|
||||
if (!options || options.length === 0) {
|
||||
return <Box />;
|
||||
}
|
||||
|
||||
// Left/right navigation is handled by parent component
|
||||
// This component is purely for display
|
||||
// onValueChange is kept for interface compatibility but not used internally
|
||||
|
||||
const currentOption = options[currentIndex] || options[0];
|
||||
const canScrollLeft = options.length > 1;
|
||||
const canScrollRight = options.length > 1;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" alignItems="center">
|
||||
<Text
|
||||
color={isActive && canScrollLeft ? Colors.AccentGreen : Colors.Gray}
|
||||
>
|
||||
{canScrollLeft ? '←' : ' '}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text
|
||||
color={isActive ? Colors.AccentGreen : Colors.Foreground}
|
||||
bold={isActive}
|
||||
>
|
||||
{currentOption.label}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text
|
||||
color={isActive && canScrollRight ? Colors.AccentGreen : Colors.Gray}
|
||||
>
|
||||
{canScrollRight ? '→' : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Export the interface for external use
|
||||
export type { EnumSelectorProps };
|
||||
@@ -7,7 +7,7 @@
|
||||
import React, { Fragment, useEffect, useId } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import stringWidth from 'string-width';
|
||||
import { Colors } from '../../colors.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { toCodePoints } from '../../utils/textUtils.js';
|
||||
import { useOverflowActions } from '../../contexts/OverflowContext.js';
|
||||
|
||||
@@ -186,14 +186,14 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
|
||||
return (
|
||||
<Box flexDirection="column" width={maxWidth} flexShrink={0}>
|
||||
{totalHiddenLines > 0 && overflowDirection === 'top' && (
|
||||
<Text color={Colors.Gray} wrap="truncate">
|
||||
<Text color={theme.text.secondary} wrap="truncate">
|
||||
... first {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '}
|
||||
hidden ...
|
||||
</Text>
|
||||
)}
|
||||
{visibleLines}
|
||||
{totalHiddenLines > 0 && overflowDirection === 'bottom' && (
|
||||
<Text color={Colors.Gray} wrap="truncate">
|
||||
<Text color={theme.text.secondary} wrap="truncate">
|
||||
... last {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '}
|
||||
hidden ...
|
||||
</Text>
|
||||
@@ -264,13 +264,12 @@ function visitBoxRow(element: React.ReactNode): Row {
|
||||
|
||||
if (enableDebugLog) {
|
||||
const boxProps = element.props as {
|
||||
children?: React.ReactNode | undefined;
|
||||
children?: React.ReactNode;
|
||||
readonly flexDirection?:
|
||||
| 'row'
|
||||
| 'column'
|
||||
| 'row-reverse'
|
||||
| 'column-reverse'
|
||||
| undefined;
|
||||
| 'column-reverse';
|
||||
};
|
||||
// Ensure the Box has no props other than the default ones and key.
|
||||
let maxExpectedProps = 4;
|
||||
|
||||
@@ -4,178 +4,195 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import type React from 'react';
|
||||
import {
|
||||
RadioButtonSelect,
|
||||
type RadioSelectItem,
|
||||
type RadioButtonSelectProps,
|
||||
} from './RadioButtonSelect.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
BaseSelectionList,
|
||||
type BaseSelectionListProps,
|
||||
type RenderItemContext,
|
||||
} from './BaseSelectionList.js';
|
||||
|
||||
const ITEMS: Array<RadioSelectItem<string>> = [
|
||||
{ label: 'Option 1', value: 'one' },
|
||||
{ label: 'Option 2', value: 'two' },
|
||||
{ label: 'Option 3', value: 'three', disabled: true },
|
||||
];
|
||||
vi.mock('./BaseSelectionList.js', () => ({
|
||||
BaseSelectionList: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
describe('<RadioButtonSelect />', () => {
|
||||
it('renders a list of items and matches snapshot', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<RadioButtonSelect items={ITEMS} onSelect={() => {}} isFocused={true} />,
|
||||
vi.mock('../../semantic-colors.js', () => ({
|
||||
theme: {
|
||||
text: { secondary: 'COLOR_SECONDARY' },
|
||||
},
|
||||
}));
|
||||
|
||||
const MockedBaseSelectionList = vi.mocked(
|
||||
BaseSelectionList,
|
||||
) as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
type RadioRenderItemFn = (
|
||||
item: RadioSelectItem<string>,
|
||||
context: RenderItemContext,
|
||||
) => React.JSX.Element;
|
||||
const extractRenderItem = (): RadioRenderItemFn => {
|
||||
const mockCalls = MockedBaseSelectionList.mock.calls;
|
||||
|
||||
if (mockCalls.length === 0) {
|
||||
throw new Error(
|
||||
'BaseSelectionList was not called. Ensure RadioButtonSelect is rendered before calling extractRenderItem.',
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
}
|
||||
|
||||
const props = mockCalls[0][0] as BaseSelectionListProps<
|
||||
string,
|
||||
RadioSelectItem<string>
|
||||
>;
|
||||
|
||||
if (typeof props.renderItem !== 'function') {
|
||||
throw new Error('renderItem prop was not found on BaseSelectionList call.');
|
||||
}
|
||||
|
||||
return props.renderItem as RadioRenderItemFn;
|
||||
};
|
||||
|
||||
describe('RadioButtonSelect', () => {
|
||||
const mockOnSelect = vi.fn();
|
||||
const mockOnHighlight = vi.fn();
|
||||
|
||||
const ITEMS: Array<RadioSelectItem<string>> = [
|
||||
{ label: 'Option 1', value: 'one', key: 'one' },
|
||||
{ label: 'Option 2', value: 'two', key: 'two' },
|
||||
{ label: 'Option 3', value: 'three', disabled: true, key: 'three' },
|
||||
];
|
||||
|
||||
const renderComponent = (
|
||||
props: Partial<RadioButtonSelectProps<string>> = {},
|
||||
) => {
|
||||
const defaultProps: RadioButtonSelectProps<string> = {
|
||||
items: ITEMS,
|
||||
onSelect: mockOnSelect,
|
||||
...props,
|
||||
};
|
||||
return renderWithProviders(<RadioButtonSelect {...defaultProps} />);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders with the second item selected and matches snapshot', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<RadioButtonSelect items={ITEMS} initialIndex={1} onSelect={() => {}} />,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
describe('Prop forwarding to BaseSelectionList', () => {
|
||||
it('should forward all props correctly when provided', () => {
|
||||
const props = {
|
||||
items: ITEMS,
|
||||
initialIndex: 1,
|
||||
onSelect: mockOnSelect,
|
||||
onHighlight: mockOnHighlight,
|
||||
isFocused: false,
|
||||
showScrollArrows: true,
|
||||
maxItemsToShow: 5,
|
||||
showNumbers: false,
|
||||
};
|
||||
|
||||
renderComponent(props);
|
||||
|
||||
expect(BaseSelectionList).toHaveBeenCalledTimes(1);
|
||||
expect(BaseSelectionList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
...props,
|
||||
renderItem: expect.any(Function),
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default props if not provided', () => {
|
||||
renderComponent({
|
||||
items: ITEMS,
|
||||
onSelect: mockOnSelect,
|
||||
});
|
||||
|
||||
expect(BaseSelectionList).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
initialIndex: 0,
|
||||
isFocused: true,
|
||||
showScrollArrows: false,
|
||||
maxItemsToShow: 10,
|
||||
showNumbers: true,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders with numbers hidden and matches snapshot', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<RadioButtonSelect
|
||||
items={ITEMS}
|
||||
onSelect={() => {}}
|
||||
showNumbers={false}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
describe('renderItem implementation', () => {
|
||||
let renderItem: RadioRenderItemFn;
|
||||
const mockContext: RenderItemContext = {
|
||||
isSelected: false,
|
||||
titleColor: 'MOCK_TITLE_COLOR',
|
||||
numberColor: 'MOCK_NUMBER_COLOR',
|
||||
};
|
||||
|
||||
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 } = renderWithProviders(
|
||||
<RadioButtonSelect
|
||||
items={manyItems}
|
||||
onSelect={() => {}}
|
||||
showScrollArrows={true}
|
||||
maxItemsToShow={5}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
beforeEach(() => {
|
||||
renderComponent();
|
||||
renderItem = extractRenderItem();
|
||||
});
|
||||
|
||||
it('renders with special theme display and matches snapshot', () => {
|
||||
const themeItems: Array<RadioSelectItem<string>> = [
|
||||
{
|
||||
it('should render the standard label display with correct color and truncation', () => {
|
||||
const item = ITEMS[0];
|
||||
|
||||
const result = renderItem(item, mockContext);
|
||||
|
||||
expect(result?.props?.color).toBe(mockContext.titleColor);
|
||||
expect(result?.props?.children).toBe('Option 1');
|
||||
expect(result?.props?.wrap).toBe('truncate');
|
||||
});
|
||||
|
||||
it('should render the special theme display when theme props are present', () => {
|
||||
const themeItem: 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 } = renderWithProviders(
|
||||
<RadioButtonSelect items={themeItems} onSelect={() => {}} />,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
key: 'a-light',
|
||||
};
|
||||
|
||||
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 } = renderWithProviders(
|
||||
<RadioButtonSelect items={manyItems} onSelect={() => {}} />,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
const result = renderItem(themeItem, mockContext);
|
||||
|
||||
it('renders nothing when no items are provided', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<RadioButtonSelect items={[]} onSelect={() => {}} isFocused={true} />,
|
||||
);
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
});
|
||||
expect(result?.props?.color).toBe(mockContext.titleColor);
|
||||
expect(result?.props?.wrap).toBe('truncate');
|
||||
|
||||
describe('keyboard navigation', () => {
|
||||
it('should call onSelect when "enter" is pressed', () => {
|
||||
const onSelect = vi.fn();
|
||||
const { stdin } = renderWithProviders(
|
||||
<RadioButtonSelect items={ITEMS} onSelect={onSelect} />,
|
||||
);
|
||||
const children = result?.props?.children;
|
||||
|
||||
stdin.write('\r');
|
||||
if (!Array.isArray(children) || children.length < 3) {
|
||||
throw new Error(
|
||||
'Expected children to be an array with at least 3 elements for theme display',
|
||||
);
|
||||
}
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('one');
|
||||
});
|
||||
expect(children[0]).toBe('Theme A');
|
||||
expect(children[1]).toBe(' ');
|
||||
|
||||
describe('when isFocused is false', () => {
|
||||
it('should not handle any keyboard input', () => {
|
||||
const onSelect = vi.fn();
|
||||
const { stdin } = renderWithProviders(
|
||||
<RadioButtonSelect
|
||||
items={ITEMS}
|
||||
onSelect={onSelect}
|
||||
isFocused={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
stdin.write('\u001B[A'); // Up arrow
|
||||
stdin.write('\r'); // Enter
|
||||
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{ description: 'when isFocused is true', isFocused: true },
|
||||
{ description: 'when isFocused is omitted', isFocused: undefined },
|
||||
])('$description', ({ isFocused }) => {
|
||||
it('should navigate down with arrow key and select with enter', async () => {
|
||||
const onSelect = vi.fn();
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<RadioButtonSelect
|
||||
items={ITEMS}
|
||||
onSelect={onSelect}
|
||||
isFocused={isFocused}
|
||||
/>,
|
||||
);
|
||||
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('● 2. Option 2');
|
||||
});
|
||||
|
||||
stdin.write('\r');
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('two');
|
||||
const nestedTextElement = children[2] as React.ReactElement<{
|
||||
color?: string;
|
||||
children?: React.ReactNode;
|
||||
}>;
|
||||
expect(nestedTextElement?.props?.color).toBe('COLOR_SECONDARY');
|
||||
expect(nestedTextElement?.props?.children).toBe('(Light)');
|
||||
});
|
||||
|
||||
it('should navigate up with arrow key and select with enter', async () => {
|
||||
const onSelect = vi.fn();
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<RadioButtonSelect
|
||||
items={ITEMS}
|
||||
onSelect={onSelect}
|
||||
initialIndex={1}
|
||||
isFocused={isFocused}
|
||||
/>,
|
||||
);
|
||||
it('should fall back to standard display if only one theme prop is present', () => {
|
||||
const partialThemeItem: RadioSelectItem<string> = {
|
||||
label: 'Incomplete Theme',
|
||||
value: 'incomplete',
|
||||
themeNameDisplay: 'Only Name',
|
||||
key: 'incomplete',
|
||||
};
|
||||
|
||||
stdin.write('\u001B[A'); // Up arrow
|
||||
const result = renderItem(partialThemeItem, mockContext);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('● 1. Option 1');
|
||||
});
|
||||
|
||||
stdin.write('\r');
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('one');
|
||||
expect(result?.props?.children).toBe('Incomplete Theme');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,19 +5,17 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { Colors } from '../../colors.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { BaseSelectionList } from './BaseSelectionList.js';
|
||||
import type { SelectionListItem } from '../../hooks/useSelectionList.js';
|
||||
|
||||
/**
|
||||
* Represents a single option for the RadioButtonSelect.
|
||||
* Requires a label for display and a value to be returned on selection.
|
||||
*/
|
||||
export interface RadioSelectItem<T> {
|
||||
export interface RadioSelectItem<T> extends SelectionListItem<T> {
|
||||
label: string;
|
||||
value: T;
|
||||
disabled?: boolean;
|
||||
themeNameDisplay?: string;
|
||||
themeTypeDisplay?: string;
|
||||
}
|
||||
@@ -61,178 +59,33 @@ export function RadioButtonSelect<T>({
|
||||
maxItemsToShow = 10,
|
||||
showNumbers = true,
|
||||
}: RadioButtonSelectProps<T>): React.JSX.Element {
|
||||
const [activeIndex, setActiveIndex] = useState(initialIndex);
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
const [numberInput, setNumberInput] = useState('');
|
||||
const numberInputTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
useEffect(() => {
|
||||
const newScrollOffset = Math.max(
|
||||
0,
|
||||
Math.min(activeIndex - maxItemsToShow + 1, items.length - maxItemsToShow),
|
||||
);
|
||||
if (activeIndex < scrollOffset) {
|
||||
setScrollOffset(activeIndex);
|
||||
} else if (activeIndex >= scrollOffset + maxItemsToShow) {
|
||||
setScrollOffset(newScrollOffset);
|
||||
}
|
||||
}, [activeIndex, items.length, scrollOffset, maxItemsToShow]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (numberInputTimer.current) {
|
||||
clearTimeout(numberInputTimer.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
const { sequence, name } = key;
|
||||
const isNumeric = showNumbers && /^[0-9]$/.test(sequence);
|
||||
|
||||
// Any key press that is not a digit should clear the number input buffer.
|
||||
if (!isNumeric && numberInputTimer.current) {
|
||||
clearTimeout(numberInputTimer.current);
|
||||
setNumberInput('');
|
||||
}
|
||||
|
||||
if (name === 'k' || name === 'up') {
|
||||
const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
|
||||
setActiveIndex(newIndex);
|
||||
onHighlight?.(items[newIndex]!.value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === 'j' || name === 'down') {
|
||||
const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
|
||||
setActiveIndex(newIndex);
|
||||
onHighlight?.(items[newIndex]!.value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === 'return') {
|
||||
onSelect(items[activeIndex]!.value);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle numeric input for selection.
|
||||
if (isNumeric) {
|
||||
if (numberInputTimer.current) {
|
||||
clearTimeout(numberInputTimer.current);
|
||||
}
|
||||
|
||||
const newNumberInput = numberInput + sequence;
|
||||
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) },
|
||||
);
|
||||
|
||||
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{showScrollArrows && (
|
||||
<Text color={scrollOffset > 0 ? Colors.Foreground : Colors.Gray}>
|
||||
▲
|
||||
</Text>
|
||||
)}
|
||||
{visibleItems.map((item, index) => {
|
||||
const itemIndex = scrollOffset + index;
|
||||
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;
|
||||
<BaseSelectionList<T, RadioSelectItem<T>>
|
||||
items={items}
|
||||
initialIndex={initialIndex}
|
||||
onSelect={onSelect}
|
||||
onHighlight={onHighlight}
|
||||
isFocused={isFocused}
|
||||
showNumbers={showNumbers}
|
||||
showScrollArrows={showScrollArrows}
|
||||
maxItemsToShow={maxItemsToShow}
|
||||
renderItem={(item, { titleColor }) => {
|
||||
// Handle special theme display case for ThemeDialog compatibility
|
||||
if (item.themeNameDisplay && item.themeTypeDisplay) {
|
||||
return (
|
||||
<Text color={titleColor} wrap="truncate" key={item.key}>
|
||||
{item.themeNameDisplay}{' '}
|
||||
<Text color={theme.text.secondary}>{item.themeTypeDisplay}</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (!showNumbers) {
|
||||
numberColor = Colors.Gray;
|
||||
}
|
||||
|
||||
const numberColumnWidth = String(items.length).length;
|
||||
const itemNumberText = `${String(itemIndex + 1).padStart(
|
||||
numberColumnWidth,
|
||||
)}.`;
|
||||
|
||||
// Regular label display
|
||||
return (
|
||||
<Box key={item.label} alignItems="center">
|
||||
<Box minWidth={2} flexShrink={0}>
|
||||
<Text
|
||||
color={isSelected ? Colors.AccentGreen : Colors.Foreground}
|
||||
aria-hidden
|
||||
>
|
||||
{isSelected ? '●' : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
marginRight={1}
|
||||
flexShrink={0}
|
||||
minWidth={itemNumberText.length}
|
||||
aria-state={{ checked: isSelected }}
|
||||
>
|
||||
<Text color={numberColor}>{itemNumberText}</Text>
|
||||
</Box>
|
||||
{item.themeNameDisplay && item.themeTypeDisplay ? (
|
||||
<Text color={textColor} wrap="truncate">
|
||||
{item.themeNameDisplay}{' '}
|
||||
<Text color={Colors.Gray}>{item.themeTypeDisplay}</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={textColor} wrap="truncate">
|
||||
{item.label}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Text color={titleColor} wrap="truncate">
|
||||
{item.label}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
{showScrollArrows && (
|
||||
<Text
|
||||
color={
|
||||
scrollOffset + maxItemsToShow < items.length
|
||||
? Colors.Foreground
|
||||
: Colors.Gray
|
||||
}
|
||||
>
|
||||
▼
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
55
packages/cli/src/ui/components/shared/ScopeSelector.tsx
Normal file
55
packages/cli/src/ui/components/shared/ScopeSelector.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { SettingScope } from '../../../config/settings.js';
|
||||
import { getScopeItems } from '../../../utils/dialogScopeUtils.js';
|
||||
import { RadioButtonSelect } from './RadioButtonSelect.js';
|
||||
|
||||
interface ScopeSelectorProps {
|
||||
/** Callback function when a scope is selected */
|
||||
onSelect: (scope: SettingScope) => void;
|
||||
/** Callback function when a scope is highlighted */
|
||||
onHighlight: (scope: SettingScope) => void;
|
||||
/** Whether the component is focused */
|
||||
isFocused: boolean;
|
||||
/** The initial scope to select */
|
||||
initialScope: SettingScope;
|
||||
}
|
||||
|
||||
export function ScopeSelector({
|
||||
onSelect,
|
||||
onHighlight,
|
||||
isFocused,
|
||||
initialScope,
|
||||
}: ScopeSelectorProps): React.JSX.Element {
|
||||
const scopeItems = getScopeItems().map((item) => ({
|
||||
...item,
|
||||
key: item.value,
|
||||
}));
|
||||
|
||||
const initialIndex = scopeItems.findIndex(
|
||||
(item) => item.value === initialScope,
|
||||
);
|
||||
const safeInitialIndex = initialIndex >= 0 ? initialIndex : 0;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold={isFocused} wrap="truncate">
|
||||
{isFocused ? '> ' : ' '}Apply To
|
||||
</Text>
|
||||
<RadioButtonSelect
|
||||
items={scopeItems}
|
||||
initialIndex={safeInitialIndex}
|
||||
onSelect={onSelect}
|
||||
onHighlight={onHighlight}
|
||||
isFocused={isFocused}
|
||||
showNumbers={isFocused}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`DescriptiveRadioButtonSelect > should render correctly with custom props 1`] = `
|
||||
"▲
|
||||
1. Foo Title
|
||||
This is Foo.
|
||||
● 2. Bar Title
|
||||
This is Bar.
|
||||
3. Baz Title
|
||||
This is Baz.
|
||||
▼"
|
||||
`;
|
||||
|
||||
exports[`DescriptiveRadioButtonSelect > should render correctly with default props 1`] = `
|
||||
"● Foo Title
|
||||
This is Foo.
|
||||
Bar Title
|
||||
This is Bar.
|
||||
Baz Title
|
||||
This is Baz."
|
||||
`;
|
||||
@@ -0,0 +1,9 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<EnumSelector /> > renders inactive state and matches snapshot 1`] = `"← 中文 (简体) →"`;
|
||||
|
||||
exports[`<EnumSelector /> > renders with numeric options and matches snapshot 1`] = `"← Medium →"`;
|
||||
|
||||
exports[`<EnumSelector /> > renders with single option and matches snapshot 1`] = `" Only Option"`;
|
||||
|
||||
exports[`<EnumSelector /> > renders with string options and matches snapshot 1`] = `"← English →"`;
|
||||
@@ -1,47 +0,0 @@
|
||||
// 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"
|
||||
`;
|
||||
@@ -172,6 +172,117 @@ describe('textBufferReducer', () => {
|
||||
expect(state.undoStack[0].cursorCol).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete_word_left action', () => {
|
||||
it('should delete a simple word', () => {
|
||||
const stateWithText: TextBufferState = {
|
||||
...initialState,
|
||||
lines: ['hello world'],
|
||||
cursorRow: 0,
|
||||
cursorCol: 11,
|
||||
};
|
||||
const action: TextBufferAction = { type: 'delete_word_left' };
|
||||
const state = textBufferReducer(stateWithText, action);
|
||||
expect(state.lines).toEqual(['hello ']);
|
||||
expect(state.cursorCol).toBe(6);
|
||||
});
|
||||
|
||||
it('should delete a path segment', () => {
|
||||
const stateWithText: TextBufferState = {
|
||||
...initialState,
|
||||
lines: ['path/to/file'],
|
||||
cursorRow: 0,
|
||||
cursorCol: 12,
|
||||
};
|
||||
const action: TextBufferAction = { type: 'delete_word_left' };
|
||||
const state = textBufferReducer(stateWithText, action);
|
||||
expect(state.lines).toEqual(['path/to/']);
|
||||
expect(state.cursorCol).toBe(8);
|
||||
});
|
||||
|
||||
it('should delete variable_name parts', () => {
|
||||
const stateWithText: TextBufferState = {
|
||||
...initialState,
|
||||
lines: ['variable_name'],
|
||||
cursorRow: 0,
|
||||
cursorCol: 13,
|
||||
};
|
||||
const action: TextBufferAction = { type: 'delete_word_left' };
|
||||
const state = textBufferReducer(stateWithText, action);
|
||||
expect(state.lines).toEqual(['variable_']);
|
||||
expect(state.cursorCol).toBe(9);
|
||||
});
|
||||
|
||||
it('should act like backspace at the beginning of a line', () => {
|
||||
const stateWithText: TextBufferState = {
|
||||
...initialState,
|
||||
lines: ['hello', 'world'],
|
||||
cursorRow: 1,
|
||||
cursorCol: 0,
|
||||
};
|
||||
const action: TextBufferAction = { type: 'delete_word_left' };
|
||||
const state = textBufferReducer(stateWithText, action);
|
||||
expect(state.lines).toEqual(['helloworld']);
|
||||
expect(state.cursorRow).toBe(0);
|
||||
expect(state.cursorCol).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete_word_right action', () => {
|
||||
it('should delete a simple word', () => {
|
||||
const stateWithText: TextBufferState = {
|
||||
...initialState,
|
||||
lines: ['hello world'],
|
||||
cursorRow: 0,
|
||||
cursorCol: 0,
|
||||
};
|
||||
const action: TextBufferAction = { type: 'delete_word_right' };
|
||||
const state = textBufferReducer(stateWithText, action);
|
||||
expect(state.lines).toEqual(['world']);
|
||||
expect(state.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should delete a path segment', () => {
|
||||
const stateWithText: TextBufferState = {
|
||||
...initialState,
|
||||
lines: ['path/to/file'],
|
||||
cursorRow: 0,
|
||||
cursorCol: 0,
|
||||
};
|
||||
const action: TextBufferAction = { type: 'delete_word_right' };
|
||||
let state = textBufferReducer(stateWithText, action);
|
||||
expect(state.lines).toEqual(['/to/file']);
|
||||
state = textBufferReducer(state, action);
|
||||
expect(state.lines).toEqual(['to/file']);
|
||||
});
|
||||
|
||||
it('should delete variable_name parts', () => {
|
||||
const stateWithText: TextBufferState = {
|
||||
...initialState,
|
||||
lines: ['variable_name'],
|
||||
cursorRow: 0,
|
||||
cursorCol: 0,
|
||||
};
|
||||
const action: TextBufferAction = { type: 'delete_word_right' };
|
||||
const state = textBufferReducer(stateWithText, action);
|
||||
expect(state.lines).toEqual(['_name']);
|
||||
expect(state.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should act like delete at the end of a line', () => {
|
||||
const stateWithText: TextBufferState = {
|
||||
...initialState,
|
||||
lines: ['hello', 'world'],
|
||||
cursorRow: 0,
|
||||
cursorCol: 5,
|
||||
};
|
||||
const action: TextBufferAction = { type: 'delete_word_right' };
|
||||
const state = textBufferReducer(stateWithText, action);
|
||||
expect(state.lines).toEqual(['helloworld']);
|
||||
expect(state.cursorRow).toBe(0);
|
||||
expect(state.cursorCol).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Helper to get the state from the hook
|
||||
@@ -1282,6 +1393,69 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||
expect(getBufferState(result).text).toBe('Pasted Text');
|
||||
});
|
||||
|
||||
it('should sanitize large text (>5000 chars) and strip unsafe characters', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||
);
|
||||
const unsafeChars = '\x07\x08\x0B\x0C';
|
||||
const largeTextWithUnsafe =
|
||||
'safe text'.repeat(600) + unsafeChars + 'more safe text';
|
||||
|
||||
expect(largeTextWithUnsafe.length).toBeGreaterThan(5000);
|
||||
|
||||
act(() =>
|
||||
result.current.handleInput({
|
||||
name: '',
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: largeTextWithUnsafe,
|
||||
}),
|
||||
);
|
||||
|
||||
const resultText = getBufferState(result).text;
|
||||
expect(resultText).not.toContain('\x07');
|
||||
expect(resultText).not.toContain('\x08');
|
||||
expect(resultText).not.toContain('\x0B');
|
||||
expect(resultText).not.toContain('\x0C');
|
||||
expect(resultText).toContain('safe text');
|
||||
expect(resultText).toContain('more safe text');
|
||||
});
|
||||
|
||||
it('should sanitize large ANSI text (>5000 chars) and strip escape codes', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||
);
|
||||
const largeTextWithAnsi =
|
||||
'\x1B[31m' +
|
||||
'red text'.repeat(800) +
|
||||
'\x1B[0m' +
|
||||
'\x1B[32m' +
|
||||
'green text'.repeat(200) +
|
||||
'\x1B[0m';
|
||||
|
||||
expect(largeTextWithAnsi.length).toBeGreaterThan(5000);
|
||||
|
||||
act(() =>
|
||||
result.current.handleInput({
|
||||
name: '',
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
paste: false,
|
||||
sequence: largeTextWithAnsi,
|
||||
}),
|
||||
);
|
||||
|
||||
const resultText = getBufferState(result).text;
|
||||
expect(resultText).not.toContain('\x1B[31m');
|
||||
expect(resultText).not.toContain('\x1B[32m');
|
||||
expect(resultText).not.toContain('\x1B[0m');
|
||||
expect(resultText).toContain('red text');
|
||||
expect(resultText).toContain('green text');
|
||||
});
|
||||
|
||||
it('should not strip popular emojis', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||
@@ -1321,6 +1495,53 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||
expect(stripAnsi('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Memoization', () => {
|
||||
it('should keep action references stable across re-renders', () => {
|
||||
// We pass a stable `isValidPath` so that callbacks that depend on it
|
||||
// are not recreated on every render.
|
||||
const isValidPath = () => false;
|
||||
const { result, rerender } = renderHook(() =>
|
||||
useTextBuffer({ viewport, isValidPath }),
|
||||
);
|
||||
|
||||
const initialInsert = result.current.insert;
|
||||
const initialBackspace = result.current.backspace;
|
||||
const initialMove = result.current.move;
|
||||
const initialHandleInput = result.current.handleInput;
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.insert).toBe(initialInsert);
|
||||
expect(result.current.backspace).toBe(initialBackspace);
|
||||
expect(result.current.move).toBe(initialMove);
|
||||
expect(result.current.handleInput).toBe(initialHandleInput);
|
||||
});
|
||||
|
||||
it('should have memoized actions that operate on the latest state', () => {
|
||||
const isValidPath = () => false;
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({ viewport, isValidPath }),
|
||||
);
|
||||
|
||||
// Store a reference to the memoized insert function.
|
||||
const memoizedInsert = result.current.insert;
|
||||
|
||||
// Update the buffer state.
|
||||
act(() => {
|
||||
result.current.insert('hello');
|
||||
});
|
||||
expect(getBufferState(result).text).toBe('hello');
|
||||
|
||||
// Now, call the original memoized function reference.
|
||||
act(() => {
|
||||
memoizedInsert(' world');
|
||||
});
|
||||
|
||||
// It should have operated on the updated state.
|
||||
expect(getBufferState(result).text).toBe('hello world');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('offsetToLogicalPos', () => {
|
||||
|
||||
@@ -9,13 +9,13 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import pathMod from 'node:path';
|
||||
import { useState, useCallback, useEffect, useMemo, useReducer } from 'react';
|
||||
import stringWidth from 'string-width';
|
||||
import { unescapePath } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
toCodePoints,
|
||||
cpLen,
|
||||
cpSlice,
|
||||
stripUnsafeCharacters,
|
||||
getCachedStringWidth,
|
||||
} from '../../utils/textUtils.js';
|
||||
import type { VimAction } from './vim-buffer-actions.js';
|
||||
import { handleVimAction } from './vim-buffer-actions.js';
|
||||
@@ -629,21 +629,23 @@ export function logicalPosToOffset(
|
||||
return offset;
|
||||
}
|
||||
|
||||
// Helper to calculate visual lines and map cursor positions
|
||||
function calculateVisualLayout(
|
||||
logicalLines: string[],
|
||||
logicalCursor: [number, number],
|
||||
viewportWidth: number,
|
||||
): {
|
||||
export interface VisualLayout {
|
||||
visualLines: string[];
|
||||
visualCursor: [number, number];
|
||||
logicalToVisualMap: Array<Array<[number, number]>>; // For each logical line, an array of [visualLineIndex, startColInLogical]
|
||||
visualToLogicalMap: Array<[number, number]>; // For each visual line, its [logicalLineIndex, startColInLogical]
|
||||
} {
|
||||
// For each logical line, an array of [visualLineIndex, startColInLogical]
|
||||
logicalToVisualMap: Array<Array<[number, number]>>;
|
||||
// For each visual line, its [logicalLineIndex, startColInLogical]
|
||||
visualToLogicalMap: Array<[number, number]>;
|
||||
}
|
||||
|
||||
// Calculates the visual wrapping of lines and the mapping between logical and visual coordinates.
|
||||
// This is an expensive operation and should be memoized.
|
||||
function calculateLayout(
|
||||
logicalLines: string[],
|
||||
viewportWidth: number,
|
||||
): VisualLayout {
|
||||
const visualLines: string[] = [];
|
||||
const logicalToVisualMap: Array<Array<[number, number]>> = [];
|
||||
const visualToLogicalMap: Array<[number, number]> = [];
|
||||
let currentVisualCursor: [number, number] = [0, 0];
|
||||
|
||||
logicalLines.forEach((logLine, logIndex) => {
|
||||
logicalToVisualMap[logIndex] = [];
|
||||
@@ -652,9 +654,6 @@ function calculateVisualLayout(
|
||||
logicalToVisualMap[logIndex].push([visualLines.length, 0]);
|
||||
visualToLogicalMap.push([logIndex, 0]);
|
||||
visualLines.push('');
|
||||
if (logIndex === logicalCursor[0] && logicalCursor[1] === 0) {
|
||||
currentVisualCursor = [visualLines.length - 1, 0];
|
||||
}
|
||||
} else {
|
||||
// Non-empty logical line
|
||||
let currentPosInLogLine = 0; // Tracks position within the current logical line (code point index)
|
||||
@@ -670,7 +669,7 @@ function calculateVisualLayout(
|
||||
// Iterate through code points to build the current visual line (chunk)
|
||||
for (let i = currentPosInLogLine; i < codePointsInLogLine.length; i++) {
|
||||
const char = codePointsInLogLine[i];
|
||||
const charVisualWidth = stringWidth(char);
|
||||
const charVisualWidth = getCachedStringWidth(char);
|
||||
|
||||
if (currentChunkVisualWidth + charVisualWidth > viewportWidth) {
|
||||
// Character would exceed viewport width
|
||||
@@ -754,30 +753,6 @@ function calculateVisualLayout(
|
||||
visualToLogicalMap.push([logIndex, currentPosInLogLine]);
|
||||
visualLines.push(currentChunk);
|
||||
|
||||
// Cursor mapping logic
|
||||
// Note: currentPosInLogLine here is the start of the currentChunk within the logical line.
|
||||
if (logIndex === logicalCursor[0]) {
|
||||
const cursorLogCol = logicalCursor[1]; // This is a code point index
|
||||
if (
|
||||
cursorLogCol >= currentPosInLogLine &&
|
||||
cursorLogCol < currentPosInLogLine + numCodePointsInChunk // Cursor is within this chunk
|
||||
) {
|
||||
currentVisualCursor = [
|
||||
visualLines.length - 1,
|
||||
cursorLogCol - currentPosInLogLine, // Visual col is also code point index within visual line
|
||||
];
|
||||
} else if (
|
||||
cursorLogCol === currentPosInLogLine + numCodePointsInChunk &&
|
||||
numCodePointsInChunk > 0
|
||||
) {
|
||||
// Cursor is exactly at the end of this non-empty chunk
|
||||
currentVisualCursor = [
|
||||
visualLines.length - 1,
|
||||
numCodePointsInChunk,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
const logicalStartOfThisChunk = currentPosInLogLine;
|
||||
currentPosInLogLine += numCodePointsInChunk;
|
||||
|
||||
@@ -793,23 +768,6 @@ function calculateVisualLayout(
|
||||
currentPosInLogLine++;
|
||||
}
|
||||
}
|
||||
// After all chunks of a non-empty logical line are processed,
|
||||
// if the cursor is at the very end of this logical line, update visual cursor.
|
||||
if (
|
||||
logIndex === logicalCursor[0] &&
|
||||
logicalCursor[1] === codePointsInLogLine.length // Cursor at end of logical line
|
||||
) {
|
||||
const lastVisualLineIdx = visualLines.length - 1;
|
||||
if (
|
||||
lastVisualLineIdx >= 0 &&
|
||||
visualLines[lastVisualLineIdx] !== undefined
|
||||
) {
|
||||
currentVisualCursor = [
|
||||
lastVisualLineIdx,
|
||||
cpLen(visualLines[lastVisualLineIdx]), // Cursor at end of last visual line for this logical line
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -824,27 +782,67 @@ function calculateVisualLayout(
|
||||
logicalToVisualMap[0].push([0, 0]);
|
||||
visualToLogicalMap.push([0, 0]);
|
||||
}
|
||||
currentVisualCursor = [0, 0];
|
||||
}
|
||||
// Handle cursor at the very end of the text (after all processing)
|
||||
// This case might be covered by the loop end condition now, but kept for safety.
|
||||
else if (
|
||||
logicalCursor[0] === logicalLines.length - 1 &&
|
||||
logicalCursor[1] === cpLen(logicalLines[logicalLines.length - 1]) &&
|
||||
visualLines.length > 0
|
||||
) {
|
||||
const lastVisLineIdx = visualLines.length - 1;
|
||||
currentVisualCursor = [lastVisLineIdx, cpLen(visualLines[lastVisLineIdx])];
|
||||
}
|
||||
|
||||
return {
|
||||
visualLines,
|
||||
visualCursor: currentVisualCursor,
|
||||
logicalToVisualMap,
|
||||
visualToLogicalMap,
|
||||
};
|
||||
}
|
||||
|
||||
// Calculates the visual cursor position based on a pre-calculated layout.
|
||||
// This is a lightweight operation.
|
||||
function calculateVisualCursorFromLayout(
|
||||
layout: VisualLayout,
|
||||
logicalCursor: [number, number],
|
||||
): [number, number] {
|
||||
const { logicalToVisualMap, visualLines } = layout;
|
||||
const [logicalRow, logicalCol] = logicalCursor;
|
||||
|
||||
const segmentsForLogicalLine = logicalToVisualMap[logicalRow];
|
||||
|
||||
if (!segmentsForLogicalLine || segmentsForLogicalLine.length === 0) {
|
||||
// This can happen for an empty document.
|
||||
return [0, 0];
|
||||
}
|
||||
|
||||
// Find the segment where the logical column fits.
|
||||
// The segments are sorted by startColInLogical.
|
||||
let targetSegmentIndex = segmentsForLogicalLine.findIndex(
|
||||
([, startColInLogical], index) => {
|
||||
const nextStartColInLogical =
|
||||
index + 1 < segmentsForLogicalLine.length
|
||||
? segmentsForLogicalLine[index + 1][1]
|
||||
: Infinity;
|
||||
return (
|
||||
logicalCol >= startColInLogical && logicalCol < nextStartColInLogical
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// If not found, it means the cursor is at the end of the logical line.
|
||||
if (targetSegmentIndex === -1) {
|
||||
if (logicalCol === 0) {
|
||||
targetSegmentIndex = 0;
|
||||
} else {
|
||||
targetSegmentIndex = segmentsForLogicalLine.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
const [visualRow, startColInLogical] =
|
||||
segmentsForLogicalLine[targetSegmentIndex];
|
||||
const visualCol = logicalCol - startColInLogical;
|
||||
|
||||
// The visual column should not exceed the length of the visual line.
|
||||
const clampedVisualCol = Math.min(
|
||||
visualCol,
|
||||
cpLen(visualLines[visualRow] ?? ''),
|
||||
);
|
||||
|
||||
return [visualRow, clampedVisualCol];
|
||||
}
|
||||
|
||||
// --- Start of reducer logic ---
|
||||
|
||||
export interface TextBufferState {
|
||||
@@ -857,6 +855,8 @@ export interface TextBufferState {
|
||||
clipboard: string | null;
|
||||
selectionAnchor: [number, number] | null;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
visualLayout: VisualLayout;
|
||||
}
|
||||
|
||||
const historyLimit = 100;
|
||||
@@ -884,6 +884,14 @@ export type TextBufferAction =
|
||||
dir: Direction;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'set_cursor';
|
||||
payload: {
|
||||
cursorRow: number;
|
||||
cursorCol: number;
|
||||
preferredCol: number | null;
|
||||
};
|
||||
}
|
||||
| { type: 'delete' }
|
||||
| { type: 'delete_word_left' }
|
||||
| { type: 'delete_word_right' }
|
||||
@@ -903,7 +911,7 @@ export type TextBufferAction =
|
||||
}
|
||||
| { type: 'move_to_offset'; payload: { offset: number } }
|
||||
| { type: 'create_undo_snapshot' }
|
||||
| { type: 'set_viewport_width'; payload: number }
|
||||
| { type: 'set_viewport'; payload: { width: number; height: number } }
|
||||
| { type: 'vim_delete_word_forward'; payload: { count: number } }
|
||||
| { type: 'vim_delete_word_backward'; payload: { count: number } }
|
||||
| { type: 'vim_delete_word_end'; payload: { count: number } }
|
||||
@@ -941,7 +949,7 @@ export type TextBufferAction =
|
||||
| { type: 'vim_move_to_line'; payload: { lineNumber: number } }
|
||||
| { type: 'vim_escape_insert_mode' };
|
||||
|
||||
export function textBufferReducer(
|
||||
function textBufferReducerLogic(
|
||||
state: TextBufferState,
|
||||
action: TextBufferAction,
|
||||
): TextBufferState {
|
||||
@@ -1047,80 +1055,120 @@ export function textBufferReducer(
|
||||
};
|
||||
}
|
||||
|
||||
case 'set_viewport_width': {
|
||||
if (action.payload === state.viewportWidth) {
|
||||
case 'set_viewport': {
|
||||
const { width, height } = action.payload;
|
||||
if (width === state.viewportWidth && height === state.viewportHeight) {
|
||||
return state;
|
||||
}
|
||||
return { ...state, viewportWidth: action.payload };
|
||||
return {
|
||||
...state,
|
||||
viewportWidth: width,
|
||||
viewportHeight: height,
|
||||
};
|
||||
}
|
||||
|
||||
case 'move': {
|
||||
const { dir } = action.payload;
|
||||
const { lines, cursorRow, cursorCol, viewportWidth } = state;
|
||||
const visualLayout = calculateVisualLayout(
|
||||
lines,
|
||||
[cursorRow, cursorCol],
|
||||
viewportWidth,
|
||||
);
|
||||
const { visualLines, visualCursor, visualToLogicalMap } = visualLayout;
|
||||
const { cursorRow, cursorCol, lines, visualLayout, preferredCol } = state;
|
||||
|
||||
let newVisualRow = visualCursor[0];
|
||||
let newVisualCol = visualCursor[1];
|
||||
let newPreferredCol = state.preferredCol;
|
||||
// Visual movements
|
||||
if (
|
||||
dir === 'left' ||
|
||||
dir === 'right' ||
|
||||
dir === 'up' ||
|
||||
dir === 'down' ||
|
||||
dir === 'home' ||
|
||||
dir === 'end'
|
||||
) {
|
||||
const visualCursor = calculateVisualCursorFromLayout(visualLayout, [
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
]);
|
||||
const { visualLines, visualToLogicalMap } = visualLayout;
|
||||
|
||||
const currentVisLineLen = cpLen(visualLines[newVisualRow] ?? '');
|
||||
let newVisualRow = visualCursor[0];
|
||||
let newVisualCol = visualCursor[1];
|
||||
let newPreferredCol = preferredCol;
|
||||
|
||||
switch (dir) {
|
||||
case 'left':
|
||||
newPreferredCol = null;
|
||||
if (newVisualCol > 0) {
|
||||
newVisualCol--;
|
||||
} else if (newVisualRow > 0) {
|
||||
newVisualRow--;
|
||||
newVisualCol = cpLen(visualLines[newVisualRow] ?? '');
|
||||
}
|
||||
break;
|
||||
case 'right':
|
||||
newPreferredCol = null;
|
||||
if (newVisualCol < currentVisLineLen) {
|
||||
newVisualCol++;
|
||||
} else if (newVisualRow < visualLines.length - 1) {
|
||||
newVisualRow++;
|
||||
const currentVisLineLen = cpLen(visualLines[newVisualRow] ?? '');
|
||||
|
||||
switch (dir) {
|
||||
case 'left':
|
||||
newPreferredCol = null;
|
||||
if (newVisualCol > 0) {
|
||||
newVisualCol--;
|
||||
} else if (newVisualRow > 0) {
|
||||
newVisualRow--;
|
||||
newVisualCol = cpLen(visualLines[newVisualRow] ?? '');
|
||||
}
|
||||
break;
|
||||
case 'right':
|
||||
newPreferredCol = null;
|
||||
if (newVisualCol < currentVisLineLen) {
|
||||
newVisualCol++;
|
||||
} else if (newVisualRow < visualLines.length - 1) {
|
||||
newVisualRow++;
|
||||
newVisualCol = 0;
|
||||
}
|
||||
break;
|
||||
case 'up':
|
||||
if (newVisualRow > 0) {
|
||||
if (newPreferredCol === null) newPreferredCol = newVisualCol;
|
||||
newVisualRow--;
|
||||
newVisualCol = clamp(
|
||||
newPreferredCol,
|
||||
0,
|
||||
cpLen(visualLines[newVisualRow] ?? ''),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'down':
|
||||
if (newVisualRow < visualLines.length - 1) {
|
||||
if (newPreferredCol === null) newPreferredCol = newVisualCol;
|
||||
newVisualRow++;
|
||||
newVisualCol = clamp(
|
||||
newPreferredCol,
|
||||
0,
|
||||
cpLen(visualLines[newVisualRow] ?? ''),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'home':
|
||||
newPreferredCol = null;
|
||||
newVisualCol = 0;
|
||||
}
|
||||
break;
|
||||
case 'up':
|
||||
if (newVisualRow > 0) {
|
||||
if (newPreferredCol === null) newPreferredCol = newVisualCol;
|
||||
newVisualRow--;
|
||||
newVisualCol = clamp(
|
||||
newPreferredCol,
|
||||
0,
|
||||
cpLen(visualLines[newVisualRow] ?? ''),
|
||||
break;
|
||||
case 'end':
|
||||
newPreferredCol = null;
|
||||
newVisualCol = currentVisLineLen;
|
||||
break;
|
||||
default: {
|
||||
const exhaustiveCheck: never = dir;
|
||||
console.error(
|
||||
`Unknown visual movement direction: ${exhaustiveCheck}`,
|
||||
);
|
||||
return state;
|
||||
}
|
||||
break;
|
||||
case 'down':
|
||||
if (newVisualRow < visualLines.length - 1) {
|
||||
if (newPreferredCol === null) newPreferredCol = newVisualCol;
|
||||
newVisualRow++;
|
||||
newVisualCol = clamp(
|
||||
newPreferredCol,
|
||||
}
|
||||
|
||||
if (visualToLogicalMap[newVisualRow]) {
|
||||
const [logRow, logStartCol] = visualToLogicalMap[newVisualRow];
|
||||
return {
|
||||
...state,
|
||||
cursorRow: logRow,
|
||||
cursorCol: clamp(
|
||||
logStartCol + newVisualCol,
|
||||
0,
|
||||
cpLen(visualLines[newVisualRow] ?? ''),
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'home':
|
||||
newPreferredCol = null;
|
||||
newVisualCol = 0;
|
||||
break;
|
||||
case 'end':
|
||||
newPreferredCol = null;
|
||||
newVisualCol = currentVisLineLen;
|
||||
break;
|
||||
cpLen(lines[logRow] ?? ''),
|
||||
),
|
||||
preferredCol: newPreferredCol,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
// Logical movements
|
||||
switch (dir) {
|
||||
case 'wordLeft': {
|
||||
const { cursorRow, cursorCol, lines } = state;
|
||||
if (cursorCol === 0 && cursorRow === 0) return state;
|
||||
|
||||
let newCursorRow = cursorRow;
|
||||
@@ -1156,7 +1204,6 @@ export function textBufferReducer(
|
||||
};
|
||||
}
|
||||
case 'wordRight': {
|
||||
const { cursorRow, cursorCol, lines } = state;
|
||||
if (
|
||||
cursorRow === lines.length - 1 &&
|
||||
cursorCol === cpLen(lines[cursorRow] ?? '')
|
||||
@@ -1186,23 +1233,15 @@ export function textBufferReducer(
|
||||
};
|
||||
}
|
||||
default:
|
||||
break;
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
if (visualToLogicalMap[newVisualRow]) {
|
||||
const [logRow, logStartCol] = visualToLogicalMap[newVisualRow];
|
||||
return {
|
||||
...state,
|
||||
cursorRow: logRow,
|
||||
cursorCol: clamp(
|
||||
logStartCol + newVisualCol,
|
||||
0,
|
||||
cpLen(state.lines[logRow] ?? ''),
|
||||
),
|
||||
preferredCol: newPreferredCol,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
case 'set_cursor': {
|
||||
return {
|
||||
...state,
|
||||
...action.payload,
|
||||
};
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
@@ -1214,14 +1253,22 @@ export function textBufferReducer(
|
||||
newLines[cursorRow] =
|
||||
cpSlice(lineContent, 0, cursorCol) +
|
||||
cpSlice(lineContent, cursorCol + 1);
|
||||
return { ...nextState, lines: newLines, preferredCol: null };
|
||||
return {
|
||||
...nextState,
|
||||
lines: newLines,
|
||||
preferredCol: null,
|
||||
};
|
||||
} else if (cursorRow < lines.length - 1) {
|
||||
const nextState = pushUndoLocal(state);
|
||||
const nextLineContent = currentLine(cursorRow + 1);
|
||||
const newLines = [...nextState.lines];
|
||||
newLines[cursorRow] = lineContent + nextLineContent;
|
||||
newLines.splice(cursorRow + 1, 1);
|
||||
return { ...nextState, lines: newLines, preferredCol: null };
|
||||
return {
|
||||
...nextState,
|
||||
lines: newLines,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
@@ -1229,47 +1276,38 @@ export function textBufferReducer(
|
||||
case 'delete_word_left': {
|
||||
const { cursorRow, cursorCol } = state;
|
||||
if (cursorCol === 0 && cursorRow === 0) return state;
|
||||
if (cursorCol === 0) {
|
||||
|
||||
const nextState = pushUndoLocal(state);
|
||||
const newLines = [...nextState.lines];
|
||||
let newCursorRow = cursorRow;
|
||||
let newCursorCol = cursorCol;
|
||||
|
||||
if (newCursorCol > 0) {
|
||||
const lineContent = currentLine(newCursorRow);
|
||||
const prevWordStart = findPrevWordStartInLine(
|
||||
lineContent,
|
||||
newCursorCol,
|
||||
);
|
||||
const start = prevWordStart === null ? 0 : prevWordStart;
|
||||
newLines[newCursorRow] =
|
||||
cpSlice(lineContent, 0, start) + cpSlice(lineContent, newCursorCol);
|
||||
newCursorCol = start;
|
||||
} else {
|
||||
// Act as a backspace
|
||||
const nextState = pushUndoLocal(state);
|
||||
const prevLineContent = currentLine(cursorRow - 1);
|
||||
const currentLineContentVal = currentLine(cursorRow);
|
||||
const newCol = cpLen(prevLineContent);
|
||||
const newLines = [...nextState.lines];
|
||||
newLines[cursorRow - 1] = prevLineContent + currentLineContentVal;
|
||||
newLines.splice(cursorRow, 1);
|
||||
return {
|
||||
...nextState,
|
||||
lines: newLines,
|
||||
cursorRow: cursorRow - 1,
|
||||
cursorCol: newCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
newCursorRow--;
|
||||
newCursorCol = newCol;
|
||||
}
|
||||
const nextState = pushUndoLocal(state);
|
||||
const lineContent = currentLine(cursorRow);
|
||||
const arr = toCodePoints(lineContent);
|
||||
let start = cursorCol;
|
||||
let onlySpaces = true;
|
||||
for (let i = 0; i < start; i++) {
|
||||
if (isWordChar(arr[i])) {
|
||||
onlySpaces = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (onlySpaces && start > 0) {
|
||||
start--;
|
||||
} else {
|
||||
while (start > 0 && !isWordChar(arr[start - 1])) start--;
|
||||
while (start > 0 && isWordChar(arr[start - 1])) start--;
|
||||
}
|
||||
const newLines = [...nextState.lines];
|
||||
newLines[cursorRow] =
|
||||
cpSlice(lineContent, 0, start) + cpSlice(lineContent, cursorCol);
|
||||
|
||||
return {
|
||||
...nextState,
|
||||
lines: newLines,
|
||||
cursorCol: start,
|
||||
cursorRow: newCursorRow,
|
||||
cursorCol: newCursorCol,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
@@ -1277,26 +1315,32 @@ export function textBufferReducer(
|
||||
case 'delete_word_right': {
|
||||
const { cursorRow, cursorCol, lines } = state;
|
||||
const lineContent = currentLine(cursorRow);
|
||||
const arr = toCodePoints(lineContent);
|
||||
if (cursorCol >= arr.length && cursorRow === lines.length - 1)
|
||||
const lineLen = cpLen(lineContent);
|
||||
|
||||
if (cursorCol >= lineLen && cursorRow === lines.length - 1) {
|
||||
return state;
|
||||
if (cursorCol >= arr.length) {
|
||||
// Act as a delete
|
||||
const nextState = pushUndoLocal(state);
|
||||
}
|
||||
|
||||
const nextState = pushUndoLocal(state);
|
||||
const newLines = [...nextState.lines];
|
||||
|
||||
if (cursorCol >= lineLen) {
|
||||
// Act as a delete, joining with the next line
|
||||
const nextLineContent = currentLine(cursorRow + 1);
|
||||
const newLines = [...nextState.lines];
|
||||
newLines[cursorRow] = lineContent + nextLineContent;
|
||||
newLines.splice(cursorRow + 1, 1);
|
||||
return { ...nextState, lines: newLines, preferredCol: null };
|
||||
} else {
|
||||
const nextWordStart = findNextWordStartInLine(lineContent, cursorCol);
|
||||
const end = nextWordStart === null ? lineLen : nextWordStart;
|
||||
newLines[cursorRow] =
|
||||
cpSlice(lineContent, 0, cursorCol) + cpSlice(lineContent, end);
|
||||
}
|
||||
const nextState = pushUndoLocal(state);
|
||||
let end = cursorCol;
|
||||
while (end < arr.length && !isWordChar(arr[end])) end++;
|
||||
while (end < arr.length && isWordChar(arr[end])) end++;
|
||||
const newLines = [...nextState.lines];
|
||||
newLines[cursorRow] =
|
||||
cpSlice(lineContent, 0, cursorCol) + cpSlice(lineContent, end);
|
||||
return { ...nextState, lines: newLines, preferredCol: null };
|
||||
|
||||
return {
|
||||
...nextState,
|
||||
lines: newLines,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'kill_line_right': {
|
||||
@@ -1306,7 +1350,10 @@ export function textBufferReducer(
|
||||
const nextState = pushUndoLocal(state);
|
||||
const newLines = [...nextState.lines];
|
||||
newLines[cursorRow] = cpSlice(lineContent, 0, cursorCol);
|
||||
return { ...nextState, lines: newLines };
|
||||
return {
|
||||
...nextState,
|
||||
lines: newLines,
|
||||
};
|
||||
} else if (cursorRow < lines.length - 1) {
|
||||
// Act as a delete
|
||||
const nextState = pushUndoLocal(state);
|
||||
@@ -1314,7 +1361,11 @@ export function textBufferReducer(
|
||||
const newLines = [...nextState.lines];
|
||||
newLines[cursorRow] = lineContent + nextLineContent;
|
||||
newLines.splice(cursorRow + 1, 1);
|
||||
return { ...nextState, lines: newLines, preferredCol: null };
|
||||
return {
|
||||
...nextState,
|
||||
lines: newLines,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
@@ -1444,6 +1495,25 @@ export function textBufferReducer(
|
||||
}
|
||||
}
|
||||
|
||||
export function textBufferReducer(
|
||||
state: TextBufferState,
|
||||
action: TextBufferAction,
|
||||
): TextBufferState {
|
||||
const newState = textBufferReducerLogic(state, action);
|
||||
|
||||
if (
|
||||
newState.lines !== state.lines ||
|
||||
newState.viewportWidth !== state.viewportWidth
|
||||
) {
|
||||
return {
|
||||
...newState,
|
||||
visualLayout: calculateLayout(newState.lines, newState.viewportWidth),
|
||||
};
|
||||
}
|
||||
|
||||
return newState;
|
||||
}
|
||||
|
||||
// --- End of reducer logic ---
|
||||
|
||||
export function useTextBuffer({
|
||||
@@ -1462,6 +1532,10 @@ export function useTextBuffer({
|
||||
lines.length === 0 ? [''] : lines,
|
||||
initialCursorOffset,
|
||||
);
|
||||
const visualLayout = calculateLayout(
|
||||
lines.length === 0 ? [''] : lines,
|
||||
viewport.width,
|
||||
);
|
||||
return {
|
||||
lines: lines.length === 0 ? [''] : lines,
|
||||
cursorRow: initialCursorRow,
|
||||
@@ -1472,21 +1546,29 @@ export function useTextBuffer({
|
||||
clipboard: null,
|
||||
selectionAnchor: null,
|
||||
viewportWidth: viewport.width,
|
||||
viewportHeight: viewport.height,
|
||||
visualLayout,
|
||||
};
|
||||
}, [initialText, initialCursorOffset, viewport.width]);
|
||||
}, [initialText, initialCursorOffset, viewport.width, viewport.height]);
|
||||
|
||||
const [state, dispatch] = useReducer(textBufferReducer, initialState);
|
||||
const { lines, cursorRow, cursorCol, preferredCol, selectionAnchor } = state;
|
||||
const {
|
||||
lines,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
preferredCol,
|
||||
selectionAnchor,
|
||||
visualLayout,
|
||||
} = state;
|
||||
|
||||
const text = useMemo(() => lines.join('\n'), [lines]);
|
||||
|
||||
const visualLayout = useMemo(
|
||||
() =>
|
||||
calculateVisualLayout(lines, [cursorRow, cursorCol], state.viewportWidth),
|
||||
[lines, cursorRow, cursorCol, state.viewportWidth],
|
||||
const visualCursor = useMemo(
|
||||
() => calculateVisualCursorFromLayout(visualLayout, [cursorRow, cursorCol]),
|
||||
[visualLayout, cursorRow, cursorCol],
|
||||
);
|
||||
|
||||
const { visualLines, visualCursor } = visualLayout;
|
||||
const { visualLines, visualToLogicalMap } = visualLayout;
|
||||
|
||||
const [visualScrollRow, setVisualScrollRow] = useState<number>(0);
|
||||
|
||||
@@ -1497,12 +1579,17 @@ export function useTextBuffer({
|
||||
}, [text, onChange]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: 'set_viewport_width', payload: viewport.width });
|
||||
}, [viewport.width]);
|
||||
dispatch({
|
||||
type: 'set_viewport',
|
||||
payload: { width: viewport.width, height: viewport.height },
|
||||
});
|
||||
}, [viewport.width, viewport.height]);
|
||||
|
||||
// Update visual scroll (vertical)
|
||||
useEffect(() => {
|
||||
const { height } = viewport;
|
||||
const totalVisualLines = visualLines.length;
|
||||
const maxScrollStart = Math.max(0, totalVisualLines - height);
|
||||
let newVisualScrollRow = visualScrollRow;
|
||||
|
||||
if (visualCursor[0] < visualScrollRow) {
|
||||
@@ -1510,10 +1597,15 @@ export function useTextBuffer({
|
||||
} else if (visualCursor[0] >= visualScrollRow + height) {
|
||||
newVisualScrollRow = visualCursor[0] - height + 1;
|
||||
}
|
||||
|
||||
// When the number of visual lines shrinks (e.g., after widening the viewport),
|
||||
// ensure scroll never starts beyond the last valid start so we can render a full window.
|
||||
newVisualScrollRow = clamp(newVisualScrollRow, 0, maxScrollStart);
|
||||
|
||||
if (newVisualScrollRow !== visualScrollRow) {
|
||||
setVisualScrollRow(newVisualScrollRow);
|
||||
}
|
||||
}, [visualCursor, visualScrollRow, viewport]);
|
||||
}, [visualCursor, visualScrollRow, viewport, visualLines.length]);
|
||||
|
||||
const insert = useCallback(
|
||||
(ch: string, { paste = false }: { paste?: boolean } = {}): void => {
|
||||
@@ -1571,9 +1663,12 @@ export function useTextBuffer({
|
||||
dispatch({ type: 'delete' });
|
||||
}, []);
|
||||
|
||||
const move = useCallback((dir: Direction): void => {
|
||||
dispatch({ type: 'move', payload: { dir } });
|
||||
}, []);
|
||||
const move = useCallback(
|
||||
(dir: Direction): void => {
|
||||
dispatch({ type: 'move', payload: { dir } });
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const undo = useCallback((): void => {
|
||||
dispatch({ type: 'undo' });
|
||||
@@ -1836,11 +1931,23 @@ export function useTextBuffer({
|
||||
)
|
||||
backspace();
|
||||
else if (key.name === 'delete' || (key.ctrl && key.name === 'd')) del();
|
||||
else if (key.ctrl && !key.shift && key.name === 'z') undo();
|
||||
else if (key.ctrl && key.shift && key.name === 'z') redo();
|
||||
else if (input && !key.ctrl && !key.meta) {
|
||||
insert(input, { paste: key.paste });
|
||||
}
|
||||
},
|
||||
[newline, move, deleteWordLeft, deleteWordRight, backspace, del, insert],
|
||||
[
|
||||
newline,
|
||||
move,
|
||||
deleteWordLeft,
|
||||
deleteWordRight,
|
||||
backspace,
|
||||
del,
|
||||
insert,
|
||||
undo,
|
||||
redo,
|
||||
],
|
||||
);
|
||||
|
||||
const renderedVisualLines = useMemo(
|
||||
@@ -1877,69 +1984,135 @@ export function useTextBuffer({
|
||||
dispatch({ type: 'move_to_offset', payload: { offset } });
|
||||
}, []);
|
||||
|
||||
const returnValue: TextBuffer = {
|
||||
lines,
|
||||
text,
|
||||
cursor: [cursorRow, cursorCol],
|
||||
preferredCol,
|
||||
selectionAnchor,
|
||||
const returnValue: TextBuffer = useMemo(
|
||||
() => ({
|
||||
lines,
|
||||
text,
|
||||
cursor: [cursorRow, cursorCol],
|
||||
preferredCol,
|
||||
selectionAnchor,
|
||||
|
||||
allVisualLines: visualLines,
|
||||
viewportVisualLines: renderedVisualLines,
|
||||
visualCursor,
|
||||
visualScrollRow,
|
||||
allVisualLines: visualLines,
|
||||
viewportVisualLines: renderedVisualLines,
|
||||
visualCursor,
|
||||
visualScrollRow,
|
||||
visualToLogicalMap,
|
||||
|
||||
setText,
|
||||
insert,
|
||||
newline,
|
||||
backspace,
|
||||
del,
|
||||
move,
|
||||
undo,
|
||||
redo,
|
||||
replaceRange,
|
||||
replaceRangeByOffset,
|
||||
moveToOffset,
|
||||
deleteWordLeft,
|
||||
deleteWordRight,
|
||||
killLineRight,
|
||||
killLineLeft,
|
||||
handleInput,
|
||||
openInExternalEditor,
|
||||
// Vim-specific operations
|
||||
vimDeleteWordForward,
|
||||
vimDeleteWordBackward,
|
||||
vimDeleteWordEnd,
|
||||
vimChangeWordForward,
|
||||
vimChangeWordBackward,
|
||||
vimChangeWordEnd,
|
||||
vimDeleteLine,
|
||||
vimChangeLine,
|
||||
vimDeleteToEndOfLine,
|
||||
vimChangeToEndOfLine,
|
||||
vimChangeMovement,
|
||||
vimMoveLeft,
|
||||
vimMoveRight,
|
||||
vimMoveUp,
|
||||
vimMoveDown,
|
||||
vimMoveWordForward,
|
||||
vimMoveWordBackward,
|
||||
vimMoveWordEnd,
|
||||
vimDeleteChar,
|
||||
vimInsertAtCursor,
|
||||
vimAppendAtCursor,
|
||||
vimOpenLineBelow,
|
||||
vimOpenLineAbove,
|
||||
vimAppendAtLineEnd,
|
||||
vimInsertAtLineStart,
|
||||
vimMoveToLineStart,
|
||||
vimMoveToLineEnd,
|
||||
vimMoveToFirstNonWhitespace,
|
||||
vimMoveToFirstLine,
|
||||
vimMoveToLastLine,
|
||||
vimMoveToLine,
|
||||
vimEscapeInsertMode,
|
||||
};
|
||||
setText,
|
||||
insert,
|
||||
newline,
|
||||
backspace,
|
||||
del,
|
||||
move,
|
||||
undo,
|
||||
redo,
|
||||
replaceRange,
|
||||
replaceRangeByOffset,
|
||||
moveToOffset,
|
||||
deleteWordLeft,
|
||||
deleteWordRight,
|
||||
|
||||
killLineRight,
|
||||
killLineLeft,
|
||||
handleInput,
|
||||
openInExternalEditor,
|
||||
// Vim-specific operations
|
||||
vimDeleteWordForward,
|
||||
vimDeleteWordBackward,
|
||||
vimDeleteWordEnd,
|
||||
vimChangeWordForward,
|
||||
vimChangeWordBackward,
|
||||
vimChangeWordEnd,
|
||||
vimDeleteLine,
|
||||
vimChangeLine,
|
||||
vimDeleteToEndOfLine,
|
||||
vimChangeToEndOfLine,
|
||||
vimChangeMovement,
|
||||
vimMoveLeft,
|
||||
vimMoveRight,
|
||||
vimMoveUp,
|
||||
vimMoveDown,
|
||||
vimMoveWordForward,
|
||||
vimMoveWordBackward,
|
||||
vimMoveWordEnd,
|
||||
vimDeleteChar,
|
||||
vimInsertAtCursor,
|
||||
vimAppendAtCursor,
|
||||
vimOpenLineBelow,
|
||||
vimOpenLineAbove,
|
||||
vimAppendAtLineEnd,
|
||||
vimInsertAtLineStart,
|
||||
vimMoveToLineStart,
|
||||
vimMoveToLineEnd,
|
||||
vimMoveToFirstNonWhitespace,
|
||||
vimMoveToFirstLine,
|
||||
vimMoveToLastLine,
|
||||
vimMoveToLine,
|
||||
vimEscapeInsertMode,
|
||||
}),
|
||||
[
|
||||
lines,
|
||||
text,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
preferredCol,
|
||||
selectionAnchor,
|
||||
visualLines,
|
||||
renderedVisualLines,
|
||||
visualCursor,
|
||||
visualScrollRow,
|
||||
setText,
|
||||
insert,
|
||||
newline,
|
||||
backspace,
|
||||
del,
|
||||
move,
|
||||
undo,
|
||||
redo,
|
||||
replaceRange,
|
||||
replaceRangeByOffset,
|
||||
moveToOffset,
|
||||
deleteWordLeft,
|
||||
deleteWordRight,
|
||||
killLineRight,
|
||||
killLineLeft,
|
||||
handleInput,
|
||||
openInExternalEditor,
|
||||
vimDeleteWordForward,
|
||||
vimDeleteWordBackward,
|
||||
vimDeleteWordEnd,
|
||||
vimChangeWordForward,
|
||||
vimChangeWordBackward,
|
||||
vimChangeWordEnd,
|
||||
vimDeleteLine,
|
||||
vimChangeLine,
|
||||
vimDeleteToEndOfLine,
|
||||
vimChangeToEndOfLine,
|
||||
vimChangeMovement,
|
||||
vimMoveLeft,
|
||||
vimMoveRight,
|
||||
vimMoveUp,
|
||||
vimMoveDown,
|
||||
vimMoveWordForward,
|
||||
vimMoveWordBackward,
|
||||
vimMoveWordEnd,
|
||||
vimDeleteChar,
|
||||
vimInsertAtCursor,
|
||||
vimAppendAtCursor,
|
||||
vimOpenLineBelow,
|
||||
vimOpenLineAbove,
|
||||
vimAppendAtLineEnd,
|
||||
vimInsertAtLineStart,
|
||||
vimMoveToLineStart,
|
||||
vimMoveToLineEnd,
|
||||
vimMoveToFirstNonWhitespace,
|
||||
vimMoveToFirstLine,
|
||||
vimMoveToLastLine,
|
||||
vimMoveToLine,
|
||||
vimEscapeInsertMode,
|
||||
visualToLogicalMap,
|
||||
],
|
||||
);
|
||||
return returnValue;
|
||||
}
|
||||
|
||||
@@ -1962,6 +2135,12 @@ export interface TextBuffer {
|
||||
viewportVisualLines: string[]; // The subset of visual lines to be rendered based on visualScrollRow and viewport.height
|
||||
visualCursor: [number, number]; // Visual cursor [row, col] relative to the start of all visualLines
|
||||
visualScrollRow: number; // Scroll position for visual lines (index of the first visible visual line)
|
||||
/**
|
||||
* For each visual line (by absolute index in allVisualLines) provides a tuple
|
||||
* [logicalLineIndex, startColInLogical] that maps where that visual line
|
||||
* begins within the logical buffer. Indices are code-point based.
|
||||
*/
|
||||
visualToLogicalMap: Array<[number, number]>;
|
||||
|
||||
// Actions
|
||||
|
||||
@@ -2011,6 +2190,7 @@ export interface TextBuffer {
|
||||
* follows the caret and the next contiguous run of word characters.
|
||||
*/
|
||||
deleteWordRight: () => void;
|
||||
|
||||
/**
|
||||
* Deletes text from the cursor to the end of the current line.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user