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
+77 -5
View File
@@ -1,13 +1,85 @@
<!-- session strip -->
<div class="flex items-center gap-1.5 border-b border-border px-2 py-1.5">
<div class="relative 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"
(click)="menuOpen.set(!menuOpen())"
class="flex min-w-0 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" />
@if (panel().connecting()) {
<ng-icon name="mynaSpinner" class="shrink-0 animate-spin text-primary" size="0.9rem" />
} @else {
<ng-icon
[name]="isRemote() ? 'mynaServers' : 'mynaHardDrive'"
[class]="isRemote() ? 'shrink-0 text-primary' : 'shrink-0 text-muted-foreground'"
size="0.9rem"
/>
}
<span class="truncate">{{ panel().title() }}</span>
<ng-icon name="mynaChevronDown" class="shrink-0 text-muted-foreground" size="0.8rem" />
</button>
@if (isRemote()) {
<button
(click)="disconnect()"
title="Disconnect (back to local)"
class="grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaLogout" size="0.85rem" />
</button>
}
@if (menuOpen()) {
<div class="fixed inset-0 z-20" (click)="menuOpen.set(false)"></div>
<div
class="absolute top-full left-2 z-30 mt-1 max-h-80 w-64 overflow-y-auto rounded-lg border border-border bg-card py-1 text-card-foreground shadow-xl"
>
<button
(click)="pickLocal()"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-accent"
>
<ng-icon name="mynaHardDrive" size="0.9rem" class="shrink-0 text-muted-foreground" />
<span class="flex-1">Local</span>
@if (!isRemote()) {
<ng-icon name="mynaCheck" size="0.85rem" class="shrink-0 text-primary" />
}
</button>
@if (connections.grouped().root.length || connections.grouped().folders.length) {
<div class="my-1 border-t border-border"></div>
}
@for (connection of connections.grouped().root; track connection.id) {
<button
(click)="pickConnection(connection)"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-accent"
>
<ng-icon name="mynaServers" size="0.9rem" class="shrink-0 text-muted-foreground" />
<span class="flex-1 truncate">{{ connection.name }}</span>
</button>
}
@for (group of connections.grouped().folders; track group.folder.id) {
<div class="px-3 pt-1.5 pb-0.5 text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
{{ group.folder.name }}
</div>
@for (connection of group.connections; track connection.id) {
<button
(click)="pickConnection(connection)"
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-xs hover:bg-accent"
>
<ng-icon name="mynaServers" size="0.9rem" class="shrink-0 text-muted-foreground" />
<span class="flex-1 truncate">{{ connection.name }}</span>
</button>
}
}
<div class="my-1 border-t border-border"></div>
<button
(click)="openManager()"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-accent"
>
<ng-icon name="mynaCog" size="0.9rem" class="shrink-0 text-muted-foreground" />
Manage connections…
</button>
</div>
}
</div>
<!-- path bar -->
+28 -1
View File
@@ -9,7 +9,9 @@ import {
viewChild,
} from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import type { FileEntry } from '@shared/types';
import type { FileEntry, SavedConnection } from '@shared/types';
import { ConnectionsService } from '../connections.service';
import { DialogsService } from '../dialogs.service';
import { FileActionsService } from '../file-actions.service';
import { formatDate, formatSize } from '../format';
import { PanelsService, type Side, type SortKey } from '../panels.service';
@@ -27,10 +29,14 @@ export class FilePanel {
readonly panels = inject(PanelsService);
readonly actions = inject(FileActionsService);
readonly transfers = inject(TransfersService);
readonly connections = inject(ConnectionsService);
readonly dialogs = inject(DialogsService);
readonly panel = computed(() => this.panels.get(this.side()));
readonly isActive = computed(() => this.panels.activeSide() === this.side());
readonly isRemote = computed(() => this.panel().sessionId() !== 'local');
readonly pathDraft = signal('');
readonly menuOpen = signal(false);
private readonly listEl = viewChild.required<ElementRef<HTMLDivElement>>('list');
private lastClickedIndex = 0;
@@ -52,6 +58,27 @@ export class FilePanel {
this.panels.activeSide.set(this.side());
}
pickLocal(): void {
this.menuOpen.set(false);
void this.panels.disconnectPanel(this.side());
}
pickConnection(connection: SavedConnection): void {
this.menuOpen.set(false);
this.activate();
void this.panels.connectPanel(this.side(), connection, connection.name);
}
openManager(): void {
this.menuOpen.set(false);
this.activate();
this.dialogs.manager.set(true);
}
disconnect(): void {
void this.panels.disconnectPanel(this.side());
}
iconFor(entry: FileEntry): string {
return entry.type === 'dir' ? 'mynaFolder' : 'mynaFile';
}