Add terminal + browser preview Electron app#6
Conversation
Chromium-based desktop application combining a real bash/zsh terminal (via xterm.js + node-pty) with an integrated browser preview panel. Features split-pane layout, tabbed browsing, URL navigation, DevTools access, keyboard shortcuts, and a Catppuccin Mocha theme. https://claude.ai/code/session_01JTLtTA9hANEmiUWwCoKNX3
Redesigned architecture with 3 tab levels: - Project tabs (top bar): independent workspaces, color-coded, renamable - Terminal tabs (per project): each spawns its own PTY shell process - Browser tabs (per project): each with its own Chromium webview main.js now manages a PTY pool via ID-based IPC instead of a single process. Each project maintains its own layout, divider position, and focus state independently. https://claude.ai/code/session_01JTLtTA9hANEmiUWwCoKNX3
There was a problem hiding this comment.
Pull request overview
This PR introduces a new Electron-based desktop application that combines terminal emulation with browser preview functionality in a split-pane interface. The application features a three-level tabbing system (projects > terminal tabs + browser tabs), using xterm.js for terminal rendering, node-pty for shell process management, and Electron's webview for browser functionality, all styled with the Catppuccin Mocha theme.
Changes:
- Creates a complete Electron application with terminal emulation and browser preview capabilities
- Implements multi-project workspace management with independent terminal and browser tab sets
- Adds comprehensive keyboard shortcuts and layout management for split-pane views
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| package.json | Defines dependencies for Electron, xterm.js, and node-pty |
| main.js | Electron main process handling PTY process lifecycle and IPC communication |
| preload.js | Context bridge exposing terminal API to renderer with security isolation |
| renderer.js | Frontend logic managing three-level tab system and UI interactions |
| index.html | UI structure and Catppuccin Mocha theme styling |
| README.md | Documentation covering features, setup, keyboard shortcuts, and architecture |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| function setupDividerDrag(project, dom) { | ||
| let dragging = false; | ||
|
|
||
| dom.divider.addEventListener('mousedown', (e) => { | ||
| dragging = true; | ||
| dom.divider.classList.add('dragging'); | ||
| const isVert = dom.workspace.classList.contains('layout-vertical'); | ||
| document.body.style.cursor = isVert ? 'row-resize' : 'col-resize'; | ||
| document.body.style.userSelect = 'none'; | ||
| dom.webviewContainer.style.pointerEvents = 'none'; | ||
| e.preventDefault(); | ||
| }); | ||
|
|
||
| document.addEventListener('mousemove', (e) => { | ||
| if (!dragging) return; | ||
| const rect = dom.workspace.getBoundingClientRect(); | ||
|
|
||
| if (dom.workspace.classList.contains('layout-vertical')) { | ||
| const pct = ((e.clientY - rect.top) / rect.height) * 100; | ||
| const clamped = Math.max(15, Math.min(85, pct)); | ||
| dom.termPanel.style.flex = 'none'; | ||
| dom.termPanel.style.height = clamped + '%'; | ||
| dom.browserPanel.style.flex = 'none'; | ||
| dom.browserPanel.style.height = (100 - clamped) + '%'; | ||
| } else { | ||
| const pct = ((e.clientX - rect.left) / rect.width) * 100; | ||
| const clamped = Math.max(15, Math.min(85, pct)); | ||
| dom.termPanel.style.flex = 'none'; | ||
| dom.termPanel.style.width = clamped + '%'; | ||
| dom.browserPanel.style.flex = 'none'; | ||
| dom.browserPanel.style.width = (100 - clamped) + '%'; | ||
| } | ||
| }); | ||
|
|
||
| document.addEventListener('mouseup', () => { | ||
| if (!dragging) return; | ||
| dragging = false; | ||
| dom.divider.classList.remove('dragging'); | ||
| document.body.style.cursor = ''; | ||
| document.body.style.userSelect = ''; | ||
| dom.webviewContainer.style.pointerEvents = ''; | ||
| const tt = getActiveTerminalTab(project); | ||
| if (tt) { | ||
| tt.fitAddon.fit(); | ||
| if (tt.alive) window.terminalAPI.resize(tt.ptyId, tt.term.cols, tt.term.rows); | ||
| } | ||
| }); | ||
| } |
There was a problem hiding this comment.
The divider drag event listeners are attached to the document but never cleaned up. If multiple projects are created and destroyed, these listeners will accumulate, leading to performance degradation and potential bugs where dragging affects the wrong project. Consider using AbortController or storing references to remove listeners when a project is closed.
|
|
||
| const webview = document.createElement('webview'); | ||
| webview.setAttribute('src', url); | ||
| webview.setAttribute('allowpopups', ''); |
There was a problem hiding this comment.
The webview is created with 'allowpopups' attribute without any additional security constraints. This allows any loaded page to open popup windows, which could be used for phishing attacks or unwanted advertising. Consider adding additional webview security attributes like 'disablewebsecurity=false' (explicitly), and implementing popup handling/blocking through webview events.
| if (url.startsWith('/') || url.startsWith('./') || url.startsWith('~')) { | ||
| url = 'file://' + url; | ||
| } else if (!url.match(/^[a-zA-Z]+:\/\//)) { | ||
| if (url.match(/^[\w.-]+\.\w{2,}/)) { |
There was a problem hiding this comment.
The navigateBrowser function accepts arbitrary user input and constructs file:// URLs without validation or sanitization. A malicious user could potentially access sensitive files on the system. Consider implementing a whitelist of allowed file paths or validating that file paths are within safe directories before allowing file:// protocol navigation.
| if (url.startsWith('/') || url.startsWith('./') || url.startsWith('~')) { | |
| url = 'file://' + url; | |
| } else if (!url.match(/^[a-zA-Z]+:\/\//)) { | |
| if (url.match(/^[\w.-]+\.\w{2,}/)) { | |
| const hasScheme = /^[a-zA-Z]+:\/\//.test(url); | |
| const isDomainLike = /^[\w.-]+\.\w{2,}/.test(url); | |
| // Do not construct file:// URLs from arbitrary user input to avoid exposing local files. | |
| // Path-like inputs (starting with "/", "./", or "~") are treated as search queries instead. | |
| if (!hasScheme && (url.startsWith('/') || url.startsWith('./') || url.startsWith('~'))) { | |
| url = 'https://duckduckgo.com/?q=' + encodeURIComponent(url); | |
| } else if (!hasScheme) { | |
| if (isDomainLike) { |
| } | ||
|
|
||
| function renameProject(proj) { | ||
| const tabEl = projectTabList.querySelector(`.project-tab:nth-child(${projects.indexOf(proj) + 1}) .tab-title`); |
There was a problem hiding this comment.
The querySelector uses a pseudo-selector based on indexOf which is fragile and will break if project ordering changes. If projects can be reordered or if the project is removed and re-added, this selector may target the wrong element. Consider adding a data attribute with the project ID to the tab element for more reliable selection.
| const input = document.createElement('input'); | ||
| input.type = 'text'; | ||
| input.value = proj.name; | ||
| input.style.cssText = 'width:100px;background:var(--overlay);border:1px solid var(--accent);color:var(--text);font-size:12px;font-family:inherit;border-radius:3px;padding:0 4px;outline:none;'; |
There was a problem hiding this comment.
The inline style string is overly complex and difficult to maintain. Consider defining this style in a CSS class in index.html or using a style object with proper key-value pairs. This would improve readability and make it easier to modify styles in the future.
| input.style.cssText = 'width:100px;background:var(--overlay);border:1px solid var(--accent);color:var(--text);font-size:12px;font-family:inherit;border-radius:3px;padding:0 4px;outline:none;'; | |
| input.style.width = '100px'; | |
| input.style.background = 'var(--overlay)'; | |
| input.style.border = '1px solid var(--accent)'; | |
| input.style.color = 'var(--text)'; | |
| input.style.fontSize = '12px'; | |
| input.style.fontFamily = 'inherit'; | |
| input.style.borderRadius = '3px'; | |
| input.style.padding = '0 4px'; | |
| input.style.outline = 'none'; |
| ipcMain.on('pty:resize', (_event, id, cols, rows) => { | ||
| const proc = ptyProcesses.get(id); | ||
| if (proc) { | ||
| try { proc.resize(cols, rows); } catch (_) {} |
There was a problem hiding this comment.
The try-catch block silently swallows all errors when resizing the PTY. This can hide important issues like invalid dimensions or process failures. Consider logging the error to help with debugging, especially since terminal resize failures can cause display issues.
| // ── Create a new PTY process ────────────────────────────────────────── | ||
| ipcMain.handle('pty:create', (_event, opts = {}) => { | ||
| const id = nextPtyId++; | ||
| const shell = getShell(); | ||
| const cwd = opts.cwd || process.env.HOME || os.homedir(); | ||
|
|
||
| const proc = pty.spawn(shell, [], { | ||
| name: 'xterm-256color', | ||
| cols: opts.cols || 80, | ||
| rows: opts.rows || 24, | ||
| cwd, | ||
| env: { | ||
| ...process.env, | ||
| TERM: 'xterm-256color', | ||
| COLORTERM: 'truecolor' | ||
| } | ||
| }); | ||
|
|
||
| proc.onData((data) => { | ||
| if (mainWindow && !mainWindow.isDestroyed()) { | ||
| mainWindow.webContents.send('pty:data', id, data); | ||
| } | ||
| }); | ||
|
|
||
| proc.onExit(({ exitCode }) => { | ||
| if (mainWindow && !mainWindow.isDestroyed()) { | ||
| mainWindow.webContents.send('pty:exit', id, exitCode); | ||
| } | ||
| ptyProcesses.delete(id); | ||
| }); | ||
|
|
||
| ptyProcesses.set(id, proc); | ||
|
|
||
| const shellName = path.basename(shell); | ||
| return { id, shell: shellName, pid: proc.pid }; | ||
| }); | ||
|
|
||
| // ── Write to a PTY ──────────────────────────────────────────────────── | ||
| ipcMain.on('pty:write', (_event, id, data) => { | ||
| const proc = ptyProcesses.get(id); | ||
| if (proc) proc.write(data); | ||
| }); | ||
|
|
||
| // ── Resize a PTY ────────────────────────────────────────────────────── | ||
| ipcMain.on('pty:resize', (_event, id, cols, rows) => { | ||
| const proc = ptyProcesses.get(id); | ||
| if (proc) { | ||
| try { proc.resize(cols, rows); } catch (_) {} | ||
| } | ||
| }); | ||
|
|
||
| // ── Kill a PTY ──────────────────────────────────────────────────────── | ||
| ipcMain.on('pty:kill', (_event, id) => { | ||
| const proc = ptyProcesses.get(id); | ||
| if (proc) { | ||
| proc.kill(); | ||
| ptyProcesses.delete(id); | ||
| } | ||
| }); | ||
|
|
||
| mainWindow.on('closed', () => { | ||
| mainWindow = null; | ||
| for (const proc of ptyProcesses.values()) proc.kill(); | ||
| ptyProcesses.clear(); | ||
| }); | ||
| } | ||
|
|
There was a problem hiding this comment.
The IPC handlers are registered inside createWindow(), which means they will be registered multiple times if the window is closed and reopened (via the activate event on macOS). This can lead to duplicate event handlers and unexpected behavior. Move the ipcMain handler registrations outside of createWindow() to ensure they're only registered once.
| // ── Create a new PTY process ────────────────────────────────────────── | |
| ipcMain.handle('pty:create', (_event, opts = {}) => { | |
| const id = nextPtyId++; | |
| const shell = getShell(); | |
| const cwd = opts.cwd || process.env.HOME || os.homedir(); | |
| const proc = pty.spawn(shell, [], { | |
| name: 'xterm-256color', | |
| cols: opts.cols || 80, | |
| rows: opts.rows || 24, | |
| cwd, | |
| env: { | |
| ...process.env, | |
| TERM: 'xterm-256color', | |
| COLORTERM: 'truecolor' | |
| } | |
| }); | |
| proc.onData((data) => { | |
| if (mainWindow && !mainWindow.isDestroyed()) { | |
| mainWindow.webContents.send('pty:data', id, data); | |
| } | |
| }); | |
| proc.onExit(({ exitCode }) => { | |
| if (mainWindow && !mainWindow.isDestroyed()) { | |
| mainWindow.webContents.send('pty:exit', id, exitCode); | |
| } | |
| ptyProcesses.delete(id); | |
| }); | |
| ptyProcesses.set(id, proc); | |
| const shellName = path.basename(shell); | |
| return { id, shell: shellName, pid: proc.pid }; | |
| }); | |
| // ── Write to a PTY ──────────────────────────────────────────────────── | |
| ipcMain.on('pty:write', (_event, id, data) => { | |
| const proc = ptyProcesses.get(id); | |
| if (proc) proc.write(data); | |
| }); | |
| // ── Resize a PTY ────────────────────────────────────────────────────── | |
| ipcMain.on('pty:resize', (_event, id, cols, rows) => { | |
| const proc = ptyProcesses.get(id); | |
| if (proc) { | |
| try { proc.resize(cols, rows); } catch (_) {} | |
| } | |
| }); | |
| // ── Kill a PTY ──────────────────────────────────────────────────────── | |
| ipcMain.on('pty:kill', (_event, id) => { | |
| const proc = ptyProcesses.get(id); | |
| if (proc) { | |
| proc.kill(); | |
| ptyProcesses.delete(id); | |
| } | |
| }); | |
| mainWindow.on('closed', () => { | |
| mainWindow = null; | |
| for (const proc of ptyProcesses.values()) proc.kill(); | |
| ptyProcesses.clear(); | |
| }); | |
| } | |
| mainWindow.on('closed', () => { | |
| mainWindow = null; | |
| for (const proc of ptyProcesses.values()) proc.kill(); | |
| ptyProcesses.clear(); | |
| }); | |
| } | |
| // ── IPC handlers (registered once at the top level) ─────────────────── | |
| // Create a new PTY process | |
| ipcMain.handle('pty:create', (_event, opts = {}) => { | |
| const id = nextPtyId++; | |
| const shell = getShell(); | |
| const cwd = opts.cwd || process.env.HOME || os.homedir(); | |
| const proc = pty.spawn(shell, [], { | |
| name: 'xterm-256color', | |
| cols: opts.cols || 80, | |
| rows: opts.rows || 24, | |
| cwd, | |
| env: { | |
| ...process.env, | |
| TERM: 'xterm-256color', | |
| COLORTERM: 'truecolor' | |
| } | |
| }); | |
| proc.onData((data) => { | |
| if (mainWindow && !mainWindow.isDestroyed()) { | |
| mainWindow.webContents.send('pty:data', id, data); | |
| } | |
| }); | |
| proc.onExit(({ exitCode }) => { | |
| if (mainWindow && !mainWindow.isDestroyed()) { | |
| mainWindow.webContents.send('pty:exit', id, exitCode); | |
| } | |
| ptyProcesses.delete(id); | |
| }); | |
| ptyProcesses.set(id, proc); | |
| const shellName = path.basename(shell); | |
| return { id, shell: shellName, pid: proc.pid }; | |
| }); | |
| // Write to a PTY | |
| ipcMain.on('pty:write', (_event, id, data) => { | |
| const proc = ptyProcesses.get(id); | |
| if (proc) proc.write(data); | |
| }); | |
| // Resize a PTY | |
| ipcMain.on('pty:resize', (_event, id, cols, rows) => { | |
| const proc = ptyProcesses.get(id); | |
| if (proc) { | |
| try { proc.resize(cols, rows); } catch (_) {} | |
| } | |
| }); | |
| // Kill a PTY | |
| ipcMain.on('pty:kill', (_event, id) => { | |
| const proc = ptyProcesses.get(id); | |
| if (proc) { | |
| proc.kill(); | |
| ptyProcesses.delete(id); | |
| } | |
| }); |
| onData: (callback) => ipcRenderer.on('pty:data', (_event, id, data) => callback(id, data)), | ||
|
|
||
| // Listen for PTY exit (callback receives id, exitCode) | ||
| onExit: (callback) => ipcRenderer.on('pty:exit', (_event, id, code) => callback(id, code)) |
There was a problem hiding this comment.
The onData and onExit listeners don't provide cleanup methods to remove listeners. This can cause memory leaks if components are destroyed and recreated. Consider returning cleanup functions from these methods (e.g., return a function that calls ipcRenderer.removeListener) to allow proper cleanup.
| onData: (callback) => ipcRenderer.on('pty:data', (_event, id, data) => callback(id, data)), | |
| // Listen for PTY exit (callback receives id, exitCode) | |
| onExit: (callback) => ipcRenderer.on('pty:exit', (_event, id, code) => callback(id, code)) | |
| onData: (callback) => { | |
| const listener = (_event, id, data) => callback(id, data); | |
| ipcRenderer.on('pty:data', listener); | |
| // Return cleanup function to remove this listener | |
| return () => ipcRenderer.removeListener('pty:data', listener); | |
| }, | |
| // Listen for PTY exit (callback receives id, exitCode) | |
| onExit: (callback) => { | |
| const listener = (_event, id, code) => callback(id, code); | |
| ipcRenderer.on('pty:exit', listener); | |
| // Return cleanup function to remove this listener | |
| return () => ipcRenderer.removeListener('pty:exit', listener); | |
| } |
| if (url.startsWith('/') || url.startsWith('./') || url.startsWith('~')) { | ||
| url = 'file://' + url; |
There was a problem hiding this comment.
The URL navigation logic treats any input starting with '' as a file path and prepends 'file://', but '' is a shell alias for the home directory that isn't automatically expanded in URLs. This will result in broken file:// URLs. Consider expanding the tilde to the actual home directory path or removing this pattern from file path detection.
| if (url.startsWith('/') || url.startsWith('./') || url.startsWith('~')) { | |
| url = 'file://' + url; | |
| if (url.startsWith('/') || url.startsWith('./')) { | |
| url = 'file://' + url; | |
| } else if (url.startsWith('~')) { | |
| const homeDir = (typeof process !== 'undefined' && process.env) | |
| ? (process.env.HOME || process.env.USERPROFILE) | |
| : null; | |
| if (homeDir) { | |
| url = 'file://' + homeDir + url.slice(1); | |
| } else { | |
| // Fallback to previous behavior if home directory cannot be determined | |
| url = 'file://' + url; | |
| } |
| if (url.startsWith('/') || url.startsWith('./') || url.startsWith('~')) { | ||
| url = 'file://' + url; | ||
| } else if (!url.match(/^[a-zA-Z]+:\/\//)) { | ||
| if (url.match(/^[\w.-]+\.\w{2,}/)) { |
There was a problem hiding this comment.
The regex pattern /^[\w.-]+.\w{2,}/ is too simplistic for domain detection and will produce false positives. For example, it would match "123.456" or "file.mp3" and incorrectly prepend "https://". Consider using a more robust domain validation pattern that checks for valid TLDs or at least requires alphabetic characters in the TLD portion.
| if (url.match(/^[\w.-]+\.\w{2,}/)) { | |
| if (url.match(/^(?!-)(?:[A-Za-z0-9-]{1,63}\.)+[A-Za-z]{2,}(?:\/|$)/)) { |
Chromium-based desktop application combining a real bash/zsh terminal
(via xterm.js + node-pty) with an integrated browser preview panel.
Features split-pane layout, tabbed browsing, URL navigation, DevTools
access, keyboard shortcuts, and a Catppuccin Mocha theme.
https://claude.ai/code/session_01JTLtTA9hANEmiUWwCoKNX3