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
+62 -5
View File
@@ -1,5 +1,5 @@
import { Injectable, computed, signal } from '@angular/core';
import type { FileEntry } from '@shared/types';
import { Injectable, computed, effect, signal } from '@angular/core';
import type { ConnectionConfig, FileEntry } from '@shared/types';
import { RpcService } from './rpc.service';
import { ToastService } from './toast.service';
@@ -14,6 +14,7 @@ interface PanelDeps {
export class PanelState {
readonly sessionId = signal('local');
readonly title = signal('Local');
readonly connecting = signal(false);
readonly cwd = signal('');
readonly entries = signal<FileEntry[]>([]);
readonly loading = signal(false);
@@ -152,14 +153,70 @@ export class PanelsService {
/** Bumped to tell the active panel to grab keyboard focus again (e.g. after a dialog closes). */
readonly focusTick = signal(0);
constructor(rpc: RpcService, toast: ToastService) {
constructor(
private rpc: RpcService,
private 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);
// this service is first injected during template rendering, where signal
// writes (load -> loading.set) are forbidden — defer past the render pass
queueMicrotask(() => {
void this.left.load(null);
void this.right.load(null);
});
}
// a session died on its own — fall the affected panel(s) back to local
effect(() => {
const event = this.rpc.sessionClosed();
if (!event) return;
for (const panel of [this.left, this.right]) {
if (panel.sessionId() === event.sessionId) {
this.toast.error(`${panel.title()}: disconnected (${event.reason})`);
this.resetToLocal(panel);
}
}
});
}
/** Connect a panel to a server; replaces (and disconnects) its previous remote session. */
async connectPanel(side: Side, config: ConnectionConfig, title: string): Promise<boolean> {
const panel = this.get(side);
panel.connecting.set(true);
try {
const res = await this.rpc.request.connect({ config });
if (!res.ok) {
this.toast.error(`${title}: ${res.error}`);
return false;
}
const previous = panel.sessionId();
if (previous !== 'local') {
void this.rpc.request.disconnect({ sessionId: previous });
}
panel.sessionId.set(res.sessionId);
panel.title.set(title);
await panel.load(config.initialPath ?? res.homeDir);
return true;
} finally {
panel.connecting.set(false);
}
}
async disconnectPanel(side: Side): Promise<void> {
const panel = this.get(side);
const sessionId = panel.sessionId();
if (sessionId === 'local') return;
await this.rpc.request.disconnect({ sessionId });
this.resetToLocal(panel);
}
private resetToLocal(panel: PanelState): void {
panel.sessionId.set('local');
panel.title.set('Local');
void panel.load(null);
}
get(side: Side): PanelState {