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:
@@ -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> {}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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
@@ -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})`);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type {
|
||||
ConnFolder,
|
||||
ConnectionConfig,
|
||||
ConnectionsFile,
|
||||
EntryType,
|
||||
FileEntry,
|
||||
Result,
|
||||
SavedConnection,
|
||||
TransferSnapshot,
|
||||
} from "./types";
|
||||
|
||||
// Structurally equivalent to electrobun's ElectrobunRPCSchema, written in
|
||||
// resolved form so this file stays importable from the Angular bundle without
|
||||
// pulling in bun-only type modules.
|
||||
//
|
||||
// bun.requests = calls the webview makes, handled in src/bun/rpc-handlers.ts
|
||||
// webview.messages = push events from bun, handled in ui rpc.service.ts
|
||||
export type AppRPC = {
|
||||
bun: {
|
||||
requests: {
|
||||
// sessions
|
||||
connect: {
|
||||
params: { config: ConnectionConfig };
|
||||
response: Result<{ sessionId: string; homeDir: string }>;
|
||||
};
|
||||
disconnect: { params: { sessionId: string }; response: Result };
|
||||
// filesystem (uniform across "local" and sftp sessions)
|
||||
listDir: {
|
||||
params: { sessionId: string; path: string | null }; // null -> home dir
|
||||
response: Result<{ path: string; entries: FileEntry[] }>;
|
||||
};
|
||||
mkdir: { params: { sessionId: string; path: string }; response: Result };
|
||||
createFile: { params: { sessionId: string; path: string }; response: Result };
|
||||
rename: {
|
||||
params: { sessionId: string; oldPath: string; newPath: string };
|
||||
response: Result;
|
||||
};
|
||||
deleteEntries: {
|
||||
params: { sessionId: string; entries: { path: string; type: EntryType }[] };
|
||||
response: Result;
|
||||
};
|
||||
chmod: {
|
||||
params: { sessionId: string; paths: string[]; mode: number };
|
||||
response: Result;
|
||||
};
|
||||
// transfers
|
||||
enqueueTransfers: {
|
||||
params: {
|
||||
srcSessionId: string;
|
||||
items: { path: string; type: EntryType }[];
|
||||
dstSessionId: string;
|
||||
dstDir: string;
|
||||
};
|
||||
response: Result<{ jobIds: string[] }>;
|
||||
};
|
||||
cancelTransfer: { params: { jobId: string }; response: Result };
|
||||
dismissTransfer: { params: { jobId: string }; response: Result };
|
||||
// connection store
|
||||
getConnections: {
|
||||
params: Record<string, never>;
|
||||
response: Result<{ store: ConnectionsFile }>;
|
||||
};
|
||||
upsertConnection: {
|
||||
params: { connection: SavedConnection };
|
||||
response: Result<{ id: string }>;
|
||||
};
|
||||
deleteConnection: { params: { id: string }; response: Result };
|
||||
upsertFolder: { params: { folder: ConnFolder }; response: Result<{ id: string }> };
|
||||
deleteFolder: { params: { id: string }; response: Result };
|
||||
};
|
||||
messages: Record<never, never>;
|
||||
};
|
||||
webview: {
|
||||
requests: Record<never, never>;
|
||||
messages: {
|
||||
transferUpdate: TransferSnapshot;
|
||||
sessionClosed: { sessionId: string; reason: string };
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
export type EntryType = "file" | "dir" | "symlink";
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
path: string; // absolute path on its filesystem
|
||||
type: EntryType;
|
||||
size: number; // bytes
|
||||
mtime: number; // epoch milliseconds
|
||||
mode: number; // permission bits only (masked & 0o7777)
|
||||
}
|
||||
|
||||
/** Every RPC response uses this envelope; errors never cross the bridge as exceptions. */
|
||||
export type Result<T = object> = ({ ok: true } & T) | { ok: false; error: string };
|
||||
|
||||
export type AuthConfig =
|
||||
| { kind: "password"; password: string }
|
||||
| { kind: "key"; privateKeyPath: string; passphrase?: string };
|
||||
|
||||
export interface ConnectionConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
auth: AuthConfig;
|
||||
initialPath?: string | null;
|
||||
}
|
||||
|
||||
export interface SavedConnection extends ConnectionConfig {
|
||||
id: string;
|
||||
folderId: string | null;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ConnFolder {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ConnectionsFile {
|
||||
version: 1;
|
||||
folders: ConnFolder[];
|
||||
connections: SavedConnection[];
|
||||
}
|
||||
|
||||
export type TransferState =
|
||||
| "queued"
|
||||
| "scanning"
|
||||
| "running"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled";
|
||||
|
||||
export interface TransferSnapshot {
|
||||
id: string;
|
||||
name: string; // display name (basename of source)
|
||||
srcSessionId: string;
|
||||
srcPath: string;
|
||||
dstSessionId: string;
|
||||
dstDir: string;
|
||||
state: TransferState;
|
||||
totalFiles: number;
|
||||
totalBytes: number;
|
||||
doneFiles: number;
|
||||
doneBytes: number;
|
||||
currentFile: string;
|
||||
currentFileBytes: number;
|
||||
currentFileTotal: number;
|
||||
error?: string;
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
// electrobun ships raw .ts sources whose WGPU adapter imports "three" without
|
||||
// bundled types; we never use that path, so give tsc an any-typed stand-in.
|
||||
declare module "three";
|
||||
Reference in New Issue
Block a user