diff --git a/lib/services/Project.ts b/lib/services/Project.ts index d9b0c94..7dcf76f 100644 --- a/lib/services/Project.ts +++ b/lib/services/Project.ts @@ -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.) */ @@ -110,7 +124,6 @@ export class Project { } async connectToContainer(containerId: string): Promise { - console.log(`Connecting to container ${containerId}`) this.container = await Container.connect(containerId, { timeoutMs: CONTAINER_TIMEOUT, autoPause: true, @@ -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 @@ -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 diff --git a/lib/services/ProjectHandlers.ts b/lib/services/ProjectHandlers.ts index 5a3b859..b066d0b 100644 --- a/lib/services/ProjectHandlers.ts +++ b/lib/services/ProjectHandlers.ts @@ -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 @@ -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 { @@ -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) { + 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 @@ -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 () => { + 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 @@ -148,5 +202,7 @@ export const createProjectHandlers = ( resizeTerminal: handleResizeTerminal, terminalData: handleTerminalData, closeTerminal: handleCloseTerminal, + stopPreview: handleStopPreview, + getInitialState: handleGetInitialState, } } diff --git a/lib/services/TerminalManager.ts b/lib/services/TerminalManager.ts index d6e47df..1896126 100644 --- a/lib/services/TerminalManager.ts +++ b/lib/services/TerminalManager.ts @@ -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 = {} + private screenBuffers: Record = {} constructor(container: Container) { this.container = container @@ -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) + } + 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, }) @@ -37,13 +53,11 @@ export class TerminalManager { console.log("Created terminal", id) } - async resizeTerminal(dimensions: { - cols: number - rows: number - }): Promise { - Object.values(this.terminals).forEach((t) => { - t.resize(dimensions) - }) + async resizeTerminal( + id: string, + dimensions: { cols: number; rows: number }, + ): Promise { + this.terminals[id]?.resize(dimensions) } async sendTerminalData(id: string, data: string): Promise { @@ -61,6 +75,7 @@ export class TerminalManager { await this.terminals[id].close() delete this.terminals[id] + delete this.screenBuffers[id] } async closeAllTerminals(): Promise { @@ -68,7 +83,21 @@ export class TerminalManager { 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 { + const out: Record = {} + for (const id of Object.keys(this.terminals)) { + const buf = this.screenBuffers[id] + if (buf) out[id] = buf + } + return out + } } diff --git a/package-lock.json b/package-lock.json index 2658efa..b5ec6de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12961,6 +12961,111 @@ "engines": { "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } + }, + "web/node_modules/@next/swc-darwin-arm64": { + "version": "16.0.10", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.10.tgz", + "integrity": "sha512-4XgdKtdVsaflErz+B5XeG0T5PeXKDdruDf3CRpnhN+8UebNa5N2H58+3GDgpn/9GBurrQ1uWW768FfscwYkJRg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "web/node_modules/@next/swc-darwin-x64": { + "version": "16.0.10", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.10.tgz", + "integrity": "sha512-spbEObMvRKkQ3CkYVOME+ocPDFo5UqHb8EMTS78/0mQ+O1nqE8toHJVioZo4TvebATxgA8XMTHHrScPrn68OGw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "web/node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.0.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.10.tgz", + "integrity": "sha512-uQtWE3X0iGB8apTIskOMi2w/MKONrPOUCi5yLO+v3O8Mb5c7K4Q5KD1jvTpTF5gJKa3VH/ijKjKUq9O9UhwOYw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "web/node_modules/@next/swc-linux-arm64-musl": { + "version": "16.0.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.10.tgz", + "integrity": "sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "web/node_modules/@next/swc-linux-x64-gnu": { + "version": "16.0.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.10.tgz", + "integrity": "sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "web/node_modules/@next/swc-linux-x64-musl": { + "version": "16.0.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.10.tgz", + "integrity": "sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "web/node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.0.10", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.10.tgz", + "integrity": "sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } } } } diff --git a/server/src/index.ts b/server/src/index.ts index 5914436..40b6368 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -40,6 +40,27 @@ process.on("unhandledRejection", (reason, promise) => { // Initialize containers and managers const connections = new ConnectionManager() +const projectCache = new Map() +const projectCreationPromises = new Map>() + +async function getOrCreateProject(projectId: string): Promise { + let existing = projectCache.get(projectId) + if (existing) return existing + let promise = projectCreationPromises.get(projectId) + if (!promise) { + promise = (async () => { + let project = projectCache.get(projectId) + if (!project) { + project = new Project(projectId) + await project.initialize() + projectCache.set(projectId, project) + } + return project + })() + projectCreationPromises.set(projectId, promise) + } + return promise +} // Initialize Express app and create HTTP server const app: Express = express() @@ -107,9 +128,8 @@ io.on("connection", async (socket) => { }) } - // Create or retrieve the project container for the given project ID - const project = new Project(data.projectId) - await project.initialize() + // Get or create project (serialized so concurrent connections share one project) + const project = await getOrCreateProject(data.projectId) await project.fileManager?.startWatching(sendFileNotifications) // Register event handlers for the project @@ -124,6 +144,7 @@ io.on("connection", async (socket) => { dokkuClient, gitClient, }, + connections, ) // For each event handler, listen on the socket for that event @@ -143,13 +164,12 @@ io.on("connection", async (socket) => { }) socket.emit("ready") + // Initial terminal/preview state is sent when client emits getInitialState (avoids race with client listeners) - // Handle disconnection event + // Handle disconnection event (project and terminals stay in cache so next window sees same state) socket.on("disconnect", async () => { try { - // Deregister the connection connections.removeConnectionForProject(socket, data.projectId) - await project.killDevServers() } catch (e: any) { handleErrors("Error disconnecting:", e, socket) } diff --git a/web/components/project/hooks/useEditor.ts b/web/components/project/hooks/useEditor.ts index ec7db51..ba8b9b3 100644 --- a/web/components/project/hooks/useEditor.ts +++ b/web/components/project/hooks/useEditor.ts @@ -40,7 +40,7 @@ export interface DecorationsState { export const useEditor = ({ projectId, fileId }: UseEditorProps) => { const { saveFile, fileTree: files = [] } = useFileTree() - const { terminalRef, gridRef } = useEditorContext() + const { terminalRef, dockRef, gridRef } = useEditorContext() const { creatingTerminal, createNewTerminal } = useTerminal() const draft = useAppStore((s) => s.drafts[fileId ?? ""]) const { data: serverFileContent = "" } = fileRouter.fileContent.useQuery({ @@ -267,6 +267,7 @@ export const useEditor = ({ projectId, fileId }: UseEditorProps) => { monaco: editorRef, gridRef, terminalRef, + dockRef, isCreatingTerminal: creatingTerminal, createNewTerminal, saveFile: () => { @@ -285,6 +286,7 @@ export const useEditor = ({ projectId, fileId }: UseEditorProps) => { saveFile, gridRef, terminalRef, + dockRef, draft, serverFileContent, creatingTerminal, diff --git a/web/components/project/hooks/useEditorSocket.ts b/web/components/project/hooks/useEditorSocket.ts index d87149f..28da085 100644 --- a/web/components/project/hooks/useEditorSocket.ts +++ b/web/components/project/hooks/useEditorSocket.ts @@ -5,44 +5,57 @@ import { toast } from "sonner" export const useEditorSocket = () => { const { socket } = useSocket() - const { dockRef } = useEditor() + const { dockRef, loadPreviewURL } = useEditor() - // Preview URL handler - const handlePreviewURL = useCallback((url: string) => { - const previewPanel = dockRef.current?.getPanel("preview") - if (previewPanel) { - previewPanel.api.updateParameters({ src: url }) - previewPanel.api.setActive() - return - } + const openOrUpdatePreviewPanel = useCallback( + (url: string) => { + const previewPanel = dockRef.current?.getPanel("preview") + if (previewPanel) { + previewPanel.api.updateParameters({ src: url }) + previewPanel.api.setActive() + return + } - const groups = dockRef.current?.groups - // If we have exactly one group, split to the right for the preview - // Otherwise open in default location - if (groups?.length === 1) { - dockRef.current?.addPanel({ - id: "preview", - component: "preview", - title: "Preview", - tabComponent: "preview", - params: { src: url }, - renderer: "always", - position: { - referenceGroup: groups[0].id, - direction: "right", - }, - }) - } else { - dockRef.current?.addPanel({ - id: "preview", - component: "preview", - title: "Preview", - renderer: "always", - tabComponent: "preview", - params: { src: url }, - }) - } - }, []) + const groups = dockRef.current?.groups + if (groups?.length === 1) { + dockRef.current?.addPanel({ + id: "preview", + component: "preview", + title: "Preview", + tabComponent: "preview", + params: { src: url }, + renderer: "always", + position: { + referenceGroup: groups[0].id, + direction: "right", + }, + }) + } else { + dockRef.current?.addPanel({ + id: "preview", + component: "preview", + title: "Preview", + renderer: "always", + tabComponent: "preview", + params: { src: url }, + }) + } + }, + [dockRef], + ) + + const handlePreviewState = useCallback( + ({ url }: { url: string | null; runTerminalId: string | null }) => { + loadPreviewURL(url) + if (url) { + openOrUpdatePreviewPanel(url) + } else { + const previewPanel = dockRef.current?.getPanel("preview") + previewPanel?.api.close() + } + }, + [loadPreviewURL, openOrUpdatePreviewPanel, dockRef], + ) // Register socket event listeners useEffect(() => { @@ -52,15 +65,14 @@ export const useEditorSocket = () => { toast.error(message) } - // Register events socket.on("error", onError) - socket.on("previewURL", handlePreviewURL) + socket.on("previewState", handlePreviewState) return () => { socket.off("error", onError) - socket.off("previewURL", handlePreviewURL) + socket.off("previewState", handlePreviewState) } - }, [socket, handlePreviewURL]) + }, [socket, handlePreviewState]) return { socket, diff --git a/web/components/project/layout/components/right-header-actions.tsx b/web/components/project/layout/components/right-header-actions.tsx index 012861a..3676abd 100644 --- a/web/components/project/layout/components/right-header-actions.tsx +++ b/web/components/project/layout/components/right-header-actions.tsx @@ -1,6 +1,7 @@ "use client" import { Button } from "@/components/ui/button" +import { useEditor } from "@/context/editor-context" import { useSocket } from "@/context/SocketContext" import { useTerminal } from "@/context/TerminalContext" import { MAX_TERMINALS } from "@/lib/constants" @@ -15,19 +16,17 @@ import { toast } from "sonner" */ export function TerminalRightHeaderActions(props: IDockviewHeaderActionsProps) { const { group, containerApi } = props + const { dockRef } = useEditor() const { isReady: isSocketReady } = useSocket() const { createNewTerminal } = useTerminal() const [isCreating, setIsCreating] = useState(false) const handleAddTerminal = () => { - // Count existing terminal panels across all groups - const allTerminalPanels = containerApi.panels.filter((p) => - p.id.startsWith("terminal-"), - ) - - // Check max terminals limit - if (allTerminalPanels.length >= MAX_TERMINALS) { + // Count existing terminal panels across this container and the dock (user may have moved some) + const here = containerApi.panels.filter((p) => p.id.startsWith("terminal-")).length + const inDock = dockRef.current?.panels.filter((p) => p.id.startsWith("terminal-")).length ?? 0 + if (here + inDock >= MAX_TERMINALS) { toast.error("You reached the maximum # of terminals.") return } @@ -35,10 +34,11 @@ export function TerminalRightHeaderActions(props: IDockviewHeaderActionsProps) { setIsCreating(true) createNewTerminal() .then((id) => { - // add terminal panel - group.panels.at(-1) + if (!id) return + const panelId = `terminal-${id}` + if (containerApi.getPanel(panelId) || dockRef.current?.getPanel(panelId)) return containerApi.addPanel({ - id: `terminal-${id}`, + id: panelId, component: "terminal", title: "Shell", tabComponent: "terminal", diff --git a/web/components/project/layout/components/tab-components.tsx b/web/components/project/layout/components/tab-components.tsx index 038a2e1..e10ac0c 100644 --- a/web/components/project/layout/components/tab-components.tsx +++ b/web/components/project/layout/components/tab-components.tsx @@ -117,35 +117,32 @@ export const tabComponents = { const { api } = props const terminalId = api.id.split("-")[1] // Extract terminal ID from panel ID - const { terminals, setTerminals, setActiveTerminalId } = useTerminal() + const { terminals, closeTerminal, setActiveTerminalId, isPreviewTerminal } = + useTerminal() + const isPreview = isPreviewTerminal(terminalId) const closeActionOverride = React.useCallback(() => { - // Find the terminal and dispose it + if (isPreview) return // Preview terminal can only be closed via Stop const terminal = terminals.find((t) => t.id === terminalId) if (terminal?.terminal) { terminal.terminal.dispose() } - setTerminals((prev) => prev.filter((t) => t.id !== terminalId)) - setActiveTerminalId((prevActiveId) => - prevActiveId === terminalId ? "" : prevActiveId, - ) api.close() - - // If the terminal grid panel will have no terminals after this close, hide it if (terminalRef.current?.panels.length === 1) { const terminalGridPanel = gridRef.current?.getPanel("terminal") if (terminalGridPanel) { terminalGridPanel.api.setVisible(false) } } + closeTerminal(terminalId) }, [ api, terminalId, terminals, - setTerminals, - setActiveTerminalId, + closeTerminal, gridRef, terminalRef, + isPreview, ]) return ( } + hideClose={isPreview} /> ) }, diff --git a/web/components/project/layout/components/terminal-panel.tsx b/web/components/project/layout/components/terminal-panel.tsx index dc68942..3804c2e 100644 --- a/web/components/project/layout/components/terminal-panel.tsx +++ b/web/components/project/layout/components/terminal-panel.tsx @@ -5,7 +5,7 @@ import { useTerminal } from "@/context/TerminalContext" import { Terminal } from "@xterm/xterm" import { IDockviewPanelProps } from "dockview" import { Loader2 } from "lucide-react" -import { useEffect } from "react" +import { useEffect, useState } from "react" import EditorTerminal from "../../terminals/terminal" export interface TerminalPanelParams {} @@ -17,12 +17,20 @@ export function TerminalPanel(props: IDockviewPanelProps) { const term = terminals.find((t) => t.id === terminalId) + const [isActive, setIsActive] = useState(() => props.api.isActive) + useEffect(() => { + const disposable = props.api.onDidActiveChange(() => { + setIsActive(props.api.isActive) + }) + return () => disposable.dispose() + }, [props.api]) + // Auto-focus terminal when it becomes active useEffect(() => { - if (term) { + if (term && isActive) { term.terminal?.focus() } - }, [term]) + }, [term, isActive]) if (!term || !socket || !isReady) { return ( @@ -48,6 +56,8 @@ export function TerminalPanel(props: IDockviewPanelProps) { ) }} visible + isActive={isActive} + initialScreen={term.initialScreen} /> ) } diff --git a/web/components/project/layout/hooks/usePanelToggles.ts b/web/components/project/layout/hooks/usePanelToggles.ts index 398c6da..5b751cd 100644 --- a/web/components/project/layout/hooks/usePanelToggles.ts +++ b/web/components/project/layout/hooks/usePanelToggles.ts @@ -14,7 +14,7 @@ export function useToggleSidebar() { } export function useToggleTerminal() { - const { gridRef, terminalRef } = useEditor() + const { gridRef, terminalRef, dockRef } = useEditor() const { creatingTerminal, createNewTerminal } = useTerminal() const { isReady: isSocketReady } = useSocket() @@ -26,12 +26,17 @@ export function useToggleTerminal() { panel.api.setVisible(!isVisible) if (!isVisible && isSocketReady) { - const existingTerminals = Boolean(terminalRef.current?.panels.length) - if (!existingTerminals && !creatingTerminal) { + const ref = terminalRef.current + const dock = dockRef.current + const existingInTerminal = ref?.panels.length ?? 0 + const existingInDock = dock?.panels.filter((p) => p.id.startsWith("terminal-")).length ?? 0 + if (existingInTerminal === 0 && existingInDock === 0 && !creatingTerminal) { createNewTerminal().then((id) => { if (!id) return - terminalRef.current?.addPanel({ - id: `terminal-${id}`, + const panelId = `terminal-${id}` + if (ref?.getPanel(panelId) || dock?.getPanel(panelId)) return + ref?.addPanel({ + id: panelId, component: "terminal", title: "Shell", tabComponent: "terminal", @@ -39,7 +44,7 @@ export function useToggleTerminal() { }) } } - }, [gridRef, terminalRef, isSocketReady, creatingTerminal, createNewTerminal]) + }, [gridRef, terminalRef, dockRef, isSocketReady, creatingTerminal, createNewTerminal]) } export function useToggleChat() { diff --git a/web/components/project/layout/index.tsx b/web/components/project/layout/index.tsx index 6424a91..c1afa03 100644 --- a/web/components/project/layout/index.tsx +++ b/web/components/project/layout/index.tsx @@ -13,7 +13,7 @@ import { themeLight, } from "dockview" import { useTheme } from "next-themes" -import { useCallback, useEffect, type FunctionComponent } from "react" +import { useCallback, useEffect, useRef, type FunctionComponent } from "react" import { useEditorSocket } from "../hooks/useEditorSocket" import { ChatPanel } from "./components/chat-panel" import { EditorPanel } from "./components/editor-panel" @@ -35,7 +35,9 @@ export function Dock(_props: DockProps) { const { resolvedTheme } = useTheme() const { gridRef, dockRef, terminalRef } = useEditor() const { isReady: isSocketReady } = useSocket() - const { creatingTerminal, createNewTerminal } = useTerminal() + const { terminals, creatingTerminal, createNewTerminal } = useTerminal() + const prevTerminalIdsRef = useRef>(new Set()) + const hasAttemptedInitialCreateRef = useRef(false) const chatHandlers = useChatPanelHandlers() useEditorSocket() @@ -60,13 +62,12 @@ export function Dock(_props: DockProps) { }) if (result.handled) { - // If terminal container is now empty, hide the terminal grid panel - if (terminalRef.current?.panels.length === 0) { - const terminalGridPanel = gridRef.current?.getPanel("terminal") - if (terminalGridPanel) { - terminalGridPanel.api.setVisible(false) + // Terminal was moved from terminal dock to somewhere else → hide terminal dock when empty + queueMicrotask(() => { + if (terminalRef.current?.panels.length === 0) { + gridRef.current?.getPanel("terminal")?.api.setVisible(false) } - } + }) } }, [terminalRef, dockRef, gridRef], @@ -88,6 +89,36 @@ export function Dock(_props: DockProps) { [dockRef, terminalRef], ) + // Sync terminal tabs to dock panels. Run when terminals change or when either dock becomes ready (avoids refresh race). + const syncTerminalPanels = useCallback(() => { + if (terminals.length === 0) return + const ref = terminalRef.current + const dock = dockRef.current + if (!ref || !dock) return + terminals.forEach((term) => { + const id = `terminal-${term.id}` + if (!ref.getPanel(id) && !dock.getPanel(id)) { + ref.addPanel({ + id, + component: "terminal", + title: "Shell", + tabComponent: "terminal", + }) + } + }) + const hasPanelsInTerminalDock = ref.panels.length > 0 + const allInMainDock = terminals.every((t) => dock.getPanel(`terminal-${t.id}`) != null) + const terminalGridPanel = gridRef.current?.getPanel("terminal") + if ( + terminalGridPanel && + !terminalGridPanel.api.isVisible && + hasPanelsInTerminalDock && + !allInMainDock + ) { + terminalGridPanel.api.setVisible(true) + } + }, [terminals, gridRef, terminalRef, dockRef]) + // components const dockComponents: PanelCollection = { terminal: TerminalPanel, @@ -109,8 +140,8 @@ export function Dock(_props: DockProps) { components={dockComponents} onReady={(event) => { dockRef.current = event.api - // Set up handler for external drag events (from file explorer) event.api.onUnhandledDragOverEvent(handleDockUnhandledDragOver) + syncTerminalPanels() }} onDidDrop={handleDockDidDrop} /> @@ -129,8 +160,8 @@ export function Dock(_props: DockProps) { rightHeaderActionsComponent={TerminalRightHeaderActions} onReady={(event) => { terminalRef.current = event.api - // Accept drags from dock to allow terminal panels back event.api.onUnhandledDragOverEvent(handleDockUnhandledDragOver) + syncTerminalPanels() }} onDidDrop={handleTerminalDidDrop} /> @@ -161,25 +192,57 @@ export function Dock(_props: DockProps) { } }, [resolvedTheme, gridRef]) + // Create one terminal on first load only if project has none (once per session; don't auto-create after user closes last tab) useEffect(() => { - if (!isSocketReady) return - // create terminal on load if none exist - if (!creatingTerminal && terminalRef.current) { - const existingTerminals = terminalRef.current.panels.length - if (existingTerminals === 0) { - createNewTerminal().then((id) => { - if (!id) return - // add terminal panel - terminalRef.current?.addPanel({ - id: `terminal-${id}`, - component: "terminal", - title: "Shell", - tabComponent: "terminal", - }) + if (!isSocketReady || hasAttemptedInitialCreateRef.current) return + const t = setTimeout(() => { + hasAttemptedInitialCreateRef.current = true + if (creatingTerminal || terminals.length > 0) return + if (!terminalRef.current) return + if (terminalRef.current.panels.length > 0) return + createNewTerminal().then((id) => { + if (!id) return + const ref = terminalRef.current + const dock = dockRef.current + const panelId = `terminal-${id}` + if (ref?.getPanel(panelId) || dock?.getPanel(panelId)) return + ref?.addPanel({ + id: panelId, + component: "terminal", + title: "Shell", + tabComponent: "terminal", }) + }) + }, 400) + return () => clearTimeout(t) + }, [isSocketReady, terminals.length, creatingTerminal]) + + useEffect(() => { + syncTerminalPanels() + }, [syncTerminalPanels]) + + // When a terminal is removed (e.g. terminalClosed from server), close its panel in both containers so tabs stay in sync + useEffect(() => { + const ref = terminalRef.current + const dock = dockRef.current + if (!ref) return + const currentIds = new Set(terminals.map((t) => t.id)) + prevTerminalIdsRef.current.forEach((id) => { + if (!currentIds.has(id)) { + const panelId = `terminal-${id}` + ref.getPanel(panelId)?.api.close() + dock?.getPanel(panelId)?.api.close() + } + }) + prevTerminalIdsRef.current = currentIds + if (terminals.length === 0 && gridRef.current) { + const terminalGridPanel = gridRef.current.getPanel("terminal") + if (terminalGridPanel?.api.isVisible) { + terminalGridPanel.api.setVisible(false) } } - }, [isSocketReady]) + }, [terminals, terminalRef, dockRef, gridRef]) + return (
{ if (!id) return - terminalRef.current?.addPanel({ - id: `terminal-${id}`, + const ref = terminalRef.current + const dock = dockRef.current + const panelId = `terminal-${id}` + if (ref?.getPanel(panelId) || dock?.getPanel(panelId)) return + ref?.addPanel({ + id: panelId, component: "terminal", title: "Shell", tabComponent: "terminal", @@ -98,6 +102,7 @@ export function enableEditorShortcuts({ monaco, gridRef, terminalRef, + dockRef, createNewTerminal, isCreatingTerminal, saveFile, @@ -105,6 +110,7 @@ export function enableEditorShortcuts({ monaco: editor.IStandaloneCodeEditor gridRef: MutableRefObject terminalRef: MutableRefObject + dockRef: MutableRefObject createNewTerminal: () => Promise isCreatingTerminal: boolean saveFile: () => void @@ -140,9 +146,12 @@ export function enableEditorShortcuts({ if (!isCreatingTerminal) { createNewTerminal().then((id) => { if (!id) return - // add terminal panel - terminalRef.current?.addPanel({ - id: `terminal-${id}`, + const ref = terminalRef.current + const dock = dockRef.current + const panelId = `terminal-${id}` + if (ref?.getPanel(panelId) || dock?.getPanel(panelId)) return + ref?.addPanel({ + id: panelId, component: "terminal", title: "Shell", tabComponent: "terminal", diff --git a/web/components/project/navbar/index.tsx b/web/components/project/navbar/index.tsx index 09bf738..58a8a99 100644 --- a/web/components/project/navbar/index.tsx +++ b/web/components/project/navbar/index.tsx @@ -6,6 +6,7 @@ import UserButton from "@/components/ui/userButton" import { Pencil } from "lucide-react" import Link from "next/link" import { useState } from "react" +import { useEditor } from "@/context/editor-context" import { useProjectContext } from "@/context/project-context" import DeployButtonModal from "./deploy" import DownloadButton from "./downloadButton" @@ -20,8 +21,8 @@ export default function Navbar({ }) { const [isEditOpen, setIsEditOpen] = useState(false) const [isShareOpen, setIsShareOpen] = useState(false) - const [isRunning, setIsRunning] = useState(false) const { user, project } = useProjectContext() + const { previewURL } = useEditor() const isOwner = project.userId === user.id return ( @@ -58,8 +59,7 @@ export default function Navbar({
diff --git a/web/components/project/navbar/run.tsx b/web/components/project/navbar/run.tsx index f6150c0..5b4e6bb 100644 --- a/web/components/project/navbar/run.tsx +++ b/web/components/project/navbar/run.tsx @@ -6,105 +6,53 @@ import { useTerminal } from "@/context/TerminalContext" import { Sandbox } from "@/lib/types" import { templateConfigs } from "@gitwit/templates" import { LoaderCircle, Play, StopCircle } from "lucide-react" -import { useTheme } from "next-themes" -import { useEffect, useRef } from "react" import { toast } from "sonner" export default function RunButtonModal({ isRunning, - setIsRunning, sandboxData, }: { isRunning: boolean - setIsRunning: (running: boolean) => void sandboxData: Sandbox }) { - const { gridRef, dockRef, terminalRef } = useEditor() - const { resolvedTheme } = useTheme() + const { gridRef } = useEditor() const { createNewTerminal, - closeTerminal, + stopPreview, terminals, creatingTerminal, closingTerminal, } = useTerminal() - // Ref to keep track of the last created terminal's ID - const lastCreatedTerminalRef = useRef(null) - // Disable button when creating or closing a terminal const isTransitioning = creatingTerminal || !!closingTerminal - // Effect to update the lastCreatedTerminalRef when a new terminal is added - useEffect(() => { - if (terminals.length > 0 && !isRunning) { - const latestTerminal = terminals[terminals.length - 1] - if ( - latestTerminal && - latestTerminal.id !== lastCreatedTerminalRef.current - ) { - lastCreatedTerminalRef.current = latestTerminal.id - } - } - }, [terminals, isRunning]) - // commands to run in the terminal const handleRun = async () => { - // Guard against rapid clicks during state transitions if (isTransitioning) return - if (isRunning && lastCreatedTerminalRef.current) { - // Stop: Close the terminal (panel will auto-hide if it's the last one) - await closeTerminal(lastCreatedTerminalRef.current) - lastCreatedTerminalRef.current = null - - // Close preview panel if it exists - const previewPanel = dockRef.current?.panels.find( - (panel) => panel.id === "preview", - ) - if (previewPanel) { - previewPanel.api.close() - } - } else if (!isRunning && terminals.length < 4) { - const command = - templateConfigs[sandboxData.type]?.runCommand || "npm run dev" - - try { - // Show terminal panel if hidden - const terminalPanel = gridRef.current?.getPanel("terminal") - if (terminalPanel && !terminalPanel.api.isVisible) { - terminalPanel.api.setVisible(true) - } - - // Create a new terminal with the appropriate command - const terminalId = await createNewTerminal(command) - if (!terminalId) { - throw new Error("Failed to create terminal") - } + if (isRunning) { + stopPreview() + return + } - // Add terminal panel to the terminal container - terminalRef.current?.addPanel({ - id: `terminal-${terminalId}`, - component: "terminal", - title: "Shell", - tabComponent: "terminal", - params: { - dockRef, - terminalRef, - theme: resolvedTheme, - }, - }) - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : "Failed to create new terminal", - ) - return - } - } else if (!isRunning) { + if (terminals.length >= 4) { toast.error("You've reached the maximum number of terminals.") return } - setIsRunning(!isRunning) + const command = + templateConfigs[sandboxData.type]?.runCommand || "npm run dev" + try { + const terminalPanel = gridRef.current?.getPanel("terminal") + if (terminalPanel && !terminalPanel.api.isVisible) { + terminalPanel.api.setVisible(true) + } + + await createNewTerminal(command) + // Panel is added by the sync effect in terminals/index when state updates + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to create terminal", + ) + } } return ( diff --git a/web/components/project/terminals/index.tsx b/web/components/project/terminals/index.tsx deleted file mode 100644 index d143fb3..0000000 --- a/web/components/project/terminals/index.tsx +++ /dev/null @@ -1,96 +0,0 @@ -"use client" - -import { Button } from "@/components/ui/button" -import Tab from "@/components/ui/tab" -import { useSocket } from "@/context/SocketContext" -import { useTerminal } from "@/context/TerminalContext" -import { Terminal } from "@xterm/xterm" -import { Plus, SquareTerminal, TerminalSquare } from "lucide-react" -import { useEffect } from "react" -import { toast } from "sonner" -import EditorTerminal from "./terminal" - -export default function Terminals() { - const { socket } = useSocket() - - const { - terminals, - setTerminals, - createNewTerminal, - closeTerminal, - activeTerminalId, - setActiveTerminalId, - creatingTerminal, - } = useTerminal() - - const activeTerminal = terminals.find((t) => t.id === activeTerminalId) - - // Effect to set the active terminal when a new one is created - useEffect(() => { - if (terminals.length > 0 && !activeTerminalId) { - setActiveTerminalId(terminals[terminals.length - 1].id) - } - }, [terminals, activeTerminalId, setActiveTerminalId]) - - const handleCreateTerminal = () => { - if (terminals.length >= 4) { - toast.error("You reached the maximum # of terminals.") - return - } - createNewTerminal() - } - - return ( - <> -
- {terminals.map((term) => ( - setActiveTerminalId(term.id)} - onClose={() => closeTerminal(term.id)} - selected={activeTerminalId === term.id} - > - - Shell - - ))} - -
- {socket && activeTerminal ? ( -
- {terminals.map((term) => ( - { - setTerminals((prev) => - prev.map((term) => - term.id === activeTerminalId - ? { ...term, terminal: t } - : term, - ), - ) - }} - visible={activeTerminalId === term.id} - /> - ))} -
- ) : ( -
- - No terminals open. -
- )} - - ) -} diff --git a/web/components/project/terminals/terminal.tsx b/web/components/project/terminals/terminal.tsx index 05d24b8..f0e223f 100644 --- a/web/components/project/terminals/terminal.tsx +++ b/web/components/project/terminals/terminal.tsx @@ -16,24 +16,39 @@ export default function EditorTerminal({ term, setTerm, visible, + isActive = false, + initialScreen, }: { socket: Socket id: string term: Terminal | null setTerm: (term: Terminal) => void visible: boolean + isActive?: boolean + initialScreen?: string }) { const { resolvedTheme: theme } = useTheme() const terminalContainerRef = useRef>(null) const fitAddonRef = useRef(null) + const hasWrittenInitialScreenRef = useRef(false) + const fitAndNotifyRef = useRef<(() => void) | null>(null) + // Always listen for terminal output (needed for both new terminals and after panel move) useEffect(() => { - if (!terminalContainerRef.current) return - - // If terminal already exists (e.g., from a panel move), skip creation - if (term) { - return + if (!term) return + const handleTerminalResponse = (response: { id: string; data: string }) => { + if (response.id === id) term.write(response.data) } + socket.on("terminalResponse", handleTerminalResponse) + return () => { + socket.off("terminalResponse", handleTerminalResponse) + } + }, [term, id, socket]) + + // Run once on mount: create terminal if needed (skip when term exists, e.g. after panel move) + useEffect(() => { + if (!terminalContainerRef.current) return + if (term) return const terminal = new Terminal({ cursorBlink: true, @@ -84,14 +99,12 @@ export default function EditorTerminal({ setTerm(terminal) return () => { - // Don't dispose terminal on unmount - it may be reused after panel move - // Terminal disposal is handled explicitly in closeTerminal terminalContainerRef.current?.removeEventListener( "contextmenu", handleContextMenu, ) } - }, [term]) + }, []) useEffect(() => { if (term) { @@ -99,6 +112,11 @@ export default function EditorTerminal({ } }, [theme]) + // When this terminal becomes the active panel, fit and send resize so server PTY matches + useEffect(() => { + if (isActive) fitAndNotifyRef.current?.() + }, [isActive]) + useEffect(() => { if (!term) return @@ -109,6 +127,13 @@ export default function EditorTerminal({ term.open(terminalContainerRef.current) fitAddon.fit() fitAddonRef.current = fitAddon + if ( + initialScreen && + !hasWrittenInitialScreenRef.current + ) { + term.write(initialScreen) + hasWrittenInitialScreenRef.current = true + } } else { // Terminal already opened - reattach to new container const terminalElement = term.element @@ -118,10 +143,7 @@ export default function EditorTerminal({ ) { // Move the terminal DOM element to the new container terminalContainerRef.current.appendChild(terminalElement) - // Refit after reattachment - setTimeout(() => { - fitAddonRef.current?.fit() - }, 10) + setTimeout(() => fitAndNotifyRef.current?.(), 10) } } @@ -129,54 +151,44 @@ export default function EditorTerminal({ socket.emit("terminalData", { id, data }) }) - const disposableOnResize = term.onResize((dimensions) => { - fitAddonRef.current?.fit() - socket.emit("terminalResize", { dimensions }) - }) - const resizeObserver = new ResizeObserver( - debounce((entries) => { - if (!fitAddonRef.current || !terminalContainerRef.current) return - - const entry = entries[0] - if (!entry) return + const fitAndNotify = () => { + if (!fitAddonRef.current) return + try { + fitAddonRef.current.fit() + notifyResizeDebounced() + } catch (err) { + console.error("Error during fit:", err) + } + } + const notifyResizeDebounced = debounce(() => { + socket.emit("resizeTerminal", { + id, + dimensions: { cols: term.cols, rows: term.rows }, + }) + }, 50) + const fitAndNotifyDebounced = debounce(fitAndNotify, 50) + fitAndNotifyRef.current = fitAndNotify - const { width, height } = entry.contentRect + const disposableOnResize = term.onResize(() => notifyResizeDebounced()) + const handleFocus = () => fitAndNotify() + const el = term.element + el?.addEventListener("focus", handleFocus) - if ( - width !== terminalContainerRef.current.offsetWidth || - height !== terminalContainerRef.current.offsetHeight - ) { - try { - fitAddonRef.current.fit() - } catch (err) { - console.error("Error during fit:", err) - } - } - }, 50), - ) + const resizeObserver = new ResizeObserver(() => { + if (fitAddonRef.current && terminalContainerRef.current) + fitAndNotifyDebounced() + }) resizeObserver.observe(terminalContainerRef.current) return () => { + fitAndNotifyRef.current = null + el?.removeEventListener("focus", handleFocus) disposableOnData.dispose() disposableOnResize.dispose() resizeObserver.disconnect() } }, [term, terminalContainerRef.current]) - useEffect(() => { - if (!term) return - const handleTerminalResponse = (response: { id: string; data: string }) => { - if (response.id === id) { - term.write(response.data) - } - } - socket.on("terminalResponse", handleTerminalResponse) - - return () => { - socket.off("terminalResponse", handleTerminalResponse) - } - }, [term, id, socket]) - return ( <>
{ setIsReady(true) + // Defer so TerminalContext's useEffect can register listeners first + setTimeout(() => newSocket.emit("getInitialState"), 0) }) newSocket.on("disconnect", () => { diff --git a/web/context/TerminalContext.tsx b/web/context/TerminalContext.tsx index e0b70a3..460746e 100644 --- a/web/context/TerminalContext.tsx +++ b/web/context/TerminalContext.tsx @@ -4,6 +4,7 @@ import { useSocket } from "@/context/SocketContext" import { closeTerminal as closeTerminalHelper, createTerminal as createTerminalHelper, + stopPreview as stopPreviewApi, } from "@/lib/api/terminal" import { MAX_TERMINALS } from "@/lib/constants" import { Terminal } from "@xterm/xterm" @@ -24,6 +25,8 @@ interface TerminalState { id: string terminal: Terminal | null isBusy: boolean + /** Cached screen from server for newly opened windows */ + initialScreen?: string } interface TerminalContextType { @@ -34,8 +37,11 @@ interface TerminalContextType { creatingTerminal: boolean setCreatingTerminal: React.Dispatch> closingTerminal: string + runTerminalId: string | null createNewTerminal: (command?: string) => Promise closeTerminal: (id: string) => Promise + stopPreview: () => void + isPreviewTerminal: (id: string) => boolean deploy: (callback: () => void) => void getAppExists: | ((appName: string) => Promise<{ success: boolean; exists?: boolean }>) @@ -67,6 +73,63 @@ export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({ const [activeTerminalId, setActiveTerminalId] = useState("") const [creatingTerminal, setCreatingTerminal] = useState(false) const [closingTerminal, setClosingTerminal] = useState("") + const [runTerminalId, setRunTerminalId] = useState(null) + + // Synced from server: terminal list and preview state + useEffect(() => { + if (!socket) return + const onTerminalCreated = ({ id }: { id: string }) => { + setTerminals((prev) => + prev.some((t) => t.id === id) + ? prev + : [...prev, { id, terminal: null, isBusy: false }], + ) + } + const onTerminalClosed = ({ id }: { id: string }) => { + delete commandSentAtRef.current[id] + setTerminals((prev) => prev.filter((t) => t.id !== id)) + setActiveTerminalId((prev) => (prev === id ? "" : prev)) + } + const onTerminalState = ({ + ids, + screens, + }: { + ids: string[] + screens?: Record + }) => { + setTerminals((prev) => { + const existing = new Set(prev.map((t) => t.id)) + const toAdd = ids + .filter((id) => !existing.has(id)) + .map((id) => ({ + id, + terminal: null as Terminal | null, + isBusy: false, + initialScreen: screens?.[id], + })) + return toAdd.length ? [...prev, ...toAdd] : prev + }) + } + const onPreviewState = ({ + url, + runTerminalId: rid, + }: { + url: string | null + runTerminalId: string | null + }) => { + setRunTerminalId(rid) + } + socket.on("terminalCreated", onTerminalCreated) + socket.on("terminalClosed", onTerminalClosed) + socket.on("terminalState", onTerminalState) + socket.on("previewState", onPreviewState) + return () => { + socket.off("terminalCreated", onTerminalCreated) + socket.off("terminalClosed", onTerminalClosed) + socket.off("terminalState", onTerminalState) + socket.off("previewState", onPreviewState) + } + }, [socket]) // Track when commands were sent to ignore prompts that arrive too quickly // (the existing prompt before command runs) @@ -121,6 +184,15 @@ export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({ [terminals], ) + const stopPreview = useCallback(() => { + if (socket) stopPreviewApi(socket) + }, [socket]) + + const isPreviewTerminal = useCallback( + (id: string) => runTerminalId === id, + [runTerminalId], + ) + const sendCommandToTerminal = useCallback( (id: string, command: string) => { if (!socket) return @@ -233,8 +305,11 @@ export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({ creatingTerminal, setCreatingTerminal, closingTerminal, + runTerminalId, createNewTerminal, closeTerminal, + stopPreview, + isPreviewTerminal, deploy, getAppExists: isSocketReady ? getAppExists : null, isTerminalBusy, diff --git a/web/context/editor-context.tsx b/web/context/editor-context.tsx index ed8a976..b37471b 100644 --- a/web/context/editor-context.tsx +++ b/web/context/editor-context.tsx @@ -43,7 +43,7 @@ interface EditorContextType { togglePreviewPanel: () => void toggleLayout: () => void toggleAIChat: () => void - loadPreviewURL: (url: string) => void + loadPreviewURL: (url: string | null) => void setIsAIChatOpen: React.Dispatch> setIsPreviewCollapsed: React.Dispatch> previewPanelRef: React.RefObject @@ -104,8 +104,8 @@ export function EditorProvider({ children }: { children: ReactNode }) { } }, [isAIChatOpen, previousLayout]) - const loadPreviewURL = useCallback((url: string) => { - setPreviewURL(url) + const loadPreviewURL = useCallback((url: string | null) => { + setPreviewURL(url ?? "") }, []) // Handler registry diff --git a/web/lib/api/terminal.ts b/web/lib/api/terminal.ts index 48c7032..3263b2b 100644 --- a/web/lib/api/terminal.ts +++ b/web/lib/api/terminal.ts @@ -118,3 +118,7 @@ export const closeTerminal = ({ }) }) } + +export const stopPreview = (socket: Socket): void => { + socket.emit("stopPreview") +}