feat: Implement custom window controls and theme management for Electron app

This commit is contained in:
LAPTOP-O016UC3M\Qi Chen
2025-12-08 16:15:05 +08:00
parent bb402b2ad5
commit 8b40c8edce
7 changed files with 243 additions and 23 deletions
+3
View File
@@ -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 = () => {
+64 -7
View File
@@ -251,24 +251,49 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
}, [fontSize, terminalTheme, isVisible]);
// Debounced fit for resize operations - wait until resize ends
useEffect(() => {
if (!containerRef.current || !fitAddonRef.current) return;
let resizeTimeout: ReturnType<typeof setTimeout> | 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<typeof setTimeout> | 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<TerminalProps> = ({
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<TerminalProps> = ({
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');
+67 -2
View File
@@ -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 <span className={cn("inline-block h-2 w-2 rounded-full shadow-[0_0_0_2px_rgba(0,0,0,0.35)]", tone)} />;
};
// 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 (
<div className="flex items-center app-no-drag">
<button
onClick={handleMinimize}
className="h-10 w-12 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150"
title="Minimize"
>
<Minus size={16} />
</button>
<button
onClick={handleMaximize}
className="h-10 w-12 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150"
title={isMaximized ? "Restore" : "Maximize"}
>
{isMaximized ? (
// Restore icon (two overlapping squares)
<Copy size={14} />
) : (
// Maximize icon (single square)
<Square size={14} />
)}
</button>
<button
onClick={handleClose}
className="h-10 w-12 flex items-center justify-center text-muted-foreground hover:bg-red-500 hover:text-white transition-all duration-150"
title="Close"
>
<X size={16} />
</button>
</div>
);
};
export const TopTabs: React.FC<TopTabsProps> = ({
theme,
isVaultActive,
@@ -167,7 +229,10 @@ export const TopTabs: React.FC<TopTabsProps> = ({
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
</Button>
</div>
{/* Custom window controls for Windows/Linux */}
{!isMacClient && <WindowControls />}
</div>
</div>
);
};
+94 -13
View File
@@ -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);
+8
View File
@@ -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
Vendored
+6
View File
@@ -41,6 +41,12 @@ interface NebulaBridge {
writeSftp(sftpId: string, path: string, content: string): Promise<void>;
closeSftp(sftpId: string): Promise<void>;
mkdirSftp(sftpId: string, path: string): Promise<void>;
setTheme?(theme: 'light' | 'dark'): Promise<boolean>;
// Window controls for custom title bar (Windows/Linux)
windowMinimize?(): Promise<void>;
windowMaximize?(): Promise<boolean>;
windowClose?(): Promise<void>;
windowIsMaximized?(): Promise<boolean>;
}
declare global {
+1 -1
View File
@@ -46,4 +46,4 @@
"vite": "^6.2.0",
"wait-on": "^7.2.0"
}
}
}