feat: first cut at the full renderer

This commit is contained in:
2026-02-20 17:54:04 +01:00
parent 22cb63e0a7
commit 3bbc5281e8
25 changed files with 4989 additions and 976 deletions

View File

@@ -86,6 +86,35 @@
gap: 4px;
}
.task-group-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.task-group-toggle {
width: 100%;
display: flex;
align-items: center;
gap: 6px;
border: none;
background-color: var(--vscode-list-hoverBackground);
color: var(--vscode-editor-foreground);
border-radius: 4px;
padding: 6px 8px;
cursor: pointer;
text-align: left;
}
.task-group-chevron {
color: var(--vscode-descriptionForeground);
}
.task-group-title {
font-size: 12px;
font-weight: 600;
}
.task-item {
display: flex;
align-items: center;
@@ -131,20 +160,49 @@
min-width: 0;
}
.task-message {
.task-child-row {
margin-left: 16px;
}
.task-name {
font-size: 12px;
color: var(--vscode-editor-foreground);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-message {
font-size: 11px;
color: var(--vscode-descriptionForeground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-progress-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
}
.task-progress-bar {
flex: 1;
height: 3px;
background-color: var(--vscode-input-background);
border-radius: 2px;
margin-top: 4px;
overflow: hidden;
}
.task-progress-value {
min-width: 32px;
font-size: 11px;
color: var(--vscode-descriptionForeground);
text-align: right;
}
.task-progress-fill {
height: 100%;
background-color: var(--vscode-focusBorder);

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useAppStore } from '../../store';
import type { TaskProgress } from '../../../main/shared/electronApi';
import './Panel.css';
function getPostRelativePath(createdAt: string, slug: string): string | null {
@@ -33,6 +34,72 @@ 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<string, { groupName: string; tasks: TaskProgress[]; firstIndex: number }>();
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 {
panelVisible,
@@ -48,6 +115,7 @@ export const Panel: React.FC = () => {
setSelectedPost,
setActiveView,
} = useAppStore();
const [collapsedTaskGroups, setCollapsedTaskGroups] = useState<Set<string>>(new Set());
const [gitLogLoading, setGitLogLoading] = useState(false);
const [gitLogError, setGitLogError] = useState<string | null>(null);
const [postLinksLoading, setPostLinksLoading] = useState(false);
@@ -69,6 +137,7 @@ export const Panel: React.FC = () => {
const requestIdRef = useRef(0);
const recentTasks = tasks.slice(-10).reverse();
const recentTaskEntries = useMemo(() => buildTaskEntries(recentTasks), [recentTasks]);
const activeEditorTab = useMemo(() => tabs.find((tab) => tab.id === activeTabId) ?? null, [tabs, activeTabId]);
const canActivatePostLinks = activeEditorTab?.type === 'post';
const canActivateGitLog = activeEditorTab?.type === 'post' || activeEditorTab?.type === 'media';
@@ -230,6 +299,52 @@ export const Panel: React.FC = () => {
setActiveView('posts');
};
const toggleTaskGroup = (groupId: string) => {
setCollapsedTaskGroups((prev) => {
const next = new Set(prev);
if (next.has(groupId)) {
next.delete(groupId);
} else {
next.add(groupId);
}
return next;
});
};
const renderTaskRow = (task: TaskProgress, isChild = false) => (
<div key={task.taskId} className={`task-item status-${task.status} ${isChild ? 'task-child-row' : ''}`.trim()}>
<div className="task-status">
{task.status === 'running' && <span className="task-spinner" />}
{task.status === 'completed' && <span className="task-check"></span>}
{task.status === 'failed' && <span className="task-error"></span>}
{task.status === 'pending' && <span className="task-pending"></span>}
</div>
<div className="task-info">
<div className="task-name">{task.name || task.taskId}</div>
<div className="task-message">{task.message}</div>
{(task.status === 'running' || task.status === 'pending') && (
<div className="task-progress-row">
<div className="task-progress-bar">
<div
className="task-progress-fill"
style={{ width: `${task.progress}%` }}
/>
</div>
<span className="task-progress-value">{Math.round(task.progress)}%</span>
</div>
)}
</div>
{task.status === 'running' && (
<button
className="task-cancel"
onClick={() => window.electronAPI?.tasks.cancel(task.taskId)}
>
Cancel
</button>
)}
</div>
);
return (
<div className="panel">
<div className="panel-header">
@@ -292,35 +407,28 @@ export const Panel: React.FC = () => {
<div className="panel-empty">No recent tasks</div>
) : (
<div className="task-list">
{recentTasks.map(task => (
<div key={task.taskId} className={`task-item status-${task.status}`}>
<div className="task-status">
{task.status === 'running' && <span className="task-spinner" />}
{task.status === 'completed' && <span className="task-check"></span>}
{task.status === 'failed' && <span className="task-error"></span>}
{task.status === 'pending' && <span className="task-pending"></span>}
</div>
<div className="task-info">
<div className="task-message">{task.message}</div>
{task.status === 'running' && (
<div className="task-progress-bar">
<div
className="task-progress-fill"
style={{ width: `${task.progress}%` }}
/>
</div>
)}
</div>
{task.status === 'running' && (
{recentTaskEntries.map((entry) => {
if (entry.kind === 'single') {
return renderTaskRow(entry.task);
}
const expanded = !collapsedTaskGroups.has(entry.groupId);
return (
<div key={entry.groupId} className="task-group-row">
<button
className="task-cancel"
onClick={() => window.electronAPI?.tasks.cancel(task.taskId)}
type="button"
className="task-group-toggle"
onClick={() => toggleTaskGroup(entry.groupId)}
aria-expanded={expanded}
aria-label={`${entry.groupName} (${entry.tasks.length})`}
>
Cancel
<span className="task-group-chevron">{expanded ? '▾' : '▸'}</span>
<span className="task-group-title">{entry.groupName} ({entry.tasks.length})</span>
</button>
)}
</div>
))}
{expanded && entry.tasks.map((task) => renderTaskRow(task, true))}
</div>
);
})}
</div>
)
)}

View File

@@ -108,6 +108,41 @@
background-color: var(--vscode-list-hoverBackground);
}
.task-group {
display: block;
}
.task-group-toggle {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border: none;
background: transparent;
color: var(--vscode-foreground);
text-align: left;
cursor: pointer;
}
.task-group-toggle:hover {
background-color: var(--vscode-list-hoverBackground);
}
.task-group-chevron {
color: var(--vscode-descriptionForeground);
width: 10px;
}
.task-group-title {
font-size: 12px;
font-weight: 600;
}
.task-group-child {
padding-left: 28px;
}
.task-item-info {
display: flex;
align-items: flex-start;

View File

@@ -1,10 +1,78 @@
import React, { useState, useRef, useEffect } from 'react';
import { useAppStore } from '../../store';
import type { TaskProgress } from '../../../main/shared/electronApi';
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<string, { groupName: string; tasks: TaskProgress[]; firstIndex: number }>();
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 { tasks } = useAppStore();
const [isOpen, setIsOpen] = useState(false);
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const popupRef = useRef<HTMLDivElement>(null);
const runningTasks = tasks.filter(t => t.status === 'running');
@@ -20,6 +88,10 @@ export const TaskPopup: React.FC = () => {
const hasActiveTasks = runningTasks.length > 0 || pendingTasks.length > 0;
const runningEntries = buildTaskEntries(runningTasks);
const pendingEntries = buildTaskEntries(pendingTasks);
const recentEntries = buildTaskEntries(recentTasks);
// Close popup when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
@@ -67,6 +139,74 @@ export const TaskPopup: React.FC = () => {
}
};
const toggleGroup = (groupId: string) => {
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(groupId)) {
next.delete(groupId);
} else {
next.add(groupId);
}
return next;
});
};
const renderTaskItem = (task: TaskProgress, className: string = '') => (
<div key={task.taskId} className={`task-item ${task.status} ${className}`.trim()}>
<div className="task-item-info">
{getStatusIcon(task.status)}
<div className="task-item-details">
<div className="task-item-message">{task.message}</div>
{task.status === 'running' && (
<div className="task-progress-bar">
<div
className="task-progress-fill"
style={{ width: `${task.progress}%` }}
/>
</div>
)}
{task.error && (
<div className="task-item-error">{task.error}</div>
)}
</div>
</div>
{(task.status === 'running' || task.status === 'pending') && (
<button
className="task-cancel"
onClick={() => handleCancel(task.taskId)}
title="Cancel task"
>
</button>
)}
{(task.status === 'completed' || task.status === 'failed') && task.endTime && (
<span className="task-time">{formatTime(task.endTime)}</span>
)}
</div>
);
const renderEntries = (entries: TaskEntry[]) => entries.map((entry) => {
if (entry.kind === 'single') {
return renderTaskItem(entry.task);
}
const isExpanded = !collapsedGroups.has(entry.groupId);
return (
<div key={entry.groupId} className="task-group">
<button
className="task-group-toggle"
onClick={() => toggleGroup(entry.groupId)}
aria-expanded={isExpanded}
aria-label={`${entry.groupName} (${entry.tasks.length})`}
>
<span className="task-group-chevron">{isExpanded ? '▾' : '▸'}</span>
<span className="task-group-title">{entry.groupName} ({entry.tasks.length})</span>
</button>
{isExpanded && entry.tasks.map((task) => renderTaskItem(task, 'task-group-child'))}
</div>
);
});
if (!hasActiveTasks && recentTasks.length === 0) {
return null;
}
@@ -107,74 +247,21 @@ export const TaskPopup: React.FC = () => {
{runningTasks.length > 0 && (
<div className="task-section">
<div className="task-section-title">Running</div>
{runningTasks.map(task => (
<div key={task.taskId} className="task-item running">
<div className="task-item-info">
{getStatusIcon(task.status)}
<div className="task-item-details">
<div className="task-item-message">{task.message}</div>
<div className="task-progress-bar">
<div
className="task-progress-fill"
style={{ width: `${task.progress}%` }}
/>
</div>
</div>
</div>
<button
className="task-cancel"
onClick={() => handleCancel(task.taskId)}
title="Cancel task"
>
</button>
</div>
))}
{renderEntries(runningEntries)}
</div>
)}
{pendingTasks.length > 0 && (
<div className="task-section">
<div className="task-section-title">Pending</div>
{pendingTasks.map(task => (
<div key={task.taskId} className="task-item pending">
<div className="task-item-info">
{getStatusIcon(task.status)}
<div className="task-item-details">
<div className="task-item-message">{task.message}</div>
</div>
</div>
<button
className="task-cancel"
onClick={() => handleCancel(task.taskId)}
title="Cancel task"
>
</button>
</div>
))}
{renderEntries(pendingEntries)}
</div>
)}
{recentTasks.length > 0 && (
<div className="task-section">
<div className="task-section-title">Recent</div>
{recentTasks.map(task => (
<div key={task.taskId} className={`task-item ${task.status}`}>
<div className="task-item-info">
{getStatusIcon(task.status)}
<div className="task-item-details">
<div className="task-item-message">{task.message}</div>
{task.error && (
<div className="task-item-error">{task.error}</div>
)}
</div>
</div>
{task.endTime && (
<span className="task-time">{formatTime(task.endTime)}</span>
)}
</div>
))}
{renderEntries(recentEntries)}
</div>
)}