diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..bbf2e31 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(npm run build:*)" + ] + } +} diff --git a/src/main/engine/MediaEngine.ts b/src/main/engine/MediaEngine.ts index 052281d..2479dfb 100644 --- a/src/main/engine/MediaEngine.ts +++ b/src/main/engine/MediaEngine.ts @@ -542,9 +542,12 @@ export class MediaEngine extends EventEmitter { name: 'Rebuild database from media files', execute: async (onProgress) => { const db = getDatabase().getLocal(); - + onProgress(0, 'Deleting existing media for project...'); + // Notify UI that rebuild is starting so it can clear the list + this.emit('rebuildStarted'); + // Delete all media for the current project - clean slate rebuild const existingMedia = await db.select({ id: media.id }).from(media).where(eq(media.projectId, this.currentProjectId)).all(); if (existingMedia.length > 0) { @@ -553,7 +556,7 @@ export class MediaEngine extends EventEmitter { } onProgress(5, 'Scanning media directory...'); - + // Recursively find all .meta files in the media directory tree const metaFiles: string[] = []; const scanDir = async (dir: string) => { @@ -578,18 +581,18 @@ export class MediaEngine extends EventEmitter { // Already exists } await scanDir(mediaBaseDir); - + onProgress(10, `Found ${metaFiles.length} media sidecar files`); - + for (let i = 0; i < metaFiles.length; i++) { const sidecarPath = metaFiles[i]; const mediaFilePath = sidecarPath.replace('.meta', ''); const metaFileName = path.basename(sidecarPath); - - onProgress(10 + (80 * (i / metaFiles.length)), `Processing ${metaFileName}...`); - + + onProgress(10 + (80 * (i / metaFiles.length)), `Processing ${i + 1}/${metaFiles.length}: ${metaFileName}`); + const metadata = await this.readSidecarFile(sidecarPath); - + if (metadata) { try { const stats = await fs.stat(mediaFilePath); @@ -621,13 +624,18 @@ export class MediaEngine extends EventEmitter { console.error(`Media file not found for sidecar: ${sidecarPath}`, error); } } + + // Yield to event loop periodically so the window stays responsive + if (i % 10 === 0) { + await new Promise(resolve => setImmediate(resolve)); + } } - + onProgress(100, 'Database rebuild complete'); this.emit('databaseRebuilt'); }, }; - + await taskManager.runTask(task); } } diff --git a/src/main/engine/PostEngine.ts b/src/main/engine/PostEngine.ts index 17d7110..47c2f2f 100644 --- a/src/main/engine/PostEngine.ts +++ b/src/main/engine/PostEngine.ts @@ -897,6 +897,9 @@ export class PostEngine extends EventEmitter { onProgress(0, 'Deleting existing posts for project...'); + // Notify UI that rebuild is starting so it can clear the list + this.emit('rebuildStarted'); + // Delete all posts for the current project - clean slate rebuild const existingPosts = await db.select({ id: posts.id }).from(posts).where(eq(posts.projectId, this.currentProjectId)).all(); if (existingPosts.length > 0) { @@ -951,7 +954,7 @@ export class PostEngine extends EventEmitter { const filePath = mdFiles[i]; const fileName = path.basename(filePath); - onProgress(10 + (80 * (i / mdFiles.length)), `Processing ${fileName}...`); + onProgress(10 + (80 * (i / mdFiles.length)), `Processing ${i + 1}/${mdFiles.length}: ${fileName}`); const postData = await this.readPostFile(filePath); @@ -1007,6 +1010,11 @@ export class PostEngine extends EventEmitter { } } } + + // Yield to event loop periodically so the window stays responsive + if (i % 10 === 0) { + await new Promise(resolve => setImmediate(resolve)); + } } onProgress(100, 'Database rebuild complete'); diff --git a/src/main/engine/TaskManager.ts b/src/main/engine/TaskManager.ts index 596552e..cad92d0 100644 --- a/src/main/engine/TaskManager.ts +++ b/src/main/engine/TaskManager.ts @@ -83,13 +83,20 @@ export class TaskManager extends EventEmitter { this.emit('taskStarted', progress); try { + let lastEmitTime = 0; + const THROTTLE_MS = 250; // Only emit progress to renderer every 250ms + const result = await task.execute((progressValue, message) => { if (abortController.signal.aborted) { throw new Error('Task cancelled'); } progress.progress = progressValue; progress.message = message; - this.emit('taskProgress', progress); + const now = Date.now(); + if (now - lastEmitTime >= THROTTLE_MS || progressValue >= 100) { + lastEmitTime = now; + this.emit('taskProgress', { ...progress }); + } }); progress.status = 'completed'; diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 602faee..6209375 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -137,7 +137,10 @@ export function registerIpcHandlers(): void { if (project) { engine.setProjectContext(project.id); } - return engine.rebuildDatabaseFromFiles(); + // Fire and forget - don't await, let it run in background + engine.rebuildDatabaseFromFiles().catch(err => { + console.error('Post rebuild failed:', err); + }); }); ipcMain.handle('posts:search', async (_, query: string) => { @@ -256,7 +259,10 @@ export function registerIpcHandlers(): void { if (project) { engine.setProjectContext(project.id); } - return engine.rebuildDatabaseFromFiles(); + // Fire and forget - don't await, let it run in background + engine.rebuildDatabaseFromFiles().catch(err => { + console.error('Media rebuild failed:', err); + }); }); ipcMain.handle('media:getThumbnail', async (_, id: string, size?: 'small' | 'medium' | 'large') => { @@ -440,11 +446,13 @@ export function registerIpcHandlers(): void { postEngine.on('postCreated', forwardEvent('post:created')); postEngine.on('postUpdated', forwardEvent('post:updated')); postEngine.on('postDeleted', forwardEvent('post:deleted')); + postEngine.on('rebuildStarted', forwardEvent('posts:rebuildStarted')); postEngine.on('databaseRebuilt', forwardEvent('posts:databaseRebuilt')); mediaEngine.on('mediaImported', forwardEvent('media:imported')); mediaEngine.on('mediaUpdated', forwardEvent('media:updated')); mediaEngine.on('mediaDeleted', forwardEvent('media:deleted')); + mediaEngine.on('rebuildStarted', forwardEvent('media:rebuildStarted')); mediaEngine.on('databaseRebuilt', forwardEvent('media:databaseRebuilt')); syncEngine.on('syncStarted', forwardEvent('sync:started')); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index c8ad4b8..60d8553 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -144,6 +144,13 @@ const App: React.FC = () => { ); // Task events + unsubscribers.push( + window.electronAPI?.on('task:started', (task: unknown) => { + const t = task as TaskProgress; + updateTask(t.taskId, t); + }) || (() => {}) + ); + unsubscribers.push( window.electronAPI?.on('task:progress', (task: unknown) => { const t = task as TaskProgress; @@ -235,22 +242,47 @@ const App: React.FC = () => { }) || (() => {}) ); + // Rebuild events - clear store on start, reload on complete unsubscribers.push( - window.electronAPI?.on('menu:rebuildDatabase', async () => { - await window.electronAPI?.posts.rebuildFromFiles(); - await window.electronAPI?.media.rebuildFromFiles(); - // Reload data - const posts = await window.electronAPI?.posts.getAll(); - if (posts) { - setPosts(posts as PostData[]); + window.electronAPI?.on('posts:rebuildStarted', () => { + setPosts([], false, 0); + setSelectedPost(null); + }) || (() => {}) + ); + + unsubscribers.push( + window.electronAPI?.on('posts:databaseRebuilt', async () => { + const postsResult = await window.electronAPI?.posts.getAll({ limit: 500, offset: 0 }); + if (postsResult) { + const { items, hasMore, total } = postsResult as { items: PostData[]; hasMore: boolean; total: number }; + setPosts(items, hasMore, total); } - const media = await window.electronAPI?.media.getAll(); - if (media) { - setMedia(media as MediaData[]); + }) || (() => {}) + ); + + unsubscribers.push( + window.electronAPI?.on('media:rebuildStarted', () => { + setMedia([]); + }) || (() => {}) + ); + + unsubscribers.push( + window.electronAPI?.on('media:databaseRebuilt', async () => { + const mediaResult = await window.electronAPI?.media.getAll(); + if (mediaResult) { + setMedia(mediaResult as MediaData[]); } }) || (() => {}) ); + unsubscribers.push( + window.electronAPI?.on('menu:rebuildDatabase', () => { + // Fire and forget - the handlers return immediately now + window.electronAPI?.posts.rebuildFromFiles(); + window.electronAPI?.media.rebuildFromFiles(); + }) || (() => {}) + ); + return () => { unsubscribers.forEach(unsub => unsub()); }; diff --git a/src/renderer/store/appStore.ts b/src/renderer/store/appStore.ts index a942b13..22d39d5 100644 --- a/src/renderer/store/appStore.ts +++ b/src/renderer/store/appStore.ts @@ -255,9 +255,14 @@ export const useAppStore = create()( // Task Actions setTasks: (tasks) => set({ tasks }), - updateTask: (taskId, task) => set((state) => ({ - tasks: state.tasks.map((t) => (t.taskId === taskId ? { ...t, ...task } : t)), - })), + updateTask: (taskId, task) => set((state) => { + const exists = state.tasks.some((t) => t.taskId === taskId); + if (exists) { + return { tasks: state.tasks.map((t) => (t.taskId === taskId ? { ...t, ...task } : t)) }; + } + // Add new task if it doesn't exist yet + return { tasks: [...state.tasks, { taskId, status: 'running', progress: 0, message: '', startTime: new Date().toISOString(), ...task } as TaskProgress] }; + }), // Sync Actions setSyncStatus: (syncStatus) => set({ syncStatus }),