Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { PROJECT_COLOR_VALUES } from "shared/constants/project-colors";

export function assignRandomColor(): string {
return PROJECT_COLOR_VALUES[Math.floor(Math.random() * PROJECT_COLOR_VALUES.length)];
return PROJECT_COLOR_VALUES[
Math.floor(Math.random() * PROJECT_COLOR_VALUES.length)
];
}
135 changes: 9 additions & 126 deletions apps/desktop/src/main/lib/terminal-history.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { createReadStream, createWriteStream, promises as fs } from "node:fs";
import { createWriteStream, promises as fs } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import readline from "node:readline";

export interface HistoryDataEvent {
t: number; // timestamp
Expand All @@ -18,16 +17,6 @@ export interface HistoryExitEvent {

export type HistoryEvent = HistoryDataEvent | HistoryExitEvent;

export interface SessionMetadata {
cwd: string;
cols: number;
rows: number;
startedAt: string;
endedAt?: string;
exitCode?: number;
byteLength: number;
}

// Use environment variable or tmpdir for tests
const getBaseDir = () => {
if (process.env.NODE_ENV === "test" || process.env.BUN_ENV === "test") {
Expand All @@ -51,58 +40,24 @@ export function getHistoryFilePath(workspaceId: string, tabId: string): string {
return join(dir, "history.ndjson");
}

export function getMetadataPath(workspaceId: string, tabId: string): string {
const dir = getHistoryDir(workspaceId, tabId);
return join(dir, "meta.json");
}

export class HistoryWriter {
private writeStream: ReturnType<typeof createWriteStream> | null = null;
private byteLength = 0;
private metadata: SessionMetadata;
private filePath: string;
private metaPath: string;
private isFinalizing = false;
private finalizePromise: Promise<void> | null = null;
private finalized = false;

constructor(
private workspaceId: string,
private tabId: string,
cwd: string,
cols: number,
rows: number,
) {
this.filePath = getHistoryFilePath(workspaceId, tabId);
this.metaPath = getMetadataPath(workspaceId, tabId);
this.metadata = {
cwd,
cols,
rows,
startedAt: new Date().toISOString(),
byteLength: 0,
};
}

async init(): Promise<void> {
const dir = getHistoryDir(this.workspaceId, this.tabId);

await fs.mkdir(dir, { recursive: true });

try {
const stats = await fs.stat(this.filePath);
this.byteLength = stats.size;
} catch {
this.byteLength = 0;
}

this.metadata.byteLength = this.byteLength;

// We write raw NDJSON and compress on read for easier appending
this.writeStream = createWriteStream(this.filePath, { flags: "a" });
this.finalized = false;

await this.writeMetadata();
}

writeData(data: string): void {
Expand All @@ -119,15 +74,9 @@ export class HistoryWriter {

const line = `${JSON.stringify(event)}\n`;
this.writeStream.write(line);
this.byteLength += Buffer.byteLength(line);
}

async writeExit(exitCode?: number, signal?: number): Promise<void> {
if (this.isFinalizing || this.finalizePromise) {
await this.finalizePromise;
return;
}

if (!this.writeStream) {
console.warn("HistoryWriter not initialized");
return;
Expand All @@ -142,18 +91,15 @@ export class HistoryWriter {

const line = `${JSON.stringify(event)}\n`;
this.writeStream.write(line);
this.byteLength += Buffer.byteLength(line);

await this.finalize(exitCode);
await this.finalize();
}

async finalize(exitCode?: number): Promise<void> {
async finalize(): Promise<void> {
if (this.finalizePromise) {
return this.finalizePromise;
}

this.isFinalizing = true;
this.finalized = true;
this.finalizePromise = (async () => {
if (this.writeStream) {
await new Promise<void>((resolve, reject) => {
Expand All @@ -163,32 +109,15 @@ export class HistoryWriter {
});
this.writeStream = null;
}

if (!this.metadata.endedAt) {
this.metadata.endedAt = new Date().toISOString();
}
if (exitCode !== undefined) {
this.metadata.exitCode = exitCode;
}
this.metadata.byteLength = this.byteLength;
await this.writeMetadata();
})().finally(() => {
this.isFinalizing = false;
this.finalizePromise = null;
});

return this.finalizePromise;
}

private async writeMetadata(): Promise<void> {
try {
await fs.writeFile(this.metaPath, JSON.stringify(this.metadata, null, 2));
} catch (error) {
console.error("Failed to write metadata:", error);
}
}

isOpen(): boolean {
return this.writeStream !== null && !this.finalized;
return this.writeStream !== null;
}
}

Expand All @@ -201,7 +130,6 @@ export class HistoryReader {
async getLatestSession(): Promise<{
scrollback: string;
wasRecovered: boolean;
metadata?: SessionMetadata;
}> {
try {
const filePath = getHistoryFilePath(this.workspaceId, this.tabId);
Expand All @@ -212,21 +140,11 @@ export class HistoryReader {
return { scrollback: "", wasRecovered: false };
}

let metadata: SessionMetadata | undefined;
try {
const metaPath = getMetadataPath(this.workspaceId, this.tabId);
const metaContent = await fs.readFile(metaPath, "utf-8");
metadata = JSON.parse(metaContent);
} catch {
// Metadata not available
}

const scrollback = await this.decodeHistory(filePath);

return {
scrollback,
wasRecovered: scrollback.length > 0,
metadata,
};
} catch (error) {
console.error("Failed to read history:", error);
Expand All @@ -235,60 +153,25 @@ export class HistoryReader {
}

private async decodeHistory(filePath: string): Promise<string> {
const MAX_CHARS = 100000;
const MAX_BYTES_TO_READ = 500000;

try {
const stats = await fs.stat(filePath);
const fileSize = stats.size;

if (fileSize === 0) {
return "";
}

const startPos = Math.max(0, fileSize - MAX_BYTES_TO_READ);

const readStream = createReadStream(filePath, {
start: startPos,
});

const rl = readline.createInterface({
input: readStream,
crlfDelay: Number.POSITIVE_INFINITY,
});

let scrollback = "";
let isFirstLine = true;

for await (const line of rl) {
// Skip first partial line if we started mid-file
if (isFirstLine && startPos > 0) {
isFirstLine = false;
continue;
}
const content = await fs.readFile(filePath, "utf-8");
const lines = content.split("\n");

for (const line of lines) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line) as HistoryEvent;

if (event.type === "data") {
const data = Buffer.from(event.data, "base64").toString();
scrollback += data;

// Trim periodically to prevent memory issues, but keep reading to the end
if (scrollback.length > MAX_CHARS * 2) {
scrollback = scrollback.slice(-MAX_CHARS);
}
}
} catch {
// Skip malformed lines
}
}

// Final trim to MAX_CHARS to ensure we return the most recent data
if (scrollback.length > MAX_CHARS) {
scrollback = scrollback.slice(-MAX_CHARS);
}

return scrollback;
} catch (error) {
console.error("Failed to decode history:", error);
Expand Down
12 changes: 8 additions & 4 deletions apps/desktop/src/main/lib/terminal-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export class TerminalManager extends EventEmitter {
private sessions = new Map<string, TerminalSession>();
private readonly DEFAULT_COLS = 80;
private readonly DEFAULT_ROWS = 24;
private readonly MAX_SCROLLBACK_CHARS = 100000;

async createOrAttach(params: {
tabId: string;
Expand All @@ -61,6 +62,7 @@ export class TerminalManager extends EventEmitter {
if (cols !== undefined && rows !== undefined) {
this.resize({ tabId, cols, rows });
}

return {
isNew: false,
scrollback: existing.scrollback,
Expand Down Expand Up @@ -89,7 +91,8 @@ export class TerminalManager extends EventEmitter {

// Spawn as login shell (-l for zsh/bash) to source profile files
// This ensures pyenv, nvm, etc. are initialized before .zshrc runs
const shellArgs = shell.includes("zsh") || shell.includes("bash") ? ["-l"] : [];
const shellArgs =
shell.includes("zsh") || shell.includes("bash") ? ["-l"] : [];

const ptyProcess = pty.spawn(shell, shellArgs, {
name: "xterm-256color",
Expand Down Expand Up @@ -319,9 +322,10 @@ export class TerminalManager extends EventEmitter {
session.scrollback[0] += data;
}

const MAX_CHARS = 50000;
if (session.scrollback[0].length > MAX_CHARS) {
session.scrollback[0] = session.scrollback[0].slice(-MAX_CHARS);
if (session.scrollback[0].length > this.MAX_SCROLLBACK_CHARS) {
session.scrollback[0] = session.scrollback[0].slice(
-this.MAX_SCROLLBACK_CHARS,
);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,6 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => {
}) => {
if (result.wasRecovered && result.scrollback.length > 0) {
xterm.write(result.scrollback[0]);
xterm.write("\r\n\r\n\x1b[2m[Recovered session history]\x1b[0m\r\n");
} else if (!result.isNew && result.scrollback.length > 0) {
xterm.write(result.scrollback[0]);
}
Expand Down
Loading
Loading