feat: custom title bar that is more compact

This commit is contained in:
2026-02-17 10:52:25 +01:00
parent 03cf6ae9e7
commit 7b5829e965
14 changed files with 756 additions and 113 deletions

View File

@@ -12,6 +12,7 @@ import { getGitEngine } from '../engine/GitEngine';
import { taskManager, TaskProgress } from '../engine/TaskManager'; import { taskManager, TaskProgress } from '../engine/TaskManager';
import { getDatabase } from '../database'; import { getDatabase } from '../database';
import { media } from '../database/schema'; import { media } from '../database/schema';
import { APP_MENU_ACTION_EVENT_MAP, APP_MENU_WEB_CONTENTS_ACTIONS, type AppMenuAction } from '../shared/menuCommands';
/** /**
* Wrap an IPC handler so that "Database is closing" errors during shutdown * Wrap an IPC handler so that "Database is closing" errors during shutdown
@@ -46,6 +47,45 @@ function resolvePostCreatedAt(post: { createdAt: Date | string }): Date {
return Number.isNaN(parsed.getTime()) ? new Date() : parsed; return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
} }
function runWebContentsMenuAction(sender: any, action: AppMenuAction): boolean {
if (!sender) {
return false;
}
if (!APP_MENU_WEB_CONTENTS_ACTIONS.has(action)) {
return false;
}
switch (action) {
case 'undo':
sender.undo?.();
return true;
case 'redo':
sender.redo?.();
return true;
case 'cut':
sender.cut?.();
return true;
case 'copy':
sender.copy?.();
return true;
case 'paste':
sender.paste?.();
return true;
case 'delete':
sender.delete?.();
return true;
case 'selectAll':
sender.selectAll?.();
return true;
case 'toggleDevTools':
sender.toggleDevTools?.();
return true;
default:
return false;
}
}
export function registerIpcHandlers(): void { export function registerIpcHandlers(): void {
// ============ Git Handlers ============ // ============ Git Handlers ============
@@ -673,6 +713,20 @@ export function registerIpcHandlers(): void {
} }
}); });
safeHandle('app:triggerMenuAction', async (event, action: string) => {
const typedAction = action as AppMenuAction;
const handledByWebContents = runWebContentsMenuAction((event as any)?.sender, typedAction);
if (handledByWebContents) {
return;
}
const channel = APP_MENU_ACTION_EVENT_MAP[typedAction];
if (!channel) {
return;
}
event.sender.send(channel);
});
// ============ Meta Handlers ============ // ============ Meta Handlers ============
safeHandle('meta:getTags', async () => { safeHandle('meta:getTags', async () => {

View File

@@ -8,6 +8,7 @@ import { eq } from 'drizzle-orm';
import { getMediaEngine } from './engine/MediaEngine'; import { getMediaEngine } from './engine/MediaEngine';
import { getPostEngine } from './engine/PostEngine'; import { getPostEngine } from './engine/PostEngine';
import { PreviewServer } from './engine/PreviewServer'; import { PreviewServer } from './engine/PreviewServer';
import { APP_MENU_ACTION_EVENT_MAP, APP_MENU_GROUPS, type AppMenuAction, type AppMenuItemDefinition } from './shared/menuCommands';
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
let previewServer: PreviewServer | null = null; let previewServer: PreviewServer | null = null;
@@ -58,7 +59,7 @@ function createWindow(): void {
symbolColor: '#cccccc', symbolColor: '#cccccc',
height: 34, height: 34,
}, },
autoHideMenuBar: true, autoHideMenuBar: false,
}), }),
webPreferences: { webPreferences: {
preload: path.join(__dirname, 'preload.js'), preload: path.join(__dirname, 'preload.js'),
@@ -169,32 +170,67 @@ async function startPreviewServerOnAppStart(): Promise<void> {
} }
function createApplicationMenu(): Menu { function createApplicationMenu(): Menu {
const commandDefinitions = APP_MENU_GROUPS
.flatMap(group => group.items)
.filter(item => !item.separator)
.reduce<Record<string, AppMenuItemDefinition>>((acc, item) => {
acc[item.action] = item;
return acc;
}, {});
const triggerMenuAction = (action: AppMenuAction): void => {
const channel = APP_MENU_ACTION_EVENT_MAP[action];
if (channel) {
mainWindow?.webContents.send(channel);
}
};
const buildSharedMenuItem = (action: AppMenuAction): MenuItemConstructorOptions => {
const definition = commandDefinitions[action];
if (!definition) {
throw new Error(`Unknown shared menu action: ${action}`);
}
if (definition.role) {
return {
label: definition.label,
role: definition.role,
accelerator: definition.accelerator,
};
}
return {
label: definition.label,
accelerator: definition.accelerator,
click: () => {
triggerMenuAction(action);
},
};
};
const buildSharedGroupMenuItems = (groupLabel: string): MenuItemConstructorOptions[] => {
const group = APP_MENU_GROUPS.find(item => item.label === groupLabel);
if (!group) {
return [];
}
return group.items.map((item) => {
if (item.separator) {
return { type: 'separator' };
}
return buildSharedMenuItem(item.action as AppMenuAction);
});
};
const template: MenuItemConstructorOptions[] = [ const template: MenuItemConstructorOptions[] = [
{ {
label: 'File', label: 'File',
submenu: [ submenu: [
{ buildSharedMenuItem('newPost'),
label: 'New Post', buildSharedMenuItem('importMedia'),
accelerator: 'CmdOrCtrl+N',
click: () => {
mainWindow?.webContents.send('menu:newPost');
},
},
{
label: 'Import Media...',
accelerator: 'CmdOrCtrl+I',
click: () => {
mainWindow?.webContents.send('menu:importMedia');
},
},
{ type: 'separator' }, { type: 'separator' },
{ buildSharedMenuItem('save'),
label: 'Save',
accelerator: 'CmdOrCtrl+S',
click: () => {
mainWindow?.webContents.send('menu:save');
},
},
{ type: 'separator' }, { type: 'separator' },
{ {
label: 'Open in Browser', label: 'Open in Browser',
@@ -226,65 +262,12 @@ function createApplicationMenu(): Menu {
}, },
{ {
label: 'Edit', label: 'Edit',
submenu: [ submenu: buildSharedGroupMenuItems('Edit'),
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'delete' },
{ type: 'separator' },
{ role: 'selectAll' },
{ type: 'separator' },
{
label: 'Find',
accelerator: 'CmdOrCtrl+F',
click: () => {
mainWindow?.webContents.send('menu:find');
},
},
{
label: 'Replace',
accelerator: 'CmdOrCtrl+H',
click: () => {
mainWindow?.webContents.send('menu:replace');
},
},
],
}, },
{ {
label: 'View', label: 'View',
submenu: [ submenu: [
{ ...buildSharedGroupMenuItems('View'),
label: 'Posts',
accelerator: 'CmdOrCtrl+1',
click: () => {
mainWindow?.webContents.send('menu:viewPosts');
},
},
{
label: 'Media',
accelerator: 'CmdOrCtrl+2',
click: () => {
mainWindow?.webContents.send('menu:viewMedia');
},
},
{ type: 'separator' },
{
label: 'Toggle Sidebar',
accelerator: 'CmdOrCtrl+B',
click: () => {
mainWindow?.webContents.send('menu:toggleSidebar');
},
},
{
label: 'Toggle Panel',
accelerator: 'CmdOrCtrl+J',
click: () => {
mainWindow?.webContents.send('menu:togglePanel');
},
},
{ type: 'separator' }, { type: 'separator' },
{ role: 'reload' }, { role: 'reload' },
{ role: 'forceReload' }, { role: 'forceReload' },
@@ -306,13 +289,7 @@ function createApplicationMenu(): Menu {
{ {
label: 'Blog', label: 'Blog',
submenu: [ submenu: [
{ buildSharedMenuItem('publishSelected'),
label: 'Publish Selected',
accelerator: 'CmdOrCtrl+Shift+P',
click: () => {
mainWindow?.webContents.send('menu:publishSelected');
},
},
{ type: 'separator' }, { type: 'separator' },
{ {
label: 'Preview Post', label: 'Preview Post',
@@ -328,36 +305,16 @@ function createApplicationMenu(): Menu {
}, },
}, },
{ type: 'separator' }, { type: 'separator' },
{ buildSharedMenuItem('rebuildDatabase'),
label: 'Rebuild Database from Files', buildSharedMenuItem('reindexText'),
click: () => {
mainWindow?.webContents.send('menu:rebuildDatabase');
},
},
{
label: 'Reindex Search Text',
click: () => {
mainWindow?.webContents.send('menu:reindexText');
},
},
{ type: 'separator' }, { type: 'separator' },
{ buildSharedMenuItem('metadataDiff'),
label: 'Metadata Diff Tool',
click: () => {
mainWindow?.webContents.send('menu:metadataDiff');
},
},
], ],
}, },
{ {
label: 'Help', label: 'Help',
submenu: [ submenu: [
{ buildSharedMenuItem('about'),
label: 'About Blogging Desktop Server',
click: () => {
mainWindow?.webContents.send('menu:about');
},
},
{ type: 'separator' }, { type: 'separator' },
{ {
label: 'View on GitHub', label: 'View on GitHub',

View File

@@ -142,6 +142,7 @@ export const electronAPI: ElectronAPI = {
getDefaultProjectPath: (projectId: string) => ipcRenderer.invoke('app:getDefaultProjectPath', projectId), getDefaultProjectPath: (projectId: string) => ipcRenderer.invoke('app:getDefaultProjectPath', projectId),
readProjectMetadata: (folderPath: string) => ipcRenderer.invoke('app:readProjectMetadata', folderPath), readProjectMetadata: (folderPath: string) => ipcRenderer.invoke('app:readProjectMetadata', folderPath),
setPreviewPostTarget: (postId: string | null) => ipcRenderer.invoke('app:setPreviewPostTarget', postId), setPreviewPostTarget: (postId: string | null) => ipcRenderer.invoke('app:setPreviewPostTarget', postId),
triggerMenuAction: (action: string) => ipcRenderer.invoke('app:triggerMenuAction', action),
}, },
// Meta (tags, categories, and project metadata) // Meta (tags, categories, and project metadata)

View File

@@ -510,6 +510,7 @@ export interface ElectronAPI {
getDefaultProjectPath: (projectId: string) => Promise<string>; getDefaultProjectPath: (projectId: string) => Promise<string>;
readProjectMetadata: (folderPath: string) => Promise<{ name?: string; description?: string; mainLanguage?: string } | null>; readProjectMetadata: (folderPath: string) => Promise<{ name?: string; description?: string; mainLanguage?: string } | null>;
setPreviewPostTarget: (postId: string | null) => Promise<void>; setPreviewPostTarget: (postId: string | null) => Promise<void>;
triggerMenuAction: (action: string) => Promise<void>;
}; };
meta: { meta: {
getTags: () => Promise<string[]>; getTags: () => Promise<string[]>;

View File

@@ -0,0 +1,120 @@
export type AppMenuAction =
| 'newPost'
| 'importMedia'
| 'save'
| 'undo'
| 'redo'
| 'cut'
| 'copy'
| 'paste'
| 'delete'
| 'selectAll'
| 'find'
| 'replace'
| 'viewPosts'
| 'viewMedia'
| 'toggleSidebar'
| 'togglePanel'
| 'toggleDevTools'
| 'publishSelected'
| 'rebuildDatabase'
| 'reindexText'
| 'metadataDiff'
| 'about';
export type AppMenuRole = 'undo' | 'redo' | 'cut' | 'copy' | 'paste' | 'delete' | 'selectAll';
export interface AppMenuItemDefinition {
label: string;
action: AppMenuAction | `${string}-separator-${number}`;
accelerator?: string;
separator?: boolean;
role?: AppMenuRole;
}
export interface AppMenuGroupDefinition {
label: 'File' | 'Edit' | 'View' | 'Blog' | 'Help';
items: AppMenuItemDefinition[];
}
export const APP_MENU_GROUPS: AppMenuGroupDefinition[] = [
{
label: 'File',
items: [
{ label: 'New Post', action: 'newPost', accelerator: 'CmdOrCtrl+N' },
{ label: 'Import Media...', action: 'importMedia', accelerator: 'CmdOrCtrl+I' },
{ label: 'Save', action: 'save', accelerator: 'CmdOrCtrl+S' },
],
},
{
label: 'Edit',
items: [
{ label: 'Undo', action: 'undo', accelerator: 'CmdOrCtrl+Z', role: 'undo' },
{ label: 'Redo', action: 'redo', accelerator: 'CmdOrCtrl+Y', role: 'redo' },
{ label: '', action: 'edit-separator-1', separator: true },
{ label: 'Cut', action: 'cut', accelerator: 'CmdOrCtrl+X', role: 'cut' },
{ label: 'Copy', action: 'copy', accelerator: 'CmdOrCtrl+C', role: 'copy' },
{ label: 'Paste', action: 'paste', accelerator: 'CmdOrCtrl+V', role: 'paste' },
{ label: 'Delete', action: 'delete', role: 'delete' },
{ label: '', action: 'edit-separator-2', separator: true },
{ label: 'Select All', action: 'selectAll', accelerator: 'CmdOrCtrl+A', role: 'selectAll' },
{ label: '', action: 'edit-separator-3', separator: true },
{ label: 'Find', action: 'find', accelerator: 'CmdOrCtrl+F' },
{ label: 'Replace', action: 'replace', accelerator: 'CmdOrCtrl+H' },
],
},
{
label: 'View',
items: [
{ label: 'Posts', action: 'viewPosts', accelerator: 'CmdOrCtrl+1' },
{ label: 'Media', action: 'viewMedia', accelerator: 'CmdOrCtrl+2' },
{ label: 'Toggle Sidebar', action: 'toggleSidebar', accelerator: 'CmdOrCtrl+B' },
{ label: 'Toggle Panel', action: 'togglePanel', accelerator: 'CmdOrCtrl+J' },
{ label: 'Toggle Developer Tools', action: 'toggleDevTools', accelerator: 'CmdOrCtrl+Shift+I' },
],
},
{
label: 'Blog',
items: [
{ label: 'Publish Selected', action: 'publishSelected', accelerator: 'CmdOrCtrl+Shift+P' },
{ label: 'Rebuild Database from Files', action: 'rebuildDatabase' },
{ label: 'Reindex Search Text', action: 'reindexText' },
{ label: 'Metadata Diff Tool', action: 'metadataDiff' },
],
},
{
label: 'Help',
items: [
{ label: 'About Blogging Desktop Server', action: 'about' },
],
},
];
export const APP_MENU_ACTION_EVENT_MAP: Partial<Record<AppMenuAction, string>> = {
newPost: 'menu:newPost',
importMedia: 'menu:importMedia',
save: 'menu:save',
find: 'menu:find',
replace: 'menu:replace',
viewPosts: 'menu:viewPosts',
viewMedia: 'menu:viewMedia',
toggleSidebar: 'menu:toggleSidebar',
togglePanel: 'menu:togglePanel',
toggleDevTools: 'menu:toggleDevTools',
publishSelected: 'menu:publishSelected',
rebuildDatabase: 'menu:rebuildDatabase',
reindexText: 'menu:reindexText',
metadataDiff: 'menu:metadataDiff',
about: 'menu:about',
};
export const APP_MENU_WEB_CONTENTS_ACTIONS: ReadonlySet<AppMenuAction> = new Set([
'undo',
'redo',
'cut',
'copy',
'paste',
'delete',
'selectAll',
'toggleDevTools',
]);

View File

@@ -472,6 +472,10 @@ export const TabBar: React.FC = () => {
} }
}; };
if (tabs.length === 0) {
return null;
}
return ( return (
<div className="tab-bar"> <div className="tab-bar">
{showLeftArrow && ( {showLeftArrow && (

View File

@@ -1,4 +1,5 @@
.window-titlebar { .window-titlebar {
position: relative;
height: 34px; height: 34px;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -6,8 +7,96 @@
background-color: var(--vscode-editorGroupHeader-tabsBackground, #252526); background-color: var(--vscode-editorGroupHeader-tabsBackground, #252526);
border-bottom: 1px solid var(--vscode-editorGroupHeader-tabsBorder, #1e1e1e); border-bottom: 1px solid var(--vscode-editorGroupHeader-tabsBorder, #1e1e1e);
flex-shrink: 0; flex-shrink: 0;
app-region: drag;
-webkit-app-region: drag; -webkit-app-region: drag;
padding-right: 10px; padding-right: calc(10px + var(--bds-titlebar-overlay-right, 0px));
}
.window-titlebar-menu-bar {
display: flex;
align-items: center;
height: 100%;
margin-left: 6px;
gap: 2px;
app-region: no-drag;
-webkit-app-region: no-drag;
z-index: 2;
}
.window-titlebar-menu-button {
height: 24px;
border: none;
background: transparent;
color: var(--vscode-titleBar-activeForeground, var(--vscode-foreground, #cccccc));
padding: 0 8px;
border-radius: 4px;
font-size: 12px;
line-height: 1;
cursor: pointer;
}
.window-titlebar-menu-button:focus,
.window-titlebar-menu-button:focus-visible {
outline: none;
box-shadow: none;
}
.window-titlebar-menu-button:hover,
.window-titlebar-menu-button.is-active {
background-color: var(--vscode-toolbar-hoverBackground, rgba(90, 93, 94, 0.31));
}
.window-titlebar-menu-dropdown {
position: absolute;
top: 30px;
min-width: 210px;
padding: 6px;
display: flex;
flex-direction: column;
gap: 2px;
background-color: var(--vscode-menu-background, #252526);
border: 1px solid var(--vscode-menu-border, #454545);
border-radius: 6px;
box-shadow: var(--vscode-widget-shadow, 0 8px 24px rgba(0, 0, 0, 0.4));
app-region: no-drag;
-webkit-app-region: no-drag;
z-index: 10;
}
.window-titlebar-menu-item {
border: none;
background: transparent;
color: var(--vscode-menu-foreground, var(--vscode-foreground, #cccccc));
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
text-align: left;
border-radius: 4px;
padding: 6px 8px;
font-size: 12px;
cursor: pointer;
}
.window-titlebar-menu-item:focus,
.window-titlebar-menu-item:focus-visible {
outline: none;
box-shadow: none;
background-color: var(--vscode-toolbar-hoverBackground, rgba(90, 93, 94, 0.31));
}
.window-titlebar-menu-item:hover {
background-color: var(--vscode-menu-selectionBackground, rgba(9, 71, 113, 0.45));
}
.window-titlebar-menu-item-accelerator {
opacity: 0.8;
}
.window-titlebar-menu-separator {
height: 1px;
margin: 4px 2px;
background-color: var(--vscode-menu-separatorBackground, rgba(255, 255, 255, 0.08));
} }
.window-titlebar-drag-region { .window-titlebar-drag-region {
@@ -15,6 +104,25 @@
height: 100%; height: 100%;
} }
.window-titlebar-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
max-width: 45%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--vscode-titleBar-activeForeground, var(--vscode-foreground, #cccccc));
font-size: 12px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
user-select: none;
pointer-events: none;
}
.window-titlebar-actions { .window-titlebar-actions {
height: 100%; height: 100%;
display: flex; display: flex;
@@ -35,6 +143,7 @@
color: var(--vscode-foreground, #cccccc); color: var(--vscode-foreground, #cccccc);
cursor: pointer; cursor: pointer;
border-radius: 4px; border-radius: 4px;
app-region: no-drag;
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
} }

View File

@@ -1,13 +1,180 @@
import React from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { useAppStore } from '../../store'; import { useAppStore } from '../../store';
import { APP_MENU_GROUPS } from '../../../main/shared/menuCommands';
import './WindowTitleBar.css'; import './WindowTitleBar.css';
type WindowControlsOverlayLike = {
visible: boolean;
getTitlebarAreaRect: () => DOMRect;
addEventListener: (type: 'geometrychange', listener: EventListener) => void;
removeEventListener: (type: 'geometrychange', listener: EventListener) => void;
};
export const WindowTitleBar: React.FC = () => { export const WindowTitleBar: React.FC = () => {
const { sidebarVisible, toggleSidebar } = useAppStore(); const { sidebarVisible, toggleSidebar } = useAppStore();
const [windowTitle, setWindowTitle] = useState<string>(document.title || 'Blogging Desktop Server');
const [openMenu, setOpenMenu] = useState<{ label: string; left: number } | null>(null);
const menuRootRef = useRef<HTMLDivElement | null>(null);
const isMac = navigator.platform.toLowerCase().includes('mac');
const isDevMode = (window as Window & { __BDS_IS_DEV__?: boolean }).__BDS_IS_DEV__
?? (typeof import.meta !== 'undefined' && Boolean(import.meta.env?.DEV));
const visibleMenuGroups = APP_MENU_GROUPS.map((group) => {
if (group.label !== 'View') {
return group;
}
return {
...group,
items: group.items.filter(item => isDevMode || item.action !== 'toggleDevTools'),
};
});
useEffect(() => {
const rootStyle = document.documentElement.style;
const setInsets = (left: number, right: number) => {
rootStyle.setProperty('--bds-titlebar-overlay-left', `${left}px`);
rootStyle.setProperty('--bds-titlebar-overlay-right', `${right}px`);
};
const overlay = (navigator as Navigator & { windowControlsOverlay?: WindowControlsOverlayLike }).windowControlsOverlay;
if (!overlay) {
setInsets(0, 0);
return;
}
const syncOverlayInsets = () => {
if (!overlay.visible) {
setInsets(0, 0);
return;
}
const titlebarRect = overlay.getTitlebarAreaRect();
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || titlebarRect.right;
const leftInset = Math.max(0, Math.round(titlebarRect.left));
const rightInset = Math.max(0, Math.round(viewportWidth - titlebarRect.right));
setInsets(leftInset, rightInset);
};
const onGeometryChange: EventListener = () => {
syncOverlayInsets();
};
const onResize = () => {
syncOverlayInsets();
};
syncOverlayInsets();
overlay.addEventListener('geometrychange', onGeometryChange);
const canListenToResize = typeof window.addEventListener === 'function';
if (canListenToResize) {
window.addEventListener('resize', onResize);
}
return () => {
overlay.removeEventListener('geometrychange', onGeometryChange);
if (canListenToResize && typeof window.removeEventListener === 'function') {
window.removeEventListener('resize', onResize);
}
};
}, []);
useEffect(() => {
const updateTitle = () => {
setWindowTitle(document.title || 'Blogging Desktop Server');
};
updateTitle();
const titleElement = document.querySelector('title');
if (!titleElement) {
return;
}
const observer = new MutationObserver(() => {
updateTitle();
});
observer.observe(titleElement, { childList: true });
return () => {
observer.disconnect();
};
}, []);
useEffect(() => {
if (!openMenu) {
return;
}
const onDocumentMouseDown = (event: MouseEvent) => {
const target = event.target as Node;
if (menuRootRef.current && !menuRootRef.current.contains(target)) {
setOpenMenu(null);
}
};
const onEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setOpenMenu(null);
}
};
document.addEventListener('mousedown', onDocumentMouseDown);
document.addEventListener('keydown', onEscape);
return () => {
document.removeEventListener('mousedown', onDocumentMouseDown);
document.removeEventListener('keydown', onEscape);
};
}, [openMenu]);
const handleMenuButtonClick = (event: React.MouseEvent<HTMLButtonElement>, label: string) => {
const buttonRect = event.currentTarget.getBoundingClientRect();
const rootRect = menuRootRef.current?.getBoundingClientRect();
const left = rootRect ? buttonRect.left - rootRect.left : buttonRect.left;
if (openMenu?.label === label) {
setOpenMenu(null);
return;
}
setOpenMenu({ label, left });
};
const handleMenuItemClick = (action: string) => {
setOpenMenu(null);
void window.electronAPI?.app?.triggerMenuAction?.(action);
};
const formatAccelerator = (accelerator: string): string => {
const firstPass = accelerator
.replace(/CmdOrCtrl/g, isMac ? '⌘' : 'Ctrl')
.replace(/Alt/g, isMac ? '⌥' : 'Alt')
.replace(/Shift/g, isMac ? '⇧' : 'Shift');
return firstPass;
};
const activeMenu = openMenu ? visibleMenuGroups.find(group => group.label === openMenu.label) : null;
return ( return (
<div className="window-titlebar" data-testid="window-titlebar"> <div className="window-titlebar" data-testid="window-titlebar" ref={menuRootRef}>
<div className="window-titlebar-menu-bar" data-testid="window-titlebar-menu-bar">
{visibleMenuGroups.map(group => (
<button
key={group.label}
className={`window-titlebar-menu-button${openMenu?.label === group.label ? ' is-active' : ''}`}
type="button"
onClick={(event) => handleMenuButtonClick(event, group.label)}
aria-label={group.label}
>
{group.label}
</button>
))}
</div>
<div className="window-titlebar-drag-region" /> <div className="window-titlebar-drag-region" />
<div className="window-titlebar-title" data-testid="window-titlebar-title" title={windowTitle}>
{windowTitle}
</div>
<div className="window-titlebar-actions"> <div className="window-titlebar-actions">
<button <button
className="window-titlebar-action-button" className="window-titlebar-action-button"
@@ -20,6 +187,34 @@ export const WindowTitleBar: React.FC = () => {
</span> </span>
</button> </button>
</div> </div>
{openMenu && activeMenu && (
<div
className="window-titlebar-menu-dropdown"
data-testid="window-titlebar-menu-dropdown"
style={{ left: `${openMenu.left}px` }}
>
{activeMenu.items.map(item => {
if (item.separator) {
return <div key={item.action} className="window-titlebar-menu-separator" />;
}
const acceleratorText = item.accelerator ? formatAccelerator(item.accelerator) : null;
return (
<button
key={item.action}
type="button"
className="window-titlebar-menu-item"
onClick={() => handleMenuItemClick(item.action)}
aria-label={acceleratorText ? `${item.label} ${acceleratorText}` : item.label}
>
<span className="window-titlebar-menu-item-label">{item.label}</span>
{acceleratorText && <span className="window-titlebar-menu-item-accelerator">{acceleratorText}</span>}
</button>
);
})}
</div>
)}
</div> </div>
); );
}; };

View File

@@ -120,7 +120,7 @@ describe('main bootstrap preview behavior', () => {
symbolColor: '#cccccc', symbolColor: '#cccccc',
height: 34, height: 34,
}, },
autoHideMenuBar: true, autoHideMenuBar: false,
})); }));
Object.defineProperty(process, 'platform', { value: originalPlatform }); Object.defineProperty(process, 'platform', { value: originalPlatform });

View File

@@ -1339,6 +1339,39 @@ describe('IPC Handlers', () => {
expect(result).toBe('/Users/test/bds/project-1'); expect(result).toBe('/Users/test/bds/project-1');
}); });
}); });
describe('app:triggerMenuAction', () => {
it('should forward custom titlebar action to renderer menu channel', async () => {
const send = vi.fn();
const event = { sender: { send } };
await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'newPost');
expect(send).toHaveBeenCalledWith('menu:newPost');
});
it('should execute default edit actions on webContents sender', async () => {
const undo = vi.fn();
const send = vi.fn();
const event = { sender: { undo, send } };
await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'undo');
expect(undo).toHaveBeenCalled();
expect(send).not.toHaveBeenCalled();
});
it('should execute toggleDevTools on sender when action is toggleDevTools', async () => {
const toggleDevTools = vi.fn();
const send = vi.fn();
const event = { sender: { toggleDevTools, send } };
await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'toggleDevTools');
expect(toggleDevTools).toHaveBeenCalled();
expect(send).not.toHaveBeenCalled();
});
});
}); });
// ============ Error Handling ============ // ============ Error Handling ============

View File

@@ -78,4 +78,12 @@ describe('TabBar', () => {
expect(await screen.findByText('abc123d feat: improve commit diff tabs')).toBeInTheDocument(); expect(await screen.findByText('abc123d feat: improve commit diff tabs')).toBeInTheDocument();
expect((window as any).electronAPI.git.getHistory).toHaveBeenCalledWith('/repo/path', 200); expect((window as any).electronAPI.git.getHistory).toHaveBeenCalledWith('/repo/path', 200);
}); });
it('does not render the tab bar when there are no open tabs', () => {
useAppStore.setState({ tabs: [], activeTabId: null });
const { container } = render(<TabBar />);
expect(container.querySelector('.tab-bar')).toBeNull();
});
}); });

View File

@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
describe('WindowTitleBar styles', () => {
const cssPath = path.resolve(
__dirname,
'../../../src/renderer/components/WindowTitleBar/WindowTitleBar.css'
);
it('defines both standard and webkit drag regions for cross-platform support', () => {
const css = fs.readFileSync(cssPath, 'utf8');
expect(css).toMatch(/app-region:\s*drag;/);
expect(css).toMatch(/-webkit-app-region:\s*drag;/);
expect(css).toMatch(/app-region:\s*no-drag;/);
expect(css).toMatch(/-webkit-app-region:\s*no-drag;/);
});
it('reserves overlay control width to keep custom actions clickable on Windows/Linux', () => {
const css = fs.readFileSync(cssPath, 'utf8');
expect(css).toMatch(/padding-right:\s*calc\(10px\s*\+\s*var\(--bds-titlebar-overlay-right,\s*0px\)\)/);
});
it('uses neutral focus styling for menu buttons without blue accent outlines', () => {
const css = fs.readFileSync(cssPath, 'utf8');
expect(css).toMatch(/\.window-titlebar-menu-button:focus/);
expect(css).toMatch(/outline:\s*none;/);
expect(css).toMatch(/box-shadow:\s*none;/);
});
});

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import { WindowTitleBar } from '../../../src/renderer/components/WindowTitleBar/WindowTitleBar'; import { WindowTitleBar } from '../../../src/renderer/components/WindowTitleBar/WindowTitleBar';
import { useAppStore } from '../../../src/renderer/store'; import { useAppStore } from '../../../src/renderer/store';
@@ -36,4 +36,129 @@ describe('WindowTitleBar', () => {
expect(iconFrame).toHaveAttribute('data-shape', 'frame-square'); expect(iconFrame).toHaveAttribute('data-shape', 'frame-square');
expect(iconPane).toHaveAttribute('data-shape', 'left-half'); expect(iconPane).toHaveAttribute('data-shape', 'left-half');
}); });
it('updates overlay inset CSS variables when window controls geometry changes', () => {
const geometryListeners = new Set<EventListener>();
let rect = {
x: 0,
y: 0,
width: 924,
height: 34,
top: 0,
right: 924,
bottom: 34,
left: 0,
toJSON: () => '',
} as DOMRect;
const mockOverlay = {
visible: true,
getTitlebarAreaRect: () => rect,
addEventListener: (type: string, listener: EventListenerOrEventListenerObject) => {
if (type === 'geometrychange' && typeof listener === 'function') {
geometryListeners.add(listener);
}
},
removeEventListener: (type: string, listener: EventListenerOrEventListenerObject) => {
if (type === 'geometrychange' && typeof listener === 'function') {
geometryListeners.delete(listener);
}
},
};
Object.defineProperty(window, 'innerWidth', { value: 1024, configurable: true });
(navigator as Navigator & { windowControlsOverlay?: typeof mockOverlay }).windowControlsOverlay = mockOverlay;
render(<WindowTitleBar />);
expect(document.documentElement.style.getPropertyValue('--bds-titlebar-overlay-right')).toBe('100px');
expect(document.documentElement.style.getPropertyValue('--bds-titlebar-overlay-left')).toBe('0px');
rect = {
...rect,
width: 824,
right: 824,
} as DOMRect;
geometryListeners.forEach(listener => listener(new Event('geometrychange')));
expect(document.documentElement.style.getPropertyValue('--bds-titlebar-overlay-right')).toBe('200px');
});
it('renders the window title centered in the custom title bar', () => {
document.title = 'Blogging Desktop Server';
render(<WindowTitleBar />);
const title = screen.getByTestId('window-titlebar-title');
expect(title).toBeInTheDocument();
expect(title).toHaveTextContent('Blogging Desktop Server');
});
it('renders VS Code-style top menu labels in the title bar', () => {
render(<WindowTitleBar />);
expect(screen.getByRole('button', { name: 'File' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'View' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Blog' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Help' })).toBeInTheDocument();
});
it('dispatches menu action through electron API when menu item is clicked', () => {
const triggerMenuAction = vi.fn().mockResolvedValue(undefined);
window.electronAPI.app = {
...(window.electronAPI.app || {}),
triggerMenuAction,
};
render(<WindowTitleBar />);
fireEvent.click(screen.getByRole('button', { name: 'File' }));
fireEvent.click(screen.getByRole('button', { name: 'New Post Ctrl+N' }));
expect(triggerMenuAction).toHaveBeenCalledWith('newPost');
});
it('shows default edit actions with accelerators in Edit menu', () => {
render(<WindowTitleBar />);
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
expect(screen.getByRole('button', { name: 'Undo Ctrl+Z' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Redo Ctrl+Y' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Cut Ctrl+X' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Copy Ctrl+C' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Paste Ctrl+V' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Select All Ctrl+A' })).toBeInTheDocument();
});
it('shows assigned accelerators in View and Blog menus', () => {
render(<WindowTitleBar />);
fireEvent.click(screen.getByRole('button', { name: 'View' }));
expect(screen.getByRole('button', { name: 'Posts Ctrl+1' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Media Ctrl+2' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Toggle Sidebar Ctrl+B' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Toggle Panel Ctrl+J' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Blog' }));
expect(screen.getByRole('button', { name: 'Publish Selected Ctrl+Shift+P' })).toBeInTheDocument();
});
it('shows Toggle Developer Tools in View menu in development mode', () => {
(window as Window & { __BDS_IS_DEV__?: boolean }).__BDS_IS_DEV__ = true;
const triggerMenuAction = vi.fn().mockResolvedValue(undefined);
window.electronAPI.app = {
...(window.electronAPI.app || {}),
triggerMenuAction,
};
render(<WindowTitleBar />);
fireEvent.click(screen.getByRole('button', { name: 'View' }));
fireEvent.click(screen.getByRole('button', { name: 'Toggle Developer Tools Ctrl+Shift+I' }));
expect(triggerMenuAction).toHaveBeenCalledWith('toggleDevTools');
});
}); });

View File

@@ -124,6 +124,9 @@ Object.defineProperty(globalThis, 'window', {
cancel: vi.fn(), cancel: vi.fn(),
clearCompleted: vi.fn(), clearCompleted: vi.fn(),
}, },
app: {
triggerMenuAction: vi.fn(),
},
import: { import: {
selectAndAnalyze: vi.fn(), selectAndAnalyze: vi.fn(),
analyzeFile: vi.fn(), analyzeFile: vi.fn(),