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
+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 {