8bd7310cb4
- Added vim tool-call IDs across EventController, UiHelpers, and ToolExecution for per-call preview mapping. - Extended Vim type and command parsing with update/write, edit, global, yank, and put handling. - Added Vim engine support for new commands and motions, including gJ, g*, g#, g_, |, and gu/gU/g~. - Reworked Vim tool flow to cache per-file engine clones, reuse tool details, and stream inserts in chunks. - Updated vim.md and changelog to document new vim keys, ex aliases, and fixed :global preview behavior. - Added parser and renderer tests plus a tmp/vim_test.txt fixture for chunked, cursor, and partial-insert cases.
310 lines
9.4 KiB
TypeScript
310 lines
9.4 KiB
TypeScript
import { clonePosition, type Position, type VimBufferSnapshot, type VimFingerprint, type VimLoadedFile } from "./types";
|
|
|
|
function splitText(text: string): string[] {
|
|
if (text.length === 0) {
|
|
return [""];
|
|
}
|
|
return text.split("\n");
|
|
}
|
|
|
|
export function snapshotEqual(left: VimBufferSnapshot, right: VimBufferSnapshot): boolean {
|
|
if (
|
|
left.displayPath !== right.displayPath ||
|
|
left.filePath !== right.filePath ||
|
|
left.modified !== right.modified ||
|
|
left.trailingNewline !== right.trailingNewline ||
|
|
left.cursor.line !== right.cursor.line ||
|
|
left.cursor.col !== right.cursor.col ||
|
|
left.editabilityChecked !== right.editabilityChecked
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
if (left.baseFingerprint === null || right.baseFingerprint === null) {
|
|
if (left.baseFingerprint !== right.baseFingerprint) {
|
|
return false;
|
|
}
|
|
} else if (
|
|
left.baseFingerprint.exists !== right.baseFingerprint.exists ||
|
|
left.baseFingerprint.size !== right.baseFingerprint.size ||
|
|
left.baseFingerprint.mtimeMs !== right.baseFingerprint.mtimeMs ||
|
|
left.baseFingerprint.hash !== right.baseFingerprint.hash
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
if (left.lines.length !== right.lines.length) {
|
|
return false;
|
|
}
|
|
|
|
for (let index = 0; index < left.lines.length; index += 1) {
|
|
if (left.lines[index] !== right.lines[index]) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
export class VimBuffer {
|
|
displayPath: string;
|
|
filePath: string;
|
|
lines: string[];
|
|
cursor: Position;
|
|
modified: boolean;
|
|
trailingNewline: boolean;
|
|
baseFingerprint: VimFingerprint | null;
|
|
editabilityChecked: boolean;
|
|
|
|
constructor(input: VimLoadedFile) {
|
|
this.displayPath = input.displayPath;
|
|
this.filePath = input.absolutePath;
|
|
this.lines = input.lines.length > 0 ? [...input.lines] : [""];
|
|
this.cursor = { line: 0, col: 0 };
|
|
this.modified = false;
|
|
this.trailingNewline = input.trailingNewline;
|
|
this.baseFingerprint = input.fingerprint ? { ...input.fingerprint } : null;
|
|
this.editabilityChecked = false;
|
|
}
|
|
|
|
clone(): VimBuffer {
|
|
const clone = new VimBuffer({
|
|
absolutePath: this.filePath,
|
|
displayPath: this.displayPath,
|
|
lines: [...this.lines],
|
|
trailingNewline: this.trailingNewline,
|
|
fingerprint: this.baseFingerprint ? { ...this.baseFingerprint } : null,
|
|
});
|
|
clone.cursor = clonePosition(this.cursor);
|
|
clone.modified = this.modified;
|
|
clone.editabilityChecked = this.editabilityChecked;
|
|
return clone;
|
|
}
|
|
|
|
createSnapshot(): VimBufferSnapshot {
|
|
return {
|
|
displayPath: this.displayPath,
|
|
filePath: this.filePath,
|
|
lines: [...this.lines],
|
|
cursor: clonePosition(this.cursor),
|
|
modified: this.modified,
|
|
trailingNewline: this.trailingNewline,
|
|
baseFingerprint: this.baseFingerprint ? { ...this.baseFingerprint } : null,
|
|
editabilityChecked: this.editabilityChecked,
|
|
};
|
|
}
|
|
|
|
restore(snapshot: VimBufferSnapshot): void {
|
|
this.displayPath = snapshot.displayPath;
|
|
this.filePath = snapshot.filePath;
|
|
this.lines = snapshot.lines.length > 0 ? [...snapshot.lines] : [""];
|
|
this.cursor = clonePosition(snapshot.cursor);
|
|
this.modified = snapshot.modified;
|
|
this.trailingNewline = snapshot.trailingNewline;
|
|
this.baseFingerprint = snapshot.baseFingerprint ? { ...snapshot.baseFingerprint } : null;
|
|
this.editabilityChecked = snapshot.editabilityChecked;
|
|
this.clampCursor();
|
|
}
|
|
|
|
replaceLoadedFile(input: VimLoadedFile): void {
|
|
this.displayPath = input.displayPath;
|
|
this.filePath = input.absolutePath;
|
|
this.lines = input.lines.length > 0 ? [...input.lines] : [""];
|
|
this.cursor = { line: 0, col: 0 };
|
|
this.modified = false;
|
|
this.trailingNewline = input.trailingNewline;
|
|
this.baseFingerprint = input.fingerprint ? { ...input.fingerprint } : null;
|
|
this.editabilityChecked = false;
|
|
}
|
|
|
|
markSaved(input: VimLoadedFile): void {
|
|
this.lines = input.lines.length > 0 ? [...input.lines] : [""];
|
|
this.modified = false;
|
|
this.trailingNewline = input.trailingNewline;
|
|
this.baseFingerprint = input.fingerprint ? { ...input.fingerprint } : null;
|
|
this.clampCursor();
|
|
}
|
|
|
|
lineCount(): number {
|
|
return this.lines.length;
|
|
}
|
|
|
|
lastLineIndex(): number {
|
|
return Math.max(0, this.lines.length - 1);
|
|
}
|
|
|
|
getLine(line: number): string {
|
|
return this.lines[this.clampLine(line)] ?? "";
|
|
}
|
|
|
|
clampLine(line: number): number {
|
|
return Math.min(Math.max(line, 0), this.lastLineIndex());
|
|
}
|
|
|
|
clampCol(line: number, col: number): number {
|
|
return Math.min(Math.max(col, 0), this.getLine(line).length);
|
|
}
|
|
|
|
setCursor(position: Position): void {
|
|
this.cursor = {
|
|
line: this.clampLine(position.line),
|
|
col: this.clampCol(position.line, position.col),
|
|
};
|
|
}
|
|
|
|
clampCursor(): void {
|
|
this.setCursor(this.cursor);
|
|
}
|
|
|
|
firstNonBlank(line: number): number {
|
|
const content = this.getLine(line);
|
|
const index = content.search(/\S/);
|
|
return index === -1 ? 0 : index;
|
|
}
|
|
|
|
getText(): string {
|
|
return this.lines.join("\n");
|
|
}
|
|
|
|
setText(text: string, trailingNewline = this.trailingNewline): void {
|
|
const normalizedText = trailingNewline && text.endsWith("\n") ? text.slice(0, -1) : text;
|
|
this.lines = splitText(normalizedText);
|
|
this.trailingNewline = trailingNewline;
|
|
this.clampCursor();
|
|
}
|
|
|
|
currentOffset(): number {
|
|
return this.positionToOffset(this.cursor);
|
|
}
|
|
|
|
positionToOffset(position: Position): number {
|
|
const line = this.clampLine(position.line);
|
|
const col = this.clampCol(line, position.col);
|
|
let offset = 0;
|
|
for (let index = 0; index < line; index += 1) {
|
|
offset += this.lines[index]!.length + 1;
|
|
}
|
|
return offset + col;
|
|
}
|
|
|
|
offsetToPosition(offset: number): Position {
|
|
const text = this.getText();
|
|
const clamped = Math.min(Math.max(offset, 0), text.length);
|
|
let remaining = clamped;
|
|
for (let line = 0; line < this.lines.length; line += 1) {
|
|
const current = this.lines[line]!;
|
|
if (remaining <= current.length) {
|
|
return { line, col: remaining };
|
|
}
|
|
remaining -= current.length;
|
|
if (line < this.lines.length - 1) {
|
|
if (remaining === 0) {
|
|
return { line: line + 1, col: 0 };
|
|
}
|
|
remaining -= 1;
|
|
}
|
|
}
|
|
return { line: this.lastLineIndex(), col: this.getLine(this.lastLineIndex()).length };
|
|
}
|
|
|
|
setCursorFromOffset(offset: number): void {
|
|
this.cursor = this.offsetToPosition(offset);
|
|
}
|
|
|
|
replaceOffsets(start: number, end: number, replacement: string, cursorOffset = start + replacement.length): void {
|
|
const text = this.getText();
|
|
const normalizedStart = Math.min(Math.max(start, 0), text.length);
|
|
const normalizedEnd = Math.min(Math.max(end, normalizedStart), text.length);
|
|
const nextText = `${text.slice(0, normalizedStart)}${replacement}${text.slice(normalizedEnd)}`;
|
|
// getText() omits the trailing-newline marker, so any \n in the
|
|
// replacement is content (a line separator), not a file-trailing newline.
|
|
// Bypass setText() which would incorrectly strip it.
|
|
this.lines = splitText(nextText);
|
|
this.clampCursor();
|
|
this.setCursorFromOffset(cursorOffset);
|
|
}
|
|
|
|
deleteOffsets(start: number, end: number): string {
|
|
const text = this.getText();
|
|
const normalizedStart = Math.min(Math.max(start, 0), text.length);
|
|
const normalizedEnd = Math.min(Math.max(end, normalizedStart), text.length);
|
|
const removed = text.slice(normalizedStart, normalizedEnd);
|
|
this.replaceOffsets(normalizedStart, normalizedEnd, "", normalizedStart);
|
|
return removed;
|
|
}
|
|
|
|
deleteLines(startLine: number, endLine: number): string[] {
|
|
const start = this.clampLine(Math.min(startLine, endLine));
|
|
const end = this.clampLine(Math.max(startLine, endLine));
|
|
const removed = this.lines.slice(start, end + 1);
|
|
this.lines.splice(start, end - start + 1);
|
|
if (this.lines.length === 0) {
|
|
this.lines = [""];
|
|
}
|
|
this.setCursor({ line: Math.min(start, this.lastLineIndex()), col: 0 });
|
|
if (this.lines.length > 1 || removed.length > 1) {
|
|
this.trailingNewline = true;
|
|
}
|
|
return removed;
|
|
}
|
|
|
|
insertLines(index: number, newLines: string[]): void {
|
|
const at = Math.min(Math.max(index, 0), this.lines.length);
|
|
const normalized = newLines.length > 0 ? newLines : [""];
|
|
this.lines.splice(at, 0, ...normalized);
|
|
this.setCursor({ line: at, col: 0 });
|
|
this.trailingNewline = true;
|
|
}
|
|
|
|
replaceLine(line: number, content: string): void {
|
|
const target = this.clampLine(line);
|
|
this.lines[target] = content;
|
|
this.setCursor(this.cursor);
|
|
}
|
|
|
|
joinLines(startLine: number, count: number): void {
|
|
const start = this.clampLine(startLine);
|
|
const end = this.clampLine(start + Math.max(count, 1));
|
|
if (start >= end) {
|
|
return;
|
|
}
|
|
const joined = this.lines
|
|
.slice(start, end + 1)
|
|
.map(line => line.trim())
|
|
.join(" ");
|
|
this.lines.splice(start, end - start + 1, joined);
|
|
this.setCursor({ line: start, col: Math.max(0, joined.length - 1) });
|
|
}
|
|
|
|
indentLines(startLine: number, endLine: number, indentUnit: string, direction: 1 | -1): void {
|
|
const start = this.clampLine(Math.min(startLine, endLine));
|
|
const end = this.clampLine(Math.max(startLine, endLine));
|
|
for (let line = start; line <= end; line += 1) {
|
|
const content = this.lines[line] ?? "";
|
|
if (direction > 0) {
|
|
this.lines[line] = `${indentUnit}${content}`;
|
|
continue;
|
|
}
|
|
if (content.startsWith(indentUnit)) {
|
|
this.lines[line] = content.slice(indentUnit.length);
|
|
continue;
|
|
}
|
|
const spaces = content.match(/^ +/)?.[0].length ?? 0;
|
|
this.lines[line] = content.slice(Math.min(spaces, indentUnit.length));
|
|
}
|
|
this.setCursor(this.cursor);
|
|
}
|
|
|
|
getCharacterAtOffset(offset: number): string {
|
|
const text = this.getText();
|
|
if (offset < 0 || offset >= text.length) {
|
|
return "";
|
|
}
|
|
return text[offset] ?? "";
|
|
}
|
|
|
|
getCharacter(position: Position): string {
|
|
return this.getCharacterAtOffset(this.positionToOffset(position));
|
|
}
|
|
}
|