feat(electrobun): file management — CodeMirror editor, PDF viewer, CSV table, real file I/O
- CodeEditor: CodeMirror 6 with Catppuccin theme, 15+ languages, Ctrl+S save, dirty tracking, save-on-blur - PdfViewer: pdfjs-dist canvas rendering, zoom 0.5-3x, HiDPI, lazy page load - CsvTable: RFC 4180 parser, delimiter auto-detect, sortable columns, sticky header - FileBrowser: real filesystem via files.list/read/write RPC, lazy dir loading, file type routing (code→editor, pdf→viewer, csv→table, images→display) - 10MB size gate, binary detection, base64 encoding for non-text files
This commit is contained in:
parent
29a3370e79
commit
252fca70df
22 changed files with 8116 additions and 227 deletions
515
ui-electrobun/src/mainview/TaskBoardTab.svelte
Normal file
515
ui-electrobun/src/mainview/TaskBoardTab.svelte
Normal file
|
|
@ -0,0 +1,515 @@
|
|||
<script lang="ts">
|
||||
import { appRpc } from './rpc.ts';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
assignedTo: string | null;
|
||||
createdBy: string;
|
||||
groupId: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
groupId: string;
|
||||
/** Agent ID perspective for creating tasks (defaults to 'admin'). */
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
let { groupId, agentId = 'admin' }: Props = $props();
|
||||
|
||||
// ── State ────────────────────────────────────────────────────────────
|
||||
|
||||
const COLUMNS = ['todo', 'progress', 'review', 'done', 'blocked'] as const;
|
||||
const COL_LABELS: Record<string, string> = {
|
||||
todo: 'To Do', progress: 'In Progress', review: 'Review',
|
||||
done: 'Done', blocked: 'Blocked',
|
||||
};
|
||||
const PRIORITY_COLORS: Record<string, string> = {
|
||||
high: 'var(--ctp-red)', medium: 'var(--ctp-peach)',
|
||||
low: 'var(--ctp-teal)',
|
||||
};
|
||||
|
||||
let tasks = $state<Task[]>([]);
|
||||
let showCreateForm = $state(false);
|
||||
let newTitle = $state('');
|
||||
let newDesc = $state('');
|
||||
let newPriority = $state('medium');
|
||||
let error = $state('');
|
||||
|
||||
// Drag state
|
||||
let draggedTaskId = $state<string | null>(null);
|
||||
let dragOverCol = $state<string | null>(null);
|
||||
|
||||
// ── Derived ──────────────────────────────────────────────────────────
|
||||
|
||||
let tasksByCol = $derived(
|
||||
COLUMNS.reduce((acc, col) => {
|
||||
acc[col] = tasks.filter(t => t.status === col);
|
||||
return acc;
|
||||
}, {} as Record<string, Task[]>)
|
||||
);
|
||||
|
||||
// ── Data fetching ────────────────────────────────────────────────────
|
||||
|
||||
async function loadTasks() {
|
||||
try {
|
||||
const res = await appRpc.request['bttask.listTasks']({ groupId });
|
||||
tasks = res.tasks;
|
||||
} catch (err) {
|
||||
console.error('[TaskBoard] loadTasks:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function createTask() {
|
||||
const title = newTitle.trim();
|
||||
if (!title) { error = 'Title required'; return; }
|
||||
error = '';
|
||||
|
||||
try {
|
||||
const res = await appRpc.request['bttask.createTask']({
|
||||
title,
|
||||
description: newDesc.trim(),
|
||||
priority: newPriority,
|
||||
groupId,
|
||||
createdBy: agentId,
|
||||
});
|
||||
if (res.ok) {
|
||||
newTitle = '';
|
||||
newDesc = '';
|
||||
newPriority = 'medium';
|
||||
showCreateForm = false;
|
||||
await loadTasks();
|
||||
} else {
|
||||
error = res.error ?? 'Failed to create task';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[TaskBoard] createTask:', err);
|
||||
error = 'Failed to create task';
|
||||
}
|
||||
}
|
||||
|
||||
async function moveTask(taskId: string, newStatus: string) {
|
||||
const task = tasks.find(t => t.id === taskId);
|
||||
if (!task || task.status === newStatus) return;
|
||||
|
||||
try {
|
||||
const res = await appRpc.request['bttask.updateTaskStatus']({
|
||||
taskId,
|
||||
status: newStatus,
|
||||
expectedVersion: task.version,
|
||||
});
|
||||
if (res.ok) {
|
||||
// Optimistic update
|
||||
task.status = newStatus;
|
||||
task.version = res.newVersion ?? task.version + 1;
|
||||
tasks = [...tasks]; // trigger reactivity
|
||||
} else {
|
||||
error = res.error ?? 'Version conflict';
|
||||
await loadTasks(); // reload on conflict
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[TaskBoard] moveTask:', err);
|
||||
await loadTasks();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTask(taskId: string) {
|
||||
try {
|
||||
await appRpc.request['bttask.deleteTask']({ taskId });
|
||||
tasks = tasks.filter(t => t.id !== taskId);
|
||||
} catch (err) {
|
||||
console.error('[TaskBoard] deleteTask:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drag handlers ────────────────────────────────────────────────────
|
||||
|
||||
function onDragStart(e: DragEvent, taskId: string) {
|
||||
draggedTaskId = taskId;
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', taskId);
|
||||
}
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent, col: string) {
|
||||
e.preventDefault();
|
||||
dragOverCol = col;
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
dragOverCol = null;
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent, col: string) {
|
||||
e.preventDefault();
|
||||
dragOverCol = null;
|
||||
if (draggedTaskId) {
|
||||
moveTask(draggedTaskId, col);
|
||||
draggedTaskId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
draggedTaskId = null;
|
||||
dragOverCol = null;
|
||||
}
|
||||
|
||||
// ── Init + polling ───────────────────────────────────────────────────
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
$effect(() => {
|
||||
loadTasks();
|
||||
pollTimer = setInterval(loadTasks, 5000);
|
||||
return () => { if (pollTimer) clearInterval(pollTimer); };
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="task-board">
|
||||
<!-- Toolbar -->
|
||||
<div class="tb-toolbar">
|
||||
<span class="tb-title">Task Board</span>
|
||||
<span class="tb-count">{tasks.length} tasks</span>
|
||||
<button class="tb-add-btn" onclick={() => { showCreateForm = !showCreateForm; }}>
|
||||
{showCreateForm ? 'Cancel' : '+ Task'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Create form -->
|
||||
{#if showCreateForm}
|
||||
<div class="tb-create-form">
|
||||
<input
|
||||
class="tb-input"
|
||||
type="text"
|
||||
placeholder="Task title"
|
||||
bind:value={newTitle}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') createTask(); }}
|
||||
/>
|
||||
<input
|
||||
class="tb-input tb-desc"
|
||||
type="text"
|
||||
placeholder="Description (optional)"
|
||||
bind:value={newDesc}
|
||||
/>
|
||||
<div class="tb-form-row">
|
||||
<select class="tb-select" bind:value={newPriority}>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
<button class="tb-submit" onclick={createTask}>Create</button>
|
||||
</div>
|
||||
{#if error}
|
||||
<span class="tb-error">{error}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Kanban columns -->
|
||||
<div class="tb-columns">
|
||||
{#each COLUMNS as col}
|
||||
<div
|
||||
class="tb-column"
|
||||
class:drag-over={dragOverCol === col}
|
||||
ondragover={(e) => onDragOver(e, col)}
|
||||
ondragleave={onDragLeave}
|
||||
ondrop={(e) => onDrop(e, col)}
|
||||
role="list"
|
||||
aria-label="{COL_LABELS[col]} column"
|
||||
>
|
||||
<div class="tb-col-header">
|
||||
<span class="tb-col-label">{COL_LABELS[col]}</span>
|
||||
<span class="tb-col-count">{tasksByCol[col]?.length ?? 0}</span>
|
||||
</div>
|
||||
|
||||
<div class="tb-col-body">
|
||||
{#each tasksByCol[col] ?? [] as task (task.id)}
|
||||
<div
|
||||
class="tb-card"
|
||||
class:dragging={draggedTaskId === task.id}
|
||||
draggable="true"
|
||||
ondragstart={(e) => onDragStart(e, task.id)}
|
||||
ondragend={onDragEnd}
|
||||
role="listitem"
|
||||
>
|
||||
<div class="card-header">
|
||||
<span
|
||||
class="priority-dot"
|
||||
style:background={PRIORITY_COLORS[task.priority] ?? 'var(--ctp-overlay0)'}
|
||||
title="Priority: {task.priority}"
|
||||
></span>
|
||||
<span class="card-title">{task.title}</span>
|
||||
<button
|
||||
class="card-delete"
|
||||
onclick={() => deleteTask(task.id)}
|
||||
title="Delete task"
|
||||
aria-label="Delete task"
|
||||
>×</button>
|
||||
</div>
|
||||
{#if task.description}
|
||||
<div class="card-desc">{task.description}</div>
|
||||
{/if}
|
||||
{#if task.assignedTo}
|
||||
<div class="card-assignee">
|
||||
<span class="assignee-icon">@</span>
|
||||
{task.assignedTo}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.task-board {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tb-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
background: var(--ctp-mantle);
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tb-title {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.tb-count {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--ctp-overlay0);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.tb-add-btn {
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: color-mix(in srgb, var(--ctp-green) 15%, transparent);
|
||||
border: 1px solid var(--ctp-green);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--ctp-green);
|
||||
font-family: var(--ui-font-family);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.tb-add-btn:hover {
|
||||
background: color-mix(in srgb, var(--ctp-green) 30%, transparent);
|
||||
}
|
||||
|
||||
.tb-create-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
background: var(--ctp-mantle);
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tb-input {
|
||||
height: 1.625rem;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--ctp-text);
|
||||
font-family: var(--ui-font-family);
|
||||
font-size: 0.75rem;
|
||||
padding: 0 0.375rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tb-input:focus { border-color: var(--ctp-mauve); }
|
||||
|
||||
.tb-form-row {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tb-select {
|
||||
height: 1.625rem;
|
||||
background: var(--ctp-surface0);
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--ctp-text);
|
||||
font-family: var(--ui-font-family);
|
||||
font-size: 0.6875rem;
|
||||
padding: 0 0.25rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tb-submit {
|
||||
padding: 0.125rem 0.625rem;
|
||||
height: 1.625rem;
|
||||
background: color-mix(in srgb, var(--ctp-mauve) 20%, transparent);
|
||||
border: 1px solid var(--ctp-mauve);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--ctp-mauve);
|
||||
font-family: var(--ui-font-family);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.tb-submit:hover {
|
||||
background: color-mix(in srgb, var(--ctp-mauve) 35%, transparent);
|
||||
}
|
||||
|
||||
.tb-error {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
.tb-columns {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-x: auto;
|
||||
gap: 1px;
|
||||
background: var(--ctp-surface0);
|
||||
}
|
||||
|
||||
.tb-column {
|
||||
flex: 1;
|
||||
min-width: 8rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--ctp-base);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.tb-column.drag-over {
|
||||
background: color-mix(in srgb, var(--ctp-blue) 8%, var(--ctp-base));
|
||||
}
|
||||
|
||||
.tb-col-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-bottom: 1px solid var(--ctp-surface0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tb-col-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.tb-col-count {
|
||||
font-size: 0.625rem;
|
||||
color: var(--ctp-overlay0);
|
||||
background: var(--ctp-surface0);
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.tb-col-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.375rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.tb-card {
|
||||
background: var(--ctp-mantle);
|
||||
border: 1px solid var(--ctp-surface0);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
cursor: grab;
|
||||
transition: border-color 0.12s, opacity 0.12s;
|
||||
}
|
||||
|
||||
.tb-card:hover { border-color: var(--ctp-surface1); }
|
||||
.tb-card.dragging { opacity: 0.4; }
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.priority-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
flex: 1;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-delete {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--ctp-overlay0);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.tb-card:hover .card-delete { opacity: 1; }
|
||||
.card-delete:hover { color: var(--ctp-red); }
|
||||
|
||||
.card-desc {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--ctp-subtext0);
|
||||
margin-top: 0.125rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-assignee {
|
||||
font-size: 0.625rem;
|
||||
color: var(--ctp-overlay1);
|
||||
margin-top: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.assignee-icon {
|
||||
color: var(--ctp-blue);
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue