diff --git a/src/ui/app.ts b/src/ui/app.ts index aa1f7a9..145f3a0 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -247,7 +247,7 @@ export class App { if (name === SHORTCUTS.clear.key) { this.panes.get(this.activePane)?.clear() - this.logWriter.truncate(this.activePane) + this.logWriter.markCopyStart(this.activePane) return } @@ -413,13 +413,11 @@ export class App { } } - /** Copy all text in the active pane to clipboard. */ + /** Copy all text in the active pane to clipboard (unwrapped, from log file). */ private copyAllText(): void { if (!this.activePane) return - const pane = this.panes.get(this.activePane) - if (!pane) return - const text = pane.getText() + const text = this.logWriter.readLog(this.activePane) if (!text) { this.statusBar.showTemporaryMessage('No output to copy') return diff --git a/src/utils/log-writer.test.ts b/src/utils/log-writer.test.ts index cea6d07..cc01812 100644 --- a/src/utils/log-writer.test.ts +++ b/src/utils/log-writer.test.ts @@ -128,20 +128,76 @@ describe('LogWriter', () => { writer.close() }) - test('truncate clears log file', () => { + test('readLog returns all content without markCopyStart', () => { + const writer = new LogWriter(dir) + writer.handleEvent(outputEvent('api', 'line 1\n')) + writer.handleEvent(outputEvent('api', 'line 2\n')) + + const content = writer.readLog('api') + expect(content).toBe('line 1\nline 2\n') + writer.close() + }) + + test('readLog returns undefined for unknown process', () => { + const writer = new LogWriter(dir) + expect(writer.readLog('unknown')).toBeUndefined() + writer.close() + }) + + test('readLog returns undefined when no output after markCopyStart', () => { const writer = new LogWriter(dir) writer.handleEvent(outputEvent('api', 'old content\n')) + writer.markCopyStart('api') - const contentBefore = readFileSync(join(dir, 'api.log'), 'utf-8') - expect(contentBefore).toContain('old content') + expect(writer.readLog('api')).toBeUndefined() + writer.close() + }) - writer.truncate('api') + test('markCopyStart makes readLog return only new content', () => { + const writer = new LogWriter(dir) + writer.handleEvent(outputEvent('api', 'old content\n')) + writer.markCopyStart('api') writer.handleEvent(outputEvent('api', 'new content\n')) + + const content = writer.readLog('api') + expect(content).toBe('new content\n') + expect(content).not.toContain('old content') + + // Full file still has everything + const fullFile = readFileSync(join(dir, 'api.log'), 'utf-8') + expect(fullFile).toContain('old content') + expect(fullFile).toContain('new content') + writer.close() + }) + + test('markCopyStart can be called multiple times', () => { + const writer = new LogWriter(dir) + writer.handleEvent(outputEvent('api', 'first\n')) + writer.markCopyStart('api') + writer.handleEvent(outputEvent('api', 'second\n')) + writer.markCopyStart('api') + writer.handleEvent(outputEvent('api', 'third\n')) + + const content = writer.readLog('api') + expect(content).toBe('third\n') writer.close() + }) - const contentAfter = readFileSync(join(dir, 'api.log'), 'utf-8') - expect(contentAfter).toBe('new content\n') - expect(contentAfter).not.toContain('old content') + test('markCopyStart on unknown process is a no-op', () => { + const writer = new LogWriter(dir) + writer.markCopyStart('unknown') // should not throw + expect(writer.readLog('unknown')).toBeUndefined() + writer.close() + }) + + test('readLog strips ANSI from content', () => { + const writer = new LogWriter(dir) + writer.handleEvent(outputEvent('api', '\x1b[31mred\x1b[0m\n')) + + const content = writer.readLog('api') + expect(content).toBe('red\n') + expect(content).not.toContain('\x1b') + writer.close() }) test('createTemp creates a temp directory', () => { diff --git a/src/utils/log-writer.ts b/src/utils/log-writer.ts index 1e15081..b2d38e7 100644 --- a/src/utils/log-writer.ts +++ b/src/utils/log-writer.ts @@ -1,4 +1,4 @@ -import { closeSync, mkdirSync, openSync, rmSync, symlinkSync, unlinkSync, writeSync } from 'node:fs' +import { closeSync, mkdirSync, openSync, readSync, rmSync, symlinkSync, unlinkSync, writeSync } from 'node:fs' import { tmpdir } from 'node:os' import { basename, join } from 'node:path' import type { ProcessEvent } from '../types' @@ -17,6 +17,7 @@ export class LogWriter { private dir: string private isTemp: boolean private files = new Map() + private copyOffsets = new Map() private decoder = new TextDecoder() private encoder = new TextEncoder() @@ -95,6 +96,38 @@ export class LogWriter { } } + /** Mark the current end of the log file as the start point for readLog. */ + markCopyStart(name: string): void { + const path = this.getLogPath(name) + if (!path) return + try { + this.copyOffsets.set(name, Bun.file(path).size) + } catch { + // Ignore — file may not exist yet + } + } + + /** Read log file content for a process (from last clear point). */ + readLog(name: string): string | undefined { + const path = this.getLogPath(name) + if (!path) return undefined + try { + const size = Bun.file(path).size + const offset = this.copyOffsets.get(name) ?? 0 + if (size <= offset) return undefined + const fd = openSync(path, 'r') + try { + const buf = Buffer.alloc(size - offset) + readSync(fd, buf, 0, buf.length, offset) + return buf.toString('utf-8') + } finally { + closeSync(fd) + } + } catch { + return undefined + } + } + /** Get the log file path for a process, or undefined if no output yet. */ getLogPath(name: string): string | undefined { if (this.files.has(name)) { @@ -207,20 +240,6 @@ export class LogWriter { } } - /** Truncate a process's log file (used when pane is cleared). */ - truncate(name: string): void { - const fd = this.files.get(name) - if (fd === undefined) return - try { - closeSync(fd) - const path = join(this.dir, `${name}.log`) - const newFd = openSync(path, 'w') - this.files.set(name, newFd) - } catch { - // Ignore errors — file may have been deleted - } - } - close(): void { for (const fd of this.files.values()) { closeSync(fd)