Redo connection manager: Stacks, More options, drag & drop, WinSCP import

Folders become Stacks (connections.json v2, no v1 migration): collapsible
stack-first list, open-stack-in-tabs (first reuses a pristine tab), drag
connections between stacks. More... reveals a per-connection Local
directory applied to the other panel when local and untouched. Import
reads a WinSCP.ini export incl. deobfuscated passwords.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 18:28:30 +02:00
co-authored by Claude Fable 5
parent 9a4420fdc2
commit e2f3737656
13 changed files with 533 additions and 125 deletions
+6 -1
View File
@@ -79,7 +79,12 @@ export class App {
const id = action.slice('connect:'.length);
const connection = this.connections.connections().find((c) => c.id === id);
if (connection) {
void this.panels.connectPanel(this.panels.activeSide(), connection, connection.name);
void this.panels.connectPanel(
this.panels.activeSide(),
connection,
connection.name,
connection.localDir,
);
}
return;
}
@@ -12,7 +12,7 @@
</div>
<div class="flex min-h-0 flex-1">
<!-- list -->
<!-- stacks + connections 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
@@ -23,64 +23,139 @@
<ng-icon name="mynaPlus" size="0.85rem" /> Connection
</button>
<button
(click)="newFolder()"
title="New folder"
(click)="newStack()"
title="New stack"
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
<ng-icon name="mynaFolderPlus" size="0.85rem" /> Stack
</button>
<div class="flex-1"></div>
<button
(click)="importWinScp()"
[disabled]="importing()"
title="Import sessions from a WinSCP.ini export"
class="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
>
@if (importing()) {
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
} @else {
<ng-icon name="mynaArchive" size="0.85rem" />
}
Import
</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"
<!-- stacks -->
@for (group of store.grouped().stacks; track group.stack.id) {
<div
(dragover)="onDragOver($event, group.stack.id)"
(dragleave)="dropTarget() === group.stack.id && dropTarget.set(null)"
(drop)="onDrop($event, group.stack.id)"
class="rounded-md"
[class.bg-primary/10]="dropTarget() === group.stack.id"
[class.ring-1]="dropTarget() === group.stack.id"
[class.ring-inset]="dropTarget() === group.stack.id"
[class.ring-primary]="dropTarget() === group.stack.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 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>
@if (group.connections.length) {
<div class="group flex items-center gap-1 px-2 pt-2 pb-0.5">
<button
(click)="openFolder(group.connections)"
title="Open all in tabs"
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-primary"
(click)="toggleStack(group.stack)"
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground hover:text-foreground"
[title]="group.stack.collapsed ? 'Expand' : 'Collapse'"
>
<ng-icon name="mynaArrowRight" size="0.75rem" />
<ng-icon
[name]="group.stack.collapsed ? 'mynaChevronRight' : 'mynaChevronDown'"
size="0.8rem"
/>
</button>
<span class="truncate text-[11px] font-medium text-muted-foreground uppercase">
{{ group.stack.name }}
</span>
<span class="shrink-0 text-[10px] text-muted-foreground/70 tabular-nums">
{{ group.connections.length }}
</span>
<div class="min-w-0 flex-1"></div>
<button
(click)="renameStack(group.stack)"
title="Rename stack"
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-foreground"
>
<ng-icon name="mynaEdit" size="0.75rem" />
</button>
<button
(click)="deleteStack(group.stack)"
title="Delete stack"
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-destructive"
>
<ng-icon name="mynaTrash" size="0.75rem" />
</button>
@if (group.connections.length) {
<button
(click)="openStack(group.connections)"
title="Open stack in tabs"
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground hover:text-primary"
>
<ng-icon name="mynaArrowRight" size="0.8rem" />
</button>
}
</div>
@if (!group.stack.collapsed) {
@for (connection of group.connections; track connection.id) {
<button
(click)="edit(connection)"
draggable="true"
(dragstart)="onDragStart($event, connection)"
(dragend)="onDragEnd()"
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"
[class.opacity-50]="draggedId() === 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>
}
}
<button
(click)="deleteFolder(group.folder.id, group.folder.name)"
title="Delete folder"
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-destructive"
>
<ng-icon name="mynaTrash" size="0.75rem" />
</button>
</div>
@for (connection of group.connections; track connection.id) {
}
<!-- loose connections -->
<div
(dragover)="onDragOver($event, 'loose')"
(dragleave)="dropTarget() === 'loose' && dropTarget.set(null)"
(drop)="onDrop($event, null)"
class="min-h-8 rounded-md pb-1"
[class.bg-primary/10]="dropTarget() === 'loose'"
[class.ring-1]="dropTarget() === 'loose'"
[class.ring-inset]="dropTarget() === 'loose'"
[class.ring-primary]="dropTarget() === 'loose'"
>
@if (store.grouped().stacks.length && store.grouped().loose.length) {
<div class="px-2 pt-2 pb-0.5 text-[11px] font-medium text-muted-foreground uppercase">
Connections
</div>
}
@for (connection of store.grouped().loose; 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"
draggable="true"
(dragstart)="onDragStart($event, connection)"
(dragend)="onDragEnd()"
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"
[class.opacity-50]="draggedId() === 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) {
</div>
@if (!store.connections().length && !store.stacks().length) {
<p class="px-3 py-2 text-[11px] text-muted-foreground">
No saved connections yet. Create one to get started.
No saved connections yet. Create one, or import your WinSCP sessions.
</p>
}
</div>
@@ -122,17 +197,17 @@
/>
</label>
<label class="flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">Folder</span>
<span class="text-muted-foreground">Stack</span>
<select
(change)="patch('folderId', $any($event.target).value || null)"
(change)="patch('stackId', $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"
>
<!-- [selected] per option: binding [value] on the <select> runs
before the options render and silently shows "(none)" -->
<option value="" [selected]="!d.folderId">(none)</option>
@for (folder of store.folders(); track folder.id) {
<option [value]="folder.id" [selected]="d.folderId === folder.id">
{{ folder.name }}
<option value="" [selected]="!d.stackId">(none)</option>
@for (stack of store.stacks(); track stack.id) {
<option [value]="stack.id" [selected]="d.stackId === stack.id">
{{ stack.name }}
</option>
}
</select>
@@ -223,6 +298,22 @@
/>
</label>
}
@if (moreOpen()) {
<label class="col-span-2 flex flex-col gap-1 text-xs">
<span class="text-muted-foreground">Local directory (optional)</span>
<input
[value]="d.localDir"
(input)="patch('localDir', $any($event.target).value)"
placeholder="~/projects/my-server"
spellcheck="false"
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
/>
<span class="text-[10px] text-muted-foreground/80">
The other panel opens here when it's on Local and hasn't been navigated.
</span>
</label>
}
</div>
<div class="flex items-center gap-2 border-t border-border px-4 py-2.5">
@@ -232,6 +323,12 @@
>
<ng-icon name="mynaTrash" size="0.85rem" /> Delete
</button>
<button
(click)="moreOpen.set(!moreOpen())"
class="rounded-md border border-border px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
{{ moreOpen() ? 'Less…' : 'More…' }}
</button>
<div class="flex-1"></div>
<button
(click)="save()"
@@ -1,6 +1,6 @@
import { Component, computed, inject, signal } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import type { SavedConnection } from '@shared/types';
import type { SavedConnection, Stack } from '@shared/types';
import { ConnectionsService } from '../connections.service';
import { DialogsService } from '../dialogs.service';
import { PanelsService } from '../panels.service';
@@ -9,7 +9,7 @@ import { RpcService } from '../rpc.service';
interface Draft {
id: string;
name: string;
folderId: string | null;
stackId: string | null;
host: string;
port: number;
username: string;
@@ -18,13 +18,14 @@ interface Draft {
privateKeyPath: string;
passphrase: string;
initialPath: string;
localDir: string;
}
function toDraft(c: SavedConnection): Draft {
return {
id: c.id,
name: c.name,
folderId: c.folderId,
stackId: c.stackId,
host: c.host,
port: c.port,
username: c.username,
@@ -34,6 +35,7 @@ function toDraft(c: SavedConnection): Draft {
privateKeyPath: c.auth.kind === 'key' ? c.auth.privateKeyPath : '',
passphrase: c.auth.kind === 'key' ? (c.auth.passphrase ?? '') : '',
initialPath: c.initialPath ?? '',
localDir: c.localDir ?? '',
};
}
@@ -41,7 +43,7 @@ function fromDraft(d: Draft): SavedConnection {
return {
id: d.id,
name: d.name.trim() || `${d.username}@${d.host}`,
folderId: d.folderId,
stackId: d.stackId,
host: d.host.trim(),
port: d.port || 22,
username: d.username.trim(),
@@ -54,6 +56,7 @@ function fromDraft(d: Draft): SavedConnection {
...(d.passphrase ? { passphrase: d.passphrase } : {}),
},
initialPath: d.initialPath.trim() || null,
localDir: d.localDir.trim() || null,
};
}
@@ -70,6 +73,14 @@ export class ConnectionManager {
readonly draft = signal<Draft | null>(null);
readonly busy = signal(false);
readonly importing = signal(false);
/** The More… section (local directory) in the edit form. */
readonly moreOpen = signal(false);
/** Connection id being dragged between stacks. */
readonly draggedId = signal<string | null>(null);
/** Highlighted drop target: a stack id, 'loose', or null. */
readonly dropTarget = signal<string | null>(null);
readonly valid = computed(() => {
const d = this.draft();
if (!d) return false;
@@ -83,10 +94,11 @@ export class ConnectionManager {
}
newConnection(): void {
this.moreOpen.set(false);
this.draft.set({
id: '',
name: '',
folderId: null,
stackId: null,
host: '',
port: 22,
username: 'root',
@@ -95,11 +107,13 @@ export class ConnectionManager {
privateKeyPath: '',
passphrase: '',
initialPath: '',
localDir: '',
});
}
edit(connection: SavedConnection): void {
this.draft.set(toDraft(connection));
this.moreOpen.set(!!connection.localDir);
}
close(): void {
@@ -127,6 +141,7 @@ export class ConnectionManager {
this.panels.activeSide(),
connection,
connection.name,
connection.localDir,
);
if (ok) this.close();
} finally {
@@ -157,27 +172,80 @@ export class ConnectionManager {
if (res.ok && res.path) this.patch('privateKeyPath', res.path);
}
openFolder(connections: SavedConnection[]): void {
this.panels.openInNewTabs(connections);
async importWinScp(): Promise<void> {
this.importing.set(true);
try {
await this.store.importWinScp();
} finally {
this.importing.set(false);
}
}
// ---- stacks ----
openStack(connections: SavedConnection[]): void {
this.panels.openStack(connections);
this.close();
}
newFolder(): void {
toggleStack(stack: Stack): void {
void this.store.upsertStack({ ...stack, collapsed: !stack.collapsed });
}
newStack(): void {
this.dialogs.prompt.set({
title: 'New folder',
placeholder: 'folder name',
title: 'New stack',
placeholder: 'stack name',
confirmLabel: 'Create',
onConfirm: (name) => void this.store.upsertFolder({ id: '', name }),
onConfirm: (name) => void this.store.upsertStack({ 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),
renameStack(stack: Stack): void {
this.dialogs.prompt.set({
title: 'Rename stack',
initial: stack.name,
confirmLabel: 'Rename',
onConfirm: (name) => void this.store.upsertStack({ ...stack, name }),
});
}
deleteStack(stack: Stack): void {
this.dialogs.confirm.set({
title: 'Delete stack',
body: `Delete stack "${stack.name}"? Its connections stay, ungrouped.`,
confirmLabel: 'Delete',
destructive: true,
onConfirm: () => void this.store.removeStack(stack.id),
});
}
// ---- drag & drop (move a connection between stacks / loose) ----
onDragStart(event: DragEvent, connection: SavedConnection): void {
this.draggedId.set(connection.id);
event.dataTransfer?.setData('text/plain', connection.id);
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
}
onDragOver(event: DragEvent, target: string): void {
if (!this.draggedId()) return;
event.preventDefault();
this.dropTarget.set(target);
}
onDrop(event: DragEvent, stackId: string | null): void {
event.preventDefault();
const id = this.draggedId();
this.draggedId.set(null);
this.dropTarget.set(null);
const connection = this.store.connections().find((c) => c.id === id);
if (!connection || connection.stackId === stackId) return;
void this.store.upsert({ ...connection, stackId });
}
onDragEnd(): void {
this.draggedId.set(null);
this.dropTarget.set(null);
}
}
+36 -18
View File
@@ -1,5 +1,5 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import type { ConnFolder, SavedConnection, SshConfigHost } from '@shared/types';
import type { SavedConnection, SshConfigHost, Stack } from '@shared/types';
import { RpcService } from './rpc.service';
import { ToastService } from './toast.service';
@@ -8,20 +8,20 @@ export class ConnectionsService {
private readonly rpc = inject(RpcService);
private readonly toast = inject(ToastService);
readonly folders = signal<ConnFolder[]>([]);
readonly stacks = signal<Stack[]>([]);
readonly connections = signal<SavedConnection[]>([]);
/** Read-only hosts from ~/.ssh/config; empty when the file is absent or unreadable. */
readonly sshHosts = signal<SshConfigHost[]>([]);
/** Root-level connections plus one group per folder (folders always shown). */
/** One group per stack (stacks always shown), plus the loose connections. */
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),
stacks: this.stacks().map((stack) => ({
stack,
connections: connections.filter((c) => c.stackId === stack.id),
})),
loose: connections.filter((c) => !c.stackId),
};
});
@@ -32,22 +32,22 @@ export class ConnectionsService {
}
}
/** Silent on failure — a broken ssh config just means an empty group. */
async reloadSshHosts(): Promise<void> {
const res = await this.rpc.request.getSshConfigHosts({});
this.sshHosts.set(res.ok ? res.hosts : []);
}
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.stacks.set(res.store.stacks);
this.connections.set(res.store.connections);
}
/** Silent on failure — a broken ssh config just means an empty group. */
async reloadSshHosts(): Promise<void> {
const res = await this.rpc.request.getSshConfigHosts({});
this.sshHosts.set(res.ok ? res.hosts : []);
}
async upsert(connection: SavedConnection): Promise<string | null> {
const res = await this.rpc.request.upsertConnection({ connection });
if (!res.ok) {
@@ -64,15 +64,33 @@ export class ConnectionsService {
await this.reload();
}
async upsertFolder(folder: ConnFolder): Promise<void> {
const res = await this.rpc.request.upsertFolder({ folder });
async upsertStack(stack: Stack): Promise<void> {
const res = await this.rpc.request.upsertStack({ stack });
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 });
async removeStack(id: string): Promise<void> {
const res = await this.rpc.request.deleteStack({ id });
if (!res.ok) this.toast.error(res.error);
await this.reload();
}
/** Native picker + WinSCP.ini import; silent when the dialog is cancelled. */
async importWinScp(): Promise<void> {
const res = await this.rpc.request.importWinScp({});
if (!res.ok) {
this.toast.error(res.error);
return;
}
if (res.connections || res.stacks || res.skipped) {
const skipped = res.skipped ? ` (${res.skipped} skipped)` : '';
this.toast.success(
`Imported ${res.connections} connection${res.connections === 1 ? '' : 's'}` +
(res.stacks ? ` into ${res.stacks} new stack${res.stacks === 1 ? '' : 's'}` : '') +
skipped,
);
}
await this.reload();
}
}
+4 -4
View File
@@ -95,10 +95,10 @@
}
</button>
@if (connections.grouped().root.length || connections.grouped().folders.length) {
@if (connections.grouped().loose.length || connections.grouped().stacks.length) {
<div class="my-1 border-t border-border"></div>
}
@for (connection of connections.grouped().root; track connection.id) {
@for (connection of connections.grouped().loose; track connection.id) {
<button
(click)="pickConnection(connection)"
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-(length:--path-font) hover:bg-accent"
@@ -107,9 +107,9 @@
<span class="flex-1 truncate">{{ connection.name }}</span>
</button>
}
@for (group of connections.grouped().folders; track group.folder.id) {
@for (group of connections.grouped().stacks; track group.stack.id) {
<div class="px-3 pt-1.5 pb-0.5 text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
{{ group.folder.name }}
{{ group.stack.name }}
</div>
@for (connection of group.connections; track connection.id) {
<button
+1 -1
View File
@@ -89,7 +89,7 @@ export class FilePanel {
pickConnection(connection: SavedConnection): void {
this.menuOpen.set(false);
this.activate();
void this.panels.connectPanel(this.side(), connection, connection.name);
void this.panels.connectPanel(this.side(), connection, connection.name, connection.localDir);
}
toggleMenu(): void {
+39 -8
View File
@@ -26,6 +26,8 @@ export class PanelState {
readonly slowLoad = signal(false);
/** Bumping this discards the result of any in-flight connect (cancel/supersede). */
connectToken = 0;
/** True once the user navigated or connected — a "pristine" panel is still at local home. */
touched = false;
/** Input on this panel is ignored while it's connecting or loading. */
readonly busy = computed(() => this.connecting() || this.loading());
@@ -73,6 +75,7 @@ export class PanelState {
constructor(private deps: PanelDeps) {}
async load(path: string | null): Promise<void> {
if (path !== null) this.touched = true;
this.loading.set(true);
const slowTimer = setTimeout(() => this.slowLoad.set(true), 300);
try {
@@ -311,16 +314,29 @@ export class PanelsService {
}
}
/** One new tab per connection, its left panel connecting (right stays local). */
openInNewTabs(connections: SavedConnection[]): void {
for (const connection of connections) {
this.newWorkspace();
/**
* Open a stack: one tab per connection, left panel connecting (right stays
* local, jumping to the connection's localDir when set). The first
* connection reuses the current tab if it's pristine (local/local, never
* navigated) instead of leaving a useless Local tab behind.
*/
openStack(connections: SavedConnection[]): void {
for (const [index, connection] of connections.entries()) {
if (index > 0 || !this.workspaceIsPristine(this.activeWorkspace())) {
this.newWorkspace();
}
// connectPanel resolves the panel synchronously (before awaiting), so
// firing these back-to-back binds each connection to its own tab
void this.connectPanel('left', connection, connection.name);
void this.connectPanel('left', connection, connection.name, connection.localDir);
}
}
private workspaceIsPristine(workspace: Workspace): boolean {
return [workspace.left, workspace.right].every(
(p) => p.sessionId() === 'local' && !p.touched && !p.connecting(),
);
}
/** Reorder a tab (drag & drop). Order is purely visual; ids stay stable. */
moveWorkspace(id: number, toIndex: number): void {
this.workspaces.update((list) => {
@@ -361,9 +377,19 @@ export class PanelsService {
// ---- session binding ----
/** 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);
/**
* Connect a panel to a server; replaces (and disconnects) its previous remote
* session. When the connection has a localDir and the workspace's other panel
* is local and untouched, that panel jumps to the localDir.
*/
async connectPanel(
side: Side,
config: ConnectionConfig,
title: string,
localDir?: string | null,
): Promise<boolean> {
const workspace = this.activeWorkspace();
const panel = workspace[side];
const token = ++panel.connectToken;
panel.connecting.set(true);
panel.connectingTo.set(title);
@@ -385,6 +411,11 @@ export class PanelsService {
panel.sessionId.set(res.sessionId);
panel.fsKey.set(res.fsKey);
panel.title.set(title);
panel.touched = true;
const other = workspace[side === 'left' ? 'right' : 'left'];
if (localDir && other.sessionId() === 'local' && !other.touched) {
void other.load(localDir);
}
await panel.load(config.initialPath ?? res.homeDir);
return true;
} finally {