P1: typed RPC bridge + FileSystemProvider + dual-panel local browsing

- shared/types + shared/rpc: full wire contract (Result envelope, no exceptions cross the bridge)
- FileSystemProvider interface, LocalProvider, SessionRegistry with built-in 'local'
- walk.ts: provider-generic recursive walk + removeRecursive
- rpc-handlers wired into BrowserWindow; 10min request timeout for slow SFTP ops
- Angular: RpcService (Electroview bridge + plain-browser guard), PanelsService with
  signals-based PanelState (sort/cursor/selection), FilePanel component with
  keyboard nav (arrows/Space/Enter/Backspace/Tab), path bar, toasts
- verified: both panels listDir home over the bridge in the running app

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jokin
2026-07-11 13:35:02 +02:00
co-authored by Claude Fable 5
parent 8092cb0c08
commit a150c5ee2e
17 changed files with 1062 additions and 15 deletions
+88
View File
@@ -0,0 +1,88 @@
import { createReadStream, createWriteStream } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { Stats } from "node:fs";
import type { EntryType, FileEntry } from "../../shared/types";
import type { FileSystemProvider } from "./provider";
function typeOf(st: Stats): EntryType {
if (st.isSymbolicLink()) return "symlink";
return st.isDirectory() ? "dir" : "file";
}
function toEntry(name: string, full: string, st: Stats): FileEntry {
return {
name,
path: full,
type: typeOf(st),
size: st.size,
mtime: st.mtimeMs,
mode: st.mode & 0o7777,
};
}
export class LocalProvider implements FileSystemProvider {
readonly kind = "local" as const;
join = (...parts: string[]) => path.join(...parts);
dirname = (p: string) => path.dirname(p);
basename = (p: string) => path.basename(p);
normalize = (p: string) => path.resolve(p);
async homeDir(): Promise<string> {
return os.homedir();
}
async list(dir: string): Promise<FileEntry[]> {
const names = await fs.readdir(dir);
const entries: FileEntry[] = [];
for (const name of names) {
const full = path.join(dir, name);
try {
entries.push(toEntry(name, full, await fs.lstat(full)));
} catch {
// entry vanished or is unreadable — skip it
}
}
return entries;
}
async stat(p: string): Promise<FileEntry> {
return toEntry(path.basename(p), p, await fs.lstat(p));
}
async mkdir(p: string): Promise<void> {
await fs.mkdir(p);
}
async createEmptyFile(p: string): Promise<void> {
await fs.writeFile(p, "", { flag: "wx" });
}
async rename(oldPath: string, newPath: string): Promise<void> {
await fs.rename(oldPath, newPath);
}
async deleteFile(p: string): Promise<void> {
await fs.unlink(p);
}
async deleteEmptyDir(p: string): Promise<void> {
await fs.rmdir(p);
}
async chmod(p: string, mode: number): Promise<void> {
await fs.chmod(p, mode);
}
createReadStream(p: string) {
return createReadStream(p);
}
createWriteStream(p: string) {
return createWriteStream(p);
}
async dispose(): Promise<void> {}
}
+31
View File
@@ -0,0 +1,31 @@
import type { Readable, Writable } from "node:stream";
import type { FileEntry } from "../../shared/types";
/**
* The one abstraction that makes panels symmetric: local disk and SFTP servers
* implement the same surface, so browsing and transfers are provider-agnostic.
*
* Providers are deliberately dumb (single-node ops + streams). Recursion lives
* once in transfers/walk.ts. Path helpers belong to the provider because local
* paths use the OS separator while remote paths are always POSIX — never join
* a path for one filesystem with the other's helper.
*/
export interface FileSystemProvider {
readonly kind: "local" | "sftp";
join(...parts: string[]): string;
dirname(p: string): string;
basename(p: string): string;
normalize(p: string): string;
homeDir(): Promise<string>;
list(dir: string): Promise<FileEntry[]>; // never includes "." / ".."
stat(path: string): Promise<FileEntry>;
mkdir(path: string): Promise<void>;
createEmptyFile(path: string): Promise<void>; // fails if it already exists
rename(oldPath: string, newPath: string): Promise<void>;
deleteFile(path: string): Promise<void>; // also removes symlinks
deleteEmptyDir(path: string): Promise<void>;
chmod(path: string, mode: number): Promise<void>;
createReadStream(path: string): Readable;
createWriteStream(path: string): Writable;
dispose(): Promise<void>;
}
+42
View File
@@ -0,0 +1,42 @@
import { LocalProvider } from "./local";
import type { FileSystemProvider } from "./provider";
export const LOCAL_SESSION_ID = "local";
/**
* Maps sessionId -> provider. "local" is always available; each SFTP connect
* adds a new session (one SSH connection per panel-connect, no refcounting).
*/
export class SessionRegistry {
private sessions = new Map<string, FileSystemProvider>([
[LOCAL_SESSION_ID, new LocalProvider()],
]);
private counter = 0;
get(sessionId: string): FileSystemProvider {
const provider = this.sessions.get(sessionId);
if (!provider) throw new Error(`Session "${sessionId}" is not connected`);
return provider;
}
add(provider: FileSystemProvider): string {
const id = `sftp-${++this.counter}`;
this.sessions.set(id, provider);
return id;
}
async remove(sessionId: string): Promise<void> {
if (sessionId === LOCAL_SESSION_ID)
throw new Error("The local session cannot be disconnected");
const provider = this.sessions.get(sessionId);
if (!provider) return;
this.sessions.delete(sessionId);
await provider.dispose();
}
/** Remove without dispose — for sessions that died on their own. */
drop(sessionId: string): void {
if (sessionId === LOCAL_SESSION_ID) return;
this.sessions.delete(sessionId);
}
}
+6 -1
View File
@@ -1,5 +1,7 @@
import { BrowserWindow, Updater } from "electrobun/bun";
import { BRANDING } from "../shared/branding";
import { SessionRegistry } from "./fs/registry";
import { createAppRPC } from "./rpc-handlers";
const PROD_URL = "views://app/index.html";
@@ -21,9 +23,11 @@ async function getMainViewUrl(): Promise<string> {
}
}
const registry = new SessionRegistry();
const rpc = createAppRPC(registry);
const url = await getMainViewUrl();
const mainWindow = new BrowserWindow({
new BrowserWindow({
title: BRANDING.appName,
url,
frame: {
@@ -32,6 +36,7 @@ const mainWindow = new BrowserWindow({
x: 120,
y: 120,
},
rpc,
});
console.log(`${BRANDING.appName} started (${url})`);
+86
View File
@@ -0,0 +1,86 @@
import { BrowserView } from "electrobun/bun";
import type { AppRPC } from "../shared/rpc";
import type { Result } from "../shared/types";
import type { SessionRegistry } from "./fs/registry";
import { removeRecursive } from "./transfers/walk";
// SFTP ops can be slow (big listings, recursive deletes) — the 1s electrobun
// default would cut them off.
const MAX_REQUEST_TIME_MS = 10 * 60_000;
async function wrap<T extends object>(fn: () => Promise<T> | T): Promise<Result<T>> {
try {
return { ok: true, ...(await fn()) };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
}
}
const NOT_YET = { ok: false as const, error: "Not implemented yet" };
export function createAppRPC(registry: SessionRegistry) {
return BrowserView.defineRPC<AppRPC>({
maxRequestTime: MAX_REQUEST_TIME_MS,
handlers: {
requests: {
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) };
}),
mkdir: ({ sessionId, path }) =>
wrap(async () => {
await registry.get(sessionId).mkdir(path);
return {};
}),
createFile: ({ sessionId, path }) =>
wrap(async () => {
await registry.get(sessionId).createEmptyFile(path);
return {};
}),
rename: ({ sessionId, oldPath, newPath }) =>
wrap(async () => {
await registry.get(sessionId).rename(oldPath, newPath);
return {};
}),
deleteEntries: ({ sessionId, entries }) =>
wrap(async () => {
const fs = registry.get(sessionId);
for (const { path, type } of entries) {
await removeRecursive(fs, path, type);
}
return {};
}),
chmod: ({ sessionId, paths, mode }) =>
wrap(async () => {
const fs = registry.get(sessionId);
for (const path of paths) {
await fs.chmod(path, mode);
}
return {};
}),
// P4: SFTP sessions
connect: async () => NOT_YET,
disconnect: async () => NOT_YET,
// P3: transfer engine
enqueueTransfers: async () => NOT_YET,
cancelTransfer: async () => NOT_YET,
dismissTransfer: async () => NOT_YET,
// P4: connection store
getConnections: async () => NOT_YET,
upsertConnection: async () => NOT_YET,
deleteConnection: async () => NOT_YET,
upsertFolder: async () => NOT_YET,
deleteFolder: async () => NOT_YET,
},
},
});
}
+56
View File
@@ -0,0 +1,56 @@
import type { EntryType, FileEntry } from "../../shared/types";
import type { FileSystemProvider } from "../fs/provider";
export interface WalkedTree {
/** Relative dir paths as segment arrays, top-down. [] is the root itself. */
dirs: string[][];
files: { entry: FileEntry; dirSegments: string[] }[];
totalBytes: number;
skippedSymlinks: number;
}
/**
* Depth-first walk of a directory. Segment arrays (not joined strings) so the
* destination side can re-join them with its own separator. Symlinks are
* skipped — following them risks cycles and surprising copies.
*/
export async function walkTree(
fs: FileSystemProvider,
rootDir: string,
): Promise<WalkedTree> {
const tree: WalkedTree = { dirs: [[]], files: [], totalBytes: 0, skippedSymlinks: 0 };
async function visit(dir: string, segments: string[]): Promise<void> {
for (const entry of await fs.list(dir)) {
if (entry.type === "symlink") {
tree.skippedSymlinks++;
} else if (entry.type === "dir") {
const childSegments = [...segments, entry.name];
tree.dirs.push(childSegments);
await visit(entry.path, childSegments);
} else {
tree.files.push({ entry, dirSegments: segments });
tree.totalBytes += entry.size;
}
}
}
await visit(rootDir, []);
return tree;
}
/** Post-order recursive delete, provider-generic (works locally and over SFTP). */
export async function removeRecursive(
fs: FileSystemProvider,
path: string,
type: EntryType,
): Promise<void> {
if (type !== "dir") {
await fs.deleteFile(path);
return;
}
for (const entry of await fs.list(path)) {
await removeRecursive(fs, entry.path, entry.type);
}
await fs.deleteEmptyDir(path);
}