Merge pull request #68 from Psypeal/feat/linux-custom-titlebar
feat: custom title bar on Linux with native toggle
This commit is contained in:
commit
1233a6f155
14 changed files with 72 additions and 20 deletions
|
|
@ -424,8 +424,8 @@ function syncTrafficLightPosition(win: BrowserWindow): void {
|
|||
*/
|
||||
function createWindow(): void {
|
||||
const isMac = process.platform === 'darwin';
|
||||
const isLinux = process.platform === 'linux';
|
||||
const iconPath = isMac ? undefined : getWindowIconPath();
|
||||
const useNativeTitleBar = !isMac && configManager.getConfig().general.useNativeTitleBar;
|
||||
mainWindow = new BrowserWindow({
|
||||
width: DEFAULT_WINDOW_WIDTH,
|
||||
height: DEFAULT_WINDOW_HEIGHT,
|
||||
|
|
@ -436,7 +436,7 @@ function createWindow(): void {
|
|||
contextIsolation: true,
|
||||
},
|
||||
backgroundColor: '#1a1a1a',
|
||||
...(isLinux ? {} : { titleBarStyle: 'hidden' as const }),
|
||||
...(useNativeTitleBar ? {} : { titleBarStyle: 'hidden' as const }),
|
||||
...(isMac && { trafficLightPosition: getTrafficLightPositionForZoom(1) }),
|
||||
title: 'claude-devtools',
|
||||
});
|
||||
|
|
@ -485,6 +485,7 @@ function createWindow(): void {
|
|||
|
||||
// Prevent Electron's default Ctrl+R / Cmd+R page reload so the renderer
|
||||
// keyboard handler can use it as "Refresh Session" (fixes #58).
|
||||
// Also prevent Ctrl+Shift+R / Cmd+Shift+R (hard reload).
|
||||
if ((input.control || input.meta) && input.key.toLowerCase() === 'r') {
|
||||
event.preventDefault();
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V
|
|||
'defaultTab',
|
||||
'claudeRootPath',
|
||||
'autoExpandAIGroups',
|
||||
'useNativeTitleBar',
|
||||
];
|
||||
|
||||
const result: Partial<GeneralConfig> = {};
|
||||
|
|
@ -274,6 +275,12 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V
|
|||
}
|
||||
result.autoExpandAIGroups = value;
|
||||
break;
|
||||
case 'useNativeTitleBar':
|
||||
if (typeof value !== 'boolean') {
|
||||
return { valid: false, error: `general.${key} must be a boolean` };
|
||||
}
|
||||
result.useNativeTitleBar = value;
|
||||
break;
|
||||
default:
|
||||
return { valid: false, error: `Unsupported general key: ${key}` };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { BrowserWindow, type IpcMain } from 'electron';
|
||||
import { app, BrowserWindow, type IpcMain } from 'electron';
|
||||
|
||||
const logger = createLogger('IPC:window');
|
||||
|
||||
|
|
@ -40,6 +40,11 @@ export function registerWindowHandlers(ipcMain: IpcMain): void {
|
|||
return win != null && !win.isDestroyed() && win.isMaximized();
|
||||
});
|
||||
|
||||
ipcMain.handle('app:relaunch', () => {
|
||||
app.relaunch();
|
||||
app.exit(0);
|
||||
});
|
||||
|
||||
logger.info('Window handlers registered');
|
||||
}
|
||||
|
||||
|
|
@ -48,5 +53,6 @@ export function removeWindowHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler('window:maximize');
|
||||
ipcMain.removeHandler('window:close');
|
||||
ipcMain.removeHandler('window:isMaximized');
|
||||
ipcMain.removeHandler('app:relaunch');
|
||||
logger.info('Window handlers removed');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -182,6 +182,7 @@ export interface GeneralConfig {
|
|||
defaultTab: 'dashboard' | 'last-session';
|
||||
claudeRootPath: string | null;
|
||||
autoExpandAIGroups: boolean;
|
||||
useNativeTitleBar: boolean;
|
||||
}
|
||||
|
||||
export interface DisplayConfig {
|
||||
|
|
@ -250,6 +251,7 @@ const DEFAULT_CONFIG: AppConfig = {
|
|||
defaultTab: 'dashboard',
|
||||
claudeRootPath: null,
|
||||
autoExpandAIGroups: false,
|
||||
useNativeTitleBar: false,
|
||||
},
|
||||
display: {
|
||||
showTimestamps: true,
|
||||
|
|
|
|||
|
|
@ -171,3 +171,6 @@ export const WINDOW_CLOSE = 'window:close';
|
|||
|
||||
/** Whether the window is currently maximized */
|
||||
export const WINDOW_IS_MAXIMIZED = 'window:isMaximized';
|
||||
|
||||
/** Relaunch the application */
|
||||
export const APP_RELAUNCH = 'app:relaunch';
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL } from '@shared/constants';
|
|||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
import {
|
||||
APP_RELAUNCH,
|
||||
CONTEXT_CHANGED,
|
||||
CONTEXT_GET_ACTIVE,
|
||||
CONTEXT_LIST,
|
||||
|
|
@ -357,6 +358,7 @@ const electronAPI: ElectronAPI = {
|
|||
maximize: () => ipcRenderer.invoke(WINDOW_MAXIMIZE),
|
||||
close: () => ipcRenderer.invoke(WINDOW_CLOSE),
|
||||
isMaximized: () => ipcRenderer.invoke(WINDOW_IS_MAXIMIZED) as Promise<boolean>,
|
||||
relaunch: () => ipcRenderer.invoke(APP_RELAUNCH),
|
||||
},
|
||||
|
||||
onTodoChange: (callback: (event: IpcFileChangePayload) => void): (() => void) => {
|
||||
|
|
|
|||
|
|
@ -512,6 +512,7 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
maximize: async (): Promise<void> => {},
|
||||
close: async (): Promise<void> => {},
|
||||
isMaximized: async (): Promise<boolean> => false,
|
||||
relaunch: async (): Promise<void> => {},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,32 +1,36 @@
|
|||
/**
|
||||
* WindowsTitleBar - Conventional title bar for Windows when the native frame is hidden.
|
||||
* CustomTitleBar - Conventional title bar for Windows and Linux when the native frame is hidden.
|
||||
*
|
||||
* Renders a draggable top strip with window controls (minimize, maximize/restore, close)
|
||||
* on the right, matching Windows conventions. Only shown in Electron on Windows (win32).
|
||||
* on the right. Only shown in Electron on Windows or Linux (macOS uses native traffic lights).
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { isElectronMode } from '@renderer/api';
|
||||
import faviconUrl from '@renderer/favicon.png';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { Minus, Square, X } from 'lucide-react';
|
||||
|
||||
const TITLE_BAR_HEIGHT = 32;
|
||||
|
||||
function isWindowsDesktop(): boolean {
|
||||
function needsCustomTitleBar(): boolean {
|
||||
if (!isElectronMode()) return false;
|
||||
return window.navigator.userAgent.includes('Windows');
|
||||
const ua = window.navigator.userAgent;
|
||||
return ua.includes('Windows') || ua.includes('Linux');
|
||||
}
|
||||
|
||||
export const WindowsTitleBar = (): React.JSX.Element | null => {
|
||||
export const CustomTitleBar = (): React.JSX.Element | null => {
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
const isWin = isWindowsDesktop();
|
||||
const useNativeTitleBar = useStore((s) => s.appConfig?.general?.useNativeTitleBar ?? false);
|
||||
const showTitleBar = needsCustomTitleBar() && !useNativeTitleBar;
|
||||
const api = typeof window !== 'undefined' ? window.electronAPI?.windowControls : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (api) void api.isMaximized().then(setIsMaximized);
|
||||
}, [api]);
|
||||
|
||||
if (!isWin || !api) return null;
|
||||
if (!showTitleBar || !api) return null;
|
||||
|
||||
const { minimize, maximize, close, isMaximized: getIsMaximized } = api;
|
||||
|
||||
|
|
@ -49,14 +53,9 @@ export const WindowsTitleBar = (): React.JSX.Element | null => {
|
|||
|
||||
return (
|
||||
<div className="flex shrink-0 select-none items-stretch" style={titleBarStyle}>
|
||||
{/* Draggable area — app title optional */}
|
||||
<div className="flex flex-1 items-center pl-4" style={{ minWidth: 0 }}>
|
||||
<span
|
||||
className="truncate text-sm font-semibold"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
claude-devtools
|
||||
</span>
|
||||
{/* Draggable area — app icon */}
|
||||
<div className="flex flex-1 items-center pl-3" style={{ minWidth: 0 }}>
|
||||
<img src={faviconUrl} alt="" className="size-5 shrink-0 rounded-sm" draggable={false} />
|
||||
</div>
|
||||
|
||||
{/* Window controls — no-drag so they receive clicks */}
|
||||
|
|
@ -16,9 +16,9 @@ import { UpdateDialog } from '../common/UpdateDialog';
|
|||
import { WorkspaceIndicator } from '../common/WorkspaceIndicator';
|
||||
import { CommandPalette } from '../search/CommandPalette';
|
||||
|
||||
import { CustomTitleBar } from './CustomTitleBar';
|
||||
import { PaneContainer } from './PaneContainer';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { WindowsTitleBar } from './WindowsTitleBar';
|
||||
|
||||
export const TabbedLayout = (): React.JSX.Element => {
|
||||
// Enable keyboard shortcuts
|
||||
|
|
@ -33,7 +33,7 @@ export const TabbedLayout = (): React.JSX.Element => {
|
|||
{ '--macos-traffic-light-padding-left': `${trafficLightPadding}px` } as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<WindowsTitleBar />
|
||||
<CustomTitleBar />
|
||||
<UpdateBanner />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Command Palette (Cmd+K) */}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export interface SafeConfig {
|
|||
defaultTab: 'dashboard' | 'last-session';
|
||||
claudeRootPath: string | null;
|
||||
autoExpandAIGroups: boolean;
|
||||
useNativeTitleBar: boolean;
|
||||
};
|
||||
notifications: {
|
||||
enabled: boolean;
|
||||
|
|
@ -156,6 +157,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
|
|||
defaultTab: displayConfig?.general?.defaultTab ?? 'dashboard',
|
||||
claudeRootPath: displayConfig?.general?.claudeRootPath ?? null,
|
||||
autoExpandAIGroups: displayConfig?.general?.autoExpandAIGroups ?? false,
|
||||
useNativeTitleBar: displayConfig?.general?.useNativeTitleBar ?? false,
|
||||
},
|
||||
notifications: {
|
||||
enabled: displayConfig?.notifications?.enabled ?? true,
|
||||
|
|
|
|||
|
|
@ -288,6 +288,7 @@ export function useSettingsHandlers({
|
|||
defaultTab: 'dashboard',
|
||||
claudeRootPath: null,
|
||||
autoExpandAIGroups: false,
|
||||
useNativeTitleBar: false,
|
||||
},
|
||||
display: {
|
||||
showTimestamps: true,
|
||||
|
|
|
|||
|
|
@ -297,6 +297,31 @@ export const GeneralSection = ({
|
|||
disabled={saving}
|
||||
/>
|
||||
</SettingRow>
|
||||
{isElectron && !window.navigator.userAgent.includes('Macintosh') && (
|
||||
<SettingRow
|
||||
label="Use native title bar"
|
||||
description="Use the default system window frame instead of the custom title bar"
|
||||
>
|
||||
<SettingsToggle
|
||||
enabled={safeConfig.general.useNativeTitleBar}
|
||||
onChange={async (v) => {
|
||||
const shouldRelaunch = await confirm({
|
||||
title: 'Restart required',
|
||||
message: 'The app needs to restart to apply the title bar change. Restart now?',
|
||||
confirmLabel: 'Restart',
|
||||
});
|
||||
if (shouldRelaunch) {
|
||||
onGeneralToggle('useNativeTitleBar', v);
|
||||
// Small delay to let config persist before relaunch
|
||||
setTimeout(() => {
|
||||
void window.electronAPI?.windowControls?.relaunch();
|
||||
}, 200);
|
||||
}
|
||||
}}
|
||||
disabled={saving}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
{isElectron && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -432,6 +432,7 @@ export interface ElectronAPI {
|
|||
maximize: () => Promise<void>;
|
||||
close: () => Promise<void>;
|
||||
isMaximized: () => Promise<boolean>;
|
||||
relaunch: () => Promise<void>;
|
||||
};
|
||||
|
||||
// Updater API
|
||||
|
|
|
|||
|
|
@ -264,6 +264,8 @@ export interface AppConfig {
|
|||
claudeRootPath: string | null;
|
||||
/** Whether to auto-expand AI response groups when opening a transcript or receiving new messages */
|
||||
autoExpandAIGroups: boolean;
|
||||
/** Whether to use the native OS title bar instead of the custom one (Linux/Windows) */
|
||||
useNativeTitleBar: boolean;
|
||||
};
|
||||
/** Display and UI settings */
|
||||
display: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue