Merge pull request #6 from rfc1437/copilot/unify-window-decorations

Unify compact window decorations on Windows/Linux to match integrated cross-platform title bar UX
This commit is contained in:
Georg Bauer
2026-02-17 11:43:09 +01:00
committed by GitHub
19 changed files with 1437 additions and 173 deletions

View File

@@ -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<void> {

View File

@@ -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 () => {

View File

@@ -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<void> {
}
function createApplicationMenu(): Menu {
const commandDefinitions = APP_MENU_GROUPS
.flatMap(group => group.items)
.filter(item => !item.separator)
.reduce<Record<string, AppMenuItemDefinition>>((acc, item) => {
acc[item.action] = item;
return acc;
}, {});
const triggerMenuAction = (action: AppMenuAction): void => {
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'),
},
];

View File

@@ -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)

View File

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

View File

@@ -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<Record<AppMenuAction, string>> = {
newPost: 'menu:newPost',
importMedia: 'menu:importMedia',
save: 'menu:save',
find: 'menu:find',
replace: 'menu:replace',
viewPosts: 'menu:viewPosts',
viewMedia: 'menu:viewMedia',
toggleSidebar: 'menu:toggleSidebar',
togglePanel: 'menu:togglePanel',
toggleDevTools: 'menu:toggleDevTools',
publishSelected: 'menu:publishSelected',
rebuildDatabase: 'menu:rebuildDatabase',
reindexText: 'menu:reindexText',
metadataDiff: 'menu:metadataDiff',
about: 'menu:about',
};
export const APP_MENU_WEB_CONTENTS_ACTIONS: ReadonlySet<AppMenuAction> = new Set([
'undo',
'redo',
'cut',
'copy',
'paste',
'delete',
'selectAll',
'toggleDevTools',
]);

View File

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

View File

@@ -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));
}

View File

@@ -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<string>(document.title || 'Blogging Desktop Server');
const [openMenu, setOpenMenu] = useState<{ label: string; left: number } | null>(null);
const [showMnemonics, setShowMnemonics] = useState<boolean>(false);
const [keyboardMenuItemIndex, setKeyboardMenuItemIndex] = useState<number | null>(null);
const menuRootRef = useRef<HTMLDivElement | null>(null);
const menuButtonRefs = useRef<Record<string, HTMLButtonElement | 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'),
};
});
const mnemonicByKey = useMemo(() => {
return visibleMenuGroups.reduce<Record<string, string>>((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<HTMLButtonElement>, 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<HTMLButtonElement>, 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 (
<>
<span className="window-titlebar-menu-mnemonic">{label.charAt(0)}</span>
{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 (
<div className="window-titlebar" data-testid="window-titlebar">
<div className="window-titlebar" data-testid="window-titlebar" ref={menuRootRef}>
<div className="window-titlebar-menu-bar" data-testid="window-titlebar-menu-bar">
{visibleMenuGroups.map(group => (
<button
key={group.label}
ref={(element) => {
menuButtonRefs.current[group.label] = element;
}}
className={`window-titlebar-menu-button${openMenu?.label === group.label ? ' is-active' : ''}`}
type="button"
onClick={(event) => handleMenuButtonClick(event, group.label)}
onMouseEnter={(event) => handleMenuButtonMouseEnter(event, group.label)}
aria-label={group.label}
>
{renderMenuLabel(group.label)}
</button>
))}
</div>
<div className="window-titlebar-drag-region" />
<div className="window-titlebar-title" data-testid="window-titlebar-title" title={windowTitle}>
{windowTitle}
</div>
<div className="window-titlebar-actions">
<button
className="window-titlebar-action-button"
@@ -19,7 +416,48 @@ export const WindowTitleBar: React.FC = () => {
<span className="window-titlebar-sidebar-pane" data-shape="left-half" />
</span>
</button>
<button
className="window-titlebar-action-button"
aria-label="Toggle Panel"
onClick={togglePanel}
title={`${panelVisible ? 'Hide' : 'Show'} Panel (Ctrl+J)`}
>
<span className="window-titlebar-panel-icon" data-shape="frame-square" aria-hidden="true">
<span className="window-titlebar-panel-pane" data-shape="bottom-half" />
</span>
</button>
</div>
{openMenu && activeMenu && (
<div
className="window-titlebar-menu-dropdown"
data-testid="window-titlebar-menu-dropdown"
style={{ left: `${openMenu.left}px` }}
>
{activeMenu.items.map((item) => {
if (item.separator) {
return <div key={item.action} className="window-titlebar-menu-separator" />;
}
const acceleratorText = item.accelerator ? formatAccelerator(item.accelerator) : null;
const actionableItems = activeMenu.items.filter(menuItem => !menuItem.separator);
const currentActionableIndex = actionableItems.findIndex(menuItem => menuItem.action === item.action);
const isKeyboardActive = keyboardMenuItemIndex !== null && keyboardMenuItemIndex === currentActionableIndex;
return (
<button
key={item.action}
type="button"
className={`window-titlebar-menu-item${isKeyboardActive ? ' is-keyboard-active' : ''}`}
onClick={() => handleMenuItemClick(item.action)}
aria-label={acceleratorText ? `${item.label} ${acceleratorText}` : item.label}
>
<span className="window-titlebar-menu-item-label">{item.label}</span>
{acceleratorText && <span className="window-titlebar-menu-item-accelerator">{acceleratorText}</span>}
</button>
);
})}
</div>
)}
</div>
);
};

View File

@@ -29,6 +29,7 @@ import { MediaEngine, MediaData } from '../../src/main/engine/MediaEngine';
const mockMedia = new Map<string, any>();
const mockPostMedia = new Map<string, any>();
const mockFiles = new Map<string, Buffer | string>();
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);
}
}),
}));

View File

@@ -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<string, string>();
@@ -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();
});
});

View File

@@ -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<string, any>();
@@ -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 () => {

View File

@@ -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',

View File

@@ -16,6 +16,9 @@ const registeredHandlers = new Map<string, (...args: any[]) => Promise<any>>();
// Mock ipcMain to capture handler registrations
vi.mock('electron', () => ({
app: {
quit: vi.fn(),
},
ipcMain: {
handle: vi.fn((channel: string, handler: (...args: any[]) => Promise<any>) => {
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 ============

View File

@@ -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<string, unknown>], 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);
});

View File

@@ -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(<TabBar />);
expect(container.querySelector('.tab-bar')).toBeNull();
});
});

View File

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

View File

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

View File

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