diff --git a/electrobun.config.ts b/electrobun.config.ts index 66a3395..b9155f4 100644 --- a/electrobun.config.ts +++ b/electrobun.config.ts @@ -10,6 +10,9 @@ export default { build: { bun: { entrypoint: "src/bun/index.ts", + // ssh2's optional native accelerators must never be bundled; its + // runtime try/catch falls back to pure JS when the require fails. + external: ["cpu-features", "*.node"], }, copy: { "ui/dist/ui/browser": "views/app", diff --git a/src/bun/fs/sftp.ts b/src/bun/fs/sftp.ts new file mode 100644 index 0000000..10f12fe --- /dev/null +++ b/src/bun/fs/sftp.ts @@ -0,0 +1,177 @@ +import { readFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { Client, type ConnectConfig, type SFTPWrapper } from "ssh2"; +import type { ConnectionConfig, EntryType, FileEntry } from "../../shared/types"; +import type { FileSystemProvider } from "./provider"; + +const S_IFMT = 0o170000; +const S_IFDIR = 0o040000; +const S_IFLNK = 0o120000; + +function typeFromMode(mode: number): EntryType { + const fmt = mode & S_IFMT; + if (fmt === S_IFDIR) return "dir"; + if (fmt === S_IFLNK) return "symlink"; + return "file"; +} + +function expandTilde(p: string): string { + if (p === "~") return os.homedir(); + if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2)); + return p; +} + +export class SftpProvider implements FileSystemProvider { + readonly kind = "sftp" as const; + private closedByUs = false; + + private constructor( + private client: Client, + private sftp: SFTPWrapper, + ) {} + + /** Resolves once the SSH connection is ready and the SFTP subsystem is open. */ + static async connect( + config: ConnectionConfig, + onClose: (reason: string) => void, + ): Promise { + const connectConfig: ConnectConfig = { + host: config.host, + port: config.port, + username: config.username, + readyTimeout: 20_000, + keepaliveInterval: 15_000, + keepaliveCountMax: 3, + }; + if (config.auth.kind === "password") { + connectConfig.password = config.auth.password; + } else { + connectConfig.privateKey = readFileSync(expandTilde(config.auth.privateKeyPath)); + if (config.auth.passphrase) connectConfig.passphrase = config.auth.passphrase; + } + + const client = new Client(); + const provider = await new Promise((resolve, reject) => { + client + .on("ready", () => { + client.sftp((err, sftp) => { + if (err) { + client.end(); + reject(err); + } else { + resolve(new SftpProvider(client, sftp)); + } + }); + }) + .on("error", reject) + .connect(connectConfig); + }); + + let notified = false; + const notify = (reason: string) => { + if (notified || provider.closedByUs) return; + notified = true; + onClose(reason); + }; + client.on("close", () => notify("connection closed")); + client.on("error", (err) => notify(err.message)); + return provider; + } + + join = (...parts: string[]) => path.posix.join(...parts); + dirname = (p: string) => path.posix.dirname(p); + basename = (p: string) => path.posix.basename(p); + normalize = (p: string) => path.posix.normalize(p); + + homeDir(): Promise { + return new Promise((resolve, reject) => { + this.sftp.realpath(".", (err, abs) => (err ? reject(err) : resolve(abs))); + }); + } + + list(dir: string): Promise { + return new Promise((resolve, reject) => { + this.sftp.readdir(dir, (err, entries) => { + if (err) return reject(err); + resolve( + entries + .filter((e) => e.filename !== "." && e.filename !== "..") + .map((e) => ({ + name: e.filename, + path: path.posix.join(dir, e.filename), + type: typeFromMode(e.attrs.mode), + size: e.attrs.size, + mtime: e.attrs.mtime * 1000, // sftp attrs are seconds + mode: e.attrs.mode & 0o7777, + })), + ); + }); + }); + } + + stat(p: string): Promise { + return new Promise((resolve, reject) => { + this.sftp.lstat(p, (err, stats) => { + if (err) return reject(err); + resolve({ + name: path.posix.basename(p), + path: p, + type: typeFromMode(stats.mode), + size: stats.size, + mtime: stats.mtime * 1000, + mode: stats.mode & 0o7777, + }); + }); + }); + } + + mkdir(p: string): Promise { + return new Promise((resolve, reject) => { + this.sftp.mkdir(p, (err) => (err ? reject(err) : resolve())); + }); + } + + createEmptyFile(p: string): Promise { + return new Promise((resolve, reject) => { + this.sftp.writeFile(p, "", { flag: "wx" }, (err) => (err ? reject(err) : resolve())); + }); + } + + rename(oldPath: string, newPath: string): Promise { + return new Promise((resolve, reject) => { + this.sftp.rename(oldPath, newPath, (err) => (err ? reject(err) : resolve())); + }); + } + + deleteFile(p: string): Promise { + return new Promise((resolve, reject) => { + this.sftp.unlink(p, (err) => (err ? reject(err) : resolve())); + }); + } + + deleteEmptyDir(p: string): Promise { + return new Promise((resolve, reject) => { + this.sftp.rmdir(p, (err) => (err ? reject(err) : resolve())); + }); + } + + chmod(p: string, mode: number): Promise { + return new Promise((resolve, reject) => { + this.sftp.chmod(p, mode, (err) => (err ? reject(err) : resolve())); + }); + } + + createReadStream(p: string) { + return this.sftp.createReadStream(p); + } + + createWriteStream(p: string) { + return this.sftp.createWriteStream(p); + } + + async dispose(): Promise { + this.closedByUs = true; + this.client.end(); + } +} diff --git a/src/bun/index.ts b/src/bun/index.ts index d238a40..4e85879 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -2,6 +2,7 @@ import { BrowserWindow, Updater } from "electrobun/bun"; import { BRANDING } from "../shared/branding"; import { SessionRegistry } from "./fs/registry"; import { createAppRPC } from "./rpc-handlers"; +import { ConnectionStore } from "./store/connections"; import { TransferEngine } from "./transfers/engine"; const PROD_URL = "views://app/index.html"; @@ -26,7 +27,9 @@ async function getMainViewUrl(): Promise { const registry = new SessionRegistry(); const engine = new TransferEngine(registry); -const rpc = createAppRPC(registry, engine); +const store = new ConnectionStore(); +const events = { sessionClosed: (_info: { sessionId: string; reason: string }) => {} }; +const rpc = createAppRPC({ registry, engine, store, events }); const url = await getMainViewUrl(); new BrowserWindow({ @@ -42,5 +45,6 @@ new BrowserWindow({ }); engine.onUpdate = (snapshot) => rpc.send.transferUpdate(snapshot); +events.sessionClosed = (info) => rpc.send.sessionClosed(info); console.log(`${BRANDING.appName} started (${url})`); diff --git a/src/bun/rpc-handlers.ts b/src/bun/rpc-handlers.ts index b3ec9c7..0e2c1f3 100644 --- a/src/bun/rpc-handlers.ts +++ b/src/bun/rpc-handlers.ts @@ -2,6 +2,8 @@ import { BrowserView } from "electrobun/bun"; import type { AppRPC } from "../shared/rpc"; import type { Result } from "../shared/types"; import type { SessionRegistry } from "./fs/registry"; +import { SftpProvider } from "./fs/sftp"; +import type { ConnectionStore } from "./store/connections"; import type { TransferEngine } from "./transfers/engine"; import { removeRecursive } from "./transfers/walk"; @@ -9,6 +11,14 @@ import { removeRecursive } from "./transfers/walk"; // default would cut them off. const MAX_REQUEST_TIME_MS = 10 * 60_000; +export interface AppDeps { + registry: SessionRegistry; + engine: TransferEngine; + store: ConnectionStore; + /** Assigned after the rpc object exists (it needs rpc.send). */ + events: { sessionClosed: (info: { sessionId: string; reason: string }) => void }; +} + async function wrap(fn: () => Promise | T): Promise> { try { return { ok: true, ...(await fn()) }; @@ -17,16 +27,37 @@ async function wrap(fn: () => Promise | T): Promise({ maxRequestTime: MAX_REQUEST_TIME_MS, handlers: { requests: { + // ---- sessions ---- + connect: ({ config }) => + wrap(async () => { + let sessionId = ""; + const provider = await SftpProvider.connect(config, (reason) => { + if (!sessionId) return; + registry.drop(sessionId); + engine.failSession(sessionId, reason); + events.sessionClosed({ sessionId, reason }); + }); + sessionId = registry.add(provider); + const homeDir = await provider.homeDir(); + console.log(`[rpc] connected ${sessionId} -> ${config.username}@${config.host}`); + return { sessionId, homeDir }; + }), + + disconnect: ({ sessionId }) => + wrap(async () => { + await registry.remove(sessionId); + console.log(`[rpc] disconnected ${sessionId}`); + return {}; + }), + + // ---- filesystem ---- listDir: ({ sessionId, path }) => wrap(async () => { - console.log(`[rpc] listDir ${sessionId} ${path ?? "(home)"}`); const fs = registry.get(sessionId); const dir = fs.normalize(path ?? (await fs.homeDir())); return { path: dir, entries: await fs.list(dir) }; @@ -68,10 +99,7 @@ export function createAppRPC(registry: SessionRegistry, engine: TransferEngine) return {}; }), - // P4: SFTP sessions - connect: async () => NOT_YET, - disconnect: async () => NOT_YET, - + // ---- transfers ---- enqueueTransfers: (params) => wrap(async () => ({ jobIds: engine.enqueue(params) })), @@ -86,12 +114,27 @@ export function createAppRPC(registry: SessionRegistry, engine: TransferEngine) engine.dismiss(jobId); return {}; }), - // P4: connection store - getConnections: async () => NOT_YET, - upsertConnection: async () => NOT_YET, - deleteConnection: async () => NOT_YET, - upsertFolder: async () => NOT_YET, - deleteFolder: async () => NOT_YET, + + // ---- connection store ---- + getConnections: () => wrap(async () => ({ store: await store.load() })), + + upsertConnection: ({ connection }) => + wrap(async () => ({ id: await store.upsertConnection(connection) })), + + deleteConnection: ({ id }) => + wrap(async () => { + await store.deleteConnection(id); + return {}; + }), + + upsertFolder: ({ folder }) => + wrap(async () => ({ id: await store.upsertFolder(folder) })), + + deleteFolder: ({ id }) => + wrap(async () => { + await store.deleteFolder(id); + return {}; + }), }, }, }); diff --git a/src/bun/store/connections.ts b/src/bun/store/connections.ts new file mode 100644 index 0000000..0bea562 --- /dev/null +++ b/src/bun/store/connections.ts @@ -0,0 +1,70 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { ConnFolder, ConnectionsFile, SavedConnection } from "../../shared/types"; +import { appDataDir } from "./paths"; + +const EMPTY: ConnectionsFile = { version: 1, folders: [], connections: [] }; + +/** + * Plain-JSON store for saved connections (passwords included — the user's + * explicit v1 choice). Kept at 0600 and written atomically via tmp+rename. + */ +export class ConnectionStore { + private readonly file = path.join(appDataDir(), "connections.json"); + private data: ConnectionsFile | null = null; + + async load(): Promise { + if (!this.data) { + try { + this.data = JSON.parse(await fs.readFile(this.file, "utf8")) as ConnectionsFile; + } catch { + this.data = structuredClone(EMPTY); + } + } + return this.data; + } + + async upsertConnection(connection: SavedConnection): Promise { + const data = await this.load(); + const saved = { ...connection, id: connection.id || crypto.randomUUID() }; + const index = data.connections.findIndex((c) => c.id === saved.id); + if (index >= 0) data.connections[index] = saved; + else data.connections.push(saved); + await this.save(); + return saved.id; + } + + async deleteConnection(id: string): Promise { + const data = await this.load(); + data.connections = data.connections.filter((c) => c.id !== id); + await this.save(); + } + + async upsertFolder(folder: ConnFolder): Promise { + const data = await this.load(); + const saved = { ...folder, id: folder.id || crypto.randomUUID() }; + const index = data.folders.findIndex((f) => f.id === saved.id); + if (index >= 0) data.folders[index] = saved; + else data.folders.push(saved); + await this.save(); + return saved.id; + } + + /** Deleting a folder moves its connections to the root. */ + async deleteFolder(id: string): Promise { + const data = await this.load(); + data.folders = data.folders.filter((f) => f.id !== id); + for (const connection of data.connections) { + if (connection.folderId === id) connection.folderId = null; + } + await this.save(); + } + + private async save(): Promise { + await fs.mkdir(path.dirname(this.file), { recursive: true }); + const tmp = `${this.file}.tmp`; + await fs.writeFile(tmp, JSON.stringify(this.data, null, "\t"), { mode: 0o600 }); + await fs.rename(tmp, this.file); + await fs.chmod(this.file, 0o600); + } +} diff --git a/src/bun/store/paths.ts b/src/bun/store/paths.ts new file mode 100644 index 0000000..ed401d9 --- /dev/null +++ b/src/bun/store/paths.ts @@ -0,0 +1,18 @@ +import os from "node:os"; +import path from "node:path"; +import { BRANDING } from "../../shared/branding"; + +export function appDataDir(): string { + const home = os.homedir(); + switch (process.platform) { + case "darwin": + return path.join(home, "Library", "Application Support", BRANDING.appName); + case "win32": + return path.join( + process.env.APPDATA ?? path.join(home, "AppData", "Roaming"), + BRANDING.appName, + ); + default: + return path.join(process.env.XDG_CONFIG_HOME ?? path.join(home, ".config"), BRANDING.appName); + } +} diff --git a/ui/src/app/app.html b/ui/src/app/app.html index eddd6a6..36938ab 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -2,6 +2,16 @@

{{ appName }}

+
+ @if (rpc.available) { + + }
@if (!rpc.available) { @@ -70,6 +80,9 @@ } + @if (dialogs.manager()) { + + } @if (dialogs.prompt(); as req) { } diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index 6111c06..ac6b4a4 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -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 { diff --git a/ui/src/app/connection-manager/connection-manager.html b/ui/src/app/connection-manager/connection-manager.html new file mode 100644 index 0000000..a04604e --- /dev/null +++ b/ui/src/app/connection-manager/connection-manager.html @@ -0,0 +1,246 @@ +
+
+ +
+

Connections

+ +
+ +
+ +
+
+ + +
+ +
+ @for (connection of store.grouped().root; track connection.id) { + + } + @for (group of store.grouped().folders; track group.folder.id) { +
+ + + {{ group.folder.name }} + + +
+ @for (connection of group.connections; track connection.id) { + + } @empty { +
empty
+ } + } + @if (!store.connections().length && !store.folders().length) { +

+ No saved connections yet. Create one to get started. +

+ } +
+
+ + +
+ @if (draft(); as d) { +
+ + + +
+ + +
+ + + + +
+ Auth + + +
+ + @if (d.authKind === 'password') { + + } @else { + + + } +
+ +
+ +
+ + +
+ } @else { +
+
+ +

+ Select a connection or create a new one. +

+
+
+ } +
+
+
+
diff --git a/ui/src/app/connection-manager/connection-manager.ts b/ui/src/app/connection-manager/connection-manager.ts new file mode 100644 index 0000000..b5f7c5f --- /dev/null +++ b/ui/src/app/connection-manager/connection-manager.ts @@ -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(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(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 { + 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 { + 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), + }); + } +} diff --git a/ui/src/app/connections.service.ts b/ui/src/app/connections.service.ts new file mode 100644 index 0000000..9b8efb5 --- /dev/null +++ b/ui/src/app/connections.service.ts @@ -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([]); + readonly connections = signal([]); + + /** 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 { + 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 { + 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 { + const res = await this.rpc.request.deleteConnection({ id }); + if (!res.ok) this.toast.error(res.error); + await this.reload(); + } + + async upsertFolder(folder: ConnFolder): Promise { + const res = await this.rpc.request.upsertFolder({ folder }); + if (!res.ok) this.toast.error(res.error); + await this.reload(); + } + + async removeFolder(id: string): Promise { + const res = await this.rpc.request.deleteFolder({ id }); + if (!res.ok) this.toast.error(res.error); + await this.reload(); + } +} diff --git a/ui/src/app/dialogs.service.ts b/ui/src/app/dialogs.service.ts index a76d701..96c0b7a 100644 --- a/ui/src/app/dialogs.service.ts +++ b/ui/src/app/dialogs.service.ts @@ -27,6 +27,7 @@ export class DialogsService { readonly prompt = signal(null); readonly confirm = signal(null); readonly chmod = signal(null); + readonly manager = signal(false); get anyOpen(): boolean { return !!(this.prompt() || this.confirm() || this.chmod()); diff --git a/ui/src/app/file-panel/file-panel.html b/ui/src/app/file-panel/file-panel.html index 0a94fb6..8d4d256 100644 --- a/ui/src/app/file-panel/file-panel.html +++ b/ui/src/app/file-panel/file-panel.html @@ -1,13 +1,85 @@ -
+
+ @if (isRemote()) { + + } + + @if (menuOpen()) { +
+
+ + + @if (connections.grouped().root.length || connections.grouped().folders.length) { +
+ } + @for (connection of connections.grouped().root; track connection.id) { + + } + @for (group of connections.grouped().folders; track group.folder.id) { +
+ {{ group.folder.name }} +
+ @for (connection of group.connections; track connection.id) { + + } + } + +
+ +
+ }
diff --git a/ui/src/app/file-panel/file-panel.ts b/ui/src/app/file-panel/file-panel.ts index 1e81e45..527f926 100644 --- a/ui/src/app/file-panel/file-panel.ts +++ b/ui/src/app/file-panel/file-panel.ts @@ -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>('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'; } diff --git a/ui/src/app/panels.service.ts b/ui/src/app/panels.service.ts index 022e69c..25138e7 100644 --- a/ui/src/app/panels.service.ts +++ b/ui/src/app/panels.service.ts @@ -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([]); 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 { + 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 { + 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 { diff --git a/ui/src/app/rpc.service.ts b/ui/src/app/rpc.service.ts index 9a15eeb..f7dde03 100644 --- a/ui/src/app/rpc.service.ts +++ b/ui/src/app/rpc.service.ts @@ -17,6 +17,7 @@ export class RpcService { readonly sessionClosed = signal(null); private rpc: ReturnType> | undefined; + private ready: Promise = 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 + >; + return methods[method](params); + }, + }); } }