diff --git a/src/filesystem/lib.ts b/src/filesystem/lib.ts index ce4af9f38a..ef268884ea 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -4,6 +4,7 @@ import os from 'os'; import { randomBytes } from 'crypto'; import { diffLines, createTwoFilesPatch } from 'diff'; import { minimatch } from 'minimatch'; +import { StringDecoder } from 'string_decoder'; import { normalizePath, expandHome } from './path-utils.js'; import { isPathWithinAllowedDirectories } from './path-validation.js'; @@ -281,9 +282,9 @@ export async function applyFileEdits( return formattedDiff; } -// Memory-efficient implementation to get the last N lines of a file +// Read the last N lines of a file (UTF-8 safe) export async function tailFile(filePath: string, numLines: number): Promise { - const CHUNK_SIZE = 1024; // Read 1KB at a time + const CHUNK_SIZE = 1024; const stats = await fs.stat(filePath); const fileSize = stats.size; @@ -292,48 +293,52 @@ export async function tailFile(filePath: string, numLines: number): Promise 0 && linesFound < numLines) { + // Read from the end until we have enough lines or reach start + while (position > 0 && newlineCount <= numLines) { const size = Math.min(CHUNK_SIZE, position); position -= size; const { bytesRead } = await fileHandle.read(chunk, 0, size, position); if (!bytesRead) break; - // Get the chunk as a string and prepend any remaining text from previous iteration - const readData = chunk.slice(0, bytesRead).toString('utf-8'); - const chunkText = readData + remainingText; - - // Split by newlines and count - const chunkLines = normalizeLineEndings(chunkText).split('\n'); + const data = chunk.slice(0, bytesRead); + rawBuffers.push(data); - // If this isn't the end of the file, the first line is likely incomplete - // Save it to prepend to the next chunk - if (position > 0) { - remainingText = chunkLines[0]; - chunkLines.shift(); // Remove the first (incomplete) line - } - - // Add lines to our result (up to the number we need) - for (let i = chunkLines.length - 1; i >= 0 && linesFound < numLines; i--) { - lines.unshift(chunkLines[i]); - linesFound++; + // Count newlines in raw bytes (0x0A is single-byte even in UTF-8) + for (let i = 0; i < data.length; i++) { + if (data[i] === 0x0A) newlineCount++; } } - return lines.join('\n'); + // Reverse to get forward chronological order + rawBuffers.reverse(); + + // Decode the concatenated buffer with StringDecoder (handles cross-chunk UTF-8) + const decoder = new StringDecoder('utf-8'); + const fullText = rawBuffers.map(b => decoder.write(b)).join(''); + const finalChunk = decoder.end(); + + // Split into lines and take the last numLines + const allLines = normalizeLineEndings(fullText + finalChunk).split('\n'); + + // Filter out the last empty element if the file ends with newline + const relevantLines = allLines.filter(Boolean).length > numLines + ? allLines.slice(-numLines - 1) // include empty trailing line + : allLines; + + return allLines.slice(-numLines).join('\n'); } finally { await fileHandle.close(); } } -// New function to get the first N lines of a file +// Read the first N lines of a file (UTF-8 safe) export async function headFile(filePath: string, numLines: number): Promise { const fileHandle = await fs.open(filePath, 'r'); try { @@ -341,13 +346,14 @@ export async function headFile(filePath: string, numLines: number): Promise 0 && lines.length < numLines) { lines.push(buffer); }