From 8b40c8edce7d7db475aee6dc4ff486facd1e010b Mon Sep 17 00:00:00 2001 From: "LAPTOP-O016UC3M\\Qi Chen" <16399091+binaricat@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:15:05 +0800 Subject: [PATCH] feat: Implement custom window controls and theme management for Electron app --- application/state/useSettingsState.ts | 3 + components/Terminal.tsx | 71 +++++++++++++++-- components/TopTabs.tsx | 69 ++++++++++++++++- electron/main.cjs | 107 ++++++++++++++++++++++---- electron/preload.cjs | 8 ++ global.d.ts | 6 ++ package.json | 2 +- 7 files changed, 243 insertions(+), 23 deletions(-) diff --git a/application/state/useSettingsState.ts b/application/state/useSettingsState.ts index 92280e04c..29b138a10 100644 --- a/application/state/useSettingsState.ts +++ b/application/state/useSettingsState.ts @@ -25,6 +25,9 @@ const applyThemeTokens = (theme: 'light' | 'dark', primaryColor: string) => { ? '220 40% 96%' : (!Number.isNaN(lightness) && lightness < 55 ? '0 0% 98%' : '222 47% 12%'); root.style.setProperty('--accent-foreground', accentForeground); + + // Sync with native window title bar (Electron) + window.nebula?.setTheme?.(theme); }; export const useSettingsState = () => { diff --git a/components/Terminal.tsx b/components/Terminal.tsx index b5b47a677..a3ec9b293 100644 --- a/components/Terminal.tsx +++ b/components/Terminal.tsx @@ -251,24 +251,49 @@ const TerminalComponent: React.FC = ({ } }, [fontSize, terminalTheme, isVisible]); + // Debounced fit for resize operations - wait until resize ends useEffect(() => { if (!containerRef.current || !fitAddonRef.current) return; + let resizeTimeout: ReturnType | null = null; + const observer = new ResizeObserver(() => { - safeFit(); + // Clear previous timeout + if (resizeTimeout) { + clearTimeout(resizeTimeout); + } + // Wait 150ms after last resize event before fitting + resizeTimeout = setTimeout(() => { + safeFit(); + }, 150); }); observer.observe(containerRef.current); - return () => observer.disconnect(); + return () => { + if (resizeTimeout) clearTimeout(resizeTimeout); + observer.disconnect(); + }; }, [isVisible]); useEffect(() => { + let resizeTimeout: ReturnType | null = null; + const handler = () => { - // Defer slightly to allow layout to settle after window resize. - requestAnimationFrame(() => safeFit()); + // Clear previous timeout + if (resizeTimeout) { + clearTimeout(resizeTimeout); + } + // Wait 150ms after last resize event before fitting + resizeTimeout = setTimeout(() => { + safeFit(); + }, 150); }; + window.addEventListener('resize', handler); - return () => window.removeEventListener('resize', handler); + return () => { + if (resizeTimeout) clearTimeout(resizeTimeout); + window.removeEventListener('resize', handler); + }; }, []); const startSSH = async (term: GhosttyTerminal) => { @@ -310,7 +335,23 @@ const TerminalComponent: React.FC = ({ disposeDataRef.current = window.nebula.onSessionData(id, (chunk) => { term.write(chunk); - if (!hasConnectedRef.current) updateStatus('connected'); + if (!hasConnectedRef.current) { + updateStatus('connected'); + // Trigger fit after connection to ensure proper terminal size + setTimeout(() => { + if (fitAddonRef.current) { + try { + fitAddonRef.current.fit(); + // Send updated size to remote + if (sessionRef.current && window.nebula?.resizeSession) { + window.nebula.resizeSession(sessionRef.current, term.cols, term.rows); + } + } catch (err) { + console.warn("Post-connect fit failed", err); + } + } + }, 100); + } }); disposeExitRef.current = window.nebula.onSessionExit(id, (evt) => { @@ -362,7 +403,23 @@ const TerminalComponent: React.FC = ({ sessionRef.current = id; disposeDataRef.current = window.nebula?.onSessionData(id, (chunk) => { term.write(chunk); - if (!hasConnectedRef.current) updateStatus('connected'); + if (!hasConnectedRef.current) { + updateStatus('connected'); + // Trigger fit after connection to ensure proper terminal size + setTimeout(() => { + if (fitAddonRef.current) { + try { + fitAddonRef.current.fit(); + // Send updated size to remote + if (sessionRef.current && window.nebula?.resizeSession) { + window.nebula.resizeSession(sessionRef.current, term.cols, term.rows); + } + } catch (err) { + console.warn("Post-connect fit failed", err); + } + } + }, 100); + } }); disposeExitRef.current = window.nebula?.onSessionExit(id, (evt) => { updateStatus('disconnected'); diff --git a/components/TopTabs.tsx b/components/TopTabs.tsx index 0abb0c0f5..ce0e6c676 100644 --- a/components/TopTabs.tsx +++ b/components/TopTabs.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { TerminalSquare, Shield, Folder, LayoutGrid, Plus, Bell, User, Sun, Moon, X } from 'lucide-react'; +import React, { useState, useEffect } from 'react'; +import { TerminalSquare, Shield, Folder, LayoutGrid, Plus, Bell, User, Sun, Moon, X, Minus, Square, Copy } from 'lucide-react'; import { TerminalSession, Workspace } from '../types'; import { Button } from './ui/button'; import { cn } from '../lib/utils'; @@ -34,6 +34,68 @@ const sessionStatusDot = (status: TerminalSession['status']) => { return ; }; +// Custom window controls for Windows/Linux (frameless window) +const WindowControls: React.FC = () => { + const [isMaximized, setIsMaximized] = useState(false); + + useEffect(() => { + // Check initial maximized state + window.nebula?.windowIsMaximized?.().then(setIsMaximized); + + // Listen for window resize to update maximized state + const handleResize = () => { + window.nebula?.windowIsMaximized?.().then(setIsMaximized); + }; + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + const handleMinimize = () => { + window.nebula?.windowMinimize?.(); + }; + + const handleMaximize = async () => { + const result = await window.nebula?.windowMaximize?.(); + setIsMaximized(result ?? false); + }; + + const handleClose = () => { + window.nebula?.windowClose?.(); + }; + + return ( +
+ + + +
+ ); +}; + export const TopTabs: React.FC = ({ theme, isVaultActive, @@ -167,7 +229,10 @@ export const TopTabs: React.FC = ({ {theme === 'dark' ? : } + {/* Custom window controls for Windows/Linux */} + {!isMacClient && } ); }; + diff --git a/electron/main.cjs b/electron/main.cjs index 83ab64da2..c5cd430c5 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -14,7 +14,7 @@ try { console.log("electron module raw:", electronModule); console.log("process.versions:", process.versions); console.log("env ELECTRON_RUN_AS_NODE:", process.env.ELECTRON_RUN_AS_NODE); -const { app, BrowserWindow } = electronModule || {}; +const { app, BrowserWindow, nativeTheme } = electronModule || {}; if (!app || !BrowserWindow) { throw new Error("Failed to load Electron runtime. Ensure the app is launched with the Electron binary."); } @@ -207,14 +207,26 @@ const registerSSHBridge = (win) => { const write = (_event, payload) => { const session = sessions.get(payload.sessionId); - session?.stream?.write(payload.data); + if (!session) return; + // SSH sessions use stream, local terminal uses proc + if (session.stream) { + session.stream.write(payload.data); + } else if (session.proc) { + session.proc.write(payload.data); + } }; const resize = (_event, payload) => { const session = sessions.get(payload.sessionId); - if (!session?.stream) return; + if (!session) return; try { - session.stream.setWindow(payload.rows, payload.cols, 0, 0); + if (session.stream) { + // SSH session - use setWindow + session.stream.setWindow(payload.rows, payload.cols, 0, 0); + } else if (session.proc) { + // Local terminal - use resize + session.proc.resize(payload.cols, payload.rows); + } } catch (err) { console.warn("Resize failed", err); } @@ -224,8 +236,12 @@ const registerSSHBridge = (win) => { const session = sessions.get(payload.sessionId); if (!session) return; try { - session.stream?.close(); - session.conn?.end(); + if (session.stream) { + session.stream.close(); + session.conn?.end(); + } else if (session.proc) { + session.proc.kill(); + } } catch (err) { console.warn("Close failed", err); } @@ -409,18 +425,37 @@ const registerSSHBridge = (win) => { electronModule.ipcMain.handle("nebula:sftp:mkdir", mkdirSftp); }; +// Store reference to main window for theme updates +let mainWindow = null; + +// Theme colors configuration +const THEME_COLORS = { + dark: { + background: "#0b1220", + titleBarColor: "#0b1220", + symbolColor: "#ffffff", + }, + light: { + background: "#ffffff", + titleBarColor: "#f8fafc", + symbolColor: "#1e293b", + }, +}; + +// Read initial theme from a simple approach - default to light +let currentTheme = "light"; + async function createWindow() { + const themeConfig = THEME_COLORS[currentTheme]; const win = new BrowserWindow({ width: 1400, height: 900, - backgroundColor: "#0b1220", + backgroundColor: themeConfig.background, icon: appIcon, - titleBarStyle: isMac ? "hiddenInset" : "hidden", - titleBarOverlay: { - color: isMac ? "#0b1220" : "#0b1220", - symbolColor: "#ffffff", - height: 44, - }, + // macOS: use hiddenInset for native traffic lights + // Windows/Linux: use frameless window with custom controls + frame: isMac, + titleBarStyle: isMac ? "hiddenInset" : undefined, trafficLightPosition: isMac ? { x: 12, y: 12 } : undefined, webPreferences: { preload, @@ -430,6 +465,52 @@ async function createWindow() { }, }); + mainWindow = win; + + // Window control handlers (for custom title bar on Windows/Linux) + electronModule.ipcMain.handle("nebula:window:minimize", () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.minimize(); + } + }); + + electronModule.ipcMain.handle("nebula:window:maximize", () => { + if (mainWindow && !mainWindow.isDestroyed()) { + if (mainWindow.isMaximized()) { + mainWindow.unmaximize(); + return false; + } else { + mainWindow.maximize(); + return true; + } + } + return false; + }); + + electronModule.ipcMain.handle("nebula:window:close", () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.close(); + } + }); + + electronModule.ipcMain.handle("nebula:window:isMaximized", () => { + if (mainWindow && !mainWindow.isDestroyed()) { + return mainWindow.isMaximized(); + } + return false; + }); + + // Handle theme change requests from renderer + electronModule.ipcMain.handle("nebula:setTheme", (_event, theme) => { + currentTheme = theme; + nativeTheme.themeSource = theme; + const themeConfig = THEME_COLORS[theme] || THEME_COLORS.light; + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.setBackgroundColor(themeConfig.background); + } + return true; + }); + if (isDev) { try { await win.loadURL(devServerUrl); diff --git a/electron/preload.cjs b/electron/preload.cjs index 19b5052c1..6c4d1732a 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -80,6 +80,14 @@ const api = { mkdirSftp: async (sftpId, path) => { return ipcRenderer.invoke("nebula:sftp:mkdir", { sftpId, path }); }, + setTheme: async (theme) => { + return ipcRenderer.invoke("nebula:setTheme", theme); + }, + // Window controls for custom title bar + windowMinimize: () => ipcRenderer.invoke("nebula:window:minimize"), + windowMaximize: () => ipcRenderer.invoke("nebula:window:maximize"), + windowClose: () => ipcRenderer.invoke("nebula:window:close"), + windowIsMaximized: () => ipcRenderer.invoke("nebula:window:isMaximized"), }; // Merge with existing nebula (if any) to avoid stale objects on hot reload diff --git a/global.d.ts b/global.d.ts index c7ee184a4..785debc57 100644 --- a/global.d.ts +++ b/global.d.ts @@ -41,6 +41,12 @@ interface NebulaBridge { writeSftp(sftpId: string, path: string, content: string): Promise; closeSftp(sftpId: string): Promise; mkdirSftp(sftpId: string, path: string): Promise; + setTheme?(theme: 'light' | 'dark'): Promise; + // Window controls for custom title bar (Windows/Linux) + windowMinimize?(): Promise; + windowMaximize?(): Promise; + windowClose?(): Promise; + windowIsMaximized?(): Promise; } declare global { diff --git a/package.json b/package.json index 0b5230c04..62950ed22 100755 --- a/package.json +++ b/package.json @@ -46,4 +46,4 @@ "vite": "^6.2.0", "wait-on": "^7.2.0" } -} +} \ No newline at end of file