P1: typed RPC bridge + FileSystemProvider + dual-panel local browsing

- shared/types + shared/rpc: full wire contract (Result envelope, no exceptions cross the bridge)
- FileSystemProvider interface, LocalProvider, SessionRegistry with built-in 'local'
- walk.ts: provider-generic recursive walk + removeRecursive
- rpc-handlers wired into BrowserWindow; 10min request timeout for slow SFTP ops
- Angular: RpcService (Electroview bridge + plain-browser guard), PanelsService with
  signals-based PanelState (sort/cursor/selection), FilePanel component with
  keyboard nav (arrows/Space/Enter/Backspace/Tab), path bar, toasts
- verified: both panels listDir home over the bridge in the running app

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jokin
2026-07-11 13:35:02 +02:00
co-authored by Claude Fable 5
parent 8092cb0c08
commit a150c5ee2e
17 changed files with 1062 additions and 15 deletions
+40 -12
View File
@@ -1,17 +1,45 @@
<div class="flex h-screen flex-col">
<header class="flex items-center gap-2 border-b border-border px-4 py-2">
<ng-icon name="mynaArrowLeftRight" class="text-muted-foreground" />
<h1 class="text-sm font-semibold">{{ appName }}</h1>
<div class="flex h-screen flex-col bg-background text-foreground">
<header class="flex items-center gap-2 border-b border-border px-3 py-1.5">
<ng-icon name="mynaArrowLeftRight" class="text-muted-foreground" size="0.9rem" />
<h1 class="text-[13px] font-semibold tracking-tight">{{ appName }}</h1>
</header>
<main class="flex flex-1 items-center justify-center">
<div class="rounded-lg border border-border bg-card p-6 text-card-foreground shadow-sm">
<div class="flex items-center gap-3">
<ng-icon name="mynaHardDrive" size="1.5rem" class="text-primary" />
<ng-icon name="mynaArrowLeftRight" size="1.25rem" class="text-muted-foreground" />
<ng-icon name="mynaServers" size="1.5rem" class="text-primary" />
@if (!rpc.available) {
<div class="flex flex-1 items-center justify-center">
<div class="max-w-sm rounded-lg border border-border bg-card p-6 text-center shadow-sm">
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
<p class="mt-2 text-sm font-medium">Not connected to the app</p>
<p class="mt-1 text-xs text-muted-foreground">
This page is running in a plain browser. Launch the desktop app to use it.
</p>
</div>
<p class="mt-3 text-sm text-muted-foreground">P0 skeleton — panels arrive in P1.</p>
</div>
</main>
} @else {
<main class="flex min-h-0 flex-1 divide-x divide-border">
<app-file-panel side="left" />
<app-file-panel side="right" />
</main>
}
<!-- toasts -->
<div class="pointer-events-none fixed right-3 bottom-3 z-50 flex w-80 flex-col gap-2">
@for (t of toast.toasts(); track t.id) {
<div
class="pointer-events-auto flex items-start gap-2 rounded-lg border border-border bg-card p-3 text-xs text-card-foreground shadow-lg"
>
<ng-icon
[name]="toastIcon(t.kind)"
size="1rem"
[class]="t.kind === 'error' ? 'shrink-0 text-destructive' : 'shrink-0 text-success'"
/>
<span class="min-w-0 flex-1 wrap-break-word">{{ t.text }}</span>
<button
(click)="toast.dismiss(t.id)"
class="shrink-0 text-muted-foreground hover:text-foreground"
>
<ng-icon name="mynaX" size="0.9rem" />
</button>
</div>
}
</div>
</div>
+13 -2
View File
@@ -1,16 +1,27 @@
import { Component } from '@angular/core';
import { Component, inject } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { BRANDING } from '@shared/branding';
import { FilePanel } from './file-panel/file-panel';
import { RpcService } from './rpc.service';
import { ToastService } from './toast.service';
@Component({
selector: 'app-root',
imports: [NgIcon],
imports: [NgIcon, FilePanel],
templateUrl: './app.html',
})
export class App {
readonly appName = BRANDING.appName;
readonly rpc = inject(RpcService);
readonly toast = inject(ToastService);
constructor() {
document.title = BRANDING.appName;
}
toastIcon(kind: 'error' | 'info' | 'success'): string {
if (kind === 'error') return 'mynaXCircle';
if (kind === 'success') return 'mynaCheckCircle';
return 'mynaCheck';
}
}
+106
View File
@@ -0,0 +1,106 @@
<!-- session strip -->
<div class="flex items-center gap-1.5 border-b border-border px-2 py-1.5">
<button
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium hover:bg-accent"
title="Choose what this panel shows"
>
<ng-icon name="mynaHardDrive" class="text-muted-foreground" size="0.9rem" />
<span>{{ panel().title() }}</span>
<ng-icon name="mynaChevronDown" class="text-muted-foreground" size="0.8rem" />
</button>
</div>
<!-- path bar -->
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5">
<button
(click)="panel().up()"
title="Parent directory (Backspace)"
class="grid size-7 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaArrowUp" size="1rem" />
</button>
<input
[value]="pathDraft()"
(input)="pathDraft.set($any($event.target).value)"
(keydown.enter)="commitPath()"
(keydown.escape)="resetPath()"
(keydown)="$event.stopPropagation()"
spellcheck="false"
class="h-7 min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono text-xs outline-none focus:ring-2 focus:ring-ring"
/>
<button
(click)="panel().refresh()"
title="Refresh"
class="grid size-7 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
>
@if (panel().loading()) {
<ng-icon name="mynaSpinner" size="1rem" class="animate-spin" />
} @else {
<ng-icon name="mynaRefresh" size="1rem" />
}
</button>
</div>
<!-- column headers -->
<div
class="grid grid-cols-[minmax(0,1fr)_5.5rem_8.5rem] border-b border-border text-[11px] font-medium text-muted-foreground"
>
<button class="px-2 py-1 text-left hover:text-foreground" (click)="panel().toggleSort('name')">
Name {{ sortIndicator('name') }}
</button>
<button class="px-2 py-1 text-right hover:text-foreground" (click)="panel().toggleSort('size')">
Size {{ sortIndicator('size') }}
</button>
<button class="px-2 py-1 text-left hover:text-foreground" (click)="panel().toggleSort('mtime')">
Modified {{ sortIndicator('mtime') }}
</button>
</div>
<!-- file list -->
<div
#list
tabindex="0"
(keydown)="onKeydown($event)"
(mousedown)="activate()"
class="flex-1 overflow-y-auto outline-none"
[class.bg-accent]="false"
>
@for (entry of panel().sorted(); track entry.name; let i = $index) {
<div
[attr.data-index]="i"
(click)="rowClick($event, i)"
(dblclick)="openAt(i)"
class="grid h-7 grid-cols-[minmax(0,1fr)_5.5rem_8.5rem] items-center text-[13px]"
[class.bg-accent]="i === panel().cursor() && !isSelected(entry)"
[class.bg-primary/15]="isSelected(entry)"
[class.ring-1]="i === panel().cursor() && isActive()"
[class.ring-inset]="i === panel().cursor() && isActive()"
[class.ring-ring]="i === panel().cursor() && isActive()"
>
<div class="flex min-w-0 items-center gap-1.5 px-2">
<ng-icon
[name]="iconFor(entry)"
size="0.95rem"
[class]="entry.type === 'dir' ? 'shrink-0 text-primary' : 'shrink-0 text-muted-foreground'"
/>
<span class="truncate" [class.italic]="entry.type === 'symlink'">{{ entry.name }}</span>
</div>
<span class="px-2 text-right font-mono text-xs tabular-nums text-muted-foreground">
{{ entry.type === 'dir' ? '—' : formatSize(entry.size) }}
</span>
<span class="px-2 font-mono text-xs tabular-nums text-muted-foreground">
{{ formatDate(entry.mtime) }}
</span>
</div>
} @empty {
<div class="p-6 text-center text-xs text-muted-foreground">Empty directory</div>
}
</div>
<!-- footer -->
<div class="flex items-center gap-2 border-t border-border px-2 py-1 text-[11px] text-muted-foreground">
<span>{{ panel().sorted().length }} items</span>
@if (panel().selected().size; as count) {
<span>· {{ count }} selected</span>
}
</div>
+154
View File
@@ -0,0 +1,154 @@
import {
Component,
ElementRef,
computed,
effect,
inject,
input,
signal,
viewChild,
} from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import type { FileEntry } from '@shared/types';
import { formatDate, formatSize } from '../format';
import { PanelsService, type Side, type SortKey } from '../panels.service';
@Component({
selector: 'app-file-panel',
imports: [NgIcon],
templateUrl: './file-panel.html',
host: { class: 'flex min-w-0 flex-1 flex-col overflow-hidden' },
})
export class FilePanel {
readonly side = input.required<Side>();
readonly panels = inject(PanelsService);
readonly panel = computed(() => this.panels.get(this.side()));
readonly isActive = computed(() => this.panels.activeSide() === this.side());
readonly pathDraft = signal('');
private readonly listEl = viewChild.required<ElementRef<HTMLDivElement>>('list');
private lastClickedIndex = 0;
readonly formatSize = formatSize;
readonly formatDate = formatDate;
constructor() {
// keep the editable path bar in sync with the actual cwd
effect(() => this.pathDraft.set(this.panel().cwd()));
// focus follows the active side (Tab switches panels)
effect(() => {
if (this.isActive()) this.listEl().nativeElement.focus();
});
}
activate(): void {
this.panels.activeSide.set(this.side());
}
iconFor(entry: FileEntry): string {
return entry.type === 'dir' ? 'mynaFolder' : 'mynaFile';
}
isSelected(entry: FileEntry): boolean {
return this.panel().selected().has(entry.name);
}
sortIndicator(key: SortKey): string {
if (this.panel().sortKey() !== key) return '';
return this.panel().sortAsc() ? '↑' : '↓';
}
commitPath(): void {
const value = this.pathDraft().trim();
if (value) void this.panel().load(value);
this.listEl().nativeElement.focus();
}
resetPath(): void {
this.pathDraft.set(this.panel().cwd());
this.listEl().nativeElement.focus();
}
rowClick(event: MouseEvent, index: number): void {
this.activate();
const panel = this.panel();
if (event.shiftKey) {
panel.selectRange(this.lastClickedIndex, index);
} else if (event.metaKey || event.ctrlKey) {
const entry = panel.sorted()[index];
if (entry) panel.toggleSelect(entry.name);
} else {
panel.clearSelection();
}
panel.cursor.set(index);
this.lastClickedIndex = index;
}
openAt(index: number): void {
const entry = this.panel().sorted()[index];
if (entry) this.panel().open(entry);
}
onKeydown(event: KeyboardEvent): void {
const panel = this.panel();
const key = event.key;
if (key === 'Tab') {
event.preventDefault();
this.panels.activeSide.set(this.panels.otherSide(this.side()));
return;
}
if ((event.metaKey || event.ctrlKey) && key.toLowerCase() === 'a') {
event.preventDefault();
panel.selectAll();
return;
}
switch (key) {
case 'ArrowDown':
panel.moveCursor(1);
break;
case 'ArrowUp':
panel.moveCursor(-1);
break;
case 'PageDown':
panel.moveCursor(20);
break;
case 'PageUp':
panel.moveCursor(-20);
break;
case 'Home':
panel.cursor.set(0);
break;
case 'End':
panel.cursor.set(Math.max(0, panel.sorted().length - 1));
break;
case ' ':
{
const entry = panel.sorted()[panel.cursor()];
if (entry) panel.toggleSelect(entry.name);
panel.moveCursor(1);
}
break;
case 'Enter':
this.openAt(panel.cursor());
return void event.preventDefault();
case 'Backspace':
panel.up();
break;
default:
return; // let unhandled keys through
}
event.preventDefault();
this.scrollCursorIntoView();
}
private scrollCursorIntoView(): void {
const row = this.listEl().nativeElement.querySelector(
`[data-index="${this.panel().cursor()}"]`,
);
row?.scrollIntoView({ block: 'nearest' });
}
}
+34
View File
@@ -0,0 +1,34 @@
const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'];
export function formatSize(bytes: number): string {
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < SIZE_UNITS.length - 1) {
value /= 1024;
unit++;
}
return `${unit === 0 ? value : value.toFixed(1)} ${SIZE_UNITS[unit]}`;
}
const DATE_FORMAT = new Intl.DateTimeFormat(undefined, {
year: '2-digit',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
export function formatDate(epochMs: number): string {
if (!epochMs) return '';
return DATE_FORMAT.format(new Date(epochMs));
}
/** 0o755 -> "rwxr-xr-x" */
export function formatMode(mode: number): string {
const flags = 'rwxrwxrwx';
let out = '';
for (let i = 0; i < 9; i++) {
out += mode & (0o400 >> i) ? flags[i] : '-';
}
return out;
}
+178
View File
@@ -0,0 +1,178 @@
import { Injectable, computed, signal } from '@angular/core';
import type { FileEntry } from '@shared/types';
import { RpcService } from './rpc.service';
import { ToastService } from './toast.service';
export type Side = 'left' | 'right';
export type SortKey = 'name' | 'size' | 'mtime';
interface PanelDeps {
rpc: RpcService;
toast: ToastService;
}
export class PanelState {
readonly sessionId = signal('local');
readonly title = signal('Local');
readonly cwd = signal('');
readonly entries = signal<FileEntry[]>([]);
readonly loading = signal(false);
readonly sortKey = signal<SortKey>('name');
readonly sortAsc = signal(true);
readonly cursor = signal(0);
readonly selected = signal<ReadonlySet<string>>(new Set());
/** Directories first, then the active sort column. */
readonly sorted = computed(() => {
const list = [...this.entries()];
const key = this.sortKey();
const dir = this.sortAsc() ? 1 : -1;
list.sort((a, b) => {
const aDir = a.type === 'dir';
const bDir = b.type === 'dir';
if (aDir !== bDir) return aDir ? -1 : 1;
let cmp = 0;
if (key === 'size') cmp = a.size - b.size;
else if (key === 'mtime') cmp = a.mtime - b.mtime;
if (cmp === 0) {
cmp = a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
if (key === 'name') return cmp * dir;
}
return cmp * dir;
});
return list;
});
readonly selectedEntries = computed(() => {
const names = this.selected();
return this.sorted().filter((e) => names.has(e.name));
});
constructor(private deps: PanelDeps) {}
async load(path: string | null): Promise<void> {
this.loading.set(true);
try {
const res = await this.deps.rpc.request.listDir({ sessionId: this.sessionId(), path });
if (!res.ok) {
this.deps.toast.error(res.error);
return;
}
this.cwd.set(res.path);
this.entries.set(res.entries);
this.cursor.set(0);
this.selected.set(new Set());
} finally {
this.loading.set(false);
}
}
/** Reload cwd, keeping cursor position and selection by name. */
async refresh(): Promise<void> {
const keepCursorName = this.sorted()[this.cursor()]?.name;
const keepSelected = this.selected();
this.loading.set(true);
try {
const res = await this.deps.rpc.request.listDir({
sessionId: this.sessionId(),
path: this.cwd(),
});
if (!res.ok) {
this.deps.toast.error(res.error);
return;
}
this.cwd.set(res.path);
this.entries.set(res.entries);
const names = new Set(res.entries.map((e) => e.name));
this.selected.set(new Set([...keepSelected].filter((n) => names.has(n))));
const idx = this.sorted().findIndex((e) => e.name === keepCursorName);
this.cursor.set(idx >= 0 ? idx : Math.min(this.cursor(), Math.max(0, this.sorted().length - 1)));
} finally {
this.loading.set(false);
}
}
open(entry: FileEntry): void {
if (entry.type === 'file') return;
// symlinks: try to open as a dir; the error toast covers file links
void this.load(entry.path);
}
up(): void {
if (!this.cwd()) return;
void this.load(`${this.cwd()}/..`);
}
toggleSort(key: SortKey): void {
if (this.sortKey() === key) {
this.sortAsc.update((v) => !v);
} else {
this.sortKey.set(key);
this.sortAsc.set(true);
}
}
moveCursor(delta: number): void {
const max = this.sorted().length - 1;
if (max < 0) return;
this.cursor.set(Math.max(0, Math.min(max, this.cursor() + delta)));
}
toggleSelect(name: string): void {
this.selected.update((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
}
selectRange(fromIndex: number, toIndex: number): void {
const [lo, hi] = fromIndex <= toIndex ? [fromIndex, toIndex] : [toIndex, fromIndex];
const names = this.sorted()
.slice(lo, hi + 1)
.map((e) => e.name);
this.selected.update((prev) => new Set([...prev, ...names]));
}
selectAll(): void {
this.selected.set(new Set(this.sorted().map((e) => e.name)));
}
clearSelection(): void {
if (this.selected().size) this.selected.set(new Set());
}
}
@Injectable({ providedIn: 'root' })
export class PanelsService {
readonly left: PanelState;
readonly right: PanelState;
readonly activeSide = signal<Side>('left');
constructor(rpc: RpcService, toast: ToastService) {
const deps = { rpc, toast };
this.left = new PanelState(deps);
this.right = new PanelState(deps);
if (rpc.available) {
void this.left.load(null);
void this.right.load(null);
}
}
get(side: Side): PanelState {
return side === 'left' ? this.left : this.right;
}
active(): PanelState {
return this.get(this.activeSide());
}
otherSide(side: Side): Side {
return side === 'left' ? 'right' : 'left';
}
other(side: Side): PanelState {
return this.get(this.otherSide(side));
}
}
+42
View File
@@ -0,0 +1,42 @@
import { Injectable, signal } from '@angular/core';
import { Electroview } from 'electrobun/view';
import type { AppRPC } from '@shared/rpc';
import type { TransferSnapshot } from '@shared/types';
// Matches MAX_REQUEST_TIME_MS on the bun side — SFTP ops can be slow.
const MAX_REQUEST_TIME_MS = 10 * 60_000;
export type SessionClosedEvent = { sessionId: string; reason: string };
@Injectable({ providedIn: 'root' })
export class RpcService {
/** False when the page is opened in a plain browser instead of the app. */
readonly available: boolean;
readonly transferUpdate = signal<TransferSnapshot | null>(null);
readonly sessionClosed = signal<SessionClosedEvent | null>(null);
private rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined;
constructor() {
this.available = typeof window !== 'undefined' && '__electrobun' in window;
if (!this.available) return;
this.rpc = Electroview.defineRPC<AppRPC>({
maxRequestTime: MAX_REQUEST_TIME_MS,
handlers: {
messages: {
transferUpdate: (snapshot) => this.transferUpdate.set(snapshot),
sessionClosed: (event) => this.sessionClosed.set(event),
},
},
});
new Electroview({ rpc: this.rpc });
}
/** Typed request proxy: `await rpc.request.listDir({...})`. */
get request() {
if (!this.rpc) throw new Error('RPC is only available inside the desktop app');
return this.rpc.request;
}
}
+35
View File
@@ -0,0 +1,35 @@
import { Injectable, signal } from '@angular/core';
export interface Toast {
id: number;
kind: 'error' | 'info' | 'success';
text: string;
}
@Injectable({ providedIn: 'root' })
export class ToastService {
readonly toasts = signal<Toast[]>([]);
private nextId = 1;
error(text: string) {
this.show('error', text, 6000);
}
info(text: string) {
this.show('info', text, 3000);
}
success(text: string) {
this.show('success', text, 3000);
}
dismiss(id: number) {
this.toasts.update((list) => list.filter((t) => t.id !== id));
}
private show(kind: Toast['kind'], text: string, ttlMs: number) {
const toast = { id: this.nextId++, kind, text };
this.toasts.update((list) => [...list, toast]);
setTimeout(() => this.dismiss(toast.id), ttlMs);
}
}