diff --git a/apps/desktop/src/lib/trpc/routers/projects/utils/colors/colors.ts b/apps/desktop/src/lib/trpc/routers/projects/utils/colors/colors.ts index c43b9bb9271..656864ad077 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/utils/colors/colors.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/utils/colors/colors.ts @@ -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) + ]; } diff --git a/apps/desktop/src/main/lib/terminal-history.ts b/apps/desktop/src/main/lib/terminal-history.ts index 1e332477460..46ffa5ca112 100644 --- a/apps/desktop/src/main/lib/terminal-history.ts +++ b/apps/desktop/src/main/lib/terminal-history.ts @@ -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 @@ -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") { @@ -51,37 +40,16 @@ 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 | null = null; - private byteLength = 0; - private metadata: SessionMetadata; private filePath: string; - private metaPath: string; - private isFinalizing = false; private finalizePromise: Promise | 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 { @@ -89,20 +57,7 @@ export class HistoryWriter { 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 { @@ -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 { - if (this.isFinalizing || this.finalizePromise) { - await this.finalizePromise; - return; - } - if (!this.writeStream) { console.warn("HistoryWriter not initialized"); return; @@ -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 { + async finalize(): Promise { if (this.finalizePromise) { return this.finalizePromise; } - this.isFinalizing = true; - this.finalized = true; this.finalizePromise = (async () => { if (this.writeStream) { await new Promise((resolve, reject) => { @@ -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 { - 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; } } @@ -201,7 +130,6 @@ export class HistoryReader { async getLatestSession(): Promise<{ scrollback: string; wasRecovered: boolean; - metadata?: SessionMetadata; }> { try { const filePath = getHistoryFilePath(this.workspaceId, this.tabId); @@ -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); @@ -235,60 +153,25 @@ export class HistoryReader { } private async decodeHistory(filePath: string): Promise { - 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); diff --git a/apps/desktop/src/main/lib/terminal-manager.ts b/apps/desktop/src/main/lib/terminal-manager.ts index 21830868e08..24620d55396 100644 --- a/apps/desktop/src/main/lib/terminal-manager.ts +++ b/apps/desktop/src/main/lib/terminal-manager.ts @@ -38,6 +38,7 @@ export class TerminalManager extends EventEmitter { private sessions = new Map(); private readonly DEFAULT_COLS = 80; private readonly DEFAULT_ROWS = 24; + private readonly MAX_SCROLLBACK_CHARS = 100000; async createOrAttach(params: { tabId: string; @@ -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, @@ -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", @@ -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, + ); } } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index a9a09d68aeb..ecc81849d6f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -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]); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx index a989acbd084..4f5b25c24e3 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx @@ -11,95 +11,94 @@ import { useTabContentDrop } from "./useTabContentDrop"; interface RenderTabContentProps { tab: Tab; + activeTabId: string | null; isDropZone: boolean; } -function renderTabContent({ tab, isDropZone }: RenderTabContentProps) { - switch (tab.type) { - case TabType.Setup: - return ; - case TabType.Single: - return ; - case TabType.Group: - return ; - default: - return null; - } -} +function renderTabContent({ + tab, + activeTabId, + isDropZone, +}: RenderTabContentProps) { + const isActive = tab.id === activeTabId; + const content = (() => { + switch (tab.type) { + case TabType.Setup: + return ; + case TabType.Single: + return ; + case TabType.Group: + return ; + default: + return null; + } + })(); -interface RenderTabsProps { - tabs: Tab[]; - activeTabId: string | null; - isDropZone: boolean; -} + const style: React.CSSProperties = { + visibility: isActive ? "visible" : "hidden", + pointerEvents: isActive ? "auto" : "none", + }; -function renderTabs({ tabs, activeTabId, isDropZone }: RenderTabsProps) { - return tabs.map((tab) => { - const isActive = tab.id === activeTabId; - return ( -
- {renderTabContent({ tab, isDropZone: isActive && isDropZone })} -
- ); - }); + return ( +
+ {content} +
+ ); } export function TabsContent() { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const activeWorkspaceId = activeWorkspace?.id; - const allTabs = useTabs(); + const tabs = useTabs(); const activeTabIds = useActiveTabIds(); - const { tabToRender, workspaceTabs } = useMemo(() => { - if (!activeWorkspaceId) return { tabToRender: null, workspaceTabs: [] }; - const activeTabId = activeTabIds[activeWorkspaceId]; + const { tabToRender, allTabs } = useMemo(() => { + // Get all top-level tabs (tabs without parent) across all workspaces + const allTabs = tabs.filter((tab) => !tab.parentId); - // Get all top-level tabs (tabs without parent) for this workspace - const workspaceTabs = allTabs.filter( - (tab) => tab.workspaceId === activeWorkspaceId && !tab.parentId, - ); + if (!activeWorkspaceId) { + return { tabToRender: null, allTabs }; + } + + const activeTabId = activeTabIds[activeWorkspaceId]; if (!activeTabId) { - return { tabToRender: null, workspaceTabs }; + return { tabToRender: null, allTabs }; } - const activeTab = allTabs.find((tab) => tab.id === activeTabId); + const activeTab = tabs.find((tab) => tab.id === activeTabId); if (!activeTab) { - return { tabToRender: null, workspaceTabs }; + return { tabToRender: null, allTabs }; } let displayTab = activeTab; if (activeTab.parentId) { - const parentGroup = allTabs.find((tab) => tab.id === activeTab.parentId); + const parentGroup = tabs.find((tab) => tab.id === activeTab.parentId); displayTab = parentGroup || activeTab; } - return { tabToRender: displayTab, workspaceTabs }; - }, [activeWorkspaceId, activeTabIds, allTabs]); + return { tabToRender: displayTab, allTabs }; + }, [activeWorkspaceId, activeTabIds, tabs]); const { isDropZone, attachDrop } = useTabContentDrop(tabToRender); + const activeTabId = tabToRender?.id ?? null; + if (!tabToRender) { return ( -
+
- {/* Render all workspace tabs hidden to preserve terminal scrollback */} - {workspaceTabs.map((tab) => ( -
- {renderTabContent({ tab, isDropZone: false })} -
- ))} + {allTabs.map((tab) => { + return ( +
+ {renderTabContent({ + tab, + activeTabId: null, + isDropZone: false, + })} +
+ ); + })}
); } @@ -111,11 +110,16 @@ export function TabsContent() { return (
- {/* Render all workspace tabs - active visible, others hidden (xterm.js auto-pauses) */} - {renderTabs({ - tabs: workspaceTabs, - activeTabId: tabToRender.id, - isDropZone, + {allTabs.map((tab) => { + return ( +
+ {renderTabContent({ + tab, + activeTabId, + isDropZone, + })} +
+ ); })} {isDropZone && }