From e2f37376564c45640814c3470c6e265f22880f35 Mon Sep 17 00:00:00 2001 From: Jokin Date: Mon, 13 Jul 2026 18:28:30 +0200 Subject: [PATCH] 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 --- src/bun/menu.ts | 16 +- src/bun/rpc-handlers.ts | 28 ++- src/bun/store/connections.ts | 26 +-- src/bun/store/winscp-import.ts | 157 +++++++++++++++ src/shared/rpc.ts | 11 +- src/shared/types.ts | 13 +- ui/src/app/app.ts | 7 +- .../connection-manager.html | 185 +++++++++++++----- .../connection-manager/connection-manager.ts | 104 ++++++++-- ui/src/app/connections.service.ts | 54 +++-- ui/src/app/file-panel/file-panel.html | 8 +- ui/src/app/file-panel/file-panel.ts | 2 +- ui/src/app/panels.service.ts | 47 ++++- 13 files changed, 533 insertions(+), 125 deletions(-) create mode 100644 src/bun/store/winscp-import.ts diff --git a/src/bun/menu.ts b/src/bun/menu.ts index 31e2222..a8cf349 100644 --- a/src/bun/menu.ts +++ b/src/bun/menu.ts @@ -11,21 +11,21 @@ import type { ConnectionStore } from "./store/connections"; export async function applyApplicationMenu(store: ConnectionStore): Promise { if (process.platform !== "darwin") return; - const { folders, connections } = await store.load(); + const { stacks, connections } = await store.load(); const item = (c: SavedConnection): ApplicationMenuItemConfig => ({ label: c.name, action: `connect:${c.id}`, }); - const rootItems = connections.filter((c) => !c.folderId).map(item); - const folderMenus: ApplicationMenuItemConfig[] = folders.map((folder) => ({ - label: folder.name, - submenu: connections.filter((c) => c.folderId === folder.id).map(item), + const looseItems = connections.filter((c) => !c.stackId).map(item); + const stackMenus: ApplicationMenuItemConfig[] = stacks.map((stack) => ({ + label: stack.name, + submenu: connections.filter((c) => c.stackId === stack.id).map(item), })); const connectSubmenu: ApplicationMenuItemConfig[] = [ - ...rootItems, - ...folderMenus, - ...(rootItems.length || folderMenus.length ? [{ type: "separator" } as const] : []), + ...looseItems, + ...stackMenus, + ...(looseItems.length || stackMenus.length ? [{ type: "separator" } as const] : []), { label: "Connection Manager…", action: "manage-connections" }, ]; diff --git a/src/bun/rpc-handlers.ts b/src/bun/rpc-handlers.ts index 637eba9..106b099 100644 --- a/src/bun/rpc-handlers.ts +++ b/src/bun/rpc-handlers.ts @@ -8,6 +8,7 @@ import { SftpProvider } from "./fs/sftp"; import type { ConnectionStore } from "./store/connections"; import type { SettingsStore } from "./store/settings"; import { loadSshConfigHosts } from "./store/ssh-config"; +import { importWinScpIni } from "./store/winscp-import"; import type { TransferEngine } from "./transfers/engine"; import { removeRecursive } from "./transfers/walk"; @@ -152,20 +153,39 @@ export function createAppRPC({ return {}; }), - upsertFolder: ({ folder }) => + upsertStack: ({ stack }) => wrap(async () => { - const id = await store.upsertFolder(folder); + const id = await store.upsertStack(stack); events.connectionsChanged(); return { id }; }), - deleteFolder: ({ id }) => + deleteStack: ({ id }) => wrap(async () => { - await store.deleteFolder(id); + await store.deleteStack(id); events.connectionsChanged(); return {}; }), + importWinScp: () => + wrap(async () => { + const { homedir } = await import("node:os"); + const picked = await Utils.openFileDialog({ + startingFolder: homedir(), + allowedFileTypes: "*", + canChooseFiles: true, + canChooseDirectory: false, + allowsMultipleSelection: false, + }); + const file = picked.find((p) => p.trim().length > 0); + // dialog cancelled — all-zero result, UI shows nothing + if (!file) return { connections: 0, stacks: 0, skipped: 0 }; + const text = await (await import("node:fs/promises")).readFile(file, "utf8"); + const result = await importWinScpIni(text, store); + events.connectionsChanged(); + return result; + }), + getSshConfigHosts: () => wrap(async () => ({ hosts: loadSshConfigHosts() })), // ---- settings ---- diff --git a/src/bun/store/connections.ts b/src/bun/store/connections.ts index 0bea562..88ee3b0 100644 --- a/src/bun/store/connections.ts +++ b/src/bun/store/connections.ts @@ -1,13 +1,14 @@ import fs from "node:fs/promises"; import path from "node:path"; -import type { ConnFolder, ConnectionsFile, SavedConnection } from "../../shared/types"; +import type { ConnectionsFile, SavedConnection, Stack } from "../../shared/types"; import { appDataDir } from "./paths"; -const EMPTY: ConnectionsFile = { version: 1, folders: [], connections: [] }; +const EMPTY: ConnectionsFile = { version: 2, stacks: [], connections: [] }; /** * Plain-JSON store for saved connections (passwords included — the user's * explicit v1 choice). Kept at 0600 and written atomically via tmp+rename. + * Only the current version 2 format is read; anything else starts empty. */ export class ConnectionStore { private readonly file = path.join(appDataDir(), "connections.json"); @@ -16,7 +17,8 @@ export class ConnectionStore { async load(): Promise { if (!this.data) { try { - this.data = JSON.parse(await fs.readFile(this.file, "utf8")) as ConnectionsFile; + const parsed = JSON.parse(await fs.readFile(this.file, "utf8")) as ConnectionsFile; + this.data = parsed.version === 2 ? parsed : structuredClone(EMPTY); } catch { this.data = structuredClone(EMPTY); } @@ -40,22 +42,22 @@ export class ConnectionStore { await this.save(); } - async upsertFolder(folder: ConnFolder): Promise { + async upsertStack(stack: Stack): Promise { 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); + const saved = { ...stack, id: stack.id || crypto.randomUUID() }; + const index = data.stacks.findIndex((s) => s.id === saved.id); + if (index >= 0) data.stacks[index] = saved; + else data.stacks.push(saved); await this.save(); return saved.id; } - /** Deleting a folder moves its connections to the root. */ - async deleteFolder(id: string): Promise { + /** Deleting a stack moves its connections to the loose list. */ + async deleteStack(id: string): Promise { const data = await this.load(); - data.folders = data.folders.filter((f) => f.id !== id); + data.stacks = data.stacks.filter((s) => s.id !== id); for (const connection of data.connections) { - if (connection.folderId === id) connection.folderId = null; + if (connection.stackId === id) connection.stackId = null; } await this.save(); } diff --git a/src/bun/store/winscp-import.ts b/src/bun/store/winscp-import.ts new file mode 100644 index 0000000..20fd6de --- /dev/null +++ b/src/bun/store/winscp-import.ts @@ -0,0 +1,157 @@ +import type { AuthConfig, SavedConnection } from "../../shared/types"; +import type { ConnectionStore } from "./connections"; + +const PW_MAGIC = 0xa3; +const PW_FLAG = 0xff; + +/** + * Reverse WinSCP's documented password obfuscation: the stored value is a hex + * string; each nibble pair decodes via ~(byte ^ 0xA3), with a small header + * (flag, length, offset) and — when flagged — a `username+hostname` key prefix + * that must match. Returns null when the value doesn't decode cleanly. + */ +export function decodeWinScpPassword( + hostname: string, + username: string, + encoded: string, +): string | null { + const nibbles = [...encoded].map((c) => Number.parseInt(c, 16)); + if (nibbles.some(Number.isNaN) || nibbles.length % 2 !== 0) return null; + let pos = 0; + const next = (): number => { + const a = nibbles[pos] ?? 0; + const b = nibbles[pos + 1] ?? 0; + pos += 2; + return ~(((a << 4) + b) ^ PW_MAGIC) & 0xff; + }; + + const flag = next(); + let length: number; + if (flag === PW_FLAG) { + next(); // dummy byte + length = next(); + } else { + length = flag; + } + // `pos += next() * 2` would snapshot pos before next() advances it + const offset = next(); + pos += offset * 2; // offset counts bytes; positions are nibbles + + let result = ""; + for (let i = 0; i < length; i++) { + if (pos >= nibbles.length) return null; + result += String.fromCharCode(next()); + } + if (flag === PW_FLAG) { + const key = username + hostname; + if (!result.startsWith(key)) return null; + result = result.slice(key.length); + } + return result; +} + +/** WinSCP percent-encodes special chars in section names and path values. */ +function decodePercent(value: string): string { + return value.replace(/%([0-9A-Fa-f]{2})/g, (_, hex: string) => + String.fromCharCode(Number.parseInt(hex, 16)), + ); +} + +interface IniSection { + name: string; + values: Record; +} + +function parseIni(text: string): IniSection[] { + const sections: IniSection[] = []; + let current: IniSection | null = null; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith(";")) continue; + const sectionMatch = line.match(/^\[(.+)\]$/); + if (sectionMatch) { + current = { name: sectionMatch[1], values: {} }; + sections.push(current); + continue; + } + const eq = line.indexOf("="); + if (eq > 0 && current) { + current.values[line.slice(0, eq)] = line.slice(eq + 1); + } + } + return sections; +} + +export interface WinScpImportResult { + connections: number; + stacks: number; + skipped: number; +} + +/** + * Import sessions from a WinSCP.ini export. Session folder paths become + * stacks (first level joined by " / " for nested folders), RemoteDirectory → + * initialPath, LocalDirectory → localDir. [Workspaces\…] sections are ignored. + */ +export async function importWinScpIni( + text: string, + store: ConnectionStore, +): Promise { + const sessions = parseIni(text).filter((s) => s.name.startsWith("Sessions\\")); + const stackIds = new Map(); + // snapshot the count — the store mutates the same array on upsert + const stacksBefore = (await store.load()).stacks.length; + for (const stack of (await store.load()).stacks) stackIds.set(stack.name, stack.id); + + let connections = 0; + let skipped = 0; + for (const session of sessions) { + const fullName = decodePercent(session.name.slice("Sessions\\".length)); + if (fullName === "Default Settings") continue; + const host = session.values["HostName"]; + if (!host) { + skipped++; + continue; + } + + const segments = fullName.split("/"); + const name = segments.pop() || fullName; + const stackName = segments.join(" / "); + let stackId: string | null = null; + if (stackName) { + stackId = stackIds.get(stackName) ?? null; + if (!stackId) { + stackId = await store.upsertStack({ id: "", name: stackName }); + stackIds.set(stackName, stackId); + } + } + + const username = session.values["UserName"] ?? "root"; + let auth: AuthConfig; + const keyFile = session.values["PublicKeyFile"]; + if (keyFile) { + auth = { kind: "key", privateKeyPath: decodePercent(keyFile) }; + } else { + const encoded = session.values["Password"]; + const password = encoded ? decodeWinScpPassword(host, username, encoded) : null; + auth = { kind: "password", password: password ?? "" }; + } + + const connection: SavedConnection = { + id: "", + stackId, + name, + host, + port: Number(session.values["PortNumber"]) || 22, + username, + auth, + initialPath: session.values["RemoteDirectory"] || null, + localDir: session.values["LocalDirectory"] || null, + }; + await store.upsertConnection(connection); + connections++; + } + + const stacksAfter = (await store.load()).stacks.length; + return { connections, stacks: stacksAfter - stacksBefore, skipped }; +} diff --git a/src/shared/rpc.ts b/src/shared/rpc.ts index d639562..f7dc788 100644 --- a/src/shared/rpc.ts +++ b/src/shared/rpc.ts @@ -1,6 +1,5 @@ import type { AppSettings, - ConnFolder, ConnectionConfig, ConnectionsFile, EntryType, @@ -8,6 +7,7 @@ import type { Result, SavedConnection, SshConfigHost, + Stack, TransferSnapshot, } from "./types"; @@ -67,8 +67,13 @@ export type AppRPC = { response: Result<{ id: string }>; }; deleteConnection: { params: { id: string }; response: Result }; - upsertFolder: { params: { folder: ConnFolder }; response: Result<{ id: string }> }; - deleteFolder: { params: { id: string }; response: Result }; + upsertStack: { params: { stack: Stack }; response: Result<{ id: string }> }; + deleteStack: { params: { id: string }; response: Result }; + /** Native file picker + parse of a WinSCP.ini export into the store. */ + importWinScp: { + params: Record; + response: Result<{ connections: number; stacks: number; skipped: number }>; + }; getSshConfigHosts: { params: Record; response: Result<{ hosts: SshConfigHost[] }>; diff --git a/src/shared/types.ts b/src/shared/types.ts index 9d3a423..05f2b6b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -27,13 +27,18 @@ export interface ConnectionConfig { export interface SavedConnection extends ConnectionConfig { id: string; - folderId: string | null; + stackId: string | null; name: string; + /** Other-panel local dir applied when that panel is local and untouched. */ + localDir?: string | null; } -export interface ConnFolder { +/** A named group of connections that can be opened together as tabs. */ +export interface Stack { id: string; name: string; + /** Collapsed in the connection-manager list (UI state, persisted). */ + collapsed?: boolean; } /** A concrete host alias resolved from ~/.ssh/config (read-only). */ @@ -47,8 +52,8 @@ export interface SshConfigHost { } export interface ConnectionsFile { - version: 1; - folders: ConnFolder[]; + version: 2; + stacks: Stack[]; connections: SavedConnection[]; } diff --git a/ui/src/app/app.ts b/ui/src/app/app.ts index b1dec90..61bbf6a 100644 --- a/ui/src/app/app.ts +++ b/ui/src/app/app.ts @@ -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; } diff --git a/ui/src/app/connection-manager/connection-manager.html b/ui/src/app/connection-manager/connection-manager.html index 55ca9a0..14fad00 100644 --- a/ui/src/app/connection-manager/connection-manager.html +++ b/ui/src/app/connection-manager/connection-manager.html @@ -12,7 +12,7 @@
- +
+
+
- @for (connection of store.grouped().root; track connection.id) { - - } - @for (group of store.grouped().folders; track group.folder.id) { -
- - - {{ group.folder.name }} - - @if (group.connections.length) { +
+ + {{ group.stack.name }} + + + {{ group.connections.length }} + +
+ + + @if (group.connections.length) { + + } +
+ @if (!group.stack.collapsed) { + @for (connection of group.connections; track connection.id) { + + } @empty { +
empty
+ } } -
- @for (connection of group.connections; track connection.id) { + } + + +
+ @if (store.grouped().stacks.length && store.grouped().loose.length) { +
+ Connections +
+ } + @for (connection of store.grouped().loose; track connection.id) { - } @empty { -
empty
} - } - @if (!store.connections().length && !store.folders().length) { +
+ + @if (!store.connections().length && !store.stacks().length) {

- No saved connections yet. Create one to get started. + No saved connections yet. Create one, or import your WinSCP sessions.

}
@@ -122,17 +197,17 @@ /> } + + @if (moreOpen()) { + + }
@@ -232,6 +323,12 @@ > Delete +
- @if (connections.grouped().root.length || connections.grouped().folders.length) { + @if (connections.grouped().loose.length || connections.grouped().stacks.length) {
} - @for (connection of connections.grouped().root; track connection.id) { + @for (connection of connections.grouped().loose; track connection.id) { } - @for (group of connections.grouped().folders; track group.folder.id) { + @for (group of connections.grouped().stacks; track group.stack.id) {
- {{ group.folder.name }} + {{ group.stack.name }}
@for (connection of group.connections; track connection.id) {