diff --git a/src/main/engine/MediaEngine.ts b/src/main/engine/MediaEngine.ts index c709d06..0a213ae 100644 --- a/src/main/engine/MediaEngine.ts +++ b/src/main/engine/MediaEngine.ts @@ -960,7 +960,8 @@ export class MediaEngine extends EventEmitter { const dbMedia = await db.select().from(media).where(eq(media.id, id)).get(); if (!dbMedia?.filePath) return null; const dataDir = this.getDataDir(); - return path.relative(dataDir, dbMedia.filePath); + const relativePath = path.relative(dataDir, dbMedia.filePath); + return relativePath.replace(/\\/g, '/'); } async rebuildDatabaseFromFiles(): Promise { diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index f67c7fb..62886da 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -1,4 +1,4 @@ -import { ipcMain, dialog, shell } from 'electron'; +import { app, ipcMain, dialog, shell } from 'electron'; import * as path from 'path'; import * as fsPromises from 'fs/promises'; import { eq } from 'drizzle-orm'; @@ -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,36 @@ export function registerIpcHandlers(): void { } }); + safeHandle('app:triggerMenuAction', async (event, action: string) => { + const typedAction = action as AppMenuAction; + + if (typedAction === 'quit') { + app.quit(); + return; + } + + if (typedAction === 'viewOnGitHub') { + await shell.openExternal('https://github.com/rfc1437/bDS'); + return; + } + + if (typedAction === 'reportIssue') { + await shell.openExternal('https://github.com/rfc1437/bDS/issues'); + return; + } + + 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 e326b9e..f5dd29e 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; @@ -41,6 +42,7 @@ protocol.registerSchemesAsPrivileged([ ]); function createWindow(): void { + const isMac = process.platform === 'darwin'; mainWindow = new BrowserWindow({ width: 1400, height: 900, @@ -48,7 +50,17 @@ function createWindow(): void { minHeight: 600, title: 'Blogging Desktop Server', backgroundColor: '#1e1e1e', // VS Code dark background - titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', + titleBarStyle: isMac ? 'hiddenInset' : 'hidden', + ...(isMac + ? {} + : { + titleBarOverlay: { + color: '#252526', + symbolColor: '#cccccc', + height: 34, + }, + autoHideMenuBar: false, + }), webPreferences: { preload: path.join(__dirname, 'preload.js'), nodeIntegration: false, @@ -158,32 +170,82 @@ 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 => { + if (action === 'quit') { + app.quit(); + return; + } + + if (action === 'viewOnGitHub') { + void shell.openExternal('https://github.com/rfc1437/bDS'); + return; + } + + if (action === 'reportIssue') { + void shell.openExternal('https://github.com/rfc1437/bDS/issues'); + return; + } + + 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', @@ -204,76 +266,17 @@ function createApplicationMenu(): Menu { }, }, { type: 'separator' }, - { - label: 'Exit', - accelerator: process.platform === 'darwin' ? 'Cmd+Q' : 'Alt+F4', - click: () => { - app.quit(); - }, - }, + buildSharedMenuItem('quit'), ], }, { 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' }, @@ -295,13 +298,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', @@ -317,50 +314,15 @@ 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'); - }, - }, - { type: 'separator' }, - { - label: 'View on GitHub', - click: async () => { - await shell.openExternal('https://github.com/rfc1437/bDS'); - }, - }, - { - label: 'Report Issue', - click: async () => { - await shell.openExternal('https://github.com/rfc1437/bDS/issues'); - }, - }, - ], + submenu: buildSharedGroupMenuItems('Help'), }, ]; 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..ec05d22 --- /dev/null +++ b/src/main/shared/menuCommands.ts @@ -0,0 +1,128 @@ +export type AppMenuAction = + | 'newPost' + | 'importMedia' + | 'save' + | 'quit' + | 'undo' + | 'redo' + | 'cut' + | 'copy' + | 'paste' + | 'delete' + | 'selectAll' + | 'find' + | 'replace' + | 'viewPosts' + | 'viewMedia' + | 'toggleSidebar' + | 'togglePanel' + | 'toggleDevTools' + | 'publishSelected' + | 'rebuildDatabase' + | 'reindexText' + | 'metadataDiff' + | 'about' + | 'viewOnGitHub' + | 'reportIssue'; + +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: '', action: 'file-separator-1', separator: true }, + { label: 'Quit', action: 'quit', accelerator: 'CmdOrCtrl+Q' }, + ], + }, + { + 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' }, + { label: '', action: 'help-separator-1', separator: true }, + { label: 'View on GitHub', action: 'viewOnGitHub' }, + { label: 'Report Issue', action: 'reportIssue' }, + ], + }, +]; + +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..a855078 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,105 @@ 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-mnemonic { + text-decoration: underline; + text-underline-offset: 2px; +} + +.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.is-keyboard-active { + 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 +113,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 +152,7 @@ color: var(--vscode-foreground, #cccccc); cursor: pointer; border-radius: 4px; + app-region: no-drag; -webkit-app-region: no-drag; } @@ -63,6 +181,25 @@ background-color: currentColor; } +.window-titlebar-panel-icon { + width: 14px; + height: 14px; + border: 1.5px solid currentColor; + border-radius: 2px; + display: block; + position: relative; + overflow: hidden; +} + +.window-titlebar-panel-pane { + position: absolute; + left: 0; + bottom: 0; + width: 100%; + height: 33.3333%; + background-color: currentColor; +} + .window-titlebar-action-button:hover { background-color: var(--vscode-toolbar-hoverBackground, rgba(90, 93, 94, 0.31)); } diff --git a/src/renderer/components/WindowTitleBar/WindowTitleBar.tsx b/src/renderer/components/WindowTitleBar/WindowTitleBar.tsx index 3f3f8a5..bbe21a1 100644 --- a/src/renderer/components/WindowTitleBar/WindowTitleBar.tsx +++ b/src/renderer/components/WindowTitleBar/WindowTitleBar.tsx @@ -1,13 +1,410 @@ -import React from 'react'; +import React, { useEffect, useMemo, 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 { sidebarVisible, panelVisible, toggleSidebar, togglePanel } = useAppStore(); + const [windowTitle, setWindowTitle] = useState(document.title || 'Blogging Desktop Server'); + const [openMenu, setOpenMenu] = useState<{ label: string; left: number } | null>(null); + const [showMnemonics, setShowMnemonics] = useState(false); + const [keyboardMenuItemIndex, setKeyboardMenuItemIndex] = useState(null); + const menuRootRef = useRef(null); + const menuButtonRefs = useRef>({}); + 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'), + }; + }); + + const mnemonicByKey = useMemo(() => { + return visibleMenuGroups.reduce>((acc, group) => { + const mnemonicKey = group.label.charAt(0).toLowerCase(); + acc[mnemonicKey] = group.label; + return acc; + }, {}); + }, [visibleMenuGroups]); + + const getMenuLeft = (label: string): number | null => { + const button = menuButtonRefs.current[label]; + if (!button) { + return null; + } + + const buttonRect = button.getBoundingClientRect(); + const rootRect = menuRootRef.current?.getBoundingClientRect(); + return rootRect ? buttonRect.left - rootRect.left : buttonRect.left; + }; + + const openMenuByLabel = (label: string) => { + const left = getMenuLeft(label); + if (left === null) { + return; + } + setKeyboardMenuItemIndex(null); + setOpenMenu({ label, left }); + }; + + const getMenuActionableItems = (label: string) => { + const group = visibleMenuGroups.find(item => item.label === label); + if (!group) { + return []; + } + + return group.items.filter(item => !item.separator); + }; + + const switchOpenMenuByOffset = (offset: number) => { + if (!openMenu) { + return; + } + + const currentIndex = visibleMenuGroups.findIndex(group => group.label === openMenu.label); + if (currentIndex < 0) { + return; + } + + const nextIndex = (currentIndex + offset + visibleMenuGroups.length) % visibleMenuGroups.length; + openMenuByLabel(visibleMenuGroups[nextIndex].label); + }; + + 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); + setShowMnemonics(false); + } + }; + + const onEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setOpenMenu(null); + setKeyboardMenuItemIndex(null); + setShowMnemonics(false); + return; + } + + if (event.key === 'ArrowRight') { + event.preventDefault(); + switchOpenMenuByOffset(1); + return; + } + + if (event.key === 'ArrowLeft') { + event.preventDefault(); + switchOpenMenuByOffset(-1); + return; + } + + if (!openMenu) { + return; + } + + const actionableItems = getMenuActionableItems(openMenu.label); + if (actionableItems.length === 0) { + return; + } + + if (event.key === 'ArrowDown') { + event.preventDefault(); + setKeyboardMenuItemIndex((previous) => { + if (previous === null) { + return 0; + } + return (previous + 1) % actionableItems.length; + }); + return; + } + + if (event.key === 'ArrowUp') { + event.preventDefault(); + setKeyboardMenuItemIndex((previous) => { + if (previous === null) { + return actionableItems.length - 1; + } + return (previous - 1 + actionableItems.length) % actionableItems.length; + }); + return; + } + + if ((event.key === 'Enter' || event.key === ' ') && keyboardMenuItemIndex !== null) { + event.preventDefault(); + const targetItem = actionableItems[keyboardMenuItemIndex]; + if (targetItem) { + void window.electronAPI?.app?.triggerMenuAction?.(targetItem.action); + setOpenMenu(null); + setKeyboardMenuItemIndex(null); + setShowMnemonics(false); + } + return; + } + + if (event.key === 'Home') { + event.preventDefault(); + setKeyboardMenuItemIndex(0); + return; + } + + if (event.key === 'End') { + event.preventDefault(); + setKeyboardMenuItemIndex(actionableItems.length - 1); + return; + } + + if (event.key.length === 1 && !event.altKey && !event.shiftKey) { + const typed = event.key.toLowerCase(); + const matchingIndices = actionableItems + .map((item, index) => ({ item, index })) + .filter(entry => entry.item.label.toLowerCase().startsWith(typed)) + .map(entry => entry.index); + + if (matchingIndices.length === 0) { + return; + } + + event.preventDefault(); + if (keyboardMenuItemIndex === null) { + setKeyboardMenuItemIndex(matchingIndices[0]); + return; + } + + const currentMatchPosition = matchingIndices.findIndex(index => index === keyboardMenuItemIndex); + if (currentMatchPosition >= 0) { + const nextPosition = (currentMatchPosition + 1) % matchingIndices.length; + setKeyboardMenuItemIndex(matchingIndices[nextPosition]); + return; + } + + const firstAfterCurrent = matchingIndices.find(index => index > keyboardMenuItemIndex); + setKeyboardMenuItemIndex(firstAfterCurrent ?? matchingIndices[0]); + } + }; + + document.addEventListener('mousedown', onDocumentMouseDown); + document.addEventListener('keydown', onEscape); + + return () => { + document.removeEventListener('mousedown', onDocumentMouseDown); + document.removeEventListener('keydown', onEscape); + }; + }, [keyboardMenuItemIndex, openMenu, visibleMenuGroups]); + + useEffect(() => { + setKeyboardMenuItemIndex(null); + }, [openMenu?.label]); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.metaKey || event.ctrlKey) { + return; + } + + if (event.key === 'Alt' && !event.shiftKey) { + setShowMnemonics(true); + return; + } + + if (event.altKey && event.key.length === 1) { + const targetMenuLabel = mnemonicByKey[event.key.toLowerCase()]; + if (targetMenuLabel) { + event.preventDefault(); + setShowMnemonics(true); + openMenuByLabel(targetMenuLabel); + } + return; + } + + if (showMnemonics && event.key !== 'Shift') { + setShowMnemonics(false); + } + }; + + const onDocumentMouseDown = () => { + if (showMnemonics) { + setShowMnemonics(false); + } + }; + + document.addEventListener('keydown', onKeyDown); + document.addEventListener('mousedown', onDocumentMouseDown); + + return () => { + document.removeEventListener('keydown', onKeyDown); + document.removeEventListener('mousedown', onDocumentMouseDown); + }; + }, [mnemonicByKey, showMnemonics]); + + const handleMenuButtonClick = (event: React.MouseEvent, label: string) => { + const left = getMenuLeft(label); + if (left === null) { + return; + } + + if (openMenu?.label === label) { + setOpenMenu(null); + setKeyboardMenuItemIndex(null); + return; + } + + setOpenMenu({ label, left }); + }; + + const handleMenuButtonMouseEnter = (event: React.MouseEvent, label: string) => { + if (!openMenu || openMenu.label === label) { + return; + } + + const buttonRect = event.currentTarget.getBoundingClientRect(); + const rootRect = menuRootRef.current?.getBoundingClientRect(); + const left = rootRect ? buttonRect.left - rootRect.left : buttonRect.left; + setOpenMenu({ label, left }); + }; + + const handleMenuItemClick = (action: string) => { + setOpenMenu(null); + setKeyboardMenuItemIndex(null); + setShowMnemonics(false); + void window.electronAPI?.app?.triggerMenuAction?.(action); + }; + + const renderMenuLabel = (label: string) => { + if (!showMnemonics || label.length === 0) { + return label; + } + + return ( + <> + {label.charAt(0)} + {label.slice(1)} + + ); + }; + + 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; + const actionableItems = activeMenu.items.filter(menuItem => !menuItem.separator); + const currentActionableIndex = actionableItems.findIndex(menuItem => menuItem.action === item.action); + const isKeyboardActive = keyboardMenuItemIndex !== null && keyboardMenuItemIndex === currentActionableIndex; + + return ( + + ); + })} +
+ )}
); }; diff --git a/tests/engine/MediaEngine.test.ts b/tests/engine/MediaEngine.test.ts index 73ce026..e662d7c 100644 --- a/tests/engine/MediaEngine.test.ts +++ b/tests/engine/MediaEngine.test.ts @@ -29,6 +29,7 @@ import { MediaEngine, MediaData } from '../../src/main/engine/MediaEngine'; const mockMedia = new Map(); const mockPostMedia = new Map(); const mockFiles = new Map(); +const normalizePath = (value: string): string => value.replace(/\\/g, '/'); // Track database operations for testing let mediaDeleteCalled = false; @@ -126,7 +127,8 @@ vi.mock('../../src/main/database', () => ({ // Mock fs/promises vi.mock('fs/promises', () => ({ readFile: vi.fn(async (path: string) => { - const content = mockFiles.get(path); + const normalizedPath = normalizePath(path); + const content = mockFiles.get(normalizedPath); if (!content) { const error = new Error(`ENOENT: no such file or directory, open '${path}'`); (error as any).code = 'ENOENT'; @@ -135,29 +137,29 @@ vi.mock('fs/promises', () => ({ return content; }), writeFile: vi.fn(async (path: string, content: Buffer | string) => { - mockFiles.set(path, content); + mockFiles.set(normalizePath(path), content); }), unlink: vi.fn(async (path: string) => { - mockFiles.delete(path); + mockFiles.delete(normalizePath(path)); }), mkdir: vi.fn(async () => {}), readdir: vi.fn(async () => []), stat: vi.fn(async (path: string) => ({ - isFile: () => mockFiles.has(path), - isDirectory: () => !mockFiles.has(path), - size: mockFiles.get(path)?.length || 0, + isFile: () => mockFiles.has(normalizePath(path)), + isDirectory: () => !mockFiles.has(normalizePath(path)), + size: mockFiles.get(normalizePath(path))?.length || 0, })), access: vi.fn(async (path: string) => { - if (!mockFiles.has(path)) { + if (!mockFiles.has(normalizePath(path))) { const error = new Error(`ENOENT`); (error as any).code = 'ENOENT'; throw error; } }), copyFile: vi.fn(async (src: string, dest: string) => { - const content = mockFiles.get(src); + const content = mockFiles.get(normalizePath(src)); if (content) { - mockFiles.set(dest, content); + mockFiles.set(normalizePath(dest), content); } }), })); diff --git a/tests/engine/MetaEngine.test.ts b/tests/engine/MetaEngine.test.ts index 012042e..28653c5 100644 --- a/tests/engine/MetaEngine.test.ts +++ b/tests/engine/MetaEngine.test.ts @@ -10,6 +10,7 @@ */ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import * as path from 'path'; // Mock data stores const mockFiles = new Map(); @@ -749,17 +750,20 @@ describe('MetaEngine', () => { }); it('should use custom dataDir when provided in setProjectContext', () => { - metaEngine.setProjectContext('project-with-custom-dir', '/custom/data/path'); + const customDataDir = path.join('custom', 'data', 'path'); + metaEngine.setProjectContext('project-with-custom-dir', customDataDir); const metaDir = metaEngine.getMetaDir(); - expect(metaDir).toContain('/custom/data/path'); + expect(normalizePath(metaDir)).toContain(normalizePath(customDataDir)); }); it('should sync dataPath from database to project.json if different', async () => { const metaDir = metaEngine.getMetaDir(); + const oldPath = path.join('old', 'path', 'from', 'file'); + const newPath = path.join('new', 'path', 'from', 'database'); mockFiles.set(normalizePath(`${metaDir}/project.json`), JSON.stringify({ name: 'Project', - dataPath: '/old/path/from/file', + dataPath: oldPath, })); // Database has the currently selected (authoritative) path @@ -767,7 +771,7 @@ describe('MetaEngine', () => { id: 'test-project', name: 'Project', description: null, - dataPath: '/new/path/from/database', + dataPath: newPath, slug: 'project', createdAt: new Date(), updatedAt: new Date(), @@ -779,7 +783,7 @@ describe('MetaEngine', () => { const savedProjectJson = mockFiles.get(normalizePath(`${metaDir}/project.json`)); expect(savedProjectJson).toBeDefined(); const parsed = JSON.parse(savedProjectJson!); - expect(parsed.dataPath).toBe('/new/path/from/database'); + expect(normalizePath(parsed.dataPath)).toBe(normalizePath(newPath)); expect(mockLocalDb.update).not.toHaveBeenCalled(); }); }); diff --git a/tests/engine/ProjectEngine.test.ts b/tests/engine/ProjectEngine.test.ts index 6cd0294..240e783 100644 --- a/tests/engine/ProjectEngine.test.ts +++ b/tests/engine/ProjectEngine.test.ts @@ -6,9 +6,12 @@ */ import { describe, it, expect, beforeEach, vi } from 'vitest'; +import * as path from 'path'; import { ProjectEngine, ProjectData } from '../../src/main/engine/ProjectEngine'; import { resetMockCounters } from '../utils/factories'; +const normalizePath = (value: string): string => value.replace(/\\/g, '/'); + // Create mock data stores const mockProjects = new Map(); @@ -550,7 +553,7 @@ describe('ProjectEngine', () => { describe('Custom dataPath', () => { it('should create project with custom dataPath', async () => { - const customPath = '/Users/test/Documents/MyBlog'; + const customPath = path.join('Users', 'test', 'Documents', 'MyBlog'); const project = await projectEngine.createProject({ name: 'Custom Path Project', dataPath: customPath, @@ -561,7 +564,8 @@ describe('ProjectEngine', () => { it('should create meta and thumbnails directories in custom dataPath', async () => { const fs = await import('fs/promises'); - const customPath = '/Users/test/Documents/MyBlog'; + const customPath = path.join('Users', 'test', 'Documents', 'MyBlog'); + const normalizedCustomPath = normalizePath(customPath); await projectEngine.createProject({ name: 'Custom Dirs Project', @@ -569,17 +573,18 @@ describe('ProjectEngine', () => { }); const mkdirCalls = vi.mocked(fs.mkdir).mock.calls; - const createdPaths = mkdirCalls.map(call => call[0]); + const createdPaths = mkdirCalls.map(call => normalizePath(String(call[0]))); // Should create meta/ and thumbnails/ in custom dataPath - expect(createdPaths).toContainEqual(expect.stringContaining(customPath)); - expect(createdPaths.some(p => String(p).includes(customPath) && String(p).includes('meta'))).toBe(true); - expect(createdPaths.some(p => String(p).includes(customPath) && String(p).includes('thumbnails'))).toBe(true); + expect(createdPaths).toContainEqual(expect.stringContaining(normalizedCustomPath)); + expect(createdPaths.some(p => p.includes(normalizedCustomPath) && p.includes('meta'))).toBe(true); + expect(createdPaths.some(p => p.includes(normalizedCustomPath) && p.includes('thumbnails'))).toBe(true); }); it('should create posts and media directories in custom dataPath', async () => { const fs = await import('fs/promises'); - const customPath = '/Users/test/Documents/MyBlog'; + const customPath = path.join('Users', 'test', 'Documents', 'MyBlog'); + const normalizedCustomPath = normalizePath(customPath); await projectEngine.createProject({ name: 'Custom Data Project', @@ -587,11 +592,11 @@ describe('ProjectEngine', () => { }); const mkdirCalls = vi.mocked(fs.mkdir).mock.calls; - const createdPaths = mkdirCalls.map(call => call[0]); + const createdPaths = mkdirCalls.map(call => normalizePath(String(call[0]))); // Should create posts/ and media/ in custom dataPath - expect(createdPaths.some(p => String(p).includes(customPath) && String(p).includes('posts'))).toBe(true); - expect(createdPaths.some(p => String(p).includes(customPath) && String(p).includes('media'))).toBe(true); + expect(createdPaths.some(p => p.includes(normalizedCustomPath) && p.includes('posts'))).toBe(true); + expect(createdPaths.some(p => p.includes(normalizedCustomPath) && p.includes('media'))).toBe(true); }); it('should create meta and thumbnails in internal storage when no dataPath', async () => { @@ -611,7 +616,7 @@ describe('ProjectEngine', () => { it('should use getDataDir with custom dataPath', () => { const projectId = 'test-id'; - const customPath = '/Users/test/MyBlog'; + const customPath = path.join('Users', 'test', 'MyBlog'); const dataDir = projectEngine.getDataDir(projectId, customPath); @@ -826,7 +831,7 @@ describe('ProjectEngine', () => { name: 'My Project', slug: 'my-project', description: 'A test project', - dataPath: '/custom/path', + dataPath: path.join('custom', 'path'), createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-06-01'), isActive: true, @@ -848,7 +853,7 @@ describe('ProjectEngine', () => { expect(result?.name).toBe('My Project'); expect(result?.slug).toBe('my-project'); expect(result?.description).toBe('A test project'); - expect(result?.dataPath).toBe('/custom/path'); + expect(result?.dataPath).toBe(path.join('custom', 'path')); expect(result?.isActive).toBe(true); }); @@ -1181,7 +1186,7 @@ describe('ProjectEngine', () => { id: 'resolved-project', name: 'Resolved Project', slug: 'resolved', - dataPath: '/custom/data/path', + dataPath: path.join('custom', 'data', 'path'), createdAt: new Date(), updatedAt: new Date(), isActive: false, @@ -1198,8 +1203,9 @@ describe('ProjectEngine', () => { const paths = await projectEngine.getProjectPathsResolved('resolved-project'); - expect(paths.posts).toContain('/custom/data/path'); - expect(paths.media).toContain('/custom/data/path'); + const normalizedBasePath = normalizePath(projectWithPath.dataPath); + expect(normalizePath(paths.posts)).toContain(normalizedBasePath); + expect(normalizePath(paths.media)).toContain(normalizedBasePath); }); it('should use internal path when project has no dataPath', async () => { diff --git a/tests/engine/mainStartup.test.ts b/tests/engine/mainStartup.test.ts index 59f4a9e..f784c3c 100644 --- a/tests/engine/mainStartup.test.ts +++ b/tests/engine/mainStartup.test.ts @@ -6,6 +6,126 @@ describe('main bootstrap preview behavior', () => { vi.resetModules(); }); + it.each(['win32', 'linux'])('uses compact unified window decorations on %s', async (platform) => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: platform }); + + const mockApp = { + name: 'bDS', + whenReady: vi.fn(() => Promise.resolve()), + on: vi.fn(), + quit: vi.fn(), + }; + + const browserWindowCalls: any[] = []; + class BrowserWindowMock { + static getAllWindows = vi.fn(() => [{ id: 1 }]); + + loadURL = vi.fn(); + loadFile = vi.fn(); + on = vi.fn(); + isDestroyed = vi.fn(() => false); + webContents = { + on: vi.fn(), + send: vi.fn(), + openDevTools: vi.fn(), + toggleDevTools: vi.fn(), + }; + + constructor(options: any) { + browserWindowCalls.push(options); + } + } + + vi.doMock('electron', () => ({ + app: mockApp, + BrowserWindow: BrowserWindowMock, + Menu: { + buildFromTemplate: vi.fn(() => ({})), + setApplicationMenu: vi.fn(), + }, + ipcMain: { + on: vi.fn(), + handle: vi.fn(), + removeHandler: vi.fn(), + }, + protocol: { + registerSchemesAsPrivileged: vi.fn(), + handle: vi.fn(), + }, + net: { + fetch: vi.fn(), + }, + shell: { + openExternal: vi.fn(), + openPath: vi.fn(), + }, + })); + + class MockPreviewServer { + start = vi.fn().mockResolvedValue(4123); + stop = vi.fn().mockResolvedValue(undefined); + getBaseUrl = vi.fn(() => 'http://127.0.0.1:4123'); + } + + vi.doMock('../../src/main/engine/PreviewServer', () => ({ + PreviewServer: MockPreviewServer, + })); + + vi.doMock('../../src/main/database', () => ({ + getDatabase: vi.fn(() => ({ + initializeLocal: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + getLocal: vi.fn(() => ({ + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + get: vi.fn().mockResolvedValue(null), + })), + })), + })), + })), + getDataPaths: vi.fn(() => ({ database: '/tmp/mock.db' })), + })), + })); + + vi.doMock('../../src/main/ipc', () => ({ + registerIpcHandlers: vi.fn(), + registerChatHandlers: vi.fn(), + initializeChatHandlers: vi.fn(), + cleanupChatHandlers: vi.fn().mockResolvedValue(undefined), + })); + + vi.doMock('../../src/main/database/schema', () => ({ + media: {}, + })); + + vi.doMock('drizzle-orm', () => ({ + eq: vi.fn(), + })); + + vi.doMock('../../src/main/engine/MediaEngine', () => ({ + getMediaEngine: vi.fn(() => ({ + getThumbnailPaths: vi.fn().mockResolvedValue({ small: null }), + })), + })); + + await import('../../src/main/main'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(browserWindowCalls[0]).toEqual(expect.objectContaining({ + titleBarStyle: 'hidden', + titleBarOverlay: { + color: '#252526', + symbolColor: '#cccccc', + height: 34, + }, + autoHideMenuBar: false, + })); + + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + it('starts preview server during app startup', async () => { const mockApp = { name: 'bDS', diff --git a/tests/ipc/handlers.test.ts b/tests/ipc/handlers.test.ts index c8950e7..6d70b3b 100644 --- a/tests/ipc/handlers.test.ts +++ b/tests/ipc/handlers.test.ts @@ -16,6 +16,9 @@ const registeredHandlers = new Map Promise>(); // Mock ipcMain to capture handler registrations vi.mock('electron', () => ({ + app: { + quit: vi.fn(), + }, ipcMain: { handle: vi.fn((channel: string, handler: (...args: any[]) => Promise) => { registeredHandlers.set(channel, handler); @@ -27,6 +30,7 @@ vi.mock('electron', () => ({ }, shell: { openPath: vi.fn(), + openExternal: vi.fn(), showItemInFolder: vi.fn(), }, })); @@ -1339,6 +1343,61 @@ 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(); + }); + + it('should quit the application when action is quit', async () => { + const { app } = await import('electron'); + const send = vi.fn(); + const event = { sender: { send } }; + + await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'quit'); + + expect(app.quit).toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); + }); + + it('should open repository URL when action is viewOnGitHub', async () => { + const { shell } = await import('electron'); + const send = vi.fn(); + const event = { sender: { send } }; + + await invokeHandlerWithEvent(event, 'app:triggerMenuAction', 'viewOnGitHub'); + + expect(shell.openExternal).toHaveBeenCalledWith('https://github.com/rfc1437/bDS'); + expect(send).not.toHaveBeenCalled(); + }); + }); }); // ============ Error Handling ============ diff --git a/tests/renderer/components/MilkdownMarkdownRoundTrip.test.ts b/tests/renderer/components/MilkdownMarkdownRoundTrip.test.ts index cb70ed1..f4256b0 100644 --- a/tests/renderer/components/MilkdownMarkdownRoundTrip.test.ts +++ b/tests/renderer/components/MilkdownMarkdownRoundTrip.test.ts @@ -11,6 +11,7 @@ import { visit } from 'unist-util-visit'; import { normalizeMilkdownMarkdown } from '../../../src/renderer/utils/markdownEscape'; const wxrRefDir = path.join(__dirname, '../../assets/wxr-ref'); +const normalizeLineEndingsToLf = (value: string): string => value.replace(/\r\n/g, '\n'); const remarkTightListsPlugin: Plugin<[Record], Root> = () => { return (tree: Root) => { @@ -42,13 +43,14 @@ describe('Milkdown markdown round trip', () => { const files = fs.readdirSync(wxrRefDir).filter((file) => file.endsWith('.md')); for (const file of files) { - const raw = fs.readFileSync(path.join(wxrRefDir, file), 'utf-8'); + const raw = normalizeLineEndingsToLf(fs.readFileSync(path.join(wxrRefDir, file), 'utf-8')); const { content } = matter(raw); + const normalizedContent = normalizeLineEndingsToLf(content); const editor = await Editor.make() .config((ctx) => { ctx.set(rootCtx, root); - ctx.set(defaultValueCtx, content); + ctx.set(defaultValueCtx, normalizedContent); ctx.set(remarkStringifyOptionsCtx, { bullet: '-', listItemIndent: 'one', @@ -59,16 +61,16 @@ describe('Milkdown markdown round trip', () => { .use(gfm) .create(); - const serialized = editor.action((ctx) => { + const serialized = normalizeLineEndingsToLf(editor.action((ctx) => { const parser = ctx.get(parserCtx); const serializer = ctx.get(serializerCtx); - const doc = parser(content); + const doc = parser(normalizedContent); return normalizeMilkdownMarkdown(serializer(doc)); - }); + })); await editor.destroy(); - expect(serialized, `round trip mismatch for ${file}`).toBe(content); + expect(serialized, `round trip mismatch for ${file}`).toBe(normalizedContent); } }, 30000); }); 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..408f200 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'; @@ -8,6 +8,7 @@ describe('WindowTitleBar', () => { beforeEach(() => { useAppStore.setState({ sidebarVisible: true, + panelVisible: false, }); }); @@ -36,4 +37,288 @@ describe('WindowTitleBar', () => { expect(iconFrame).toHaveAttribute('data-shape', 'frame-square'); expect(iconPane).toHaveAttribute('data-shape', 'left-half'); }); + + it('renders a right-side panel toggle button and toggles panel visibility', () => { + render(); + + const toggleButton = screen.getByLabelText('Toggle Panel'); + expect(toggleButton).toBeInTheDocument(); + expect(toggleButton).toHaveAttribute('title', 'Show Panel (Ctrl+J)'); + + fireEvent.click(toggleButton); + + expect(useAppStore.getState().panelVisible).toBe(true); + expect(toggleButton).toHaveAttribute('title', 'Hide Panel (Ctrl+J)'); + }); + + it('uses a VS Code-like panel toggle icon shape', () => { + render(); + + const toggleButton = screen.getByLabelText('Toggle Panel'); + const iconFrame = toggleButton.querySelector('.window-titlebar-panel-icon'); + const iconPane = toggleButton.querySelector('.window-titlebar-panel-pane'); + + expect(iconFrame).not.toBeNull(); + expect(iconPane).not.toBeNull(); + expect(iconFrame).toHaveAttribute('data-shape', 'frame-square'); + expect(iconPane).toHaveAttribute('data-shape', 'bottom-half'); + }); + + it('places panel toggle to the right of sidebar toggle', () => { + render(); + + const actionButtons = Array.from(document.querySelectorAll('.window-titlebar-actions .window-titlebar-action-button')); + + expect(actionButtons).toHaveLength(2); + expect(actionButtons[0]).toHaveAttribute('aria-label', 'Toggle Sidebar'); + expect(actionButtons[1]).toHaveAttribute('aria-label', 'Toggle Panel'); + }); + + 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 Quit in File menu and View on GitHub in Help menu', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'File' })); + expect(screen.getByRole('button', { name: 'Quit Ctrl+Q' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Help' })); + expect(screen.getByRole('button', { name: 'View on GitHub' })).toBeInTheDocument(); + }); + + it('switches to another menu on hover when a menu is already open', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'File' })); + expect(screen.getByRole('button', { name: 'New Post Ctrl+N' })).toBeInTheDocument(); + + fireEvent.mouseEnter(screen.getByRole('button', { name: 'Edit' })); + + expect(screen.getByRole('button', { name: 'Undo Ctrl+Z' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'New Post Ctrl+N' })).toBeNull(); + }); + + it('shows menu mnemonics when Alt is pressed', () => { + const { container } = render(); + + expect(container.querySelector('.window-titlebar-menu-mnemonic')).toBeNull(); + + fireEvent.keyDown(document, { key: 'Alt' }); + + expect(container.querySelector('.window-titlebar-menu-mnemonic')).not.toBeNull(); + }); + + it('opens File menu when Alt+F is pressed', () => { + render(); + + fireEvent.keyDown(document, { key: 'f', altKey: true }); + + expect(screen.getByRole('button', { name: 'New Post Ctrl+N' })).toBeInTheDocument(); + }); + + it('navigates menu items with arrow keys and activates selection with Enter', () => { + const triggerMenuAction = vi.fn().mockResolvedValue(undefined); + window.electronAPI.app = { + ...(window.electronAPI.app || {}), + triggerMenuAction, + }; + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'File' })); + fireEvent.keyDown(document, { key: 'ArrowDown' }); + fireEvent.keyDown(document, { key: 'Enter' }); + + expect(triggerMenuAction).toHaveBeenCalledWith('newPost'); + }); + + it('switches open menu with ArrowRight and ArrowLeft', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'File' })); + expect(screen.getByRole('button', { name: 'New Post Ctrl+N' })).toBeInTheDocument(); + + fireEvent.keyDown(document, { key: 'ArrowRight' }); + expect(screen.getByRole('button', { name: 'Undo Ctrl+Z' })).toBeInTheDocument(); + + fireEvent.keyDown(document, { key: 'ArrowLeft' }); + expect(screen.getByRole('button', { name: 'New Post Ctrl+N' })).toBeInTheDocument(); + }); + + it('jumps to first and last menu item with Home and End', () => { + const triggerMenuAction = vi.fn().mockResolvedValue(undefined); + window.electronAPI.app = { + ...(window.electronAPI.app || {}), + triggerMenuAction, + }; + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'File' })); + + fireEvent.keyDown(document, { key: 'End' }); + fireEvent.keyDown(document, { key: 'Enter' }); + expect(triggerMenuAction).toHaveBeenCalledWith('quit'); + + fireEvent.click(screen.getByRole('button', { name: 'File' })); + fireEvent.keyDown(document, { key: 'Home' }); + fireEvent.keyDown(document, { key: 'Enter' }); + expect(triggerMenuAction).toHaveBeenCalledWith('newPost'); + }); + + it('jumps to matching item by first letter key and activates with Enter', () => { + const triggerMenuAction = vi.fn().mockResolvedValue(undefined); + window.electronAPI.app = { + ...(window.electronAPI.app || {}), + triggerMenuAction, + }; + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'File' })); + fireEvent.keyDown(document, { key: 'i' }); + fireEvent.keyDown(document, { key: 'Enter' }); + + expect(triggerMenuAction).toHaveBeenCalledWith('importMedia'); + }); + + it('cycles through same-letter matches on repeated key presses', () => { + const triggerMenuAction = vi.fn().mockResolvedValue(undefined); + window.electronAPI.app = { + ...(window.electronAPI.app || {}), + triggerMenuAction, + }; + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + fireEvent.keyDown(document, { key: 'r' }); + fireEvent.keyDown(document, { key: 'r' }); + fireEvent.keyDown(document, { key: 'Enter' }); + + expect(triggerMenuAction).toHaveBeenCalledWith('replace'); + }); + + 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(),