mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-02-01 22:48:03 +00:00
feat: add double-click to expand/collapse large paste placeholders (#17471)
This commit is contained in:
@@ -124,3 +124,6 @@ available combinations.
|
||||
single-line input, navigate backward or forward through prompt history.
|
||||
- `Number keys (1-9, multi-digit)` inside selection dialogs: Jump directly to
|
||||
the numbered radio option and confirm when the full number is entered.
|
||||
- `Double-click` on a paste placeholder (`[Pasted Text: X lines]`) in alternate
|
||||
buffer mode: Expand to view full content inline. Double-click again to
|
||||
collapse.
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
createMockSettings,
|
||||
} from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { act } from 'react';
|
||||
import { act, useState } from 'react';
|
||||
import type { InputPromptProps } from './InputPrompt.js';
|
||||
import { InputPrompt } from './InputPrompt.js';
|
||||
import type { TextBuffer } from './shared/text-buffer.js';
|
||||
@@ -2900,6 +2900,93 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should toggle paste expansion on double-click', async () => {
|
||||
const id = '[Pasted Text: 10 lines]';
|
||||
const largeText =
|
||||
'line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10';
|
||||
|
||||
const baseProps = props;
|
||||
const TestWrapper = () => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const currentLines = isExpanded ? largeText.split('\n') : [id];
|
||||
const currentText = isExpanded ? largeText : id;
|
||||
|
||||
const buffer = {
|
||||
...baseProps.buffer,
|
||||
text: currentText,
|
||||
lines: currentLines,
|
||||
viewportVisualLines: currentLines,
|
||||
allVisualLines: currentLines,
|
||||
pastedContent: { [id]: largeText },
|
||||
transformationsByLine: isExpanded
|
||||
? currentLines.map(() => [])
|
||||
: [
|
||||
[
|
||||
{
|
||||
logStart: 0,
|
||||
logEnd: id.length,
|
||||
logicalText: id,
|
||||
collapsedText: id,
|
||||
type: 'paste',
|
||||
id,
|
||||
},
|
||||
],
|
||||
],
|
||||
visualScrollRow: 0,
|
||||
visualToLogicalMap: currentLines.map(
|
||||
(_, i) => [i, 0] as [number, number],
|
||||
),
|
||||
visualToTransformedMap: currentLines.map(() => 0),
|
||||
getLogicalPositionFromVisual: vi.fn().mockReturnValue({
|
||||
row: 0,
|
||||
col: 2,
|
||||
}),
|
||||
togglePasteExpansion: vi.fn().mockImplementation(() => {
|
||||
setIsExpanded(!isExpanded);
|
||||
}),
|
||||
getExpandedPasteAtLine: vi
|
||||
.fn()
|
||||
.mockReturnValue(isExpanded ? id : null),
|
||||
};
|
||||
|
||||
return <InputPrompt {...baseProps} buffer={buffer as TextBuffer} />;
|
||||
};
|
||||
|
||||
const { stdin, stdout, unmount, simulateClick } = renderWithProviders(
|
||||
<TestWrapper />,
|
||||
{
|
||||
mouseEventsEnabled: true,
|
||||
useAlternateBuffer: true,
|
||||
uiActions,
|
||||
},
|
||||
);
|
||||
|
||||
// 1. Verify initial placeholder
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
// Simulate double-click to expand
|
||||
await simulateClick(stdin, 5, 2);
|
||||
await simulateClick(stdin, 5, 2);
|
||||
|
||||
// 2. Verify expanded content is visible
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
// Simulate double-click to collapse
|
||||
await simulateClick(stdin, 5, 2);
|
||||
await simulateClick(stdin, 5, 2);
|
||||
|
||||
// 3. Verify placeholder is restored
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should move cursor on mouse click with plain borders', async () => {
|
||||
props.config.getUseBackgroundColor = () => false;
|
||||
props.buffer.text = 'hello world';
|
||||
|
||||
@@ -12,10 +12,11 @@ import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||||
import { HalfLinePaddedBox } from './shared/HalfLinePaddedBox.js';
|
||||
import type { TextBuffer } from './shared/text-buffer.js';
|
||||
import {
|
||||
type TextBuffer,
|
||||
logicalPosToOffset,
|
||||
PASTED_TEXT_PLACEHOLDER_REGEX,
|
||||
getTransformUnderCursor,
|
||||
} from './shared/text-buffer.js';
|
||||
import { cpSlice, cpLen, toCodePoints } from '../utils/textUtils.js';
|
||||
import chalk from 'chalk';
|
||||
@@ -56,8 +57,10 @@ import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { useMouseClick } from '../hooks/useMouseClick.js';
|
||||
import { useMouseDoubleClick } from '../hooks/useMouseDoubleClick.js';
|
||||
import { useMouse, type MouseEvent } from '../contexts/MouseContext.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
|
||||
/**
|
||||
* Returns if the terminal can be trusted to handle paste events atomically
|
||||
@@ -397,6 +400,40 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
{ isActive: focus },
|
||||
);
|
||||
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
|
||||
// Double-click to expand/collapse paste placeholders
|
||||
useMouseDoubleClick(
|
||||
innerBoxRef,
|
||||
(_event, relX, relY) => {
|
||||
if (!isAlternateBuffer) return;
|
||||
|
||||
const logicalPos = buffer.getLogicalPositionFromVisual(
|
||||
buffer.visualScrollRow + relY,
|
||||
relX,
|
||||
);
|
||||
if (!logicalPos) return;
|
||||
|
||||
// Check for paste placeholder (collapsed state)
|
||||
const transform = getTransformUnderCursor(
|
||||
logicalPos.row,
|
||||
logicalPos.col,
|
||||
buffer.transformationsByLine,
|
||||
);
|
||||
if (transform?.type === 'paste' && transform.id) {
|
||||
buffer.togglePasteExpansion(transform.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for expanded paste region
|
||||
const expandedId = buffer.getExpandedPasteAtLine(logicalPos.row);
|
||||
if (expandedId) {
|
||||
buffer.togglePasteExpansion(expandedId);
|
||||
}
|
||||
},
|
||||
{ isActive: focus },
|
||||
);
|
||||
|
||||
useMouse(
|
||||
(event: MouseEvent) => {
|
||||
if (event.name === 'right-release') {
|
||||
|
||||
@@ -44,6 +44,33 @@ exports[`InputPrompt > image path transformation snapshots > should snapshot exp
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 1`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> [Pasted Text: 10 lines]
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 2`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> line1
|
||||
line2
|
||||
line3
|
||||
line4
|
||||
line5
|
||||
line6
|
||||
line7
|
||||
line8
|
||||
line9
|
||||
line10
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 3`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> [Pasted Text: 10 lines]
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> Type your message or @path/to/file
|
||||
|
||||
@@ -57,6 +57,7 @@ const initialState: TextBufferState = {
|
||||
transformationsByLine: [[]],
|
||||
visualLayout: defaultVisualLayout,
|
||||
pastedContent: {},
|
||||
expandedPasteInfo: new Map(),
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -531,6 +532,150 @@ describe('textBufferReducer', () => {
|
||||
expect(state.cursorCol).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggle_paste_expansion action', () => {
|
||||
const placeholder = '[Pasted Text: 6 lines]';
|
||||
const content = 'line1\nline2\nline3\nline4\nline5\nline6';
|
||||
|
||||
it('should expand a placeholder correctly', () => {
|
||||
const stateWithPlaceholder = createStateWithTransformations({
|
||||
lines: ['prefix ' + placeholder + ' suffix'],
|
||||
cursorRow: 0,
|
||||
cursorCol: 0,
|
||||
pastedContent: { [placeholder]: content },
|
||||
});
|
||||
|
||||
const action: TextBufferAction = {
|
||||
type: 'toggle_paste_expansion',
|
||||
payload: { id: placeholder },
|
||||
};
|
||||
|
||||
const state = textBufferReducer(stateWithPlaceholder, action);
|
||||
|
||||
expect(state.lines).toEqual([
|
||||
'prefix line1',
|
||||
'line2',
|
||||
'line3',
|
||||
'line4',
|
||||
'line5',
|
||||
'line6 suffix',
|
||||
]);
|
||||
expect(state.expandedPasteInfo.has(placeholder)).toBe(true);
|
||||
const info = state.expandedPasteInfo.get(placeholder);
|
||||
expect(info).toEqual({
|
||||
startLine: 0,
|
||||
lineCount: 6,
|
||||
prefix: 'prefix ',
|
||||
suffix: ' suffix',
|
||||
});
|
||||
// Cursor should be at the end of expanded content (before suffix)
|
||||
expect(state.cursorRow).toBe(5);
|
||||
expect(state.cursorCol).toBe(5); // length of 'line6'
|
||||
});
|
||||
|
||||
it('should collapse an expanded placeholder correctly', () => {
|
||||
const expandedState = createStateWithTransformations({
|
||||
lines: [
|
||||
'prefix line1',
|
||||
'line2',
|
||||
'line3',
|
||||
'line4',
|
||||
'line5',
|
||||
'line6 suffix',
|
||||
],
|
||||
cursorRow: 5,
|
||||
cursorCol: 5,
|
||||
pastedContent: { [placeholder]: content },
|
||||
expandedPasteInfo: new Map([
|
||||
[
|
||||
placeholder,
|
||||
{
|
||||
startLine: 0,
|
||||
lineCount: 6,
|
||||
prefix: 'prefix ',
|
||||
suffix: ' suffix',
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
|
||||
const action: TextBufferAction = {
|
||||
type: 'toggle_paste_expansion',
|
||||
payload: { id: placeholder },
|
||||
};
|
||||
|
||||
const state = textBufferReducer(expandedState, action);
|
||||
|
||||
expect(state.lines).toEqual(['prefix ' + placeholder + ' suffix']);
|
||||
expect(state.expandedPasteInfo.has(placeholder)).toBe(false);
|
||||
// Cursor should be at the end of the collapsed placeholder
|
||||
expect(state.cursorRow).toBe(0);
|
||||
expect(state.cursorCol).toBe(('prefix ' + placeholder).length);
|
||||
});
|
||||
|
||||
it('should expand single-line content correctly', () => {
|
||||
const singleLinePlaceholder = '[Pasted Text: 10 chars]';
|
||||
const singleLineContent = 'some text';
|
||||
const stateWithPlaceholder = createStateWithTransformations({
|
||||
lines: [singleLinePlaceholder],
|
||||
cursorRow: 0,
|
||||
cursorCol: 0,
|
||||
pastedContent: { [singleLinePlaceholder]: singleLineContent },
|
||||
});
|
||||
|
||||
const state = textBufferReducer(stateWithPlaceholder, {
|
||||
type: 'toggle_paste_expansion',
|
||||
payload: { id: singleLinePlaceholder },
|
||||
});
|
||||
|
||||
expect(state.lines).toEqual(['some text']);
|
||||
expect(state.cursorRow).toBe(0);
|
||||
expect(state.cursorCol).toBe(9);
|
||||
});
|
||||
|
||||
it('should return current state if placeholder ID not found in pastedContent', () => {
|
||||
const action: TextBufferAction = {
|
||||
type: 'toggle_paste_expansion',
|
||||
payload: { id: 'unknown' },
|
||||
};
|
||||
const state = textBufferReducer(initialState, action);
|
||||
expect(state).toBe(initialState);
|
||||
});
|
||||
|
||||
it('should preserve expandedPasteInfo when lines change from edits outside the region', () => {
|
||||
// Start with an expanded paste at line 0 (3 lines long)
|
||||
const placeholder = '[Pasted Text: 3 lines]';
|
||||
const expandedState = createStateWithTransformations({
|
||||
lines: ['line1', 'line2', 'line3', 'suffix'],
|
||||
cursorRow: 3,
|
||||
cursorCol: 0,
|
||||
pastedContent: { [placeholder]: 'line1\nline2\nline3' },
|
||||
expandedPasteInfo: new Map([
|
||||
[
|
||||
placeholder,
|
||||
{
|
||||
startLine: 0,
|
||||
lineCount: 3,
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(expandedState.expandedPasteInfo.size).toBe(1);
|
||||
|
||||
// Insert a newline at the end - this changes lines but is OUTSIDE the expanded region
|
||||
const stateAfterInsert = textBufferReducer(expandedState, {
|
||||
type: 'insert',
|
||||
payload: '\n',
|
||||
});
|
||||
|
||||
// Lines changed, but expandedPasteInfo should be PRESERVED and optionally shifted (no shift here since edit is after)
|
||||
expect(stateAfterInsert.expandedPasteInfo.size).toBe(1);
|
||||
expect(stateAfterInsert.expandedPasteInfo.has(placeholder)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const getBufferState = (result: { current: TextBuffer }) => {
|
||||
|
||||
@@ -586,6 +586,7 @@ interface UndoHistoryEntry {
|
||||
cursorRow: number;
|
||||
cursorCol: number;
|
||||
pastedContent: Record<string, string>;
|
||||
expandedPasteInfo: Map<string, ExpandedPasteInfo>;
|
||||
}
|
||||
|
||||
function calculateInitialCursorPosition(
|
||||
@@ -814,6 +815,110 @@ export function getTransformUnderCursor(
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface ExpandedPasteInfo {
|
||||
startLine: number;
|
||||
lineCount: number;
|
||||
prefix: string;
|
||||
suffix: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a line index falls within an expanded paste region.
|
||||
* Returns the paste placeholder ID if found, null otherwise.
|
||||
*/
|
||||
export function getExpandedPasteAtLine(
|
||||
lineIndex: number,
|
||||
expandedPasteInfo: Map<string, ExpandedPasteInfo>,
|
||||
): string | null {
|
||||
for (const [id, info] of expandedPasteInfo) {
|
||||
if (
|
||||
lineIndex >= info.startLine &&
|
||||
lineIndex < info.startLine + info.lineCount
|
||||
) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Surgery for expanded paste regions when lines are added or removed.
|
||||
* Adjusts startLine indices and detaches any region that is partially or fully deleted.
|
||||
*/
|
||||
export function shiftExpandedRegions(
|
||||
expandedPasteInfo: Map<string, ExpandedPasteInfo>,
|
||||
changeStartLine: number,
|
||||
lineDelta: number,
|
||||
changeEndLine?: number, // Inclusive
|
||||
): {
|
||||
newInfo: Map<string, ExpandedPasteInfo>;
|
||||
detachedIds: Set<string>;
|
||||
} {
|
||||
const newInfo = new Map<string, ExpandedPasteInfo>();
|
||||
const detachedIds = new Set<string>();
|
||||
if (expandedPasteInfo.size === 0) return { newInfo, detachedIds };
|
||||
|
||||
const effectiveEndLine = changeEndLine ?? changeStartLine;
|
||||
|
||||
for (const [id, info] of expandedPasteInfo) {
|
||||
const infoEndLine = info.startLine + info.lineCount - 1;
|
||||
|
||||
// 1. Check for overlap/intersection with the changed range
|
||||
const isOverlapping =
|
||||
changeStartLine <= infoEndLine && effectiveEndLine >= info.startLine;
|
||||
|
||||
if (isOverlapping) {
|
||||
// If the change is a deletion (lineDelta < 0) that touches this region, we detach.
|
||||
// If it's an insertion, we only detach if it's a multi-line insertion (lineDelta > 0)
|
||||
// that isn't at the very start of the region (which would shift it).
|
||||
// Regular character typing (lineDelta === 0) does NOT detach.
|
||||
if (
|
||||
lineDelta < 0 ||
|
||||
(lineDelta > 0 &&
|
||||
changeStartLine > info.startLine &&
|
||||
changeStartLine <= infoEndLine)
|
||||
) {
|
||||
detachedIds.add(id);
|
||||
continue; // Detach by not adding to newInfo
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Shift regions that start at or after the change point
|
||||
if (info.startLine >= changeStartLine) {
|
||||
newInfo.set(id, {
|
||||
...info,
|
||||
startLine: info.startLine + lineDelta,
|
||||
});
|
||||
} else {
|
||||
newInfo.set(id, info);
|
||||
}
|
||||
}
|
||||
|
||||
return { newInfo, detachedIds };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach any expanded paste region if the cursor is within it.
|
||||
* This converts the expanded content to regular text that can no longer be collapsed.
|
||||
* Returns the state unchanged if cursor is not in an expanded region.
|
||||
*/
|
||||
export function detachExpandedPaste(state: TextBufferState): TextBufferState {
|
||||
const expandedId = getExpandedPasteAtLine(
|
||||
state.cursorRow,
|
||||
state.expandedPasteInfo,
|
||||
);
|
||||
if (!expandedId) return state;
|
||||
|
||||
const newExpandedInfo = new Map(state.expandedPasteInfo);
|
||||
newExpandedInfo.delete(expandedId);
|
||||
const { [expandedId]: _, ...newPastedContent } = state.pastedContent;
|
||||
return {
|
||||
...state,
|
||||
expandedPasteInfo: newExpandedInfo,
|
||||
pastedContent: newPastedContent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an atomic placeholder that should be deleted as a unit.
|
||||
* Extensible to support future placeholder types.
|
||||
@@ -1272,16 +1377,18 @@ export interface TextBufferState {
|
||||
viewportHeight: number;
|
||||
visualLayout: VisualLayout;
|
||||
pastedContent: Record<string, string>;
|
||||
expandedPasteInfo: Map<string, ExpandedPasteInfo>;
|
||||
}
|
||||
|
||||
const historyLimit = 100;
|
||||
|
||||
export const pushUndo = (currentState: TextBufferState): TextBufferState => {
|
||||
const snapshot = {
|
||||
const snapshot: UndoHistoryEntry = {
|
||||
lines: [...currentState.lines],
|
||||
cursorRow: currentState.cursorRow,
|
||||
cursorCol: currentState.cursorCol,
|
||||
pastedContent: { ...currentState.pastedContent },
|
||||
expandedPasteInfo: new Map(currentState.expandedPasteInfo),
|
||||
};
|
||||
const newStack = [...currentState.undoStack, snapshot];
|
||||
if (newStack.length > historyLimit) {
|
||||
@@ -1383,7 +1490,8 @@ export type TextBufferAction =
|
||||
| { type: 'vim_move_to_first_line' }
|
||||
| { type: 'vim_move_to_last_line' }
|
||||
| { type: 'vim_move_to_line'; payload: { lineNumber: number } }
|
||||
| { type: 'vim_escape_insert_mode' };
|
||||
| { type: 'vim_escape_insert_mode' }
|
||||
| { type: 'toggle_paste_expansion'; payload: { id: string } };
|
||||
|
||||
export interface TextBufferOptions {
|
||||
inputFilter?: (text: string) => string;
|
||||
@@ -1422,7 +1530,7 @@ function textBufferReducerLogic(
|
||||
}
|
||||
|
||||
case 'insert': {
|
||||
const nextState = pushUndoLocal(state);
|
||||
const nextState = detachExpandedPaste(pushUndoLocal(state));
|
||||
const newLines = [...nextState.lines];
|
||||
let newCursorRow = nextState.cursorRow;
|
||||
let newCursorCol = nextState.cursorCol;
|
||||
@@ -1468,6 +1576,7 @@ function textBufferReducerLogic(
|
||||
const before = cpSlice(lineContent, 0, newCursorCol);
|
||||
const after = cpSlice(lineContent, newCursorCol);
|
||||
|
||||
let lineDelta = 0;
|
||||
if (parts.length > 1) {
|
||||
newLines[newCursorRow] = before + parts[0];
|
||||
const remainingParts = parts.slice(1);
|
||||
@@ -1478,6 +1587,7 @@ function textBufferReducerLogic(
|
||||
0,
|
||||
lastPartOriginal + after,
|
||||
);
|
||||
lineDelta = parts.length - 1;
|
||||
newCursorRow = newCursorRow + parts.length - 1;
|
||||
newCursorCol = cpLen(lastPartOriginal);
|
||||
} else {
|
||||
@@ -1485,6 +1595,16 @@ function textBufferReducerLogic(
|
||||
newCursorCol = cpLen(before) + cpLen(parts[0]);
|
||||
}
|
||||
|
||||
const { newInfo: newExpandedInfo, detachedIds } = shiftExpandedRegions(
|
||||
nextState.expandedPasteInfo,
|
||||
nextState.cursorRow,
|
||||
lineDelta,
|
||||
);
|
||||
|
||||
for (const id of detachedIds) {
|
||||
delete newPastedContent[id];
|
||||
}
|
||||
|
||||
return {
|
||||
...nextState,
|
||||
lines: newLines,
|
||||
@@ -1492,6 +1612,7 @@ function textBufferReducerLogic(
|
||||
cursorCol: newCursorCol,
|
||||
preferredCol: null,
|
||||
pastedContent: newPastedContent,
|
||||
expandedPasteInfo: newExpandedInfo,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1507,10 +1628,13 @@ function textBufferReducerLogic(
|
||||
}
|
||||
|
||||
case 'backspace': {
|
||||
const { cursorRow, cursorCol, lines, transformationsByLine } = state;
|
||||
const stateWithUndo = pushUndoLocal(state);
|
||||
const currentState = detachExpandedPaste(stateWithUndo);
|
||||
const { cursorRow, cursorCol, lines, transformationsByLine } =
|
||||
currentState;
|
||||
|
||||
// Early return if at start of buffer
|
||||
if (cursorCol === 0 && cursorRow === 0) return state;
|
||||
if (cursorCol === 0 && cursorRow === 0) return currentState;
|
||||
|
||||
// Check if cursor is at end of an atomic placeholder
|
||||
const transformations = transformationsByLine[cursorRow] ?? [];
|
||||
@@ -1521,7 +1645,7 @@ function textBufferReducerLogic(
|
||||
);
|
||||
|
||||
if (placeholder) {
|
||||
const nextState = pushUndoLocal(state);
|
||||
const nextState = currentState;
|
||||
const newLines = [...nextState.lines];
|
||||
newLines[cursorRow] =
|
||||
cpSlice(newLines[cursorRow], 0, placeholder.start) +
|
||||
@@ -1551,13 +1675,14 @@ function textBufferReducerLogic(
|
||||
}
|
||||
|
||||
// Standard backspace logic
|
||||
const nextState = pushUndoLocal(state);
|
||||
const nextState = currentState;
|
||||
const newLines = [...nextState.lines];
|
||||
let newCursorRow = nextState.cursorRow;
|
||||
let newCursorCol = nextState.cursorCol;
|
||||
|
||||
const currentLine = (r: number) => newLines[r] ?? '';
|
||||
|
||||
let lineDelta = 0;
|
||||
if (newCursorCol > 0) {
|
||||
const lineContent = currentLine(newCursorRow);
|
||||
newLines[newCursorRow] =
|
||||
@@ -1570,16 +1695,31 @@ function textBufferReducerLogic(
|
||||
const newCol = cpLen(prevLineContent);
|
||||
newLines[newCursorRow - 1] = prevLineContent + currentLineContentVal;
|
||||
newLines.splice(newCursorRow, 1);
|
||||
lineDelta = -1;
|
||||
newCursorRow--;
|
||||
newCursorCol = newCol;
|
||||
}
|
||||
|
||||
const { newInfo: newExpandedInfo, detachedIds } = shiftExpandedRegions(
|
||||
nextState.expandedPasteInfo,
|
||||
nextState.cursorRow + lineDelta, // shift based on the line that was removed
|
||||
lineDelta,
|
||||
nextState.cursorRow,
|
||||
);
|
||||
|
||||
const newPastedContent = { ...nextState.pastedContent };
|
||||
for (const id of detachedIds) {
|
||||
delete newPastedContent[id];
|
||||
}
|
||||
|
||||
return {
|
||||
...nextState,
|
||||
lines: newLines,
|
||||
cursorRow: newCursorRow,
|
||||
cursorCol: newCursorCol,
|
||||
preferredCol: null,
|
||||
pastedContent: newPastedContent,
|
||||
expandedPasteInfo: newExpandedInfo,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1767,7 +1907,10 @@ function textBufferReducerLogic(
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
const { cursorRow, cursorCol, lines, transformationsByLine } = state;
|
||||
const stateWithUndo = pushUndoLocal(state);
|
||||
const currentState = detachExpandedPaste(stateWithUndo);
|
||||
const { cursorRow, cursorCol, lines, transformationsByLine } =
|
||||
currentState;
|
||||
|
||||
// Check if cursor is at start of an atomic placeholder
|
||||
const transformations = transformationsByLine[cursorRow] ?? [];
|
||||
@@ -1778,7 +1921,7 @@ function textBufferReducerLogic(
|
||||
);
|
||||
|
||||
if (placeholder) {
|
||||
const nextState = pushUndoLocal(state);
|
||||
const nextState = currentState;
|
||||
const newLines = [...nextState.lines];
|
||||
newLines[cursorRow] =
|
||||
cpSlice(newLines[cursorRow], 0, placeholder.start) +
|
||||
@@ -1809,37 +1952,51 @@ function textBufferReducerLogic(
|
||||
|
||||
// Standard delete logic
|
||||
const lineContent = currentLine(cursorRow);
|
||||
let lineDelta = 0;
|
||||
const nextState = currentState;
|
||||
const newLines = [...nextState.lines];
|
||||
|
||||
if (cursorCol < currentLineLen(cursorRow)) {
|
||||
const nextState = pushUndoLocal(state);
|
||||
const newLines = [...nextState.lines];
|
||||
newLines[cursorRow] =
|
||||
cpSlice(lineContent, 0, cursorCol) +
|
||||
cpSlice(lineContent, cursorCol + 1);
|
||||
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,
|
||||
};
|
||||
lineDelta = -1;
|
||||
} else {
|
||||
return currentState;
|
||||
}
|
||||
return state;
|
||||
|
||||
const { newInfo: newExpandedInfo, detachedIds } = shiftExpandedRegions(
|
||||
nextState.expandedPasteInfo,
|
||||
nextState.cursorRow,
|
||||
lineDelta,
|
||||
nextState.cursorRow + (lineDelta < 0 ? 1 : 0),
|
||||
);
|
||||
|
||||
const newPastedContent = { ...nextState.pastedContent };
|
||||
for (const id of detachedIds) {
|
||||
delete newPastedContent[id];
|
||||
}
|
||||
|
||||
return {
|
||||
...nextState,
|
||||
lines: newLines,
|
||||
preferredCol: null,
|
||||
pastedContent: newPastedContent,
|
||||
expandedPasteInfo: newExpandedInfo,
|
||||
};
|
||||
}
|
||||
|
||||
case 'delete_word_left': {
|
||||
const { cursorRow, cursorCol } = state;
|
||||
if (cursorCol === 0 && cursorRow === 0) return state;
|
||||
const stateWithUndo = pushUndoLocal(state);
|
||||
const currentState = detachExpandedPaste(stateWithUndo);
|
||||
const { cursorRow, cursorCol } = currentState;
|
||||
if (cursorCol === 0 && cursorRow === 0) return currentState;
|
||||
|
||||
const nextState = pushUndoLocal(state);
|
||||
const nextState = currentState;
|
||||
const newLines = [...nextState.lines];
|
||||
let newCursorRow = cursorRow;
|
||||
let newCursorCol = cursorCol;
|
||||
@@ -1875,15 +2032,17 @@ function textBufferReducerLogic(
|
||||
}
|
||||
|
||||
case 'delete_word_right': {
|
||||
const { cursorRow, cursorCol, lines } = state;
|
||||
const stateWithUndo = pushUndoLocal(state);
|
||||
const currentState = detachExpandedPaste(stateWithUndo);
|
||||
const { cursorRow, cursorCol, lines } = currentState;
|
||||
const lineContent = currentLine(cursorRow);
|
||||
const lineLen = cpLen(lineContent);
|
||||
|
||||
if (cursorCol >= lineLen && cursorRow === lines.length - 1) {
|
||||
return state;
|
||||
return currentState;
|
||||
}
|
||||
|
||||
const nextState = pushUndoLocal(state);
|
||||
const nextState = currentState;
|
||||
const newLines = [...nextState.lines];
|
||||
|
||||
if (cursorCol >= lineLen) {
|
||||
@@ -1906,10 +2065,12 @@ function textBufferReducerLogic(
|
||||
}
|
||||
|
||||
case 'kill_line_right': {
|
||||
const { cursorRow, cursorCol, lines } = state;
|
||||
const stateWithUndo = pushUndoLocal(state);
|
||||
const currentState = detachExpandedPaste(stateWithUndo);
|
||||
const { cursorRow, cursorCol, lines } = currentState;
|
||||
const lineContent = currentLine(cursorRow);
|
||||
if (cursorCol < currentLineLen(cursorRow)) {
|
||||
const nextState = pushUndoLocal(state);
|
||||
const nextState = currentState;
|
||||
const newLines = [...nextState.lines];
|
||||
newLines[cursorRow] = cpSlice(lineContent, 0, cursorCol);
|
||||
return {
|
||||
@@ -1918,7 +2079,7 @@ function textBufferReducerLogic(
|
||||
};
|
||||
} else if (cursorRow < lines.length - 1) {
|
||||
// Act as a delete
|
||||
const nextState = pushUndoLocal(state);
|
||||
const nextState = currentState;
|
||||
const nextLineContent = currentLine(cursorRow + 1);
|
||||
const newLines = [...nextState.lines];
|
||||
newLines[cursorRow] = lineContent + nextLineContent;
|
||||
@@ -1929,13 +2090,15 @@ function textBufferReducerLogic(
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
return currentState;
|
||||
}
|
||||
|
||||
case 'kill_line_left': {
|
||||
const { cursorRow, cursorCol } = state;
|
||||
const stateWithUndo = pushUndoLocal(state);
|
||||
const currentState = detachExpandedPaste(stateWithUndo);
|
||||
const { cursorRow, cursorCol } = currentState;
|
||||
if (cursorCol > 0) {
|
||||
const nextState = pushUndoLocal(state);
|
||||
const nextState = currentState;
|
||||
const lineContent = currentLine(cursorRow);
|
||||
const newLines = [...nextState.lines];
|
||||
newLines[cursorRow] = cpSlice(lineContent, cursorCol);
|
||||
@@ -1946,18 +2109,19 @@ function textBufferReducerLogic(
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
return currentState;
|
||||
}
|
||||
|
||||
case 'undo': {
|
||||
const stateToRestore = state.undoStack[state.undoStack.length - 1];
|
||||
if (!stateToRestore) return state;
|
||||
|
||||
const currentSnapshot = {
|
||||
const currentSnapshot: UndoHistoryEntry = {
|
||||
lines: [...state.lines],
|
||||
cursorRow: state.cursorRow,
|
||||
cursorCol: state.cursorCol,
|
||||
pastedContent: { ...state.pastedContent },
|
||||
expandedPasteInfo: new Map(state.expandedPasteInfo),
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
@@ -1971,11 +2135,12 @@ function textBufferReducerLogic(
|
||||
const stateToRestore = state.redoStack[state.redoStack.length - 1];
|
||||
if (!stateToRestore) return state;
|
||||
|
||||
const currentSnapshot = {
|
||||
const currentSnapshot: UndoHistoryEntry = {
|
||||
lines: [...state.lines],
|
||||
cursorRow: state.cursorRow,
|
||||
cursorCol: state.cursorCol,
|
||||
pastedContent: { ...state.pastedContent },
|
||||
expandedPasteInfo: new Map(state.expandedPasteInfo),
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
@@ -1988,7 +2153,7 @@ function textBufferReducerLogic(
|
||||
case 'replace_range': {
|
||||
const { startRow, startCol, endRow, endCol, text } = action.payload;
|
||||
const nextState = pushUndoLocal(state);
|
||||
return replaceRangeInternal(
|
||||
const newState = replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
@@ -1996,6 +2161,29 @@ function textBufferReducerLogic(
|
||||
endCol,
|
||||
text,
|
||||
);
|
||||
|
||||
const oldLineCount = endRow - startRow + 1;
|
||||
const newLineCount =
|
||||
newState.lines.length - (nextState.lines.length - oldLineCount);
|
||||
const lineDelta = newLineCount - oldLineCount;
|
||||
|
||||
const { newInfo: newExpandedInfo, detachedIds } = shiftExpandedRegions(
|
||||
nextState.expandedPasteInfo,
|
||||
startRow,
|
||||
lineDelta,
|
||||
endRow,
|
||||
);
|
||||
|
||||
const newPastedContent = { ...newState.pastedContent };
|
||||
for (const id of detachedIds) {
|
||||
delete newPastedContent[id];
|
||||
}
|
||||
|
||||
return {
|
||||
...newState,
|
||||
pastedContent: newPastedContent,
|
||||
expandedPasteInfo: newExpandedInfo,
|
||||
};
|
||||
}
|
||||
|
||||
case 'move_to_offset': {
|
||||
@@ -2051,6 +2239,133 @@ function textBufferReducerLogic(
|
||||
case 'vim_escape_insert_mode':
|
||||
return handleVimAction(state, action as VimAction);
|
||||
|
||||
case 'toggle_paste_expansion': {
|
||||
const { id } = action.payload;
|
||||
const info = state.expandedPasteInfo.get(id);
|
||||
|
||||
if (info) {
|
||||
const nextState = pushUndoLocal(state);
|
||||
// COLLAPSE: Restore original line with placeholder
|
||||
const newLines = [...nextState.lines];
|
||||
newLines.splice(
|
||||
info.startLine,
|
||||
info.lineCount,
|
||||
info.prefix + id + info.suffix,
|
||||
);
|
||||
|
||||
const lineDelta = 1 - info.lineCount;
|
||||
const { newInfo: newExpandedInfo, detachedIds } = shiftExpandedRegions(
|
||||
nextState.expandedPasteInfo,
|
||||
info.startLine,
|
||||
lineDelta,
|
||||
info.startLine + info.lineCount - 1,
|
||||
);
|
||||
newExpandedInfo.delete(id); // Already shifted, now remove self
|
||||
|
||||
const newPastedContent = { ...nextState.pastedContent };
|
||||
for (const detachedId of detachedIds) {
|
||||
if (detachedId !== id) {
|
||||
delete newPastedContent[detachedId];
|
||||
}
|
||||
}
|
||||
|
||||
// Move cursor to end of collapsed placeholder
|
||||
const newCursorRow = info.startLine;
|
||||
const newCursorCol = cpLen(info.prefix) + cpLen(id);
|
||||
|
||||
return {
|
||||
...nextState,
|
||||
lines: newLines,
|
||||
cursorRow: newCursorRow,
|
||||
cursorCol: newCursorCol,
|
||||
preferredCol: null,
|
||||
pastedContent: newPastedContent,
|
||||
expandedPasteInfo: newExpandedInfo,
|
||||
};
|
||||
} else {
|
||||
// EXPAND: Replace placeholder with content
|
||||
const content = state.pastedContent[id];
|
||||
if (!content) return state;
|
||||
|
||||
// Find line and position containing exactly this placeholder
|
||||
let lineIndex = -1;
|
||||
let placeholderStart = -1;
|
||||
for (let i = 0; i < state.lines.length; i++) {
|
||||
const transforms = state.transformationsByLine[i] ?? [];
|
||||
const transform = transforms.find(
|
||||
(t) => t.type === 'paste' && t.id === id,
|
||||
);
|
||||
if (transform) {
|
||||
lineIndex = i;
|
||||
placeholderStart = transform.logStart;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (lineIndex === -1) return state;
|
||||
|
||||
const nextState = pushUndoLocal(state);
|
||||
|
||||
const line = nextState.lines[lineIndex];
|
||||
const prefix = cpSlice(line, 0, placeholderStart);
|
||||
const suffix = cpSlice(line, placeholderStart + cpLen(id));
|
||||
|
||||
// Split content into lines
|
||||
const contentLines = content.split('\n');
|
||||
const newLines = [...nextState.lines];
|
||||
|
||||
let expandedLines: string[];
|
||||
if (contentLines.length === 1) {
|
||||
// Single-line content
|
||||
expandedLines = [prefix + contentLines[0] + suffix];
|
||||
} else {
|
||||
// Multi-line content
|
||||
expandedLines = [
|
||||
prefix + contentLines[0],
|
||||
...contentLines.slice(1, -1),
|
||||
contentLines[contentLines.length - 1] + suffix,
|
||||
];
|
||||
}
|
||||
|
||||
newLines.splice(lineIndex, 1, ...expandedLines);
|
||||
|
||||
const lineDelta = expandedLines.length - 1;
|
||||
const { newInfo: newExpandedInfo, detachedIds } = shiftExpandedRegions(
|
||||
nextState.expandedPasteInfo,
|
||||
lineIndex,
|
||||
lineDelta,
|
||||
lineIndex,
|
||||
);
|
||||
|
||||
const newPastedContent = { ...nextState.pastedContent };
|
||||
for (const detachedId of detachedIds) {
|
||||
delete newPastedContent[detachedId];
|
||||
}
|
||||
|
||||
newExpandedInfo.set(id, {
|
||||
startLine: lineIndex,
|
||||
lineCount: expandedLines.length,
|
||||
prefix,
|
||||
suffix,
|
||||
});
|
||||
|
||||
// Move cursor to end of expanded content (before suffix)
|
||||
const newCursorRow = lineIndex + expandedLines.length - 1;
|
||||
const lastExpandedLine = expandedLines[expandedLines.length - 1];
|
||||
const newCursorCol = cpLen(lastExpandedLine) - cpLen(suffix);
|
||||
|
||||
return {
|
||||
...nextState,
|
||||
lines: newLines,
|
||||
cursorRow: newCursorRow,
|
||||
cursorCol: newCursorCol,
|
||||
preferredCol: null,
|
||||
pastedContent: newPastedContent,
|
||||
expandedPasteInfo: newExpandedInfo,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
default: {
|
||||
const exhaustiveCheck: never = action;
|
||||
debugLogger.error(`Unknown action encountered: ${exhaustiveCheck}`);
|
||||
@@ -2095,6 +2410,7 @@ export function textBufferReducer(
|
||||
) {
|
||||
const shouldResetPreferred =
|
||||
oldInside !== newInside || movedBetweenTransforms;
|
||||
|
||||
return {
|
||||
...newState,
|
||||
preferredCol: shouldResetPreferred ? null : newState.preferredCol,
|
||||
@@ -2152,6 +2468,7 @@ export function useTextBuffer({
|
||||
viewportHeight: viewport.height,
|
||||
visualLayout,
|
||||
pastedContent: {},
|
||||
expandedPasteInfo: new Map(),
|
||||
};
|
||||
}, [initialText, initialCursorOffset, viewport.width, viewport.height]);
|
||||
|
||||
@@ -2169,6 +2486,7 @@ export function useTextBuffer({
|
||||
visualLayout,
|
||||
transformationsByLine,
|
||||
pastedContent,
|
||||
expandedPasteInfo,
|
||||
} = state;
|
||||
|
||||
const text = useMemo(() => lines.join('\n'), [lines]);
|
||||
@@ -2454,7 +2772,12 @@ export function useTextBuffer({
|
||||
const openInExternalEditor = useCallback(async (): Promise<void> => {
|
||||
const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'gemini-edit-'));
|
||||
const filePath = pathMod.join(tmpDir, 'buffer.txt');
|
||||
fs.writeFileSync(filePath, text, 'utf8');
|
||||
// Expand paste placeholders so user sees full content in editor
|
||||
const expandedText = text.replace(
|
||||
PASTED_TEXT_PLACEHOLDER_REGEX,
|
||||
(match) => pastedContent[match] || match,
|
||||
);
|
||||
fs.writeFileSync(filePath, expandedText, 'utf8');
|
||||
|
||||
let command: string | undefined = undefined;
|
||||
const args = [filePath];
|
||||
@@ -2488,6 +2811,17 @@ export function useTextBuffer({
|
||||
|
||||
let newText = fs.readFileSync(filePath, 'utf8');
|
||||
newText = newText.replace(/\r\n?/g, '\n');
|
||||
|
||||
// Attempt to re-collapse unchanged pasted content back into placeholders
|
||||
const sortedPlaceholders = Object.entries(pastedContent).sort(
|
||||
(a, b) => b[1].length - a[1].length,
|
||||
);
|
||||
for (const [id, content] of sortedPlaceholders) {
|
||||
if (newText.includes(content)) {
|
||||
newText = newText.replace(content, id);
|
||||
}
|
||||
}
|
||||
|
||||
dispatch({ type: 'set_text', payload: newText, pushToUndo: false });
|
||||
} catch (err) {
|
||||
coreEvents.emitFeedback(
|
||||
@@ -2509,7 +2843,7 @@ export function useTextBuffer({
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}, [text, stdin, setRawMode, getPreferredEditor]);
|
||||
}, [text, pastedContent, stdin, setRawMode, getPreferredEditor]);
|
||||
|
||||
const handleInput = useCallback(
|
||||
(key: Key): void => {
|
||||
@@ -2650,11 +2984,81 @@ export function useTextBuffer({
|
||||
[visualLayout, lines],
|
||||
);
|
||||
|
||||
const getLogicalPositionFromVisual = useCallback(
|
||||
(visRow: number, visCol: number): { row: number; col: number } | null => {
|
||||
const {
|
||||
visualLines,
|
||||
visualToLogicalMap,
|
||||
transformedToLogicalMaps,
|
||||
visualToTransformedMap,
|
||||
} = visualLayout;
|
||||
|
||||
// Clamp visRow to valid range
|
||||
const clampedVisRow = Math.max(
|
||||
0,
|
||||
Math.min(visRow, visualLines.length - 1),
|
||||
);
|
||||
const visualLine = visualLines[clampedVisRow] || '';
|
||||
|
||||
if (!visualToLogicalMap[clampedVisRow]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [logRow] = visualToLogicalMap[clampedVisRow];
|
||||
const transformedToLogicalMap = transformedToLogicalMaps?.[logRow] ?? [];
|
||||
|
||||
// Where does this visual line begin within the transformed line?
|
||||
const startColInTransformed =
|
||||
visualToTransformedMap?.[clampedVisRow] ?? 0;
|
||||
|
||||
// Handle wide characters: convert visual X position to character offset
|
||||
const codePoints = toCodePoints(visualLine);
|
||||
let currentVisX = 0;
|
||||
let charOffset = 0;
|
||||
|
||||
for (const char of codePoints) {
|
||||
const charWidth = getCachedStringWidth(char);
|
||||
if (visCol < currentVisX + charWidth) {
|
||||
if (charWidth > 1 && visCol >= currentVisX + charWidth / 2) {
|
||||
charOffset++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
currentVisX += charWidth;
|
||||
charOffset++;
|
||||
}
|
||||
|
||||
charOffset = Math.min(charOffset, codePoints.length);
|
||||
|
||||
const transformedCol = Math.min(
|
||||
startColInTransformed + charOffset,
|
||||
Math.max(0, transformedToLogicalMap.length - 1),
|
||||
);
|
||||
|
||||
const row = logRow;
|
||||
const col =
|
||||
transformedToLogicalMap[transformedCol] ?? cpLen(lines[logRow] ?? '');
|
||||
|
||||
return { row, col };
|
||||
},
|
||||
[visualLayout, lines],
|
||||
);
|
||||
|
||||
const getOffset = useCallback(
|
||||
(): number => logicalPosToOffset(lines, cursorRow, cursorCol),
|
||||
[lines, cursorRow, cursorCol],
|
||||
);
|
||||
|
||||
const togglePasteExpansion = useCallback((id: string): void => {
|
||||
dispatch({ type: 'toggle_paste_expansion', payload: { id } });
|
||||
}, []);
|
||||
|
||||
const getExpandedPasteAtLineCallback = useCallback(
|
||||
(lineIndex: number): string | null =>
|
||||
getExpandedPasteAtLine(lineIndex, expandedPasteInfo),
|
||||
[expandedPasteInfo],
|
||||
);
|
||||
|
||||
const returnValue: TextBuffer = useMemo(
|
||||
() => ({
|
||||
lines,
|
||||
@@ -2686,6 +3090,10 @@ export function useTextBuffer({
|
||||
moveToOffset,
|
||||
getOffset,
|
||||
moveToVisualPosition,
|
||||
getLogicalPositionFromVisual,
|
||||
getExpandedPasteAtLine: getExpandedPasteAtLineCallback,
|
||||
togglePasteExpansion,
|
||||
expandedPasteInfo,
|
||||
deleteWordLeft,
|
||||
deleteWordRight,
|
||||
|
||||
@@ -2757,6 +3165,10 @@ export function useTextBuffer({
|
||||
moveToOffset,
|
||||
getOffset,
|
||||
moveToVisualPosition,
|
||||
getLogicalPositionFromVisual,
|
||||
getExpandedPasteAtLineCallback,
|
||||
togglePasteExpansion,
|
||||
expandedPasteInfo,
|
||||
deleteWordLeft,
|
||||
deleteWordRight,
|
||||
killLineRight,
|
||||
@@ -2926,6 +3338,29 @@ export interface TextBuffer {
|
||||
getOffset: () => number;
|
||||
moveToOffset(offset: number): void;
|
||||
moveToVisualPosition(visualRow: number, visualCol: number): void;
|
||||
/**
|
||||
* Convert visual coordinates to logical position without moving cursor.
|
||||
* Returns null if the position is out of bounds.
|
||||
*/
|
||||
getLogicalPositionFromVisual(
|
||||
visualRow: number,
|
||||
visualCol: number,
|
||||
): { row: number; col: number } | null;
|
||||
/**
|
||||
* Check if a line index falls within an expanded paste region.
|
||||
* Returns the paste placeholder ID if found, null otherwise.
|
||||
*/
|
||||
getExpandedPasteAtLine(lineIndex: number): string | null;
|
||||
/**
|
||||
* Toggle expansion state for a paste placeholder.
|
||||
* If collapsed, expands to show full content inline.
|
||||
* If expanded, collapses back to placeholder.
|
||||
*/
|
||||
togglePasteExpansion(id: string): void;
|
||||
/**
|
||||
* The current expanded paste info map (read-only).
|
||||
*/
|
||||
expandedPasteInfo: Map<string, ExpandedPasteInfo>;
|
||||
|
||||
// Vim-specific operations
|
||||
/**
|
||||
|
||||
@@ -35,6 +35,7 @@ const createTestState = (
|
||||
transformationsByLine: [[]],
|
||||
visualLayout: defaultVisualLayout,
|
||||
pastedContent: {},
|
||||
expandedPasteInfo: new Map(),
|
||||
});
|
||||
|
||||
describe('vim-buffer-actions', () => {
|
||||
@@ -906,7 +907,13 @@ describe('vim-buffer-actions', () => {
|
||||
it('should preserve undo stack in operations', () => {
|
||||
const state = createTestState(['hello'], 0, 0);
|
||||
state.undoStack = [
|
||||
{ lines: ['previous'], cursorRow: 0, cursorCol: 0, pastedContent: {} },
|
||||
{
|
||||
lines: ['previous'],
|
||||
cursorRow: 0,
|
||||
cursorCol: 0,
|
||||
pastedContent: {},
|
||||
expandedPasteInfo: new Map(),
|
||||
},
|
||||
];
|
||||
|
||||
const action = {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getPositionFromOffsets,
|
||||
replaceRangeInternal,
|
||||
pushUndo,
|
||||
detachExpandedPaste,
|
||||
isWordCharStrict,
|
||||
isWordCharWithCombining,
|
||||
isCombiningMark,
|
||||
@@ -105,7 +106,7 @@ export function handleVimAction(
|
||||
}
|
||||
|
||||
if (endRow !== cursorRow || endCol !== cursorCol) {
|
||||
const nextState = pushUndo(state);
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
@@ -135,7 +136,7 @@ export function handleVimAction(
|
||||
}
|
||||
|
||||
if (startRow !== cursorRow || startCol !== cursorCol) {
|
||||
const nextState = pushUndo(state);
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
@@ -188,7 +189,7 @@ export function handleVimAction(
|
||||
}
|
||||
|
||||
if (endRow !== cursorRow || endCol !== cursorCol) {
|
||||
const nextState = pushUndo(state);
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
@@ -211,7 +212,7 @@ export function handleVimAction(
|
||||
if (totalLines === 1 || linesToDelete >= totalLines) {
|
||||
// If there's only one line, or we're deleting all remaining lines,
|
||||
// clear the content but keep one empty line (text editors should never be completely empty)
|
||||
const nextState = pushUndo(state);
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
return {
|
||||
...nextState,
|
||||
lines: [''],
|
||||
@@ -221,7 +222,7 @@ export function handleVimAction(
|
||||
};
|
||||
}
|
||||
|
||||
const nextState = pushUndo(state);
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
const newLines = [...nextState.lines];
|
||||
newLines.splice(cursorRow, linesToDelete);
|
||||
|
||||
@@ -243,7 +244,7 @@ export function handleVimAction(
|
||||
if (lines.length === 0) return state;
|
||||
|
||||
const linesToChange = Math.min(count, lines.length - cursorRow);
|
||||
const nextState = pushUndo(state);
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
|
||||
const { startOffset, endOffset } = getLineRangeOffsets(
|
||||
cursorRow,
|
||||
@@ -269,7 +270,7 @@ export function handleVimAction(
|
||||
case 'vim_change_to_end_of_line': {
|
||||
const currentLine = lines[cursorRow] || '';
|
||||
if (cursorCol < cpLen(currentLine)) {
|
||||
const nextState = pushUndo(state);
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
@@ -292,7 +293,7 @@ export function handleVimAction(
|
||||
// Change N characters to the left
|
||||
const startCol = Math.max(0, cursorCol - count);
|
||||
return replaceRangeInternal(
|
||||
pushUndo(state),
|
||||
detachExpandedPaste(pushUndo(state)),
|
||||
cursorRow,
|
||||
startCol,
|
||||
cursorRow,
|
||||
@@ -308,7 +309,7 @@ export function handleVimAction(
|
||||
if (totalLines === 1) {
|
||||
const currentLine = state.lines[0] || '';
|
||||
return replaceRangeInternal(
|
||||
pushUndo(state),
|
||||
detachExpandedPaste(pushUndo(state)),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
@@ -316,7 +317,7 @@ export function handleVimAction(
|
||||
'',
|
||||
);
|
||||
} else {
|
||||
const nextState = pushUndo(state);
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
const { startOffset, endOffset } = getLineRangeOffsets(
|
||||
cursorRow,
|
||||
linesToChange,
|
||||
@@ -344,7 +345,7 @@ export function handleVimAction(
|
||||
if (state.lines.length === 1) {
|
||||
const currentLine = state.lines[0] || '';
|
||||
return replaceRangeInternal(
|
||||
pushUndo(state),
|
||||
detachExpandedPaste(pushUndo(state)),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
@@ -354,7 +355,7 @@ export function handleVimAction(
|
||||
} else {
|
||||
const startRow = Math.max(0, cursorRow - count + 1);
|
||||
const linesToChange = cursorRow - startRow + 1;
|
||||
const nextState = pushUndo(state);
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
const { startOffset, endOffset } = getLineRangeOffsets(
|
||||
startRow,
|
||||
linesToChange,
|
||||
@@ -392,7 +393,7 @@ export function handleVimAction(
|
||||
// Right
|
||||
// Change N characters to the right
|
||||
return replaceRangeInternal(
|
||||
pushUndo(state),
|
||||
detachExpandedPaste(pushUndo(state)),
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
cursorRow,
|
||||
@@ -624,7 +625,7 @@ export function handleVimAction(
|
||||
|
||||
if (cursorCol < lineLength) {
|
||||
const deleteCount = Math.min(count, lineLength - cursorCol);
|
||||
const nextState = pushUndo(state);
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
@@ -656,7 +657,7 @@ export function handleVimAction(
|
||||
|
||||
case 'vim_open_line_below': {
|
||||
const { cursorRow, lines } = state;
|
||||
const nextState = pushUndo(state);
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
|
||||
// Insert newline at end of current line
|
||||
const endOfLine = cpLen(lines[cursorRow] || '');
|
||||
@@ -672,7 +673,7 @@ export function handleVimAction(
|
||||
|
||||
case 'vim_open_line_above': {
|
||||
const { cursorRow } = state;
|
||||
const nextState = pushUndo(state);
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
|
||||
// Insert newline at beginning of current line
|
||||
const resultState = replaceRangeInternal(
|
||||
|
||||
148
packages/cli/src/ui/hooks/useMouseDoubleClick.test.ts
Normal file
148
packages/cli/src/ui/hooks/useMouseDoubleClick.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { renderHook } from '../../test-utils/render.js';
|
||||
import { useMouseDoubleClick } from './useMouseDoubleClick.js';
|
||||
import * as MouseContext from '../contexts/MouseContext.js';
|
||||
import type { MouseEvent } from '../contexts/MouseContext.js';
|
||||
import type { DOMElement } from 'ink';
|
||||
|
||||
describe('useMouseDoubleClick', () => {
|
||||
const mockHandler = vi.fn();
|
||||
const mockContainerRef = {
|
||||
current: {} as DOMElement,
|
||||
};
|
||||
|
||||
// Mock getBoundingBox from ink
|
||||
vi.mock('ink', async () => {
|
||||
const actual = await vi.importActual('ink');
|
||||
return {
|
||||
...actual,
|
||||
getBoundingBox: () => ({ x: 0, y: 0, width: 80, height: 24 }),
|
||||
};
|
||||
});
|
||||
|
||||
let mouseCallback: (event: MouseEvent) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Mock useMouse to capture the callback
|
||||
vi.spyOn(MouseContext, 'useMouse').mockImplementation((callback) => {
|
||||
mouseCallback = callback;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should detect double-click within threshold', async () => {
|
||||
renderHook(() => useMouseDoubleClick(mockContainerRef, mockHandler));
|
||||
|
||||
const event1: MouseEvent = {
|
||||
name: 'left-press',
|
||||
col: 10,
|
||||
row: 5,
|
||||
shift: false,
|
||||
meta: false,
|
||||
ctrl: false,
|
||||
button: 'left',
|
||||
};
|
||||
const event2: MouseEvent = {
|
||||
name: 'left-press',
|
||||
col: 10,
|
||||
row: 5,
|
||||
shift: false,
|
||||
meta: false,
|
||||
ctrl: false,
|
||||
button: 'left',
|
||||
};
|
||||
await act(async () => {
|
||||
mouseCallback(event1);
|
||||
vi.advanceTimersByTime(200);
|
||||
mouseCallback(event2);
|
||||
});
|
||||
|
||||
expect(mockHandler).toHaveBeenCalledWith(event2, 9, 4);
|
||||
});
|
||||
|
||||
it('should NOT detect double-click if time exceeds threshold', async () => {
|
||||
renderHook(() => useMouseDoubleClick(mockContainerRef, mockHandler));
|
||||
|
||||
const event1: MouseEvent = {
|
||||
name: 'left-press',
|
||||
col: 10,
|
||||
row: 5,
|
||||
shift: false,
|
||||
meta: false,
|
||||
ctrl: false,
|
||||
button: 'left',
|
||||
};
|
||||
const event2: MouseEvent = {
|
||||
name: 'left-press',
|
||||
col: 10,
|
||||
row: 5,
|
||||
shift: false,
|
||||
meta: false,
|
||||
ctrl: false,
|
||||
button: 'left',
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
mouseCallback(event1);
|
||||
vi.advanceTimersByTime(500); // Threshold is 400ms
|
||||
mouseCallback(event2);
|
||||
});
|
||||
|
||||
expect(mockHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT detect double-click if distance exceeds tolerance', async () => {
|
||||
renderHook(() => useMouseDoubleClick(mockContainerRef, mockHandler));
|
||||
|
||||
const event1: MouseEvent = {
|
||||
name: 'left-press',
|
||||
col: 10,
|
||||
row: 5,
|
||||
shift: false,
|
||||
meta: false,
|
||||
ctrl: false,
|
||||
button: 'left',
|
||||
};
|
||||
const event2: MouseEvent = {
|
||||
name: 'left-press',
|
||||
col: 15,
|
||||
row: 10,
|
||||
shift: false,
|
||||
meta: false,
|
||||
ctrl: false,
|
||||
button: 'left',
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
mouseCallback(event1);
|
||||
vi.advanceTimersByTime(200);
|
||||
mouseCallback(event2);
|
||||
});
|
||||
|
||||
expect(mockHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should respect isActive option', () => {
|
||||
renderHook(() =>
|
||||
useMouseDoubleClick(mockContainerRef, mockHandler, { isActive: false }),
|
||||
);
|
||||
|
||||
expect(MouseContext.useMouse).toHaveBeenCalledWith(expect.any(Function), {
|
||||
isActive: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
72
packages/cli/src/ui/hooks/useMouseDoubleClick.ts
Normal file
72
packages/cli/src/ui/hooks/useMouseDoubleClick.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { getBoundingBox, type DOMElement } from 'ink';
|
||||
import type React from 'react';
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { useMouse, type MouseEvent } from '../contexts/MouseContext.js';
|
||||
|
||||
const DOUBLE_CLICK_THRESHOLD_MS = 400;
|
||||
const DOUBLE_CLICK_DISTANCE_TOLERANCE = 2;
|
||||
|
||||
export const useMouseDoubleClick = (
|
||||
containerRef: React.RefObject<DOMElement | null>,
|
||||
handler: (event: MouseEvent, relativeX: number, relativeY: number) => void,
|
||||
options: { isActive?: boolean } = {},
|
||||
) => {
|
||||
const { isActive = true } = options;
|
||||
const handlerRef = useRef(handler);
|
||||
handlerRef.current = handler;
|
||||
|
||||
const lastClickRef = useRef<{
|
||||
time: number;
|
||||
col: number;
|
||||
row: number;
|
||||
} | null>(null);
|
||||
|
||||
const onMouse = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (event.name !== 'left-press' || !containerRef.current) return;
|
||||
|
||||
const now = Date.now();
|
||||
const lastClick = lastClickRef.current;
|
||||
|
||||
// Check if this is a valid double-click
|
||||
if (
|
||||
lastClick &&
|
||||
now - lastClick.time < DOUBLE_CLICK_THRESHOLD_MS &&
|
||||
Math.abs(event.col - lastClick.col) <=
|
||||
DOUBLE_CLICK_DISTANCE_TOLERANCE &&
|
||||
Math.abs(event.row - lastClick.row) <= DOUBLE_CLICK_DISTANCE_TOLERANCE
|
||||
) {
|
||||
// Double-click detected
|
||||
const { x, y, width, height } = getBoundingBox(containerRef.current);
|
||||
// Terminal mouse events are 1-based, Ink layout is 0-based.
|
||||
const mouseX = event.col - 1;
|
||||
const mouseY = event.row - 1;
|
||||
|
||||
const relativeX = mouseX - x;
|
||||
const relativeY = mouseY - y;
|
||||
|
||||
if (
|
||||
relativeX >= 0 &&
|
||||
relativeX < width &&
|
||||
relativeY >= 0 &&
|
||||
relativeY < height
|
||||
) {
|
||||
handlerRef.current(event, relativeX, relativeY);
|
||||
}
|
||||
lastClickRef.current = null; // Reset after double-click
|
||||
} else {
|
||||
// First click, record it
|
||||
lastClickRef.current = { time: now, col: event.col, row: event.row };
|
||||
}
|
||||
},
|
||||
[containerRef],
|
||||
);
|
||||
|
||||
useMouse(onMouse, { isActive });
|
||||
};
|
||||
@@ -68,6 +68,7 @@ const createMockTextBufferState = (
|
||||
visualToTransformedMap: [],
|
||||
},
|
||||
pastedContent: {},
|
||||
expandedPasteInfo: new Map(),
|
||||
...partial,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user