fix: proper opening of the mac app on bookmarklet
This commit is contained in:
@@ -19,6 +19,8 @@ let activePreviewPostId: string | null = null;
|
|||||||
let appInitialized = false;
|
let appInitialized = false;
|
||||||
let blogmarkQueue: string[] = [];
|
let blogmarkQueue: string[] = [];
|
||||||
let blogmarkQueueProcessing = false;
|
let blogmarkQueueProcessing = false;
|
||||||
|
let pendingBlogmarkCreatedEvents: unknown[] = [];
|
||||||
|
let rendererReady = false;
|
||||||
const PREVIEW_SERVER_PORT = 4123;
|
const PREVIEW_SERVER_PORT = 4123;
|
||||||
const BLOG_PREVIEW_POST_MENU_ID = APP_MENU_ITEM_IDS.previewPost;
|
const BLOG_PREVIEW_POST_MENU_ID = APP_MENU_ITEM_IDS.previewPost;
|
||||||
const BLOGMARK_PROTOCOL = 'bds';
|
const BLOGMARK_PROTOCOL = 'bds';
|
||||||
@@ -201,6 +203,7 @@ protocol.registerSchemesAsPrivileged([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
function createWindow(): void {
|
function createWindow(): void {
|
||||||
|
rendererReady = false;
|
||||||
const isMac = process.platform === 'darwin';
|
const isMac = process.platform === 'darwin';
|
||||||
const initialWindowState = resolveInitialWindowState();
|
const initialWindowState = resolveInitialWindowState();
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
@@ -376,8 +379,22 @@ async function processBlogmarkDeepLink(rawDeepLink: string): Promise<void> {
|
|||||||
categories: preferredCategory ? [preferredCategory] : [],
|
categories: preferredCategory ? [preferredCategory] : [],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed() && rendererReady) {
|
||||||
mainWindow.webContents.send('blogmark:created', createdPost);
|
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<void> {
|
|||||||
activePreviewPostId = typeof postId === 'string' && postId.length > 0 ? postId : null;
|
activePreviewPostId = typeof postId === 'string' && postId.length > 0 ? postId : null;
|
||||||
setPreviewPostMenuEnabled(Boolean(activePreviewPostId));
|
setPreviewPostMenuEnabled(Boolean(activePreviewPostId));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('app:rendererReady', async () => {
|
||||||
|
rendererReady = true;
|
||||||
|
flushPendingBlogmarkCreatedEvents();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
// Initialize and register chat handlers
|
// Initialize and register chat handlers
|
||||||
initializeChatHandlers(() => mainWindow);
|
initializeChatHandlers(() => mainWindow);
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ export const electronAPI: ElectronAPI = {
|
|||||||
readProjectMetadata: (folderPath: string) => ipcRenderer.invoke('app:readProjectMetadata', folderPath),
|
readProjectMetadata: (folderPath: string) => ipcRenderer.invoke('app:readProjectMetadata', folderPath),
|
||||||
getBlogmarkBookmarklet: () => ipcRenderer.invoke('app:getBlogmarkBookmarklet'),
|
getBlogmarkBookmarklet: () => ipcRenderer.invoke('app:getBlogmarkBookmarklet'),
|
||||||
copyToClipboard: (text: string) => ipcRenderer.invoke('app:copyToClipboard', text),
|
copyToClipboard: (text: string) => ipcRenderer.invoke('app:copyToClipboard', text),
|
||||||
|
notifyRendererReady: () => ipcRenderer.invoke('app:rendererReady'),
|
||||||
setPreviewPostTarget: (postId: string | null) => ipcRenderer.invoke('app:setPreviewPostTarget', postId),
|
setPreviewPostTarget: (postId: string | null) => ipcRenderer.invoke('app:setPreviewPostTarget', postId),
|
||||||
triggerMenuAction: (action: string) => ipcRenderer.invoke('app:triggerMenuAction', action),
|
triggerMenuAction: (action: string) => ipcRenderer.invoke('app:triggerMenuAction', action),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -567,6 +567,7 @@ export interface ElectronAPI {
|
|||||||
readProjectMetadata: (folderPath: string) => Promise<{ name?: string; description?: string; publicUrl?: string; mainLanguage?: string } | null>;
|
readProjectMetadata: (folderPath: string) => Promise<{ name?: string; description?: string; publicUrl?: string; mainLanguage?: string } | null>;
|
||||||
getBlogmarkBookmarklet: () => Promise<string>;
|
getBlogmarkBookmarklet: () => Promise<string>;
|
||||||
copyToClipboard: (text: string) => Promise<boolean>;
|
copyToClipboard: (text: string) => Promise<boolean>;
|
||||||
|
notifyRendererReady: () => Promise<boolean>;
|
||||||
setPreviewPostTarget: (postId: string | null) => Promise<void>;
|
setPreviewPostTarget: (postId: string | null) => Promise<void>;
|
||||||
triggerMenuAction: (action: string) => Promise<void>;
|
triggerMenuAction: (action: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 { ActivityBar, Sidebar, Editor, StatusBar, Panel, TabBar, ToastContainer, showToast, ResizablePanel, WindowTitleBar } from './components';
|
||||||
import { useAppStore, PostData, MediaData, TaskProgress } from './store';
|
import { useAppStore, PostData, MediaData, TaskProgress } from './store';
|
||||||
import { loadTabsForProject, saveTabsForProject } from './utils';
|
import { loadTabsForProject, saveTabsForProject } from './utils';
|
||||||
import { openEntityTab, openSingletonToolTab } from './navigation/tabPolicy';
|
import { openSingletonToolTab } from './navigation/tabPolicy';
|
||||||
import { persistSiteValidationReport } from './navigation/siteValidationPersistence';
|
import { persistSiteValidationReport } from './navigation/siteValidationPersistence';
|
||||||
import { executeActivityClick } from './navigation/activityExecution';
|
import { executeActivityClick } from './navigation/activityExecution';
|
||||||
|
import { handleBlogmarkCreatedEvent } from './navigation/blogmarkHandling';
|
||||||
|
import { createDeferredEventGate } from './navigation/deferredEventGate';
|
||||||
import { createAndFocusPost } from './navigation/postCreation';
|
import { createAndFocusPost } from './navigation/postCreation';
|
||||||
import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from './utils/picoTheme';
|
import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from './utils/picoTheme';
|
||||||
import { useI18n } from './i18n';
|
import { useI18n } from './i18n';
|
||||||
@@ -33,6 +35,25 @@ const App: React.FC = () => {
|
|||||||
openTab,
|
openTab,
|
||||||
restoreTabState,
|
restoreTabState,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
const blogmarkEventGateRef = useRef(createDeferredEventGate<PostData>());
|
||||||
|
|
||||||
|
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
|
// Load initial data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -80,6 +101,9 @@ const App: React.FC = () => {
|
|||||||
console.error('Failed to load initial data:', error);
|
console.error('Failed to load initial data:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
blogmarkEventGateRef.current.markReady(processBlogmarkCreated);
|
||||||
|
}, 0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -216,28 +240,12 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
unsubscribers.push(
|
unsubscribers.push(
|
||||||
window.electronAPI?.on('blogmark:created', (post: unknown) => {
|
window.electronAPI?.on('blogmark:created', (post: unknown) => {
|
||||||
const created = post as { id?: string } | null;
|
const created = post as PostData;
|
||||||
if (!created?.id) {
|
if (!created?.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = useAppStore.getState();
|
blogmarkEventGateRef.current.push(created, processBlogmarkCreated);
|
||||||
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');
|
|
||||||
}) || (() => {})
|
}) || (() => {})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -475,6 +483,10 @@ const App: React.FC = () => {
|
|||||||
}) || (() => {})
|
}) || (() => {})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
void window.electronAPI?.app.notifyRendererReady?.().catch((error) => {
|
||||||
|
console.error('Failed to notify renderer readiness:', error);
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribers.forEach(unsub => unsub());
|
unsubscribers.forEach(unsub => unsub());
|
||||||
};
|
};
|
||||||
|
|||||||
40
src/renderer/navigation/blogmarkHandling.ts
Normal file
40
src/renderer/navigation/blogmarkHandling.ts
Normal file
@@ -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');
|
||||||
|
}
|
||||||
35
src/renderer/navigation/deferredEventGate.ts
Normal file
35
src/renderer/navigation/deferredEventGate.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
export interface DeferredEventGate<T> {
|
||||||
|
push: (event: T, consume: (event: T) => void) => void;
|
||||||
|
markReady: (consume: (event: T) => void) => void;
|
||||||
|
isReady: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDeferredEventGate<T>(): DeferredEventGate<T> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -650,6 +650,7 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
|
|
||||||
it('handles bds deep-link by creating a blogmark post with preferred category', async () => {
|
it('handles bds deep-link by creating a blogmark post with preferred category', async () => {
|
||||||
const listeners = new Map<string, (...args: any[]) => void>();
|
const listeners = new Map<string, (...args: any[]) => void>();
|
||||||
|
const ipcHandlers = new Map<string, (...args: any[]) => any>();
|
||||||
const mockApp = {
|
const mockApp = {
|
||||||
name: 'bDS',
|
name: 'bDS',
|
||||||
whenReady: vi.fn(() => Promise.resolve()),
|
whenReady: vi.fn(() => Promise.resolve()),
|
||||||
@@ -691,7 +692,9 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
},
|
},
|
||||||
ipcMain: {
|
ipcMain: {
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
handle: vi.fn(),
|
handle: vi.fn((channel: string, handler: (...args: any[]) => any) => {
|
||||||
|
ipcHandlers.set(channel, handler);
|
||||||
|
}),
|
||||||
removeHandler: vi.fn(),
|
removeHandler: vi.fn(),
|
||||||
},
|
},
|
||||||
protocol: {
|
protocol: {
|
||||||
@@ -794,9 +797,181 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const rendererReadyHandler = ipcHandlers.get('app:rendererReady');
|
||||||
|
await rendererReadyHandler?.();
|
||||||
|
|
||||||
expect(windows[0]?.webContents.send).toHaveBeenCalledWith(
|
expect(windows[0]?.webContents.send).toHaveBeenCalledWith(
|
||||||
'blogmark:created',
|
'blogmark:created',
|
||||||
expect.objectContaining({ id: 'new-post-id' }),
|
expect.objectContaining({ id: 'new-post-id' }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('queues blogmark created event until renderer has finished loading', async () => {
|
||||||
|
const listeners = new Map<string, (...args: any[]) => void>();
|
||||||
|
const webContentsListeners = new Map<string, (...args: any[]) => void>();
|
||||||
|
const ipcHandlers = new Map<string, (...args: any[]) => 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<typeof vi.fn> } }> = [];
|
||||||
|
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](<https://example.com/>)',
|
||||||
|
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' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
60
tests/renderer/navigation/blogmarkHandling.test.ts
Normal file
60
tests/renderer/navigation/blogmarkHandling.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
31
tests/renderer/navigation/deferredEventGate.test.ts
Normal file
31
tests/renderer/navigation/deferredEventGate.test.ts
Normal file
@@ -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<string>();
|
||||||
|
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<string>();
|
||||||
|
const consume = vi.fn();
|
||||||
|
|
||||||
|
gate.markReady(consume);
|
||||||
|
gate.push('now', consume);
|
||||||
|
|
||||||
|
expect(consume).toHaveBeenCalledTimes(1);
|
||||||
|
expect(consume).toHaveBeenCalledWith('now');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -130,6 +130,7 @@ Object.defineProperty(globalThis, 'window', {
|
|||||||
triggerMenuAction: vi.fn(),
|
triggerMenuAction: vi.fn(),
|
||||||
getBlogmarkBookmarklet: vi.fn(),
|
getBlogmarkBookmarklet: vi.fn(),
|
||||||
copyToClipboard: vi.fn(),
|
copyToClipboard: vi.fn(),
|
||||||
|
notifyRendererReady: vi.fn(),
|
||||||
},
|
},
|
||||||
import: {
|
import: {
|
||||||
selectAndAnalyze: vi.fn(),
|
selectAndAnalyze: vi.fn(),
|
||||||
|
|||||||
Reference in New Issue
Block a user