Files
miniSCP/ui/src/app/connection-manager/connection-manager.ts
T
JokinandClaude Fable 5 0d0e4cfde4 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>
2026-07-11 14:12:00 +02:00

171 lines
4.3 KiB
TypeScript

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),
});
}
}