mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-23 12:30:43 -07:00
paste transform followup (#17624)
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
This commit is contained in:
@@ -2987,6 +2987,89 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should collapse expanded paste on double-click after the end of the line', 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(true); // Start expanded
|
||||
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().mockImplementation(
|
||||
(_vRow, _vCol) =>
|
||||
// Simulate that we are past the end of the line by returning something
|
||||
// that getTransformUnderCursor won't match, or having the caller handle it.
|
||||
null,
|
||||
),
|
||||
togglePasteExpansion: vi.fn().mockImplementation(() => {
|
||||
setIsExpanded(!isExpanded);
|
||||
}),
|
||||
getExpandedPasteAtLine: vi
|
||||
.fn()
|
||||
.mockImplementation((row) =>
|
||||
isExpanded && row >= 0 && row < 10 ? id : null,
|
||||
),
|
||||
};
|
||||
|
||||
return <InputPrompt {...baseProps} buffer={buffer as TextBuffer} />;
|
||||
};
|
||||
|
||||
const { stdin, stdout, unmount, simulateClick } = renderWithProviders(
|
||||
<TestWrapper />,
|
||||
{
|
||||
mouseEventsEnabled: true,
|
||||
useAlternateBuffer: true,
|
||||
uiActions,
|
||||
},
|
||||
);
|
||||
|
||||
// Verify initially expanded
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toContain('line1');
|
||||
});
|
||||
|
||||
// Simulate double-click WAY to the right on the first line
|
||||
await simulateClick(stdin, 100, 2);
|
||||
await simulateClick(stdin, 100, 2);
|
||||
|
||||
// Verify it is NOW collapsed
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toContain(id);
|
||||
expect(stdout.lastFrame()).not.toContain('line1');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should move cursor on mouse click with plain borders', async () => {
|
||||
props.config.getUseBackgroundColor = () => false;
|
||||
props.buffer.text = 'hello world';
|
||||
|
||||
@@ -57,7 +57,6 @@ 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';
|
||||
@@ -403,35 +402,54 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
|
||||
// Double-click to expand/collapse paste placeholders
|
||||
useMouseDoubleClick(
|
||||
useMouseClick(
|
||||
innerBoxRef,
|
||||
(_event, relX, relY) => {
|
||||
if (!isAlternateBuffer) return;
|
||||
|
||||
const logicalPos = buffer.getLogicalPositionFromVisual(
|
||||
buffer.visualScrollRow + relY,
|
||||
relX,
|
||||
);
|
||||
if (!logicalPos) return;
|
||||
const visualLine = buffer.viewportVisualLines[relY];
|
||||
if (!visualLine) return;
|
||||
|
||||
// Even if we click past the end of the line, we might want to collapse an expanded paste
|
||||
const isPastEndOfLine = relX >= stringWidth(visualLine);
|
||||
|
||||
const logicalPos = isPastEndOfLine
|
||||
? null
|
||||
: buffer.getLogicalPositionFromVisual(
|
||||
buffer.visualScrollRow + relY,
|
||||
relX,
|
||||
);
|
||||
|
||||
// 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;
|
||||
if (logicalPos) {
|
||||
const transform = getTransformUnderCursor(
|
||||
logicalPos.row,
|
||||
logicalPos.col,
|
||||
buffer.transformationsByLine,
|
||||
);
|
||||
if (transform?.type === 'paste' && transform.id) {
|
||||
buffer.togglePasteExpansion(
|
||||
transform.id,
|
||||
logicalPos.row,
|
||||
logicalPos.col,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for expanded paste region
|
||||
const expandedId = buffer.getExpandedPasteAtLine(logicalPos.row);
|
||||
// If we didn't click a placeholder to expand, check if we are inside or after
|
||||
// an expanded paste region and collapse it.
|
||||
const row = buffer.visualScrollRow + relY;
|
||||
const expandedId = buffer.getExpandedPasteAtLine(row);
|
||||
if (expandedId) {
|
||||
buffer.togglePasteExpansion(expandedId);
|
||||
buffer.togglePasteExpansion(
|
||||
expandedId,
|
||||
row,
|
||||
logicalPos?.col ?? relX, // Fallback to relX if past end of line
|
||||
);
|
||||
}
|
||||
},
|
||||
{ isActive: focus },
|
||||
{ isActive: focus, name: 'double-click' },
|
||||
);
|
||||
|
||||
useMouse(
|
||||
@@ -1228,6 +1246,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const absoluteVisualIdx =
|
||||
scrollVisualRow + visualIdxInRenderedSet;
|
||||
const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx];
|
||||
if (!mapEntry) return null;
|
||||
|
||||
const cursorVisualRow =
|
||||
cursorVisualRowAbsolute - scrollVisualRow;
|
||||
const isOnCursorLine =
|
||||
|
||||
@@ -57,7 +57,7 @@ const initialState: TextBufferState = {
|
||||
transformationsByLine: [[]],
|
||||
visualLayout: defaultVisualLayout,
|
||||
pastedContent: {},
|
||||
expandedPasteInfo: new Map(),
|
||||
expandedPaste: null,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -547,7 +547,7 @@ describe('textBufferReducer', () => {
|
||||
|
||||
const action: TextBufferAction = {
|
||||
type: 'toggle_paste_expansion',
|
||||
payload: { id: placeholder },
|
||||
payload: { id: placeholder, row: 0, col: 7 },
|
||||
};
|
||||
|
||||
const state = textBufferReducer(stateWithPlaceholder, action);
|
||||
@@ -560,9 +560,10 @@ describe('textBufferReducer', () => {
|
||||
'line5',
|
||||
'line6 suffix',
|
||||
]);
|
||||
expect(state.expandedPasteInfo.has(placeholder)).toBe(true);
|
||||
const info = state.expandedPasteInfo.get(placeholder);
|
||||
expect(state.expandedPaste?.id).toBe(placeholder);
|
||||
const info = state.expandedPaste;
|
||||
expect(info).toEqual({
|
||||
id: placeholder,
|
||||
startLine: 0,
|
||||
lineCount: 6,
|
||||
prefix: 'prefix ',
|
||||
@@ -586,28 +587,24 @@ describe('textBufferReducer', () => {
|
||||
cursorRow: 5,
|
||||
cursorCol: 5,
|
||||
pastedContent: { [placeholder]: content },
|
||||
expandedPasteInfo: new Map([
|
||||
[
|
||||
placeholder,
|
||||
{
|
||||
startLine: 0,
|
||||
lineCount: 6,
|
||||
prefix: 'prefix ',
|
||||
suffix: ' suffix',
|
||||
},
|
||||
],
|
||||
]),
|
||||
expandedPaste: {
|
||||
id: placeholder,
|
||||
startLine: 0,
|
||||
lineCount: 6,
|
||||
prefix: 'prefix ',
|
||||
suffix: ' suffix',
|
||||
},
|
||||
});
|
||||
|
||||
const action: TextBufferAction = {
|
||||
type: 'toggle_paste_expansion',
|
||||
payload: { id: placeholder },
|
||||
payload: { id: placeholder, row: 0, col: 7 },
|
||||
};
|
||||
|
||||
const state = textBufferReducer(expandedState, action);
|
||||
|
||||
expect(state.lines).toEqual(['prefix ' + placeholder + ' suffix']);
|
||||
expect(state.expandedPasteInfo.has(placeholder)).toBe(false);
|
||||
expect(state.expandedPaste).toBeNull();
|
||||
// Cursor should be at the end of the collapsed placeholder
|
||||
expect(state.cursorRow).toBe(0);
|
||||
expect(state.cursorCol).toBe(('prefix ' + placeholder).length);
|
||||
@@ -625,7 +622,7 @@ describe('textBufferReducer', () => {
|
||||
|
||||
const state = textBufferReducer(stateWithPlaceholder, {
|
||||
type: 'toggle_paste_expansion',
|
||||
payload: { id: singleLinePlaceholder },
|
||||
payload: { id: singleLinePlaceholder, row: 0, col: 0 },
|
||||
});
|
||||
|
||||
expect(state.lines).toEqual(['some text']);
|
||||
@@ -636,13 +633,13 @@ describe('textBufferReducer', () => {
|
||||
it('should return current state if placeholder ID not found in pastedContent', () => {
|
||||
const action: TextBufferAction = {
|
||||
type: 'toggle_paste_expansion',
|
||||
payload: { id: 'unknown' },
|
||||
payload: { id: 'unknown', row: 0, col: 0 },
|
||||
};
|
||||
const state = textBufferReducer(initialState, action);
|
||||
expect(state).toBe(initialState);
|
||||
});
|
||||
|
||||
it('should preserve expandedPasteInfo when lines change from edits outside the region', () => {
|
||||
it('should preserve expandedPaste 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({
|
||||
@@ -650,20 +647,16 @@ describe('textBufferReducer', () => {
|
||||
cursorRow: 3,
|
||||
cursorCol: 0,
|
||||
pastedContent: { [placeholder]: 'line1\nline2\nline3' },
|
||||
expandedPasteInfo: new Map([
|
||||
[
|
||||
placeholder,
|
||||
{
|
||||
startLine: 0,
|
||||
lineCount: 3,
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
},
|
||||
],
|
||||
]),
|
||||
expandedPaste: {
|
||||
id: placeholder,
|
||||
startLine: 0,
|
||||
lineCount: 3,
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
},
|
||||
});
|
||||
|
||||
expect(expandedState.expandedPasteInfo.size).toBe(1);
|
||||
expect(expandedState.expandedPaste).not.toBeNull();
|
||||
|
||||
// Insert a newline at the end - this changes lines but is OUTSIDE the expanded region
|
||||
const stateAfterInsert = textBufferReducer(expandedState, {
|
||||
@@ -671,9 +664,9 @@ describe('textBufferReducer', () => {
|
||||
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);
|
||||
// Lines changed, but expandedPaste should be PRESERVED and optionally shifted (no shift here since edit is after)
|
||||
expect(stateAfterInsert.expandedPaste).not.toBeNull();
|
||||
expect(stateAfterInsert.expandedPaste?.id).toBe(placeholder);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2914,9 +2907,9 @@ describe('Transformation Utilities', () => {
|
||||
expect(result).toEqual(transformations[0]);
|
||||
});
|
||||
|
||||
it('should find transformation when cursor is at end', () => {
|
||||
it('should NOT find transformation when cursor is at end', () => {
|
||||
const result = getTransformUnderCursor(0, 14, [transformations]);
|
||||
expect(result).toEqual(transformations[0]);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when cursor is not on a transformation', () => {
|
||||
@@ -2928,6 +2921,22 @@ describe('Transformation Utilities', () => {
|
||||
const result = getTransformUnderCursor(0, 5, []);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('regression: should not find paste transformation when clicking one character after it', () => {
|
||||
const pasteId = '[Pasted Text: 5 lines]';
|
||||
const line = pasteId + ' suffix';
|
||||
const transformations = calculateTransformationsForLine(line);
|
||||
const pasteTransform = transformations.find((t) => t.type === 'paste');
|
||||
expect(pasteTransform).toBeDefined();
|
||||
|
||||
const endPos = pasteTransform!.logEnd;
|
||||
// Position strictly at end should be null
|
||||
expect(getTransformUnderCursor(0, endPos, [transformations])).toBeNull();
|
||||
// Position inside should be found
|
||||
expect(getTransformUnderCursor(0, endPos - 1, [transformations])).toEqual(
|
||||
pasteTransform,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateTransformedLine', () => {
|
||||
@@ -3100,4 +3109,46 @@ describe('Transformation Utilities', () => {
|
||||
expect(result.current.allVisualLines[2]).toBe('line 3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scroll Regressions', () => {
|
||||
const scrollViewport: Viewport = { width: 80, height: 5 };
|
||||
|
||||
it('should not show empty viewport when collapsing a large paste that was scrolled', () => {
|
||||
const largeContent =
|
||||
'line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10';
|
||||
const placeholder = '[Pasted Text: 10 lines]';
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({
|
||||
initialText: placeholder,
|
||||
viewport: scrollViewport,
|
||||
isValidPath: () => false,
|
||||
}),
|
||||
);
|
||||
|
||||
// Setup: paste large content
|
||||
act(() => {
|
||||
result.current.setText('');
|
||||
result.current.insert(largeContent, { paste: true });
|
||||
});
|
||||
|
||||
// Expand it
|
||||
act(() => {
|
||||
result.current.togglePasteExpansion(placeholder, 0, 0);
|
||||
});
|
||||
|
||||
// Verify scrolled state
|
||||
expect(result.current.visualScrollRow).toBe(5);
|
||||
|
||||
// Collapse it
|
||||
act(() => {
|
||||
result.current.togglePasteExpansion(placeholder, 9, 0);
|
||||
});
|
||||
|
||||
// Verify viewport is NOT empty immediately (clamping in useMemo)
|
||||
expect(result.current.allVisualLines.length).toBe(1);
|
||||
expect(result.current.viewportVisualLines.length).toBe(1);
|
||||
expect(result.current.viewportVisualLines[0]).toBe(placeholder);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -586,7 +586,7 @@ interface UndoHistoryEntry {
|
||||
cursorRow: number;
|
||||
cursorCol: number;
|
||||
pastedContent: Record<string, string>;
|
||||
expandedPasteInfo: Map<string, ExpandedPasteInfo>;
|
||||
expandedPaste: ExpandedPasteInfo | null;
|
||||
}
|
||||
|
||||
function calculateInitialCursorPosition(
|
||||
@@ -807,7 +807,7 @@ export function getTransformUnderCursor(
|
||||
const spans = spansByLine[row];
|
||||
if (!spans || spans.length === 0) return null;
|
||||
for (const span of spans) {
|
||||
if (col >= span.logStart && col <= span.logEnd) {
|
||||
if (col >= span.logStart && col < span.logEnd) {
|
||||
return span;
|
||||
}
|
||||
if (col < span.logStart) break;
|
||||
@@ -816,6 +816,7 @@ export function getTransformUnderCursor(
|
||||
}
|
||||
|
||||
export interface ExpandedPasteInfo {
|
||||
id: string;
|
||||
startLine: number;
|
||||
lineCount: number;
|
||||
prefix: string;
|
||||
@@ -828,15 +829,14 @@ export interface ExpandedPasteInfo {
|
||||
*/
|
||||
export function getExpandedPasteAtLine(
|
||||
lineIndex: number,
|
||||
expandedPasteInfo: Map<string, ExpandedPasteInfo>,
|
||||
expandedPaste: ExpandedPasteInfo | null,
|
||||
): string | null {
|
||||
for (const [id, info] of expandedPasteInfo) {
|
||||
if (
|
||||
lineIndex >= info.startLine &&
|
||||
lineIndex < info.startLine + info.lineCount
|
||||
) {
|
||||
return id;
|
||||
}
|
||||
if (
|
||||
expandedPaste &&
|
||||
lineIndex >= expandedPaste.startLine &&
|
||||
lineIndex < expandedPaste.startLine + expandedPaste.lineCount
|
||||
) {
|
||||
return expandedPaste.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -846,55 +846,51 @@ export function getExpandedPasteAtLine(
|
||||
* Adjusts startLine indices and detaches any region that is partially or fully deleted.
|
||||
*/
|
||||
export function shiftExpandedRegions(
|
||||
expandedPasteInfo: Map<string, ExpandedPasteInfo>,
|
||||
expandedPaste: ExpandedPasteInfo | null,
|
||||
changeStartLine: number,
|
||||
lineDelta: number,
|
||||
changeEndLine?: number, // Inclusive
|
||||
): {
|
||||
newInfo: Map<string, ExpandedPasteInfo>;
|
||||
detachedIds: Set<string>;
|
||||
newInfo: ExpandedPasteInfo | null;
|
||||
isDetached: boolean;
|
||||
} {
|
||||
const newInfo = new Map<string, ExpandedPasteInfo>();
|
||||
const detachedIds = new Set<string>();
|
||||
if (expandedPasteInfo.size === 0) return { newInfo, detachedIds };
|
||||
if (!expandedPaste) return { newInfo: null, isDetached: false };
|
||||
|
||||
const effectiveEndLine = changeEndLine ?? changeStartLine;
|
||||
const infoEndLine = expandedPaste.startLine + expandedPaste.lineCount - 1;
|
||||
|
||||
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 >= expandedPaste.startLine;
|
||||
|
||||
// 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);
|
||||
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 > expandedPaste.startLine &&
|
||||
changeStartLine <= infoEndLine)
|
||||
) {
|
||||
return { newInfo: null, isDetached: true };
|
||||
}
|
||||
}
|
||||
|
||||
return { newInfo, detachedIds };
|
||||
// 2. Shift regions that start at or after the change point
|
||||
if (expandedPaste.startLine >= changeStartLine) {
|
||||
return {
|
||||
newInfo: {
|
||||
...expandedPaste,
|
||||
startLine: expandedPaste.startLine + lineDelta,
|
||||
},
|
||||
isDetached: false,
|
||||
};
|
||||
}
|
||||
|
||||
return { newInfo: expandedPaste, isDetached: false };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -905,16 +901,14 @@ export function shiftExpandedRegions(
|
||||
export function detachExpandedPaste(state: TextBufferState): TextBufferState {
|
||||
const expandedId = getExpandedPasteAtLine(
|
||||
state.cursorRow,
|
||||
state.expandedPasteInfo,
|
||||
state.expandedPaste,
|
||||
);
|
||||
if (!expandedId) return state;
|
||||
|
||||
const newExpandedInfo = new Map(state.expandedPasteInfo);
|
||||
newExpandedInfo.delete(expandedId);
|
||||
const { [expandedId]: _, ...newPastedContent } = state.pastedContent;
|
||||
return {
|
||||
...state,
|
||||
expandedPasteInfo: newExpandedInfo,
|
||||
expandedPaste: null,
|
||||
pastedContent: newPastedContent,
|
||||
};
|
||||
}
|
||||
@@ -1377,7 +1371,7 @@ export interface TextBufferState {
|
||||
viewportHeight: number;
|
||||
visualLayout: VisualLayout;
|
||||
pastedContent: Record<string, string>;
|
||||
expandedPasteInfo: Map<string, ExpandedPasteInfo>;
|
||||
expandedPaste: ExpandedPasteInfo | null;
|
||||
}
|
||||
|
||||
const historyLimit = 100;
|
||||
@@ -1388,7 +1382,9 @@ export const pushUndo = (currentState: TextBufferState): TextBufferState => {
|
||||
cursorRow: currentState.cursorRow,
|
||||
cursorCol: currentState.cursorCol,
|
||||
pastedContent: { ...currentState.pastedContent },
|
||||
expandedPasteInfo: new Map(currentState.expandedPasteInfo),
|
||||
expandedPaste: currentState.expandedPaste
|
||||
? { ...currentState.expandedPaste }
|
||||
: null,
|
||||
};
|
||||
const newStack = [...currentState.undoStack, snapshot];
|
||||
if (newStack.length > historyLimit) {
|
||||
@@ -1491,7 +1487,10 @@ export type TextBufferAction =
|
||||
| { type: 'vim_move_to_last_line' }
|
||||
| { type: 'vim_move_to_line'; payload: { lineNumber: number } }
|
||||
| { type: 'vim_escape_insert_mode' }
|
||||
| { type: 'toggle_paste_expansion'; payload: { id: string } };
|
||||
| {
|
||||
type: 'toggle_paste_expansion';
|
||||
payload: { id: string; row: number; col: number };
|
||||
};
|
||||
|
||||
export interface TextBufferOptions {
|
||||
inputFilter?: (text: string) => string;
|
||||
@@ -1595,14 +1594,14 @@ function textBufferReducerLogic(
|
||||
newCursorCol = cpLen(before) + cpLen(parts[0]);
|
||||
}
|
||||
|
||||
const { newInfo: newExpandedInfo, detachedIds } = shiftExpandedRegions(
|
||||
nextState.expandedPasteInfo,
|
||||
const { newInfo: newExpandedPaste, isDetached } = shiftExpandedRegions(
|
||||
nextState.expandedPaste,
|
||||
nextState.cursorRow,
|
||||
lineDelta,
|
||||
);
|
||||
|
||||
for (const id of detachedIds) {
|
||||
delete newPastedContent[id];
|
||||
if (isDetached && newExpandedPaste === null && nextState.expandedPaste) {
|
||||
delete newPastedContent[nextState.expandedPaste.id];
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1612,7 +1611,7 @@ function textBufferReducerLogic(
|
||||
cursorCol: newCursorCol,
|
||||
preferredCol: null,
|
||||
pastedContent: newPastedContent,
|
||||
expandedPasteInfo: newExpandedInfo,
|
||||
expandedPaste: newExpandedPaste,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1700,16 +1699,16 @@ function textBufferReducerLogic(
|
||||
newCursorCol = newCol;
|
||||
}
|
||||
|
||||
const { newInfo: newExpandedInfo, detachedIds } = shiftExpandedRegions(
|
||||
nextState.expandedPasteInfo,
|
||||
const { newInfo: newExpandedPaste, isDetached } = shiftExpandedRegions(
|
||||
nextState.expandedPaste,
|
||||
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];
|
||||
if (isDetached && nextState.expandedPaste) {
|
||||
delete newPastedContent[nextState.expandedPaste.id];
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1719,7 +1718,7 @@ function textBufferReducerLogic(
|
||||
cursorCol: newCursorCol,
|
||||
preferredCol: null,
|
||||
pastedContent: newPastedContent,
|
||||
expandedPasteInfo: newExpandedInfo,
|
||||
expandedPaste: newExpandedPaste,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1969,16 +1968,16 @@ function textBufferReducerLogic(
|
||||
return currentState;
|
||||
}
|
||||
|
||||
const { newInfo: newExpandedInfo, detachedIds } = shiftExpandedRegions(
|
||||
nextState.expandedPasteInfo,
|
||||
const { newInfo: newExpandedPaste, isDetached } = shiftExpandedRegions(
|
||||
nextState.expandedPaste,
|
||||
nextState.cursorRow,
|
||||
lineDelta,
|
||||
nextState.cursorRow + (lineDelta < 0 ? 1 : 0),
|
||||
);
|
||||
|
||||
const newPastedContent = { ...nextState.pastedContent };
|
||||
for (const id of detachedIds) {
|
||||
delete newPastedContent[id];
|
||||
if (isDetached && nextState.expandedPaste) {
|
||||
delete newPastedContent[nextState.expandedPaste.id];
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1986,7 +1985,7 @@ function textBufferReducerLogic(
|
||||
lines: newLines,
|
||||
preferredCol: null,
|
||||
pastedContent: newPastedContent,
|
||||
expandedPasteInfo: newExpandedInfo,
|
||||
expandedPaste: newExpandedPaste,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2121,7 +2120,7 @@ function textBufferReducerLogic(
|
||||
cursorRow: state.cursorRow,
|
||||
cursorCol: state.cursorCol,
|
||||
pastedContent: { ...state.pastedContent },
|
||||
expandedPasteInfo: new Map(state.expandedPasteInfo),
|
||||
expandedPaste: state.expandedPaste ? { ...state.expandedPaste } : null,
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
@@ -2140,7 +2139,7 @@ function textBufferReducerLogic(
|
||||
cursorRow: state.cursorRow,
|
||||
cursorCol: state.cursorCol,
|
||||
pastedContent: { ...state.pastedContent },
|
||||
expandedPasteInfo: new Map(state.expandedPasteInfo),
|
||||
expandedPaste: state.expandedPaste ? { ...state.expandedPaste } : null,
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
@@ -2167,22 +2166,22 @@ function textBufferReducerLogic(
|
||||
newState.lines.length - (nextState.lines.length - oldLineCount);
|
||||
const lineDelta = newLineCount - oldLineCount;
|
||||
|
||||
const { newInfo: newExpandedInfo, detachedIds } = shiftExpandedRegions(
|
||||
nextState.expandedPasteInfo,
|
||||
const { newInfo: newExpandedPaste, isDetached } = shiftExpandedRegions(
|
||||
nextState.expandedPaste,
|
||||
startRow,
|
||||
lineDelta,
|
||||
endRow,
|
||||
);
|
||||
|
||||
const newPastedContent = { ...newState.pastedContent };
|
||||
for (const id of detachedIds) {
|
||||
delete newPastedContent[id];
|
||||
if (isDetached && nextState.expandedPaste) {
|
||||
delete newPastedContent[nextState.expandedPaste.id];
|
||||
}
|
||||
|
||||
return {
|
||||
...newState,
|
||||
pastedContent: newPastedContent,
|
||||
expandedPasteInfo: newExpandedInfo,
|
||||
expandedPaste: newExpandedPaste,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2240,38 +2239,22 @@ function textBufferReducerLogic(
|
||||
return handleVimAction(state, action as VimAction);
|
||||
|
||||
case 'toggle_paste_expansion': {
|
||||
const { id } = action.payload;
|
||||
const info = state.expandedPasteInfo.get(id);
|
||||
const { id, row, col } = action.payload;
|
||||
const expandedPaste = state.expandedPaste;
|
||||
|
||||
if (info) {
|
||||
if (expandedPaste && expandedPaste.id === id) {
|
||||
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,
|
||||
expandedPaste.startLine,
|
||||
expandedPaste.lineCount,
|
||||
expandedPaste.prefix + id + expandedPaste.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);
|
||||
const newCursorRow = expandedPaste.startLine;
|
||||
const newCursorCol = cpLen(expandedPaste.prefix) + cpLen(id);
|
||||
|
||||
return {
|
||||
...nextState,
|
||||
@@ -2279,32 +2262,85 @@ function textBufferReducerLogic(
|
||||
cursorRow: newCursorRow,
|
||||
cursorCol: newCursorCol,
|
||||
preferredCol: null,
|
||||
pastedContent: newPastedContent,
|
||||
expandedPasteInfo: newExpandedInfo,
|
||||
expandedPaste: null,
|
||||
};
|
||||
} else {
|
||||
// EXPAND: Replace placeholder with content
|
||||
const content = state.pastedContent[id];
|
||||
if (!content) return state;
|
||||
|
||||
// Collapse any existing expanded paste first
|
||||
let currentState = state;
|
||||
let targetRow = row;
|
||||
if (state.expandedPaste) {
|
||||
const existingInfo = state.expandedPaste;
|
||||
const lineDelta = 1 - existingInfo.lineCount;
|
||||
|
||||
if (targetRow !== undefined && targetRow > existingInfo.startLine) {
|
||||
// If we collapsed something above our target, our target row shifted up
|
||||
targetRow += lineDelta;
|
||||
}
|
||||
|
||||
currentState = textBufferReducerLogic(state, {
|
||||
type: 'toggle_paste_expansion',
|
||||
payload: {
|
||||
id: existingInfo.id,
|
||||
row: existingInfo.startLine,
|
||||
col: 0,
|
||||
},
|
||||
});
|
||||
// Update transformations because they are needed for finding the next placeholder
|
||||
currentState.transformationsByLine = calculateTransformations(
|
||||
currentState.lines,
|
||||
);
|
||||
}
|
||||
|
||||
const content = currentState.pastedContent[id];
|
||||
if (!content) return currentState;
|
||||
|
||||
// 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,
|
||||
|
||||
const tryFindOnLine = (idx: number) => {
|
||||
const transforms = currentState.transformationsByLine[idx] ?? [];
|
||||
|
||||
// Precise match by col
|
||||
let transform = transforms.find(
|
||||
(t) =>
|
||||
t.type === 'paste' &&
|
||||
t.id === id &&
|
||||
col >= t.logStart &&
|
||||
col <= t.logEnd,
|
||||
);
|
||||
|
||||
if (!transform) {
|
||||
// Fallback to first match on line
|
||||
transform = transforms.find(
|
||||
(t) => t.type === 'paste' && t.id === id,
|
||||
);
|
||||
}
|
||||
|
||||
if (transform) {
|
||||
lineIndex = i;
|
||||
lineIndex = idx;
|
||||
placeholderStart = transform.logStart;
|
||||
break;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Try provided row first for precise targeting
|
||||
if (targetRow >= 0 && targetRow < currentState.lines.length) {
|
||||
tryFindOnLine(targetRow);
|
||||
}
|
||||
|
||||
if (lineIndex === -1) {
|
||||
for (let i = 0; i < currentState.lines.length; i++) {
|
||||
if (tryFindOnLine(i)) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (lineIndex === -1) return state;
|
||||
if (lineIndex === -1) return currentState;
|
||||
|
||||
const nextState = pushUndoLocal(state);
|
||||
const nextState = pushUndoLocal(currentState);
|
||||
|
||||
const line = nextState.lines[lineIndex];
|
||||
const prefix = cpSlice(line, 0, placeholderStart);
|
||||
@@ -2329,26 +2365,6 @@ function textBufferReducerLogic(
|
||||
|
||||
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];
|
||||
@@ -2360,8 +2376,13 @@ function textBufferReducerLogic(
|
||||
cursorRow: newCursorRow,
|
||||
cursorCol: newCursorCol,
|
||||
preferredCol: null,
|
||||
pastedContent: newPastedContent,
|
||||
expandedPasteInfo: newExpandedInfo,
|
||||
expandedPaste: {
|
||||
id,
|
||||
startLine: lineIndex,
|
||||
lineCount: expandedLines.length,
|
||||
prefix,
|
||||
suffix,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2468,7 +2489,7 @@ export function useTextBuffer({
|
||||
viewportHeight: viewport.height,
|
||||
visualLayout,
|
||||
pastedContent: {},
|
||||
expandedPasteInfo: new Map(),
|
||||
expandedPaste: null,
|
||||
};
|
||||
}, [initialText, initialCursorOffset, viewport.width, viewport.height]);
|
||||
|
||||
@@ -2486,7 +2507,7 @@ export function useTextBuffer({
|
||||
visualLayout,
|
||||
transformationsByLine,
|
||||
pastedContent,
|
||||
expandedPasteInfo,
|
||||
expandedPaste,
|
||||
} = state;
|
||||
|
||||
const text = useMemo(() => lines.join('\n'), [lines]);
|
||||
@@ -2503,7 +2524,7 @@ export function useTextBuffer({
|
||||
visualToTransformedMap,
|
||||
} = visualLayout;
|
||||
|
||||
const [visualScrollRow, setVisualScrollRow] = useState<number>(0);
|
||||
const [scrollRowState, setScrollRowState] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (onChange) {
|
||||
@@ -2523,11 +2544,11 @@ export function useTextBuffer({
|
||||
const { height } = viewport;
|
||||
const totalVisualLines = visualLines.length;
|
||||
const maxScrollStart = Math.max(0, totalVisualLines - height);
|
||||
let newVisualScrollRow = visualScrollRow;
|
||||
let newVisualScrollRow = scrollRowState;
|
||||
|
||||
if (visualCursor[0] < visualScrollRow) {
|
||||
if (visualCursor[0] < scrollRowState) {
|
||||
newVisualScrollRow = visualCursor[0];
|
||||
} else if (visualCursor[0] >= visualScrollRow + height) {
|
||||
} else if (visualCursor[0] >= scrollRowState + height) {
|
||||
newVisualScrollRow = visualCursor[0] - height + 1;
|
||||
}
|
||||
|
||||
@@ -2535,10 +2556,10 @@ export function useTextBuffer({
|
||||
// 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);
|
||||
if (newVisualScrollRow !== scrollRowState) {
|
||||
setScrollRowState(newVisualScrollRow);
|
||||
}
|
||||
}, [visualCursor, visualScrollRow, viewport, visualLines.length]);
|
||||
}, [visualCursor, scrollRowState, viewport, visualLines.length]);
|
||||
|
||||
const insert = useCallback(
|
||||
(ch: string, { paste = false }: { paste?: boolean } = {}): void => {
|
||||
@@ -2881,6 +2902,14 @@ export function useTextBuffer({
|
||||
],
|
||||
);
|
||||
|
||||
const visualScrollRow = useMemo(() => {
|
||||
const totalVisualLines = visualLines.length;
|
||||
return Math.min(
|
||||
scrollRowState,
|
||||
Math.max(0, totalVisualLines - viewport.height),
|
||||
);
|
||||
}, [visualLines.length, scrollRowState, viewport.height]);
|
||||
|
||||
const renderedVisualLines = useMemo(
|
||||
() => visualLines.slice(visualScrollRow, visualScrollRow + viewport.height),
|
||||
[visualLines, visualScrollRow, viewport.height],
|
||||
@@ -3049,14 +3078,17 @@ export function useTextBuffer({
|
||||
[lines, cursorRow, cursorCol],
|
||||
);
|
||||
|
||||
const togglePasteExpansion = useCallback((id: string): void => {
|
||||
dispatch({ type: 'toggle_paste_expansion', payload: { id } });
|
||||
}, []);
|
||||
const togglePasteExpansion = useCallback(
|
||||
(id: string, row: number, col: number): void => {
|
||||
dispatch({ type: 'toggle_paste_expansion', payload: { id, row, col } });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const getExpandedPasteAtLineCallback = useCallback(
|
||||
(lineIndex: number): string | null =>
|
||||
getExpandedPasteAtLine(lineIndex, expandedPasteInfo),
|
||||
[expandedPasteInfo],
|
||||
getExpandedPasteAtLine(lineIndex, expandedPaste),
|
||||
[expandedPaste],
|
||||
);
|
||||
|
||||
const returnValue: TextBuffer = useMemo(
|
||||
@@ -3093,7 +3125,7 @@ export function useTextBuffer({
|
||||
getLogicalPositionFromVisual,
|
||||
getExpandedPasteAtLine: getExpandedPasteAtLineCallback,
|
||||
togglePasteExpansion,
|
||||
expandedPasteInfo,
|
||||
expandedPaste,
|
||||
deleteWordLeft,
|
||||
deleteWordRight,
|
||||
|
||||
@@ -3168,7 +3200,7 @@ export function useTextBuffer({
|
||||
getLogicalPositionFromVisual,
|
||||
getExpandedPasteAtLineCallback,
|
||||
togglePasteExpansion,
|
||||
expandedPasteInfo,
|
||||
expandedPaste,
|
||||
deleteWordLeft,
|
||||
deleteWordRight,
|
||||
killLineRight,
|
||||
@@ -3356,11 +3388,11 @@ export interface TextBuffer {
|
||||
* If collapsed, expands to show full content inline.
|
||||
* If expanded, collapses back to placeholder.
|
||||
*/
|
||||
togglePasteExpansion(id: string): void;
|
||||
togglePasteExpansion(id: string, row: number, col: number): void;
|
||||
/**
|
||||
* The current expanded paste info map (read-only).
|
||||
* The current expanded paste info (read-only).
|
||||
*/
|
||||
expandedPasteInfo: Map<string, ExpandedPasteInfo>;
|
||||
expandedPaste: ExpandedPasteInfo | null;
|
||||
|
||||
// Vim-specific operations
|
||||
/**
|
||||
|
||||
@@ -35,7 +35,7 @@ const createTestState = (
|
||||
transformationsByLine: [[]],
|
||||
visualLayout: defaultVisualLayout,
|
||||
pastedContent: {},
|
||||
expandedPasteInfo: new Map(),
|
||||
expandedPaste: null,
|
||||
});
|
||||
|
||||
describe('vim-buffer-actions', () => {
|
||||
@@ -912,7 +912,7 @@ describe('vim-buffer-actions', () => {
|
||||
cursorRow: 0,
|
||||
cursorCol: 0,
|
||||
pastedContent: {},
|
||||
expandedPasteInfo: new Map(),
|
||||
expandedPaste: null,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -229,4 +229,88 @@ describe('MouseContext', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit a double-click event when two left-presses occur quickly at the same position', () => {
|
||||
const handler = vi.fn();
|
||||
const { result } = renderHook(() => useMouseContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(handler);
|
||||
});
|
||||
|
||||
// First click
|
||||
act(() => {
|
||||
stdin.write('\x1b[<0;10;20M');
|
||||
});
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ name: 'left-press', col: 10, row: 20 }),
|
||||
);
|
||||
|
||||
// Second click (within threshold)
|
||||
act(() => {
|
||||
stdin.write('\x1b[<0;10;20M');
|
||||
});
|
||||
|
||||
// Should have called for the second left-press AND the double-click
|
||||
expect(handler).toHaveBeenCalledTimes(3);
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'double-click', col: 10, row: 20 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT emit a double-click event if clicks are too far apart', () => {
|
||||
const handler = vi.fn();
|
||||
const { result } = renderHook(() => useMouseContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(handler);
|
||||
});
|
||||
|
||||
// First click
|
||||
act(() => {
|
||||
stdin.write('\x1b[<0;10;20M');
|
||||
});
|
||||
|
||||
// Second click (too far)
|
||||
act(() => {
|
||||
stdin.write('\x1b[<0;15;25M');
|
||||
});
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
expect(handler).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'double-click' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT emit a double-click event if too much time passes', async () => {
|
||||
vi.useFakeTimers();
|
||||
const handler = vi.fn();
|
||||
const { result } = renderHook(() => useMouseContext(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.subscribe(handler);
|
||||
});
|
||||
|
||||
// First click
|
||||
act(() => {
|
||||
stdin.write('\x1b[<0;10;20M');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(500); // Threshold is 400ms
|
||||
});
|
||||
|
||||
// Second click
|
||||
act(() => {
|
||||
stdin.write('\x1b[<0;10;20M');
|
||||
});
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(2);
|
||||
expect(handler).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'double-click' }),
|
||||
);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
type MouseEvent,
|
||||
type MouseEventName,
|
||||
type MouseHandler,
|
||||
DOUBLE_CLICK_THRESHOLD_MS,
|
||||
DOUBLE_CLICK_DISTANCE_TOLERANCE,
|
||||
} from '../utils/mouse.js';
|
||||
|
||||
export type { MouseEvent, MouseEventName, MouseHandler };
|
||||
@@ -67,6 +69,11 @@ export function MouseProvider({
|
||||
}) {
|
||||
const { stdin } = useStdin();
|
||||
const subscribers = useRef<Set<MouseHandler>>(new Set()).current;
|
||||
const lastClickRef = useRef<{
|
||||
time: number;
|
||||
col: number;
|
||||
row: number;
|
||||
} | null>(null);
|
||||
|
||||
const subscribe = useCallback(
|
||||
(handler: MouseHandler) => {
|
||||
@@ -96,6 +103,30 @@ export function MouseProvider({
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.name === 'left-press') {
|
||||
const now = Date.now();
|
||||
const lastClick = lastClickRef.current;
|
||||
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
|
||||
) {
|
||||
const doubleClickEvent: MouseEvent = {
|
||||
...event,
|
||||
name: 'double-click',
|
||||
};
|
||||
for (const handler of subscribers) {
|
||||
handler(doubleClickEvent);
|
||||
}
|
||||
lastClickRef.current = null;
|
||||
} else {
|
||||
lastClickRef.current = { time: now, col: event.col, row: event.row };
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!handled &&
|
||||
event.name === 'move' &&
|
||||
|
||||
@@ -6,18 +6,30 @@
|
||||
|
||||
import { getBoundingBox, type DOMElement } from 'ink';
|
||||
import type React from 'react';
|
||||
import { useMouse, type MouseEvent } from '../contexts/MouseContext.js';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import {
|
||||
useMouse,
|
||||
type MouseEvent,
|
||||
type MouseEventName,
|
||||
} from '../contexts/MouseContext.js';
|
||||
|
||||
export const useMouseClick = (
|
||||
containerRef: React.RefObject<DOMElement | null>,
|
||||
handler: (event: MouseEvent, relativeX: number, relativeY: number) => void,
|
||||
options: { isActive?: boolean; button?: 'left' | 'right' } = {},
|
||||
options: {
|
||||
isActive?: boolean;
|
||||
button?: 'left' | 'right';
|
||||
name?: MouseEventName;
|
||||
} = {},
|
||||
) => {
|
||||
const { isActive = true, button = 'left' } = options;
|
||||
const { isActive = true, button = 'left', name } = options;
|
||||
const handlerRef = useRef(handler);
|
||||
handlerRef.current = handler;
|
||||
|
||||
useMouse(
|
||||
const onMouse = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
const eventName = button === 'left' ? 'left-press' : 'right-release';
|
||||
const eventName =
|
||||
name ?? (button === 'left' ? 'left-press' : 'right-release');
|
||||
if (event.name === eventName && containerRef.current) {
|
||||
const { x, y, width, height } = getBoundingBox(containerRef.current);
|
||||
// Terminal mouse events are 1-based, Ink layout is 0-based.
|
||||
@@ -33,10 +45,12 @@ export const useMouseClick = (
|
||||
relativeY >= 0 &&
|
||||
relativeY < height
|
||||
) {
|
||||
handler(event, relativeX, relativeY);
|
||||
handlerRef.current(event, relativeX, relativeY);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive },
|
||||
[containerRef, button, name],
|
||||
);
|
||||
|
||||
useMouse(onMouse, { isActive });
|
||||
};
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
/**
|
||||
* @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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* @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,7 +68,7 @@ const createMockTextBufferState = (
|
||||
visualToTransformedMap: [],
|
||||
},
|
||||
pastedContent: {},
|
||||
expandedPasteInfo: new Map(),
|
||||
expandedPaste: null,
|
||||
...partial,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -24,7 +24,11 @@ export type MouseEventName =
|
||||
| 'scroll-down'
|
||||
| 'scroll-left'
|
||||
| 'scroll-right'
|
||||
| 'move';
|
||||
| 'move'
|
||||
| 'double-click';
|
||||
|
||||
export const DOUBLE_CLICK_THRESHOLD_MS = 400;
|
||||
export const DOUBLE_CLICK_DISTANCE_TOLERANCE = 2;
|
||||
|
||||
export interface MouseEvent {
|
||||
name: MouseEventName;
|
||||
|
||||
Reference in New Issue
Block a user