diff --git a/src/bun/fs/local.ts b/src/bun/fs/local.ts new file mode 100644 index 0000000..85524c2 --- /dev/null +++ b/src/bun/fs/local.ts @@ -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 { + return os.homedir(); + } + + async list(dir: string): Promise { + 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 { + return toEntry(path.basename(p), p, await fs.lstat(p)); + } + + async mkdir(p: string): Promise { + await fs.mkdir(p); + } + + async createEmptyFile(p: string): Promise { + await fs.writeFile(p, "", { flag: "wx" }); + } + + async rename(oldPath: string, newPath: string): Promise { + await fs.rename(oldPath, newPath); + } + + async deleteFile(p: string): Promise { + await fs.unlink(p); + } + + async deleteEmptyDir(p: string): Promise { + await fs.rmdir(p); + } + + async chmod(p: string, mode: number): Promise { + await fs.chmod(p, mode); + } + + createReadStream(p: string) { + return createReadStream(p); + } + + createWriteStream(p: string) { + return createWriteStream(p); + } + + async dispose(): Promise {} +} diff --git a/src/bun/fs/provider.ts b/src/bun/fs/provider.ts new file mode 100644 index 0000000..552c1ca --- /dev/null +++ b/src/bun/fs/provider.ts @@ -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; + list(dir: string): Promise; // never includes "." / ".." + stat(path: string): Promise; + mkdir(path: string): Promise; + createEmptyFile(path: string): Promise; // fails if it already exists + rename(oldPath: string, newPath: string): Promise; + deleteFile(path: string): Promise; // also removes symlinks + deleteEmptyDir(path: string): Promise; + chmod(path: string, mode: number): Promise; + createReadStream(path: string): Readable; + createWriteStream(path: string): Writable; + dispose(): Promise; +} diff --git a/src/bun/fs/registry.ts b/src/bun/fs/registry.ts new file mode 100644 index 0000000..cbc7fb4 --- /dev/null +++ b/src/bun/fs/registry.ts @@ -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([ + [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 { + 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); + } +} diff --git a/src/bun/index.ts b/src/bun/index.ts index f7fa108..e7f69bb 100644 --- a/src/bun/index.ts +++ b/src/bun/index.ts @@ -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 { } } +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})`); diff --git a/src/bun/rpc-handlers.ts b/src/bun/rpc-handlers.ts new file mode 100644 index 0000000..cef2d12 --- /dev/null +++ b/src/bun/rpc-handlers.ts @@ -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(fn: () => Promise | T): Promise> { + 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({ + 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, + }, + }, + }); +} diff --git a/src/bun/transfers/walk.ts b/src/bun/transfers/walk.ts new file mode 100644 index 0000000..3c843f3 --- /dev/null +++ b/src/bun/transfers/walk.ts @@ -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 { + const tree: WalkedTree = { dirs: [[]], files: [], totalBytes: 0, skippedSymlinks: 0 }; + + async function visit(dir: string, segments: string[]): Promise { + 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 { + 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); +} diff --git a/src/shared/rpc.ts b/src/shared/rpc.ts new file mode 100644 index 0000000..8b57460 --- /dev/null +++ b/src/shared/rpc.ts @@ -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; + 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; + }; + webview: { + requests: Record; + messages: { + transferUpdate: TransferSnapshot; + sessionClosed: { sessionId: string; reason: string }; + }; + }; +}; diff --git a/src/shared/types.ts b/src/shared/types.ts new file mode 100644 index 0000000..214da69 --- /dev/null +++ b/src/shared/types.ts @@ -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 = ({ 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; +} diff --git a/src/types/shims.d.ts b/src/types/shims.d.ts new file mode 100644 index 0000000..9e42ed2 --- /dev/null +++ b/src/types/shims.d.ts @@ -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"; diff --git a/ui/src/app/app.html b/ui/src/app/app.html index 5cb605b..2a79183 100644 --- a/ui/src/app/app.html +++ b/ui/src/app/app.html @@ -1,17 +1,45 @@ -
-
- -

{{ appName }}

+
+
+ +

{{ appName }}

-
-
-
- - - + @if (!rpc.available) { +
+
+ +

Not connected to the app

+

+ This page is running in a plain browser. Launch the desktop app to use it. +

-

P0 skeleton — panels arrive in P1.

-
+ } @else { +
+ + +
+ } + + +
+ @for (t of toast.toasts(); track t.id) { +
+ + {{ t.text }} + +
+ } +
diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index 3641b48..e95bf7c 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -1,16 +1,27 @@ -import { Component } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { NgIcon } from '@ng-icons/core'; import { BRANDING } from '@shared/branding'; +import { FilePanel } from './file-panel/file-panel'; +import { RpcService } from './rpc.service'; +import { ToastService } from './toast.service'; @Component({ selector: 'app-root', - imports: [NgIcon], + imports: [NgIcon, FilePanel], templateUrl: './app.html', }) export class App { readonly appName = BRANDING.appName; + readonly rpc = inject(RpcService); + readonly toast = inject(ToastService); constructor() { document.title = BRANDING.appName; } + + toastIcon(kind: 'error' | 'info' | 'success'): string { + if (kind === 'error') return 'mynaXCircle'; + if (kind === 'success') return 'mynaCheckCircle'; + return 'mynaCheck'; + } } diff --git a/ui/src/app/file-panel/file-panel.html b/ui/src/app/file-panel/file-panel.html new file mode 100644 index 0000000..0a94fb6 --- /dev/null +++ b/ui/src/app/file-panel/file-panel.html @@ -0,0 +1,106 @@ + +
+ +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ @for (entry of panel().sorted(); track entry.name; let i = $index) { +
+
+ + {{ entry.name }} +
+ + {{ entry.type === 'dir' ? '—' : formatSize(entry.size) }} + + + {{ formatDate(entry.mtime) }} + +
+ } @empty { +
Empty directory
+ } +
+ + +
+ {{ panel().sorted().length }} items + @if (panel().selected().size; as count) { + · {{ count }} selected + } +
diff --git a/ui/src/app/file-panel/file-panel.ts b/ui/src/app/file-panel/file-panel.ts new file mode 100644 index 0000000..957f99d --- /dev/null +++ b/ui/src/app/file-panel/file-panel.ts @@ -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(); + + 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>('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' }); + } +} diff --git a/ui/src/app/format.ts b/ui/src/app/format.ts new file mode 100644 index 0000000..0dd200a --- /dev/null +++ b/ui/src/app/format.ts @@ -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; +} diff --git a/ui/src/app/panels.service.ts b/ui/src/app/panels.service.ts new file mode 100644 index 0000000..44a895a --- /dev/null +++ b/ui/src/app/panels.service.ts @@ -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([]); + readonly loading = signal(false); + readonly sortKey = signal('name'); + readonly sortAsc = signal(true); + readonly cursor = signal(0); + readonly selected = signal>(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 { + 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 { + 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('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)); + } +} diff --git a/ui/src/app/rpc.service.ts b/ui/src/app/rpc.service.ts new file mode 100644 index 0000000..9a15eeb --- /dev/null +++ b/ui/src/app/rpc.service.ts @@ -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(null); + readonly sessionClosed = signal(null); + + private rpc: ReturnType> | undefined; + + constructor() { + this.available = typeof window !== 'undefined' && '__electrobun' in window; + if (!this.available) return; + + this.rpc = Electroview.defineRPC({ + 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; + } +} diff --git a/ui/src/app/toast.service.ts b/ui/src/app/toast.service.ts new file mode 100644 index 0000000..5236a68 --- /dev/null +++ b/ui/src/app/toast.service.ts @@ -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([]); + 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); + } +}