From 335be10d4258c75b5348929e11ce7c274c45bff5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Feb 2026 05:52:51 +0000 Subject: [PATCH 1/2] Add terminal + browser preview Electron app 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 --- terminal-browser-preview/README.md | 41 +++ terminal-browser-preview/index.html | 375 +++++++++++++++++++++++ terminal-browser-preview/main.js | 84 ++++++ terminal-browser-preview/package.json | 22 ++ terminal-browser-preview/preload.js | 8 + terminal-browser-preview/renderer.js | 409 ++++++++++++++++++++++++++ 6 files changed, 939 insertions(+) create mode 100644 terminal-browser-preview/README.md create mode 100644 terminal-browser-preview/index.html create mode 100644 terminal-browser-preview/main.js create mode 100644 terminal-browser-preview/package.json create mode 100644 terminal-browser-preview/preload.js create mode 100644 terminal-browser-preview/renderer.js diff --git a/terminal-browser-preview/README.md b/terminal-browser-preview/README.md new file mode 100644 index 0000000..82b1ac0 --- /dev/null +++ b/terminal-browser-preview/README.md @@ -0,0 +1,41 @@ +# Terminal + Browser Preview + +A Chromium-based desktop application that combines a fully-featured bash/zsh terminal with an integrated browser preview panel. Built with Electron, xterm.js, and node-pty. + +## Features + +- **Real terminal** - Full bash/zsh shell via node-pty with 256-color support +- **Browser preview** - Chromium-powered webview with tabs, navigation, and DevTools +- **Split-pane layout** - Horizontal, vertical, terminal-only, or browser-only views +- **Resizable panels** - Drag the divider to adjust the split ratio +- **Multiple browser tabs** - Open, close, and switch between tabs +- **URL bar** - Navigate to URLs, file paths, or search queries +- **Keyboard shortcuts** - Ctrl+T (new tab), Ctrl+W (close tab), Ctrl+L (focus URL bar), Ctrl+1/2/3 (layout modes) + +## Setup + +```bash +cd terminal-browser-preview +npm install +npx electron-rebuild # rebuild node-pty for Electron +npm start +``` + +## Keyboard Shortcuts + +| Shortcut | Action | +|----------|--------| +| `Ctrl+T` | New browser tab | +| `Ctrl+W` | Close current tab | +| `Ctrl+L` | Focus URL bar | +| `Ctrl+R` | Reload page | +| `Ctrl+1` | Split view | +| `Ctrl+2` | Terminal only | +| `Ctrl+3` | Browser only | + +## Architecture + +- **main.js** - Electron main process: creates window, spawns PTY shell +- **preload.js** - Secure IPC bridge between main and renderer +- **renderer.js** - Terminal (xterm.js) + browser tab management + layout controls +- **index.html** - UI layout with Catppuccin Mocha theme diff --git a/terminal-browser-preview/index.html b/terminal-browser-preview/index.html new file mode 100644 index 0000000..5b7ab9e --- /dev/null +++ b/terminal-browser-preview/index.html @@ -0,0 +1,375 @@ + + + + + + Terminal + Browser Preview + + + + + +
+ Terminal + Browser Preview +
+ + + + +
+ + +
+ +
+
+ bash + terminal +
+
+
+ + +
+ + +
+
+
+ +
+
+ + + + + +
+
+
+
+ + +
+
+ + Connected +
+
+
+
+
+ + + + diff --git a/terminal-browser-preview/main.js b/terminal-browser-preview/main.js new file mode 100644 index 0000000..7c9bc1c --- /dev/null +++ b/terminal-browser-preview/main.js @@ -0,0 +1,84 @@ +const { app, BrowserWindow, ipcMain } = require('electron'); +const path = require('path'); +const os = require('os'); +const pty = require('node-pty'); + +let mainWindow; +let ptyProcess; + +function getShell() { + if (process.platform === 'win32') return 'powershell.exe'; + return process.env.SHELL || '/bin/bash'; +} + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1400, + height: 900, + minWidth: 800, + minHeight: 500, + title: 'Terminal + Browser Preview', + backgroundColor: '#1e1e2e', + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + nodeIntegration: false, + contextIsolation: true, + webviewTag: true + } + }); + + mainWindow.loadFile('index.html'); + + const shell = getShell(); + const cwd = process.env.HOME || os.homedir(); + + ptyProcess = pty.spawn(shell, [], { + name: 'xterm-256color', + cols: 80, + rows: 24, + cwd: cwd, + env: { + ...process.env, + TERM: 'xterm-256color', + COLORTERM: 'truecolor' + } + }); + + ptyProcess.onData((data) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('terminal:data', data); + } + }); + + ptyProcess.onExit(({ exitCode }) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('terminal:exit', exitCode); + } + }); + + ipcMain.on('terminal:input', (_event, data) => { + if (ptyProcess) ptyProcess.write(data); + }); + + ipcMain.on('terminal:resize', (_event, { cols, rows }) => { + if (ptyProcess) { + try { ptyProcess.resize(cols, rows); } catch (_) {} + } + }); + + mainWindow.on('closed', () => { + mainWindow = null; + if (ptyProcess) ptyProcess.kill(); + }); +} + +app.whenReady().then(createWindow); + +app.on('window-all-closed', () => { + if (ptyProcess) ptyProcess.kill(); + app.quit(); +}); + +app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow(); +}); diff --git a/terminal-browser-preview/package.json b/terminal-browser-preview/package.json new file mode 100644 index 0000000..c98b9e2 --- /dev/null +++ b/terminal-browser-preview/package.json @@ -0,0 +1,22 @@ +{ + "name": "terminal-browser-preview", + "version": "1.0.0", + "description": "Chromium-based terminal with integrated browser preview - mixes bash/zsh with a browser tab", + "main": "main.js", + "scripts": { + "start": "electron .", + "dev": "electron . --dev" + }, + "keywords": ["terminal", "browser", "electron", "xterm", "preview"], + "license": "MIT", + "dependencies": { + "electron": "^33.0.0", + "@xterm/xterm": "^5.5.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-web-links": "^0.11.0", + "node-pty": "^1.0.0" + }, + "devDependencies": { + "electron-rebuild": "^3.2.9" + } +} diff --git a/terminal-browser-preview/preload.js b/terminal-browser-preview/preload.js new file mode 100644 index 0000000..6d95cd6 --- /dev/null +++ b/terminal-browser-preview/preload.js @@ -0,0 +1,8 @@ +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('terminalAPI', { + send: (data) => ipcRenderer.send('terminal:input', data), + resize: (cols, rows) => ipcRenderer.send('terminal:resize', { cols, rows }), + onData: (callback) => ipcRenderer.on('terminal:data', (_event, data) => callback(data)), + onExit: (callback) => ipcRenderer.on('terminal:exit', (_event, code) => callback(code)) +}); diff --git a/terminal-browser-preview/renderer.js b/terminal-browser-preview/renderer.js new file mode 100644 index 0000000..79976e9 --- /dev/null +++ b/terminal-browser-preview/renderer.js @@ -0,0 +1,409 @@ +/* ======================================================================== + Terminal + Browser Preview - Renderer + ======================================================================== */ + +const { Terminal } = require('@xterm/xterm'); +const { FitAddon } = require('@xterm/addon-fit'); +const { WebLinksAddon } = require('@xterm/addon-web-links'); + +// ── Terminal Setup ────────────────────────────────────────────────────── + +const term = new Terminal({ + fontSize: 14, + fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Consolas', monospace", + cursorBlink: true, + cursorStyle: 'bar', + allowProposedApi: true, + scrollback: 10000, + theme: { + background: '#1e1e2e', + foreground: '#cdd6f4', + cursor: '#f5e0dc', + cursorAccent: '#1e1e2e', + selectionBackground: '#45475a', + selectionForeground: '#cdd6f4', + black: '#45475a', + red: '#f38ba8', + green: '#a6e3a1', + yellow: '#f9e2af', + blue: '#89b4fa', + magenta: '#f5c2e7', + cyan: '#94e2d5', + white: '#bac2de', + brightBlack: '#585b70', + brightRed: '#f38ba8', + brightGreen: '#a6e3a1', + brightYellow: '#f9e2af', + brightBlue: '#89b4fa', + brightMagenta: '#f5c2e7', + brightCyan: '#94e2d5', + brightWhite: '#a6adc8' + } +}); + +const fitAddon = new FitAddon(); +term.loadAddon(fitAddon); +term.loadAddon(new WebLinksAddon()); + +const termContainer = document.getElementById('terminal-container'); +term.open(termContainer); + +requestAnimationFrame(() => { + fitAddon.fit(); + window.terminalAPI.resize(term.cols, term.rows); + document.getElementById('terminal-size').textContent = `${term.cols}x${term.rows}`; +}); + +// Terminal I/O +term.onData((data) => window.terminalAPI.send(data)); +window.terminalAPI.onData((data) => term.write(data)); +window.terminalAPI.onExit((code) => { + term.write(`\r\n\x1b[31m[Process exited with code ${code}]\x1b[0m\r\n`); + document.getElementById('pty-status').style.background = '#f38ba8'; + document.getElementById('pty-label').textContent = 'Disconnected'; +}); + +// Resize handling +const resizeObserver = new ResizeObserver(() => { + requestAnimationFrame(() => { + fitAddon.fit(); + window.terminalAPI.resize(term.cols, term.rows); + document.getElementById('terminal-size').textContent = `${term.cols}x${term.rows}`; + }); +}); +resizeObserver.observe(termContainer); + +// ── Browser Tab Management ────────────────────────────────────────────── + +let tabs = []; +let activeTabId = null; +let nextTabId = 1; + +const tabList = document.getElementById('tab-list'); +const webviewContainer = document.getElementById('webview-container'); +const urlInput = document.getElementById('url-input'); + +function createTab(url = 'about:blank') { + const id = nextTabId++; + + const webview = document.createElement('webview'); + webview.setAttribute('src', url); + webview.setAttribute('allowpopups', ''); + webview.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:none;'; + webviewContainer.appendChild(webview); + + const tab = { + id, + title: 'New Tab', + url, + webview + }; + + // Listen for navigation events + webview.addEventListener('did-navigate', (e) => { + tab.url = e.url; + if (tab.id === activeTabId) urlInput.value = e.url; + }); + + webview.addEventListener('did-navigate-in-page', (e) => { + tab.url = e.url; + if (tab.id === activeTabId) urlInput.value = e.url; + }); + + webview.addEventListener('page-title-updated', (e) => { + tab.title = e.title || 'Untitled'; + renderTabs(); + }); + + webview.addEventListener('did-start-loading', () => { + if (tab.id === activeTabId) { + document.getElementById('webview-status').textContent = 'Loading...'; + } + }); + + webview.addEventListener('did-stop-loading', () => { + if (tab.id === activeTabId) { + document.getElementById('webview-status').textContent = ''; + } + }); + + tabs.push(tab); + switchToTab(id); + renderTabs(); + return tab; +} + +function switchToTab(id) { + activeTabId = id; + tabs.forEach(t => { + t.webview.style.display = t.id === id ? 'flex' : 'none'; + }); + const tab = tabs.find(t => t.id === id); + if (tab) urlInput.value = tab.url; + renderTabs(); +} + +function closeTab(id) { + const idx = tabs.findIndex(t => t.id === id); + if (idx === -1) return; + + const tab = tabs[idx]; + tab.webview.remove(); + tabs.splice(idx, 1); + + if (tabs.length === 0) { + createTab(); + } else if (activeTabId === id) { + const newIdx = Math.min(idx, tabs.length - 1); + switchToTab(tabs[newIdx].id); + } + renderTabs(); +} + +function renderTabs() { + tabList.innerHTML = ''; + tabs.forEach(tab => { + const el = document.createElement('div'); + el.className = 'browser-tab' + (tab.id === activeTabId ? ' active' : ''); + + const title = document.createElement('span'); + title.className = 'tab-title'; + title.textContent = tab.title; + el.appendChild(title); + + const close = document.createElement('span'); + close.className = 'tab-close'; + close.textContent = '\u00d7'; + close.addEventListener('click', (e) => { + e.stopPropagation(); + closeTab(tab.id); + }); + el.appendChild(close); + + el.addEventListener('click', () => switchToTab(tab.id)); + tabList.appendChild(el); + }); +} + +function getActiveWebview() { + const tab = tabs.find(t => t.id === activeTabId); + return tab ? tab.webview : null; +} + +function navigateTo(input) { + const wv = getActiveWebview(); + if (!wv) return; + + let url = input.trim(); + if (!url) return; + + // Check if it's a local file path + if (url.startsWith('/') || url.startsWith('./') || url.startsWith('~')) { + url = 'file://' + url; + } + // Check if it's a URL without protocol + else if (!url.match(/^[a-zA-Z]+:\/\//)) { + // Check if it looks like a domain + if (url.match(/^[\w.-]+\.\w{2,}/)) { + url = 'https://' + url; + } else { + // Treat as search query + url = 'https://duckduckgo.com/?q=' + encodeURIComponent(url); + } + } + + wv.setAttribute('src', url); + const tab = tabs.find(t => t.id === activeTabId); + if (tab) tab.url = url; +} + +// ── URL Bar Events ────────────────────────────────────────────────────── + +urlInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + navigateTo(urlInput.value); + urlInput.blur(); + } +}); + +urlInput.addEventListener('focus', () => urlInput.select()); + +// ── Navigation Buttons ────────────────────────────────────────────────── + +document.getElementById('nav-back').addEventListener('click', () => { + const wv = getActiveWebview(); + if (wv && wv.canGoBack()) wv.goBack(); +}); + +document.getElementById('nav-forward').addEventListener('click', () => { + const wv = getActiveWebview(); + if (wv && wv.canGoForward()) wv.goForward(); +}); + +document.getElementById('nav-reload').addEventListener('click', () => { + const wv = getActiveWebview(); + if (wv) wv.reload(); +}); + +document.getElementById('nav-devtools').addEventListener('click', () => { + const wv = getActiveWebview(); + if (wv) { + if (wv.isDevToolsOpened()) { + wv.closeDevTools(); + } else { + wv.openDevTools(); + } + } +}); + +document.getElementById('new-tab-btn').addEventListener('click', () => { + createTab(); + urlInput.focus(); +}); + +// ── Split Pane Divider ────────────────────────────────────────────────── + +const divider = document.getElementById('divider'); +const terminalPanel = document.getElementById('terminal-panel'); +const browserPanel = document.getElementById('browser-panel'); +let isDragging = false; + +divider.addEventListener('mousedown', (e) => { + isDragging = true; + divider.classList.add('dragging'); + document.body.style.cursor = document.body.classList.contains('vertical') ? 'row-resize' : 'col-resize'; + document.body.style.userSelect = 'none'; + // Prevent webview from capturing mouse events + webviewContainer.style.pointerEvents = 'none'; + e.preventDefault(); +}); + +document.addEventListener('mousemove', (e) => { + if (!isDragging) return; + const main = document.getElementById('main'); + const rect = main.getBoundingClientRect(); + + if (document.body.classList.contains('vertical')) { + const y = e.clientY - rect.top; + const pct = (y / rect.height) * 100; + const clamped = Math.max(15, Math.min(85, pct)); + terminalPanel.style.flex = 'none'; + terminalPanel.style.height = clamped + '%'; + browserPanel.style.flex = 'none'; + browserPanel.style.height = (100 - clamped) + '%'; + } else { + const x = e.clientX - rect.left; + const pct = (x / rect.width) * 100; + const clamped = Math.max(15, Math.min(85, pct)); + terminalPanel.style.flex = 'none'; + terminalPanel.style.width = clamped + '%'; + browserPanel.style.flex = 'none'; + browserPanel.style.width = (100 - clamped) + '%'; + } +}); + +document.addEventListener('mouseup', () => { + if (!isDragging) return; + isDragging = false; + divider.classList.remove('dragging'); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + webviewContainer.style.pointerEvents = ''; + fitAddon.fit(); + window.terminalAPI.resize(term.cols, term.rows); +}); + +// ── Layout Buttons ────────────────────────────────────────────────────── + +const layoutButtons = { + split: document.getElementById('btn-layout-split'), + terminal: document.getElementById('btn-layout-terminal'), + browser: document.getElementById('btn-layout-browser'), + vertical: document.getElementById('btn-layout-vertical') +}; + +function setLayout(mode) { + document.body.classList.remove('terminal-only', 'browser-only', 'vertical'); + + // Reset inline styles from dragging + terminalPanel.style.flex = ''; + terminalPanel.style.width = ''; + terminalPanel.style.height = ''; + browserPanel.style.flex = ''; + browserPanel.style.width = ''; + browserPanel.style.height = ''; + + Object.values(layoutButtons).forEach(b => b.classList.remove('active')); + + switch (mode) { + case 'terminal': + document.body.classList.add('terminal-only'); + layoutButtons.terminal.classList.add('active'); + break; + case 'browser': + document.body.classList.add('browser-only'); + layoutButtons.browser.classList.add('active'); + break; + case 'vertical': + document.body.classList.add('vertical'); + layoutButtons.vertical.classList.add('active'); + break; + default: + layoutButtons.split.classList.add('active'); + break; + } + + requestAnimationFrame(() => { + fitAddon.fit(); + window.terminalAPI.resize(term.cols, term.rows); + }); +} + +layoutButtons.split.addEventListener('click', () => setLayout('split')); +layoutButtons.terminal.addEventListener('click', () => setLayout('terminal')); +layoutButtons.browser.addEventListener('click', () => setLayout('browser')); +layoutButtons.vertical.addEventListener('click', () => setLayout('vertical')); + +// ── Keyboard Shortcuts ────────────────────────────────────────────────── + +document.addEventListener('keydown', (e) => { + const mod = e.ctrlKey || e.metaKey; + + if (mod && e.key === 't') { + e.preventDefault(); + createTab(); + urlInput.focus(); + } else if (mod && e.key === 'w') { + e.preventDefault(); + if (activeTabId) closeTab(activeTabId); + } else if (mod && e.key === 'l') { + e.preventDefault(); + urlInput.focus(); + urlInput.select(); + } else if (mod && e.key === '1') { + e.preventDefault(); + setLayout('split'); + } else if (mod && e.key === '2') { + e.preventDefault(); + setLayout('terminal'); + } else if (mod && e.key === '3') { + e.preventDefault(); + setLayout('browser'); + } else if (mod && e.key === 'r') { + e.preventDefault(); + const wv = getActiveWebview(); + if (wv) wv.reload(); + } +}); + +// ── Initialize ────────────────────────────────────────────────────────── + +// Detect shell name from the process +const shellName = document.getElementById('shell-name'); +// Will be updated from terminal output if possible + +// Create initial tab +createTab('https://duckduckgo.com'); + +// Focus terminal on start +term.focus(); From d036ba3c8e2212c89dfb6108352046414b11f355 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Feb 2026 10:06:52 +0000 Subject: [PATCH 2/2] Add multi-level tabs: projects containing terminal + browser tabs 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 --- terminal-browser-preview/README.md | 65 +- terminal-browser-preview/index.html | 261 +++++--- terminal-browser-preview/main.js | 86 ++- terminal-browser-preview/preload.js | 21 +- terminal-browser-preview/renderer.js | 931 +++++++++++++++++++-------- 5 files changed, 969 insertions(+), 395 deletions(-) diff --git a/terminal-browser-preview/README.md b/terminal-browser-preview/README.md index 82b1ac0..4716c1c 100644 --- a/terminal-browser-preview/README.md +++ b/terminal-browser-preview/README.md @@ -1,16 +1,34 @@ # Terminal + Browser Preview -A Chromium-based desktop application that combines a fully-featured bash/zsh terminal with an integrated browser preview panel. Built with Electron, xterm.js, and node-pty. +A Chromium-based desktop application that combines bash/zsh terminals with browser preview tabs, organized into project workspaces. Built with Electron, xterm.js, and node-pty. + +## 3-Level Tab Architecture + +``` +[Project A] [Project B] [Project C] [+] <-- project tabs (top level) + ┌──────────────────────┬──────────────────────┐ + │ [bash#1] [zsh#2] [+] │ [Tab1] [Tab2] [+] │ <-- terminal & browser tabs + │ │ [< > ↻ URL bar 🔧] │ + │ Terminal content │ Browser content │ + │ │ │ + └──────────────────────┴──────────────────────┘ +``` + +Each project is an independent workspace with its own: +- Multiple terminal tabs (each running a separate shell session) +- Multiple browser tabs (each with its own webview) +- Independent layout (split, terminal-only, browser-only, vertical) +- Resizable split divider ## Features -- **Real terminal** - Full bash/zsh shell via node-pty with 256-color support -- **Browser preview** - Chromium-powered webview with tabs, navigation, and DevTools -- **Split-pane layout** - Horizontal, vertical, terminal-only, or browser-only views -- **Resizable panels** - Drag the divider to adjust the split ratio -- **Multiple browser tabs** - Open, close, and switch between tabs -- **URL bar** - Navigate to URLs, file paths, or search queries -- **Keyboard shortcuts** - Ctrl+T (new tab), Ctrl+W (close tab), Ctrl+L (focus URL bar), Ctrl+1/2/3 (layout modes) +- **Project workspaces** - Color-coded, renamable (double-click), independent +- **Multiple terminals** - Each tab spawns its own bash/zsh PTY process +- **Multiple browser tabs** - Chromium webviews with navigation, DevTools +- **Split-pane layouts** - Horizontal, vertical, terminal-only, browser-only (per project) +- **Resizable panels** - Drag divider to adjust split ratio +- **URL intelligence** - Detects URLs, file paths, and search queries +- **Catppuccin Mocha** dark theme ## Setup @@ -23,19 +41,40 @@ npm start ## Keyboard Shortcuts +### Projects +| Shortcut | Action | +|----------|--------| +| `Ctrl+Shift+P` | New project | +| `Ctrl+Shift+W` | Close project | +| `Ctrl+PageUp/Down` | Cycle projects | + +### Terminals +| Shortcut | Action | +|----------|--------| +| `Ctrl+Shift+T` | New terminal tab | +| `Ctrl+W` | Close focused tab | +| `Ctrl+Tab` | Next tab (in focused panel) | +| `Ctrl+Shift+Tab` | Previous tab | + +### Browser | Shortcut | Action | |----------|--------| | `Ctrl+T` | New browser tab | -| `Ctrl+W` | Close current tab | | `Ctrl+L` | Focus URL bar | | `Ctrl+R` | Reload page | + +### Layout +| Shortcut | Action | +|----------|--------| | `Ctrl+1` | Split view | | `Ctrl+2` | Terminal only | | `Ctrl+3` | Browser only | ## Architecture -- **main.js** - Electron main process: creates window, spawns PTY shell -- **preload.js** - Secure IPC bridge between main and renderer -- **renderer.js** - Terminal (xterm.js) + browser tab management + layout controls -- **index.html** - UI layout with Catppuccin Mocha theme +``` +main.js Electron main process - manages PTY pool via IPC +preload.js Secure context bridge - ID-based terminal API +renderer.js 3-level tab manager: projects > terminals + browsers +index.html UI layout with Catppuccin Mocha theme +``` diff --git a/terminal-browser-preview/index.html b/terminal-browser-preview/index.html index 5b7ab9e..0ea9778 100644 --- a/terminal-browser-preview/index.html +++ b/terminal-browser-preview/index.html @@ -18,10 +18,12 @@ --green: #a6e3a1; --red: #f38ba8; --yellow: #f9e2af; + --mauve: #cba6f7; + --peach: #fab387; --border: #45475a; --tab-bg: #11111b; - --tab-active: #1e1e2e; --toolbar-h: 40px; + --project-bar-h: 34px; --divider-w: 5px; } @@ -74,69 +76,122 @@ -webkit-app-region: drag; } - /* ===== Main Layout ===== */ - #main { - flex: 1; + /* ===== Project Tab Bar ===== */ + #project-bar { + height: var(--project-bar-h); + background: #0d0d17; display: flex; - overflow: hidden; + align-items: flex-end; + padding: 0 4px; + gap: 1px; + border-bottom: 2px solid var(--accent); + overflow-x: auto; } - /* ===== Terminal Panel ===== */ - #terminal-panel { - flex: 1; - min-width: 200px; - display: flex; - flex-direction: column; - background: var(--bg); - } + #project-bar::-webkit-scrollbar { height: 2px; } + #project-bar::-webkit-scrollbar-track { background: transparent; } + #project-bar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 1px; } - #terminal-header { - height: 32px; + .project-tab { + height: 30px; + min-width: 100px; + max-width: 220px; background: var(--surface); - border-bottom: 1px solid var(--border); + border: 1px solid var(--border); + border-bottom: none; + border-radius: 8px 8px 0 0; + padding: 0 10px; display: flex; align-items: center; - padding: 0 12px; + gap: 6px; + cursor: pointer; font-size: 12px; color: var(--subtext); - gap: 6px; + flex-shrink: 0; + transition: background 0.15s, border-color 0.15s; } - #terminal-header .shell-indicator { - color: var(--green); - font-weight: 600; + .project-tab.active { + background: var(--bg); + color: var(--text); + border-color: var(--accent); + border-bottom: 2px solid var(--bg); + margin-bottom: -2px; } - #terminal-container { - flex: 1; - padding: 4px; + .project-tab:hover:not(.active) { background: var(--overlay); } + + .project-tab .color-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + } + + .project-tab .tab-title { overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; } - /* ===== Divider ===== */ - #divider { - width: var(--divider-w); - background: var(--border); - cursor: col-resize; - transition: background 0.15s; + .project-tab .tab-close { + width: 16px; + height: 16px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + cursor: pointer; + color: var(--subtext); flex-shrink: 0; + opacity: 0; + transition: opacity 0.15s; } - #divider:hover, #divider.dragging { - background: var(--accent); + .project-tab:hover .tab-close { opacity: 1; } + .project-tab .tab-close:hover { background: var(--red); color: var(--bg); } + + #new-project-btn { + width: 30px; + height: 30px; + background: none; + border: none; + color: var(--subtext); + font-size: 18px; + cursor: pointer; + border-radius: 6px 6px 0 0; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; } - /* ===== Browser Panel ===== */ - #browser-panel { + #new-project-btn:hover { background: var(--overlay); color: var(--text); } + + /* ===== Main Layout ===== */ + #main { flex: 1; - min-width: 200px; display: flex; - flex-direction: column; - background: var(--bg); + overflow: hidden; + position: relative; + } + + /* Project workspace (one per project, only active visible) */ + .project-workspace { + position: absolute; + inset: 0; + display: none; + flex-direction: row; + } + + .project-workspace.active { + display: flex; } - /* Browser tabs bar */ - #browser-tabs { + /* ===== Shared tab bar styles (terminal + browser) ===== */ + .tab-bar { height: 32px; background: var(--tab-bg); display: flex; @@ -147,10 +202,14 @@ overflow-x: auto; } - .browser-tab { + .tab-bar::-webkit-scrollbar { height: 2px; } + .tab-bar::-webkit-scrollbar-track { background: transparent; } + .tab-bar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 1px; } + + .tab-item { height: 28px; - min-width: 100px; - max-width: 200px; + min-width: 80px; + max-width: 180px; background: var(--surface); border: 1px solid var(--border); border-bottom: none; @@ -166,13 +225,13 @@ transition: background 0.15s; } - .browser-tab.active { + .tab-item.active { background: var(--bg); color: var(--text); } - .browser-tab:hover { background: var(--overlay); } - .browser-tab.active:hover { background: var(--bg); } + .tab-item:hover { background: var(--overlay); } + .tab-item.active:hover { background: var(--bg); } .tab-title { overflow: hidden; @@ -196,7 +255,7 @@ .tab-close:hover { background: var(--red); color: var(--bg); } - #new-tab-btn { + .new-tab-btn { width: 28px; height: 28px; background: none; @@ -211,10 +270,52 @@ flex-shrink: 0; } - #new-tab-btn:hover { background: var(--overlay); color: var(--text); } + .new-tab-btn:hover { background: var(--overlay); color: var(--text); } + + /* ===== Terminal Panel ===== */ + .panel-terminal { + flex: 1; + min-width: 200px; + display: flex; + flex-direction: column; + background: var(--bg); + } + + .terminal-instance { + flex: 1; + padding: 4px; + overflow: hidden; + display: none; + } + + .terminal-instance.active { + display: block; + } + + /* ===== Divider ===== */ + .panel-divider { + width: var(--divider-w); + background: var(--border); + cursor: col-resize; + transition: background 0.15s; + flex-shrink: 0; + } + + .panel-divider:hover, .panel-divider.dragging { + background: var(--accent); + } + + /* ===== Browser Panel ===== */ + .panel-browser { + flex: 1; + min-width: 200px; + display: flex; + flex-direction: column; + background: var(--bg); + } /* URL Bar */ - #url-bar { + .url-bar { height: 36px; background: var(--surface); border-bottom: 1px solid var(--border); @@ -240,7 +341,7 @@ .nav-btn:hover { background: var(--overlay); color: var(--text); } - #url-input { + .url-input { flex: 1; height: 26px; background: var(--overlay); @@ -253,16 +354,16 @@ outline: none; } - #url-input:focus { border-color: var(--accent); } + .url-input:focus { border-color: var(--accent); } /* Webview container */ - #webview-container { + .webview-container { flex: 1; position: relative; background: #fff; } - #webview-container webview { + .webview-container webview { position: absolute; inset: 0; width: 100%; @@ -270,22 +371,22 @@ } /* ===== Layout modes ===== */ - body.terminal-only #browser-panel, - body.terminal-only #divider { display: none; } - body.terminal-only #terminal-panel { flex: 1; } + .layout-terminal-only .panel-browser, + .layout-terminal-only .panel-divider { display: none !important; } + .layout-terminal-only .panel-terminal { flex: 1; } - body.browser-only #terminal-panel, - body.browser-only #divider { display: none; } - body.browser-only #browser-panel { flex: 1; } + .layout-browser-only .panel-terminal, + .layout-browser-only .panel-divider { display: none !important; } + .layout-browser-only .panel-browser { flex: 1; } - body.vertical #main { flex-direction: column; } - body.vertical #divider { + .layout-vertical { flex-direction: column !important; } + .layout-vertical .panel-divider { width: auto; height: var(--divider-w); cursor: row-resize; } - body.vertical #terminal-panel, - body.vertical #browser-panel { min-height: 100px; min-width: auto; } + .layout-vertical .panel-terminal, + .layout-vertical .panel-browser { min-height: 100px; min-width: auto; } /* ===== Status bar ===== */ #statusbar { @@ -328,37 +429,15 @@ - -
- -
-
- bash - terminal -
-
-
- - -
- - -
-
-
- -
-
- - - - - -
-
-
+ +
+
+
+ +
+
diff --git a/terminal-browser-preview/main.js b/terminal-browser-preview/main.js index 7c9bc1c..f264f36 100644 --- a/terminal-browser-preview/main.js +++ b/terminal-browser-preview/main.js @@ -4,7 +4,8 @@ const os = require('os'); const pty = require('node-pty'); let mainWindow; -let ptyProcess; +const ptyProcesses = new Map(); // id -> pty process +let nextPtyId = 1; function getShell() { if (process.platform === 'win32') return 'powershell.exe'; @@ -29,53 +30,78 @@ function createWindow() { mainWindow.loadFile('index.html'); - const shell = getShell(); - const cwd = process.env.HOME || os.homedir(); - - ptyProcess = pty.spawn(shell, [], { - name: 'xterm-256color', - cols: 80, - rows: 24, - cwd: cwd, - env: { - ...process.env, - TERM: 'xterm-256color', - COLORTERM: 'truecolor' - } - }); + // ── 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(); - ptyProcess.onData((data) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('terminal:data', data); - } + 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 }; }); - ptyProcess.onExit(({ exitCode }) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('terminal:exit', exitCode); - } + // ── Write to a PTY ──────────────────────────────────────────────────── + ipcMain.on('pty:write', (_event, id, data) => { + const proc = ptyProcesses.get(id); + if (proc) proc.write(data); }); - ipcMain.on('terminal:input', (_event, data) => { - if (ptyProcess) ptyProcess.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 (_) {} + } }); - ipcMain.on('terminal:resize', (_event, { cols, rows }) => { - if (ptyProcess) { - try { ptyProcess.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; - if (ptyProcess) ptyProcess.kill(); + for (const proc of ptyProcesses.values()) proc.kill(); + ptyProcesses.clear(); }); } app.whenReady().then(createWindow); app.on('window-all-closed', () => { - if (ptyProcess) ptyProcess.kill(); + for (const proc of ptyProcesses.values()) proc.kill(); + ptyProcesses.clear(); app.quit(); }); diff --git a/terminal-browser-preview/preload.js b/terminal-browser-preview/preload.js index 6d95cd6..1bdc7c8 100644 --- a/terminal-browser-preview/preload.js +++ b/terminal-browser-preview/preload.js @@ -1,8 +1,21 @@ const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('terminalAPI', { - send: (data) => ipcRenderer.send('terminal:input', data), - resize: (cols, rows) => ipcRenderer.send('terminal:resize', { cols, rows }), - onData: (callback) => ipcRenderer.on('terminal:data', (_event, data) => callback(data)), - onExit: (callback) => ipcRenderer.on('terminal:exit', (_event, code) => callback(code)) + // Create a new PTY shell, returns { id, shell, pid } + create: (opts) => ipcRenderer.invoke('pty:create', opts), + + // Write data to a specific PTY + write: (id, data) => ipcRenderer.send('pty:write', id, data), + + // Resize a specific PTY + resize: (id, cols, rows) => ipcRenderer.send('pty:resize', id, cols, rows), + + // Kill a specific PTY + kill: (id) => ipcRenderer.send('pty:kill', id), + + // Listen for data from any PTY (callback receives id, data) + 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)) }); diff --git a/terminal-browser-preview/renderer.js b/terminal-browser-preview/renderer.js index 79976e9..aa88440 100644 --- a/terminal-browser-preview/renderer.js +++ b/terminal-browser-preview/renderer.js @@ -1,170 +1,506 @@ /* ======================================================================== Terminal + Browser Preview - Renderer + 3-level tabs: Projects > Terminal tabs + Browser tabs ======================================================================== */ const { Terminal } = require('@xterm/xterm'); const { FitAddon } = require('@xterm/addon-fit'); const { WebLinksAddon } = require('@xterm/addon-web-links'); -// ── Terminal Setup ────────────────────────────────────────────────────── - -const term = new Terminal({ - fontSize: 14, - fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Consolas', monospace", - cursorBlink: true, - cursorStyle: 'bar', - allowProposedApi: true, - scrollback: 10000, - theme: { - background: '#1e1e2e', - foreground: '#cdd6f4', - cursor: '#f5e0dc', - cursorAccent: '#1e1e2e', - selectionBackground: '#45475a', - selectionForeground: '#cdd6f4', - black: '#45475a', - red: '#f38ba8', - green: '#a6e3a1', - yellow: '#f9e2af', - blue: '#89b4fa', - magenta: '#f5c2e7', - cyan: '#94e2d5', - white: '#bac2de', - brightBlack: '#585b70', - brightRed: '#f38ba8', - brightGreen: '#a6e3a1', - brightYellow: '#f9e2af', - brightBlue: '#89b4fa', - brightMagenta: '#f5c2e7', - brightCyan: '#94e2d5', - brightWhite: '#a6adc8' +const XTERM_THEME = { + background: '#1e1e2e', + foreground: '#cdd6f4', + cursor: '#f5e0dc', + cursorAccent: '#1e1e2e', + selectionBackground: '#45475a', + selectionForeground: '#cdd6f4', + black: '#45475a', + red: '#f38ba8', + green: '#a6e3a1', + yellow: '#f9e2af', + blue: '#89b4fa', + magenta: '#f5c2e7', + cyan: '#94e2d5', + white: '#bac2de', + brightBlack: '#585b70', + brightRed: '#f38ba8', + brightGreen: '#a6e3a1', + brightYellow: '#f9e2af', + brightBlue: '#89b4fa', + brightMagenta: '#f5c2e7', + brightCyan: '#94e2d5', + brightWhite: '#a6adc8' +}; + +const PROJECT_COLORS = ['#89b4fa', '#a6e3a1', '#fab387', '#cba6f7', '#f38ba8', '#f9e2af', '#94e2d5', '#f5c2e7']; + +// ╔═══════════════════════════════════════════════════════════════════════╗ +// ║ PROJECT MANAGEMENT ║ +// ╚═══════════════════════════════════════════════════════════════════════╝ + +const projects = []; // Array of project objects +let activeProjectId = null; +let nextProjectId = 1; + +const mainEl = document.getElementById('main'); +const projectTabList = document.getElementById('project-tab-list'); + +// A project contains: { id, name, color, layout, workspaceEl, +// terminalTabs[], activeTerminalId, nextTerminalId, +// browserTabs[], activeBrowserId, nextBrowserId, +// dom: { termPanel, termTabList, termContainer, browserPanel, browserTabList, +// webviewContainer, urlInput, divider } } + +function createProjectDOM(project) { + const ws = document.createElement('div'); + ws.className = 'project-workspace'; + ws.dataset.projectId = project.id; + + ws.innerHTML = ` +
+
+
+ +
+
+
+
+
+
+
+ +
+
+ + + + + +
+
+
+ `; + + mainEl.appendChild(ws); + + const dom = { + workspace: ws, + termPanel: ws.querySelector('.panel-terminal'), + termTabList: ws.querySelector('.term-tab-list'), + termContainer: ws.querySelector('.term-container'), + browserPanel: ws.querySelector('.panel-browser'), + browserTabList: ws.querySelector('.browser-tab-list'), + webviewContainer: ws.querySelector('.webview-container'), + urlInput: ws.querySelector('.url-input'), + divider: ws.querySelector('.panel-divider') + }; + + // Wire up new-tab buttons + ws.querySelector('.new-term-btn').addEventListener('click', () => { + addTerminalTab(project); + }); + + ws.querySelector('.new-browser-btn').addEventListener('click', () => { + addBrowserTab(project); + dom.urlInput.focus(); + }); + + // Wire up URL bar + dom.urlInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + navigateBrowser(project, dom.urlInput.value); + dom.urlInput.blur(); + } + }); + dom.urlInput.addEventListener('focus', () => dom.urlInput.select()); + + // Wire up nav buttons + ws.querySelector('.nav-back').addEventListener('click', () => { + const wv = getActiveWebview(project); + if (wv && wv.canGoBack()) wv.goBack(); + }); + ws.querySelector('.nav-forward').addEventListener('click', () => { + const wv = getActiveWebview(project); + if (wv && wv.canGoForward()) wv.goForward(); + }); + ws.querySelector('.nav-reload').addEventListener('click', () => { + const wv = getActiveWebview(project); + if (wv) wv.reload(); + }); + ws.querySelector('.nav-devtools').addEventListener('click', () => { + const wv = getActiveWebview(project); + if (wv) { + if (wv.isDevToolsOpened()) wv.closeDevTools(); + else wv.openDevTools(); + } + }); + + // Wire up divider drag + setupDividerDrag(project, dom); + + // Wire up panel focus tracking + dom.termPanel.addEventListener('mousedown', () => { project.focusedPanel = 'terminal'; }); + dom.browserPanel.addEventListener('mousedown', () => { project.focusedPanel = 'browser'; }); + + // Resize observer for terminal fitting + const resizeObs = new ResizeObserver(() => { + const tt = getActiveTerminalTab(project); + if (!tt) return; + requestAnimationFrame(() => { + tt.fitAddon.fit(); + if (tt.alive) window.terminalAPI.resize(tt.ptyId, tt.term.cols, tt.term.rows); + if (project.id === activeProjectId) updateStatusBar(project); + }); + }); + resizeObs.observe(dom.termContainer); + + return dom; +} + +async function createProject(name) { + const id = nextProjectId++; + const color = PROJECT_COLORS[(id - 1) % PROJECT_COLORS.length]; + + const project = { + id, + name: name || `Project ${id}`, + color, + layout: 'split', + focusedPanel: 'terminal', + terminalTabs: [], + activeTerminalId: null, + nextTerminalId: 1, + browserTabs: [], + activeBrowserId: null, + nextBrowserId: 1, + dom: null + }; + + project.dom = createProjectDOM(project); + projects.push(project); + + // Create initial terminal + browser tab + await addTerminalTab(project); + addBrowserTab(project, 'https://duckduckgo.com'); + + switchProject(id); + renderProjectTabs(); + return project; +} + +function switchProject(id) { + activeProjectId = id; + projects.forEach(p => { + p.dom.workspace.classList.toggle('active', p.id === id); + }); + + const proj = getActiveProject(); + if (proj) { + applyLayout(proj, proj.layout); + // Refit active terminal + const tt = getActiveTerminalTab(proj); + if (tt) { + requestAnimationFrame(() => { + tt.fitAddon.fit(); + if (tt.alive) window.terminalAPI.resize(tt.ptyId, tt.term.cols, tt.term.rows); + tt.term.focus(); + }); + } + updateStatusBar(proj); } -}); + renderProjectTabs(); +} -const fitAddon = new FitAddon(); -term.loadAddon(fitAddon); -term.loadAddon(new WebLinksAddon()); +function closeProject(id) { + const idx = projects.findIndex(p => p.id === id); + if (idx === -1) return; -const termContainer = document.getElementById('terminal-container'); -term.open(termContainer); + const proj = projects[idx]; -requestAnimationFrame(() => { - fitAddon.fit(); - window.terminalAPI.resize(term.cols, term.rows); - document.getElementById('terminal-size').textContent = `${term.cols}x${term.rows}`; -}); + // Kill all PTY processes + for (const tt of proj.terminalTabs) { + if (tt.alive) window.terminalAPI.kill(tt.ptyId); + tt.term.dispose(); + } -// Terminal I/O -term.onData((data) => window.terminalAPI.send(data)); -window.terminalAPI.onData((data) => term.write(data)); -window.terminalAPI.onExit((code) => { - term.write(`\r\n\x1b[31m[Process exited with code ${code}]\x1b[0m\r\n`); - document.getElementById('pty-status').style.background = '#f38ba8'; - document.getElementById('pty-label').textContent = 'Disconnected'; -}); + // Remove DOM + proj.dom.workspace.remove(); + projects.splice(idx, 1); + + if (projects.length === 0) { + createProject(); + } else if (activeProjectId === id) { + const newIdx = Math.min(idx, projects.length - 1); + switchProject(projects[newIdx].id); + } + renderProjectTabs(); +} + +function getActiveProject() { + return projects.find(p => p.id === activeProjectId) || null; +} + +function renderProjectTabs() { + projectTabList.innerHTML = ''; + projects.forEach(proj => { + const el = document.createElement('div'); + el.className = 'project-tab' + (proj.id === activeProjectId ? ' active' : ''); + + const dot = document.createElement('span'); + dot.className = 'color-dot'; + dot.style.background = proj.color; + el.appendChild(dot); + + const title = document.createElement('span'); + title.className = 'tab-title'; + title.textContent = proj.name; + title.addEventListener('dblclick', (e) => { + e.stopPropagation(); + renameProject(proj); + }); + el.appendChild(title); + + const close = document.createElement('span'); + close.className = 'tab-close'; + close.textContent = '\u00d7'; + close.addEventListener('click', (e) => { + e.stopPropagation(); + closeProject(proj.id); + }); + el.appendChild(close); + + el.addEventListener('click', () => switchProject(proj.id)); + projectTabList.appendChild(el); + }); +} + +function renameProject(proj) { + const tabEl = projectTabList.querySelector(`.project-tab:nth-child(${projects.indexOf(proj) + 1}) .tab-title`); + if (!tabEl) return; + 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;'; + tabEl.replaceWith(input); + input.focus(); + input.select(); + + const finish = () => { + proj.name = input.value.trim() || proj.name; + renderProjectTabs(); + }; + input.addEventListener('blur', finish); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') input.blur(); + if (e.key === 'Escape') { input.value = proj.name; input.blur(); } + }); +} + +// ╔═══════════════════════════════════════════════════════════════════════╗ +// ║ TERMINAL TAB MANAGEMENT (per project) ║ +// ╚═══════════════════════════════════════════════════════════════════════╝ + +async function addTerminalTab(project) { + const tabId = project.nextTerminalId++; + + const containerEl = document.createElement('div'); + containerEl.className = 'terminal-instance'; + project.dom.termContainer.appendChild(containerEl); + + const term = new Terminal({ + fontSize: 14, + fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Consolas', monospace", + cursorBlink: true, + cursorStyle: 'bar', + allowProposedApi: true, + scrollback: 10000, + theme: XTERM_THEME + }); + + const fitAddon = new FitAddon(); + term.loadAddon(fitAddon); + term.loadAddon(new WebLinksAddon()); + term.open(containerEl); + + const { id: ptyId, shell } = await window.terminalAPI.create(); + + const tab = { + id: tabId, + ptyId, + shell, + title: `${shell} #${tabId}`, + term, + fitAddon, + containerEl, + alive: true + }; + + term.onData((data) => { + if (tab.alive) window.terminalAPI.write(ptyId, data); + }); + + project.terminalTabs.push(tab); + switchTerminalTab(project, tabId); -// Resize handling -const resizeObserver = new ResizeObserver(() => { requestAnimationFrame(() => { fitAddon.fit(); - window.terminalAPI.resize(term.cols, term.rows); - document.getElementById('terminal-size').textContent = `${term.cols}x${term.rows}`; + window.terminalAPI.resize(ptyId, term.cols, term.rows); + if (project.id === activeProjectId) updateStatusBar(project); }); -}); -resizeObserver.observe(termContainer); -// ── Browser Tab Management ────────────────────────────────────────────── + return tab; +} -let tabs = []; -let activeTabId = null; -let nextTabId = 1; +function switchTerminalTab(project, tabId) { + project.activeTerminalId = tabId; + project.terminalTabs.forEach(t => { + const isActive = t.id === tabId; + t.containerEl.classList.toggle('active', isActive); + if (isActive) { + requestAnimationFrame(() => { + t.fitAddon.fit(); + if (t.alive) window.terminalAPI.resize(t.ptyId, t.term.cols, t.term.rows); + t.term.focus(); + if (project.id === activeProjectId) updateStatusBar(project); + }); + } + }); + renderTerminalTabs(project); +} -const tabList = document.getElementById('tab-list'); -const webviewContainer = document.getElementById('webview-container'); -const urlInput = document.getElementById('url-input'); +function closeTerminalTab(project, tabId) { + const idx = project.terminalTabs.findIndex(t => t.id === tabId); + if (idx === -1) return; -function createTab(url = 'about:blank') { - const id = nextTabId++; + const tab = project.terminalTabs[idx]; + if (tab.alive) window.terminalAPI.kill(tab.ptyId); + tab.term.dispose(); + tab.containerEl.remove(); + project.terminalTabs.splice(idx, 1); + + if (project.terminalTabs.length === 0) { + addTerminalTab(project); + } else if (project.activeTerminalId === tabId) { + const newIdx = Math.min(idx, project.terminalTabs.length - 1); + switchTerminalTab(project, project.terminalTabs[newIdx].id); + } + renderTerminalTabs(project); +} + +function getActiveTerminalTab(project) { + return project.terminalTabs.find(t => t.id === project.activeTerminalId) || null; +} + +function renderTerminalTabs(project) { + const list = project.dom.termTabList; + list.innerHTML = ''; + project.terminalTabs.forEach(tab => { + const el = document.createElement('div'); + el.className = 'tab-item' + (tab.id === project.activeTerminalId ? ' active' : ''); + + const title = document.createElement('span'); + title.className = 'tab-title'; + title.textContent = tab.title; + el.appendChild(title); + + const close = document.createElement('span'); + close.className = 'tab-close'; + close.textContent = '\u00d7'; + close.addEventListener('click', (e) => { + e.stopPropagation(); + closeTerminalTab(project, tab.id); + }); + el.appendChild(close); + + el.addEventListener('click', () => switchTerminalTab(project, tab.id)); + list.appendChild(el); + }); +} + +// ╔═══════════════════════════════════════════════════════════════════════╗ +// ║ BROWSER TAB MANAGEMENT (per project) ║ +// ╚═══════════════════════════════════════════════════════════════════════╝ + +function addBrowserTab(project, url = 'about:blank') { + const id = project.nextBrowserId++; const webview = document.createElement('webview'); webview.setAttribute('src', url); webview.setAttribute('allowpopups', ''); webview.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:none;'; - webviewContainer.appendChild(webview); + project.dom.webviewContainer.appendChild(webview); - const tab = { - id, - title: 'New Tab', - url, - webview - }; + const tab = { id, title: 'New Tab', url, webview }; - // Listen for navigation events webview.addEventListener('did-navigate', (e) => { tab.url = e.url; - if (tab.id === activeTabId) urlInput.value = e.url; + if (tab.id === project.activeBrowserId && project.id === activeProjectId) { + project.dom.urlInput.value = e.url; + } }); webview.addEventListener('did-navigate-in-page', (e) => { tab.url = e.url; - if (tab.id === activeTabId) urlInput.value = e.url; + if (tab.id === project.activeBrowserId && project.id === activeProjectId) { + project.dom.urlInput.value = e.url; + } }); webview.addEventListener('page-title-updated', (e) => { tab.title = e.title || 'Untitled'; - renderTabs(); + renderBrowserTabs(project); }); webview.addEventListener('did-start-loading', () => { - if (tab.id === activeTabId) { + if (tab.id === project.activeBrowserId && project.id === activeProjectId) { document.getElementById('webview-status').textContent = 'Loading...'; } }); webview.addEventListener('did-stop-loading', () => { - if (tab.id === activeTabId) { + if (tab.id === project.activeBrowserId && project.id === activeProjectId) { document.getElementById('webview-status').textContent = ''; } }); - tabs.push(tab); - switchToTab(id); - renderTabs(); + project.browserTabs.push(tab); + switchBrowserTab(project, id); + renderBrowserTabs(project); return tab; } -function switchToTab(id) { - activeTabId = id; - tabs.forEach(t => { +function switchBrowserTab(project, id) { + project.activeBrowserId = id; + project.browserTabs.forEach(t => { t.webview.style.display = t.id === id ? 'flex' : 'none'; }); - const tab = tabs.find(t => t.id === id); - if (tab) urlInput.value = tab.url; - renderTabs(); + const tab = project.browserTabs.find(t => t.id === id); + if (tab) project.dom.urlInput.value = tab.url; + renderBrowserTabs(project); } -function closeTab(id) { - const idx = tabs.findIndex(t => t.id === id); +function closeBrowserTab(project, id) { + const idx = project.browserTabs.findIndex(t => t.id === id); if (idx === -1) return; - const tab = tabs[idx]; - tab.webview.remove(); - tabs.splice(idx, 1); + project.browserTabs[idx].webview.remove(); + project.browserTabs.splice(idx, 1); - if (tabs.length === 0) { - createTab(); - } else if (activeTabId === id) { - const newIdx = Math.min(idx, tabs.length - 1); - switchToTab(tabs[newIdx].id); + if (project.browserTabs.length === 0) { + addBrowserTab(project); + } else if (project.activeBrowserId === id) { + const newIdx = Math.min(idx, project.browserTabs.length - 1); + switchBrowserTab(project, project.browserTabs[newIdx].id); } - renderTabs(); + renderBrowserTabs(project); } -function renderTabs() { - tabList.innerHTML = ''; - tabs.forEach(tab => { +function renderBrowserTabs(project) { + const list = project.dom.browserTabList; + list.innerHTML = ''; + project.browserTabs.forEach(tab => { const el = document.createElement('div'); - el.className = 'browser-tab' + (tab.id === activeTabId ? ' active' : ''); + el.className = 'tab-item' + (tab.id === project.activeBrowserId ? ' active' : ''); const title = document.createElement('span'); title.className = 'tab-title'; @@ -176,144 +512,123 @@ function renderTabs() { close.textContent = '\u00d7'; close.addEventListener('click', (e) => { e.stopPropagation(); - closeTab(tab.id); + closeBrowserTab(project, tab.id); }); el.appendChild(close); - el.addEventListener('click', () => switchToTab(tab.id)); - tabList.appendChild(el); + el.addEventListener('click', () => switchBrowserTab(project, tab.id)); + list.appendChild(el); }); } -function getActiveWebview() { - const tab = tabs.find(t => t.id === activeTabId); +function getActiveWebview(project) { + const tab = project.browserTabs.find(t => t.id === project.activeBrowserId); return tab ? tab.webview : null; } -function navigateTo(input) { - const wv = getActiveWebview(); +function navigateBrowser(project, input) { + const wv = getActiveWebview(project); if (!wv) return; let url = input.trim(); if (!url) return; - // Check if it's a local file path if (url.startsWith('/') || url.startsWith('./') || url.startsWith('~')) { url = 'file://' + url; - } - // Check if it's a URL without protocol - else if (!url.match(/^[a-zA-Z]+:\/\//)) { - // Check if it looks like a domain + } else if (!url.match(/^[a-zA-Z]+:\/\//)) { if (url.match(/^[\w.-]+\.\w{2,}/)) { url = 'https://' + url; } else { - // Treat as search query url = 'https://duckduckgo.com/?q=' + encodeURIComponent(url); } } wv.setAttribute('src', url); - const tab = tabs.find(t => t.id === activeTabId); + const tab = project.browserTabs.find(t => t.id === project.activeBrowserId); if (tab) tab.url = url; } -// ── URL Bar Events ────────────────────────────────────────────────────── +// ╔═══════════════════════════════════════════════════════════════════════╗ +// ║ PTY DATA/EXIT ROUTING ║ +// ╚═══════════════════════════════════════════════════════════════════════╝ -urlInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - navigateTo(urlInput.value); - urlInput.blur(); +window.terminalAPI.onData((ptyId, data) => { + for (const proj of projects) { + const tab = proj.terminalTabs.find(t => t.ptyId === ptyId); + if (tab) { tab.term.write(data); return; } } }); -urlInput.addEventListener('focus', () => urlInput.select()); - -// ── Navigation Buttons ────────────────────────────────────────────────── - -document.getElementById('nav-back').addEventListener('click', () => { - const wv = getActiveWebview(); - if (wv && wv.canGoBack()) wv.goBack(); -}); - -document.getElementById('nav-forward').addEventListener('click', () => { - const wv = getActiveWebview(); - if (wv && wv.canGoForward()) wv.goForward(); -}); - -document.getElementById('nav-reload').addEventListener('click', () => { - const wv = getActiveWebview(); - if (wv) wv.reload(); -}); - -document.getElementById('nav-devtools').addEventListener('click', () => { - const wv = getActiveWebview(); - if (wv) { - if (wv.isDevToolsOpened()) { - wv.closeDevTools(); - } else { - wv.openDevTools(); +window.terminalAPI.onExit((ptyId, exitCode) => { + for (const proj of projects) { + const tab = proj.terminalTabs.find(t => t.ptyId === ptyId); + if (tab) { + tab.alive = false; + tab.term.write(`\r\n\x1b[31m[Process exited with code ${exitCode}]\x1b[0m\r\n`); + tab.title = `(exited) ${tab.shell}`; + renderTerminalTabs(proj); + if (proj.id === activeProjectId) updateStatusBar(proj); + return; } } }); -document.getElementById('new-tab-btn').addEventListener('click', () => { - createTab(); - urlInput.focus(); -}); +// ╔═══════════════════════════════════════════════════════════════════════╗ +// ║ DIVIDER DRAG (per project) ║ +// ╚═══════════════════════════════════════════════════════════════════════╝ -// ── Split Pane Divider ────────────────────────────────────────────────── - -const divider = document.getElementById('divider'); -const terminalPanel = document.getElementById('terminal-panel'); -const browserPanel = document.getElementById('browser-panel'); -let isDragging = false; - -divider.addEventListener('mousedown', (e) => { - isDragging = true; - divider.classList.add('dragging'); - document.body.style.cursor = document.body.classList.contains('vertical') ? 'row-resize' : 'col-resize'; - document.body.style.userSelect = 'none'; - // Prevent webview from capturing mouse events - webviewContainer.style.pointerEvents = 'none'; - e.preventDefault(); -}); +function setupDividerDrag(project, dom) { + let dragging = false; -document.addEventListener('mousemove', (e) => { - if (!isDragging) return; - const main = document.getElementById('main'); - const rect = main.getBoundingClientRect(); - - if (document.body.classList.contains('vertical')) { - const y = e.clientY - rect.top; - const pct = (y / rect.height) * 100; - const clamped = Math.max(15, Math.min(85, pct)); - terminalPanel.style.flex = 'none'; - terminalPanel.style.height = clamped + '%'; - browserPanel.style.flex = 'none'; - browserPanel.style.height = (100 - clamped) + '%'; - } else { - const x = e.clientX - rect.left; - const pct = (x / rect.width) * 100; - const clamped = Math.max(15, Math.min(85, pct)); - terminalPanel.style.flex = 'none'; - terminalPanel.style.width = clamped + '%'; - browserPanel.style.flex = 'none'; - browserPanel.style.width = (100 - clamped) + '%'; - } -}); + 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('mouseup', () => { - if (!isDragging) return; - isDragging = false; - divider.classList.remove('dragging'); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - webviewContainer.style.pointerEvents = ''; - fitAddon.fit(); - window.terminalAPI.resize(term.cols, term.rows); -}); + 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) + '%'; + } + }); -// ── Layout Buttons ────────────────────────────────────────────────────── + 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); + } + }); +} + +// ╔═══════════════════════════════════════════════════════════════════════╗ +// ║ LAYOUT ║ +// ╚═══════════════════════════════════════════════════════════════════════╝ const layoutButtons = { split: document.getElementById('btn-layout-split'), @@ -322,88 +637,190 @@ const layoutButtons = { vertical: document.getElementById('btn-layout-vertical') }; -function setLayout(mode) { - document.body.classList.remove('terminal-only', 'browser-only', 'vertical'); +function applyLayout(project, mode) { + const ws = project.dom.workspace; + ws.classList.remove('layout-terminal-only', 'layout-browser-only', 'layout-vertical'); - // Reset inline styles from dragging - terminalPanel.style.flex = ''; - terminalPanel.style.width = ''; - terminalPanel.style.height = ''; + // Reset inline drag styles + const { termPanel, browserPanel } = project.dom; + termPanel.style.flex = ''; + termPanel.style.width = ''; + termPanel.style.height = ''; browserPanel.style.flex = ''; browserPanel.style.width = ''; browserPanel.style.height = ''; - Object.values(layoutButtons).forEach(b => b.classList.remove('active')); - switch (mode) { - case 'terminal': - document.body.classList.add('terminal-only'); - layoutButtons.terminal.classList.add('active'); - break; - case 'browser': - document.body.classList.add('browser-only'); - layoutButtons.browser.classList.add('active'); - break; - case 'vertical': - document.body.classList.add('vertical'); - layoutButtons.vertical.classList.add('active'); - break; - default: - layoutButtons.split.classList.add('active'); - break; + case 'terminal': ws.classList.add('layout-terminal-only'); break; + case 'browser': ws.classList.add('layout-browser-only'); break; + case 'vertical': ws.classList.add('layout-vertical'); break; } + project.layout = mode; + + // Update toolbar buttons + Object.values(layoutButtons).forEach(b => b.classList.remove('active')); + if (layoutButtons[mode]) layoutButtons[mode].classList.add('active'); + else layoutButtons.split.classList.add('active'); + requestAnimationFrame(() => { - fitAddon.fit(); - window.terminalAPI.resize(term.cols, term.rows); + const tt = getActiveTerminalTab(project); + if (tt) { + tt.fitAddon.fit(); + if (tt.alive) window.terminalAPI.resize(tt.ptyId, tt.term.cols, tt.term.rows); + updateStatusBar(project); + } }); } -layoutButtons.split.addEventListener('click', () => setLayout('split')); -layoutButtons.terminal.addEventListener('click', () => setLayout('terminal')); -layoutButtons.browser.addEventListener('click', () => setLayout('browser')); -layoutButtons.vertical.addEventListener('click', () => setLayout('vertical')); +function setLayoutForActiveProject(mode) { + const proj = getActiveProject(); + if (proj) applyLayout(proj, mode); +} + +layoutButtons.split.addEventListener('click', () => setLayoutForActiveProject('split')); +layoutButtons.terminal.addEventListener('click', () => setLayoutForActiveProject('terminal')); +layoutButtons.browser.addEventListener('click', () => setLayoutForActiveProject('browser')); +layoutButtons.vertical.addEventListener('click', () => setLayoutForActiveProject('vertical')); + +// ╔═══════════════════════════════════════════════════════════════════════╗ +// ║ STATUS BAR ║ +// ╚═══════════════════════════════════════════════════════════════════════╝ + +function updateStatusBar(project) { + const tt = getActiveTerminalTab(project); + const dot = document.getElementById('pty-status'); + const label = document.getElementById('pty-label'); + const sizeEl = document.getElementById('terminal-size'); + + if (tt && tt.alive) { + dot.style.background = '#a6e3a1'; + label.textContent = `${tt.shell} (pty:${tt.ptyId})`; + sizeEl.textContent = `${tt.term.cols}x${tt.term.rows}`; + } else if (tt) { + dot.style.background = '#f38ba8'; + label.textContent = 'Exited'; + sizeEl.textContent = `${tt.term.cols}x${tt.term.rows}`; + } else { + dot.style.background = '#585b70'; + label.textContent = 'No terminal'; + sizeEl.textContent = ''; + } +} + +// ╔═══════════════════════════════════════════════════════════════════════╗ +// ║ NEW PROJECT BUTTON ║ +// ╚═══════════════════════════════════════════════════════════════════════╝ + +document.getElementById('new-project-btn').addEventListener('click', () => { + createProject(); +}); -// ── Keyboard Shortcuts ────────────────────────────────────────────────── +// ╔═══════════════════════════════════════════════════════════════════════╗ +// ║ KEYBOARD SHORTCUTS ║ +// ╚═══════════════════════════════════════════════════════════════════════╝ document.addEventListener('keydown', (e) => { const mod = e.ctrlKey || e.metaKey; + const proj = getActiveProject(); + if (!proj) return; - if (mod && e.key === 't') { + // Ctrl+Shift+P = new project + if (mod && e.shiftKey && e.key === 'P') { e.preventDefault(); - createTab(); - urlInput.focus(); - } else if (mod && e.key === 'w') { + createProject(); + return; + } + + // Ctrl+Shift+T = new terminal tab + if (mod && e.shiftKey && e.key === 'T') { e.preventDefault(); - if (activeTabId) closeTab(activeTabId); - } else if (mod && e.key === 'l') { + addTerminalTab(proj); + return; + } + + // Ctrl+T = new browser tab + if (mod && !e.shiftKey && e.key === 't') { e.preventDefault(); - urlInput.focus(); - urlInput.select(); - } else if (mod && e.key === '1') { + addBrowserTab(proj); + proj.dom.urlInput.focus(); + return; + } + + // Ctrl+W = close active tab in focused panel + if (mod && !e.shiftKey && e.key === 'w') { e.preventDefault(); - setLayout('split'); - } else if (mod && e.key === '2') { + if (proj.focusedPanel === 'terminal' && proj.activeTerminalId) { + closeTerminalTab(proj, proj.activeTerminalId); + } else if (proj.focusedPanel === 'browser' && proj.activeBrowserId) { + closeBrowserTab(proj, proj.activeBrowserId); + } + return; + } + + // Ctrl+Shift+W = close project + if (mod && e.shiftKey && e.key === 'W') { e.preventDefault(); - setLayout('terminal'); - } else if (mod && e.key === '3') { + closeProject(proj.id); + return; + } + + // Ctrl+L = focus URL bar + if (mod && e.key === 'l') { e.preventDefault(); - setLayout('browser'); - } else if (mod && e.key === 'r') { + proj.dom.urlInput.focus(); + proj.dom.urlInput.select(); + return; + } + + // Ctrl+1/2/3 = layout modes + if (mod && !e.shiftKey && e.key === '1') { e.preventDefault(); setLayoutForActiveProject('split'); return; } + if (mod && !e.shiftKey && e.key === '2') { e.preventDefault(); setLayoutForActiveProject('terminal'); return; } + if (mod && !e.shiftKey && e.key === '3') { e.preventDefault(); setLayoutForActiveProject('browser'); return; } + + // Ctrl+R = reload browser + if (mod && e.key === 'r') { e.preventDefault(); - const wv = getActiveWebview(); + const wv = getActiveWebview(proj); if (wv) wv.reload(); + return; } -}); -// ── Initialize ────────────────────────────────────────────────────────── + // Ctrl+Tab / Ctrl+Shift+Tab = cycle tabs in focused panel + if (mod && e.key === 'Tab') { + e.preventDefault(); + if (proj.focusedPanel === 'terminal' && proj.terminalTabs.length > 1) { + const idx = proj.terminalTabs.findIndex(t => t.id === proj.activeTerminalId); + const next = e.shiftKey + ? (idx - 1 + proj.terminalTabs.length) % proj.terminalTabs.length + : (idx + 1) % proj.terminalTabs.length; + switchTerminalTab(proj, proj.terminalTabs[next].id); + } else if (proj.focusedPanel === 'browser' && proj.browserTabs.length > 1) { + const idx = proj.browserTabs.findIndex(t => t.id === proj.activeBrowserId); + const next = e.shiftKey + ? (idx - 1 + proj.browserTabs.length) % proj.browserTabs.length + : (idx + 1) % proj.browserTabs.length; + switchBrowserTab(proj, proj.browserTabs[next].id); + } + return; + } -// Detect shell name from the process -const shellName = document.getElementById('shell-name'); -// Will be updated from terminal output if possible + // Ctrl+PageUp / Ctrl+PageDown = cycle projects + if (mod && (e.key === 'PageUp' || e.key === 'PageDown')) { + e.preventDefault(); + if (projects.length > 1) { + const idx = projects.findIndex(p => p.id === activeProjectId); + const next = e.key === 'PageDown' + ? (idx + 1) % projects.length + : (idx - 1 + projects.length) % projects.length; + switchProject(projects[next].id); + } + return; + } +}); -// Create initial tab -createTab('https://duckduckgo.com'); +// ╔═══════════════════════════════════════════════════════════════════════╗ +// ║ INITIALIZE ║ +// ╚═══════════════════════════════════════════════════════════════════════╝ -// Focus terminal on start -term.focus(); +createProject('Project 1');