From 764e3f0994d8eb5f0025415e861fd2a6cf87edba Mon Sep 17 00:00:00 2001 From: Mike Ramirez Date: Thu, 22 Jan 2026 20:47:27 -0700 Subject: [PATCH] Add pinned tabs feature with multi-PTY support - Add multi-session PTY backend with session ID routing - Create TabBar component with Chrome-style pinned tabs - Implement useTabManager hook for tab state management - Add TerminalPane component for individual terminal instances - Add keyboard shortcuts: Cmd+T (new), Cmd+W (close), Cmd+1-9 (switch), Cmd+Shift+[/] (prev/next), Cmd+Shift+P (pin toggle) - Pinned tabs appear first as dot indicators, unpinned show title - Right-click or use pin icon to toggle pin state - Each tab has independent shell session with proper cleanup Co-Authored-By: Claude Opus 4.5 --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/main.rs | 171 ++++-- src/App.tsx | 953 +++++++++++++++++++------------- src/components/TabBar.tsx | 91 +++ src/components/TerminalPane.tsx | 133 +++++ src/hooks/useTabManager.ts | 195 +++++++ src/types/tab.ts | 13 + 8 files changed, 1113 insertions(+), 445 deletions(-) create mode 100644 src/components/TabBar.tsx create mode 100644 src/components/TerminalPane.tsx create mode 100644 src/hooks/useTabManager.ts create mode 100644 src/types/tab.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 34d556e..f6cdcc3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2982,6 +2982,7 @@ dependencies = [ "tauri", "tauri-build", "tokio", + "uuid", "window-vibrancy", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a54ca78..82efa24 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -15,6 +15,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" portable-pty = "0.8" base64 = "0.21" +uuid = { version = "1.0", features = ["v4"] } # Async runtime for PTY reading tokio = { version = "1", features = ["full"] } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 859e8b5..ed06814 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,83 +1,148 @@ -use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem}; +use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem, MasterPty}; +use std::collections::HashMap; use std::env; use std::io::{Read, Write}; use std::sync::{Arc, Mutex}; use std::thread; use tauri::Manager; +use uuid::Uuid; use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial}; +use serde::Serialize; + +struct PtySession { + writer: Arc>>, + master: Arc>>, +} struct AppState { - pty_writer: Arc>>, + sessions: Arc>>, +} + +#[derive(Clone, Serialize)] +struct PtyOutputPayload { + session_id: String, + data: Vec, +} + +#[tauri::command] +fn create_pty_session(app_handle: tauri::AppHandle, state: tauri::State) -> Result { + let session_id = Uuid::new_v4().to_string(); + + let pty_system = NativePtySystem::default(); + let mut cmd = CommandBuilder::new("zsh"); + cmd.env("TERM", "xterm-256color"); + cmd.args(["-c", "export PROMPT_EOL_MARK=''; exec zsh"]); + + if let Ok(cwd) = env::current_dir() { + cmd.cwd(cwd); + } + + let pair = pty_system.openpty(PtySize { + rows: 30, + cols: 100, + pixel_width: 0, + pixel_height: 0, + }).map_err(|e| format!("Failed to create PTY: {}", e))?; + + let mut reader = pair.master.try_clone_reader() + .map_err(|e| format!("Failed to clone reader: {}", e))?; + let writer = pair.master.take_writer() + .map_err(|e| format!("Failed to take writer: {}", e))?; + + // Spawn shell + let child = pair.slave.spawn_command(cmd) + .map_err(|e| format!("Failed to spawn shell: {}", e))?; + // Keep child alive + Box::leak(Box::new(child)); + + let session = PtySession { + writer: Arc::new(Mutex::new(writer)), + master: Arc::new(Mutex::new(pair.master)), + }; + + // Store session + { + let mut sessions = state.sessions.lock().map_err(|_| "Lock poisoned")?; + sessions.insert(session_id.clone(), session); + } + + // Read thread for this session + let sid = session_id.clone(); + thread::spawn(move || { + let mut buf = [0u8; 4096]; + loop { + match reader.read(&mut buf) { + Ok(n) if n > 0 => { + let payload = PtyOutputPayload { + session_id: sid.clone(), + data: buf[..n].to_vec(), + }; + let _ = app_handle.emit_all("pty-output", payload); + } + Ok(_) => break, // EOF + Err(_) => break, // Error + } + } + }); + + Ok(session_id) +} + +#[tauri::command] +fn write_to_pty(session_id: String, data: String, state: tauri::State) -> Result<(), String> { + let sessions = state.sessions.lock().map_err(|_| "Lock poisoned")?; + if let Some(session) = sessions.get(&session_id) { + if let Ok(mut writer) = session.writer.lock() { + let _ = write!(writer, "{}", data); + } + } + Ok(()) } #[tauri::command] -fn write_to_pty(data: String, state: tauri::State) { - if let Ok(mut writer) = state.pty_writer.lock() { - // We ignore errors for now (e.g. if pty closed) - let _ = write!(writer, "{}", data); +fn resize_pty(session_id: String, rows: u16, cols: u16, state: tauri::State) -> Result<(), String> { + let sessions = state.sessions.lock().map_err(|_| "Lock poisoned")?; + if let Some(session) = sessions.get(&session_id) { + if let Ok(master) = session.master.lock() { + let _ = master.resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }); + } } + Ok(()) +} + +#[tauri::command] +fn close_pty_session(session_id: String, state: tauri::State) -> Result<(), String> { + let mut sessions = state.sessions.lock().map_err(|_| "Lock poisoned")?; + sessions.remove(&session_id); + Ok(()) } fn main() { tauri::Builder::default() .setup(|app| { let window = app.get_window("main").unwrap(); - + #[cfg(target_os = "macos")] apply_vibrancy(&window, NSVisualEffectMaterial::HudWindow, None, None) .expect("Unsupported platform! 'apply_vibrancy' is only supported on macOS"); - let pty_system = NativePtySystem::default(); - let mut cmd = CommandBuilder::new("zsh"); - cmd.env("TERM", "xterm-256color"); - // Disable ZSH auto-logout and unsetopt PROMPT_SP to fix '%' issue - cmd.args(["-c", "export PROMPT_EOL_MARK=''; exec zsh"]); - - if let Ok(cwd) = env::current_dir() { - cmd.cwd(cwd); - } - - // Define initial size - let pair = pty_system.openpty(PtySize { - rows: 30, - cols: 100, - pixel_width: 0, - pixel_height: 0, - }).expect("Failed to create PTY"); - - let mut reader = pair.master.try_clone_reader().expect("Failed to clone reader"); - let writer = pair.master.take_writer().expect("Failed to take writer"); - - // Spawn shell - let child = pair.slave.spawn_command(cmd).expect("Failed to spawn shell"); - // Keep child alive - Box::leak(Box::new(child)); - - let app_handle = app.app_handle(); - - // Read thread - thread::spawn(move || { - let mut buf = [0u8; 4096]; - loop { - match reader.read(&mut buf) { - Ok(n) if n > 0 => { - let data = buf[..n].to_vec(); - // Send raw bytes to avoid splitting multi-byte UTF-8 characters - let _ = app_handle.emit_all("pty-output", data); - } - Ok(_) => break, // EOF - Err(_) => break, // Error - } - } - }); - app.manage(AppState { - pty_writer: Arc::new(Mutex::new(writer)), + sessions: Arc::new(Mutex::new(HashMap::new())), }); Ok(()) }) - .invoke_handler(tauri::generate_handler![write_to_pty]) + .invoke_handler(tauri::generate_handler![ + create_pty_session, + write_to_pty, + resize_pty, + close_pty_session + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src/App.tsx b/src/App.tsx index 9c24c3f..0889287 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,33 +1,61 @@ import { useEffect, useRef, useState, useCallback } from "react"; -import { Terminal } from "xterm"; -import { FitAddon } from "xterm-addon-fit"; -import { WebLinksAddon } from "xterm-addon-web-links"; -import { invoke } from "@tauri-apps/api/tauri"; import { listen } from "@tauri-apps/api/event"; import { writeText } from "@tauri-apps/api/clipboard"; import { appWindow, LogicalSize } from "@tauri-apps/api/window"; import { save } from "@tauri-apps/api/dialog"; import { writeTextFile } from "@tauri-apps/api/fs"; -import { - Copy, Clipboard, Check, Shield, ShieldAlert, - Pin, PinOff, Terminal as TermIcon, Download, - Type, Command, Settings, Layout, Ghost +import { invoke } from "@tauri-apps/api/tauri"; +import { + Copy, + Clipboard, + Check, + Shield, + ShieldAlert, + Pin, + PinOff, + Terminal as TermIcon, + Download, + Command, + Settings, + Layout, + Ghost, } from "lucide-react"; import "xterm/css/xterm.css"; import clsx from "clsx"; +import { useTabManager } from "./hooks/useTabManager"; +import { TabBar } from "./components/TabBar"; +import { TerminalPane } from "./components/TerminalPane"; +import type { TerminalInstance } from "./types/tab"; + interface Block { id: string; - y: number; + y: number; height: number; lines: string[]; } -const DEFAULT_FONT = '"JetBrainsMono Nerd Font", "JetBrains Mono", "Apple Color Emoji", monospace'; +interface PtyOutputPayload { + session_id: string; + data: number[]; +} + +const DEFAULT_FONT = + '"JetBrainsMono Nerd Font", "JetBrains Mono", "Apple Color Emoji", monospace'; const FONTS = [ - { name: "JetBrains Mono (Nerd)", value: '"JetBrainsMono Nerd Font", "JetBrains Mono", "Apple Color Emoji", monospace' }, - { name: "Fira Code (Nerd)", value: '"FiraCode Nerd Font", "Fira Code", "Apple Color Emoji", monospace' }, - { name: "Hack (Nerd)", value: '"Hack Nerd Font", "Hack", "Apple Color Emoji", monospace' }, + { + name: "JetBrains Mono (Nerd)", + value: + '"JetBrainsMono Nerd Font", "JetBrains Mono", "Apple Color Emoji", monospace', + }, + { + name: "Fira Code (Nerd)", + value: '"FiraCode Nerd Font", "Fira Code", "Apple Color Emoji", monospace', + }, + { + name: "Hack (Nerd)", + value: '"Hack Nerd Font", "Hack", "Apple Color Emoji", monospace', + }, { name: "MesloLGS NF", value: '"MesloLGS NF", "Apple Color Emoji", monospace' }, { name: "Courier New", value: '"Courier New", "Apple Color Emoji", monospace' }, ]; @@ -40,24 +68,22 @@ const WINDOW_PRESETS = [ ]; const SECRET_PATTERNS = [ - /sk-[a-zA-Z0-9]{20,}T3BlbkFJ/g, // OpenAI (Example) - /(?:AKIA|ASIA)[0-9A-Z]{16}/g, // AWS Access Key - /[a-zA-Z0-9-_]{20,}\.[a-zA-Z0-9-_]{6,}\.[a-zA-Z0-9-_]{20,}/g, // JWT-like + /sk-[a-zA-Z0-9]{20,}T3BlbkFJ/g, + /(?:AKIA|ASIA)[0-9A-Z]{16}/g, + /[a-zA-Z0-9-_]{20,}\.[a-zA-Z0-9-_]{6,}\.[a-zA-Z0-9-_]{20,}/g, ]; function redactText(text: string): string { let redacted = text; - SECRET_PATTERNS.forEach(pattern => { - redacted = redacted.replace(pattern, ''); + SECRET_PATTERNS.forEach((pattern) => { + redacted = redacted.replace(pattern, ""); }); return redacted; } export default function App() { - // Refs - const terminalRef = useRef(null); - const xtermRef = useRef(null); - const fitAddonRef = useRef(null); + // Tab Manager + const tabManager = useTabManager(); // State const [blocks, setBlocks] = useState([]); @@ -68,139 +94,183 @@ export default function App() { const [showCmdPalette, setShowCmdPalette] = useState(false); const [showFontSettings, setShowFontSettings] = useState(false); const [showResizeSettings, setShowResizeSettings] = useState(false); - const [fontFamily, setFontFamily] = useState(localStorage.getItem('shelll-font') || DEFAULT_FONT); + const [fontFamily, setFontFamily] = useState( + localStorage.getItem("shelll-font") || DEFAULT_FONT + ); const [customFont, setCustomFont] = useState(""); const [hoveredBlockId, setHoveredBlockId] = useState(null); const [isGhostMode, setIsGhostMode] = useState(false); const [isHoveringWindow, setIsHoveringWindow] = useState(true); - // Initialize Terminal + // Terminal container ref for finding panes + const terminalContainerRef = useRef(null); + + // Create first tab on mount + useEffect(() => { + tabManager.createTab(); + }, []); + + // Listen for PTY output and route to correct terminal useEffect(() => { - if (!terminalRef.current) return; - - const term = new Terminal({ - fontFamily: fontFamily, - fontSize: 14, - cursorBlink: true, - allowProposedApi: true, - theme: { - background: '#00000000', - foreground: '#eeeeee', + const unlisten = listen("pty-output", (event) => { + const { session_id, data } = event.payload; + const byteData = new Uint8Array(data); + + // Find the tab with this session ID + const tab = tabManager.tabs.find((t) => t.sessionId === session_id); + if (!tab) return; + + // Find the terminal pane element and write data + const container = terminalContainerRef.current; + if (container) { + const pane = container.querySelector( + `[data-session-id="${session_id}"]` + ) as any; + if (pane && pane.__writeData) { + pane.__writeData(byteData); + } + } + + // Request block scan if this is the active tab + if (tab.id === tabManager.activeTabId) { + requestAnimationFrame(scanBlocks); } }); - const fitAddon = new FitAddon(); - term.loadAddon(fitAddon); - term.loadAddon(new WebLinksAddon()); + return () => { + unlisten.then((f) => f()); + }; + }, [tabManager.tabs, tabManager.activeTabId]); - term.open(terminalRef.current); - fitAddon.fit(); - xtermRef.current = term; - fitAddonRef.current = fitAddon; + // Keyboard Shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Cmd+K - Command palette + if (e.metaKey && e.key === "k") { + e.preventDefault(); + setShowCmdPalette((prev) => !prev); + return; + } - term.focus(); + // Cmd+T - New tab + if (e.metaKey && e.key === "t") { + e.preventDefault(); + tabManager.createTab(); + return; + } - // Data flow - term.onData((data) => { - invoke("write_to_pty", { data }); - }); + // Cmd+W - Close current tab + if (e.metaKey && e.key === "w") { + e.preventDefault(); + if (tabManager.activeTabId) { + tabManager.closeTab(tabManager.activeTabId); + } + return; + } - const unlisten = listen("pty-output", (event) => { - const payload = event.payload as number[]; - const data = new Uint8Array(payload); - term.write(data); - requestAnimationFrame(scanBlocks); - }); + // Cmd+Shift+[ - Previous tab + if (e.metaKey && e.shiftKey && e.key === "[") { + e.preventDefault(); + tabManager.switchToPreviousTab(); + return; + } - const resizeObserver = new ResizeObserver(() => { - fitAddon.fit(); - scanBlocks(); - invoke("resize_pty", { rows: term.rows, cols: term.cols }).catch(() => {}); - }); - resizeObserver.observe(terminalRef.current); - - term.onRender(() => scanBlocks()); + // Cmd+Shift+] - Next tab + if (e.metaKey && e.shiftKey && e.key === "]") { + e.preventDefault(); + tabManager.switchToNextTab(); + return; + } - // Keyboard Shortcuts - const handleKeyDown = (e: KeyboardEvent) => { - if (e.metaKey && e.key === 'k') { - e.preventDefault(); - setShowCmdPalette(prev => !prev); + // Cmd+Shift+P - Toggle pin + if (e.metaKey && e.shiftKey && e.key === "p") { + e.preventDefault(); + if (tabManager.activeTabId) { + tabManager.togglePin(tabManager.activeTabId); } - }; - window.addEventListener('keydown', handleKeyDown); + return; + } - return () => { - term.dispose(); - unlisten.then((f) => f()); - resizeObserver.disconnect(); - window.removeEventListener('keydown', handleKeyDown); + // Cmd+1-9 - Switch to tab N + if (e.metaKey && e.key >= "1" && e.key <= "9") { + e.preventDefault(); + const index = parseInt(e.key) - 1; + tabManager.switchToTabByIndex(index); + return; + } }; - }, []); - // Update font when state changes + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [tabManager]); + + // Update font in localStorage when it changes useEffect(() => { - if (xtermRef.current) { - xtermRef.current.options.fontFamily = fontFamily; - // Trigger resize to ensure everything reflows/renders correctly - fitAddonRef.current?.fit(); - localStorage.setItem('shelll-font', fontFamily); - } + localStorage.setItem("shelll-font", fontFamily); }, [fontFamily]); // Block Scanning Logic const scanBlocks = useCallback(() => { - const term = xtermRef.current; - if (!term) return; + if (!tabManager.activeTabId) return; + + const container = terminalContainerRef.current; + if (!container) return; + const pane = container.querySelector( + `[data-tab-id="${tabManager.activeTabId}"]` + ) as any; + if (!pane || !pane.__terminal) return; + + const term = pane.__terminal; const buffer = term.buffer.active; const viewportY = buffer.viewportY; const rows = term.rows; const foundBlocks: Block[] = []; - + let inBlock = false; let blockStartRel = 0; let blockLines: string[] = []; for (let i = 0; i < rows; i++) { - const lineIdx = viewportY + i; - const line = buffer.getLine(lineIdx); - const lineStr = line?.translateToString(true); - - if (lineStr && lineStr.length > 0) { - if (!inBlock) { - inBlock = true; - blockStartRel = i; - blockLines = []; - } - blockLines.push(lineStr); - } else { - if (inBlock) { - addBlock(blockStartRel, i - 1, blockLines); - inBlock = false; - } + const lineIdx = viewportY + i; + const line = buffer.getLine(lineIdx); + const lineStr = line?.translateToString(true); + + if (lineStr && lineStr.length > 0) { + if (!inBlock) { + inBlock = true; + blockStartRel = i; + blockLines = []; + } + blockLines.push(lineStr); + } else { + if (inBlock) { + addBlock(blockStartRel, i - 1, blockLines); + inBlock = false; } + } } if (inBlock) { - addBlock(blockStartRel, rows - 1, blockLines); + addBlock(blockStartRel, rows - 1, blockLines); } function addBlock(startRel: number, endRel: number, lines: string[]) { - const element = term?.element; - if (!element) return; - - const clientHeight = element.querySelector('.xterm-screen')?.clientHeight || element.clientHeight; - const rowHeight = clientHeight / term!.rows; - - foundBlocks.push({ - id: `blk-${viewportY}-${startRel}`, - y: startRel * rowHeight, - height: (endRel - startRel + 1) * rowHeight, - lines - }); + const element = term?.element; + if (!element) return; + + const clientHeight = + element.querySelector(".xterm-screen")?.clientHeight || element.clientHeight; + const rowHeight = clientHeight / term!.rows; + + foundBlocks.push({ + id: `blk-${viewportY}-${startRel}`, + y: startRel * rowHeight, + height: (endRel - startRel + 1) * rowHeight, + lines, + }); } setBlocks(foundBlocks); - }, []); + }, [tabManager.activeTabId]); // Actions const handleCopy = async (text: string, id: string) => { @@ -211,50 +281,68 @@ export default function App() { }; const toggleSelection = (id: string) => { - setSelectedBlockIds(prev => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; + setSelectedBlockIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; }); }; const handleCopySelected = async () => { const selectedContent = blocks - .filter(b => selectedBlockIds.has(b.id)) - .map((b, idx) => `### Block ${idx + 1}\n${b.lines.join('\n')}`) - .join('\n\n'); - + .filter((b) => selectedBlockIds.has(b.id)) + .map((b, idx) => `### Block ${idx + 1}\n${b.lines.join("\n")}`) + .join("\n\n"); + if (selectedContent) { - await handleCopy(selectedContent, 'basket'); - setSelectedBlockIds(new Set()); + await handleCopy(selectedContent, "basket"); + setSelectedBlockIds(new Set()); } }; const handleCopyAll = async () => { - if (!xtermRef.current) return; - xtermRef.current.selectAll(); - const text = xtermRef.current.getSelection(); - xtermRef.current.clearSelection(); - if (text) await handleCopy(text, 'all'); + const container = terminalContainerRef.current; + if (!container || !tabManager.activeTabId) return; + + const pane = container.querySelector( + `[data-tab-id="${tabManager.activeTabId}"]` + ) as any; + if (!pane || !pane.__terminal) return; + + const term = pane.__terminal; + term.selectAll(); + const text = term.getSelection(); + term.clearSelection(); + if (text) await handleCopy(text, "all"); }; const handleExport = async () => { - if (!xtermRef.current) return; - xtermRef.current.selectAll(); - const text = xtermRef.current.getSelection(); - xtermRef.current.clearSelection(); - + const container = terminalContainerRef.current; + if (!container || !tabManager.activeTabId) return; + + const pane = container.querySelector( + `[data-tab-id="${tabManager.activeTabId}"]` + ) as any; + if (!pane || !pane.__terminal) return; + + const term = pane.__terminal; + term.selectAll(); + const text = term.getSelection(); + term.clearSelection(); + const filePath = await save({ - filters: [{ - name: 'Markdown', - extensions: ['md'] - }] + filters: [ + { + name: "Markdown", + extensions: ["md"], + }, + ], }); - + if (filePath) { - const content = isRedactMode ? redactText(text) : text; - await writeTextFile(filePath, content); + const content = isRedactMode ? redactText(text) : text; + await writeTextFile(filePath, content); } }; @@ -265,10 +353,19 @@ export default function App() { }; const runCommand = (cmd: string) => { - invoke("write_to_pty", { data: cmd + "\n" }); + if (!tabManager.activeTab) return; + invoke("write_to_pty", { sessionId: tabManager.activeTab.sessionId, data: cmd + "\n" }); setShowCmdPalette(false); // Focus back terminal - xtermRef.current?.focus(); + const container = terminalContainerRef.current; + if (container && tabManager.activeTabId) { + const pane = container.querySelector( + `[data-tab-id="${tabManager.activeTabId}"]` + ) as any; + if (pane && pane.__terminal) { + pane.__terminal.focus(); + } + } }; const handleResize = async (width: number, height: number) => { @@ -277,288 +374,360 @@ export default function App() { }; const handleMouseMove = (e: React.MouseEvent) => { - // Offset for header - const y = e.clientY - 40; // Approx header height + top padding - // Simple hit detection - const found = blocks.find(b => y >= b.y && y <= b.y + b.height); + // Offset for header + tab bar + const y = e.clientY - 72; // Header (40) + TabBar (32) approx + const found = blocks.find((b) => y >= b.y && y <= b.y + b.height); setHoveredBlockId(found ? found.id : null); }; const handleWrapperClick = (e: React.MouseEvent) => { if (e.metaKey || e.ctrlKey) { - if (hoveredBlockId) { - e.stopPropagation(); - toggleSelection(hoveredBlockId); - } + if (hoveredBlockId) { + e.stopPropagation(); + toggleSelection(hoveredBlockId); + } } }; + const handleRegisterInstance = useCallback( + (tabId: string, instance: TerminalInstance) => { + tabManager.registerTerminalInstance(tabId, instance); + }, + [tabManager] + ); + return ( -
{ - handleMouseMove(e); - if (!isHoveringWindow) setIsHoveringWindow(true); - }} - onMouseEnter={() => setIsHoveringWindow(true)} - onMouseLeave={() => setIsHoveringWindow(false)} - onClickCapture={handleWrapperClick} +
{ + handleMouseMove(e); + if (!isHoveringWindow) setIsHoveringWindow(true); + }} + onMouseEnter={() => setIsHoveringWindow(true)} + onMouseLeave={() => setIsHoveringWindow(false)} + onClickCapture={handleWrapperClick} > - {/* Header */} -
-
-
- -
- -
- - - - - - -
+ {/* Header */} +
+
+
+ +
-
- {selectedBlockIds.size > 0 && ( - - )} +
+ + + + + + +
- - - - - - - - - -
+
+ {selectedBlockIds.size > 0 && ( + + )} + + + + + + + + + +
+
+ + {/* Tab Bar */} + {/* Terminal Container */} -
-
- +
+ {/* Render all terminal panes - visibility controlled by CSS */} + {tabManager.tabs.map((tab) => ( + + ))} + {/* Block Overlays */}
- {blocks.map(block => { - const isSelected = selectedBlockIds.has(block.id); - const isHovered = hoveredBlockId === block.id; - - // Only show interactions if hovered or selected - if (!isSelected && !isHovered) return null; - - return ( -
- {/* Highlight BG - Visual Only */} -
- - {/* Copy Button - Interactive */} -
- -
-
- ); - })} + {blocks.map((block) => { + const isSelected = selectedBlockIds.has(block.id); + const isHovered = hoveredBlockId === block.id; + + if (!isSelected && !isHovered) return null; + + return ( +
+ {/* Highlight BG - Visual Only */} +
+ + {/* Copy Button - Interactive */} +
+ +
+
+ ); + })}
{/* Font Settings Modal */} {showFontSettings && ( -
-
FONT FAMILY
-
- {FONTS.map(font => ( - - ))} -
- -
-
CUSTOM FONT
-
- setCustomFont(e.target.value)} - className="flex-1 bg-neutral-950 border border-white/10 rounded px-2 py-1 text-xs text-white placeholder:text-white/20 focus:outline-none focus:border-white/30" - /> - -
-
+
+
FONT FAMILY
+
+ {FONTS.map((font) => ( + + ))} +
+ +
+
CUSTOM FONT
+
+ setCustomFont(e.target.value)} + className="flex-1 bg-neutral-950 border border-white/10 rounded px-2 py-1 text-xs text-white placeholder:text-white/20 focus:outline-none focus:border-white/30" + /> + +
+
)} {/* Resize Settings Modal */} {showResizeSettings && ( -
-
WINDOW PRESETS
- {WINDOW_PRESETS.map(preset => ( - - ))} +
+
+ WINDOW PRESETS
+ {WINDOW_PRESETS.map((preset) => ( + + ))} +
)} {/* Command Palette Modal */} {showCmdPalette && ( -
-
QUICK COMMANDS
- {[ - { label: "Git Status", cmd: "git status" }, - { label: "Git Diff", cmd: "git diff" }, - { label: "Node Version", cmd: "node -v" }, - { label: "Clear", cmd: "clear" }, - ].map(item => ( - - ))} +
+
+ QUICK COMMANDS
+ {[ + { label: "Git Status", cmd: "git status" }, + { label: "Git Diff", cmd: "git diff" }, + { label: "Node Version", cmd: "node -v" }, + { label: "Clear", cmd: "clear" }, + ].map((item) => ( + + ))} +
)}
); diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx new file mode 100644 index 0000000..312d024 --- /dev/null +++ b/src/components/TabBar.tsx @@ -0,0 +1,91 @@ +import { Plus, X, Pin } from "lucide-react"; +import clsx from "clsx"; +import type { Tab } from "../types/tab"; + +interface TabBarProps { + tabs: Tab[]; + activeTabId: string | null; + onSelectTab: (tabId: string) => void; + onCloseTab: (tabId: string) => void; + onNewTab: () => void; + onTogglePin: (tabId: string) => void; +} + +export function TabBar({ + tabs, + activeTabId, + onSelectTab, + onCloseTab, + onNewTab, + onTogglePin, +}: TabBarProps) { + const handleContextMenu = (e: React.MouseEvent, tabId: string) => { + e.preventDefault(); + onTogglePin(tabId); + }; + + return ( +
+ {tabs.map((tab) => ( +
onSelectTab(tab.id)} + onContextMenu={(e) => handleContextMenu(e, tab.id)} + className={clsx( + "group relative flex items-center gap-1 h-6 rounded transition-all cursor-pointer", + tab.isPinned ? "w-8 justify-center" : "px-2 pr-1 min-w-[80px] max-w-[160px]", + activeTabId === tab.id + ? "bg-white/10 text-white" + : "text-white/50 hover:text-white/80 hover:bg-white/5" + )} + > + {tab.isPinned ? ( +
+ ) : ( + <> + {tab.title} + + + )} + + {/* Pin indicator for non-pinned tabs */} + {!tab.isPinned && ( + + )} +
+ ))} + + {/* New Tab Button */} + +
+ ); +} diff --git a/src/components/TerminalPane.tsx b/src/components/TerminalPane.tsx new file mode 100644 index 0000000..e738fa0 --- /dev/null +++ b/src/components/TerminalPane.tsx @@ -0,0 +1,133 @@ +import { useEffect, useRef, useCallback } from "react"; +import { Terminal } from "xterm"; +import { FitAddon } from "xterm-addon-fit"; +import { WebLinksAddon } from "xterm-addon-web-links"; +import { invoke } from "@tauri-apps/api/tauri"; +import type { Tab, TerminalInstance } from "../types/tab"; + +interface TerminalPaneProps { + tab: Tab; + isActive: boolean; + fontFamily: string; + onRegisterInstance: (tabId: string, instance: TerminalInstance) => void; + onRequestScanBlocks: () => void; +} + +export function TerminalPane({ + tab, + isActive, + fontFamily, + onRegisterInstance, + onRequestScanBlocks, +}: TerminalPaneProps) { + const containerRef = useRef(null); + const terminalRef = useRef(null); + const fitAddonRef = useRef(null); + const initializedRef = useRef(false); + + // Initialize terminal + useEffect(() => { + if (!containerRef.current || initializedRef.current) return; + initializedRef.current = true; + + const term = new Terminal({ + fontFamily, + fontSize: 14, + cursorBlink: true, + allowProposedApi: true, + theme: { + background: "#00000000", + foreground: "#eeeeee", + }, + }); + + const fitAddon = new FitAddon(); + term.loadAddon(fitAddon); + term.loadAddon(new WebLinksAddon()); + + term.open(containerRef.current); + fitAddon.fit(); + + terminalRef.current = term; + fitAddonRef.current = fitAddon; + + // Register instance with tab manager + onRegisterInstance(tab.id, { terminal: term, fitAddon }); + + // Data flow - write to specific session + term.onData((data) => { + invoke("write_to_pty", { sessionId: tab.sessionId, data }); + }); + + // Resize observer + const resizeObserver = new ResizeObserver(() => { + if (fitAddonRef.current) { + fitAddonRef.current.fit(); + invoke("resize_pty", { + sessionId: tab.sessionId, + rows: term.rows, + cols: term.cols, + }).catch(() => {}); + onRequestScanBlocks(); + } + }); + resizeObserver.observe(containerRef.current); + + term.onRender(() => { + onRequestScanBlocks(); + }); + + return () => { + resizeObserver.disconnect(); + // Don't dispose terminal here - it will be disposed by tab manager on close + }; + }, [tab.id, tab.sessionId, fontFamily, onRegisterInstance, onRequestScanBlocks]); + + // Update font when it changes + useEffect(() => { + if (terminalRef.current) { + terminalRef.current.options.fontFamily = fontFamily; + fitAddonRef.current?.fit(); + } + }, [fontFamily]); + + // Focus terminal when tab becomes active + useEffect(() => { + if (isActive && terminalRef.current) { + terminalRef.current.focus(); + // Refit when becoming active to ensure correct sizing + fitAddonRef.current?.fit(); + } + }, [isActive]); + + // Method to write data to terminal (called from parent via ref or event) + const writeData = useCallback((data: Uint8Array) => { + if (terminalRef.current) { + terminalRef.current.write(data); + } + }, []); + + // Expose writeData via data attribute for parent to access + useEffect(() => { + if (containerRef.current) { + (containerRef.current as any).__writeData = writeData; + (containerRef.current as any).__terminal = terminalRef.current; + (containerRef.current as any).__fitAddon = fitAddonRef.current; + } + }, [writeData]); + + return ( +
+ ); +} diff --git a/src/hooks/useTabManager.ts b/src/hooks/useTabManager.ts new file mode 100644 index 0000000..84912d8 --- /dev/null +++ b/src/hooks/useTabManager.ts @@ -0,0 +1,195 @@ +import { useState, useCallback, useRef, useMemo } from "react"; +import { invoke } from "@tauri-apps/api/tauri"; +import type { Tab, TerminalInstance } from "../types/tab"; + +let tabCounter = 0; + +export function useTabManager() { + const [tabs, setTabs] = useState([]); + const [activeTabId, setActiveTabId] = useState(null); + const terminalInstances = useRef>(new Map()); + + const createTab = useCallback(async (): Promise => { + try { + const sessionId = await invoke("create_pty_session"); + tabCounter++; + const tab: Tab = { + id: `tab-${Date.now()}-${tabCounter}`, + sessionId, + title: `Shell ${tabCounter}`, + isPinned: false, + createdAt: Date.now(), + }; + + setTabs((prev) => [...prev, tab]); + setActiveTabId(tab.id); + return tab; + } catch (err) { + console.error("Failed to create tab:", err); + return null; + } + }, []); + + const closeTab = useCallback( + async (tabId: string) => { + const tab = tabs.find((t) => t.id === tabId); + if (!tab) return; + + // Clean up PTY session + try { + await invoke("close_pty_session", { sessionId: tab.sessionId }); + } catch (err) { + console.error("Failed to close PTY session:", err); + } + + // Clean up terminal instance + const instance = terminalInstances.current.get(tabId); + if (instance) { + instance.terminal.dispose(); + terminalInstances.current.delete(tabId); + } + + setTabs((prev) => { + const filtered = prev.filter((t) => t.id !== tabId); + // If we're closing the active tab, switch to another + if (activeTabId === tabId && filtered.length > 0) { + const currentIndex = prev.findIndex((t) => t.id === tabId); + const newIndex = Math.min(currentIndex, filtered.length - 1); + setActiveTabId(filtered[newIndex].id); + } else if (filtered.length === 0) { + setActiveTabId(null); + } + return filtered; + }); + }, + [tabs, activeTabId] + ); + + const pinTab = useCallback((tabId: string) => { + setTabs((prev) => + prev.map((tab) => + tab.id === tabId ? { ...tab, isPinned: true, pinnedAt: Date.now() } : tab + ) + ); + }, []); + + const unpinTab = useCallback((tabId: string) => { + setTabs((prev) => + prev.map((tab) => + tab.id === tabId ? { ...tab, isPinned: false, pinnedAt: undefined } : tab + ) + ); + }, []); + + const togglePin = useCallback((tabId: string) => { + const tab = tabs.find((t) => t.id === tabId); + if (tab) { + if (tab.isPinned) { + unpinTab(tabId); + } else { + pinTab(tabId); + } + } + }, [tabs, pinTab, unpinTab]); + + const switchTab = useCallback((tabId: string) => { + setActiveTabId(tabId); + }, []); + + // Helper to compute sorted tabs + const getSortedTabs = useCallback((currentTabs: Tab[]) => { + const pinned = currentTabs + .filter((t) => t.isPinned) + .sort((a, b) => (a.pinnedAt || 0) - (b.pinnedAt || 0)); + const unpinned = currentTabs + .filter((t) => !t.isPinned) + .sort((a, b) => a.createdAt - b.createdAt); + return [...pinned, ...unpinned]; + }, []); + + const switchToTabByIndex = useCallback( + (index: number) => { + const sorted = getSortedTabs(tabs); + if (index >= 0 && index < sorted.length) { + setActiveTabId(sorted[index].id); + } + }, + [tabs, getSortedTabs] + ); + + const switchToPreviousTab = useCallback(() => { + const sorted = getSortedTabs(tabs); + const currentIndex = sorted.findIndex((t) => t.id === activeTabId); + if (currentIndex > 0) { + setActiveTabId(sorted[currentIndex - 1].id); + } else if (sorted.length > 0) { + // Wrap around to last tab + setActiveTabId(sorted[sorted.length - 1].id); + } + }, [tabs, activeTabId, getSortedTabs]); + + const switchToNextTab = useCallback(() => { + const sorted = getSortedTabs(tabs); + const currentIndex = sorted.findIndex((t) => t.id === activeTabId); + if (currentIndex < sorted.length - 1) { + setActiveTabId(sorted[currentIndex + 1].id); + } else if (sorted.length > 0) { + // Wrap around to first tab + setActiveTabId(sorted[0].id); + } + }, [tabs, activeTabId, getSortedTabs]); + + const registerTerminalInstance = useCallback( + (tabId: string, instance: TerminalInstance) => { + terminalInstances.current.set(tabId, instance); + }, + [] + ); + + const getTerminalInstance = useCallback((tabId: string) => { + return terminalInstances.current.get(tabId); + }, []); + + const updateTabTitle = useCallback((tabId: string, title: string) => { + setTabs((prev) => + prev.map((tab) => (tab.id === tabId ? { ...tab, title } : tab)) + ); + }, []); + + // Computed: sorted tabs - pinned first (by pin time), then unpinned (by creation time) + const sortedTabs = useMemo(() => { + const pinned = tabs + .filter((t) => t.isPinned) + .sort((a, b) => (a.pinnedAt || 0) - (b.pinnedAt || 0)); + const unpinned = tabs + .filter((t) => !t.isPinned) + .sort((a, b) => a.createdAt - b.createdAt); + return [...pinned, ...unpinned]; + }, [tabs]); + + const activeTab = useMemo( + () => tabs.find((t) => t.id === activeTabId) || null, + [tabs, activeTabId] + ); + + return { + tabs, + sortedTabs, + activeTabId, + activeTab, + createTab, + closeTab, + pinTab, + unpinTab, + togglePin, + switchTab, + switchToTabByIndex, + switchToPreviousTab, + switchToNextTab, + registerTerminalInstance, + getTerminalInstance, + updateTabTitle, + }; +} + +export type TabManager = ReturnType; diff --git a/src/types/tab.ts b/src/types/tab.ts new file mode 100644 index 0000000..b21f4ff --- /dev/null +++ b/src/types/tab.ts @@ -0,0 +1,13 @@ +export interface Tab { + id: string; + sessionId: string; + title: string; + isPinned: boolean; + createdAt: number; + pinnedAt?: number; +} + +export interface TerminalInstance { + terminal: import("xterm").Terminal; + fitAddon: import("xterm-addon-fit").FitAddon; +}