From c9ab47d3de30cb78daa391671c46fc97afe316e3 Mon Sep 17 00:00:00 2001 From: hugo Date: Sun, 22 Feb 2026 08:31:33 +0100 Subject: [PATCH] fix: handling of render in background optimized for UI --- src/main/ipc/blogHandlers.ts | 16 +- src/renderer/components/Panel/Panel.css | 6 + src/renderer/components/Panel/Panel.tsx | 80 ++-------- .../components/TaskPopup/TaskPopup.css | 6 + .../components/TaskPopup/TaskPopup.tsx | 80 ++-------- src/renderer/utils/taskGrouping.ts | 138 ++++++++++++++++++ tests/ipc/handlers.test.ts | 62 ++++++++ tests/renderer/components/Panel.test.tsx | 2 +- tests/renderer/components/TaskPopup.test.tsx | 6 +- tests/renderer/utils/taskGrouping.test.ts | 58 ++++++++ 10 files changed, 303 insertions(+), 151 deletions(-) create mode 100644 src/renderer/utils/taskGrouping.ts create mode 100644 tests/renderer/utils/taskGrouping.test.ts diff --git a/src/main/ipc/blogHandlers.ts b/src/main/ipc/blogHandlers.ts index 7bdfd09..19e4ecd 100644 --- a/src/main/ipc/blogHandlers.ts +++ b/src/main/ipc/blogHandlers.ts @@ -125,20 +125,8 @@ export function registerBlogHandlers(safeHandle: SafeHandle): void { }; }; - const coreResult = await taskManager.runTask({ - id: `site-render-core-${taskTimestamp}`, - name: 'Render Site Core', - groupId: taskGroupId, - groupName: taskGroupName, - execute: async (onProgress) => { - return blogGenerationEngine.generate({ - ...baseOptions, - sections: ['core'], - }, (progress, message) => onProgress(progress, message || '')); - }, - }); - - const [singleResult, categoryResult, tagResult, dateResult] = await Promise.all([ + const [coreResult, singleResult, categoryResult, tagResult, dateResult] = await Promise.all([ + runSectionTask('core', 'Render Site Core', 'site-render-core'), runSectionTask('single', 'Render Single Posts', 'site-render-single'), runSectionTask('category', 'Render Category Archives', 'site-render-category'), runSectionTask('tag', 'Render Tag Archives', 'site-render-tag'), diff --git a/src/renderer/components/Panel/Panel.css b/src/renderer/components/Panel/Panel.css index 4478863..51a4aef 100644 --- a/src/renderer/components/Panel/Panel.css +++ b/src/renderer/components/Panel/Panel.css @@ -115,6 +115,12 @@ font-weight: 600; } +.task-group-meta { + margin-left: auto; + font-size: 11px; + color: var(--vscode-descriptionForeground); +} + .task-item { display: flex; align-items: center; diff --git a/src/renderer/components/Panel/Panel.tsx b/src/renderer/components/Panel/Panel.tsx index 708a9e3..7494836 100644 --- a/src/renderer/components/Panel/Panel.tsx +++ b/src/renderer/components/Panel/Panel.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useAppStore } from '../../store'; import type { TaskProgress } from '../../../main/shared/electronApi'; +import { buildTaskEntries, summarizeTaskGroup } from '../../utils/taskGrouping'; import { openEntityTab } from '../../navigation/tabPolicy'; import { useI18n } from '../../i18n'; import './Panel.css'; @@ -36,72 +37,6 @@ function toRelativePath(absolutePath: string, projectPath: string): string { return normalizedAbsolute; } -interface GroupedTaskEntry { - kind: 'group'; - groupId: string; - groupName: string; - tasks: TaskProgress[]; -} - -interface SingleTaskEntry { - kind: 'single'; - task: TaskProgress; -} - -type TaskEntry = GroupedTaskEntry | SingleTaskEntry; - -function buildTaskEntries(tasks: TaskProgress[]): TaskEntry[] { - const groupMap = new Map(); - const singles: Array<{ task: TaskProgress; index: number }> = []; - - tasks.forEach((task, index) => { - if (!task.groupId) { - singles.push({ task, index }); - return; - } - - const existing = groupMap.get(task.groupId); - if (existing) { - existing.tasks.push(task); - return; - } - - groupMap.set(task.groupId, { - groupName: task.groupName || task.groupId, - tasks: [task], - firstIndex: index, - }); - }); - - const entries: Array<{ entry: TaskEntry; index: number }> = []; - - for (const single of singles) { - entries.push({ - index: single.index, - entry: { - kind: 'single', - task: single.task, - }, - }); - } - - for (const [groupId, group] of groupMap.entries()) { - entries.push({ - index: group.firstIndex, - entry: { - kind: 'group', - groupId, - groupName: group.groupName, - tasks: group.tasks, - }, - }); - } - - return entries - .sort((a, b) => a.index - b.index) - .map((entry) => entry.entry); -} - export const Panel: React.FC = () => { const { t, language } = useI18n(); const { @@ -415,6 +350,16 @@ export const Panel: React.FC = () => { return renderTaskRow(entry.task); } + const summary = summarizeTaskGroup(entry.tasks); + const breakdownParts: string[] = []; + if (summary.running > 0) { + breakdownParts.push(`${summary.running} ${t('common.running')}`); + } + if (summary.pending > 0) { + breakdownParts.push(`${summary.pending} ${t('common.pending')}`); + } + const breakdownSuffix = breakdownParts.length > 0 ? ` · ${breakdownParts.join(' · ')}` : ''; + const groupMetaText = `${summary.progressPercent}%${breakdownSuffix}`; const expanded = !collapsedTaskGroups.has(entry.groupId); return (
@@ -423,10 +368,11 @@ export const Panel: React.FC = () => { className="task-group-toggle" onClick={() => toggleTaskGroup(entry.groupId)} aria-expanded={expanded} - aria-label={`${entry.groupName} (${entry.tasks.length})`} + aria-label={`${entry.groupName} (${entry.tasks.length}, ${groupMetaText})`} > {expanded ? '▾' : '▸'} {entry.groupName} ({entry.tasks.length}) + {groupMetaText} {expanded && entry.tasks.map((task) => renderTaskRow(task, true))}
diff --git a/src/renderer/components/TaskPopup/TaskPopup.css b/src/renderer/components/TaskPopup/TaskPopup.css index eba1dfd..f6930fb 100644 --- a/src/renderer/components/TaskPopup/TaskPopup.css +++ b/src/renderer/components/TaskPopup/TaskPopup.css @@ -139,6 +139,12 @@ font-weight: 600; } +.task-group-meta { + margin-left: auto; + font-size: 11px; + color: var(--vscode-descriptionForeground); +} + .task-group-child { padding-left: 28px; } diff --git a/src/renderer/components/TaskPopup/TaskPopup.tsx b/src/renderer/components/TaskPopup/TaskPopup.tsx index 4a54e55..ce67199 100644 --- a/src/renderer/components/TaskPopup/TaskPopup.tsx +++ b/src/renderer/components/TaskPopup/TaskPopup.tsx @@ -1,75 +1,10 @@ import React, { useState, useRef, useEffect } from 'react'; import { useAppStore } from '../../store'; import type { TaskProgress } from '../../../main/shared/electronApi'; +import { buildTaskEntries, summarizeTaskGroup, type TaskEntry } from '../../utils/taskGrouping'; import { useI18n } from '../../i18n'; import './TaskPopup.css'; -interface GroupedTaskEntry { - kind: 'group'; - groupId: string; - groupName: string; - tasks: TaskProgress[]; -} - -interface SingleTaskEntry { - kind: 'single'; - task: TaskProgress; -} - -type TaskEntry = GroupedTaskEntry | SingleTaskEntry; - -function buildTaskEntries(tasks: TaskProgress[]): TaskEntry[] { - const groupMap = new Map(); - const singles: Array<{ task: TaskProgress; index: number }> = []; - - tasks.forEach((task, index) => { - if (!task.groupId) { - singles.push({ task, index }); - return; - } - - const existing = groupMap.get(task.groupId); - if (existing) { - existing.tasks.push(task); - return; - } - - groupMap.set(task.groupId, { - groupName: task.groupName || task.groupId, - tasks: [task], - firstIndex: index, - }); - }); - - const groupedEntries: Array<{ entry: TaskEntry; index: number }> = []; - - for (const single of singles) { - groupedEntries.push({ - index: single.index, - entry: { - kind: 'single', - task: single.task, - }, - }); - } - - for (const [groupId, group] of groupMap.entries()) { - groupedEntries.push({ - index: group.firstIndex, - entry: { - kind: 'group', - groupId, - groupName: group.groupName, - tasks: group.tasks, - }, - }); - } - - return groupedEntries - .sort((a, b) => a.index - b.index) - .map((item) => item.entry); -} - export const TaskPopup: React.FC = () => { const { t } = useI18n(); const { tasks } = useAppStore(); @@ -192,6 +127,16 @@ export const TaskPopup: React.FC = () => { return renderTaskItem(entry.task); } + const summary = summarizeTaskGroup(entry.tasks); + const breakdownParts: string[] = []; + if (summary.running > 0) { + breakdownParts.push(`${summary.running} ${t('common.running')}`); + } + if (summary.pending > 0) { + breakdownParts.push(`${summary.pending} ${t('common.pending')}`); + } + const breakdownSuffix = breakdownParts.length > 0 ? ` · ${breakdownParts.join(' · ')}` : ''; + const groupMetaText = `${summary.progressPercent}%${breakdownSuffix}`; const isExpanded = !collapsedGroups.has(entry.groupId); return (
@@ -199,10 +144,11 @@ export const TaskPopup: React.FC = () => { className="task-group-toggle" onClick={() => toggleGroup(entry.groupId)} aria-expanded={isExpanded} - aria-label={`${entry.groupName} (${entry.tasks.length})`} + aria-label={`${entry.groupName} (${entry.tasks.length}, ${groupMetaText})`} > {isExpanded ? '▾' : '▸'} {entry.groupName} ({entry.tasks.length}) + {groupMetaText} {isExpanded && entry.tasks.map((task) => renderTaskItem(task, 'task-group-child'))}
diff --git a/src/renderer/utils/taskGrouping.ts b/src/renderer/utils/taskGrouping.ts new file mode 100644 index 0000000..d46787a --- /dev/null +++ b/src/renderer/utils/taskGrouping.ts @@ -0,0 +1,138 @@ +import type { TaskProgress } from '../../main/shared/electronApi'; + +export interface GroupedTaskEntry { + kind: 'group'; + groupId: string; + groupName: string; + tasks: TaskProgress[]; +} + +export interface SingleTaskEntry { + kind: 'single'; + task: TaskProgress; +} + +export type TaskEntry = GroupedTaskEntry | SingleTaskEntry; + +export interface TaskGroupSummary { + total: number; + running: number; + pending: number; + completed: number; + failed: number; + cancelled: number; + progressPercent: number; +} + +function clampProgress(value: number): number { + if (Number.isNaN(value)) { + return 0; + } + if (value < 0) { + return 0; + } + if (value > 100) { + return 100; + } + return value; +} + +function getProgressContribution(task: TaskProgress): number { + switch (task.status) { + case 'completed': + case 'failed': + case 'cancelled': + return 100; + case 'pending': + return 0; + case 'running': + default: + return clampProgress(task.progress); + } +} + +export function summarizeTaskGroup(tasks: TaskProgress[]): TaskGroupSummary { + const summary: TaskGroupSummary = { + total: tasks.length, + running: 0, + pending: 0, + completed: 0, + failed: 0, + cancelled: 0, + progressPercent: 0, + }; + + if (tasks.length === 0) { + return summary; + } + + let progressTotal = 0; + for (const task of tasks) { + if (task.status === 'running') { + summary.running += 1; + } else if (task.status === 'pending') { + summary.pending += 1; + } else if (task.status === 'completed') { + summary.completed += 1; + } else if (task.status === 'failed') { + summary.failed += 1; + } else if (task.status === 'cancelled') { + summary.cancelled += 1; + } + + progressTotal += getProgressContribution(task); + } + + summary.progressPercent = Math.round(progressTotal / tasks.length); + return summary; +} + +export function buildTaskEntries(tasks: TaskProgress[]): TaskEntry[] { + const groupMap = new Map(); + const singles: Array<{ task: TaskProgress; index: number }> = []; + + tasks.forEach((task, index) => { + if (!task.groupId) { + singles.push({ task, index }); + return; + } + + const existing = groupMap.get(task.groupId); + if (existing) { + existing.tasks.push(task); + return; + } + + groupMap.set(task.groupId, { + groupName: task.groupName || task.groupId, + tasks: [task], + firstIndex: index, + }); + }); + + const entries: Array<{ entry: TaskEntry; index: number }> = []; + + for (const single of singles) { + entries.push({ + index: single.index, + entry: { + kind: 'single', + task: single.task, + }, + }); + } + + for (const [groupId, group] of groupMap.entries()) { + entries.push({ + index: group.firstIndex, + entry: { + kind: 'group', + groupId, + groupName: group.groupName, + tasks: group.tasks, + }, + }); + } + + return entries.sort((a, b) => a.index - b.index).map((entry) => entry.entry); +} diff --git a/tests/ipc/handlers.test.ts b/tests/ipc/handlers.test.ts index 98066d6..3efe7c9 100644 --- a/tests/ipc/handlers.test.ts +++ b/tests/ipc/handlers.test.ts @@ -1697,6 +1697,68 @@ describe('IPC Handlers', () => { // ============ Blog Handlers ============ describe('Blog Handlers', () => { describe('blog:generateSitemap', () => { + it('should start section tasks without waiting for core completion', async () => { + const mockProject = createMockProject({ + id: 'test-project', + dataPath: '/mock/data', + }); + mockProjectEngine.getActiveProject.mockResolvedValue(mockProject); + mockProjectEngine.getDataDir.mockReturnValue('/mock/data/dir'); + mockMetaEngine.getProjectMetadata.mockResolvedValue({ + name: 'Test Project', + publicUrl: 'https://blog.example.com', + }); + + let resolveCoreTask: ((value: any) => void) | null = null; + const startedTaskNames: string[] = []; + const completedResult = { + path: '/mock/data/dir/html/sitemap.xml', + urlCount: 1, + postCount: 0, + feedPostCount: 0, + tagCount: 0, + categoryCount: 0, + archiveCount: 0, + pagesGenerated: 1, + feeds: { + rssPath: '/mock/data/dir/html/rss.xml', + atomPath: '/mock/data/dir/html/atom.xml', + }, + changed: { + sitemap: true, + rss: true, + atom: true, + }, + }; + + mockTaskManager.runTask.mockImplementation((task: any) => { + startedTaskNames.push(task.name); + if (task.name === 'Render Site Core') { + return new Promise((resolve) => { + resolveCoreTask = resolve; + }); + } + return Promise.resolve(completedResult); + }); + + const generationPromise = invokeHandler('blog:generateSitemap'); + + for (let index = 0; index < 20 && startedTaskNames.length === 0; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + expect(startedTaskNames).toContain('Render Site Core'); + expect(startedTaskNames).toContain('Render Single Posts'); + expect(startedTaskNames).toContain('Render Category Archives'); + expect(startedTaskNames).toContain('Render Tag Archives'); + expect(startedTaskNames).toContain('Render Date Archives'); + + expect(resolveCoreTask).toBeTruthy(); + resolveCoreTask?.(completedResult); + + await generationPromise; + }); + it('should create separate background tasks for single, category, tag, and date rendering', async () => { const mockProject = createMockProject({ id: 'test-project', diff --git a/tests/renderer/components/Panel.test.tsx b/tests/renderer/components/Panel.test.tsx index 3656498..549c9af 100644 --- a/tests/renderer/components/Panel.test.tsx +++ b/tests/renderer/components/Panel.test.tsx @@ -263,7 +263,7 @@ describe('Panel', () => { render(); - const parent = screen.getByRole('button', { name: 'Render Site (2)' }); + const parent = screen.getByRole('button', { name: /Render Site \(2, \d+% · 1 running · 1 pending\)/ }); expect(parent).toBeInTheDocument(); expect(await screen.findByText('Render Site Core')).toBeInTheDocument(); expect(screen.getByText('Render Tag Archives')).toBeInTheDocument(); diff --git a/tests/renderer/components/TaskPopup.test.tsx b/tests/renderer/components/TaskPopup.test.tsx index 22bcad5..dafcae1 100644 --- a/tests/renderer/components/TaskPopup.test.tsx +++ b/tests/renderer/components/TaskPopup.test.tsx @@ -39,7 +39,8 @@ describe('TaskPopup grouped tasks', () => { }), makeTask({ taskId: 'site-render-date-1', - status: 'running', + status: 'pending', + progress: 0, message: 'Generating date archive pages', groupId: 'site-render-1', groupName: 'Render Site', @@ -51,8 +52,9 @@ describe('TaskPopup grouped tasks', () => { fireEvent.click(screen.getByRole('button', { name: /running/i })); - const groupToggle = screen.getByRole('button', { name: /Render Site \(2\)/i }); + const groupToggle = screen.getByRole('button', { name: /Render Site \(1, \d+% · 1 running\)/i }); expect(groupToggle).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Render Site \(1, \d+% · 1 pending\)/i })).toBeInTheDocument(); expect(screen.getByText('Generating root pages')).toBeInTheDocument(); expect(screen.getByText('Generating date archive pages')).toBeInTheDocument(); diff --git a/tests/renderer/utils/taskGrouping.test.ts b/tests/renderer/utils/taskGrouping.test.ts new file mode 100644 index 0000000..b9f124d --- /dev/null +++ b/tests/renderer/utils/taskGrouping.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import type { TaskProgress } from '../../../src/main/shared/electronApi'; +import { buildTaskEntries, summarizeTaskGroup } from '../../../src/renderer/utils/taskGrouping'; + +function createTask(overrides: Partial): TaskProgress { + return { + taskId: overrides.taskId || 'task-1', + name: overrides.name || 'Task', + status: overrides.status || 'running', + progress: overrides.progress ?? 0, + message: overrides.message || 'Working', + startTime: overrides.startTime || new Date('2026-02-22T00:00:00.000Z').toISOString(), + endTime: overrides.endTime, + error: overrides.error, + groupId: overrides.groupId, + groupName: overrides.groupName, + }; +} + +describe('taskGrouping', () => { + it('should keep first-seen order while grouping task entries', () => { + const tasks: TaskProgress[] = [ + createTask({ taskId: 'single-1', name: 'Single 1' }), + createTask({ taskId: 'group-a-1', groupId: 'group-a', groupName: 'Group A' }), + createTask({ taskId: 'single-2', name: 'Single 2' }), + createTask({ taskId: 'group-a-2', groupId: 'group-a', groupName: 'Group A' }), + createTask({ taskId: 'group-b-1', groupId: 'group-b', groupName: 'Group B' }), + ]; + + const entries = buildTaskEntries(tasks); + + expect(entries).toHaveLength(4); + expect(entries[0]).toMatchObject({ kind: 'single' }); + expect(entries[1]).toMatchObject({ kind: 'group', groupId: 'group-a' }); + expect(entries[2]).toMatchObject({ kind: 'single' }); + expect(entries[3]).toMatchObject({ kind: 'group', groupId: 'group-b' }); + }); + + it('should compute aggregate group progress for concurrent mixed statuses', () => { + const tasks: TaskProgress[] = [ + createTask({ taskId: 'core', status: 'running', progress: 40, groupId: 'site-render' }), + createTask({ taskId: 'single', status: 'running', progress: 10, groupId: 'site-render' }), + createTask({ taskId: 'tag', status: 'pending', progress: 0, groupId: 'site-render' }), + createTask({ taskId: 'category', status: 'completed', progress: 100, groupId: 'site-render', endTime: new Date('2026-02-22T00:01:00.000Z').toISOString() }), + createTask({ taskId: 'date', status: 'failed', progress: 30, groupId: 'site-render', endTime: new Date('2026-02-22T00:01:00.000Z').toISOString() }), + ]; + + const summary = summarizeTaskGroup(tasks); + + expect(summary.total).toBe(5); + expect(summary.running).toBe(2); + expect(summary.pending).toBe(1); + expect(summary.completed).toBe(1); + expect(summary.failed).toBe(1); + expect(summary.cancelled).toBe(0); + expect(summary.progressPercent).toBe(50); + }); +});