Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
69550ce
chore: delete unused file
jamesmurdza Feb 23, 2026
666a8a2
feat: synchronize terminals between windows in the same project
jamesmurdza Feb 23, 2026
b182faf
chore: update package-lock.json
jamesmurdza Feb 23, 2026
49790c4
feat: retain terminals when the last window is closed
jamesmurdza Feb 23, 2026
2caa727
fix: only automatically create a new terminal when there are none alr…
jamesmurdza Feb 23, 2026
74a051a
fix: use correct event name for resize
jamesmurdza Feb 23, 2026
cab4fbc
fix: resize PTYs individually
jamesmurdza Feb 23, 2026
e1a8bb6
feat: update PTY size when active terminal changes
jamesmurdza Feb 23, 2026
625880f
feat: update PTY size when terminal is focused
jamesmurdza Feb 23, 2026
9ca0d56
feat: register terminalResponse handler before creating terminals
jamesmurdza Feb 23, 2026
3f525d8
feat: buffer terminal screen state to show in newly opened windows
jamesmurdza Feb 23, 2026
8ae99a6
chore: simplify resize notification code
jamesmurdza Feb 24, 2026
63d6b8a
fix: guard addPanel from adding terminal panels with duplicate IDs
jamesmurdza Feb 24, 2026
e9ab395
fix: add debounce to terminal resize handler
jamesmurdza Feb 24, 2026
0ebbcc6
fix: check terminal container and dock when moving terminals
jamesmurdza Feb 24, 2026
553c3b6
fix: prevent terminal dock from re-opening when all terminals are in …
jamesmurdza Feb 24, 2026
f9349b0
fix: consider both docks when adding/removing terminal panels
jamesmurdza Feb 24, 2026
0938202
fix: always restore existing terminals when the page loads
jamesmurdza Feb 24, 2026
0adad6a
fix: attempt to fix terminals freezing when moved into main dock
jamesmurdza Feb 24, 2026
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
20 changes: 17 additions & 3 deletions lib/services/Project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,24 @@ export class Project {
container: Container | null = null
containerId: string | null = null

// Preview server state (one per project, synced across all clients)
previewURL: string | null = null
runTerminalId: string | null = null

constructor(projectId: string) {
this.projectId = projectId
}

setPreview(url: string, runTerminalId: string): void {
this.previewURL = url
this.runTerminalId = runTerminalId
}

clearPreview(): void {
this.previewURL = null
this.runTerminalId = null
}

/**
* Kill all known dev server processes in the container (e.g. npm run dev, yarn dev, pnpm dev, vite, next, etc.)
*/
Expand Down Expand Up @@ -110,7 +124,6 @@ export class Project {
}

async connectToContainer(containerId: string): Promise<Container> {
console.log(`Connecting to container ${containerId}`)
this.container = await Container.connect(containerId, {
timeoutMs: CONTAINER_TIMEOUT,
autoPause: true,
Expand Down Expand Up @@ -144,7 +157,6 @@ export class Project {
// Initialize the terminal manager if it hasn't been set up yet
if (!this.terminalManager) {
this.terminalManager = new TerminalManager(container)
console.log(`Terminal manager set up for ${this.projectId}`)
}

// Initialize the file manager if it hasn't been set up yet
Expand All @@ -153,8 +165,10 @@ export class Project {
}
}

// Called when the client disconnects from the project
// Called when the last client disconnects from the project
async disconnect() {
this.clearPreview()
await this.killDevServers()
// Close all terminals managed by the terminal manager
await this.terminalManager?.closeAllTerminals()
// This way the terminal manager will be set up again if we reconnect
Expand Down
78 changes: 67 additions & 11 deletions lib/services/ProjectHandlers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import { Socket } from "socket.io"
import { ConnectionManager } from "./ConnectionManager"
import { LockManager } from "../utils/lock"
import { Project } from "./Project"

function broadcastToProject(
connections: ConnectionManager,
projectId: string,
event: string,
payload: unknown,
) {
connections.connectionsForProject(projectId).forEach((s: Socket) => {
s.emit(event, payload)
})
}

type ServerContext = {
dokkuClient: any | null
gitClient: any | null
Expand All @@ -19,9 +31,11 @@ export const createProjectHandlers = (
project: Project,
connection: ConnectionInfo,
context: ServerContext,
connections: ConnectionManager,
) => {
const { dokkuClient, gitClient } = context
const lockManager = new LockManager()
const projectId = project.projectId

// Extract port number from a string
function extractPortNumber(inputString: string): number | null {
Expand Down Expand Up @@ -93,33 +107,38 @@ export const createProjectHandlers = (
}: {
id: string
}) => {
await lockManager.acquireLock(project.projectId, async () => {
await lockManager.acquireLock(projectId, async () => {
await project.terminalManager?.createTerminal(
id,
(responseString: string) => {
connection.socket.emit("terminalResponse", {
broadcastToProject(connections, projectId, "terminalResponse", {
id,
data: responseString,
})
const port = extractPortNumber(responseString)
if (port) {
connection.socket.emit(
"previewURL",
"https://" + project.container?.getHost(port),
)
if (port && project.container && !project.previewURL) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The !project.previewURL guard means if the dev server restarts on a different port (3001 after 3000 was busy), the new URL gets silently ignored. User sees a dead preview with no way to fix it besides refreshing.

const url = "https://" + project.container.getHost(port)
project.setPreview(url, id)
broadcastToProject(connections, projectId, "previewState", {
url,
runTerminalId: id,
})
}
},
)
broadcastToProject(connections, projectId, "terminalCreated", { id })
})
}

// Handle resizing a terminal
// Handle resizing a terminal (per-terminal dimensions)
const handleResizeTerminal: SocketHandler = ({
id,
dimensions,
}: {
id: string
dimensions: { cols: number; rows: number }
}) => {
project.terminalManager?.resizeTerminal(dimensions)
project.terminalManager?.resizeTerminal(id, dimensions)
}

// Handle sending data to a terminal
Expand All @@ -134,8 +153,43 @@ export const createProjectHandlers = (
}

// Handle closing a terminal
const handleCloseTerminal: SocketHandler = ({ id }: { id: string }) => {
return project.terminalManager?.closeTerminal(id)
const handleCloseTerminal: SocketHandler = async ({ id }: { id: string }) => {
const wasRunTerminal = project.runTerminalId === id
await project.terminalManager?.closeTerminal(id)
broadcastToProject(connections, projectId, "terminalClosed", { id })
if (wasRunTerminal) {
project.clearPreview()
broadcastToProject(connections, projectId, "previewState", {
url: null,
runTerminalId: null,
})
}
}

// Send initial synced state to the requesting client (called when client receives "ready" to avoid race with listener setup)
const handleGetInitialState: SocketHandler = () => {
const ids = project.terminalManager?.getTerminalIds() ?? []
const screens =
project.terminalManager?.getScreenBuffers?.() ?? undefined
connection.socket.emit("terminalState", { ids, screens })
connection.socket.emit("previewState", {
url: project.previewURL,
runTerminalId: project.runTerminalId,
})
}

// Handle stopping the preview server (kills dev server, closes preview terminal)
const handleStopPreview: SocketHandler = async () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This calls terminalManager.closeTerminal() directly and broadcasts terminalClosed itself. But handleCloseTerminal at line 156 also checks if it's the run terminal and clears preview. Same result, two different code paths. Easy to get out of sync on future changes — consider having stopPreview go through handleCloseTerminal instead.

const runId = project.runTerminalId
if (!runId) return
await project.killDevServers()
await project.terminalManager?.closeTerminal(runId)
project.clearPreview()
broadcastToProject(connections, projectId, "terminalClosed", { id: runId })
broadcastToProject(connections, projectId, "previewState", {
url: null,
runTerminalId: null,
})
}

// Return all handlers as a map of event names to handler functions
Expand All @@ -148,5 +202,7 @@ export const createProjectHandlers = (
resizeTerminal: handleResizeTerminal,
terminalData: handleTerminalData,
closeTerminal: handleCloseTerminal,
stopPreview: handleStopPreview,
getInitialState: handleGetInitialState,
}
}
45 changes: 37 additions & 8 deletions lib/services/TerminalManager.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { Sandbox as Container } from "e2b"
import { Terminal } from "./Terminal"

const MAX_SCREEN_BUFFER_CHARS = 50_000

export class TerminalManager {
private container: Container
private terminals: Record<string, Terminal> = {}
private screenBuffers: Record<string, string> = {}

constructor(container: Container) {
this.container = container
Expand All @@ -17,9 +20,22 @@ export class TerminalManager {
return
}

this.screenBuffers[id] = ""
const pushData = (responseString: string) => {
let buf = this.screenBuffers[id]
if (buf !== undefined) {
buf += responseString
if (buf.length > MAX_SCREEN_BUFFER_CHARS) {
buf = buf.slice(-MAX_SCREEN_BUFFER_CHARS)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This cut could land in the middle of an ANSI escape sequence like \x1B[38;2;255;. When that gets replayed via term.write(initialScreen) at terminal.tsx:134 the terminal parser will freak out — garbled colors, missing text, cursor weirdness. Slice at a newline boundary or scan past partial sequences.

}
this.screenBuffers[id] = buf
}
onData(responseString)
}

this.terminals[id] = new Terminal(this.container)
await this.terminals[id].init({
onData,
onData: pushData,
cols: 80,
rows: 20,
})
Expand All @@ -37,13 +53,11 @@ export class TerminalManager {
console.log("Created terminal", id)
}

async resizeTerminal(dimensions: {
cols: number
rows: number
}): Promise<void> {
Object.values(this.terminals).forEach((t) => {
t.resize(dimensions)
})
async resizeTerminal(
id: string,
dimensions: { cols: number; rows: number },
): Promise<void> {
this.terminals[id]?.resize(dimensions)
}

async sendTerminalData(id: string, data: string): Promise<void> {
Expand All @@ -61,14 +75,29 @@ export class TerminalManager {

await this.terminals[id].close()
delete this.terminals[id]
delete this.screenBuffers[id]
}

async closeAllTerminals(): Promise<void> {
await Promise.all(
Object.entries(this.terminals).map(async ([key, terminal]) => {
await terminal.close()
delete this.terminals[key]
delete this.screenBuffers[key]
}),
)
}

getTerminalIds(): string[] {
return Object.keys(this.terminals)
}

getScreenBuffers(): Record<string, string> {
const out: Record<string, string> = {}
for (const id of Object.keys(this.terminals)) {
const buf = this.screenBuffers[id]
if (buf) out[id] = buf
}
return out
}
}
105 changes: 105 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading