mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-08 20:30:53 -07:00
feat: add double-click to expand/collapse large paste placeholders (#17471)
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user