P4: SFTP provider, connection store, connection manager UI
- SftpProvider (ssh2): password + key auth, keepalive, clean-dispose vs unexpected-close distinction, POSIX path helpers, s->ms attr normalization - ConnectionStore: plain JSON at appDataDir, 0600, atomic writes, folders - connect/disconnect + store CRUD handlers; sessionClosed push -> panels fall back to local and in-flight jobs fail - UI: session dropdown per panel (local/saved connections/manage), disconnect button, connection manager modal (folders, edit form, save/connect) - electrobun bundler: external cpu-features/*.node (ssh2 optional natives) - RpcService: gate requests on RPC socket open; defer initial panel loads past first render - verified against 192.168.10.122: full op matrix, upload/download/remote-remote recursive transfers, roundtrip integrity, recursive remote delete, store CRUD Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,9 @@ export default {
|
|||||||
build: {
|
build: {
|
||||||
bun: {
|
bun: {
|
||||||
entrypoint: "src/bun/index.ts",
|
entrypoint: "src/bun/index.ts",
|
||||||
|
// ssh2's optional native accelerators must never be bundled; its
|
||||||
|
// runtime try/catch falls back to pure JS when the require fails.
|
||||||
|
external: ["cpu-features", "*.node"],
|
||||||
},
|
},
|
||||||
copy: {
|
copy: {
|
||||||
"ui/dist/ui/browser": "views/app",
|
"ui/dist/ui/browser": "views/app",
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { Client, type ConnectConfig, type SFTPWrapper } from "ssh2";
|
||||||
|
import type { ConnectionConfig, EntryType, FileEntry } from "../../shared/types";
|
||||||
|
import type { FileSystemProvider } from "./provider";
|
||||||
|
|
||||||
|
const S_IFMT = 0o170000;
|
||||||
|
const S_IFDIR = 0o040000;
|
||||||
|
const S_IFLNK = 0o120000;
|
||||||
|
|
||||||
|
function typeFromMode(mode: number): EntryType {
|
||||||
|
const fmt = mode & S_IFMT;
|
||||||
|
if (fmt === S_IFDIR) return "dir";
|
||||||
|
if (fmt === S_IFLNK) return "symlink";
|
||||||
|
return "file";
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandTilde(p: string): string {
|
||||||
|
if (p === "~") return os.homedir();
|
||||||
|
if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SftpProvider implements FileSystemProvider {
|
||||||
|
readonly kind = "sftp" as const;
|
||||||
|
private closedByUs = false;
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
private client: Client,
|
||||||
|
private sftp: SFTPWrapper,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Resolves once the SSH connection is ready and the SFTP subsystem is open. */
|
||||||
|
static async connect(
|
||||||
|
config: ConnectionConfig,
|
||||||
|
onClose: (reason: string) => void,
|
||||||
|
): Promise<SftpProvider> {
|
||||||
|
const connectConfig: ConnectConfig = {
|
||||||
|
host: config.host,
|
||||||
|
port: config.port,
|
||||||
|
username: config.username,
|
||||||
|
readyTimeout: 20_000,
|
||||||
|
keepaliveInterval: 15_000,
|
||||||
|
keepaliveCountMax: 3,
|
||||||
|
};
|
||||||
|
if (config.auth.kind === "password") {
|
||||||
|
connectConfig.password = config.auth.password;
|
||||||
|
} else {
|
||||||
|
connectConfig.privateKey = readFileSync(expandTilde(config.auth.privateKeyPath));
|
||||||
|
if (config.auth.passphrase) connectConfig.passphrase = config.auth.passphrase;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new Client();
|
||||||
|
const provider = await new Promise<SftpProvider>((resolve, reject) => {
|
||||||
|
client
|
||||||
|
.on("ready", () => {
|
||||||
|
client.sftp((err, sftp) => {
|
||||||
|
if (err) {
|
||||||
|
client.end();
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve(new SftpProvider(client, sftp));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.on("error", reject)
|
||||||
|
.connect(connectConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
let notified = false;
|
||||||
|
const notify = (reason: string) => {
|
||||||
|
if (notified || provider.closedByUs) return;
|
||||||
|
notified = true;
|
||||||
|
onClose(reason);
|
||||||
|
};
|
||||||
|
client.on("close", () => notify("connection closed"));
|
||||||
|
client.on("error", (err) => notify(err.message));
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
join = (...parts: string[]) => path.posix.join(...parts);
|
||||||
|
dirname = (p: string) => path.posix.dirname(p);
|
||||||
|
basename = (p: string) => path.posix.basename(p);
|
||||||
|
normalize = (p: string) => path.posix.normalize(p);
|
||||||
|
|
||||||
|
homeDir(): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.sftp.realpath(".", (err, abs) => (err ? reject(err) : resolve(abs)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
list(dir: string): Promise<FileEntry[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.sftp.readdir(dir, (err, entries) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
resolve(
|
||||||
|
entries
|
||||||
|
.filter((e) => e.filename !== "." && e.filename !== "..")
|
||||||
|
.map((e) => ({
|
||||||
|
name: e.filename,
|
||||||
|
path: path.posix.join(dir, e.filename),
|
||||||
|
type: typeFromMode(e.attrs.mode),
|
||||||
|
size: e.attrs.size,
|
||||||
|
mtime: e.attrs.mtime * 1000, // sftp attrs are seconds
|
||||||
|
mode: e.attrs.mode & 0o7777,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stat(p: string): Promise<FileEntry> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.sftp.lstat(p, (err, stats) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
resolve({
|
||||||
|
name: path.posix.basename(p),
|
||||||
|
path: p,
|
||||||
|
type: typeFromMode(stats.mode),
|
||||||
|
size: stats.size,
|
||||||
|
mtime: stats.mtime * 1000,
|
||||||
|
mode: stats.mode & 0o7777,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir(p: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.sftp.mkdir(p, (err) => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createEmptyFile(p: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.sftp.writeFile(p, "", { flag: "wx" }, (err) => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
rename(oldPath: string, newPath: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.sftp.rename(oldPath, newPath, (err) => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteFile(p: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.sftp.unlink(p, (err) => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteEmptyDir(p: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.sftp.rmdir(p, (err) => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
chmod(p: string, mode: number): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.sftp.chmod(p, mode, (err) => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createReadStream(p: string) {
|
||||||
|
return this.sftp.createReadStream(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
createWriteStream(p: string) {
|
||||||
|
return this.sftp.createWriteStream(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispose(): Promise<void> {
|
||||||
|
this.closedByUs = true;
|
||||||
|
this.client.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-1
@@ -2,6 +2,7 @@ import { BrowserWindow, Updater } from "electrobun/bun";
|
|||||||
import { BRANDING } from "../shared/branding";
|
import { BRANDING } from "../shared/branding";
|
||||||
import { SessionRegistry } from "./fs/registry";
|
import { SessionRegistry } from "./fs/registry";
|
||||||
import { createAppRPC } from "./rpc-handlers";
|
import { createAppRPC } from "./rpc-handlers";
|
||||||
|
import { ConnectionStore } from "./store/connections";
|
||||||
import { TransferEngine } from "./transfers/engine";
|
import { TransferEngine } from "./transfers/engine";
|
||||||
|
|
||||||
const PROD_URL = "views://app/index.html";
|
const PROD_URL = "views://app/index.html";
|
||||||
@@ -26,7 +27,9 @@ async function getMainViewUrl(): Promise<string> {
|
|||||||
|
|
||||||
const registry = new SessionRegistry();
|
const registry = new SessionRegistry();
|
||||||
const engine = new TransferEngine(registry);
|
const engine = new TransferEngine(registry);
|
||||||
const rpc = createAppRPC(registry, engine);
|
const store = new ConnectionStore();
|
||||||
|
const events = { sessionClosed: (_info: { sessionId: string; reason: string }) => {} };
|
||||||
|
const rpc = createAppRPC({ registry, engine, store, events });
|
||||||
const url = await getMainViewUrl();
|
const url = await getMainViewUrl();
|
||||||
|
|
||||||
new BrowserWindow({
|
new BrowserWindow({
|
||||||
@@ -42,5 +45,6 @@ new BrowserWindow({
|
|||||||
});
|
});
|
||||||
|
|
||||||
engine.onUpdate = (snapshot) => rpc.send.transferUpdate(snapshot);
|
engine.onUpdate = (snapshot) => rpc.send.transferUpdate(snapshot);
|
||||||
|
events.sessionClosed = (info) => rpc.send.sessionClosed(info);
|
||||||
|
|
||||||
console.log(`${BRANDING.appName} started (${url})`);
|
console.log(`${BRANDING.appName} started (${url})`);
|
||||||
|
|||||||
+57
-14
@@ -2,6 +2,8 @@ import { BrowserView } from "electrobun/bun";
|
|||||||
import type { AppRPC } from "../shared/rpc";
|
import type { AppRPC } from "../shared/rpc";
|
||||||
import type { Result } from "../shared/types";
|
import type { Result } from "../shared/types";
|
||||||
import type { SessionRegistry } from "./fs/registry";
|
import type { SessionRegistry } from "./fs/registry";
|
||||||
|
import { SftpProvider } from "./fs/sftp";
|
||||||
|
import type { ConnectionStore } from "./store/connections";
|
||||||
import type { TransferEngine } from "./transfers/engine";
|
import type { TransferEngine } from "./transfers/engine";
|
||||||
import { removeRecursive } from "./transfers/walk";
|
import { removeRecursive } from "./transfers/walk";
|
||||||
|
|
||||||
@@ -9,6 +11,14 @@ import { removeRecursive } from "./transfers/walk";
|
|||||||
// default would cut them off.
|
// default would cut them off.
|
||||||
const MAX_REQUEST_TIME_MS = 10 * 60_000;
|
const MAX_REQUEST_TIME_MS = 10 * 60_000;
|
||||||
|
|
||||||
|
export interface AppDeps {
|
||||||
|
registry: SessionRegistry;
|
||||||
|
engine: TransferEngine;
|
||||||
|
store: ConnectionStore;
|
||||||
|
/** Assigned after the rpc object exists (it needs rpc.send). */
|
||||||
|
events: { sessionClosed: (info: { sessionId: string; reason: string }) => void };
|
||||||
|
}
|
||||||
|
|
||||||
async function wrap<T extends object>(fn: () => Promise<T> | T): Promise<Result<T>> {
|
async function wrap<T extends object>(fn: () => Promise<T> | T): Promise<Result<T>> {
|
||||||
try {
|
try {
|
||||||
return { ok: true, ...(await fn()) };
|
return { ok: true, ...(await fn()) };
|
||||||
@@ -17,16 +27,37 @@ async function wrap<T extends object>(fn: () => Promise<T> | T): Promise<Result<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const NOT_YET = { ok: false as const, error: "Not implemented yet" };
|
export function createAppRPC({ registry, engine, store, events }: AppDeps) {
|
||||||
|
|
||||||
export function createAppRPC(registry: SessionRegistry, engine: TransferEngine) {
|
|
||||||
return BrowserView.defineRPC<AppRPC>({
|
return BrowserView.defineRPC<AppRPC>({
|
||||||
maxRequestTime: MAX_REQUEST_TIME_MS,
|
maxRequestTime: MAX_REQUEST_TIME_MS,
|
||||||
handlers: {
|
handlers: {
|
||||||
requests: {
|
requests: {
|
||||||
|
// ---- sessions ----
|
||||||
|
connect: ({ config }) =>
|
||||||
|
wrap(async () => {
|
||||||
|
let sessionId = "";
|
||||||
|
const provider = await SftpProvider.connect(config, (reason) => {
|
||||||
|
if (!sessionId) return;
|
||||||
|
registry.drop(sessionId);
|
||||||
|
engine.failSession(sessionId, reason);
|
||||||
|
events.sessionClosed({ sessionId, reason });
|
||||||
|
});
|
||||||
|
sessionId = registry.add(provider);
|
||||||
|
const homeDir = await provider.homeDir();
|
||||||
|
console.log(`[rpc] connected ${sessionId} -> ${config.username}@${config.host}`);
|
||||||
|
return { sessionId, homeDir };
|
||||||
|
}),
|
||||||
|
|
||||||
|
disconnect: ({ sessionId }) =>
|
||||||
|
wrap(async () => {
|
||||||
|
await registry.remove(sessionId);
|
||||||
|
console.log(`[rpc] disconnected ${sessionId}`);
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ---- filesystem ----
|
||||||
listDir: ({ sessionId, path }) =>
|
listDir: ({ sessionId, path }) =>
|
||||||
wrap(async () => {
|
wrap(async () => {
|
||||||
console.log(`[rpc] listDir ${sessionId} ${path ?? "(home)"}`);
|
|
||||||
const fs = registry.get(sessionId);
|
const fs = registry.get(sessionId);
|
||||||
const dir = fs.normalize(path ?? (await fs.homeDir()));
|
const dir = fs.normalize(path ?? (await fs.homeDir()));
|
||||||
return { path: dir, entries: await fs.list(dir) };
|
return { path: dir, entries: await fs.list(dir) };
|
||||||
@@ -68,10 +99,7 @@ export function createAppRPC(registry: SessionRegistry, engine: TransferEngine)
|
|||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// P4: SFTP sessions
|
// ---- transfers ----
|
||||||
connect: async () => NOT_YET,
|
|
||||||
disconnect: async () => NOT_YET,
|
|
||||||
|
|
||||||
enqueueTransfers: (params) =>
|
enqueueTransfers: (params) =>
|
||||||
wrap(async () => ({ jobIds: engine.enqueue(params) })),
|
wrap(async () => ({ jobIds: engine.enqueue(params) })),
|
||||||
|
|
||||||
@@ -86,12 +114,27 @@ export function createAppRPC(registry: SessionRegistry, engine: TransferEngine)
|
|||||||
engine.dismiss(jobId);
|
engine.dismiss(jobId);
|
||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
// P4: connection store
|
|
||||||
getConnections: async () => NOT_YET,
|
// ---- connection store ----
|
||||||
upsertConnection: async () => NOT_YET,
|
getConnections: () => wrap(async () => ({ store: await store.load() })),
|
||||||
deleteConnection: async () => NOT_YET,
|
|
||||||
upsertFolder: async () => NOT_YET,
|
upsertConnection: ({ connection }) =>
|
||||||
deleteFolder: async () => NOT_YET,
|
wrap(async () => ({ id: await store.upsertConnection(connection) })),
|
||||||
|
|
||||||
|
deleteConnection: ({ id }) =>
|
||||||
|
wrap(async () => {
|
||||||
|
await store.deleteConnection(id);
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
|
||||||
|
upsertFolder: ({ folder }) =>
|
||||||
|
wrap(async () => ({ id: await store.upsertFolder(folder) })),
|
||||||
|
|
||||||
|
deleteFolder: ({ id }) =>
|
||||||
|
wrap(async () => {
|
||||||
|
await store.deleteFolder(id);
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { ConnFolder, ConnectionsFile, SavedConnection } from "../../shared/types";
|
||||||
|
import { appDataDir } from "./paths";
|
||||||
|
|
||||||
|
const EMPTY: ConnectionsFile = { version: 1, folders: [], connections: [] };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain-JSON store for saved connections (passwords included — the user's
|
||||||
|
* explicit v1 choice). Kept at 0600 and written atomically via tmp+rename.
|
||||||
|
*/
|
||||||
|
export class ConnectionStore {
|
||||||
|
private readonly file = path.join(appDataDir(), "connections.json");
|
||||||
|
private data: ConnectionsFile | null = null;
|
||||||
|
|
||||||
|
async load(): Promise<ConnectionsFile> {
|
||||||
|
if (!this.data) {
|
||||||
|
try {
|
||||||
|
this.data = JSON.parse(await fs.readFile(this.file, "utf8")) as ConnectionsFile;
|
||||||
|
} catch {
|
||||||
|
this.data = structuredClone(EMPTY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertConnection(connection: SavedConnection): Promise<string> {
|
||||||
|
const data = await this.load();
|
||||||
|
const saved = { ...connection, id: connection.id || crypto.randomUUID() };
|
||||||
|
const index = data.connections.findIndex((c) => c.id === saved.id);
|
||||||
|
if (index >= 0) data.connections[index] = saved;
|
||||||
|
else data.connections.push(saved);
|
||||||
|
await this.save();
|
||||||
|
return saved.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteConnection(id: string): Promise<void> {
|
||||||
|
const data = await this.load();
|
||||||
|
data.connections = data.connections.filter((c) => c.id !== id);
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertFolder(folder: ConnFolder): Promise<string> {
|
||||||
|
const data = await this.load();
|
||||||
|
const saved = { ...folder, id: folder.id || crypto.randomUUID() };
|
||||||
|
const index = data.folders.findIndex((f) => f.id === saved.id);
|
||||||
|
if (index >= 0) data.folders[index] = saved;
|
||||||
|
else data.folders.push(saved);
|
||||||
|
await this.save();
|
||||||
|
return saved.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deleting a folder moves its connections to the root. */
|
||||||
|
async deleteFolder(id: string): Promise<void> {
|
||||||
|
const data = await this.load();
|
||||||
|
data.folders = data.folders.filter((f) => f.id !== id);
|
||||||
|
for (const connection of data.connections) {
|
||||||
|
if (connection.folderId === id) connection.folderId = null;
|
||||||
|
}
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async save(): Promise<void> {
|
||||||
|
await fs.mkdir(path.dirname(this.file), { recursive: true });
|
||||||
|
const tmp = `${this.file}.tmp`;
|
||||||
|
await fs.writeFile(tmp, JSON.stringify(this.data, null, "\t"), { mode: 0o600 });
|
||||||
|
await fs.rename(tmp, this.file);
|
||||||
|
await fs.chmod(this.file, 0o600);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { BRANDING } from "../../shared/branding";
|
||||||
|
|
||||||
|
export function appDataDir(): string {
|
||||||
|
const home = os.homedir();
|
||||||
|
switch (process.platform) {
|
||||||
|
case "darwin":
|
||||||
|
return path.join(home, "Library", "Application Support", BRANDING.appName);
|
||||||
|
case "win32":
|
||||||
|
return path.join(
|
||||||
|
process.env.APPDATA ?? path.join(home, "AppData", "Roaming"),
|
||||||
|
BRANDING.appName,
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return path.join(process.env.XDG_CONFIG_HOME ?? path.join(home, ".config"), BRANDING.appName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,16 @@
|
|||||||
<header class="flex items-center gap-2 border-b border-border px-3 py-1.5">
|
<header class="flex items-center gap-2 border-b border-border px-3 py-1.5">
|
||||||
<ng-icon name="mynaArrowLeftRight" class="text-muted-foreground" size="0.9rem" />
|
<ng-icon name="mynaArrowLeftRight" class="text-muted-foreground" size="0.9rem" />
|
||||||
<h1 class="text-[13px] font-semibold tracking-tight">{{ appName }}</h1>
|
<h1 class="text-[13px] font-semibold tracking-tight">{{ appName }}</h1>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
@if (rpc.available) {
|
||||||
|
<button
|
||||||
|
(click)="dialogs.manager.set(true)"
|
||||||
|
title="Manage connections"
|
||||||
|
class="grid size-6 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaCog" size="0.95rem" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@if (!rpc.available) {
|
@if (!rpc.available) {
|
||||||
@@ -70,6 +80,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<!-- dialogs -->
|
<!-- dialogs -->
|
||||||
|
@if (dialogs.manager()) {
|
||||||
|
<app-connection-manager />
|
||||||
|
}
|
||||||
@if (dialogs.prompt(); as req) {
|
@if (dialogs.prompt(); as req) {
|
||||||
<app-prompt-dialog [request]="req" />
|
<app-prompt-dialog [request]="req" />
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -1,6 +1,7 @@
|
|||||||
import { Component, inject } 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 { ConnectionManager } from './connection-manager/connection-manager';
|
||||||
import { ChmodDialog } from './dialogs/chmod-dialog';
|
import { ChmodDialog } from './dialogs/chmod-dialog';
|
||||||
import { ConfirmDialog } from './dialogs/confirm-dialog';
|
import { ConfirmDialog } from './dialogs/confirm-dialog';
|
||||||
import { PromptDialog } from './dialogs/prompt-dialog';
|
import { PromptDialog } from './dialogs/prompt-dialog';
|
||||||
@@ -14,7 +15,15 @@ import { TransfersService } from './transfers.service';
|
|||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [NgIcon, FilePanel, PromptDialog, ConfirmDialog, ChmodDialog, TransferBar],
|
imports: [
|
||||||
|
NgIcon,
|
||||||
|
FilePanel,
|
||||||
|
PromptDialog,
|
||||||
|
ConfirmDialog,
|
||||||
|
ChmodDialog,
|
||||||
|
TransferBar,
|
||||||
|
ConnectionManager,
|
||||||
|
],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
})
|
})
|
||||||
export class App {
|
export class App {
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
<div class="fixed inset-0 z-30 grid place-items-center bg-black/40" (mousedown)="close()">
|
||||||
|
<div
|
||||||
|
class="flex h-[440px] w-[680px] flex-col rounded-lg border border-border bg-card text-card-foreground shadow-xl"
|
||||||
|
(mousedown)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<!-- title bar -->
|
||||||
|
<div class="flex items-center justify-between border-b border-border px-4 py-2.5">
|
||||||
|
<h2 class="text-sm font-semibold">Connections</h2>
|
||||||
|
<button (click)="close()" class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground">
|
||||||
|
<ng-icon name="mynaX" size="1rem" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex min-h-0 flex-1">
|
||||||
|
<!-- list -->
|
||||||
|
<div class="flex w-60 shrink-0 flex-col border-r border-border">
|
||||||
|
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
||||||
|
<button
|
||||||
|
(click)="newConnection()"
|
||||||
|
title="New connection"
|
||||||
|
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaPlus" size="0.85rem" /> Connection
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="newFolder()"
|
||||||
|
title="New folder"
|
||||||
|
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaFolderPlus" size="0.85rem" /> Folder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto py-1">
|
||||||
|
@for (connection of store.grouped().root; track connection.id) {
|
||||||
|
<button
|
||||||
|
(click)="edit(connection)"
|
||||||
|
class="flex w-full items-center gap-1.5 px-3 py-1.5 text-left text-xs hover:bg-accent"
|
||||||
|
[class.bg-accent]="draft()?.id === connection.id"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaServers" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
||||||
|
<span class="truncate">{{ connection.name }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@for (group of store.grouped().folders; track group.folder.id) {
|
||||||
|
<div class="group flex items-center gap-1.5 px-3 pt-2 pb-0.5">
|
||||||
|
<ng-icon name="mynaFolder" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
||||||
|
<span class="flex-1 truncate text-[11px] font-medium text-muted-foreground uppercase">
|
||||||
|
{{ group.folder.name }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
(click)="deleteFolder(group.folder.id, group.folder.name)"
|
||||||
|
title="Delete folder"
|
||||||
|
class="hidden rounded p-0.5 text-muted-foreground group-hover:block hover:text-destructive"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaTrash" size="0.75rem" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@for (connection of group.connections; track connection.id) {
|
||||||
|
<button
|
||||||
|
(click)="edit(connection)"
|
||||||
|
class="flex w-full items-center gap-1.5 py-1.5 pr-3 pl-6 text-left text-xs hover:bg-accent"
|
||||||
|
[class.bg-accent]="draft()?.id === connection.id"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaServers" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
||||||
|
<span class="truncate">{{ connection.name }}</span>
|
||||||
|
</button>
|
||||||
|
} @empty {
|
||||||
|
<div class="py-1 pl-6 text-[11px] text-muted-foreground italic">empty</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (!store.connections().length && !store.folders().length) {
|
||||||
|
<p class="px-3 py-2 text-[11px] text-muted-foreground">
|
||||||
|
No saved connections yet. Create one to get started.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- edit form -->
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
|
@if (draft(); as d) {
|
||||||
|
<div class="grid flex-1 auto-rows-min grid-cols-2 gap-x-3 gap-y-2.5 overflow-y-auto p-4">
|
||||||
|
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
||||||
|
<span class="text-muted-foreground">Name</span>
|
||||||
|
<input
|
||||||
|
[value]="d.name"
|
||||||
|
(input)="patch('name', $any($event.target).value)"
|
||||||
|
placeholder="my server"
|
||||||
|
spellcheck="false"
|
||||||
|
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1 text-xs">
|
||||||
|
<span class="text-muted-foreground">Host</span>
|
||||||
|
<input
|
||||||
|
[value]="d.host"
|
||||||
|
(input)="patch('host', $any($event.target).value)"
|
||||||
|
placeholder="192.168.1.10"
|
||||||
|
spellcheck="false"
|
||||||
|
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<label class="flex flex-col gap-1 text-xs">
|
||||||
|
<span class="text-muted-foreground">Port</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
[value]="d.port"
|
||||||
|
(input)="patch('port', +$any($event.target).value)"
|
||||||
|
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-xs">
|
||||||
|
<span class="text-muted-foreground">Folder</span>
|
||||||
|
<select
|
||||||
|
[value]="d.folderId ?? ''"
|
||||||
|
(change)="patch('folderId', $any($event.target).value || null)"
|
||||||
|
class="h-7 rounded-md border border-input bg-background px-1 outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="">(none)</option>
|
||||||
|
@for (folder of store.folders(); track folder.id) {
|
||||||
|
<option [value]="folder.id">{{ folder.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1 text-xs">
|
||||||
|
<span class="text-muted-foreground">Username</span>
|
||||||
|
<input
|
||||||
|
[value]="d.username"
|
||||||
|
(input)="patch('username', $any($event.target).value)"
|
||||||
|
spellcheck="false"
|
||||||
|
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-xs">
|
||||||
|
<span class="text-muted-foreground">Remote path (optional)</span>
|
||||||
|
<input
|
||||||
|
[value]="d.initialPath"
|
||||||
|
(input)="patch('initialPath', $any($event.target).value)"
|
||||||
|
placeholder="home directory"
|
||||||
|
spellcheck="false"
|
||||||
|
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="col-span-2 flex items-center gap-4 text-xs">
|
||||||
|
<span class="text-muted-foreground">Auth</span>
|
||||||
|
<label class="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="authKind"
|
||||||
|
[checked]="d.authKind === 'password'"
|
||||||
|
(change)="patch('authKind', 'password')"
|
||||||
|
class="accent-[var(--primary)]"
|
||||||
|
/>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="authKind"
|
||||||
|
[checked]="d.authKind === 'key'"
|
||||||
|
(change)="patch('authKind', 'key')"
|
||||||
|
class="accent-[var(--primary)]"
|
||||||
|
/>
|
||||||
|
Private key
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (d.authKind === 'password') {
|
||||||
|
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
||||||
|
<span class="text-muted-foreground">Password</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
[value]="d.password"
|
||||||
|
(input)="patch('password', $any($event.target).value)"
|
||||||
|
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
} @else {
|
||||||
|
<label class="flex flex-col gap-1 text-xs">
|
||||||
|
<span class="text-muted-foreground">Private key file</span>
|
||||||
|
<input
|
||||||
|
[value]="d.privateKeyPath"
|
||||||
|
(input)="patch('privateKeyPath', $any($event.target).value)"
|
||||||
|
placeholder="~/.ssh/id_ed25519"
|
||||||
|
spellcheck="false"
|
||||||
|
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-xs">
|
||||||
|
<span class="text-muted-foreground">Passphrase (optional)</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
[value]="d.passphrase"
|
||||||
|
(input)="patch('passphrase', $any($event.target).value)"
|
||||||
|
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 border-t border-border px-4 py-2.5">
|
||||||
|
<button
|
||||||
|
(click)="deleteCurrent()"
|
||||||
|
class="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-destructive"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaTrash" size="0.85rem" /> Delete
|
||||||
|
</button>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button
|
||||||
|
(click)="save()"
|
||||||
|
[disabled]="!valid()"
|
||||||
|
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
(click)="saveAndConnect()"
|
||||||
|
[disabled]="!valid() || busy()"
|
||||||
|
class="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
@if (busy()) {
|
||||||
|
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
|
||||||
|
}
|
||||||
|
Connect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="grid flex-1 place-items-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<ng-icon name="mynaServers" size="1.75rem" class="text-muted-foreground" />
|
||||||
|
<p class="mt-2 text-xs text-muted-foreground">
|
||||||
|
Select a connection or create a new one.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { Component, computed, inject, signal } from '@angular/core';
|
||||||
|
import { NgIcon } from '@ng-icons/core';
|
||||||
|
import type { SavedConnection } from '@shared/types';
|
||||||
|
import { ConnectionsService } from '../connections.service';
|
||||||
|
import { DialogsService } from '../dialogs.service';
|
||||||
|
import { PanelsService } from '../panels.service';
|
||||||
|
|
||||||
|
interface Draft {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
folderId: string | null;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
authKind: 'password' | 'key';
|
||||||
|
password: string;
|
||||||
|
privateKeyPath: string;
|
||||||
|
passphrase: string;
|
||||||
|
initialPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDraft(c: SavedConnection): Draft {
|
||||||
|
return {
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
folderId: c.folderId,
|
||||||
|
host: c.host,
|
||||||
|
port: c.port,
|
||||||
|
username: c.username,
|
||||||
|
authKind: c.auth.kind,
|
||||||
|
password: c.auth.kind === 'password' ? c.auth.password : '',
|
||||||
|
privateKeyPath: c.auth.kind === 'key' ? c.auth.privateKeyPath : '',
|
||||||
|
passphrase: c.auth.kind === 'key' ? (c.auth.passphrase ?? '') : '',
|
||||||
|
initialPath: c.initialPath ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromDraft(d: Draft): SavedConnection {
|
||||||
|
return {
|
||||||
|
id: d.id,
|
||||||
|
name: d.name.trim() || `${d.username}@${d.host}`,
|
||||||
|
folderId: d.folderId,
|
||||||
|
host: d.host.trim(),
|
||||||
|
port: d.port || 22,
|
||||||
|
username: d.username.trim(),
|
||||||
|
auth:
|
||||||
|
d.authKind === 'password'
|
||||||
|
? { kind: 'password', password: d.password }
|
||||||
|
: {
|
||||||
|
kind: 'key',
|
||||||
|
privateKeyPath: d.privateKeyPath.trim(),
|
||||||
|
...(d.passphrase ? { passphrase: d.passphrase } : {}),
|
||||||
|
},
|
||||||
|
initialPath: d.initialPath.trim() || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-connection-manager',
|
||||||
|
imports: [NgIcon],
|
||||||
|
templateUrl: './connection-manager.html',
|
||||||
|
})
|
||||||
|
export class ConnectionManager {
|
||||||
|
readonly store = inject(ConnectionsService);
|
||||||
|
readonly dialogs = inject(DialogsService);
|
||||||
|
readonly panels = inject(PanelsService);
|
||||||
|
|
||||||
|
readonly draft = signal<Draft | null>(null);
|
||||||
|
readonly busy = signal(false);
|
||||||
|
readonly valid = computed(() => {
|
||||||
|
const d = this.draft();
|
||||||
|
if (!d) return false;
|
||||||
|
if (!d.host.trim() || !d.username.trim()) return false;
|
||||||
|
if (d.authKind === 'key' && !d.privateKeyPath.trim()) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
patch<K extends keyof Draft>(key: K, value: Draft[K]): void {
|
||||||
|
this.draft.update((d) => (d ? { ...d, [key]: value } : d));
|
||||||
|
}
|
||||||
|
|
||||||
|
newConnection(): void {
|
||||||
|
this.draft.set({
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
folderId: null,
|
||||||
|
host: '',
|
||||||
|
port: 22,
|
||||||
|
username: 'root',
|
||||||
|
authKind: 'password',
|
||||||
|
password: '',
|
||||||
|
privateKeyPath: '',
|
||||||
|
passphrase: '',
|
||||||
|
initialPath: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
edit(connection: SavedConnection): void {
|
||||||
|
this.draft.set(toDraft(connection));
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.dialogs.manager.set(false);
|
||||||
|
this.panels.refocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(): Promise<string | null> {
|
||||||
|
const d = this.draft();
|
||||||
|
if (!d || !this.valid()) return null;
|
||||||
|
const id = await this.store.upsert(fromDraft(d));
|
||||||
|
if (id) this.patch('id', id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveAndConnect(): Promise<void> {
|
||||||
|
const d = this.draft();
|
||||||
|
if (!d) return;
|
||||||
|
this.busy.set(true);
|
||||||
|
try {
|
||||||
|
const id = await this.save();
|
||||||
|
if (!id) return;
|
||||||
|
const connection = fromDraft({ ...d, id });
|
||||||
|
const ok = await this.panels.connectPanel(
|
||||||
|
this.panels.activeSide(),
|
||||||
|
connection,
|
||||||
|
connection.name,
|
||||||
|
);
|
||||||
|
if (ok) this.close();
|
||||||
|
} finally {
|
||||||
|
this.busy.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCurrent(): void {
|
||||||
|
const d = this.draft();
|
||||||
|
if (!d?.id) {
|
||||||
|
this.draft.set(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.dialogs.confirm.set({
|
||||||
|
title: 'Delete connection',
|
||||||
|
body: `Delete "${d.name || d.host}"?`,
|
||||||
|
confirmLabel: 'Delete',
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: async () => {
|
||||||
|
await this.store.remove(d.id);
|
||||||
|
this.draft.set(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
newFolder(): void {
|
||||||
|
this.dialogs.prompt.set({
|
||||||
|
title: 'New folder',
|
||||||
|
placeholder: 'folder name',
|
||||||
|
confirmLabel: 'Create',
|
||||||
|
onConfirm: (name) => void this.store.upsertFolder({ id: '', name }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteFolder(id: string, name: string): void {
|
||||||
|
this.dialogs.confirm.set({
|
||||||
|
title: 'Delete folder',
|
||||||
|
body: `Delete folder "${name}"? Its connections move to the root.`,
|
||||||
|
confirmLabel: 'Delete',
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: () => void this.store.removeFolder(id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
|
import type { ConnFolder, SavedConnection } from '@shared/types';
|
||||||
|
import { RpcService } from './rpc.service';
|
||||||
|
import { ToastService } from './toast.service';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ConnectionsService {
|
||||||
|
private readonly rpc = inject(RpcService);
|
||||||
|
private readonly toast = inject(ToastService);
|
||||||
|
|
||||||
|
readonly folders = signal<ConnFolder[]>([]);
|
||||||
|
readonly connections = signal<SavedConnection[]>([]);
|
||||||
|
|
||||||
|
/** Root-level connections plus one group per folder (folders always shown). */
|
||||||
|
readonly grouped = computed(() => {
|
||||||
|
const connections = this.connections();
|
||||||
|
return {
|
||||||
|
root: connections.filter((c) => !c.folderId),
|
||||||
|
folders: this.folders().map((folder) => ({
|
||||||
|
folder,
|
||||||
|
connections: connections.filter((c) => c.folderId === folder.id),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (this.rpc.available) void this.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async reload(): Promise<void> {
|
||||||
|
const res = await this.rpc.request.getConnections({});
|
||||||
|
if (!res.ok) {
|
||||||
|
this.toast.error(res.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.folders.set(res.store.folders);
|
||||||
|
this.connections.set(res.store.connections);
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsert(connection: SavedConnection): Promise<string | null> {
|
||||||
|
const res = await this.rpc.request.upsertConnection({ connection });
|
||||||
|
if (!res.ok) {
|
||||||
|
this.toast.error(res.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await this.reload();
|
||||||
|
return res.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
|
const res = await this.rpc.request.deleteConnection({ id });
|
||||||
|
if (!res.ok) this.toast.error(res.error);
|
||||||
|
await this.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertFolder(folder: ConnFolder): Promise<void> {
|
||||||
|
const res = await this.rpc.request.upsertFolder({ folder });
|
||||||
|
if (!res.ok) this.toast.error(res.error);
|
||||||
|
await this.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeFolder(id: string): Promise<void> {
|
||||||
|
const res = await this.rpc.request.deleteFolder({ id });
|
||||||
|
if (!res.ok) this.toast.error(res.error);
|
||||||
|
await this.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ export class DialogsService {
|
|||||||
readonly prompt = signal<PromptRequest | null>(null);
|
readonly prompt = signal<PromptRequest | null>(null);
|
||||||
readonly confirm = signal<ConfirmRequest | null>(null);
|
readonly confirm = signal<ConfirmRequest | null>(null);
|
||||||
readonly chmod = signal<ChmodRequest | null>(null);
|
readonly chmod = signal<ChmodRequest | null>(null);
|
||||||
|
readonly manager = signal(false);
|
||||||
|
|
||||||
get anyOpen(): boolean {
|
get anyOpen(): boolean {
|
||||||
return !!(this.prompt() || this.confirm() || this.chmod());
|
return !!(this.prompt() || this.confirm() || this.chmod());
|
||||||
|
|||||||
@@ -1,13 +1,85 @@
|
|||||||
<!-- session strip -->
|
<!-- session strip -->
|
||||||
<div class="flex items-center gap-1.5 border-b border-border px-2 py-1.5">
|
<div class="relative flex items-center gap-1.5 border-b border-border px-2 py-1.5">
|
||||||
<button
|
<button
|
||||||
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium hover:bg-accent"
|
(click)="menuOpen.set(!menuOpen())"
|
||||||
|
class="flex min-w-0 items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium hover:bg-accent"
|
||||||
title="Choose what this panel shows"
|
title="Choose what this panel shows"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaHardDrive" class="text-muted-foreground" size="0.9rem" />
|
@if (panel().connecting()) {
|
||||||
<span>{{ panel().title() }}</span>
|
<ng-icon name="mynaSpinner" class="shrink-0 animate-spin text-primary" size="0.9rem" />
|
||||||
<ng-icon name="mynaChevronDown" class="text-muted-foreground" size="0.8rem" />
|
} @else {
|
||||||
|
<ng-icon
|
||||||
|
[name]="isRemote() ? 'mynaServers' : 'mynaHardDrive'"
|
||||||
|
[class]="isRemote() ? 'shrink-0 text-primary' : 'shrink-0 text-muted-foreground'"
|
||||||
|
size="0.9rem"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<span class="truncate">{{ panel().title() }}</span>
|
||||||
|
<ng-icon name="mynaChevronDown" class="shrink-0 text-muted-foreground" size="0.8rem" />
|
||||||
</button>
|
</button>
|
||||||
|
@if (isRemote()) {
|
||||||
|
<button
|
||||||
|
(click)="disconnect()"
|
||||||
|
title="Disconnect (back to local)"
|
||||||
|
class="grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaLogout" size="0.85rem" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (menuOpen()) {
|
||||||
|
<div class="fixed inset-0 z-20" (click)="menuOpen.set(false)"></div>
|
||||||
|
<div
|
||||||
|
class="absolute top-full left-2 z-30 mt-1 max-h-80 w-64 overflow-y-auto rounded-lg border border-border bg-card py-1 text-card-foreground shadow-xl"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
(click)="pickLocal()"
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-accent"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaHardDrive" size="0.9rem" class="shrink-0 text-muted-foreground" />
|
||||||
|
<span class="flex-1">Local</span>
|
||||||
|
@if (!isRemote()) {
|
||||||
|
<ng-icon name="mynaCheck" size="0.85rem" class="shrink-0 text-primary" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (connections.grouped().root.length || connections.grouped().folders.length) {
|
||||||
|
<div class="my-1 border-t border-border"></div>
|
||||||
|
}
|
||||||
|
@for (connection of connections.grouped().root; track connection.id) {
|
||||||
|
<button
|
||||||
|
(click)="pickConnection(connection)"
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-accent"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaServers" size="0.9rem" class="shrink-0 text-muted-foreground" />
|
||||||
|
<span class="flex-1 truncate">{{ connection.name }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@for (group of connections.grouped().folders; track group.folder.id) {
|
||||||
|
<div class="px-3 pt-1.5 pb-0.5 text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
|
||||||
|
{{ group.folder.name }}
|
||||||
|
</div>
|
||||||
|
@for (connection of group.connections; track connection.id) {
|
||||||
|
<button
|
||||||
|
(click)="pickConnection(connection)"
|
||||||
|
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-xs hover:bg-accent"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaServers" size="0.9rem" class="shrink-0 text-muted-foreground" />
|
||||||
|
<span class="flex-1 truncate">{{ connection.name }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="my-1 border-t border-border"></div>
|
||||||
|
<button
|
||||||
|
(click)="openManager()"
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-accent"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaCog" size="0.9rem" class="shrink-0 text-muted-foreground" />
|
||||||
|
Manage connections…
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- path bar -->
|
<!-- path bar -->
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import {
|
|||||||
viewChild,
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/core';
|
import { NgIcon } from '@ng-icons/core';
|
||||||
import type { FileEntry } from '@shared/types';
|
import type { FileEntry, SavedConnection } from '@shared/types';
|
||||||
|
import { ConnectionsService } from '../connections.service';
|
||||||
|
import { DialogsService } from '../dialogs.service';
|
||||||
import { FileActionsService } from '../file-actions.service';
|
import { FileActionsService } from '../file-actions.service';
|
||||||
import { formatDate, formatSize } from '../format';
|
import { formatDate, formatSize } from '../format';
|
||||||
import { PanelsService, type Side, type SortKey } from '../panels.service';
|
import { PanelsService, type Side, type SortKey } from '../panels.service';
|
||||||
@@ -27,10 +29,14 @@ export class FilePanel {
|
|||||||
readonly panels = inject(PanelsService);
|
readonly panels = inject(PanelsService);
|
||||||
readonly actions = inject(FileActionsService);
|
readonly actions = inject(FileActionsService);
|
||||||
readonly transfers = inject(TransfersService);
|
readonly transfers = inject(TransfersService);
|
||||||
|
readonly connections = inject(ConnectionsService);
|
||||||
|
readonly dialogs = inject(DialogsService);
|
||||||
readonly panel = computed(() => this.panels.get(this.side()));
|
readonly panel = computed(() => this.panels.get(this.side()));
|
||||||
readonly isActive = computed(() => this.panels.activeSide() === this.side());
|
readonly isActive = computed(() => this.panels.activeSide() === this.side());
|
||||||
|
readonly isRemote = computed(() => this.panel().sessionId() !== 'local');
|
||||||
|
|
||||||
readonly pathDraft = signal('');
|
readonly pathDraft = signal('');
|
||||||
|
readonly menuOpen = signal(false);
|
||||||
|
|
||||||
private readonly listEl = viewChild.required<ElementRef<HTMLDivElement>>('list');
|
private readonly listEl = viewChild.required<ElementRef<HTMLDivElement>>('list');
|
||||||
private lastClickedIndex = 0;
|
private lastClickedIndex = 0;
|
||||||
@@ -52,6 +58,27 @@ export class FilePanel {
|
|||||||
this.panels.activeSide.set(this.side());
|
this.panels.activeSide.set(this.side());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pickLocal(): void {
|
||||||
|
this.menuOpen.set(false);
|
||||||
|
void this.panels.disconnectPanel(this.side());
|
||||||
|
}
|
||||||
|
|
||||||
|
pickConnection(connection: SavedConnection): void {
|
||||||
|
this.menuOpen.set(false);
|
||||||
|
this.activate();
|
||||||
|
void this.panels.connectPanel(this.side(), connection, connection.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
openManager(): void {
|
||||||
|
this.menuOpen.set(false);
|
||||||
|
this.activate();
|
||||||
|
this.dialogs.manager.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
void this.panels.disconnectPanel(this.side());
|
||||||
|
}
|
||||||
|
|
||||||
iconFor(entry: FileEntry): string {
|
iconFor(entry: FileEntry): string {
|
||||||
return entry.type === 'dir' ? 'mynaFolder' : 'mynaFile';
|
return entry.type === 'dir' ? 'mynaFolder' : 'mynaFile';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, computed, signal } from '@angular/core';
|
import { Injectable, computed, effect, signal } from '@angular/core';
|
||||||
import type { FileEntry } from '@shared/types';
|
import type { ConnectionConfig, FileEntry } from '@shared/types';
|
||||||
import { RpcService } from './rpc.service';
|
import { RpcService } from './rpc.service';
|
||||||
import { ToastService } from './toast.service';
|
import { ToastService } from './toast.service';
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ interface PanelDeps {
|
|||||||
export class PanelState {
|
export class PanelState {
|
||||||
readonly sessionId = signal('local');
|
readonly sessionId = signal('local');
|
||||||
readonly title = signal('Local');
|
readonly title = signal('Local');
|
||||||
|
readonly connecting = signal(false);
|
||||||
readonly cwd = signal('');
|
readonly cwd = signal('');
|
||||||
readonly entries = signal<FileEntry[]>([]);
|
readonly entries = signal<FileEntry[]>([]);
|
||||||
readonly loading = signal(false);
|
readonly loading = signal(false);
|
||||||
@@ -152,14 +153,70 @@ export class PanelsService {
|
|||||||
/** Bumped to tell the active panel to grab keyboard focus again (e.g. after a dialog closes). */
|
/** Bumped to tell the active panel to grab keyboard focus again (e.g. after a dialog closes). */
|
||||||
readonly focusTick = signal(0);
|
readonly focusTick = signal(0);
|
||||||
|
|
||||||
constructor(rpc: RpcService, toast: ToastService) {
|
constructor(
|
||||||
|
private rpc: RpcService,
|
||||||
|
private toast: ToastService,
|
||||||
|
) {
|
||||||
const deps = { rpc, toast };
|
const deps = { rpc, toast };
|
||||||
this.left = new PanelState(deps);
|
this.left = new PanelState(deps);
|
||||||
this.right = new PanelState(deps);
|
this.right = new PanelState(deps);
|
||||||
if (rpc.available) {
|
if (rpc.available) {
|
||||||
|
// this service is first injected during template rendering, where signal
|
||||||
|
// writes (load -> loading.set) are forbidden — defer past the render pass
|
||||||
|
queueMicrotask(() => {
|
||||||
void this.left.load(null);
|
void this.left.load(null);
|
||||||
void this.right.load(null);
|
void this.right.load(null);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// a session died on its own — fall the affected panel(s) back to local
|
||||||
|
effect(() => {
|
||||||
|
const event = this.rpc.sessionClosed();
|
||||||
|
if (!event) return;
|
||||||
|
for (const panel of [this.left, this.right]) {
|
||||||
|
if (panel.sessionId() === event.sessionId) {
|
||||||
|
this.toast.error(`${panel.title()}: disconnected (${event.reason})`);
|
||||||
|
this.resetToLocal(panel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Connect a panel to a server; replaces (and disconnects) its previous remote session. */
|
||||||
|
async connectPanel(side: Side, config: ConnectionConfig, title: string): Promise<boolean> {
|
||||||
|
const panel = this.get(side);
|
||||||
|
panel.connecting.set(true);
|
||||||
|
try {
|
||||||
|
const res = await this.rpc.request.connect({ config });
|
||||||
|
if (!res.ok) {
|
||||||
|
this.toast.error(`${title}: ${res.error}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const previous = panel.sessionId();
|
||||||
|
if (previous !== 'local') {
|
||||||
|
void this.rpc.request.disconnect({ sessionId: previous });
|
||||||
|
}
|
||||||
|
panel.sessionId.set(res.sessionId);
|
||||||
|
panel.title.set(title);
|
||||||
|
await panel.load(config.initialPath ?? res.homeDir);
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
panel.connecting.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnectPanel(side: Side): Promise<void> {
|
||||||
|
const panel = this.get(side);
|
||||||
|
const sessionId = panel.sessionId();
|
||||||
|
if (sessionId === 'local') return;
|
||||||
|
await this.rpc.request.disconnect({ sessionId });
|
||||||
|
this.resetToLocal(panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetToLocal(panel: PanelState): void {
|
||||||
|
panel.sessionId.set('local');
|
||||||
|
panel.title.set('Local');
|
||||||
|
void panel.load(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
get(side: Side): PanelState {
|
get(side: Side): PanelState {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export class RpcService {
|
|||||||
readonly sessionClosed = signal<SessionClosedEvent | null>(null);
|
readonly sessionClosed = signal<SessionClosedEvent | null>(null);
|
||||||
|
|
||||||
private rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined;
|
private rpc: ReturnType<typeof Electroview.defineRPC<AppRPC>> | undefined;
|
||||||
|
private ready: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.available = typeof window !== 'undefined' && '__electrobun' in window;
|
this.available = typeof window !== 'undefined' && '__electrobun' in window;
|
||||||
@@ -31,12 +32,36 @@ export class RpcService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
new Electroview({ rpc: this.rpc });
|
const electroview = new Electroview({ rpc: this.rpc });
|
||||||
|
|
||||||
|
// Requests fired before the RPC WebSocket finishes connecting are lost, so
|
||||||
|
// everything below gates on it being open (services fire calls at startup).
|
||||||
|
const socket = electroview.bunSocket;
|
||||||
|
if (socket && socket.readyState !== WebSocket.OPEN) {
|
||||||
|
this.ready = new Promise((resolve) => {
|
||||||
|
socket.addEventListener('open', () => resolve(), { once: true });
|
||||||
|
socket.addEventListener('error', () => resolve(), { once: true });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Typed request proxy: `await rpc.request.listDir({...})`. */
|
}
|
||||||
|
|
||||||
|
/** Typed request proxy: `await rpc.request.listDir({...})`. Waits for the bridge. */
|
||||||
get request() {
|
get request() {
|
||||||
if (!this.rpc) throw new Error('RPC is only available inside the desktop app');
|
const rpc = this.rpc;
|
||||||
return this.rpc.request;
|
if (!rpc) throw new Error('RPC is only available inside the desktop app');
|
||||||
|
const ready = this.ready;
|
||||||
|
return new Proxy({} as (typeof rpc)['request'], {
|
||||||
|
get:
|
||||||
|
(_target, method: string) =>
|
||||||
|
async (params?: unknown) => {
|
||||||
|
await ready;
|
||||||
|
const methods = rpc.request as unknown as Record<
|
||||||
|
string,
|
||||||
|
(p?: unknown) => Promise<unknown>
|
||||||
|
>;
|
||||||
|
return methods[method](params);
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user