mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-09-24 15:49:09 +00:00
feat: Implement custom window controls and theme management for Electron app
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -46,4 +46,4 @@
|
||||
"vite": "^6.2.0",
|
||||
"wait-on": "^7.2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user