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:
Hibryda 2026-03-22 01:36:02 +01:00
parent 29a3370e79
commit 252fca70df
22 changed files with 8116 additions and 227 deletions

View 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"
>&times;</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>