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:
+8
-8
@@ -11,21 +11,21 @@ import type { ConnectionStore } from "./store/connections";
|
|||||||
export async function applyApplicationMenu(store: ConnectionStore): Promise<void> {
|
export async function applyApplicationMenu(store: ConnectionStore): Promise<void> {
|
||||||
if (process.platform !== "darwin") return;
|
if (process.platform !== "darwin") return;
|
||||||
|
|
||||||
const { folders, connections } = await store.load();
|
const { stacks, connections } = await store.load();
|
||||||
const item = (c: SavedConnection): ApplicationMenuItemConfig => ({
|
const item = (c: SavedConnection): ApplicationMenuItemConfig => ({
|
||||||
label: c.name,
|
label: c.name,
|
||||||
action: `connect:${c.id}`,
|
action: `connect:${c.id}`,
|
||||||
});
|
});
|
||||||
const rootItems = connections.filter((c) => !c.folderId).map(item);
|
const looseItems = connections.filter((c) => !c.stackId).map(item);
|
||||||
const folderMenus: ApplicationMenuItemConfig[] = folders.map((folder) => ({
|
const stackMenus: ApplicationMenuItemConfig[] = stacks.map((stack) => ({
|
||||||
label: folder.name,
|
label: stack.name,
|
||||||
submenu: connections.filter((c) => c.folderId === folder.id).map(item),
|
submenu: connections.filter((c) => c.stackId === stack.id).map(item),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const connectSubmenu: ApplicationMenuItemConfig[] = [
|
const connectSubmenu: ApplicationMenuItemConfig[] = [
|
||||||
...rootItems,
|
...looseItems,
|
||||||
...folderMenus,
|
...stackMenus,
|
||||||
...(rootItems.length || folderMenus.length ? [{ type: "separator" } as const] : []),
|
...(looseItems.length || stackMenus.length ? [{ type: "separator" } as const] : []),
|
||||||
{ label: "Connection Manager…", action: "manage-connections" },
|
{ label: "Connection Manager…", action: "manage-connections" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+24
-4
@@ -8,6 +8,7 @@ import { SftpProvider } from "./fs/sftp";
|
|||||||
import type { ConnectionStore } from "./store/connections";
|
import type { ConnectionStore } from "./store/connections";
|
||||||
import type { SettingsStore } from "./store/settings";
|
import type { SettingsStore } from "./store/settings";
|
||||||
import { loadSshConfigHosts } from "./store/ssh-config";
|
import { loadSshConfigHosts } from "./store/ssh-config";
|
||||||
|
import { importWinScpIni } from "./store/winscp-import";
|
||||||
import type { TransferEngine } from "./transfers/engine";
|
import type { TransferEngine } from "./transfers/engine";
|
||||||
import { removeRecursive } from "./transfers/walk";
|
import { removeRecursive } from "./transfers/walk";
|
||||||
|
|
||||||
@@ -152,20 +153,39 @@ export function createAppRPC({
|
|||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
upsertFolder: ({ folder }) =>
|
upsertStack: ({ stack }) =>
|
||||||
wrap(async () => {
|
wrap(async () => {
|
||||||
const id = await store.upsertFolder(folder);
|
const id = await store.upsertStack(stack);
|
||||||
events.connectionsChanged();
|
events.connectionsChanged();
|
||||||
return { id };
|
return { id };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
deleteFolder: ({ id }) =>
|
deleteStack: ({ id }) =>
|
||||||
wrap(async () => {
|
wrap(async () => {
|
||||||
await store.deleteFolder(id);
|
await store.deleteStack(id);
|
||||||
events.connectionsChanged();
|
events.connectionsChanged();
|
||||||
return {};
|
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() })),
|
getSshConfigHosts: () => wrap(async () => ({ hosts: loadSshConfigHosts() })),
|
||||||
|
|
||||||
// ---- settings ----
|
// ---- settings ----
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
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";
|
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
|
* Plain-JSON store for saved connections (passwords included — the user's
|
||||||
* explicit v1 choice). Kept at 0600 and written atomically via tmp+rename.
|
* 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 {
|
export class ConnectionStore {
|
||||||
private readonly file = path.join(appDataDir(), "connections.json");
|
private readonly file = path.join(appDataDir(), "connections.json");
|
||||||
@@ -16,7 +17,8 @@ export class ConnectionStore {
|
|||||||
async load(): Promise<ConnectionsFile> {
|
async load(): Promise<ConnectionsFile> {
|
||||||
if (!this.data) {
|
if (!this.data) {
|
||||||
try {
|
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 {
|
} catch {
|
||||||
this.data = structuredClone(EMPTY);
|
this.data = structuredClone(EMPTY);
|
||||||
}
|
}
|
||||||
@@ -40,22 +42,22 @@ export class ConnectionStore {
|
|||||||
await this.save();
|
await this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertFolder(folder: ConnFolder): Promise<string> {
|
async upsertStack(stack: Stack): Promise<string> {
|
||||||
const data = await this.load();
|
const data = await this.load();
|
||||||
const saved = { ...folder, id: folder.id || crypto.randomUUID() };
|
const saved = { ...stack, id: stack.id || crypto.randomUUID() };
|
||||||
const index = data.folders.findIndex((f) => f.id === saved.id);
|
const index = data.stacks.findIndex((s) => s.id === saved.id);
|
||||||
if (index >= 0) data.folders[index] = saved;
|
if (index >= 0) data.stacks[index] = saved;
|
||||||
else data.folders.push(saved);
|
else data.stacks.push(saved);
|
||||||
await this.save();
|
await this.save();
|
||||||
return saved.id;
|
return saved.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deleting a folder moves its connections to the root. */
|
/** Deleting a stack moves its connections to the loose list. */
|
||||||
async deleteFolder(id: string): Promise<void> {
|
async deleteStack(id: string): Promise<void> {
|
||||||
const data = await this.load();
|
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) {
|
for (const connection of data.connections) {
|
||||||
if (connection.folderId === id) connection.folderId = null;
|
if (connection.stackId === id) connection.stackId = null;
|
||||||
}
|
}
|
||||||
await this.save();
|
await this.save();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<WinScpImportResult> {
|
||||||
|
const sessions = parseIni(text).filter((s) => s.name.startsWith("Sessions\\"));
|
||||||
|
const stackIds = new Map<string, string>();
|
||||||
|
// 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 };
|
||||||
|
}
|
||||||
+8
-3
@@ -1,6 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
AppSettings,
|
AppSettings,
|
||||||
ConnFolder,
|
|
||||||
ConnectionConfig,
|
ConnectionConfig,
|
||||||
ConnectionsFile,
|
ConnectionsFile,
|
||||||
EntryType,
|
EntryType,
|
||||||
@@ -8,6 +7,7 @@ import type {
|
|||||||
Result,
|
Result,
|
||||||
SavedConnection,
|
SavedConnection,
|
||||||
SshConfigHost,
|
SshConfigHost,
|
||||||
|
Stack,
|
||||||
TransferSnapshot,
|
TransferSnapshot,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
@@ -67,8 +67,13 @@ export type AppRPC = {
|
|||||||
response: Result<{ id: string }>;
|
response: Result<{ id: string }>;
|
||||||
};
|
};
|
||||||
deleteConnection: { params: { id: string }; response: Result };
|
deleteConnection: { params: { id: string }; response: Result };
|
||||||
upsertFolder: { params: { folder: ConnFolder }; response: Result<{ id: string }> };
|
upsertStack: { params: { stack: Stack }; response: Result<{ id: string }> };
|
||||||
deleteFolder: { params: { id: string }; response: Result };
|
deleteStack: { params: { id: string }; response: Result };
|
||||||
|
/** Native file picker + parse of a WinSCP.ini export into the store. */
|
||||||
|
importWinScp: {
|
||||||
|
params: Record<string, never>;
|
||||||
|
response: Result<{ connections: number; stacks: number; skipped: number }>;
|
||||||
|
};
|
||||||
getSshConfigHosts: {
|
getSshConfigHosts: {
|
||||||
params: Record<string, never>;
|
params: Record<string, never>;
|
||||||
response: Result<{ hosts: SshConfigHost[] }>;
|
response: Result<{ hosts: SshConfigHost[] }>;
|
||||||
|
|||||||
+9
-4
@@ -27,13 +27,18 @@ export interface ConnectionConfig {
|
|||||||
|
|
||||||
export interface SavedConnection extends ConnectionConfig {
|
export interface SavedConnection extends ConnectionConfig {
|
||||||
id: string;
|
id: string;
|
||||||
folderId: string | null;
|
stackId: string | null;
|
||||||
name: string;
|
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;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
/** Collapsed in the connection-manager list (UI state, persisted). */
|
||||||
|
collapsed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A concrete host alias resolved from ~/.ssh/config (read-only). */
|
/** A concrete host alias resolved from ~/.ssh/config (read-only). */
|
||||||
@@ -47,8 +52,8 @@ export interface SshConfigHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ConnectionsFile {
|
export interface ConnectionsFile {
|
||||||
version: 1;
|
version: 2;
|
||||||
folders: ConnFolder[];
|
stacks: Stack[];
|
||||||
connections: SavedConnection[];
|
connections: SavedConnection[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -79,7 +79,12 @@ export class App {
|
|||||||
const id = action.slice('connect:'.length);
|
const id = action.slice('connect:'.length);
|
||||||
const connection = this.connections.connections().find((c) => c.id === id);
|
const connection = this.connections.connections().find((c) => c.id === id);
|
||||||
if (connection) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex min-h-0 flex-1">
|
<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 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">
|
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
||||||
<button
|
<button
|
||||||
@@ -23,64 +23,139 @@
|
|||||||
<ng-icon name="mynaPlus" size="0.85rem" /> Connection
|
<ng-icon name="mynaPlus" size="0.85rem" /> Connection
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="newFolder()"
|
(click)="newStack()"
|
||||||
title="New folder"
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto py-1">
|
<div class="flex-1 overflow-y-auto py-1">
|
||||||
@for (connection of store.grouped().root; track connection.id) {
|
<!-- stacks -->
|
||||||
<button
|
@for (group of store.grouped().stacks; track group.stack.id) {
|
||||||
(click)="edit(connection)"
|
<div
|
||||||
class="flex w-full items-center gap-1.5 px-3 py-1.5 text-left text-xs hover:bg-accent"
|
(dragover)="onDragOver($event, group.stack.id)"
|
||||||
[class.bg-accent]="draft()?.id === connection.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" />
|
<div class="group flex items-center gap-1 px-2 pt-2 pb-0.5">
|
||||||
<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) {
|
|
||||||
<button
|
<button
|
||||||
(click)="openFolder(group.connections)"
|
(click)="toggleStack(group.stack)"
|
||||||
title="Open all in tabs"
|
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground hover:text-foreground"
|
||||||
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-primary"
|
[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>
|
</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>
|
</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
|
<button
|
||||||
(click)="edit(connection)"
|
(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.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" />
|
<ng-icon name="mynaServers" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
||||||
<span class="truncate">{{ connection.name }}</span>
|
<span class="truncate">{{ connection.name }}</span>
|
||||||
</button>
|
</button>
|
||||||
} @empty {
|
|
||||||
<div class="py-1 pl-6 text-[11px] text-muted-foreground italic">empty</div>
|
|
||||||
}
|
}
|
||||||
}
|
</div>
|
||||||
@if (!store.connections().length && !store.folders().length) {
|
|
||||||
|
@if (!store.connections().length && !store.stacks().length) {
|
||||||
<p class="px-3 py-2 text-[11px] text-muted-foreground">
|
<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>
|
</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -122,17 +197,17 @@
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-xs">
|
<label class="flex flex-col gap-1 text-xs">
|
||||||
<span class="text-muted-foreground">Folder</span>
|
<span class="text-muted-foreground">Stack</span>
|
||||||
<select
|
<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"
|
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
|
<!-- [selected] per option: binding [value] on the <select> runs
|
||||||
before the options render and silently shows "(none)" -->
|
before the options render and silently shows "(none)" -->
|
||||||
<option value="" [selected]="!d.folderId">(none)</option>
|
<option value="" [selected]="!d.stackId">(none)</option>
|
||||||
@for (folder of store.folders(); track folder.id) {
|
@for (stack of store.stacks(); track stack.id) {
|
||||||
<option [value]="folder.id" [selected]="d.folderId === folder.id">
|
<option [value]="stack.id" [selected]="d.stackId === stack.id">
|
||||||
{{ folder.name }}
|
{{ stack.name }}
|
||||||
</option>
|
</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
@@ -223,6 +298,22 @@
|
|||||||
/>
|
/>
|
||||||
</label>
|
</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>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 border-t border-border px-4 py-2.5">
|
<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
|
<ng-icon name="mynaTrash" size="0.85rem" /> Delete
|
||||||
</button>
|
</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>
|
<div class="flex-1"></div>
|
||||||
<button
|
<button
|
||||||
(click)="save()"
|
(click)="save()"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Component, computed, inject, signal } from '@angular/core';
|
import { Component, computed, inject, signal } from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/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 { ConnectionsService } from '../connections.service';
|
||||||
import { DialogsService } from '../dialogs.service';
|
import { DialogsService } from '../dialogs.service';
|
||||||
import { PanelsService } from '../panels.service';
|
import { PanelsService } from '../panels.service';
|
||||||
@@ -9,7 +9,7 @@ import { RpcService } from '../rpc.service';
|
|||||||
interface Draft {
|
interface Draft {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
folderId: string | null;
|
stackId: string | null;
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -18,13 +18,14 @@ interface Draft {
|
|||||||
privateKeyPath: string;
|
privateKeyPath: string;
|
||||||
passphrase: string;
|
passphrase: string;
|
||||||
initialPath: string;
|
initialPath: string;
|
||||||
|
localDir: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toDraft(c: SavedConnection): Draft {
|
function toDraft(c: SavedConnection): Draft {
|
||||||
return {
|
return {
|
||||||
id: c.id,
|
id: c.id,
|
||||||
name: c.name,
|
name: c.name,
|
||||||
folderId: c.folderId,
|
stackId: c.stackId,
|
||||||
host: c.host,
|
host: c.host,
|
||||||
port: c.port,
|
port: c.port,
|
||||||
username: c.username,
|
username: c.username,
|
||||||
@@ -34,6 +35,7 @@ function toDraft(c: SavedConnection): Draft {
|
|||||||
privateKeyPath: c.auth.kind === 'key' ? c.auth.privateKeyPath : '',
|
privateKeyPath: c.auth.kind === 'key' ? c.auth.privateKeyPath : '',
|
||||||
passphrase: c.auth.kind === 'key' ? (c.auth.passphrase ?? '') : '',
|
passphrase: c.auth.kind === 'key' ? (c.auth.passphrase ?? '') : '',
|
||||||
initialPath: c.initialPath ?? '',
|
initialPath: c.initialPath ?? '',
|
||||||
|
localDir: c.localDir ?? '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +43,7 @@ function fromDraft(d: Draft): SavedConnection {
|
|||||||
return {
|
return {
|
||||||
id: d.id,
|
id: d.id,
|
||||||
name: d.name.trim() || `${d.username}@${d.host}`,
|
name: d.name.trim() || `${d.username}@${d.host}`,
|
||||||
folderId: d.folderId,
|
stackId: d.stackId,
|
||||||
host: d.host.trim(),
|
host: d.host.trim(),
|
||||||
port: d.port || 22,
|
port: d.port || 22,
|
||||||
username: d.username.trim(),
|
username: d.username.trim(),
|
||||||
@@ -54,6 +56,7 @@ function fromDraft(d: Draft): SavedConnection {
|
|||||||
...(d.passphrase ? { passphrase: d.passphrase } : {}),
|
...(d.passphrase ? { passphrase: d.passphrase } : {}),
|
||||||
},
|
},
|
||||||
initialPath: d.initialPath.trim() || null,
|
initialPath: d.initialPath.trim() || null,
|
||||||
|
localDir: d.localDir.trim() || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +73,14 @@ export class ConnectionManager {
|
|||||||
|
|
||||||
readonly draft = signal<Draft | null>(null);
|
readonly draft = signal<Draft | null>(null);
|
||||||
readonly busy = signal(false);
|
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(() => {
|
readonly valid = computed(() => {
|
||||||
const d = this.draft();
|
const d = this.draft();
|
||||||
if (!d) return false;
|
if (!d) return false;
|
||||||
@@ -83,10 +94,11 @@ export class ConnectionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
newConnection(): void {
|
newConnection(): void {
|
||||||
|
this.moreOpen.set(false);
|
||||||
this.draft.set({
|
this.draft.set({
|
||||||
id: '',
|
id: '',
|
||||||
name: '',
|
name: '',
|
||||||
folderId: null,
|
stackId: null,
|
||||||
host: '',
|
host: '',
|
||||||
port: 22,
|
port: 22,
|
||||||
username: 'root',
|
username: 'root',
|
||||||
@@ -95,11 +107,13 @@ export class ConnectionManager {
|
|||||||
privateKeyPath: '',
|
privateKeyPath: '',
|
||||||
passphrase: '',
|
passphrase: '',
|
||||||
initialPath: '',
|
initialPath: '',
|
||||||
|
localDir: '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
edit(connection: SavedConnection): void {
|
edit(connection: SavedConnection): void {
|
||||||
this.draft.set(toDraft(connection));
|
this.draft.set(toDraft(connection));
|
||||||
|
this.moreOpen.set(!!connection.localDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
@@ -127,6 +141,7 @@ export class ConnectionManager {
|
|||||||
this.panels.activeSide(),
|
this.panels.activeSide(),
|
||||||
connection,
|
connection,
|
||||||
connection.name,
|
connection.name,
|
||||||
|
connection.localDir,
|
||||||
);
|
);
|
||||||
if (ok) this.close();
|
if (ok) this.close();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -157,27 +172,80 @@ export class ConnectionManager {
|
|||||||
if (res.ok && res.path) this.patch('privateKeyPath', res.path);
|
if (res.ok && res.path) this.patch('privateKeyPath', res.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
openFolder(connections: SavedConnection[]): void {
|
async importWinScp(): Promise<void> {
|
||||||
this.panels.openInNewTabs(connections);
|
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();
|
this.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
newFolder(): void {
|
toggleStack(stack: Stack): void {
|
||||||
|
void this.store.upsertStack({ ...stack, collapsed: !stack.collapsed });
|
||||||
|
}
|
||||||
|
|
||||||
|
newStack(): void {
|
||||||
this.dialogs.prompt.set({
|
this.dialogs.prompt.set({
|
||||||
title: 'New folder',
|
title: 'New stack',
|
||||||
placeholder: 'folder name',
|
placeholder: 'stack name',
|
||||||
confirmLabel: 'Create',
|
confirmLabel: 'Create',
|
||||||
onConfirm: (name) => void this.store.upsertFolder({ id: '', name }),
|
onConfirm: (name) => void this.store.upsertStack({ id: '', name }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteFolder(id: string, name: string): void {
|
renameStack(stack: Stack): void {
|
||||||
this.dialogs.confirm.set({
|
this.dialogs.prompt.set({
|
||||||
title: 'Delete folder',
|
title: 'Rename stack',
|
||||||
body: `Delete folder "${name}"? Its connections move to the root.`,
|
initial: stack.name,
|
||||||
confirmLabel: 'Delete',
|
confirmLabel: 'Rename',
|
||||||
destructive: true,
|
onConfirm: (name) => void this.store.upsertStack({ ...stack, name }),
|
||||||
onConfirm: () => void this.store.removeFolder(id),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
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 { RpcService } from './rpc.service';
|
||||||
import { ToastService } from './toast.service';
|
import { ToastService } from './toast.service';
|
||||||
|
|
||||||
@@ -8,20 +8,20 @@ export class ConnectionsService {
|
|||||||
private readonly rpc = inject(RpcService);
|
private readonly rpc = inject(RpcService);
|
||||||
private readonly toast = inject(ToastService);
|
private readonly toast = inject(ToastService);
|
||||||
|
|
||||||
readonly folders = signal<ConnFolder[]>([]);
|
readonly stacks = signal<Stack[]>([]);
|
||||||
readonly connections = signal<SavedConnection[]>([]);
|
readonly connections = signal<SavedConnection[]>([]);
|
||||||
/** Read-only hosts from ~/.ssh/config; empty when the file is absent or unreadable. */
|
/** Read-only hosts from ~/.ssh/config; empty when the file is absent or unreadable. */
|
||||||
readonly sshHosts = signal<SshConfigHost[]>([]);
|
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(() => {
|
readonly grouped = computed(() => {
|
||||||
const connections = this.connections();
|
const connections = this.connections();
|
||||||
return {
|
return {
|
||||||
root: connections.filter((c) => !c.folderId),
|
stacks: this.stacks().map((stack) => ({
|
||||||
folders: this.folders().map((folder) => ({
|
stack,
|
||||||
folder,
|
connections: connections.filter((c) => c.stackId === stack.id),
|
||||||
connections: connections.filter((c) => c.folderId === folder.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> {
|
async reload(): Promise<void> {
|
||||||
const res = await this.rpc.request.getConnections({});
|
const res = await this.rpc.request.getConnections({});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
this.toast.error(res.error);
|
this.toast.error(res.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.folders.set(res.store.folders);
|
this.stacks.set(res.store.stacks);
|
||||||
this.connections.set(res.store.connections);
|
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> {
|
async upsert(connection: SavedConnection): Promise<string | null> {
|
||||||
const res = await this.rpc.request.upsertConnection({ connection });
|
const res = await this.rpc.request.upsertConnection({ connection });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -64,15 +64,33 @@ export class ConnectionsService {
|
|||||||
await this.reload();
|
await this.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertFolder(folder: ConnFolder): Promise<void> {
|
async upsertStack(stack: Stack): Promise<void> {
|
||||||
const res = await this.rpc.request.upsertFolder({ folder });
|
const res = await this.rpc.request.upsertStack({ stack });
|
||||||
if (!res.ok) this.toast.error(res.error);
|
if (!res.ok) this.toast.error(res.error);
|
||||||
await this.reload();
|
await this.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeFolder(id: string): Promise<void> {
|
async removeStack(id: string): Promise<void> {
|
||||||
const res = await this.rpc.request.deleteFolder({ id });
|
const res = await this.rpc.request.deleteStack({ id });
|
||||||
if (!res.ok) this.toast.error(res.error);
|
if (!res.ok) this.toast.error(res.error);
|
||||||
await this.reload();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,10 +95,10 @@
|
|||||||
}
|
}
|
||||||
</button>
|
</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>
|
<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
|
<button
|
||||||
(click)="pickConnection(connection)"
|
(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"
|
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>
|
<span class="flex-1 truncate">{{ connection.name }}</span>
|
||||||
</button>
|
</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">
|
<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>
|
</div>
|
||||||
@for (connection of group.connections; track connection.id) {
|
@for (connection of group.connections; track connection.id) {
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export class FilePanel {
|
|||||||
pickConnection(connection: SavedConnection): void {
|
pickConnection(connection: SavedConnection): void {
|
||||||
this.menuOpen.set(false);
|
this.menuOpen.set(false);
|
||||||
this.activate();
|
this.activate();
|
||||||
void this.panels.connectPanel(this.side(), connection, connection.name);
|
void this.panels.connectPanel(this.side(), connection, connection.name, connection.localDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleMenu(): void {
|
toggleMenu(): void {
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export class PanelState {
|
|||||||
readonly slowLoad = signal(false);
|
readonly slowLoad = signal(false);
|
||||||
/** Bumping this discards the result of any in-flight connect (cancel/supersede). */
|
/** Bumping this discards the result of any in-flight connect (cancel/supersede). */
|
||||||
connectToken = 0;
|
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. */
|
/** Input on this panel is ignored while it's connecting or loading. */
|
||||||
readonly busy = computed(() => this.connecting() || this.loading());
|
readonly busy = computed(() => this.connecting() || this.loading());
|
||||||
@@ -73,6 +75,7 @@ export class PanelState {
|
|||||||
constructor(private deps: PanelDeps) {}
|
constructor(private deps: PanelDeps) {}
|
||||||
|
|
||||||
async load(path: string | null): Promise<void> {
|
async load(path: string | null): Promise<void> {
|
||||||
|
if (path !== null) this.touched = true;
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
const slowTimer = setTimeout(() => this.slowLoad.set(true), 300);
|
const slowTimer = setTimeout(() => this.slowLoad.set(true), 300);
|
||||||
try {
|
try {
|
||||||
@@ -311,16 +314,29 @@ export class PanelsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One new tab per connection, its left panel connecting (right stays local). */
|
/**
|
||||||
openInNewTabs(connections: SavedConnection[]): void {
|
* Open a stack: one tab per connection, left panel connecting (right stays
|
||||||
for (const connection of connections) {
|
* local, jumping to the connection's localDir when set). The first
|
||||||
this.newWorkspace();
|
* 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
|
// connectPanel resolves the panel synchronously (before awaiting), so
|
||||||
// firing these back-to-back binds each connection to its own tab
|
// 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. */
|
/** Reorder a tab (drag & drop). Order is purely visual; ids stay stable. */
|
||||||
moveWorkspace(id: number, toIndex: number): void {
|
moveWorkspace(id: number, toIndex: number): void {
|
||||||
this.workspaces.update((list) => {
|
this.workspaces.update((list) => {
|
||||||
@@ -361,9 +377,19 @@ export class PanelsService {
|
|||||||
|
|
||||||
// ---- session binding ----
|
// ---- 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> {
|
* Connect a panel to a server; replaces (and disconnects) its previous remote
|
||||||
const panel = this.get(side);
|
* 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;
|
const token = ++panel.connectToken;
|
||||||
panel.connecting.set(true);
|
panel.connecting.set(true);
|
||||||
panel.connectingTo.set(title);
|
panel.connectingTo.set(title);
|
||||||
@@ -385,6 +411,11 @@ export class PanelsService {
|
|||||||
panel.sessionId.set(res.sessionId);
|
panel.sessionId.set(res.sessionId);
|
||||||
panel.fsKey.set(res.fsKey);
|
panel.fsKey.set(res.fsKey);
|
||||||
panel.title.set(title);
|
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);
|
await panel.load(config.initialPath ?? res.homeDir);
|
||||||
return true;
|
return true;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user