diff --git a/src/main/main.ts b/src/main/main.ts index acea793..45f1a29 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -19,6 +19,8 @@ let activePreviewPostId: string | null = null; let appInitialized = false; let blogmarkQueue: string[] = []; let blogmarkQueueProcessing = false; +let pendingBlogmarkCreatedEvents: unknown[] = []; +let rendererReady = false; const PREVIEW_SERVER_PORT = 4123; const BLOG_PREVIEW_POST_MENU_ID = APP_MENU_ITEM_IDS.previewPost; const BLOGMARK_PROTOCOL = 'bds'; @@ -201,6 +203,7 @@ protocol.registerSchemesAsPrivileged([ ]); function createWindow(): void { + rendererReady = false; const isMac = process.platform === 'darwin'; const initialWindowState = resolveInitialWindowState(); mainWindow = new BrowserWindow({ @@ -376,8 +379,22 @@ async function processBlogmarkDeepLink(rawDeepLink: string): Promise { categories: preferredCategory ? [preferredCategory] : [], }); - if (mainWindow && !mainWindow.isDestroyed()) { + if (mainWindow && !mainWindow.isDestroyed() && rendererReady) { mainWindow.webContents.send('blogmark:created', createdPost); + } else { + pendingBlogmarkCreatedEvents.push(createdPost); + } +} + +function flushPendingBlogmarkCreatedEvents(): void { + if (!rendererReady || !mainWindow || mainWindow.isDestroyed() || pendingBlogmarkCreatedEvents.length === 0) { + return; + } + + const queuedEvents = pendingBlogmarkCreatedEvents; + pendingBlogmarkCreatedEvents = []; + for (const payload of queuedEvents) { + mainWindow.webContents.send('blogmark:created', payload); } } @@ -708,6 +725,12 @@ async function initialize(): Promise { activePreviewPostId = typeof postId === 'string' && postId.length > 0 ? postId : null; setPreviewPostMenuEnabled(Boolean(activePreviewPostId)); }); + + ipcMain.handle('app:rendererReady', async () => { + rendererReady = true; + flushPendingBlogmarkCreatedEvents(); + return true; + }); // Initialize and register chat handlers initializeChatHandlers(() => mainWindow); diff --git a/src/main/preload.ts b/src/main/preload.ts index a25652f..cdc1ca0 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -147,6 +147,7 @@ export const electronAPI: ElectronAPI = { readProjectMetadata: (folderPath: string) => ipcRenderer.invoke('app:readProjectMetadata', folderPath), getBlogmarkBookmarklet: () => ipcRenderer.invoke('app:getBlogmarkBookmarklet'), copyToClipboard: (text: string) => ipcRenderer.invoke('app:copyToClipboard', text), + notifyRendererReady: () => ipcRenderer.invoke('app:rendererReady'), setPreviewPostTarget: (postId: string | null) => ipcRenderer.invoke('app:setPreviewPostTarget', postId), triggerMenuAction: (action: string) => ipcRenderer.invoke('app:triggerMenuAction', action), }, diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index ddba335..d00652f 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -567,6 +567,7 @@ export interface ElectronAPI { readProjectMetadata: (folderPath: string) => Promise<{ name?: string; description?: string; publicUrl?: string; mainLanguage?: string } | null>; getBlogmarkBookmarklet: () => Promise; copyToClipboard: (text: string) => Promise; + notifyRendererReady: () => Promise; setPreviewPostTarget: (postId: string | null) => Promise; triggerMenuAction: (action: string) => Promise; }; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index fb3ef5a..ca526d8 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,10 +1,12 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useRef } from 'react'; import { ActivityBar, Sidebar, Editor, StatusBar, Panel, TabBar, ToastContainer, showToast, ResizablePanel, WindowTitleBar } from './components'; import { useAppStore, PostData, MediaData, TaskProgress } from './store'; import { loadTabsForProject, saveTabsForProject } from './utils'; -import { openEntityTab, openSingletonToolTab } from './navigation/tabPolicy'; +import { openSingletonToolTab } from './navigation/tabPolicy'; import { persistSiteValidationReport } from './navigation/siteValidationPersistence'; import { executeActivityClick } from './navigation/activityExecution'; +import { handleBlogmarkCreatedEvent } from './navigation/blogmarkHandling'; +import { createDeferredEventGate } from './navigation/deferredEventGate'; import { createAndFocusPost } from './navigation/postCreation'; import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from './utils/picoTheme'; import { useI18n } from './i18n'; @@ -33,6 +35,25 @@ const App: React.FC = () => { openTab, restoreTabState, } = useAppStore(); + const blogmarkEventGateRef = useRef(createDeferredEventGate()); + + const processBlogmarkCreated = (created: PostData) => { + addPost(created); + const state = useAppStore.getState(); + handleBlogmarkCreatedEvent( + { + activeView: state.activeView, + sidebarVisible: state.sidebarVisible, + }, + created, + { + setActiveView: state.setActiveView, + toggleSidebar: state.toggleSidebar, + setSelectedPost: state.setSelectedPost, + openTab: state.openTab, + }, + ); + }; // Load initial data useEffect(() => { @@ -80,6 +101,9 @@ const App: React.FC = () => { console.error('Failed to load initial data:', error); } finally { setLoading(false); + setTimeout(() => { + blogmarkEventGateRef.current.markReady(processBlogmarkCreated); + }, 0); } }; @@ -216,28 +240,12 @@ const App: React.FC = () => { unsubscribers.push( window.electronAPI?.on('blogmark:created', (post: unknown) => { - const created = post as { id?: string } | null; + const created = post as PostData; if (!created?.id) { return; } - const state = useAppStore.getState(); - executeActivityClick( - { - activeView: state.activeView, - sidebarVisible: state.sidebarVisible, - tabs: state.tabs, - activeTabId: state.activeTabId, - }, - 'posts', - { - setActiveView: state.setActiveView, - toggleSidebar: state.toggleSidebar, - }, - ); - - state.setSelectedPost(created.id); - openEntityTab(state.openTab, 'post', created.id, 'preview'); + blogmarkEventGateRef.current.push(created, processBlogmarkCreated); }) || (() => {}) ); @@ -475,6 +483,10 @@ const App: React.FC = () => { }) || (() => {}) ); + void window.electronAPI?.app.notifyRendererReady?.().catch((error) => { + console.error('Failed to notify renderer readiness:', error); + }); + return () => { unsubscribers.forEach(unsub => unsub()); }; diff --git a/src/renderer/navigation/blogmarkHandling.ts b/src/renderer/navigation/blogmarkHandling.ts new file mode 100644 index 0000000..83faa3d --- /dev/null +++ b/src/renderer/navigation/blogmarkHandling.ts @@ -0,0 +1,40 @@ +import { openEntityTab } from './tabPolicy'; +import type { SidebarView } from './sidebarViewRegistry'; + +interface BlogmarkStateSnapshot { + activeView: SidebarView; + sidebarVisible: boolean; +} + +interface BlogmarkCreatedPayload { + id?: string; +} + +interface BlogmarkHandlers { + setActiveView: (view: SidebarView) => void; + toggleSidebar: () => void; + setSelectedPost: (id: string) => void; + openTab: (tab: { type: 'post'; id: string; isTransient: boolean }) => void; +} + +export function handleBlogmarkCreatedEvent( + snapshot: BlogmarkStateSnapshot, + payload: BlogmarkCreatedPayload | null | undefined, + handlers: BlogmarkHandlers, +): void { + const postId = typeof payload?.id === 'string' ? payload.id : ''; + if (!postId) { + return; + } + + if (snapshot.activeView !== 'posts') { + handlers.setActiveView('posts'); + } + + if (!snapshot.sidebarVisible) { + handlers.toggleSidebar(); + } + + handlers.setSelectedPost(postId); + openEntityTab(handlers.openTab, 'post', postId, 'preview'); +} diff --git a/src/renderer/navigation/deferredEventGate.ts b/src/renderer/navigation/deferredEventGate.ts new file mode 100644 index 0000000..205a93b --- /dev/null +++ b/src/renderer/navigation/deferredEventGate.ts @@ -0,0 +1,35 @@ +export interface DeferredEventGate { + push: (event: T, consume: (event: T) => void) => void; + markReady: (consume: (event: T) => void) => void; + isReady: () => boolean; +} + +export function createDeferredEventGate(): DeferredEventGate { + let ready = false; + let queue: T[] = []; + + return { + push: (event, consume) => { + if (ready) { + consume(event); + return; + } + + queue.push(event); + }, + markReady: (consume) => { + if (ready) { + return; + } + + ready = true; + const queuedEvents = queue; + queue = []; + + for (const event of queuedEvents) { + consume(event); + } + }, + isReady: () => ready, + }; +} diff --git a/tests/engine/mainStartup.test.ts b/tests/engine/mainStartup.test.ts index d73fd29..e494e65 100644 --- a/tests/engine/mainStartup.test.ts +++ b/tests/engine/mainStartup.test.ts @@ -650,6 +650,7 @@ describe('main bootstrap preview behavior', () => { it('handles bds deep-link by creating a blogmark post with preferred category', async () => { const listeners = new Map void>(); + const ipcHandlers = new Map any>(); const mockApp = { name: 'bDS', whenReady: vi.fn(() => Promise.resolve()), @@ -691,7 +692,9 @@ describe('main bootstrap preview behavior', () => { }, ipcMain: { on: vi.fn(), - handle: vi.fn(), + handle: vi.fn((channel: string, handler: (...args: any[]) => any) => { + ipcHandlers.set(channel, handler); + }), removeHandler: vi.fn(), }, protocol: { @@ -794,9 +797,181 @@ describe('main bootstrap preview behavior', () => { }), ); + const rendererReadyHandler = ipcHandlers.get('app:rendererReady'); + await rendererReadyHandler?.(); + expect(windows[0]?.webContents.send).toHaveBeenCalledWith( 'blogmark:created', expect.objectContaining({ id: 'new-post-id' }), ); }); + + it('queues blogmark created event until renderer has finished loading', async () => { + const listeners = new Map void>(); + const webContentsListeners = new Map void>(); + const ipcHandlers = new Map any>(); + const mockApp = { + name: 'bDS', + whenReady: vi.fn(() => Promise.resolve()), + on: vi.fn((event: string, callback: (...args: any[]) => void) => { + listeners.set(event, callback); + }), + quit: vi.fn(), + requestSingleInstanceLock: vi.fn(() => true), + setAsDefaultProtocolClient: vi.fn(() => true), + }; + + const windows: Array<{ webContents: { send: ReturnType } }> = []; + let rendererLoading = true; + + class MockBrowserWindow { + static getAllWindows = vi.fn(() => windows as any); + + loadURL = vi.fn(); + loadFile = vi.fn(); + on = vi.fn(); + isDestroyed = vi.fn(() => false); + webContents = { + on: vi.fn((event: string, callback: (...args: any[]) => void) => { + webContentsListeners.set(event, callback); + }), + isLoadingMainFrame: vi.fn(() => rendererLoading), + send: vi.fn(), + openDevTools: vi.fn(), + toggleDevTools: vi.fn(), + }; + + constructor() { + windows.push(this as any); + } + } + + vi.doMock('electron', () => ({ + app: mockApp, + BrowserWindow: MockBrowserWindow, + Menu: { + buildFromTemplate: vi.fn(() => ({})), + setApplicationMenu: vi.fn(), + }, + ipcMain: { + on: vi.fn(), + handle: vi.fn((channel: string, handler: (...args: any[]) => any) => { + ipcHandlers.set(channel, handler); + }), + 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'); + } + + const createPost = vi.fn().mockResolvedValue({ + id: 'queued-post-id', + title: 'Queued title', + content: '[Queued title]()', + categories: ['article'], + }); + + vi.doMock('../../src/main/engine/PreviewServer', () => ({ + PreviewServer: MockPreviewServer, + })); + + vi.doMock('../../src/main/engine/PostEngine', () => ({ + getPostEngine: vi.fn(() => ({ + getPost: vi.fn().mockResolvedValue(null), + createPost, + })), + })); + + vi.doMock('../../src/main/engine/MetaEngine', () => ({ + getMetaEngine: vi.fn(() => ({ + getProjectMetadata: vi.fn().mockResolvedValue({ blogmarkCategory: 'article' }), + })), + })); + + 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)); + + const openUrl = listeners.get('open-url'); + expect(openUrl).toBeTruthy(); + + const preventDefault = vi.fn(); + openUrl?.({ preventDefault } as any, 'bds://new-post?title=Queued%20title&url=https%3A%2F%2Fexample.com%2F'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(createPost).toHaveBeenCalledTimes(1); + expect(windows[0]?.webContents.send).not.toHaveBeenCalledWith( + 'blogmark:created', + expect.anything(), + ); + + rendererLoading = false; + const didFinishLoad = webContentsListeners.get('did-finish-load'); + didFinishLoad?.(); + + expect(windows[0]?.webContents.send).not.toHaveBeenCalledWith( + 'blogmark:created', + expect.anything(), + ); + + const rendererReadyHandler = ipcHandlers.get('app:rendererReady'); + await rendererReadyHandler?.(); + + expect(windows[0]?.webContents.send).toHaveBeenCalledWith( + 'blogmark:created', + expect.objectContaining({ id: 'queued-post-id' }), + ); + }); }); diff --git a/tests/renderer/navigation/blogmarkHandling.test.ts b/tests/renderer/navigation/blogmarkHandling.test.ts new file mode 100644 index 0000000..3131779 --- /dev/null +++ b/tests/renderer/navigation/blogmarkHandling.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest'; +import { handleBlogmarkCreatedEvent } from '../../../src/renderer/navigation/blogmarkHandling'; + +describe('handleBlogmarkCreatedEvent', () => { + it('does not collapse sidebar when already in posts view', () => { + const setActiveView = vi.fn(); + const toggleSidebar = vi.fn(); + const setSelectedPost = vi.fn(); + const openTab = vi.fn(); + + handleBlogmarkCreatedEvent( + { + activeView: 'posts', + sidebarVisible: true, + }, + { + id: 'post-1', + }, + { + setActiveView, + toggleSidebar, + setSelectedPost, + openTab, + }, + ); + + expect(setActiveView).not.toHaveBeenCalled(); + expect(toggleSidebar).not.toHaveBeenCalled(); + expect(setSelectedPost).toHaveBeenCalledWith('post-1'); + expect(openTab).toHaveBeenCalledTimes(1); + }); + + it('switches to posts view and opens sidebar when needed', () => { + const setActiveView = vi.fn(); + const toggleSidebar = vi.fn(); + const setSelectedPost = vi.fn(); + const openTab = vi.fn(); + + handleBlogmarkCreatedEvent( + { + activeView: 'media', + sidebarVisible: false, + }, + { + id: 'post-2', + }, + { + setActiveView, + toggleSidebar, + setSelectedPost, + openTab, + }, + ); + + expect(setActiveView).toHaveBeenCalledWith('posts'); + expect(toggleSidebar).toHaveBeenCalledTimes(1); + expect(setSelectedPost).toHaveBeenCalledWith('post-2'); + expect(openTab).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/renderer/navigation/deferredEventGate.test.ts b/tests/renderer/navigation/deferredEventGate.test.ts new file mode 100644 index 0000000..01f9def --- /dev/null +++ b/tests/renderer/navigation/deferredEventGate.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createDeferredEventGate } from '../../../src/renderer/navigation/deferredEventGate'; + +describe('createDeferredEventGate', () => { + it('queues events until marked ready', () => { + const gate = createDeferredEventGate(); + const consume = vi.fn(); + + gate.push('first', consume); + gate.push('second', consume); + + expect(consume).not.toHaveBeenCalled(); + + gate.markReady(consume); + + expect(consume).toHaveBeenCalledTimes(2); + expect(consume).toHaveBeenNthCalledWith(1, 'first'); + expect(consume).toHaveBeenNthCalledWith(2, 'second'); + }); + + it('consumes immediately after ready', () => { + const gate = createDeferredEventGate(); + const consume = vi.fn(); + + gate.markReady(consume); + gate.push('now', consume); + + expect(consume).toHaveBeenCalledTimes(1); + expect(consume).toHaveBeenCalledWith('now'); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index 78ac7a5..88b5601 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -130,6 +130,7 @@ Object.defineProperty(globalThis, 'window', { triggerMenuAction: vi.fn(), getBlogmarkBookmarklet: vi.fn(), copyToClipboard: vi.fn(), + notifyRendererReady: vi.fn(), }, import: { selectAndAnalyze: vi.fn(),