Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions src/ui/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
70 changes: 63 additions & 7 deletions src/utils/log-writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
49 changes: 34 additions & 15 deletions src/utils/log-writer.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -17,6 +17,7 @@ export class LogWriter {
private dir: string
private isTemp: boolean
private files = new Map<string, number>()
private copyOffsets = new Map<string, number>()
private decoder = new TextDecoder()
private encoder = new TextEncoder()

Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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)
Expand Down
Loading