diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index f67c7fb..efd29e1 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -12,6 +12,7 @@ import { getGitEngine } from '../engine/GitEngine'; import { taskManager, TaskProgress } from '../engine/TaskManager'; import { getDatabase } from '../database'; 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 @@ -46,6 +47,45 @@ function resolvePostCreatedAt(post: { createdAt: Date | string }): Date { 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 { // ============ 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 ============ safeHandle('meta:getTags', async () => { diff --git a/src/main/main.ts b/src/main/main.ts index a6d38ac..083e68a 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -8,6 +8,7 @@ import { eq } from 'drizzle-orm'; import { getMediaEngine } from './engine/MediaEngine'; import { getPostEngine } from './engine/PostEngine'; 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 previewServer: PreviewServer | null = null; @@ -58,7 +59,7 @@ function createWindow(): void { symbolColor: '#cccccc', height: 34, }, - autoHideMenuBar: true, + autoHideMenuBar: false, }), webPreferences: { preload: path.join(__dirname, 'preload.js'), @@ -169,32 +170,67 @@ async function startPreviewServerOnAppStart(): Promise { } function createApplicationMenu(): Menu { + const commandDefinitions = APP_MENU_GROUPS + .flatMap(group => group.items) + .filter(item => !item.separator) + .reduce>((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[] = [ { label: 'File', submenu: [ - { - label: 'New Post', - accelerator: 'CmdOrCtrl+N', - click: () => { - mainWindow?.webContents.send('menu:newPost'); - }, - }, - { - label: 'Import Media...', - accelerator: 'CmdOrCtrl+I', - click: () => { - mainWindow?.webContents.send('menu:importMedia'); - }, - }, + buildSharedMenuItem('newPost'), + buildSharedMenuItem('importMedia'), { type: 'separator' }, - { - label: 'Save', - accelerator: 'CmdOrCtrl+S', - click: () => { - mainWindow?.webContents.send('menu:save'); - }, - }, + buildSharedMenuItem('save'), { type: 'separator' }, { label: 'Open in Browser', @@ -226,65 +262,12 @@ function createApplicationMenu(): Menu { }, { label: 'Edit', - submenu: [ - { 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'); - }, - }, - ], + submenu: buildSharedGroupMenuItems('Edit'), }, { label: 'View', submenu: [ - { - 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'); - }, - }, + ...buildSharedGroupMenuItems('View'), { type: 'separator' }, { role: 'reload' }, { role: 'forceReload' }, @@ -306,13 +289,7 @@ function createApplicationMenu(): Menu { { label: 'Blog', submenu: [ - { - label: 'Publish Selected', - accelerator: 'CmdOrCtrl+Shift+P', - click: () => { - mainWindow?.webContents.send('menu:publishSelected'); - }, - }, + buildSharedMenuItem('publishSelected'), { type: 'separator' }, { label: 'Preview Post', @@ -328,36 +305,16 @@ function createApplicationMenu(): Menu { }, }, { type: 'separator' }, - { - label: 'Rebuild Database from Files', - click: () => { - mainWindow?.webContents.send('menu:rebuildDatabase'); - }, - }, - { - label: 'Reindex Search Text', - click: () => { - mainWindow?.webContents.send('menu:reindexText'); - }, - }, + buildSharedMenuItem('rebuildDatabase'), + buildSharedMenuItem('reindexText'), { type: 'separator' }, - { - label: 'Metadata Diff Tool', - click: () => { - mainWindow?.webContents.send('menu:metadataDiff'); - }, - }, + buildSharedMenuItem('metadataDiff'), ], }, { label: 'Help', submenu: [ - { - label: 'About Blogging Desktop Server', - click: () => { - mainWindow?.webContents.send('menu:about'); - }, - }, + buildSharedMenuItem('about'), { type: 'separator' }, { label: 'View on GitHub', diff --git a/src/main/preload.ts b/src/main/preload.ts index 16dd948..c671082 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -142,6 +142,7 @@ export const electronAPI: ElectronAPI = { getDefaultProjectPath: (projectId: string) => ipcRenderer.invoke('app:getDefaultProjectPath', projectId), readProjectMetadata: (folderPath: string) => ipcRenderer.invoke('app:readProjectMetadata', folderPath), setPreviewPostTarget: (postId: string | null) => ipcRenderer.invoke('app:setPreviewPostTarget', postId), + triggerMenuAction: (action: string) => ipcRenderer.invoke('app:triggerMenuAction', action), }, // Meta (tags, categories, and project metadata) diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index 5f1fe37..e505cbb 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -510,6 +510,7 @@ export interface ElectronAPI { getDefaultProjectPath: (projectId: string) => Promise; readProjectMetadata: (folderPath: string) => Promise<{ name?: string; description?: string; mainLanguage?: string } | null>; setPreviewPostTarget: (postId: string | null) => Promise; + triggerMenuAction: (action: string) => Promise; }; meta: { getTags: () => Promise; diff --git a/src/main/shared/menuCommands.ts b/src/main/shared/menuCommands.ts new file mode 100644 index 0000000..e5a6bc7 --- /dev/null +++ b/src/main/shared/menuCommands.ts @@ -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> = { + 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 = new Set([ + 'undo', + 'redo', + 'cut', + 'copy', + 'paste', + 'delete', + 'selectAll', + 'toggleDevTools', +]); diff --git a/src/renderer/components/TabBar/TabBar.tsx b/src/renderer/components/TabBar/TabBar.tsx index e1663af..a34504c 100644 --- a/src/renderer/components/TabBar/TabBar.tsx +++ b/src/renderer/components/TabBar/TabBar.tsx @@ -472,6 +472,10 @@ export const TabBar: React.FC = () => { } }; + if (tabs.length === 0) { + return null; + } + return (
{showLeftArrow && ( diff --git a/src/renderer/components/WindowTitleBar/WindowTitleBar.css b/src/renderer/components/WindowTitleBar/WindowTitleBar.css index 34f4ed5..b0f4d99 100644 --- a/src/renderer/components/WindowTitleBar/WindowTitleBar.css +++ b/src/renderer/components/WindowTitleBar/WindowTitleBar.css @@ -1,4 +1,5 @@ .window-titlebar { + position: relative; height: 34px; display: flex; align-items: center; @@ -6,8 +7,96 @@ background-color: var(--vscode-editorGroupHeader-tabsBackground, #252526); border-bottom: 1px solid var(--vscode-editorGroupHeader-tabsBorder, #1e1e1e); flex-shrink: 0; + 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 { @@ -15,6 +104,25 @@ 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 { height: 100%; display: flex; @@ -35,6 +143,7 @@ color: var(--vscode-foreground, #cccccc); cursor: pointer; border-radius: 4px; + app-region: no-drag; -webkit-app-region: no-drag; } diff --git a/src/renderer/components/WindowTitleBar/WindowTitleBar.tsx b/src/renderer/components/WindowTitleBar/WindowTitleBar.tsx index 3f3f8a5..e98ad42 100644 --- a/src/renderer/components/WindowTitleBar/WindowTitleBar.tsx +++ b/src/renderer/components/WindowTitleBar/WindowTitleBar.tsx @@ -1,13 +1,180 @@ -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useAppStore } from '../../store'; +import { APP_MENU_GROUPS } from '../../../main/shared/menuCommands'; 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 = () => { const { sidebarVisible, toggleSidebar } = useAppStore(); + const [windowTitle, setWindowTitle] = useState(document.title || 'Blogging Desktop Server'); + const [openMenu, setOpenMenu] = useState<{ label: string; left: number } | null>(null); + const menuRootRef = useRef(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, 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 ( -
+
+
+ {visibleMenuGroups.map(group => ( + + ))} +
+
+ {windowTitle} +
+ {openMenu && activeMenu && ( +
+ {activeMenu.items.map(item => { + if (item.separator) { + return
; + } + + const acceleratorText = item.accelerator ? formatAccelerator(item.accelerator) : null; + + return ( + + ); + })} +
+ )}
); }; diff --git a/tests/engine/mainStartup.test.ts b/tests/engine/mainStartup.test.ts index 0d43342..f784c3c 100644 --- a/tests/engine/mainStartup.test.ts +++ b/tests/engine/mainStartup.test.ts @@ -120,7 +120,7 @@ describe('main bootstrap preview behavior', () => { symbolColor: '#cccccc', height: 34, }, - autoHideMenuBar: true, + autoHideMenuBar: false, })); Object.defineProperty(process, 'platform', { value: originalPlatform }); diff --git a/tests/ipc/handlers.test.ts b/tests/ipc/handlers.test.ts index c8950e7..bb55467 100644 --- a/tests/ipc/handlers.test.ts +++ b/tests/ipc/handlers.test.ts @@ -1339,6 +1339,39 @@ describe('IPC Handlers', () => { 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 ============ diff --git a/tests/renderer/components/TabBar.test.tsx b/tests/renderer/components/TabBar.test.tsx index 122d914..efa4a26 100644 --- a/tests/renderer/components/TabBar.test.tsx +++ b/tests/renderer/components/TabBar.test.tsx @@ -78,4 +78,12 @@ describe('TabBar', () => { expect(await screen.findByText('abc123d feat: improve commit diff tabs')).toBeInTheDocument(); 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(); + + expect(container.querySelector('.tab-bar')).toBeNull(); + }); }); diff --git a/tests/renderer/components/WindowTitleBar.styles.test.ts b/tests/renderer/components/WindowTitleBar.styles.test.ts new file mode 100644 index 0000000..fef3c65 --- /dev/null +++ b/tests/renderer/components/WindowTitleBar.styles.test.ts @@ -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;/); + }); +}); diff --git a/tests/renderer/components/WindowTitleBar.test.tsx b/tests/renderer/components/WindowTitleBar.test.tsx index a0d07bc..1d93ad8 100644 --- a/tests/renderer/components/WindowTitleBar.test.tsx +++ b/tests/renderer/components/WindowTitleBar.test.tsx @@ -1,5 +1,5 @@ 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 { WindowTitleBar } from '../../../src/renderer/components/WindowTitleBar/WindowTitleBar'; import { useAppStore } from '../../../src/renderer/store'; @@ -36,4 +36,129 @@ describe('WindowTitleBar', () => { expect(iconFrame).toHaveAttribute('data-shape', 'frame-square'); expect(iconPane).toHaveAttribute('data-shape', 'left-half'); }); + + it('updates overlay inset CSS variables when window controls geometry changes', () => { + const geometryListeners = new Set(); + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + fireEvent.click(screen.getByRole('button', { name: 'View' })); + fireEvent.click(screen.getByRole('button', { name: 'Toggle Developer Tools Ctrl+Shift+I' })); + + expect(triggerMenuAction).toHaveBeenCalledWith('toggleDevTools'); + }); }); diff --git a/tests/setup.ts b/tests/setup.ts index c442868..edbda9f 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -124,6 +124,9 @@ Object.defineProperty(globalThis, 'window', { cancel: vi.fn(), clearCompleted: vi.fn(), }, + app: { + triggerMenuAction: vi.fn(), + }, import: { selectAndAnalyze: vi.fn(), analyzeFile: vi.fn(),