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
+13
View File
@@ -2,6 +2,16 @@
<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>
<div class="flex-1"></div>
@if (rpc.available) {
<button
(click)="dialogs.manager.set(true)"
title="Manage connections"
class="grid size-6 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaCog" size="0.95rem" />
</button>
}
</header>
@if (!rpc.available) {
@@ -70,6 +80,9 @@
}
<!-- dialogs -->
@if (dialogs.manager()) {
<app-connection-manager />
}
@if (dialogs.prompt(); as req) {
<app-prompt-dialog [request]="req" />
}
+10 -1
View File
@@ -1,6 +1,7 @@
import { Component, inject } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { BRANDING } from '@shared/branding';
import { ConnectionManager } from './connection-manager/connection-manager';
import { ChmodDialog } from './dialogs/chmod-dialog';
import { ConfirmDialog } from './dialogs/confirm-dialog';
import { PromptDialog } from './dialogs/prompt-dialog';
@@ -14,7 +15,15 @@ import { TransfersService } from './transfers.service';
@Component({
selector: 'app-root',
imports: [NgIcon, FilePanel, PromptDialog, ConfirmDialog, ChmodDialog, TransferBar],
imports: [
NgIcon,
FilePanel,
PromptDialog,
ConfirmDialog,
ChmodDialog,
TransferBar,
ConnectionManager,
],
templateUrl: './app.html',
})
export class App {
@@ -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>
@@ -0,0 +1,170 @@
import { Component, computed, inject, signal } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import type { SavedConnection } from '@shared/types';
import { ConnectionsService } from '../connections.service';
import { DialogsService } from '../dialogs.service';
import { PanelsService } from '../panels.service';
interface Draft {
id: string;
name: string;
folderId: string | null;
host: string;
port: number;
username: string;
authKind: 'password' | 'key';
password: string;
privateKeyPath: string;
passphrase: string;
initialPath: string;
}
function toDraft(c: SavedConnection): Draft {
return {
id: c.id,
name: c.name,
folderId: c.folderId,
host: c.host,
port: c.port,
username: c.username,
authKind: c.auth.kind,
password: c.auth.kind === 'password' ? c.auth.password : '',
privateKeyPath: c.auth.kind === 'key' ? c.auth.privateKeyPath : '',
passphrase: c.auth.kind === 'key' ? (c.auth.passphrase ?? '') : '',
initialPath: c.initialPath ?? '',
};
}
function fromDraft(d: Draft): SavedConnection {
return {
id: d.id,
name: d.name.trim() || `${d.username}@${d.host}`,
folderId: d.folderId,
host: d.host.trim(),
port: d.port || 22,
username: d.username.trim(),
auth:
d.authKind === 'password'
? { kind: 'password', password: d.password }
: {
kind: 'key',
privateKeyPath: d.privateKeyPath.trim(),
...(d.passphrase ? { passphrase: d.passphrase } : {}),
},
initialPath: d.initialPath.trim() || null,
};
}
@Component({
selector: 'app-connection-manager',
imports: [NgIcon],
templateUrl: './connection-manager.html',
})
export class ConnectionManager {
readonly store = inject(ConnectionsService);
readonly dialogs = inject(DialogsService);
readonly panels = inject(PanelsService);
readonly draft = signal<Draft | null>(null);
readonly busy = signal(false);
readonly valid = computed(() => {
const d = this.draft();
if (!d) return false;
if (!d.host.trim() || !d.username.trim()) return false;
if (d.authKind === 'key' && !d.privateKeyPath.trim()) return false;
return true;
});
patch<K extends keyof Draft>(key: K, value: Draft[K]): void {
this.draft.update((d) => (d ? { ...d, [key]: value } : d));
}
newConnection(): void {
this.draft.set({
id: '',
name: '',
folderId: null,
host: '',
port: 22,
username: 'root',
authKind: 'password',
password: '',
privateKeyPath: '',
passphrase: '',
initialPath: '',
});
}
edit(connection: SavedConnection): void {
this.draft.set(toDraft(connection));
}
close(): void {
this.dialogs.manager.set(false);
this.panels.refocus();
}
async save(): Promise<string | null> {
const d = this.draft();
if (!d || !this.valid()) return null;
const id = await this.store.upsert(fromDraft(d));
if (id) this.patch('id', id);
return id;
}
async saveAndConnect(): Promise<void> {
const d = this.draft();
if (!d) return;
this.busy.set(true);
try {
const id = await this.save();
if (!id) return;
const connection = fromDraft({ ...d, id });
const ok = await this.panels.connectPanel(
this.panels.activeSide(),
connection,
connection.name,
);
if (ok) this.close();
} finally {
this.busy.set(false);
}
}
deleteCurrent(): void {
const d = this.draft();
if (!d?.id) {
this.draft.set(null);
return;
}
this.dialogs.confirm.set({
title: 'Delete connection',
body: `Delete "${d.name || d.host}"?`,
confirmLabel: 'Delete',
destructive: true,
onConfirm: async () => {
await this.store.remove(d.id);
this.draft.set(null);
},
});
}
newFolder(): void {
this.dialogs.prompt.set({
title: 'New folder',
placeholder: 'folder name',
confirmLabel: 'Create',
onConfirm: (name) => void this.store.upsertFolder({ id: '', name }),
});
}
deleteFolder(id: string, name: string): void {
this.dialogs.confirm.set({
title: 'Delete folder',
body: `Delete folder "${name}"? Its connections move to the root.`,
confirmLabel: 'Delete',
destructive: true,
onConfirm: () => void this.store.removeFolder(id),
});
}
}
+67
View File
@@ -0,0 +1,67 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import type { ConnFolder, SavedConnection } from '@shared/types';
import { RpcService } from './rpc.service';
import { ToastService } from './toast.service';
@Injectable({ providedIn: 'root' })
export class ConnectionsService {
private readonly rpc = inject(RpcService);
private readonly toast = inject(ToastService);
readonly folders = signal<ConnFolder[]>([]);
readonly connections = signal<SavedConnection[]>([]);
/** Root-level connections plus one group per folder (folders always shown). */
readonly grouped = computed(() => {
const connections = this.connections();
return {
root: connections.filter((c) => !c.folderId),
folders: this.folders().map((folder) => ({
folder,
connections: connections.filter((c) => c.folderId === folder.id),
})),
};
});
constructor() {
if (this.rpc.available) void this.reload();
}
async reload(): Promise<void> {
const res = await this.rpc.request.getConnections({});
if (!res.ok) {
this.toast.error(res.error);
return;
}
this.folders.set(res.store.folders);
this.connections.set(res.store.connections);
}
async upsert(connection: SavedConnection): Promise<string | null> {
const res = await this.rpc.request.upsertConnection({ connection });
if (!res.ok) {
this.toast.error(res.error);
return null;
}
await this.reload();
return res.id;
}
async remove(id: string): Promise<void> {
const res = await this.rpc.request.deleteConnection({ id });
if (!res.ok) this.toast.error(res.error);
await this.reload();
}
async upsertFolder(folder: ConnFolder): Promise<void> {
const res = await this.rpc.request.upsertFolder({ folder });
if (!res.ok) this.toast.error(res.error);
await this.reload();
}
async removeFolder(id: string): Promise<void> {
const res = await this.rpc.request.deleteFolder({ id });
if (!res.ok) this.toast.error(res.error);
await this.reload();
}
}
+1
View File
@@ -27,6 +27,7 @@ export class DialogsService {
readonly prompt = signal<PromptRequest | null>(null);
readonly confirm = signal<ConfirmRequest | null>(null);
readonly chmod = signal<ChmodRequest | null>(null);
readonly manager = signal(false);
get anyOpen(): boolean {
return !!(this.prompt() || this.confirm() || this.chmod());
+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';
}
+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 {
+29 -4
View File
@@ -17,6 +17,7 @@ export class RpcService {
readonly sessionClosed = signal<SessionClosedEvent | null>(null);
private rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined;
private ready: Promise<void> = Promise.resolve();
constructor() {
this.available = typeof window !== 'undefined' && '__electrobun' in window;
@@ -31,12 +32,36 @@ export class RpcService {
},
},
});
new Electroview({ rpc: this.rpc });
const electroview = new Electroview({ rpc: this.rpc });
// Requests fired before the RPC WebSocket finishes connecting are lost, so
// everything below gates on it being open (services fire calls at startup).
const socket = electroview.bunSocket;
if (socket && socket.readyState !== WebSocket.OPEN) {
this.ready = new Promise((resolve) => {
socket.addEventListener('open', () => resolve(), { once: true });
socket.addEventListener('error', () => resolve(), { once: true });
});
}
}
/** Typed request proxy: `await rpc.request.listDir({...})`. */
/** Typed request proxy: `await rpc.request.listDir({...})`. Waits for the bridge. */
get request() {
if (!this.rpc) throw new Error('RPC is only available inside the desktop app');
return this.rpc.request;
const rpc = this.rpc;
if (!rpc) throw new Error('RPC is only available inside the desktop app');
const ready = this.ready;
return new Proxy({} as (typeof rpc)['request'], {
get:
(_target, method: string) =>
async (params?: unknown) => {
await ready;
const methods = rpc.request as unknown as Record<
string,
(p?: unknown) => Promise<unknown>
>;
return methods[method](params);
},
});
}
}