| |
| |
| |
| |
| |
|
|
| import fs from 'node:fs'; |
| import os from 'node:os'; |
| import pathMod from 'node:path'; |
| import * as path from 'node:path'; |
| import { useState, useCallback, useEffect, useMemo, useReducer } from 'react'; |
| import { LRUCache } from 'mnemonist'; |
| import { |
| coreEvents, |
| debugLogger, |
| getErrorMessage, |
| unescapePath, |
| type EditorType, |
| } from '@google/gemini-cli-core'; |
| import { |
| toCodePoints, |
| cpLen, |
| cpSlice, |
| stripUnsafeCharacters, |
| getCachedStringWidth, |
| } from '../../utils/textUtils.js'; |
| import { parsePastedPaths } from '../../utils/clipboardUtils.js'; |
| import type { Key } from '../../contexts/KeypressContext.js'; |
| import { Command } from '../../key/keyMatchers.js'; |
| import type { VimAction } from './vim-buffer-actions.js'; |
| import { handleVimAction } from './vim-buffer-actions.js'; |
| import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../../constants.js'; |
| import { openFileInEditor } from '../../utils/editorUtils.js'; |
| import { useSettings } from '../../contexts/SettingsContext.js'; |
| import { useKeyMatchers } from '../../hooks/useKeyMatchers.js'; |
|
|
| export const LARGE_PASTE_LINE_THRESHOLD = 5; |
| export const LARGE_PASTE_CHAR_THRESHOLD = 500; |
|
|
| |
| export const PASTED_TEXT_PLACEHOLDER_REGEX = |
| /\[Pasted Text: \d+ (?:lines|chars)(?: #\d+)?\]/g; |
|
|
| |
| export function expandPastePlaceholders( |
| text: string, |
| pastedContent: Record<string, string>, |
| ): string { |
| return text.replace( |
| PASTED_TEXT_PLACEHOLDER_REGEX, |
| (match) => pastedContent[match] || match, |
| ); |
| } |
|
|
| export type Direction = |
| | 'left' |
| | 'right' |
| | 'up' |
| | 'down' |
| | 'wordLeft' |
| | 'wordRight' |
| | 'home' |
| | 'end'; |
|
|
| |
| export const isWordCharStrict = (char: string): boolean => |
| /[\w\p{L}\p{N}]/u.test(char); |
|
|
| export const isWhitespace = (char: string): boolean => /\s/.test(char); |
|
|
| |
| export const isCombiningMark = (char: string): boolean => /\p{M}/u.test(char); |
|
|
| |
| export const isWordCharWithCombining = (char: string): boolean => |
| isWordCharStrict(char) || isCombiningMark(char); |
|
|
| |
| export const getCharScript = (char: string): string => { |
| if (/[\p{Script=Latin}]/u.test(char)) return 'latin'; |
| if (/[\p{Script=Han}]/u.test(char)) return 'han'; |
| if (/[\p{Script=Arabic}]/u.test(char)) return 'arabic'; |
| if (/[\p{Script=Hiragana}]/u.test(char)) return 'hiragana'; |
| if (/[\p{Script=Katakana}]/u.test(char)) return 'katakana'; |
| if (/[\p{Script=Cyrillic}]/u.test(char)) return 'cyrillic'; |
| return 'other'; |
| }; |
|
|
| |
| export const isDifferentScript = (char1: string, char2: string): boolean => { |
| if (!isWordCharStrict(char1) || !isWordCharStrict(char2)) return false; |
| return getCharScript(char1) !== getCharScript(char2); |
| }; |
|
|
| |
| export const findNextWordStartInLine = ( |
| line: string, |
| col: number, |
| ): number | null => { |
| const chars = toCodePoints(line); |
| let i = col; |
|
|
| if (i >= chars.length) return null; |
|
|
| const currentChar = chars[i]; |
|
|
| |
| if (isWordCharStrict(currentChar)) { |
| while (i < chars.length && isWordCharWithCombining(chars[i])) { |
| |
| if ( |
| i + 1 < chars.length && |
| isWordCharStrict(chars[i + 1]) && |
| isDifferentScript(chars[i], chars[i + 1]) |
| ) { |
| i++; |
| break; |
| } |
| i++; |
| } |
| } else if (!isWhitespace(currentChar)) { |
| while ( |
| i < chars.length && |
| !isWordCharStrict(chars[i]) && |
| !isWhitespace(chars[i]) |
| ) { |
| i++; |
| } |
| } |
|
|
| |
| while (i < chars.length && isWhitespace(chars[i])) { |
| i++; |
| } |
|
|
| return i < chars.length ? i : null; |
| }; |
|
|
| |
| export const findPrevWordStartInLine = ( |
| line: string, |
| col: number, |
| ): number | null => { |
| const chars = toCodePoints(line); |
| let i = col; |
|
|
| if (i <= 0) return null; |
|
|
| i--; |
|
|
| |
| while (i >= 0 && isWhitespace(chars[i])) { |
| i--; |
| } |
|
|
| if (i < 0) return null; |
|
|
| if (isWordCharStrict(chars[i])) { |
| |
| while (i >= 0 && isWordCharStrict(chars[i])) { |
| |
| if ( |
| i - 1 >= 0 && |
| isWordCharStrict(chars[i - 1]) && |
| isDifferentScript(chars[i], chars[i - 1]) |
| ) { |
| return i; |
| } |
| i--; |
| } |
| return i + 1; |
| } else { |
| |
| while (i >= 0 && !isWordCharStrict(chars[i]) && !isWhitespace(chars[i])) { |
| i--; |
| } |
| return i + 1; |
| } |
| }; |
|
|
| |
| export const findWordEndInLine = (line: string, col: number): number | null => { |
| const chars = toCodePoints(line); |
| let i = col; |
|
|
| |
| |
| let nextBaseCharIdx = i + 1; |
| while ( |
| nextBaseCharIdx < chars.length && |
| isCombiningMark(chars[nextBaseCharIdx]) |
| ) { |
| nextBaseCharIdx++; |
| } |
|
|
| const atEndOfWordChar = |
| i < chars.length && |
| isWordCharWithCombining(chars[i]) && |
| (nextBaseCharIdx >= chars.length || |
| !isWordCharStrict(chars[nextBaseCharIdx]) || |
| (isWordCharStrict(chars[i]) && |
| isDifferentScript(chars[i], chars[nextBaseCharIdx]))); |
|
|
| const atEndOfPunctuation = |
| i < chars.length && |
| !isWordCharWithCombining(chars[i]) && |
| !isWhitespace(chars[i]) && |
| (i + 1 >= chars.length || |
| isWhitespace(chars[i + 1]) || |
| isWordCharWithCombining(chars[i + 1])); |
|
|
| if (atEndOfWordChar || atEndOfPunctuation) { |
| |
| i++; |
| |
| while (i < chars.length && isCombiningMark(chars[i])) { |
| i++; |
| } |
| |
| while (i < chars.length && isWhitespace(chars[i])) { |
| i++; |
| } |
| } |
|
|
| |
| if (i < chars.length && !isWordCharWithCombining(chars[i])) { |
| |
| while (i < chars.length && isWhitespace(chars[i])) { |
| i++; |
| } |
| } |
|
|
| |
| let foundWord = false; |
| let lastBaseCharPos = -1; |
|
|
| if (i < chars.length && isWordCharWithCombining(chars[i])) { |
| |
| while (i < chars.length && isWordCharWithCombining(chars[i])) { |
| foundWord = true; |
|
|
| |
| if (isWordCharStrict(chars[i])) { |
| lastBaseCharPos = i; |
| } |
|
|
| |
| if ( |
| i + 1 < chars.length && |
| isWordCharStrict(chars[i + 1]) && |
| isDifferentScript(chars[i], chars[i + 1]) |
| ) { |
| i++; |
| if (isWordCharStrict(chars[i - 1])) { |
| lastBaseCharPos = i - 1; |
| } |
| break; |
| } |
|
|
| i++; |
| } |
| } else if (i < chars.length && !isWhitespace(chars[i])) { |
| |
| while ( |
| i < chars.length && |
| !isWordCharStrict(chars[i]) && |
| !isWhitespace(chars[i]) |
| ) { |
| foundWord = true; |
| lastBaseCharPos = i; |
| i++; |
| } |
| } |
|
|
| |
| |
| if (foundWord && lastBaseCharPos >= col) { |
| return lastBaseCharPos; |
| } |
|
|
| return null; |
| }; |
|
|
| |
| export const findNextBigWordStartInLine = ( |
| line: string, |
| col: number, |
| ): number | null => { |
| const chars = toCodePoints(line); |
| let i = col; |
|
|
| if (i >= chars.length) return null; |
|
|
| |
| if (!isWhitespace(chars[i])) { |
| while (i < chars.length && !isWhitespace(chars[i])) { |
| i++; |
| } |
| } |
|
|
| |
| while (i < chars.length && isWhitespace(chars[i])) { |
| i++; |
| } |
|
|
| return i < chars.length ? i : null; |
| }; |
|
|
| |
| export const findPrevBigWordStartInLine = ( |
| line: string, |
| col: number, |
| ): number | null => { |
| const chars = toCodePoints(line); |
| let i = col; |
|
|
| if (i <= 0) return null; |
|
|
| i--; |
|
|
| |
| while (i >= 0 && isWhitespace(chars[i])) { |
| i--; |
| } |
|
|
| if (i < 0) return null; |
|
|
| |
| while (i >= 0 && !isWhitespace(chars[i])) { |
| i--; |
| } |
| return i + 1; |
| }; |
|
|
| |
| export const findBigWordEndInLine = ( |
| line: string, |
| col: number, |
| ): number | null => { |
| const chars = toCodePoints(line); |
| let i = col; |
|
|
| |
| const atEndOfBigWord = |
| i < chars.length && |
| !isWhitespace(chars[i]) && |
| (i + 1 >= chars.length || isWhitespace(chars[i + 1])); |
|
|
| if (atEndOfBigWord) { |
| i++; |
| } |
|
|
| |
| while (i < chars.length && isWhitespace(chars[i])) { |
| i++; |
| } |
|
|
| |
| if (i < chars.length && !isWhitespace(chars[i])) { |
| while (i < chars.length && !isWhitespace(chars[i])) { |
| i++; |
| } |
| return i - 1; |
| } |
|
|
| return null; |
| }; |
|
|
| |
| const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' }); |
|
|
| function findPrevWordBoundary(line: string, cursorCol: number): number { |
| const codePoints = toCodePoints(line); |
| |
| const prefix = codePoints.slice(0, cursorCol).join(''); |
| const cursorIdx = prefix.length; |
|
|
| let targetIdx = 0; |
|
|
| for (const seg of segmenter.segment(line)) { |
| |
| |
| if (seg.index >= cursorIdx) break; |
|
|
| if (seg.isWordLike) { |
| targetIdx = seg.index; |
| } |
| } |
|
|
| return toCodePoints(line.slice(0, targetIdx)).length; |
| } |
|
|
| function findNextWordBoundary(line: string, cursorCol: number): number { |
| const codePoints = toCodePoints(line); |
| const prefix = codePoints.slice(0, cursorCol).join(''); |
| const cursorIdx = prefix.length; |
|
|
| let targetIdx = line.length; |
|
|
| for (const seg of segmenter.segment(line)) { |
| const segEnd = seg.index + seg.segment.length; |
|
|
| if (segEnd > cursorIdx) { |
| if (seg.isWordLike) { |
| targetIdx = segEnd; |
| break; |
| } |
| } |
| } |
|
|
| return toCodePoints(line.slice(0, targetIdx)).length; |
| } |
|
|
| |
| export const findNextWordAcrossLines = ( |
| lines: string[], |
| cursorRow: number, |
| cursorCol: number, |
| searchForWordStart: boolean, |
| ): { row: number; col: number } | null => { |
| |
| const currentLine = lines[cursorRow] || ''; |
| const colInCurrentLine = searchForWordStart |
| ? findNextWordStartInLine(currentLine, cursorCol) |
| : findWordEndInLine(currentLine, cursorCol); |
|
|
| if (colInCurrentLine !== null) { |
| return { row: cursorRow, col: colInCurrentLine }; |
| } |
|
|
| let firstEmptyRow: number | null = null; |
|
|
| |
| for (let row = cursorRow + 1; row < lines.length; row++) { |
| const line = lines[row] || ''; |
| const chars = toCodePoints(line); |
|
|
| |
| if (chars.length === 0) { |
| if (firstEmptyRow === null) { |
| firstEmptyRow = row; |
| } |
| continue; |
| } |
|
|
| |
| let firstNonWhitespace = 0; |
| while ( |
| firstNonWhitespace < chars.length && |
| isWhitespace(chars[firstNonWhitespace]) |
| ) { |
| firstNonWhitespace++; |
| } |
|
|
| if (firstNonWhitespace < chars.length) { |
| if (searchForWordStart) { |
| return { row, col: firstNonWhitespace }; |
| } else { |
| |
| const endCol = findWordEndInLine(line, firstNonWhitespace); |
| if (endCol !== null) { |
| return { row, col: endCol }; |
| } |
| } |
| } |
| } |
|
|
| |
| if (firstEmptyRow !== null) { |
| return { row: firstEmptyRow, col: 0 }; |
| } |
|
|
| return null; |
| }; |
|
|
| |
| export const findPrevWordAcrossLines = ( |
| lines: string[], |
| cursorRow: number, |
| cursorCol: number, |
| ): { row: number; col: number } | null => { |
| |
| const currentLine = lines[cursorRow] || ''; |
| const colInCurrentLine = findPrevWordStartInLine(currentLine, cursorCol); |
|
|
| if (colInCurrentLine !== null) { |
| return { row: cursorRow, col: colInCurrentLine }; |
| } |
|
|
| |
| for (let row = cursorRow - 1; row >= 0; row--) { |
| const line = lines[row] || ''; |
| const chars = toCodePoints(line); |
|
|
| if (chars.length === 0) continue; |
|
|
| |
| let lastWordStart = chars.length; |
| while (lastWordStart > 0 && isWhitespace(chars[lastWordStart - 1])) { |
| lastWordStart--; |
| } |
|
|
| if (lastWordStart > 0) { |
| |
| const wordStart = findPrevWordStartInLine(line, lastWordStart); |
| if (wordStart !== null) { |
| return { row, col: wordStart }; |
| } |
| } |
| } |
|
|
| return null; |
| }; |
|
|
| |
| export const findNextBigWordAcrossLines = ( |
| lines: string[], |
| cursorRow: number, |
| cursorCol: number, |
| searchForWordStart: boolean, |
| ): { row: number; col: number } | null => { |
| |
| const currentLine = lines[cursorRow] || ''; |
| const colInCurrentLine = searchForWordStart |
| ? findNextBigWordStartInLine(currentLine, cursorCol) |
| : findBigWordEndInLine(currentLine, cursorCol); |
|
|
| if (colInCurrentLine !== null) { |
| return { row: cursorRow, col: colInCurrentLine }; |
| } |
|
|
| let firstEmptyRow: number | null = null; |
|
|
| |
| for (let row = cursorRow + 1; row < lines.length; row++) { |
| const line = lines[row] || ''; |
| const chars = toCodePoints(line); |
|
|
| |
| if (chars.length === 0) { |
| if (firstEmptyRow === null) { |
| firstEmptyRow = row; |
| } |
| continue; |
| } |
|
|
| |
| let firstNonWhitespace = 0; |
| while ( |
| firstNonWhitespace < chars.length && |
| isWhitespace(chars[firstNonWhitespace]) |
| ) { |
| firstNonWhitespace++; |
| } |
|
|
| if (firstNonWhitespace < chars.length) { |
| |
| if (searchForWordStart) { |
| return { row, col: firstNonWhitespace }; |
| } else { |
| const endCol = findBigWordEndInLine(line, firstNonWhitespace); |
| if (endCol !== null) { |
| return { row, col: endCol }; |
| } |
| } |
| } |
| } |
|
|
| |
| if (firstEmptyRow !== null) { |
| return { row: firstEmptyRow, col: 0 }; |
| } |
|
|
| return null; |
| }; |
|
|
| |
| export const findPrevBigWordAcrossLines = ( |
| lines: string[], |
| cursorRow: number, |
| cursorCol: number, |
| ): { row: number; col: number } | null => { |
| |
| const currentLine = lines[cursorRow] || ''; |
| const colInCurrentLine = findPrevBigWordStartInLine(currentLine, cursorCol); |
|
|
| if (colInCurrentLine !== null) { |
| return { row: cursorRow, col: colInCurrentLine }; |
| } |
|
|
| |
| for (let row = cursorRow - 1; row >= 0; row--) { |
| const line = lines[row] || ''; |
| const chars = toCodePoints(line); |
|
|
| if (chars.length === 0) continue; |
|
|
| |
| let lastWordStart = chars.length; |
| while (lastWordStart > 0 && isWhitespace(chars[lastWordStart - 1])) { |
| lastWordStart--; |
| } |
|
|
| if (lastWordStart > 0) { |
| const wordStart = findPrevBigWordStartInLine(line, lastWordStart); |
| if (wordStart !== null) { |
| return { row, col: wordStart }; |
| } |
| } |
| } |
|
|
| return null; |
| }; |
|
|
| |
| export const getPositionFromOffsets = ( |
| startOffset: number, |
| endOffset: number, |
| lines: string[], |
| ) => { |
| let offset = 0; |
| let startRow = 0; |
| let startCol = 0; |
| let endRow = 0; |
| let endCol = 0; |
|
|
| |
| for (let i = 0; i < lines.length; i++) { |
| const lineLength = lines[i].length + 1; |
| if (offset + lineLength > startOffset) { |
| startRow = i; |
| startCol = startOffset - offset; |
| break; |
| } |
| offset += lineLength; |
| } |
|
|
| |
| offset = 0; |
| for (let i = 0; i < lines.length; i++) { |
| const lineLength = lines[i].length + (i < lines.length - 1 ? 1 : 0); |
| if (offset + lineLength >= endOffset) { |
| endRow = i; |
| endCol = endOffset - offset; |
| break; |
| } |
| offset += lineLength; |
| } |
|
|
| return { startRow, startCol, endRow, endCol }; |
| }; |
|
|
| export const getLineRangeOffsets = ( |
| startRow: number, |
| lineCount: number, |
| lines: string[], |
| ) => { |
| let startOffset = 0; |
|
|
| |
| for (let i = 0; i < startRow; i++) { |
| startOffset += lines[i].length + 1; |
| } |
|
|
| |
| let endOffset = startOffset; |
| for (let i = 0; i < lineCount; i++) { |
| const lineIndex = startRow + i; |
| if (lineIndex < lines.length) { |
| endOffset += lines[lineIndex].length; |
| if (lineIndex < lines.length - 1) { |
| endOffset += 1; |
| } |
| } |
| } |
|
|
| return { startOffset, endOffset }; |
| }; |
|
|
| export const replaceRangeInternal = ( |
| state: TextBufferState, |
| startRow: number, |
| startCol: number, |
| endRow: number, |
| endCol: number, |
| text: string, |
| ): TextBufferState => { |
| const currentLine = (row: number) => state.lines[row] || ''; |
| const currentLineLen = (row: number) => cpLen(currentLine(row)); |
| const clamp = (value: number, min: number, max: number) => |
| Math.min(Math.max(value, min), max); |
|
|
| if ( |
| startRow > endRow || |
| (startRow === endRow && startCol > endCol) || |
| startRow < 0 || |
| startCol < 0 || |
| endRow >= state.lines.length || |
| (endRow < state.lines.length && endCol > currentLineLen(endRow)) |
| ) { |
| return state; |
| } |
|
|
| const newLines = [...state.lines]; |
|
|
| const sCol = clamp(startCol, 0, currentLineLen(startRow)); |
| const eCol = clamp(endCol, 0, currentLineLen(endRow)); |
|
|
| const prefix = cpSlice(currentLine(startRow), 0, sCol); |
| const suffix = cpSlice(currentLine(endRow), eCol); |
|
|
| const normalisedReplacement = text |
| .replace(/\r\n/g, '\n') |
| .replace(/\r/g, '\n'); |
| const replacementParts = normalisedReplacement.split('\n'); |
|
|
| |
| const firstLine = prefix + replacementParts[0]; |
|
|
| if (replacementParts.length === 1) { |
| |
| newLines.splice(startRow, endRow - startRow + 1, firstLine + suffix); |
| } else { |
| |
| const lastLine = replacementParts[replacementParts.length - 1] + suffix; |
| const middleLines = replacementParts.slice(1, -1); |
| newLines.splice( |
| startRow, |
| endRow - startRow + 1, |
| firstLine, |
| ...middleLines, |
| lastLine, |
| ); |
| } |
|
|
| const finalCursorRow = startRow + replacementParts.length - 1; |
| const finalCursorCol = |
| (replacementParts.length > 1 ? 0 : sCol) + |
| cpLen(replacementParts[replacementParts.length - 1]); |
|
|
| return { |
| ...state, |
| lines: newLines, |
| cursorRow: Math.min(Math.max(finalCursorRow, 0), newLines.length - 1), |
| cursorCol: Math.max( |
| 0, |
| Math.min(finalCursorCol, cpLen(newLines[finalCursorRow] || '')), |
| ), |
| preferredCol: null, |
| }; |
| }; |
|
|
| export interface Viewport { |
| height: number; |
| width: number; |
| } |
|
|
| function clamp(v: number, min: number, max: number): number { |
| return v < min ? min : v > max ? max : v; |
| } |
|
|
| |
|
|
| interface UseTextBufferProps { |
| initialText?: string; |
| initialCursorOffset?: number; |
| viewport: Viewport; |
| stdin?: NodeJS.ReadStream | null; |
| setRawMode?: (mode: boolean) => void; |
| onChange?: (text: string) => void; |
| escapePastedPaths?: boolean; |
| shellModeActive?: boolean; |
| inputFilter?: (text: string) => string; |
| singleLine?: boolean; |
| getPreferredEditor?: () => EditorType | undefined; |
| } |
|
|
| interface UndoHistoryEntry { |
| lines: string[]; |
| cursorRow: number; |
| cursorCol: number; |
| pastedContent: Record<string, string>; |
| expandedPaste: ExpandedPasteInfo | null; |
| } |
|
|
| function calculateInitialCursorPosition( |
| initialLines: string[], |
| offset: number, |
| ): [number, number] { |
| let remainingChars = offset; |
| let row = 0; |
| while (row < initialLines.length) { |
| const lineLength = cpLen(initialLines[row]); |
| |
| const totalCharsInLineAndNewline = |
| lineLength + (row < initialLines.length - 1 ? 1 : 0); |
|
|
| if (remainingChars <= lineLength) { |
| |
| return [row, remainingChars]; |
| } |
| remainingChars -= totalCharsInLineAndNewline; |
| row++; |
| } |
| |
| if (initialLines.length > 0) { |
| const lastRow = initialLines.length - 1; |
| return [lastRow, cpLen(initialLines[lastRow])]; |
| } |
| return [0, 0]; |
| } |
|
|
| export function offsetToLogicalPos( |
| text: string, |
| offset: number, |
| ): [number, number] { |
| let row = 0; |
| let col = 0; |
| let currentOffset = 0; |
|
|
| if (offset === 0) return [0, 0]; |
|
|
| const lines = text.split('\n'); |
| for (let i = 0; i < lines.length; i++) { |
| const line = lines[i]; |
| const lineLength = cpLen(line); |
| const lineLengthWithNewline = lineLength + (i < lines.length - 1 ? 1 : 0); |
|
|
| if (offset <= currentOffset + lineLength) { |
| |
| row = i; |
| col = offset - currentOffset; |
| return [row, col]; |
| } else if (offset <= currentOffset + lineLengthWithNewline) { |
| |
| row = i; |
| col = lineLength; |
| |
| if ( |
| offset === currentOffset + lineLengthWithNewline && |
| i < lines.length - 1 |
| ) { |
| return [i + 1, 0]; |
| } |
| return [row, col]; |
| } |
| currentOffset += lineLengthWithNewline; |
| } |
|
|
| |
| |
| if (lines.length > 0) { |
| row = lines.length - 1; |
| col = cpLen(lines[row]); |
| } else { |
| row = 0; |
| col = 0; |
| } |
| return [row, col]; |
| } |
|
|
| |
| |
| |
| |
| export function logicalPosToOffset( |
| lines: string[], |
| row: number, |
| col: number, |
| ): number { |
| let offset = 0; |
|
|
| |
| const actualRow = Math.min(row, lines.length - 1); |
|
|
| |
| for (let i = 0; i < actualRow; i++) { |
| offset += cpLen(lines[i]) + 1; |
| } |
|
|
| |
| if (actualRow >= 0 && actualRow < lines.length) { |
| offset += Math.min(col, cpLen(lines[actualRow])); |
| } |
|
|
| return offset; |
| } |
| |
| |
| |
| |
| |
| |
| export interface Transformation { |
| logStart: number; |
| logEnd: number; |
| logicalText: string; |
| collapsedText: string; |
| type: 'image' | 'paste'; |
| id?: string; |
| } |
| export const imagePathRegex = |
| /@((?:\\.|[^\s\r\n\\])+?\.(?:png|jpg|jpeg|gif|webp|svg|bmp))\b/gi; |
|
|
| export function getTransformedImagePath(filePath: string): string { |
| const raw = filePath; |
|
|
| |
| const withoutAt = raw.startsWith('@') ? raw.slice(1) : raw; |
|
|
| |
| const unescaped = unescapePath(withoutAt); |
|
|
| |
| const lastSepIndex = Math.max( |
| unescaped.lastIndexOf('/'), |
| unescaped.lastIndexOf('\\'), |
| ); |
|
|
| |
| const fileName = |
| lastSepIndex >= 0 ? unescaped.slice(lastSepIndex + 1) : unescaped; |
|
|
| const extension = path.extname(fileName); |
| const baseName = path.basename(fileName, extension); |
| const maxBaseLength = 10; |
|
|
| const truncatedBase = |
| baseName.length > maxBaseLength |
| ? `...${baseName.slice(-maxBaseLength)}` |
| : baseName; |
|
|
| return `[Image ${truncatedBase}${extension}]`; |
| } |
|
|
| const transformationsCache = new LRUCache<string, Transformation[]>( |
| LRU_BUFFER_PERF_CACHE_LIMIT, |
| ); |
|
|
| export function calculateTransformationsForLine( |
| line: string, |
| ): Transformation[] { |
| const cached = transformationsCache.get(line); |
| if (cached) { |
| return cached; |
| } |
|
|
| const transformations: Transformation[] = []; |
|
|
| |
| imagePathRegex.lastIndex = 0; |
| let match: RegExpExecArray | null; |
| while ((match = imagePathRegex.exec(line)) !== null) { |
| const logicalText = match[0]; |
| const logStart = cpLen(line.substring(0, match.index)); |
| const logEnd = logStart + cpLen(logicalText); |
|
|
| transformations.push({ |
| logStart, |
| logEnd, |
| logicalText, |
| collapsedText: getTransformedImagePath(logicalText), |
| type: 'image', |
| }); |
| } |
|
|
| |
| const pasteRegex = new RegExp(PASTED_TEXT_PLACEHOLDER_REGEX.source, 'g'); |
| while ((match = pasteRegex.exec(line)) !== null) { |
| const logicalText = match[0]; |
| const logStart = cpLen(line.substring(0, match.index)); |
| const logEnd = logStart + cpLen(logicalText); |
|
|
| transformations.push({ |
| logStart, |
| logEnd, |
| logicalText, |
| collapsedText: logicalText, |
| type: 'paste', |
| id: logicalText, |
| }); |
| } |
|
|
| |
| transformations.sort((a, b) => a.logStart - b.logStart); |
|
|
| transformationsCache.set(line, transformations); |
|
|
| return transformations; |
| } |
|
|
| export function calculateTransformations(lines: string[]): Transformation[][] { |
| return lines.map((ln) => calculateTransformationsForLine(ln)); |
| } |
|
|
| export function getTransformUnderCursor( |
| row: number, |
| col: number, |
| spansByLine: Transformation[][], |
| options: { includeEdge?: boolean } = {}, |
| ): Transformation | null { |
| const spans = spansByLine[row]; |
| if (!spans || spans.length === 0) return null; |
| for (const span of spans) { |
| if ( |
| col >= span.logStart && |
| (options.includeEdge ? col <= span.logEnd : col < span.logEnd) |
| ) { |
| return span; |
| } |
| if (col < span.logStart) break; |
| } |
| return null; |
| } |
|
|
| export interface ExpandedPasteInfo { |
| id: string; |
| startLine: number; |
| lineCount: number; |
| prefix: string; |
| suffix: string; |
| } |
|
|
| |
| |
| |
| |
| export function getExpandedPasteAtLine( |
| lineIndex: number, |
| expandedPaste: ExpandedPasteInfo | null, |
| ): string | null { |
| if ( |
| expandedPaste && |
| lineIndex >= expandedPaste.startLine && |
| lineIndex < expandedPaste.startLine + expandedPaste.lineCount |
| ) { |
| return expandedPaste.id; |
| } |
| return null; |
| } |
|
|
| |
| |
| |
| |
| export function shiftExpandedRegions( |
| expandedPaste: ExpandedPasteInfo | null, |
| changeStartLine: number, |
| lineDelta: number, |
| changeEndLine?: number, |
| ): { |
| newInfo: ExpandedPasteInfo | null; |
| isDetached: boolean; |
| } { |
| if (!expandedPaste) return { newInfo: null, isDetached: false }; |
|
|
| const effectiveEndLine = changeEndLine ?? changeStartLine; |
| const infoEndLine = expandedPaste.startLine + expandedPaste.lineCount - 1; |
|
|
| |
| const isOverlapping = |
| changeStartLine <= infoEndLine && |
| effectiveEndLine >= expandedPaste.startLine; |
|
|
| if (isOverlapping) { |
| |
| |
| |
| |
| if ( |
| lineDelta < 0 || |
| (lineDelta > 0 && |
| changeStartLine > expandedPaste.startLine && |
| changeStartLine <= infoEndLine) |
| ) { |
| return { newInfo: null, isDetached: true }; |
| } |
| } |
|
|
| |
| if (expandedPaste.startLine >= changeStartLine) { |
| return { |
| newInfo: { |
| ...expandedPaste, |
| startLine: expandedPaste.startLine + lineDelta, |
| }, |
| isDetached: false, |
| }; |
| } |
|
|
| return { newInfo: expandedPaste, isDetached: false }; |
| } |
|
|
| |
| |
| |
| |
| |
| export function detachExpandedPaste(state: TextBufferState): TextBufferState { |
| const expandedId = getExpandedPasteAtLine( |
| state.cursorRow, |
| state.expandedPaste, |
| ); |
| if (!expandedId) return state; |
|
|
| const { [expandedId]: _, ...newPastedContent } = state.pastedContent; |
| return { |
| ...state, |
| expandedPaste: null, |
| pastedContent: newPastedContent, |
| }; |
| } |
|
|
| |
| |
| |
| |
| interface AtomicPlaceholder { |
| start: number; |
| end: number; |
| type: 'paste' | 'image'; |
| id?: string; |
| } |
|
|
| |
| |
| |
| |
| function findAtomicPlaceholderForBackspace( |
| line: string, |
| cursorCol: number, |
| transformations: Transformation[], |
| ): AtomicPlaceholder | null { |
| for (const transform of transformations) { |
| if (cursorCol === transform.logEnd) { |
| return { |
| start: transform.logStart, |
| end: transform.logEnd, |
| type: transform.type, |
| id: transform.id, |
| }; |
| } |
| } |
|
|
| return null; |
| } |
|
|
| |
| |
| |
| function findAtomicPlaceholderForDelete( |
| line: string, |
| cursorCol: number, |
| transformations: Transformation[], |
| ): AtomicPlaceholder | null { |
| for (const transform of transformations) { |
| if (cursorCol === transform.logStart) { |
| return { |
| start: transform.logStart, |
| end: transform.logEnd, |
| type: transform.type, |
| id: transform.id, |
| }; |
| } |
| } |
|
|
| return null; |
| } |
|
|
| export function calculateTransformedLine( |
| logLine: string, |
| logIndex: number, |
| logicalCursor: [number, number], |
| transformations: Transformation[], |
| ): { transformedLine: string; transformedToLogMap: number[] } { |
| let transformedLine = ''; |
| const transformedToLogMap: number[] = []; |
| let lastLogPos = 0; |
|
|
| const cursorIsOnThisLine = logIndex === logicalCursor[0]; |
| const cursorCol = logicalCursor[1]; |
|
|
| for (const transform of transformations) { |
| const textBeforeTransformation = cpSlice( |
| logLine, |
| lastLogPos, |
| transform.logStart, |
| ); |
| transformedLine += textBeforeTransformation; |
| for (let i = 0; i < cpLen(textBeforeTransformation); i++) { |
| transformedToLogMap.push(lastLogPos + i); |
| } |
|
|
| const isExpanded = |
| transform.type === 'image' && |
| cursorIsOnThisLine && |
| cursorCol >= transform.logStart && |
| cursorCol <= transform.logEnd; |
| const transformedText = isExpanded |
| ? transform.logicalText |
| : transform.collapsedText; |
| transformedLine += transformedText; |
|
|
| |
| const transformedLen = cpLen(transformedText); |
| if (isExpanded) { |
| for (let i = 0; i < transformedLen; i++) { |
| transformedToLogMap.push(transform.logStart + i); |
| } |
| } else { |
| |
| |
| |
| const logicalLength = Math.max(0, transform.logEnd - transform.logStart); |
| for (let i = 0; i < transformedLen; i++) { |
| |
| const transformationToLogicalOffset = |
| logicalLength === 0 |
| ? 0 |
| : Math.floor((i * logicalLength) / transformedLen); |
| const transformationToLogicalIndex = |
| transform.logStart + |
| Math.min( |
| transformationToLogicalOffset, |
| Math.max(logicalLength - 1, 0), |
| ); |
| transformedToLogMap.push(transformationToLogicalIndex); |
| } |
| } |
| lastLogPos = transform.logEnd; |
| } |
|
|
| |
| const remainingUntransformedText = cpSlice(logLine, lastLogPos); |
| transformedLine += remainingUntransformedText; |
| for (let i = 0; i < cpLen(remainingUntransformedText); i++) { |
| transformedToLogMap.push(lastLogPos + i); |
| } |
|
|
| |
| transformedToLogMap.push(cpLen(logLine)); |
|
|
| return { transformedLine, transformedToLogMap }; |
| } |
|
|
| export interface VisualLayout { |
| visualLines: string[]; |
| |
| logicalToVisualMap: Array<Array<[number, number]>>; |
| |
| visualToLogicalMap: Array<[number, number]>; |
| |
| |
| transformedToLogicalMaps: number[][]; |
| |
| visualToTransformedMap: number[]; |
| } |
|
|
| |
| interface LineLayoutResult { |
| visualLines: string[]; |
| logicalToVisualMap: Array<[number, number]>; |
| visualToLogicalMap: Array<[number, number]>; |
| transformedToLogMap: number[]; |
| visualToTransformedMap: number[]; |
| } |
|
|
| const lineLayoutCache = new LRUCache<string, LineLayoutResult>( |
| LRU_BUFFER_PERF_CACHE_LIMIT, |
| ); |
|
|
| function getLineLayoutCacheKey( |
| line: string, |
| viewportWidth: number, |
| isCursorOnLine: boolean, |
| cursorCol: number, |
| ): string { |
| |
| |
| if (!isCursorOnLine) { |
| return `${viewportWidth}:N:${line}`; |
| } |
| return `${viewportWidth}:C:${cursorCol}:${line}`; |
| } |
|
|
| |
| |
| function calculateLayout( |
| logicalLines: string[], |
| viewportWidth: number, |
| logicalCursor: [number, number], |
| ): VisualLayout { |
| const visualLines: string[] = []; |
| const logicalToVisualMap: Array<Array<[number, number]>> = []; |
| const visualToLogicalMap: Array<[number, number]> = []; |
| const transformedToLogicalMaps: number[][] = []; |
| const visualToTransformedMap: number[] = []; |
|
|
| logicalLines.forEach((logLine, logIndex) => { |
| logicalToVisualMap[logIndex] = []; |
|
|
| const isCursorOnLine = logIndex === logicalCursor[0]; |
| const cacheKey = getLineLayoutCacheKey( |
| logLine, |
| viewportWidth, |
| isCursorOnLine, |
| logicalCursor[1], |
| ); |
| const cached = lineLayoutCache.get(cacheKey); |
|
|
| if (cached) { |
| const visualLineOffset = visualLines.length; |
| visualLines.push(...cached.visualLines); |
| cached.logicalToVisualMap.forEach(([relVisualIdx, logCol]) => { |
| logicalToVisualMap[logIndex].push([ |
| visualLineOffset + relVisualIdx, |
| logCol, |
| ]); |
| }); |
| cached.visualToLogicalMap.forEach(([, logCol]) => { |
| visualToLogicalMap.push([logIndex, logCol]); |
| }); |
| transformedToLogicalMaps[logIndex] = cached.transformedToLogMap; |
| visualToTransformedMap.push(...cached.visualToTransformedMap); |
| return; |
| } |
|
|
| |
| const transformations = calculateTransformationsForLine(logLine); |
| const { transformedLine, transformedToLogMap } = calculateTransformedLine( |
| logLine, |
| logIndex, |
| logicalCursor, |
| transformations, |
| ); |
|
|
| const lineVisualLines: string[] = []; |
| const lineLogicalToVisualMap: Array<[number, number]> = []; |
| const lineVisualToLogicalMap: Array<[number, number]> = []; |
| const lineVisualToTransformedMap: number[] = []; |
|
|
| if (transformedLine.length === 0) { |
| |
| lineLogicalToVisualMap.push([0, 0]); |
| lineVisualToLogicalMap.push([logIndex, 0]); |
| lineVisualToTransformedMap.push(0); |
| lineVisualLines.push(''); |
| } else { |
| |
| let currentPosInLogLine = 0; |
| const codePointsInLogLine = toCodePoints(transformedLine); |
|
|
| while (currentPosInLogLine < codePointsInLogLine.length) { |
| let currentChunk = ''; |
| let currentChunkVisualWidth = 0; |
| let numCodePointsInChunk = 0; |
| let lastWordBreakPoint = -1; |
| let numCodePointsAtLastWordBreak = 0; |
|
|
| |
| for (let i = currentPosInLogLine; i < codePointsInLogLine.length; i++) { |
| const char = codePointsInLogLine[i]; |
| const charVisualWidth = getCachedStringWidth(char); |
|
|
| if (currentChunkVisualWidth + charVisualWidth > viewportWidth) { |
| |
| if ( |
| lastWordBreakPoint !== -1 && |
| numCodePointsAtLastWordBreak > 0 && |
| currentPosInLogLine + numCodePointsAtLastWordBreak < i |
| ) { |
| |
| currentChunk = codePointsInLogLine |
| .slice( |
| currentPosInLogLine, |
| currentPosInLogLine + numCodePointsAtLastWordBreak, |
| ) |
| .join(''); |
| numCodePointsInChunk = numCodePointsAtLastWordBreak; |
| } else { |
| |
| |
| if ( |
| numCodePointsInChunk === 0 && |
| charVisualWidth > viewportWidth |
| ) { |
| |
| currentChunk = char; |
| numCodePointsInChunk = 1; |
| } |
| } |
| break; |
| } |
|
|
| currentChunk += char; |
| currentChunkVisualWidth += charVisualWidth; |
| numCodePointsInChunk++; |
|
|
| |
| if (char === ' ') { |
| lastWordBreakPoint = i; |
| |
| numCodePointsAtLastWordBreak = numCodePointsInChunk - 1; |
| } |
| } |
|
|
| if ( |
| numCodePointsInChunk === 0 && |
| currentPosInLogLine < codePointsInLogLine.length |
| ) { |
| const firstChar = codePointsInLogLine[currentPosInLogLine]; |
| currentChunk = firstChar; |
| numCodePointsInChunk = 1; |
| } |
|
|
| const logicalStartCol = transformedToLogMap[currentPosInLogLine] ?? 0; |
| lineLogicalToVisualMap.push([lineVisualLines.length, logicalStartCol]); |
| lineVisualToLogicalMap.push([logIndex, logicalStartCol]); |
| lineVisualToTransformedMap.push(currentPosInLogLine); |
| lineVisualLines.push(currentChunk); |
|
|
| const logicalStartOfThisChunk = currentPosInLogLine; |
| currentPosInLogLine += numCodePointsInChunk; |
|
|
| if ( |
| logicalStartOfThisChunk + numCodePointsInChunk < |
| codePointsInLogLine.length && |
| currentPosInLogLine < codePointsInLogLine.length && |
| codePointsInLogLine[currentPosInLogLine] === ' ' |
| ) { |
| currentPosInLogLine++; |
| } |
| } |
| } |
|
|
| |
| lineLayoutCache.set(cacheKey, { |
| visualLines: lineVisualLines, |
| logicalToVisualMap: lineLogicalToVisualMap, |
| visualToLogicalMap: lineVisualToLogicalMap, |
| transformedToLogMap, |
| visualToTransformedMap: lineVisualToTransformedMap, |
| }); |
|
|
| const visualLineOffset = visualLines.length; |
| visualLines.push(...lineVisualLines); |
| lineLogicalToVisualMap.forEach(([relVisualIdx, logCol]) => { |
| logicalToVisualMap[logIndex].push([ |
| visualLineOffset + relVisualIdx, |
| logCol, |
| ]); |
| }); |
| lineVisualToLogicalMap.forEach(([, logCol]) => { |
| visualToLogicalMap.push([logIndex, logCol]); |
| }); |
| transformedToLogicalMaps[logIndex] = transformedToLogMap; |
| visualToTransformedMap.push(...lineVisualToTransformedMap); |
| }); |
|
|
| |
| if ( |
| logicalLines.length === 0 || |
| (logicalLines.length === 1 && logicalLines[0] === '') |
| ) { |
| if (visualLines.length === 0) { |
| visualLines.push(''); |
| if (!logicalToVisualMap[0]) logicalToVisualMap[0] = []; |
| logicalToVisualMap[0].push([0, 0]); |
| visualToLogicalMap.push([0, 0]); |
| visualToTransformedMap.push(0); |
| } |
| } |
|
|
| return { |
| visualLines, |
| logicalToVisualMap, |
| visualToLogicalMap, |
| transformedToLogicalMaps, |
| visualToTransformedMap, |
| }; |
| } |
|
|
| |
| |
| function calculateVisualCursorFromLayout( |
| layout: VisualLayout, |
| logicalCursor: [number, number], |
| ): [number, number] { |
| const { logicalToVisualMap, visualLines, transformedToLogicalMaps } = layout; |
| const [logicalRow, logicalCol] = logicalCursor; |
|
|
| const segmentsForLogicalLine = logicalToVisualMap[logicalRow]; |
|
|
| if (!segmentsForLogicalLine || segmentsForLogicalLine.length === 0) { |
| |
| return [0, 0]; |
| } |
|
|
| |
| |
| let targetSegmentIndex = segmentsForLogicalLine.findIndex( |
| ([, startColInLogical], index) => { |
| const nextStartColInLogical = |
| index + 1 < segmentsForLogicalLine.length |
| ? segmentsForLogicalLine[index + 1][1] |
| : Infinity; |
| return ( |
| logicalCol >= startColInLogical && logicalCol < nextStartColInLogical |
| ); |
| }, |
| ); |
|
|
| |
| if (targetSegmentIndex === -1) { |
| if (logicalCol === 0) { |
| targetSegmentIndex = 0; |
| } else { |
| targetSegmentIndex = segmentsForLogicalLine.length - 1; |
| } |
| } |
|
|
| const [visualRow, startColInLogical] = |
| segmentsForLogicalLine[targetSegmentIndex]; |
|
|
| |
| const transformedToLogicalMap = transformedToLogicalMaps[logicalRow] ?? []; |
| let transformedCol = 0; |
| for (let i = 0; i < transformedToLogicalMap.length; i++) { |
| if (transformedToLogicalMap[i] > logicalCol) { |
| transformedCol = Math.max(0, i - 1); |
| break; |
| } |
| if (i === transformedToLogicalMap.length - 1) { |
| transformedCol = transformedToLogicalMap.length - 1; |
| } |
| } |
| let startColInTransformed = 0; |
| while ( |
| startColInTransformed < transformedToLogicalMap.length && |
| transformedToLogicalMap[startColInTransformed] < startColInLogical |
| ) { |
| startColInTransformed++; |
| } |
| const clampedTransformedCol = Math.min( |
| transformedCol, |
| Math.max(0, transformedToLogicalMap.length - 1), |
| ); |
| const visualCol = clampedTransformedCol - startColInTransformed; |
| const clampedVisualCol = Math.min( |
| Math.max(visualCol, 0), |
| cpLen(visualLines[visualRow] ?? ''), |
| ); |
| return [visualRow, clampedVisualCol]; |
| } |
|
|
| |
|
|
| export interface TextBufferState { |
| lines: string[]; |
| cursorRow: number; |
| cursorCol: number; |
| transformationsByLine: Transformation[][]; |
| preferredCol: number | null; |
| undoStack: UndoHistoryEntry[]; |
| redoStack: UndoHistoryEntry[]; |
| clipboard: string | null; |
| selectionAnchor: [number, number] | null; |
| viewportWidth: number; |
| viewportHeight: number; |
| visualLayout: VisualLayout; |
| pastedContent: Record<string, string>; |
| expandedPaste: ExpandedPasteInfo | null; |
| yankRegister: { text: string; linewise: boolean } | null; |
| } |
|
|
| const historyLimit = 100; |
|
|
| export const pushUndo = (currentState: TextBufferState): TextBufferState => { |
| const snapshot: UndoHistoryEntry = { |
| lines: [...currentState.lines], |
| cursorRow: currentState.cursorRow, |
| cursorCol: currentState.cursorCol, |
| pastedContent: { ...currentState.pastedContent }, |
| expandedPaste: currentState.expandedPaste |
| ? { ...currentState.expandedPaste } |
| : null, |
| }; |
| const newStack = [...currentState.undoStack, snapshot]; |
| if (newStack.length > historyLimit) { |
| newStack.shift(); |
| } |
| return { ...currentState, undoStack: newStack, redoStack: [] }; |
| }; |
|
|
| function generatePastedTextId( |
| content: string, |
| lineCount: number, |
| pastedContent: Record<string, string>, |
| ): string { |
| const base = |
| lineCount > LARGE_PASTE_LINE_THRESHOLD |
| ? `[Pasted Text: ${lineCount} lines]` |
| : `[Pasted Text: ${content.length} chars]`; |
|
|
| let id = base; |
| let suffix = 2; |
| while (pastedContent[id]) { |
| id = base.replace(']', ` #${suffix}]`); |
| suffix++; |
| } |
| return id; |
| } |
|
|
| function collectPlaceholderIdsFromLines(lines: string[]): Set<string> { |
| const ids = new Set<string>(); |
| const pasteRegex = new RegExp(PASTED_TEXT_PLACEHOLDER_REGEX.source, 'g'); |
| for (const line of lines) { |
| if (!line) continue; |
| for (const match of line.matchAll(pasteRegex)) { |
| const placeholderId = match[0]; |
| if (placeholderId) { |
| ids.add(placeholderId); |
| } |
| } |
| } |
| return ids; |
| } |
|
|
| function pruneOrphanedPastedContent( |
| pastedContent: Record<string, string>, |
| expandedPasteId: string | null, |
| beforeChangedLines: string[], |
| allLines: string[], |
| ): Record<string, string> { |
| if (Object.keys(pastedContent).length === 0) return pastedContent; |
|
|
| const beforeIds = collectPlaceholderIdsFromLines(beforeChangedLines); |
| if (beforeIds.size === 0) return pastedContent; |
|
|
| const afterIds = collectPlaceholderIdsFromLines(allLines); |
| const removedIds = [...beforeIds].filter( |
| (id) => !afterIds.has(id) && id !== expandedPasteId, |
| ); |
| if (removedIds.length === 0) return pastedContent; |
|
|
| const pruned = { ...pastedContent }; |
| for (const id of removedIds) { |
| if (pruned[id]) { |
| delete pruned[id]; |
| } |
| } |
| return pruned; |
| } |
|
|
| export type TextBufferAction = |
| | { type: 'insert'; payload: string; isPaste?: boolean } |
| | { |
| type: 'set_text'; |
| payload: string; |
| pushToUndo?: boolean; |
| cursorPosition?: 'start' | 'end' | number; |
| } |
| | { type: 'add_pasted_content'; payload: { id: string; text: string } } |
| | { type: 'backspace' } |
| | { |
| type: 'move'; |
| payload: { |
| dir: Direction; |
| }; |
| } |
| | { |
| type: 'set_cursor'; |
| payload: { |
| cursorRow: number; |
| cursorCol: number; |
| preferredCol: number | null; |
| }; |
| } |
| | { type: 'delete' } |
| | { type: 'delete_word_left' } |
| | { type: 'delete_word_right' } |
| | { type: 'kill_line_right' } |
| | { type: 'kill_line_left' } |
| | { type: 'undo' } |
| | { type: 'redo' } |
| | { |
| type: 'replace_range'; |
| payload: { |
| startRow: number; |
| startCol: number; |
| endRow: number; |
| endCol: number; |
| text: string; |
| }; |
| } |
| | { type: 'move_to_offset'; payload: { offset: number } } |
| | { type: 'create_undo_snapshot' } |
| | { type: 'set_viewport'; payload: { width: number; height: number } } |
| | { type: 'vim_delete_word_forward'; payload: { count: number } } |
| | { type: 'vim_delete_word_backward'; payload: { count: number } } |
| | { type: 'vim_delete_word_end'; payload: { count: number } } |
| | { type: 'vim_delete_big_word_forward'; payload: { count: number } } |
| | { type: 'vim_delete_big_word_backward'; payload: { count: number } } |
| | { type: 'vim_delete_big_word_end'; payload: { count: number } } |
| | { type: 'vim_change_word_forward'; payload: { count: number } } |
| | { type: 'vim_change_word_backward'; payload: { count: number } } |
| | { type: 'vim_change_word_end'; payload: { count: number } } |
| | { type: 'vim_change_big_word_forward'; payload: { count: number } } |
| | { type: 'vim_change_big_word_backward'; payload: { count: number } } |
| | { type: 'vim_change_big_word_end'; payload: { count: number } } |
| | { type: 'vim_delete_line'; payload: { count: number } } |
| | { type: 'vim_change_line'; payload: { count: number } } |
| | { type: 'vim_delete_to_end_of_line'; payload: { count: number } } |
| | { type: 'vim_delete_to_start_of_line' } |
| | { type: 'vim_change_to_end_of_line'; payload: { count: number } } |
| | { |
| type: 'vim_change_movement'; |
| payload: { movement: 'h' | 'j' | 'k' | 'l'; count: number }; |
| } |
| |
| | { type: 'vim_move_left'; payload: { count: number } } |
| | { type: 'vim_move_right'; payload: { count: number } } |
| | { type: 'vim_move_up'; payload: { count: number } } |
| | { type: 'vim_move_down'; payload: { count: number } } |
| | { type: 'vim_move_word_forward'; payload: { count: number } } |
| | { type: 'vim_move_word_backward'; payload: { count: number } } |
| | { type: 'vim_move_word_end'; payload: { count: number } } |
| | { type: 'vim_move_big_word_forward'; payload: { count: number } } |
| | { type: 'vim_move_big_word_backward'; payload: { count: number } } |
| | { type: 'vim_move_big_word_end'; payload: { count: number } } |
| | { type: 'vim_delete_char'; payload: { count: number } } |
| | { type: 'vim_insert_at_cursor' } |
| | { type: 'vim_append_at_cursor' } |
| | { type: 'vim_open_line_below' } |
| | { type: 'vim_open_line_above' } |
| | { type: 'vim_append_at_line_end' } |
| | { type: 'vim_insert_at_line_start' } |
| | { type: 'vim_move_to_line_start' } |
| | { type: 'vim_move_to_line_end' } |
| | { type: 'vim_move_to_first_nonwhitespace' } |
| | { type: 'vim_move_to_first_line' } |
| | { type: 'vim_move_to_last_line' } |
| | { type: 'vim_move_to_line'; payload: { lineNumber: number } } |
| | { type: 'vim_escape_insert_mode' } |
| | { type: 'vim_delete_to_first_nonwhitespace' } |
| | { type: 'vim_change_to_start_of_line' } |
| | { type: 'vim_change_to_first_nonwhitespace' } |
| | { type: 'vim_delete_to_first_line'; payload: { count: number } } |
| | { type: 'vim_delete_to_last_line'; payload: { count: number } } |
| | { type: 'vim_delete_char_before'; payload: { count: number } } |
| | { type: 'vim_toggle_case'; payload: { count: number } } |
| | { type: 'vim_replace_char'; payload: { char: string; count: number } } |
| | { |
| type: 'vim_find_char_forward'; |
| payload: { char: string; count: number; till: boolean }; |
| } |
| | { |
| type: 'vim_find_char_backward'; |
| payload: { char: string; count: number; till: boolean }; |
| } |
| | { |
| type: 'vim_delete_to_char_forward'; |
| payload: { char: string; count: number; till: boolean }; |
| } |
| | { |
| type: 'vim_delete_to_char_backward'; |
| payload: { char: string; count: number; till: boolean }; |
| } |
| | { type: 'vim_yank_line'; payload: { count: number } } |
| | { type: 'vim_yank_word_forward'; payload: { count: number } } |
| | { type: 'vim_yank_big_word_forward'; payload: { count: number } } |
| | { type: 'vim_yank_word_end'; payload: { count: number } } |
| | { type: 'vim_yank_big_word_end'; payload: { count: number } } |
| | { type: 'vim_yank_to_end_of_line'; payload: { count: number } } |
| | { type: 'vim_paste_after'; payload: { count: number } } |
| | { type: 'vim_paste_before'; payload: { count: number } } |
| | { |
| type: 'toggle_paste_expansion'; |
| payload: { id: string; row: number; col: number }; |
| }; |
|
|
| export interface TextBufferOptions { |
| inputFilter?: (text: string) => string; |
| singleLine?: boolean; |
| } |
|
|
| function textBufferReducerLogic( |
| state: TextBufferState, |
| action: TextBufferAction, |
| options: TextBufferOptions = {}, |
| ): TextBufferState { |
| const pushUndoLocal = pushUndo; |
|
|
| const currentLine = (r: number): string => state.lines[r] ?? ''; |
| const currentLineLen = (r: number): number => cpLen(currentLine(r)); |
|
|
| switch (action.type) { |
| case 'set_text': { |
| let nextState = state; |
| if (action.pushToUndo !== false) { |
| nextState = pushUndoLocal(state); |
| } |
| const newContentLines = action.payload |
| .replace(/\r\n?/g, '\n') |
| .split('\n'); |
| const lines = newContentLines.length === 0 ? [''] : newContentLines; |
|
|
| let newCursorRow: number; |
| let newCursorCol: number; |
|
|
| if (typeof action.cursorPosition === 'number') { |
| [newCursorRow, newCursorCol] = offsetToLogicalPos( |
| action.payload, |
| action.cursorPosition, |
| ); |
| } else if (action.cursorPosition === 'start') { |
| newCursorRow = 0; |
| newCursorCol = 0; |
| } else { |
| |
| newCursorRow = lines.length - 1; |
| newCursorCol = cpLen(lines[newCursorRow] ?? ''); |
| } |
|
|
| return { |
| ...nextState, |
| lines, |
| cursorRow: newCursorRow, |
| cursorCol: newCursorCol, |
| preferredCol: null, |
| pastedContent: action.payload === '' ? {} : nextState.pastedContent, |
| }; |
| } |
|
|
| case 'insert': { |
| const nextState = detachExpandedPaste(pushUndoLocal(state)); |
| const newLines = [...nextState.lines]; |
| let newCursorRow = nextState.cursorRow; |
| let newCursorCol = nextState.cursorCol; |
|
|
| const currentLine = (r: number) => newLines[r] ?? ''; |
|
|
| let payload = action.payload; |
| let newPastedContent = nextState.pastedContent; |
|
|
| if (action.isPaste) { |
| |
| payload = payload.replace(/\r\n|\r/g, '\n'); |
| const lineCount = payload.split('\n').length; |
| if ( |
| lineCount > LARGE_PASTE_LINE_THRESHOLD || |
| payload.length > LARGE_PASTE_CHAR_THRESHOLD |
| ) { |
| const id = generatePastedTextId(payload, lineCount, newPastedContent); |
| newPastedContent = { |
| ...newPastedContent, |
| [id]: payload, |
| }; |
| payload = id; |
| } |
| } |
|
|
| if (options.singleLine) { |
| payload = payload.replace(/[\r\n]/g, ''); |
| } |
| if (options.inputFilter) { |
| payload = options.inputFilter(payload); |
| } |
|
|
| if (payload.length === 0) { |
| return state; |
| } |
|
|
| const str = stripUnsafeCharacters( |
| payload.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), |
| ); |
| const parts = str.split('\n'); |
| const lineContent = currentLine(newCursorRow); |
| 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); |
| const lastPartOriginal = remainingParts.pop() ?? ''; |
| newLines.splice(newCursorRow + 1, 0, ...remainingParts); |
| newLines.splice( |
| newCursorRow + parts.length - 1, |
| 0, |
| lastPartOriginal + after, |
| ); |
| lineDelta = parts.length - 1; |
| newCursorRow = newCursorRow + parts.length - 1; |
| newCursorCol = cpLen(lastPartOriginal); |
| } else { |
| newLines[newCursorRow] = before + parts[0] + after; |
| newCursorCol = cpLen(before) + cpLen(parts[0]); |
| } |
|
|
| const { newInfo: newExpandedPaste, isDetached } = shiftExpandedRegions( |
| nextState.expandedPaste, |
| nextState.cursorRow, |
| lineDelta, |
| ); |
|
|
| if (isDetached && newExpandedPaste === null && nextState.expandedPaste) { |
| delete newPastedContent[nextState.expandedPaste.id]; |
| } |
|
|
| return { |
| ...nextState, |
| lines: newLines, |
| cursorRow: newCursorRow, |
| cursorCol: newCursorCol, |
| preferredCol: null, |
| pastedContent: newPastedContent, |
| expandedPaste: newExpandedPaste, |
| }; |
| } |
|
|
| case 'add_pasted_content': { |
| const { id, text } = action.payload; |
| return { |
| ...state, |
| pastedContent: { |
| ...state.pastedContent, |
| [id]: text, |
| }, |
| }; |
| } |
|
|
| case 'backspace': { |
| const stateWithUndo = pushUndoLocal(state); |
| const currentState = detachExpandedPaste(stateWithUndo); |
| const { cursorRow, cursorCol, lines, transformationsByLine } = |
| currentState; |
|
|
| |
| if (cursorCol === 0 && cursorRow === 0) return currentState; |
|
|
| |
| const transformations = transformationsByLine[cursorRow] ?? []; |
| const placeholder = findAtomicPlaceholderForBackspace( |
| lines[cursorRow], |
| cursorCol, |
| transformations, |
| ); |
|
|
| if (placeholder) { |
| const nextState = currentState; |
| const newLines = [...nextState.lines]; |
| newLines[cursorRow] = |
| cpSlice(newLines[cursorRow], 0, placeholder.start) + |
| cpSlice(newLines[cursorRow], placeholder.end); |
|
|
| |
| const newTransformations = [...nextState.transformationsByLine]; |
| newTransformations[cursorRow] = calculateTransformationsForLine( |
| newLines[cursorRow], |
| ); |
|
|
| |
| let newPastedContent = nextState.pastedContent; |
| if (placeholder.type === 'paste' && placeholder.id) { |
| const { [placeholder.id]: _, ...remaining } = nextState.pastedContent; |
| newPastedContent = remaining; |
| } |
|
|
| return { |
| ...nextState, |
| lines: newLines, |
| cursorCol: placeholder.start, |
| preferredCol: null, |
| transformationsByLine: newTransformations, |
| pastedContent: newPastedContent, |
| }; |
| } |
|
|
| |
| 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] = |
| cpSlice(lineContent, 0, newCursorCol - 1) + |
| cpSlice(lineContent, newCursorCol); |
| newCursorCol--; |
| } else if (newCursorRow > 0) { |
| const prevLineContent = currentLine(newCursorRow - 1); |
| const currentLineContentVal = currentLine(newCursorRow); |
| const newCol = cpLen(prevLineContent); |
| newLines[newCursorRow - 1] = prevLineContent + currentLineContentVal; |
| newLines.splice(newCursorRow, 1); |
| lineDelta = -1; |
| newCursorRow--; |
| newCursorCol = newCol; |
| } |
|
|
| const { newInfo: newExpandedPaste, isDetached } = shiftExpandedRegions( |
| nextState.expandedPaste, |
| nextState.cursorRow + lineDelta, |
| lineDelta, |
| nextState.cursorRow, |
| ); |
|
|
| const newPastedContent = { ...nextState.pastedContent }; |
| if (isDetached && nextState.expandedPaste) { |
| delete newPastedContent[nextState.expandedPaste.id]; |
| } |
|
|
| return { |
| ...nextState, |
| lines: newLines, |
| cursorRow: newCursorRow, |
| cursorCol: newCursorCol, |
| preferredCol: null, |
| pastedContent: newPastedContent, |
| expandedPaste: newExpandedPaste, |
| }; |
| } |
|
|
| case 'set_viewport': { |
| const { width, height } = action.payload; |
| if (width === state.viewportWidth && height === state.viewportHeight) { |
| return state; |
| } |
| return { |
| ...state, |
| viewportWidth: width, |
| viewportHeight: height, |
| }; |
| } |
|
|
| case 'move': { |
| const { dir } = action.payload; |
| const { cursorRow, cursorCol, lines, visualLayout, preferredCol } = state; |
|
|
| |
| if ( |
| dir === 'left' || |
| dir === 'right' || |
| dir === 'up' || |
| dir === 'down' || |
| dir === 'home' || |
| dir === 'end' |
| ) { |
| const visualCursor = calculateVisualCursorFromLayout(visualLayout, [ |
| cursorRow, |
| cursorCol, |
| ]); |
| const { visualLines, visualToLogicalMap } = visualLayout; |
|
|
| let newVisualRow = visualCursor[0]; |
| let newVisualCol = visualCursor[1]; |
| let newPreferredCol = preferredCol; |
|
|
| const currentVisLineLen = cpLen(visualLines[newVisualRow] ?? ''); |
|
|
| switch (dir) { |
| case 'left': |
| newPreferredCol = null; |
| if (newVisualCol > 0) { |
| newVisualCol--; |
| } else if (newVisualRow > 0) { |
| newVisualRow--; |
| newVisualCol = cpLen(visualLines[newVisualRow] ?? ''); |
| } |
| break; |
| case 'right': |
| newPreferredCol = null; |
| if (newVisualCol < currentVisLineLen) { |
| newVisualCol++; |
| } else if (newVisualRow < visualLines.length - 1) { |
| newVisualRow++; |
| newVisualCol = 0; |
| } |
| break; |
| case 'up': |
| if (newVisualRow > 0) { |
| if (newPreferredCol === null) newPreferredCol = newVisualCol; |
| newVisualRow--; |
| newVisualCol = clamp( |
| newPreferredCol, |
| 0, |
| cpLen(visualLines[newVisualRow] ?? ''), |
| ); |
| } |
| break; |
| case 'down': |
| if (newVisualRow < visualLines.length - 1) { |
| if (newPreferredCol === null) newPreferredCol = newVisualCol; |
| newVisualRow++; |
| newVisualCol = clamp( |
| newPreferredCol, |
| 0, |
| cpLen(visualLines[newVisualRow] ?? ''), |
| ); |
| } |
| break; |
| case 'home': |
| newPreferredCol = null; |
| newVisualCol = 0; |
| break; |
| case 'end': |
| newPreferredCol = null; |
| newVisualCol = currentVisLineLen; |
| break; |
| default: { |
| const exhaustiveCheck: never = dir; |
| debugLogger.error( |
| `Unknown visual movement direction: ${exhaustiveCheck}`, |
| ); |
| return state; |
| } |
| } |
|
|
| if (visualToLogicalMap[newVisualRow]) { |
| const [logRow, logicalStartCol] = visualToLogicalMap[newVisualRow]; |
| const transformedToLogicalMap = |
| visualLayout.transformedToLogicalMaps?.[logRow] ?? []; |
| let transformedStartCol = 0; |
| while ( |
| transformedStartCol < transformedToLogicalMap.length && |
| transformedToLogicalMap[transformedStartCol] < logicalStartCol |
| ) { |
| transformedStartCol++; |
| } |
| const clampedTransformedCol = Math.min( |
| transformedStartCol + newVisualCol, |
| Math.max(0, transformedToLogicalMap.length - 1), |
| ); |
| const newLogicalCol = |
| transformedToLogicalMap[clampedTransformedCol] ?? |
| cpLen(lines[logRow] ?? ''); |
| return { |
| ...state, |
| cursorRow: logRow, |
| cursorCol: newLogicalCol, |
| preferredCol: newPreferredCol, |
| }; |
| } |
| return state; |
| } |
|
|
| |
| switch (dir) { |
| case 'wordLeft': { |
| if (cursorCol === 0 && cursorRow === 0) return state; |
|
|
| let newCursorRow = cursorRow; |
| let newCursorCol = cursorCol; |
|
|
| if (cursorCol === 0) { |
| newCursorRow--; |
| newCursorCol = cpLen(lines[newCursorRow] ?? ''); |
| } else { |
| const lineContent = lines[cursorRow]; |
| newCursorCol = findPrevWordBoundary(lineContent, cursorCol); |
| } |
| return { |
| ...state, |
| cursorRow: newCursorRow, |
| cursorCol: newCursorCol, |
| preferredCol: null, |
| }; |
| } |
| case 'wordRight': { |
| const lineContent = lines[cursorRow] ?? ''; |
| if ( |
| cursorRow === lines.length - 1 && |
| cursorCol === cpLen(lineContent) |
| ) { |
| return state; |
| } |
|
|
| let newCursorRow = cursorRow; |
| let newCursorCol = cursorCol; |
| const lineLen = cpLen(lineContent); |
|
|
| if (cursorCol >= lineLen) { |
| newCursorRow++; |
| newCursorCol = 0; |
| } else { |
| newCursorCol = findNextWordBoundary(lineContent, cursorCol); |
| } |
| return { |
| ...state, |
| cursorRow: newCursorRow, |
| cursorCol: newCursorCol, |
| preferredCol: null, |
| }; |
| } |
| default: |
| return state; |
| } |
| } |
|
|
| case 'set_cursor': { |
| return { |
| ...state, |
| ...action.payload, |
| }; |
| } |
|
|
| case 'delete': { |
| const stateWithUndo = pushUndoLocal(state); |
| const currentState = detachExpandedPaste(stateWithUndo); |
| const { cursorRow, cursorCol, lines, transformationsByLine } = |
| currentState; |
|
|
| |
| const transformations = transformationsByLine[cursorRow] ?? []; |
| const placeholder = findAtomicPlaceholderForDelete( |
| lines[cursorRow], |
| cursorCol, |
| transformations, |
| ); |
|
|
| if (placeholder) { |
| const nextState = currentState; |
| const newLines = [...nextState.lines]; |
| newLines[cursorRow] = |
| cpSlice(newLines[cursorRow], 0, placeholder.start) + |
| cpSlice(newLines[cursorRow], placeholder.end); |
|
|
| |
| const newTransformations = [...nextState.transformationsByLine]; |
| newTransformations[cursorRow] = calculateTransformationsForLine( |
| newLines[cursorRow], |
| ); |
|
|
| |
| let newPastedContent = nextState.pastedContent; |
| if (placeholder.type === 'paste' && placeholder.id) { |
| const { [placeholder.id]: _, ...remaining } = nextState.pastedContent; |
| newPastedContent = remaining; |
| } |
|
|
| return { |
| ...nextState, |
| lines: newLines, |
| |
| preferredCol: null, |
| transformationsByLine: newTransformations, |
| pastedContent: newPastedContent, |
| }; |
| } |
|
|
| |
| const lineContent = currentLine(cursorRow); |
| let lineDelta = 0; |
| const nextState = currentState; |
| const newLines = [...nextState.lines]; |
|
|
| if (cursorCol < currentLineLen(cursorRow)) { |
| newLines[cursorRow] = |
| cpSlice(lineContent, 0, cursorCol) + |
| cpSlice(lineContent, cursorCol + 1); |
| } else if (cursorRow < lines.length - 1) { |
| const nextLineContent = currentLine(cursorRow + 1); |
| newLines[cursorRow] = lineContent + nextLineContent; |
| newLines.splice(cursorRow + 1, 1); |
| lineDelta = -1; |
| } else { |
| return currentState; |
| } |
|
|
| const { newInfo: newExpandedPaste, isDetached } = shiftExpandedRegions( |
| nextState.expandedPaste, |
| nextState.cursorRow, |
| lineDelta, |
| nextState.cursorRow + (lineDelta < 0 ? 1 : 0), |
| ); |
|
|
| const newPastedContent = { ...nextState.pastedContent }; |
| if (isDetached && nextState.expandedPaste) { |
| delete newPastedContent[nextState.expandedPaste.id]; |
| } |
|
|
| return { |
| ...nextState, |
| lines: newLines, |
| preferredCol: null, |
| pastedContent: newPastedContent, |
| expandedPaste: newExpandedPaste, |
| }; |
| } |
|
|
| case 'delete_word_left': { |
| const stateWithUndo = pushUndoLocal(state); |
| const currentState = detachExpandedPaste(stateWithUndo); |
| const { cursorRow, cursorCol } = currentState; |
| if (cursorCol === 0 && cursorRow === 0) return currentState; |
|
|
| const nextState = currentState; |
| const newLines = [...nextState.lines]; |
| let newCursorRow = cursorRow; |
| let newCursorCol = cursorCol; |
| let beforeChangedLines: string[] = []; |
|
|
| if (newCursorCol > 0) { |
| const lineContent = currentLine(newCursorRow); |
| beforeChangedLines = [lineContent]; |
| const prevWordStart = findPrevWordStartInLine( |
| lineContent, |
| newCursorCol, |
| ); |
| const start = prevWordStart === null ? 0 : prevWordStart; |
| newLines[newCursorRow] = |
| cpSlice(lineContent, 0, start) + cpSlice(lineContent, newCursorCol); |
| newCursorCol = start; |
| } else { |
| |
| const prevLineContent = currentLine(cursorRow - 1); |
| const currentLineContentVal = currentLine(cursorRow); |
| beforeChangedLines = [prevLineContent, currentLineContentVal]; |
| const newCol = cpLen(prevLineContent); |
| newLines[cursorRow - 1] = prevLineContent + currentLineContentVal; |
| newLines.splice(cursorRow, 1); |
| newCursorRow--; |
| newCursorCol = newCol; |
| } |
|
|
| const newPastedContent = pruneOrphanedPastedContent( |
| nextState.pastedContent, |
| nextState.expandedPaste?.id ?? null, |
| beforeChangedLines, |
| newLines, |
| ); |
|
|
| return { |
| ...nextState, |
| lines: newLines, |
| cursorRow: newCursorRow, |
| cursorCol: newCursorCol, |
| preferredCol: null, |
| pastedContent: newPastedContent, |
| }; |
| } |
|
|
| case 'delete_word_right': { |
| 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 currentState; |
| } |
|
|
| const nextState = currentState; |
| const newLines = [...nextState.lines]; |
| let beforeChangedLines: string[] = []; |
|
|
| if (cursorCol >= lineLen) { |
| |
| const nextLineContent = currentLine(cursorRow + 1); |
| beforeChangedLines = [lineContent, nextLineContent]; |
| newLines[cursorRow] = lineContent + nextLineContent; |
| newLines.splice(cursorRow + 1, 1); |
| } else { |
| beforeChangedLines = [lineContent]; |
| const nextWordStart = findNextWordStartInLine(lineContent, cursorCol); |
| const end = nextWordStart === null ? lineLen : nextWordStart; |
| newLines[cursorRow] = |
| cpSlice(lineContent, 0, cursorCol) + cpSlice(lineContent, end); |
| } |
|
|
| const newPastedContent = pruneOrphanedPastedContent( |
| nextState.pastedContent, |
| nextState.expandedPaste?.id ?? null, |
| beforeChangedLines, |
| newLines, |
| ); |
|
|
| return { |
| ...nextState, |
| lines: newLines, |
| preferredCol: null, |
| pastedContent: newPastedContent, |
| }; |
| } |
|
|
| case 'kill_line_right': { |
| const stateWithUndo = pushUndoLocal(state); |
| const currentState = detachExpandedPaste(stateWithUndo); |
| const { cursorRow, cursorCol, lines } = currentState; |
| const lineContent = currentLine(cursorRow); |
| if (cursorCol < currentLineLen(cursorRow)) { |
| const nextState = currentState; |
| const newLines = [...nextState.lines]; |
| const beforeChangedLines = [lineContent]; |
| newLines[cursorRow] = cpSlice(lineContent, 0, cursorCol); |
| const newPastedContent = pruneOrphanedPastedContent( |
| nextState.pastedContent, |
| nextState.expandedPaste?.id ?? null, |
| beforeChangedLines, |
| newLines, |
| ); |
| return { |
| ...nextState, |
| lines: newLines, |
| preferredCol: null, |
| pastedContent: newPastedContent, |
| }; |
| } else if (cursorRow < lines.length - 1) { |
| |
| const nextState = currentState; |
| const nextLineContent = currentLine(cursorRow + 1); |
| const newLines = [...nextState.lines]; |
| const beforeChangedLines = [lineContent, nextLineContent]; |
| newLines[cursorRow] = lineContent + nextLineContent; |
| newLines.splice(cursorRow + 1, 1); |
| const newPastedContent = pruneOrphanedPastedContent( |
| nextState.pastedContent, |
| nextState.expandedPaste?.id ?? null, |
| beforeChangedLines, |
| newLines, |
| ); |
| return { |
| ...nextState, |
| lines: newLines, |
| preferredCol: null, |
| pastedContent: newPastedContent, |
| }; |
| } |
| return currentState; |
| } |
|
|
| case 'kill_line_left': { |
| const stateWithUndo = pushUndoLocal(state); |
| const currentState = detachExpandedPaste(stateWithUndo); |
| const { cursorRow, cursorCol } = currentState; |
| if (cursorCol > 0) { |
| const nextState = currentState; |
| const lineContent = currentLine(cursorRow); |
| const newLines = [...nextState.lines]; |
| const beforeChangedLines = [lineContent]; |
| newLines[cursorRow] = cpSlice(lineContent, cursorCol); |
| const newPastedContent = pruneOrphanedPastedContent( |
| nextState.pastedContent, |
| nextState.expandedPaste?.id ?? null, |
| beforeChangedLines, |
| newLines, |
| ); |
| return { |
| ...nextState, |
| lines: newLines, |
| cursorCol: 0, |
| preferredCol: null, |
| pastedContent: newPastedContent, |
| }; |
| } |
| return currentState; |
| } |
|
|
| case 'undo': { |
| const stateToRestore = state.undoStack[state.undoStack.length - 1]; |
| if (!stateToRestore) return state; |
|
|
| const currentSnapshot: UndoHistoryEntry = { |
| lines: [...state.lines], |
| cursorRow: state.cursorRow, |
| cursorCol: state.cursorCol, |
| pastedContent: { ...state.pastedContent }, |
| expandedPaste: state.expandedPaste ? { ...state.expandedPaste } : null, |
| }; |
| return { |
| ...state, |
| ...stateToRestore, |
| undoStack: state.undoStack.slice(0, -1), |
| redoStack: [...state.redoStack, currentSnapshot], |
| }; |
| } |
|
|
| case 'redo': { |
| const stateToRestore = state.redoStack[state.redoStack.length - 1]; |
| if (!stateToRestore) return state; |
|
|
| const currentSnapshot: UndoHistoryEntry = { |
| lines: [...state.lines], |
| cursorRow: state.cursorRow, |
| cursorCol: state.cursorCol, |
| pastedContent: { ...state.pastedContent }, |
| expandedPaste: state.expandedPaste ? { ...state.expandedPaste } : null, |
| }; |
| return { |
| ...state, |
| ...stateToRestore, |
| redoStack: state.redoStack.slice(0, -1), |
| undoStack: [...state.undoStack, currentSnapshot], |
| }; |
| } |
|
|
| case 'replace_range': { |
| const { startRow, startCol, endRow, endCol, text } = action.payload; |
| const nextState = pushUndoLocal(state); |
| const newState = replaceRangeInternal( |
| nextState, |
| startRow, |
| startCol, |
| endRow, |
| endCol, |
| text, |
| ); |
|
|
| const oldLineCount = endRow - startRow + 1; |
| const newLineCount = |
| newState.lines.length - (nextState.lines.length - oldLineCount); |
| const lineDelta = newLineCount - oldLineCount; |
|
|
| const { newInfo: newExpandedPaste, isDetached } = shiftExpandedRegions( |
| nextState.expandedPaste, |
| startRow, |
| lineDelta, |
| endRow, |
| ); |
|
|
| const newPastedContent = { ...newState.pastedContent }; |
| if (isDetached && nextState.expandedPaste) { |
| delete newPastedContent[nextState.expandedPaste.id]; |
| } |
|
|
| return { |
| ...newState, |
| pastedContent: newPastedContent, |
| expandedPaste: newExpandedPaste, |
| }; |
| } |
|
|
| case 'move_to_offset': { |
| const { offset } = action.payload; |
| const [newRow, newCol] = offsetToLogicalPos( |
| state.lines.join('\n'), |
| offset, |
| ); |
| return { |
| ...state, |
| cursorRow: newRow, |
| cursorCol: newCol, |
| preferredCol: null, |
| }; |
| } |
|
|
| case 'create_undo_snapshot': { |
| return pushUndoLocal(state); |
| } |
|
|
| |
| case 'vim_delete_word_forward': |
| case 'vim_delete_word_backward': |
| case 'vim_delete_word_end': |
| case 'vim_delete_big_word_forward': |
| case 'vim_delete_big_word_backward': |
| case 'vim_delete_big_word_end': |
| case 'vim_change_word_forward': |
| case 'vim_change_word_backward': |
| case 'vim_change_word_end': |
| case 'vim_change_big_word_forward': |
| case 'vim_change_big_word_backward': |
| case 'vim_change_big_word_end': |
| case 'vim_delete_line': |
| case 'vim_change_line': |
| case 'vim_delete_to_end_of_line': |
| case 'vim_delete_to_start_of_line': |
| case 'vim_change_to_end_of_line': |
| case 'vim_change_movement': |
| case 'vim_move_left': |
| case 'vim_move_right': |
| case 'vim_move_up': |
| case 'vim_move_down': |
| case 'vim_move_word_forward': |
| case 'vim_move_word_backward': |
| case 'vim_move_word_end': |
| case 'vim_move_big_word_forward': |
| case 'vim_move_big_word_backward': |
| case 'vim_move_big_word_end': |
| case 'vim_delete_char': |
| case 'vim_insert_at_cursor': |
| case 'vim_append_at_cursor': |
| case 'vim_open_line_below': |
| case 'vim_open_line_above': |
| case 'vim_append_at_line_end': |
| case 'vim_insert_at_line_start': |
| case 'vim_move_to_line_start': |
| case 'vim_move_to_line_end': |
| case 'vim_move_to_first_nonwhitespace': |
| case 'vim_move_to_first_line': |
| case 'vim_move_to_last_line': |
| case 'vim_move_to_line': |
| case 'vim_escape_insert_mode': |
| case 'vim_delete_to_first_nonwhitespace': |
| case 'vim_change_to_start_of_line': |
| case 'vim_change_to_first_nonwhitespace': |
| case 'vim_delete_to_first_line': |
| case 'vim_delete_to_last_line': |
| case 'vim_delete_char_before': |
| case 'vim_toggle_case': |
| case 'vim_replace_char': |
| case 'vim_find_char_forward': |
| case 'vim_find_char_backward': |
| case 'vim_delete_to_char_forward': |
| case 'vim_delete_to_char_backward': |
| case 'vim_yank_line': |
| case 'vim_yank_word_forward': |
| case 'vim_yank_big_word_forward': |
| case 'vim_yank_word_end': |
| case 'vim_yank_big_word_end': |
| case 'vim_yank_to_end_of_line': |
| case 'vim_paste_after': |
| case 'vim_paste_before': |
| return handleVimAction(state, action as VimAction); |
|
|
| case 'toggle_paste_expansion': { |
| const { id, row, col } = action.payload; |
| const expandedPaste = state.expandedPaste; |
|
|
| if (expandedPaste && expandedPaste.id === id) { |
| const nextState = pushUndoLocal(state); |
| |
| const newLines = [...nextState.lines]; |
| newLines.splice( |
| expandedPaste.startLine, |
| expandedPaste.lineCount, |
| expandedPaste.prefix + id + expandedPaste.suffix, |
| ); |
|
|
| |
| const newCursorRow = expandedPaste.startLine; |
| const newCursorCol = cpLen(expandedPaste.prefix) + cpLen(id); |
|
|
| return { |
| ...nextState, |
| lines: newLines, |
| cursorRow: newCursorRow, |
| cursorCol: newCursorCol, |
| preferredCol: null, |
| expandedPaste: null, |
| }; |
| } else { |
| |
|
|
| |
| let currentState = state; |
| let targetRow = row; |
| if (state.expandedPaste) { |
| const existingInfo = state.expandedPaste; |
| const lineDelta = 1 - existingInfo.lineCount; |
|
|
| if (targetRow !== undefined && targetRow > existingInfo.startLine) { |
| |
| targetRow += lineDelta; |
| } |
|
|
| currentState = textBufferReducerLogic(state, { |
| type: 'toggle_paste_expansion', |
| payload: { |
| id: existingInfo.id, |
| row: existingInfo.startLine, |
| col: 0, |
| }, |
| }); |
| |
| currentState.transformationsByLine = calculateTransformations( |
| currentState.lines, |
| ); |
| } |
|
|
| const content = currentState.pastedContent[id]; |
| if (!content) return currentState; |
|
|
| |
| let lineIndex = -1; |
| let placeholderStart = -1; |
|
|
| const tryFindOnLine = (idx: number) => { |
| const transforms = currentState.transformationsByLine[idx] ?? []; |
|
|
| |
| let transform = transforms.find( |
| (t) => |
| t.type === 'paste' && |
| t.id === id && |
| col >= t.logStart && |
| col <= t.logEnd, |
| ); |
|
|
| if (!transform) { |
| |
| transform = transforms.find( |
| (t) => t.type === 'paste' && t.id === id, |
| ); |
| } |
|
|
| if (transform) { |
| lineIndex = idx; |
| placeholderStart = transform.logStart; |
| return true; |
| } |
| return false; |
| }; |
|
|
| |
| 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 currentState; |
|
|
| const nextState = pushUndoLocal(currentState); |
|
|
| const line = nextState.lines[lineIndex]; |
| const prefix = cpSlice(line, 0, placeholderStart); |
| const suffix = cpSlice(line, placeholderStart + cpLen(id)); |
|
|
| |
| const contentLines = content.split('\n'); |
| const newLines = [...nextState.lines]; |
|
|
| let expandedLines: string[]; |
| if (contentLines.length === 1) { |
| |
| expandedLines = [prefix + contentLines[0] + suffix]; |
| } else { |
| |
| expandedLines = [ |
| prefix + contentLines[0], |
| ...contentLines.slice(1, -1), |
| contentLines[contentLines.length - 1] + suffix, |
| ]; |
| } |
|
|
| newLines.splice(lineIndex, 1, ...expandedLines); |
|
|
| |
| 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, |
| expandedPaste: { |
| id, |
| startLine: lineIndex, |
| lineCount: expandedLines.length, |
| prefix, |
| suffix, |
| }, |
| }; |
| } |
| } |
|
|
| default: { |
| const exhaustiveCheck: never = action; |
| debugLogger.error(`Unknown action encountered: ${exhaustiveCheck}`); |
| return state; |
| } |
| } |
| } |
|
|
| export function textBufferReducer( |
| state: TextBufferState, |
| action: TextBufferAction, |
| options: TextBufferOptions = {}, |
| ): TextBufferState { |
| const newState = textBufferReducerLogic(state, action, options); |
|
|
| const newTransformedLines = |
| newState.lines !== state.lines |
| ? calculateTransformations(newState.lines) |
| : state.transformationsByLine; |
|
|
| const oldTransform = getTransformUnderCursor( |
| state.cursorRow, |
| state.cursorCol, |
| state.transformationsByLine, |
| ); |
| const newTransform = getTransformUnderCursor( |
| newState.cursorRow, |
| newState.cursorCol, |
| newTransformedLines, |
| ); |
| const oldInside = oldTransform !== null; |
| const newInside = newTransform !== null; |
| const movedBetweenTransforms = |
| oldTransform !== newTransform && |
| (oldTransform !== null || newTransform !== null); |
|
|
| if ( |
| newState.lines !== state.lines || |
| newState.viewportWidth !== state.viewportWidth || |
| oldInside !== newInside || |
| movedBetweenTransforms |
| ) { |
| const shouldResetPreferred = |
| oldInside !== newInside || movedBetweenTransforms; |
|
|
| return { |
| ...newState, |
| preferredCol: shouldResetPreferred ? null : newState.preferredCol, |
| visualLayout: calculateLayout(newState.lines, newState.viewportWidth, [ |
| newState.cursorRow, |
| newState.cursorCol, |
| ]), |
| transformationsByLine: newTransformedLines, |
| }; |
| } |
|
|
| return newState; |
| } |
|
|
| |
|
|
| export function useTextBuffer({ |
| initialText = '', |
| initialCursorOffset = 0, |
| viewport, |
| stdin, |
| setRawMode, |
| onChange, |
| escapePastedPaths = false, |
| shellModeActive = false, |
| inputFilter, |
| singleLine = false, |
| getPreferredEditor, |
| }: UseTextBufferProps): TextBuffer { |
| const settings = useSettings(); |
| const keyMatchers = useKeyMatchers(); |
| const initialState = useMemo((): TextBufferState => { |
| const lines = initialText.split('\n'); |
| const [initialCursorRow, initialCursorCol] = calculateInitialCursorPosition( |
| lines.length === 0 ? [''] : lines, |
| initialCursorOffset, |
| ); |
| const transformationsByLine = calculateTransformations( |
| lines.length === 0 ? [''] : lines, |
| ); |
| const visualLayout = calculateLayout( |
| lines.length === 0 ? [''] : lines, |
| viewport.width, |
| [initialCursorRow, initialCursorCol], |
| ); |
| return { |
| lines: lines.length === 0 ? [''] : lines, |
| cursorRow: initialCursorRow, |
| cursorCol: initialCursorCol, |
| transformationsByLine, |
| preferredCol: null, |
| undoStack: [], |
| redoStack: [], |
| clipboard: null, |
| selectionAnchor: null, |
| viewportWidth: viewport.width, |
| viewportHeight: viewport.height, |
| visualLayout, |
| pastedContent: {}, |
| expandedPaste: null, |
| yankRegister: null, |
| }; |
| }, [initialText, initialCursorOffset, viewport.width, viewport.height]); |
|
|
| const [state, dispatch] = useReducer( |
| (s: TextBufferState, a: TextBufferAction) => |
| textBufferReducer(s, a, { inputFilter, singleLine }), |
| initialState, |
| ); |
| const { |
| lines, |
| cursorRow, |
| cursorCol, |
| preferredCol, |
| selectionAnchor, |
| visualLayout, |
| transformationsByLine, |
| pastedContent, |
| expandedPaste, |
| undoStack, |
| redoStack, |
| } = state; |
|
|
| const text = useMemo(() => lines.join('\n'), [lines]); |
|
|
| const visualCursor = useMemo( |
| () => calculateVisualCursorFromLayout(visualLayout, [cursorRow, cursorCol]), |
| [visualLayout, cursorRow, cursorCol], |
| ); |
|
|
| const { |
| visualLines, |
| visualToLogicalMap, |
| transformedToLogicalMaps, |
| visualToTransformedMap, |
| } = visualLayout; |
|
|
| const [scrollRowState, setScrollRowState] = useState<number>(0); |
|
|
| const { height } = viewport; |
| const totalVisualLines = visualLines.length; |
| const maxScrollStart = Math.max(0, totalVisualLines - height); |
| let newVisualScrollRow = scrollRowState; |
|
|
| if (visualCursor[0] < scrollRowState) { |
| newVisualScrollRow = visualCursor[0]; |
| } else if (visualCursor[0] >= scrollRowState + height) { |
| newVisualScrollRow = visualCursor[0] - height + 1; |
| } |
|
|
| newVisualScrollRow = clamp(newVisualScrollRow, 0, maxScrollStart); |
|
|
| if (newVisualScrollRow !== scrollRowState) { |
| setScrollRowState(newVisualScrollRow); |
| } |
|
|
| const actualScrollRowState = newVisualScrollRow; |
|
|
| useEffect(() => { |
| if (onChange) { |
| onChange(text); |
| } |
| }, [text, onChange]); |
|
|
| useEffect(() => { |
| dispatch({ |
| type: 'set_viewport', |
| payload: { width: viewport.width, height: viewport.height }, |
| }); |
| }, [viewport.width, viewport.height]); |
|
|
| const insert = useCallback( |
| (ch: string, { paste = false }: { paste?: boolean } = {}): void => { |
| if (typeof ch !== 'string') { |
| return; |
| } |
|
|
| let textToInsert = ch; |
| const minLengthToInferAsDragDrop = 3; |
| if ( |
| ch.length >= minLengthToInferAsDragDrop && |
| !shellModeActive && |
| paste && |
| escapePastedPaths |
| ) { |
| const processed = parsePastedPaths(ch.trim()); |
| if (processed) { |
| textToInsert = processed; |
| } |
| } |
|
|
| let currentText = ''; |
| for (const char of toCodePoints(textToInsert)) { |
| if (char.codePointAt(0) === 127) { |
| if (currentText.length > 0) { |
| dispatch({ type: 'insert', payload: currentText, isPaste: paste }); |
| currentText = ''; |
| } |
| dispatch({ type: 'backspace' }); |
| } else { |
| currentText += char; |
| } |
| } |
| if (currentText.length > 0) { |
| dispatch({ type: 'insert', payload: currentText, isPaste: paste }); |
| } |
| }, |
| [shellModeActive, escapePastedPaths], |
| ); |
|
|
| const newline = useCallback((): void => { |
| if (singleLine) { |
| return; |
| } |
| dispatch({ type: 'insert', payload: '\n' }); |
| }, [singleLine]); |
|
|
| const backspace = useCallback((): void => { |
| dispatch({ type: 'backspace' }); |
| }, []); |
|
|
| const del = useCallback((): void => { |
| dispatch({ type: 'delete' }); |
| }, []); |
|
|
| const move = useCallback( |
| (dir: Direction): void => { |
| dispatch({ type: 'move', payload: { dir } }); |
| }, |
| [dispatch], |
| ); |
|
|
| const undo = useCallback((): void => { |
| dispatch({ type: 'undo' }); |
| }, []); |
|
|
| const redo = useCallback((): void => { |
| dispatch({ type: 'redo' }); |
| }, []); |
|
|
| const setText = useCallback( |
| (newText: string, cursorPosition?: 'start' | 'end' | number): void => { |
| dispatch({ type: 'set_text', payload: newText, cursorPosition }); |
| }, |
| [], |
| ); |
|
|
| const deleteWordLeft = useCallback((): void => { |
| dispatch({ type: 'delete_word_left' }); |
| }, []); |
|
|
| const deleteWordRight = useCallback((): void => { |
| dispatch({ type: 'delete_word_right' }); |
| }, []); |
|
|
| const killLineRight = useCallback((): void => { |
| dispatch({ type: 'kill_line_right' }); |
| }, []); |
|
|
| const killLineLeft = useCallback((): void => { |
| dispatch({ type: 'kill_line_left' }); |
| }, []); |
|
|
| |
| const vimDeleteWordForward = useCallback((count: number): void => { |
| dispatch({ type: 'vim_delete_word_forward', payload: { count } }); |
| }, []); |
|
|
| const vimDeleteWordBackward = useCallback((count: number): void => { |
| dispatch({ type: 'vim_delete_word_backward', payload: { count } }); |
| }, []); |
|
|
| const vimDeleteWordEnd = useCallback((count: number): void => { |
| dispatch({ type: 'vim_delete_word_end', payload: { count } }); |
| }, []); |
|
|
| const vimDeleteBigWordForward = useCallback((count: number): void => { |
| dispatch({ type: 'vim_delete_big_word_forward', payload: { count } }); |
| }, []); |
|
|
| const vimDeleteBigWordBackward = useCallback((count: number): void => { |
| dispatch({ type: 'vim_delete_big_word_backward', payload: { count } }); |
| }, []); |
|
|
| const vimDeleteBigWordEnd = useCallback((count: number): void => { |
| dispatch({ type: 'vim_delete_big_word_end', payload: { count } }); |
| }, []); |
|
|
| const vimChangeWordForward = useCallback((count: number): void => { |
| dispatch({ type: 'vim_change_word_forward', payload: { count } }); |
| }, []); |
|
|
| const vimChangeWordBackward = useCallback((count: number): void => { |
| dispatch({ type: 'vim_change_word_backward', payload: { count } }); |
| }, []); |
|
|
| const vimChangeWordEnd = useCallback((count: number): void => { |
| dispatch({ type: 'vim_change_word_end', payload: { count } }); |
| }, []); |
|
|
| const vimChangeBigWordForward = useCallback((count: number): void => { |
| dispatch({ type: 'vim_change_big_word_forward', payload: { count } }); |
| }, []); |
|
|
| const vimChangeBigWordBackward = useCallback((count: number): void => { |
| dispatch({ type: 'vim_change_big_word_backward', payload: { count } }); |
| }, []); |
|
|
| const vimChangeBigWordEnd = useCallback((count: number): void => { |
| dispatch({ type: 'vim_change_big_word_end', payload: { count } }); |
| }, []); |
|
|
| const vimDeleteLine = useCallback((count: number): void => { |
| dispatch({ type: 'vim_delete_line', payload: { count } }); |
| }, []); |
|
|
| const vimChangeLine = useCallback((count: number): void => { |
| dispatch({ type: 'vim_change_line', payload: { count } }); |
| }, []); |
|
|
| const vimDeleteToEndOfLine = useCallback((count: number = 1): void => { |
| dispatch({ type: 'vim_delete_to_end_of_line', payload: { count } }); |
| }, []); |
|
|
| const vimDeleteToStartOfLine = useCallback((): void => { |
| dispatch({ type: 'vim_delete_to_start_of_line' }); |
| }, []); |
|
|
| const vimChangeToEndOfLine = useCallback((count: number = 1): void => { |
| dispatch({ type: 'vim_change_to_end_of_line', payload: { count } }); |
| }, []); |
|
|
| const vimDeleteToFirstNonWhitespace = useCallback((): void => { |
| dispatch({ type: 'vim_delete_to_first_nonwhitespace' }); |
| }, []); |
|
|
| const vimChangeToStartOfLine = useCallback((): void => { |
| dispatch({ type: 'vim_change_to_start_of_line' }); |
| }, []); |
|
|
| const vimChangeToFirstNonWhitespace = useCallback((): void => { |
| dispatch({ type: 'vim_change_to_first_nonwhitespace' }); |
| }, []); |
|
|
| const vimDeleteToFirstLine = useCallback((count: number): void => { |
| dispatch({ type: 'vim_delete_to_first_line', payload: { count } }); |
| }, []); |
|
|
| const vimDeleteToLastLine = useCallback((count: number): void => { |
| dispatch({ type: 'vim_delete_to_last_line', payload: { count } }); |
| }, []); |
|
|
| const vimChangeMovement = useCallback( |
| (movement: 'h' | 'j' | 'k' | 'l', count: number): void => { |
| dispatch({ type: 'vim_change_movement', payload: { movement, count } }); |
| }, |
| [], |
| ); |
|
|
| |
| const vimMoveLeft = useCallback((count: number): void => { |
| dispatch({ type: 'vim_move_left', payload: { count } }); |
| }, []); |
|
|
| const vimMoveRight = useCallback((count: number): void => { |
| dispatch({ type: 'vim_move_right', payload: { count } }); |
| }, []); |
|
|
| const vimMoveUp = useCallback((count: number): void => { |
| dispatch({ type: 'vim_move_up', payload: { count } }); |
| }, []); |
|
|
| const vimMoveDown = useCallback((count: number): void => { |
| dispatch({ type: 'vim_move_down', payload: { count } }); |
| }, []); |
|
|
| const vimMoveWordForward = useCallback((count: number): void => { |
| dispatch({ type: 'vim_move_word_forward', payload: { count } }); |
| }, []); |
|
|
| const vimMoveWordBackward = useCallback((count: number): void => { |
| dispatch({ type: 'vim_move_word_backward', payload: { count } }); |
| }, []); |
|
|
| const vimMoveWordEnd = useCallback((count: number): void => { |
| dispatch({ type: 'vim_move_word_end', payload: { count } }); |
| }, []); |
|
|
| const vimMoveBigWordForward = useCallback((count: number): void => { |
| dispatch({ type: 'vim_move_big_word_forward', payload: { count } }); |
| }, []); |
|
|
| const vimMoveBigWordBackward = useCallback((count: number): void => { |
| dispatch({ type: 'vim_move_big_word_backward', payload: { count } }); |
| }, []); |
|
|
| const vimMoveBigWordEnd = useCallback((count: number): void => { |
| dispatch({ type: 'vim_move_big_word_end', payload: { count } }); |
| }, []); |
|
|
| const vimDeleteChar = useCallback((count: number): void => { |
| dispatch({ type: 'vim_delete_char', payload: { count } }); |
| }, []); |
|
|
| const vimDeleteCharBefore = useCallback((count: number): void => { |
| dispatch({ type: 'vim_delete_char_before', payload: { count } }); |
| }, []); |
|
|
| const vimToggleCase = useCallback((count: number): void => { |
| dispatch({ type: 'vim_toggle_case', payload: { count } }); |
| }, []); |
|
|
| const vimReplaceChar = useCallback((char: string, count: number): void => { |
| dispatch({ type: 'vim_replace_char', payload: { char, count } }); |
| }, []); |
|
|
| const vimFindCharForward = useCallback( |
| (char: string, count: number, till: boolean): void => { |
| dispatch({ |
| type: 'vim_find_char_forward', |
| payload: { char, count, till }, |
| }); |
| }, |
| [], |
| ); |
|
|
| const vimFindCharBackward = useCallback( |
| (char: string, count: number, till: boolean): void => { |
| dispatch({ |
| type: 'vim_find_char_backward', |
| payload: { char, count, till }, |
| }); |
| }, |
| [], |
| ); |
|
|
| const vimDeleteToCharForward = useCallback( |
| (char: string, count: number, till: boolean): void => { |
| dispatch({ |
| type: 'vim_delete_to_char_forward', |
| payload: { char, count, till }, |
| }); |
| }, |
| [], |
| ); |
|
|
| const vimDeleteToCharBackward = useCallback( |
| (char: string, count: number, till: boolean): void => { |
| dispatch({ |
| type: 'vim_delete_to_char_backward', |
| payload: { char, count, till }, |
| }); |
| }, |
| [], |
| ); |
|
|
| const vimInsertAtCursor = useCallback((): void => { |
| dispatch({ type: 'vim_insert_at_cursor' }); |
| }, []); |
|
|
| const vimAppendAtCursor = useCallback((): void => { |
| dispatch({ type: 'vim_append_at_cursor' }); |
| }, []); |
|
|
| const vimOpenLineBelow = useCallback((): void => { |
| dispatch({ type: 'vim_open_line_below' }); |
| }, []); |
|
|
| const vimOpenLineAbove = useCallback((): void => { |
| dispatch({ type: 'vim_open_line_above' }); |
| }, []); |
|
|
| const vimAppendAtLineEnd = useCallback((): void => { |
| dispatch({ type: 'vim_append_at_line_end' }); |
| }, []); |
|
|
| const vimInsertAtLineStart = useCallback((): void => { |
| dispatch({ type: 'vim_insert_at_line_start' }); |
| }, []); |
|
|
| const vimMoveToLineStart = useCallback((): void => { |
| dispatch({ type: 'vim_move_to_line_start' }); |
| }, []); |
|
|
| const vimMoveToLineEnd = useCallback((): void => { |
| dispatch({ type: 'vim_move_to_line_end' }); |
| }, []); |
|
|
| const vimMoveToFirstNonWhitespace = useCallback((): void => { |
| dispatch({ type: 'vim_move_to_first_nonwhitespace' }); |
| }, []); |
|
|
| const vimMoveToFirstLine = useCallback((): void => { |
| dispatch({ type: 'vim_move_to_first_line' }); |
| }, []); |
|
|
| const vimMoveToLastLine = useCallback((): void => { |
| dispatch({ type: 'vim_move_to_last_line' }); |
| }, []); |
|
|
| const vimMoveToLine = useCallback((lineNumber: number): void => { |
| dispatch({ type: 'vim_move_to_line', payload: { lineNumber } }); |
| }, []); |
|
|
| const vimEscapeInsertMode = useCallback((): void => { |
| dispatch({ type: 'vim_escape_insert_mode' }); |
| }, []); |
|
|
| const vimYankLine = useCallback((count: number): void => { |
| dispatch({ type: 'vim_yank_line', payload: { count } }); |
| }, []); |
|
|
| const vimYankWordForward = useCallback((count: number): void => { |
| dispatch({ type: 'vim_yank_word_forward', payload: { count } }); |
| }, []); |
|
|
| const vimYankBigWordForward = useCallback((count: number): void => { |
| dispatch({ type: 'vim_yank_big_word_forward', payload: { count } }); |
| }, []); |
|
|
| const vimYankWordEnd = useCallback((count: number): void => { |
| dispatch({ type: 'vim_yank_word_end', payload: { count } }); |
| }, []); |
|
|
| const vimYankBigWordEnd = useCallback((count: number): void => { |
| dispatch({ type: 'vim_yank_big_word_end', payload: { count } }); |
| }, []); |
|
|
| const vimYankToEndOfLine = useCallback((count: number): void => { |
| dispatch({ type: 'vim_yank_to_end_of_line', payload: { count } }); |
| }, []); |
|
|
| const vimPasteAfter = useCallback((count: number): void => { |
| dispatch({ type: 'vim_paste_after', payload: { count } }); |
| }, []); |
|
|
| const vimPasteBefore = useCallback((count: number): void => { |
| dispatch({ type: 'vim_paste_before', payload: { count } }); |
| }, []); |
|
|
| const openInExternalEditor = useCallback(async (): Promise<void> => { |
| const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'gemini-edit-')); |
| const filePath = pathMod.join(tmpDir, 'buffer.txt'); |
| |
| const expandedText = expandPastePlaceholders(text, pastedContent); |
| fs.writeFileSync(filePath, expandedText, 'utf8'); |
|
|
| dispatch({ type: 'create_undo_snapshot' }); |
|
|
| try { |
| await openFileInEditor( |
| filePath, |
| stdin, |
| setRawMode, |
| getPreferredEditor?.(), |
| settings.merged.general.openEditorInNewWindow, |
| ); |
|
|
| let newText = fs.readFileSync(filePath, 'utf8'); |
| newText = newText.replace(/\r\n?/g, '\n'); |
|
|
| |
| 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('error', getErrorMessage(err), err); |
| } finally { |
| try { |
| fs.unlinkSync(filePath); |
| } catch { |
| |
| } |
| try { |
| fs.rmdirSync(tmpDir); |
| } catch { |
| |
| } |
| } |
| }, [ |
| text, |
| pastedContent, |
| stdin, |
| setRawMode, |
| getPreferredEditor, |
| settings.merged.general.openEditorInNewWindow, |
| ]); |
|
|
| const handleInput = useCallback( |
| (key: Key): boolean => { |
| const { sequence: input } = key; |
|
|
| if (key.name === 'paste') { |
| insert(input, { paste: true }); |
| return true; |
| } |
| if (keyMatchers[Command.RETURN](key)) { |
| if (singleLine) { |
| return false; |
| } |
| newline(); |
| return true; |
| } |
| if (keyMatchers[Command.NEWLINE](key)) { |
| if (singleLine) { |
| return false; |
| } |
| newline(); |
| return true; |
| } |
| if (keyMatchers[Command.MOVE_LEFT](key)) { |
| if (cursorRow === 0 && cursorCol === 0) return false; |
| move('left'); |
| return true; |
| } |
| if (keyMatchers[Command.MOVE_RIGHT](key)) { |
| const lastLineIdx = lines.length - 1; |
| if ( |
| cursorRow === lastLineIdx && |
| cursorCol === cpLen(lines[lastLineIdx] ?? '') |
| ) { |
| return false; |
| } |
| move('right'); |
| return true; |
| } |
| if (keyMatchers[Command.MOVE_UP](key)) { |
| if (visualCursor[0] === 0) return false; |
| move('up'); |
| return true; |
| } |
| if (keyMatchers[Command.MOVE_DOWN](key)) { |
| if (visualCursor[0] === visualLines.length - 1) return false; |
| move('down'); |
| return true; |
| } |
| if (keyMatchers[Command.MOVE_WORD_LEFT](key)) { |
| move('wordLeft'); |
| return true; |
| } |
| if (keyMatchers[Command.MOVE_WORD_RIGHT](key)) { |
| move('wordRight'); |
| return true; |
| } |
| if (keyMatchers[Command.HOME](key)) { |
| move('home'); |
| return true; |
| } |
| if (keyMatchers[Command.END](key)) { |
| move('end'); |
| return true; |
| } |
| if (keyMatchers[Command.CLEAR_INPUT](key)) { |
| if (text.length > 0) { |
| setText(''); |
| return true; |
| } |
| return false; |
| } |
| if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) { |
| deleteWordLeft(); |
| return true; |
| } |
| if (keyMatchers[Command.DELETE_WORD_FORWARD](key)) { |
| deleteWordRight(); |
| return true; |
| } |
| if (keyMatchers[Command.DELETE_CHAR_LEFT](key)) { |
| backspace(); |
| return true; |
| } |
| if (keyMatchers[Command.DELETE_CHAR_RIGHT](key)) { |
| const lastLineIdx = lines.length - 1; |
| if ( |
| cursorRow === lastLineIdx && |
| cursorCol === cpLen(lines[lastLineIdx] ?? '') |
| ) { |
| return false; |
| } |
| del(); |
| return true; |
| } |
| if (keyMatchers[Command.UNDO](key)) { |
| if (undoStack.length === 0) { |
| return false; |
| } |
| undo(); |
| return true; |
| } |
| if (keyMatchers[Command.REDO](key)) { |
| if (redoStack.length === 0) { |
| return false; |
| } |
| redo(); |
| return true; |
| } |
| if (key.insertable) { |
| insert(input, { paste: false }); |
| return true; |
| } |
| return false; |
| }, |
| [ |
| newline, |
| move, |
| deleteWordLeft, |
| deleteWordRight, |
| backspace, |
| del, |
| insert, |
| undo, |
| redo, |
| cursorRow, |
| cursorCol, |
| lines, |
| singleLine, |
| setText, |
| text, |
| visualCursor, |
| visualLines, |
| keyMatchers, |
| undoStack.length, |
| redoStack.length, |
| ], |
| ); |
|
|
| const visualScrollRow = useMemo(() => { |
| const totalVisualLines = visualLines.length; |
| return Math.min( |
| actualScrollRowState, |
| Math.max(0, totalVisualLines - viewport.height), |
| ); |
| }, [visualLines.length, actualScrollRowState, viewport.height]); |
|
|
| const renderedVisualLines = useMemo( |
| () => visualLines.slice(visualScrollRow, visualScrollRow + viewport.height), |
| [visualLines, visualScrollRow, viewport.height], |
| ); |
|
|
| const replaceRange = useCallback( |
| ( |
| startRow: number, |
| startCol: number, |
| endRow: number, |
| endCol: number, |
| text: string, |
| ): void => { |
| dispatch({ |
| type: 'replace_range', |
| payload: { startRow, startCol, endRow, endCol, text }, |
| }); |
| }, |
| [], |
| ); |
|
|
| const replaceRangeByOffset = useCallback( |
| (startOffset: number, endOffset: number, replacementText: string): void => { |
| const [startRow, startCol] = offsetToLogicalPos(text, startOffset); |
| const [endRow, endCol] = offsetToLogicalPos(text, endOffset); |
| replaceRange(startRow, startCol, endRow, endCol, replacementText); |
| }, |
| [text, replaceRange], |
| ); |
|
|
| const moveToOffset = useCallback((offset: number): void => { |
| dispatch({ type: 'move_to_offset', payload: { offset } }); |
| }, []); |
|
|
| const moveToVisualPosition = useCallback( |
| (visRow: number, visCol: number): void => { |
| const { |
| visualLines, |
| visualToLogicalMap, |
| transformedToLogicalMaps, |
| visualToTransformedMap, |
| } = visualLayout; |
| |
| const clampedVisRow = Math.max( |
| 0, |
| Math.min(visRow, visualLines.length - 1), |
| ); |
| const visualLine = visualLines[clampedVisRow] || ''; |
|
|
| if (visualToLogicalMap[clampedVisRow]) { |
| const [logRow] = visualToLogicalMap[clampedVisRow]; |
| const transformedToLogicalMap = |
| transformedToLogicalMaps?.[logRow] ?? []; |
|
|
| |
| const startColInTransformed = |
| visualToTransformedMap?.[clampedVisRow] ?? 0; |
|
|
| |
| 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 newCursorRow = logRow; |
| const newCursorCol = |
| transformedToLogicalMap[transformedCol] ?? cpLen(lines[logRow] ?? ''); |
|
|
| dispatch({ |
| type: 'set_cursor', |
| payload: { |
| cursorRow: newCursorRow, |
| cursorCol: newCursorCol, |
| preferredCol: charOffset, |
| }, |
| }); |
| } |
| }, |
| [visualLayout, lines], |
| ); |
|
|
| const getLogicalPositionFromVisual = useCallback( |
| (visRow: number, visCol: number): { row: number; col: number } | null => { |
| const { |
| visualLines, |
| visualToLogicalMap, |
| transformedToLogicalMaps, |
| visualToTransformedMap, |
| } = visualLayout; |
|
|
| |
| 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] ?? []; |
|
|
| |
| const startColInTransformed = |
| visualToTransformedMap?.[clampedVisRow] ?? 0; |
|
|
| |
| 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, row: number, col: number): void => { |
| dispatch({ type: 'toggle_paste_expansion', payload: { id, row, col } }); |
| }, |
| [], |
| ); |
|
|
| const getExpandedPasteAtLineCallback = useCallback( |
| (lineIndex: number): string | null => |
| getExpandedPasteAtLine(lineIndex, expandedPaste), |
| [expandedPaste], |
| ); |
|
|
| const returnValue: TextBuffer = useMemo( |
| () => ({ |
| lines, |
| text, |
| cursor: [cursorRow, cursorCol], |
| preferredCol, |
| selectionAnchor, |
| pastedContent, |
|
|
| allVisualLines: visualLines, |
| viewportVisualLines: renderedVisualLines, |
| visualCursor, |
| visualScrollRow, |
| viewportHeight: viewport.height, |
| visualToLogicalMap, |
| transformedToLogicalMaps, |
| visualToTransformedMap, |
| transformationsByLine, |
| visualLayout, |
| setText, |
| insert, |
| newline, |
| backspace, |
| del, |
| move, |
| undo, |
| redo, |
| replaceRange, |
| replaceRangeByOffset, |
| moveToOffset, |
| getOffset, |
| moveToVisualPosition, |
| getLogicalPositionFromVisual, |
| getExpandedPasteAtLine: getExpandedPasteAtLineCallback, |
| togglePasteExpansion, |
| expandedPaste, |
| deleteWordLeft, |
| deleteWordRight, |
|
|
| killLineRight, |
| killLineLeft, |
| handleInput, |
| openInExternalEditor, |
| |
| vimDeleteWordForward, |
| vimDeleteWordBackward, |
| vimDeleteWordEnd, |
| vimDeleteBigWordForward, |
| vimDeleteBigWordBackward, |
| vimDeleteBigWordEnd, |
| vimChangeWordForward, |
| vimChangeWordBackward, |
| vimChangeWordEnd, |
| vimChangeBigWordForward, |
| vimChangeBigWordBackward, |
| vimChangeBigWordEnd, |
| vimDeleteLine, |
| vimChangeLine, |
| vimDeleteToEndOfLine, |
| vimDeleteToStartOfLine, |
| vimChangeToEndOfLine, |
| vimDeleteToFirstNonWhitespace, |
| vimChangeToStartOfLine, |
| vimChangeToFirstNonWhitespace, |
| vimDeleteToFirstLine, |
| vimDeleteToLastLine, |
| vimChangeMovement, |
| vimMoveLeft, |
| vimMoveRight, |
| vimMoveUp, |
| vimMoveDown, |
| vimMoveWordForward, |
| vimMoveWordBackward, |
| vimMoveWordEnd, |
| vimMoveBigWordForward, |
| vimMoveBigWordBackward, |
| vimMoveBigWordEnd, |
| vimDeleteChar, |
| vimDeleteCharBefore, |
| vimToggleCase, |
| vimReplaceChar, |
| vimFindCharForward, |
| vimFindCharBackward, |
| vimDeleteToCharForward, |
| vimDeleteToCharBackward, |
| vimInsertAtCursor, |
| vimAppendAtCursor, |
| vimOpenLineBelow, |
| vimOpenLineAbove, |
| vimAppendAtLineEnd, |
| vimInsertAtLineStart, |
| vimMoveToLineStart, |
| vimMoveToLineEnd, |
| vimMoveToFirstNonWhitespace, |
| vimMoveToFirstLine, |
| vimMoveToLastLine, |
| vimMoveToLine, |
| vimEscapeInsertMode, |
| vimYankLine, |
| vimYankWordForward, |
| vimYankBigWordForward, |
| vimYankWordEnd, |
| vimYankBigWordEnd, |
| vimYankToEndOfLine, |
| vimPasteAfter, |
| vimPasteBefore, |
| }), |
| [ |
| lines, |
| text, |
| cursorRow, |
| cursorCol, |
| preferredCol, |
| selectionAnchor, |
| pastedContent, |
| visualLines, |
| renderedVisualLines, |
| visualCursor, |
| visualScrollRow, |
| viewport.height, |
| visualToLogicalMap, |
| transformedToLogicalMaps, |
| visualToTransformedMap, |
| transformationsByLine, |
| visualLayout, |
| setText, |
| insert, |
| newline, |
| backspace, |
| del, |
| move, |
| undo, |
| redo, |
| replaceRange, |
| replaceRangeByOffset, |
| moveToOffset, |
| getOffset, |
| moveToVisualPosition, |
| getLogicalPositionFromVisual, |
| getExpandedPasteAtLineCallback, |
| togglePasteExpansion, |
| expandedPaste, |
| deleteWordLeft, |
| deleteWordRight, |
| killLineRight, |
| killLineLeft, |
| handleInput, |
| openInExternalEditor, |
| vimDeleteWordForward, |
| vimDeleteWordBackward, |
| vimDeleteWordEnd, |
| vimDeleteBigWordForward, |
| vimDeleteBigWordBackward, |
| vimDeleteBigWordEnd, |
| vimChangeWordForward, |
| vimChangeWordBackward, |
| vimChangeWordEnd, |
| vimChangeBigWordForward, |
| vimChangeBigWordBackward, |
| vimChangeBigWordEnd, |
| vimDeleteLine, |
| vimChangeLine, |
| vimDeleteToEndOfLine, |
| vimDeleteToStartOfLine, |
| vimChangeToEndOfLine, |
| vimDeleteToFirstNonWhitespace, |
| vimChangeToStartOfLine, |
| vimChangeToFirstNonWhitespace, |
| vimDeleteToFirstLine, |
| vimDeleteToLastLine, |
| vimChangeMovement, |
| vimMoveLeft, |
| vimMoveRight, |
| vimMoveUp, |
| vimMoveDown, |
| vimMoveWordForward, |
| vimMoveWordBackward, |
| vimMoveWordEnd, |
| vimMoveBigWordForward, |
| vimMoveBigWordBackward, |
| vimMoveBigWordEnd, |
| vimDeleteChar, |
| vimDeleteCharBefore, |
| vimToggleCase, |
| vimReplaceChar, |
| vimFindCharForward, |
| vimFindCharBackward, |
| vimDeleteToCharForward, |
| vimDeleteToCharBackward, |
| vimInsertAtCursor, |
| vimAppendAtCursor, |
| vimOpenLineBelow, |
| vimOpenLineAbove, |
| vimAppendAtLineEnd, |
| vimInsertAtLineStart, |
| vimMoveToLineStart, |
| vimMoveToLineEnd, |
| vimMoveToFirstNonWhitespace, |
| vimMoveToFirstLine, |
| vimMoveToLastLine, |
| vimMoveToLine, |
| vimEscapeInsertMode, |
| vimYankLine, |
| vimYankWordForward, |
| vimYankBigWordForward, |
| vimYankWordEnd, |
| vimYankBigWordEnd, |
| vimYankToEndOfLine, |
| vimPasteAfter, |
| vimPasteBefore, |
| ], |
| ); |
| return returnValue; |
| } |
|
|
| export interface TextBuffer { |
| |
| lines: string[]; |
| text: string; |
| cursor: [number, number]; |
| |
| |
| |
| |
| |
| |
| preferredCol: number | null; |
| selectionAnchor: [number, number] | null; |
| pastedContent: Record<string, string>; |
|
|
| |
| allVisualLines: string[]; |
| viewportVisualLines: string[]; |
| visualCursor: [number, number]; |
| visualScrollRow: number; |
| viewportHeight: number; |
| |
| |
| |
| |
| |
| visualToLogicalMap: Array<[number, number]>; |
| |
| |
| |
| |
| transformedToLogicalMaps: number[][]; |
| |
| |
| |
| |
| visualToTransformedMap: number[]; |
| |
| transformationsByLine: Transformation[][]; |
| visualLayout: VisualLayout; |
|
|
| |
|
|
| |
| |
| |
| |
| setText: (text: string, cursorPosition?: 'start' | 'end' | number) => void; |
| |
| |
| |
| insert: (ch: string, opts?: { paste?: boolean }) => void; |
| newline: () => void; |
| backspace: () => void; |
| del: () => void; |
| move: (dir: Direction) => void; |
| undo: () => void; |
| redo: () => void; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| replaceRange: ( |
| startRow: number, |
| startCol: number, |
| endRow: number, |
| endCol: number, |
| text: string, |
| ) => void; |
| |
| |
| |
| |
| |
| |
| deleteWordLeft: () => void; |
| |
| |
| |
| |
| |
| deleteWordRight: () => void; |
|
|
| |
| |
| |
| killLineRight: () => void; |
| |
| |
| |
| killLineLeft: () => void; |
| |
| |
| |
| handleInput: (key: Key) => boolean; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| openInExternalEditor: () => Promise<void>; |
|
|
| replaceRangeByOffset: ( |
| startOffset: number, |
| endOffset: number, |
| replacementText: string, |
| ) => void; |
| getOffset: () => number; |
| moveToOffset(offset: number): void; |
| moveToVisualPosition(visualRow: number, visualCol: number): void; |
| |
| |
| |
| |
| getLogicalPositionFromVisual( |
| visualRow: number, |
| visualCol: number, |
| ): { row: number; col: number } | null; |
| |
| |
| |
| |
| getExpandedPasteAtLine(lineIndex: number): string | null; |
| |
| |
| |
| |
| |
| togglePasteExpansion(id: string, row: number, col: number): void; |
| |
| |
| |
| expandedPaste: ExpandedPasteInfo | null; |
|
|
| |
| |
| |
| |
| vimDeleteWordForward: (count: number) => void; |
| |
| |
| |
| vimDeleteWordBackward: (count: number) => void; |
| |
| |
| |
| vimDeleteWordEnd: (count: number) => void; |
| |
| |
| |
| vimDeleteBigWordForward: (count: number) => void; |
| |
| |
| |
| vimDeleteBigWordBackward: (count: number) => void; |
| |
| |
| |
| vimDeleteBigWordEnd: (count: number) => void; |
| |
| |
| |
| vimChangeWordForward: (count: number) => void; |
| |
| |
| |
| vimChangeWordBackward: (count: number) => void; |
| |
| |
| |
| vimChangeWordEnd: (count: number) => void; |
| |
| |
| |
| vimChangeBigWordForward: (count: number) => void; |
| |
| |
| |
| vimChangeBigWordBackward: (count: number) => void; |
| |
| |
| |
| vimChangeBigWordEnd: (count: number) => void; |
| |
| |
| |
| vimDeleteLine: (count: number) => void; |
| |
| |
| |
| vimChangeLine: (count: number) => void; |
| |
| |
| |
| |
| vimDeleteToEndOfLine: (count?: number) => void; |
| |
| |
| |
| vimDeleteToStartOfLine: () => void; |
| |
| |
| |
| |
| vimChangeToEndOfLine: (count?: number) => void; |
| |
| |
| |
| vimDeleteToFirstNonWhitespace: () => void; |
| |
| |
| |
| vimChangeToStartOfLine: () => void; |
| |
| |
| |
| vimChangeToFirstNonWhitespace: () => void; |
| |
| |
| |
| vimDeleteToFirstLine: (count: number) => void; |
| |
| |
| |
| vimDeleteToLastLine: (count: number) => void; |
| |
| |
| |
| vimChangeMovement: (movement: 'h' | 'j' | 'k' | 'l', count: number) => void; |
| |
| |
| |
| vimMoveLeft: (count: number) => void; |
| |
| |
| |
| vimMoveRight: (count: number) => void; |
| |
| |
| |
| vimMoveUp: (count: number) => void; |
| |
| |
| |
| vimMoveDown: (count: number) => void; |
| |
| |
| |
| vimMoveWordForward: (count: number) => void; |
| |
| |
| |
| vimMoveWordBackward: (count: number) => void; |
| |
| |
| |
| vimMoveWordEnd: (count: number) => void; |
| |
| |
| |
| vimMoveBigWordForward: (count: number) => void; |
| |
| |
| |
| vimMoveBigWordBackward: (count: number) => void; |
| |
| |
| |
| vimMoveBigWordEnd: (count: number) => void; |
| |
| |
| |
| vimDeleteChar: (count: number) => void; |
| |
| vimDeleteCharBefore: (count: number) => void; |
| |
| vimToggleCase: (count: number) => void; |
| |
| vimReplaceChar: (char: string, count: number) => void; |
| |
| vimFindCharForward: (char: string, count: number, till: boolean) => void; |
| |
| vimFindCharBackward: (char: string, count: number, till: boolean) => void; |
| |
| vimDeleteToCharForward: (char: string, count: number, till: boolean) => void; |
| |
| vimDeleteToCharBackward: (char: string, count: number, till: boolean) => void; |
| |
| |
| |
| vimInsertAtCursor: () => void; |
| |
| |
| |
| vimAppendAtCursor: () => void; |
| |
| |
| |
| vimOpenLineBelow: () => void; |
| |
| |
| |
| vimOpenLineAbove: () => void; |
| |
| |
| |
| vimAppendAtLineEnd: () => void; |
| |
| |
| |
| vimInsertAtLineStart: () => void; |
| |
| |
| |
| vimMoveToLineStart: () => void; |
| |
| |
| |
| vimMoveToLineEnd: () => void; |
| |
| |
| |
| vimMoveToFirstNonWhitespace: () => void; |
| |
| |
| |
| vimMoveToFirstLine: () => void; |
| |
| |
| |
| vimMoveToLastLine: () => void; |
| |
| |
| |
| vimMoveToLine: (lineNumber: number) => void; |
| |
| |
| |
| vimEscapeInsertMode: () => void; |
| |
| vimYankLine: (count: number) => void; |
| |
| vimYankWordForward: (count: number) => void; |
| |
| vimYankBigWordForward: (count: number) => void; |
| |
| vimYankWordEnd: (count: number) => void; |
| |
| vimYankBigWordEnd: (count: number) => void; |
| |
| vimYankToEndOfLine: (count: number) => void; |
| |
| vimPasteAfter: (count: number) => void; |
| |
| vimPasteBefore: (count: number) => void; |
| } |
|
|