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 { BrowserWindow, Updater } from "electrobun/bun";
|
||||||
import { BRANDING } from "../shared/branding";
|
import { BRANDING } from "../shared/branding";
|
||||||
|
import { SessionRegistry } from "./fs/registry";
|
||||||
|
import { createAppRPC } from "./rpc-handlers";
|
||||||
|
|
||||||
const PROD_URL = "views://app/index.html";
|
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 url = await getMainViewUrl();
|
||||||
|
|
||||||
const mainWindow = new BrowserWindow({
|
new BrowserWindow({
|
||||||
title: BRANDING.appName,
|
title: BRANDING.appName,
|
||||||
url,
|
url,
|
||||||
frame: {
|
frame: {
|
||||||
@@ -32,6 +36,7 @@ const mainWindow = new BrowserWindow({
|
|||||||
x: 120,
|
x: 120,
|
||||||
y: 120,
|
y: 120,
|
||||||
},
|
},
|
||||||
|
rpc,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`${BRANDING.appName} started (${url})`);
|
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";
|
||||||
+40
-12
@@ -1,17 +1,45 @@
|
|||||||
<div class="flex h-screen flex-col">
|
<div class="flex h-screen flex-col bg-background text-foreground">
|
||||||
<header class="flex items-center gap-2 border-b border-border px-4 py-2">
|
<header class="flex items-center gap-2 border-b border-border px-3 py-1.5">
|
||||||
<ng-icon name="mynaArrowLeftRight" class="text-muted-foreground" />
|
<ng-icon name="mynaArrowLeftRight" class="text-muted-foreground" size="0.9rem" />
|
||||||
<h1 class="text-sm font-semibold">{{ appName }}</h1>
|
<h1 class="text-[13px] font-semibold tracking-tight">{{ appName }}</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="flex flex-1 items-center justify-center">
|
@if (!rpc.available) {
|
||||||
<div class="rounded-lg border border-border bg-card p-6 text-card-foreground shadow-sm">
|
<div class="flex flex-1 items-center justify-center">
|
||||||
<div class="flex items-center gap-3">
|
<div class="max-w-sm rounded-lg border border-border bg-card p-6 text-center shadow-sm">
|
||||||
<ng-icon name="mynaHardDrive" size="1.5rem" class="text-primary" />
|
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
|
||||||
<ng-icon name="mynaArrowLeftRight" size="1.25rem" class="text-muted-foreground" />
|
<p class="mt-2 text-sm font-medium">Not connected to the app</p>
|
||||||
<ng-icon name="mynaServers" size="1.5rem" class="text-primary" />
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
This page is running in a plain browser. Launch the desktop app to use it.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-3 text-sm text-muted-foreground">P0 skeleton — panels arrive in P1.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
} @else {
|
||||||
|
<main class="flex min-h-0 flex-1 divide-x divide-border">
|
||||||
|
<app-file-panel side="left" />
|
||||||
|
<app-file-panel side="right" />
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- toasts -->
|
||||||
|
<div class="pointer-events-none fixed right-3 bottom-3 z-50 flex w-80 flex-col gap-2">
|
||||||
|
@for (t of toast.toasts(); track t.id) {
|
||||||
|
<div
|
||||||
|
class="pointer-events-auto flex items-start gap-2 rounded-lg border border-border bg-card p-3 text-xs text-card-foreground shadow-lg"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
[name]="toastIcon(t.kind)"
|
||||||
|
size="1rem"
|
||||||
|
[class]="t.kind === 'error' ? 'shrink-0 text-destructive' : 'shrink-0 text-success'"
|
||||||
|
/>
|
||||||
|
<span class="min-w-0 flex-1 wrap-break-word">{{ t.text }}</span>
|
||||||
|
<button
|
||||||
|
(click)="toast.dismiss(t.id)"
|
||||||
|
class="shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaX" size="0.9rem" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+13
-2
@@ -1,16 +1,27 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/core';
|
import { NgIcon } from '@ng-icons/core';
|
||||||
import { BRANDING } from '@shared/branding';
|
import { BRANDING } from '@shared/branding';
|
||||||
|
import { FilePanel } from './file-panel/file-panel';
|
||||||
|
import { RpcService } from './rpc.service';
|
||||||
|
import { ToastService } from './toast.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [NgIcon],
|
imports: [NgIcon, FilePanel],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
})
|
})
|
||||||
export class App {
|
export class App {
|
||||||
readonly appName = BRANDING.appName;
|
readonly appName = BRANDING.appName;
|
||||||
|
readonly rpc = inject(RpcService);
|
||||||
|
readonly toast = inject(ToastService);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
document.title = BRANDING.appName;
|
document.title = BRANDING.appName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toastIcon(kind: 'error' | 'info' | 'success'): string {
|
||||||
|
if (kind === 'error') return 'mynaXCircle';
|
||||||
|
if (kind === 'success') return 'mynaCheckCircle';
|
||||||
|
return 'mynaCheck';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<!-- session strip -->
|
||||||
|
<div class="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"
|
||||||
|
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" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- path bar -->
|
||||||
|
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
||||||
|
<button
|
||||||
|
(click)="panel().up()"
|
||||||
|
title="Parent directory (Backspace)"
|
||||||
|
class="grid size-7 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaArrowUp" size="1rem" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
[value]="pathDraft()"
|
||||||
|
(input)="pathDraft.set($any($event.target).value)"
|
||||||
|
(keydown.enter)="commitPath()"
|
||||||
|
(keydown.escape)="resetPath()"
|
||||||
|
(keydown)="$event.stopPropagation()"
|
||||||
|
spellcheck="false"
|
||||||
|
class="h-7 min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono text-xs outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
(click)="panel().refresh()"
|
||||||
|
title="Refresh"
|
||||||
|
class="grid size-7 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
@if (panel().loading()) {
|
||||||
|
<ng-icon name="mynaSpinner" size="1rem" class="animate-spin" />
|
||||||
|
} @else {
|
||||||
|
<ng-icon name="mynaRefresh" size="1rem" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- column headers -->
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-[minmax(0,1fr)_5.5rem_8.5rem] border-b border-border text-[11px] font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
|
<button class="px-2 py-1 text-left hover:text-foreground" (click)="panel().toggleSort('name')">
|
||||||
|
Name {{ sortIndicator('name') }}
|
||||||
|
</button>
|
||||||
|
<button class="px-2 py-1 text-right hover:text-foreground" (click)="panel().toggleSort('size')">
|
||||||
|
Size {{ sortIndicator('size') }}
|
||||||
|
</button>
|
||||||
|
<button class="px-2 py-1 text-left hover:text-foreground" (click)="panel().toggleSort('mtime')">
|
||||||
|
Modified {{ sortIndicator('mtime') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- file list -->
|
||||||
|
<div
|
||||||
|
#list
|
||||||
|
tabindex="0"
|
||||||
|
(keydown)="onKeydown($event)"
|
||||||
|
(mousedown)="activate()"
|
||||||
|
class="flex-1 overflow-y-auto outline-none"
|
||||||
|
[class.bg-accent]="false"
|
||||||
|
>
|
||||||
|
@for (entry of panel().sorted(); track entry.name; let i = $index) {
|
||||||
|
<div
|
||||||
|
[attr.data-index]="i"
|
||||||
|
(click)="rowClick($event, i)"
|
||||||
|
(dblclick)="openAt(i)"
|
||||||
|
class="grid h-7 grid-cols-[minmax(0,1fr)_5.5rem_8.5rem] items-center text-[13px]"
|
||||||
|
[class.bg-accent]="i === panel().cursor() && !isSelected(entry)"
|
||||||
|
[class.bg-primary/15]="isSelected(entry)"
|
||||||
|
[class.ring-1]="i === panel().cursor() && isActive()"
|
||||||
|
[class.ring-inset]="i === panel().cursor() && isActive()"
|
||||||
|
[class.ring-ring]="i === panel().cursor() && isActive()"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 items-center gap-1.5 px-2">
|
||||||
|
<ng-icon
|
||||||
|
[name]="iconFor(entry)"
|
||||||
|
size="0.95rem"
|
||||||
|
[class]="entry.type === 'dir' ? 'shrink-0 text-primary' : 'shrink-0 text-muted-foreground'"
|
||||||
|
/>
|
||||||
|
<span class="truncate" [class.italic]="entry.type === 'symlink'">{{ entry.name }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="px-2 text-right font-mono text-xs tabular-nums text-muted-foreground">
|
||||||
|
{{ entry.type === 'dir' ? '—' : formatSize(entry.size) }}
|
||||||
|
</span>
|
||||||
|
<span class="px-2 font-mono text-xs tabular-nums text-muted-foreground">
|
||||||
|
{{ formatDate(entry.mtime) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
} @empty {
|
||||||
|
<div class="p-6 text-center text-xs text-muted-foreground">Empty directory</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- footer -->
|
||||||
|
<div class="flex items-center gap-2 border-t border-border px-2 py-1 text-[11px] text-muted-foreground">
|
||||||
|
<span>{{ panel().sorted().length }} items</span>
|
||||||
|
@if (panel().selected().size; as count) {
|
||||||
|
<span>· {{ count }} selected</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
signal,
|
||||||
|
viewChild,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { NgIcon } from '@ng-icons/core';
|
||||||
|
import type { FileEntry } from '@shared/types';
|
||||||
|
import { formatDate, formatSize } from '../format';
|
||||||
|
import { PanelsService, type Side, type SortKey } from '../panels.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-file-panel',
|
||||||
|
imports: [NgIcon],
|
||||||
|
templateUrl: './file-panel.html',
|
||||||
|
host: { class: 'flex min-w-0 flex-1 flex-col overflow-hidden' },
|
||||||
|
})
|
||||||
|
export class FilePanel {
|
||||||
|
readonly side = input.required<Side>();
|
||||||
|
|
||||||
|
readonly panels = inject(PanelsService);
|
||||||
|
readonly panel = computed(() => this.panels.get(this.side()));
|
||||||
|
readonly isActive = computed(() => this.panels.activeSide() === this.side());
|
||||||
|
|
||||||
|
readonly pathDraft = signal('');
|
||||||
|
|
||||||
|
private readonly listEl = viewChild.required<ElementRef<HTMLDivElement>>('list');
|
||||||
|
private lastClickedIndex = 0;
|
||||||
|
|
||||||
|
readonly formatSize = formatSize;
|
||||||
|
readonly formatDate = formatDate;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// keep the editable path bar in sync with the actual cwd
|
||||||
|
effect(() => this.pathDraft.set(this.panel().cwd()));
|
||||||
|
// focus follows the active side (Tab switches panels)
|
||||||
|
effect(() => {
|
||||||
|
if (this.isActive()) this.listEl().nativeElement.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
activate(): void {
|
||||||
|
this.panels.activeSide.set(this.side());
|
||||||
|
}
|
||||||
|
|
||||||
|
iconFor(entry: FileEntry): string {
|
||||||
|
return entry.type === 'dir' ? 'mynaFolder' : 'mynaFile';
|
||||||
|
}
|
||||||
|
|
||||||
|
isSelected(entry: FileEntry): boolean {
|
||||||
|
return this.panel().selected().has(entry.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
sortIndicator(key: SortKey): string {
|
||||||
|
if (this.panel().sortKey() !== key) return '';
|
||||||
|
return this.panel().sortAsc() ? '↑' : '↓';
|
||||||
|
}
|
||||||
|
|
||||||
|
commitPath(): void {
|
||||||
|
const value = this.pathDraft().trim();
|
||||||
|
if (value) void this.panel().load(value);
|
||||||
|
this.listEl().nativeElement.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
resetPath(): void {
|
||||||
|
this.pathDraft.set(this.panel().cwd());
|
||||||
|
this.listEl().nativeElement.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
rowClick(event: MouseEvent, index: number): void {
|
||||||
|
this.activate();
|
||||||
|
const panel = this.panel();
|
||||||
|
if (event.shiftKey) {
|
||||||
|
panel.selectRange(this.lastClickedIndex, index);
|
||||||
|
} else if (event.metaKey || event.ctrlKey) {
|
||||||
|
const entry = panel.sorted()[index];
|
||||||
|
if (entry) panel.toggleSelect(entry.name);
|
||||||
|
} else {
|
||||||
|
panel.clearSelection();
|
||||||
|
}
|
||||||
|
panel.cursor.set(index);
|
||||||
|
this.lastClickedIndex = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
openAt(index: number): void {
|
||||||
|
const entry = this.panel().sorted()[index];
|
||||||
|
if (entry) this.panel().open(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeydown(event: KeyboardEvent): void {
|
||||||
|
const panel = this.panel();
|
||||||
|
const key = event.key;
|
||||||
|
|
||||||
|
if (key === 'Tab') {
|
||||||
|
event.preventDefault();
|
||||||
|
this.panels.activeSide.set(this.panels.otherSide(this.side()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((event.metaKey || event.ctrlKey) && key.toLowerCase() === 'a') {
|
||||||
|
event.preventDefault();
|
||||||
|
panel.selectAll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
panel.moveCursor(1);
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
panel.moveCursor(-1);
|
||||||
|
break;
|
||||||
|
case 'PageDown':
|
||||||
|
panel.moveCursor(20);
|
||||||
|
break;
|
||||||
|
case 'PageUp':
|
||||||
|
panel.moveCursor(-20);
|
||||||
|
break;
|
||||||
|
case 'Home':
|
||||||
|
panel.cursor.set(0);
|
||||||
|
break;
|
||||||
|
case 'End':
|
||||||
|
panel.cursor.set(Math.max(0, panel.sorted().length - 1));
|
||||||
|
break;
|
||||||
|
case ' ':
|
||||||
|
{
|
||||||
|
const entry = panel.sorted()[panel.cursor()];
|
||||||
|
if (entry) panel.toggleSelect(entry.name);
|
||||||
|
panel.moveCursor(1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
this.openAt(panel.cursor());
|
||||||
|
return void event.preventDefault();
|
||||||
|
case 'Backspace':
|
||||||
|
panel.up();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return; // let unhandled keys through
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
this.scrollCursorIntoView();
|
||||||
|
}
|
||||||
|
|
||||||
|
private scrollCursorIntoView(): void {
|
||||||
|
const row = this.listEl().nativeElement.querySelector(
|
||||||
|
`[data-index="${this.panel().cursor()}"]`,
|
||||||
|
);
|
||||||
|
row?.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
|
||||||
|
export function formatSize(bytes: number): string {
|
||||||
|
let value = bytes;
|
||||||
|
let unit = 0;
|
||||||
|
while (value >= 1024 && unit < SIZE_UNITS.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
unit++;
|
||||||
|
}
|
||||||
|
return `${unit === 0 ? value : value.toFixed(1)} ${SIZE_UNITS[unit]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATE_FORMAT = new Intl.DateTimeFormat(undefined, {
|
||||||
|
year: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
|
||||||
|
export function formatDate(epochMs: number): string {
|
||||||
|
if (!epochMs) return '';
|
||||||
|
return DATE_FORMAT.format(new Date(epochMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 0o755 -> "rwxr-xr-x" */
|
||||||
|
export function formatMode(mode: number): string {
|
||||||
|
const flags = 'rwxrwxrwx';
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
out += mode & (0o400 >> i) ? flags[i] : '-';
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { Injectable, computed, signal } from '@angular/core';
|
||||||
|
import type { FileEntry } from '@shared/types';
|
||||||
|
import { RpcService } from './rpc.service';
|
||||||
|
import { ToastService } from './toast.service';
|
||||||
|
|
||||||
|
export type Side = 'left' | 'right';
|
||||||
|
export type SortKey = 'name' | 'size' | 'mtime';
|
||||||
|
|
||||||
|
interface PanelDeps {
|
||||||
|
rpc: RpcService;
|
||||||
|
toast: ToastService;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PanelState {
|
||||||
|
readonly sessionId = signal('local');
|
||||||
|
readonly title = signal('Local');
|
||||||
|
readonly cwd = signal('');
|
||||||
|
readonly entries = signal<FileEntry[]>([]);
|
||||||
|
readonly loading = signal(false);
|
||||||
|
readonly sortKey = signal<SortKey>('name');
|
||||||
|
readonly sortAsc = signal(true);
|
||||||
|
readonly cursor = signal(0);
|
||||||
|
readonly selected = signal<ReadonlySet<string>>(new Set());
|
||||||
|
|
||||||
|
/** Directories first, then the active sort column. */
|
||||||
|
readonly sorted = computed(() => {
|
||||||
|
const list = [...this.entries()];
|
||||||
|
const key = this.sortKey();
|
||||||
|
const dir = this.sortAsc() ? 1 : -1;
|
||||||
|
list.sort((a, b) => {
|
||||||
|
const aDir = a.type === 'dir';
|
||||||
|
const bDir = b.type === 'dir';
|
||||||
|
if (aDir !== bDir) return aDir ? -1 : 1;
|
||||||
|
let cmp = 0;
|
||||||
|
if (key === 'size') cmp = a.size - b.size;
|
||||||
|
else if (key === 'mtime') cmp = a.mtime - b.mtime;
|
||||||
|
if (cmp === 0) {
|
||||||
|
cmp = a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
|
||||||
|
if (key === 'name') return cmp * dir;
|
||||||
|
}
|
||||||
|
return cmp * dir;
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly selectedEntries = computed(() => {
|
||||||
|
const names = this.selected();
|
||||||
|
return this.sorted().filter((e) => names.has(e.name));
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor(private deps: PanelDeps) {}
|
||||||
|
|
||||||
|
async load(path: string | null): Promise<void> {
|
||||||
|
this.loading.set(true);
|
||||||
|
try {
|
||||||
|
const res = await this.deps.rpc.request.listDir({ sessionId: this.sessionId(), path });
|
||||||
|
if (!res.ok) {
|
||||||
|
this.deps.toast.error(res.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.cwd.set(res.path);
|
||||||
|
this.entries.set(res.entries);
|
||||||
|
this.cursor.set(0);
|
||||||
|
this.selected.set(new Set());
|
||||||
|
} finally {
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reload cwd, keeping cursor position and selection by name. */
|
||||||
|
async refresh(): Promise<void> {
|
||||||
|
const keepCursorName = this.sorted()[this.cursor()]?.name;
|
||||||
|
const keepSelected = this.selected();
|
||||||
|
this.loading.set(true);
|
||||||
|
try {
|
||||||
|
const res = await this.deps.rpc.request.listDir({
|
||||||
|
sessionId: this.sessionId(),
|
||||||
|
path: this.cwd(),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
this.deps.toast.error(res.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.cwd.set(res.path);
|
||||||
|
this.entries.set(res.entries);
|
||||||
|
const names = new Set(res.entries.map((e) => e.name));
|
||||||
|
this.selected.set(new Set([...keepSelected].filter((n) => names.has(n))));
|
||||||
|
const idx = this.sorted().findIndex((e) => e.name === keepCursorName);
|
||||||
|
this.cursor.set(idx >= 0 ? idx : Math.min(this.cursor(), Math.max(0, this.sorted().length - 1)));
|
||||||
|
} finally {
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
open(entry: FileEntry): void {
|
||||||
|
if (entry.type === 'file') return;
|
||||||
|
// symlinks: try to open as a dir; the error toast covers file links
|
||||||
|
void this.load(entry.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
up(): void {
|
||||||
|
if (!this.cwd()) return;
|
||||||
|
void this.load(`${this.cwd()}/..`);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSort(key: SortKey): void {
|
||||||
|
if (this.sortKey() === key) {
|
||||||
|
this.sortAsc.update((v) => !v);
|
||||||
|
} else {
|
||||||
|
this.sortKey.set(key);
|
||||||
|
this.sortAsc.set(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
moveCursor(delta: number): void {
|
||||||
|
const max = this.sorted().length - 1;
|
||||||
|
if (max < 0) return;
|
||||||
|
this.cursor.set(Math.max(0, Math.min(max, this.cursor() + delta)));
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSelect(name: string): void {
|
||||||
|
this.selected.update((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(name)) next.delete(name);
|
||||||
|
else next.add(name);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
selectRange(fromIndex: number, toIndex: number): void {
|
||||||
|
const [lo, hi] = fromIndex <= toIndex ? [fromIndex, toIndex] : [toIndex, fromIndex];
|
||||||
|
const names = this.sorted()
|
||||||
|
.slice(lo, hi + 1)
|
||||||
|
.map((e) => e.name);
|
||||||
|
this.selected.update((prev) => new Set([...prev, ...names]));
|
||||||
|
}
|
||||||
|
|
||||||
|
selectAll(): void {
|
||||||
|
this.selected.set(new Set(this.sorted().map((e) => e.name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSelection(): void {
|
||||||
|
if (this.selected().size) this.selected.set(new Set());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class PanelsService {
|
||||||
|
readonly left: PanelState;
|
||||||
|
readonly right: PanelState;
|
||||||
|
readonly activeSide = signal<Side>('left');
|
||||||
|
|
||||||
|
constructor(rpc: RpcService, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get(side: Side): PanelState {
|
||||||
|
return side === 'left' ? this.left : this.right;
|
||||||
|
}
|
||||||
|
|
||||||
|
active(): PanelState {
|
||||||
|
return this.get(this.activeSide());
|
||||||
|
}
|
||||||
|
|
||||||
|
otherSide(side: Side): Side {
|
||||||
|
return side === 'left' ? 'right' : 'left';
|
||||||
|
}
|
||||||
|
|
||||||
|
other(side: Side): PanelState {
|
||||||
|
return this.get(this.otherSide(side));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
import { Electroview } from 'electrobun/view';
|
||||||
|
import type { AppRPC } from '@shared/rpc';
|
||||||
|
import type { TransferSnapshot } from '@shared/types';
|
||||||
|
|
||||||
|
// Matches MAX_REQUEST_TIME_MS on the bun side — SFTP ops can be slow.
|
||||||
|
const MAX_REQUEST_TIME_MS = 10 * 60_000;
|
||||||
|
|
||||||
|
export type SessionClosedEvent = { sessionId: string; reason: string };
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class RpcService {
|
||||||
|
/** False when the page is opened in a plain browser instead of the app. */
|
||||||
|
readonly available: boolean;
|
||||||
|
|
||||||
|
readonly transferUpdate = signal<TransferSnapshot | null>(null);
|
||||||
|
readonly sessionClosed = signal<SessionClosedEvent | null>(null);
|
||||||
|
|
||||||
|
private rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.available = typeof window !== 'undefined' && '__electrobun' in window;
|
||||||
|
if (!this.available) return;
|
||||||
|
|
||||||
|
this.rpc = Electroview.defineRPC<AppRPC>({
|
||||||
|
maxRequestTime: MAX_REQUEST_TIME_MS,
|
||||||
|
handlers: {
|
||||||
|
messages: {
|
||||||
|
transferUpdate: (snapshot) => this.transferUpdate.set(snapshot),
|
||||||
|
sessionClosed: (event) => this.sessionClosed.set(event),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
new Electroview({ rpc: this.rpc });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Typed request proxy: `await rpc.request.listDir({...})`. */
|
||||||
|
get request() {
|
||||||
|
if (!this.rpc) throw new Error('RPC is only available inside the desktop app');
|
||||||
|
return this.rpc.request;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: number;
|
||||||
|
kind: 'error' | 'info' | 'success';
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ToastService {
|
||||||
|
readonly toasts = signal<Toast[]>([]);
|
||||||
|
private nextId = 1;
|
||||||
|
|
||||||
|
error(text: string) {
|
||||||
|
this.show('error', text, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
info(text: string) {
|
||||||
|
this.show('info', text, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
success(text: string) {
|
||||||
|
this.show('success', text, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
dismiss(id: number) {
|
||||||
|
this.toasts.update((list) => list.filter((t) => t.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private show(kind: Toast['kind'], text: string, ttlMs: number) {
|
||||||
|
const toast = { id: this.nextId++, kind, text };
|
||||||
|
this.toasts.update((list) => [...list, toast]);
|
||||||
|
setTimeout(() => this.dismiss(toast.id), ttlMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user