feat: custom title bar on Linux with native toggle Replace WindowsTitleBar with CustomTitleBar that renders on both Windows and Linux. Add useNativeTitleBar settings toggle with app relaunch to switch between custom and native window frame.
This commit is contained in:
parent
68f16bb717
commit
6cc4299245
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