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
+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>