P4: SFTP provider, connection store, connection manager UI

- SftpProvider (ssh2): password + key auth, keepalive, clean-dispose vs
  unexpected-close distinction, POSIX path helpers, s->ms attr normalization
- ConnectionStore: plain JSON at appDataDir, 0600, atomic writes, folders
- connect/disconnect + store CRUD handlers; sessionClosed push -> panels fall
  back to local and in-flight jobs fail
- UI: session dropdown per panel (local/saved connections/manage), disconnect
  button, connection manager modal (folders, edit form, save/connect)
- electrobun bundler: external cpu-features/*.node (ssh2 optional natives)
- RpcService: gate requests on RPC socket open; defer initial panel loads past
  first render
- verified against 192.168.10.122: full op matrix, upload/download/remote-remote
  recursive transfers, roundtrip integrity, recursive remote delete, store CRUD

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jokin
2026-07-11 14:12:00 +02:00
co-authored by Claude Fable 5
parent 88af44acfc
commit 0d0e4cfde4
16 changed files with 1033 additions and 31 deletions
@@ -0,0 +1,246 @@
<div class="fixed inset-0 z-30 grid place-items-center bg-black/40" (mousedown)="close()">
<div
class="flex h-[440px] w-[680px] flex-col rounded-lg border border-border bg-card text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()"
>
<!-- title bar -->
<div class="flex items-center justify-between border-b border-border px-4 py-2.5">
<h2 class="text-sm font-semibold">Connections</h2>
<button (click)="close()" class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground">
<ng-icon name="mynaX" size="1rem" />
</button>
</div>
<div class="flex min-h-0 flex-1">
<!-- list -->
<div class="flex w-60 shrink-0 flex-col border-r border-border">
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5">
<button
(click)="newConnection()"
title="New connection"
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaPlus" size="0.85rem" /> Connection
</button>
<button
(click)="newFolder()"
title="New folder"
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaFolderPlus" size="0.85rem" /> Folder
</button>
</div>
<div class="flex-1 overflow-y-auto py-1">
@for (connection of store.grouped().root; track connection.id) {
<button
(click)="edit(connection)"
class="flex w-full items-center gap-1.5 px-3 py-1.5 text-left text-xs hover:bg-accent"
[class.bg-accent]="draft()?.id === connection.id"
>
<ng-icon name="mynaServers" size="0.85rem" class="shrink-0 text-muted-foreground" />
<span class="truncate">{{ connection.name }}</span>
</button>
}
@for (group of store.grouped().folders; track group.folder.id) {
<div class="group flex items-center gap-1.5 px-3 pt-2 pb-0.5">
<ng-icon name="mynaFolder" size="0.85rem" class="shrink-0 text-muted-foreground" />
<span class="flex-1 truncate text-[11px] font-medium text-muted-foreground uppercase">
{{ group.folder.name }}
</span>
<button
(click)="deleteFolder(group.folder.id, group.folder.name)"
title="Delete folder"
class="hidden rounded p-0.5 text-muted-foreground group-hover:block hover:text-destructive"
>
<ng-icon name="mynaTrash" size="0.75rem" />
</button>
</div>
@for (connection of group.connections; track connection.id) {
<button
(click)="edit(connection)"
class="flex w-full items-center gap-1.5 py-1.5 pr-3 pl-6 text-left text-xs hover:bg-accent"
[class.bg-accent]="draft()?.id === connection.id"
>
<ng-icon name="mynaServers" size="0.85rem" class="shrink-0 text-muted-foreground" />
<span class="truncate">{{ connection.name }}</span>
</button>
} @empty {
<div class="py-1 pl-6 text-[11px] text-muted-foreground italic">empty</div>
}
}
@if (!store.connections().length && !store.folders().length) {
<p class="px-3 py-2 text-[11px] text-muted-foreground">
No saved connections yet. Create one to get started.
</p>
}
</div>
</div>
<!-- edit form -->
<div class="flex min-w-0 flex-1 flex-col">
@if (draft(); as d) {
<div class="grid flex-1 auto-rows-min grid-cols-2 gap-x-3 gap-y-2.5 overflow-y-auto p-4">
<label class="col-span-2 flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">Name</span>
<input
[value]="d.name"
(input)="patch('name', $any($event.target).value)"
placeholder="my server"
spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">Host</span>
<input
[value]="d.host"
(input)="patch('host', $any($event.target).value)"
placeholder="192.168.1.10"
spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<div class="grid grid-cols-2 gap-3">
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">Port</span>
<input
type="number"
[value]="d.port"
(input)="patch('port', +$any($event.target).value)"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">Folder</span>
<select
[value]="d.folderId ?? ''"
(change)="patch('folderId', $any($event.target).value || null)"
class="h-7 rounded-md border border-input bg-background px-1 outline-none focus:ring-2 focus:ring-ring"
>
<option value="">(none)</option>
@for (folder of store.folders(); track folder.id) {
<option [value]="folder.id">{{ folder.name }}</option>
}
</select>
</label>
</div>
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">Username</span>
<input
[value]="d.username"
(input)="patch('username', $any($event.target).value)"
spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">Remote path (optional)</span>
<input
[value]="d.initialPath"
(input)="patch('initialPath', $any($event.target).value)"
placeholder="home directory"
spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<div class="col-span-2 flex items-center gap-4 text-xs">
<span class="text-muted-foreground">Auth</span>
<label class="flex items-center gap-1.5">
<input
type="radio"
name="authKind"
[checked]="d.authKind === 'password'"
(change)="patch('authKind', 'password')"
class="accent-[var(--primary)]"
/>
Password
</label>
<label class="flex items-center gap-1.5">
<input
type="radio"
name="authKind"
[checked]="d.authKind === 'key'"
(change)="patch('authKind', 'key')"
class="accent-[var(--primary)]"
/>
Private key
</label>
</div>
@if (d.authKind === 'password') {
<label class="col-span-2 flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">Password</span>
<input
type="password"
[value]="d.password"
(input)="patch('password', $any($event.target).value)"
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
/>
</label>
} @else {
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">Private key file</span>
<input
[value]="d.privateKeyPath"
(input)="patch('privateKeyPath', $any($event.target).value)"
placeholder="~/.ssh/id_ed25519"
spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/>
</label>
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">Passphrase (optional)</span>
<input
type="password"
[value]="d.passphrase"
(input)="patch('passphrase', $any($event.target).value)"
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
/>
</label>
}
</div>
<div class="flex items-center gap-2 border-t border-border px-4 py-2.5">
<button
(click)="deleteCurrent()"
class="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-destructive"
>
<ng-icon name="mynaTrash" size="0.85rem" /> Delete
</button>
<div class="flex-1"></div>
<button
(click)="save()"
[disabled]="!valid()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent disabled:opacity-50"
>
Save
</button>
<button
(click)="saveAndConnect()"
[disabled]="!valid() || busy()"
class="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
@if (busy()) {
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
}
Connect
</button>
</div>
} @else {
<div class="grid flex-1 place-items-center">
<div class="text-center">
<ng-icon name="mynaServers" size="1.75rem" class="text-muted-foreground" />
<p class="mt-2 text-xs text-muted-foreground">
Select a connection or create a new one.
</p>
</div>
</div>
}
</div>
</div>
</div>
</div>