List ~/.ssh/config hosts in the panel source dropdown
Read-only group below saved connections; hosts resolve HostName/User/ Port/IdentityFile (Include, wildcards, %-tokens) and connect on click. New ssh-agent auth kind covers hosts without a usable identity file; ProxyJump/ProxyCommand hosts are shown disabled. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
"name": "bunscp",
|
"name": "bunscp",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electrobun": "1.18.1",
|
"electrobun": "1.18.1",
|
||||||
|
"ssh-config": "^5.2.0",
|
||||||
"ssh2": "^1.17.0",
|
"ssh2": "^1.17.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1019,6 +1020,8 @@
|
|||||||
|
|
||||||
"spdx-license-ids": ["spdx-license-ids@3.0.23", "", {}, "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw=="],
|
"spdx-license-ids": ["spdx-license-ids@3.0.23", "", {}, "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw=="],
|
||||||
|
|
||||||
|
"ssh-config": ["ssh-config@5.2.0", "", {}, "sha512-rvPlm2RlagYjTGP+maYV4nN513imsk0z/sRMMaPwNZqeLIbS9sM/uRCJlOHc8so+xJtdjQEnYUPCDP+92G+6Gg=="],
|
||||||
|
|
||||||
"ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="],
|
"ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="],
|
||||||
|
|
||||||
"ssri": ["ssri@13.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ=="],
|
"ssri": ["ssri@13.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ=="],
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electrobun": "1.18.1",
|
"electrobun": "1.18.1",
|
||||||
|
"ssh-config": "^5.2.0",
|
||||||
"ssh2": "^1.17.0"
|
"ssh2": "^1.17.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+22
-9
@@ -1,9 +1,9 @@
|
|||||||
import dns from "node:dns/promises";
|
import dns from "node:dns/promises";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { Client, type ConnectConfig, type SFTPWrapper } from "ssh2";
|
import { Client, type ConnectConfig, type SFTPWrapper } from "ssh2";
|
||||||
import type { ConnectionConfig, EntryType, FileEntry } from "../../shared/types";
|
import type { ConnectionConfig, EntryType, FileEntry } from "../../shared/types";
|
||||||
|
import { expandTilde } from "../store/ssh-config";
|
||||||
import type { FileSystemProvider } from "./provider";
|
import type { FileSystemProvider } from "./provider";
|
||||||
|
|
||||||
const S_IFMT = 0o170000;
|
const S_IFMT = 0o170000;
|
||||||
@@ -17,12 +17,6 @@ function typeFromMode(mode: number): EntryType {
|
|||||||
return "file";
|
return "file";
|
||||||
}
|
}
|
||||||
|
|
||||||
function expandTilde(p: string): string {
|
|
||||||
if (p === "~") return os.homedir();
|
|
||||||
if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SftpProvider implements FileSystemProvider {
|
export class SftpProvider implements FileSystemProvider {
|
||||||
readonly kind = "sftp" as const;
|
readonly kind = "sftp" as const;
|
||||||
readonly fsKey: string;
|
readonly fsKey: string;
|
||||||
@@ -49,11 +43,30 @@ export class SftpProvider implements FileSystemProvider {
|
|||||||
keepaliveInterval: 15_000,
|
keepaliveInterval: 15_000,
|
||||||
keepaliveCountMax: 3,
|
keepaliveCountMax: 3,
|
||||||
};
|
};
|
||||||
if (config.auth.kind === "password") {
|
const agentSock =
|
||||||
|
process.env.SSH_AUTH_SOCK ??
|
||||||
|
(process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : undefined);
|
||||||
|
switch (config.auth.kind) {
|
||||||
|
case "password":
|
||||||
connectConfig.password = config.auth.password;
|
connectConfig.password = config.auth.password;
|
||||||
} else {
|
break;
|
||||||
|
case "agent":
|
||||||
|
if (!agentSock) throw new Error("ssh-agent not available (SSH_AUTH_SOCK is not set)");
|
||||||
|
connectConfig.agent = agentSock;
|
||||||
|
break;
|
||||||
|
case "key": {
|
||||||
|
// Alongside a passphrase-less key file, offer the agent too — it
|
||||||
|
// covers encrypted keys already loaded via ssh-add/UseKeychain.
|
||||||
|
try {
|
||||||
connectConfig.privateKey = readFileSync(expandTilde(config.auth.privateKeyPath));
|
connectConfig.privateKey = readFileSync(expandTilde(config.auth.privateKeyPath));
|
||||||
|
} catch {
|
||||||
|
if (!agentSock)
|
||||||
|
throw new Error(`identity file not found: ${config.auth.privateKeyPath}`);
|
||||||
|
}
|
||||||
if (config.auth.passphrase) connectConfig.passphrase = config.auth.passphrase;
|
if (config.auth.passphrase) connectConfig.passphrase = config.auth.passphrase;
|
||||||
|
else if (agentSock) connectConfig.agent = agentSock;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filesystem identity: resolve the host so a hostname and its IP compare
|
// Filesystem identity: resolve the host so a hostname and its IP compare
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { SessionRegistry } from "./fs/registry";
|
|||||||
import { SftpProvider } from "./fs/sftp";
|
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 type { TransferEngine } from "./transfers/engine";
|
import type { TransferEngine } from "./transfers/engine";
|
||||||
import { removeRecursive } from "./transfers/walk";
|
import { removeRecursive } from "./transfers/walk";
|
||||||
|
|
||||||
@@ -165,6 +166,8 @@ export function createAppRPC({
|
|||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getSshConfigHosts: () => wrap(async () => ({ hosts: loadSshConfigHosts() })),
|
||||||
|
|
||||||
// ---- settings ----
|
// ---- settings ----
|
||||||
getSettings: () => wrap(async () => ({ settings: await settings.load() })),
|
getSettings: () => wrap(async () => ({ settings: await settings.load() })),
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import SSHConfig from "ssh-config";
|
||||||
|
import type { SshConfigHost } from "../../shared/types";
|
||||||
|
|
||||||
|
export function expandTilde(p: string): string {
|
||||||
|
if (p === "~") return os.homedir();
|
||||||
|
if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SSH_DIR = path.join(os.homedir(), ".ssh");
|
||||||
|
const DEFAULT_IDENTITIES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splice `Include` directives into the config text before parsing — the
|
||||||
|
* ssh-config package leaves them as plain directives. Relative paths resolve
|
||||||
|
* against ~/.ssh, globs are supported, unreadable files are skipped.
|
||||||
|
*/
|
||||||
|
function expandIncludes(text: string, depth = 0): string {
|
||||||
|
if (depth > 3) return text;
|
||||||
|
return text.replace(/^[ \t]*Include[ \t=]+(.+)$/gim, (line, args: string) => {
|
||||||
|
const parts = args.trim().split(/\s+/);
|
||||||
|
const contents: string[] = [];
|
||||||
|
for (const raw of parts) {
|
||||||
|
const pattern = expandTilde(raw.replace(/^"|"$/g, ""));
|
||||||
|
const abs = path.isAbsolute(pattern) ? pattern : path.join(SSH_DIR, pattern);
|
||||||
|
try {
|
||||||
|
const files = pattern.includes("*")
|
||||||
|
? [...new Bun.Glob(path.basename(abs)).scanSync({ cwd: path.dirname(abs), absolute: true })]
|
||||||
|
: [abs];
|
||||||
|
for (const file of files.sort()) {
|
||||||
|
contents.push(expandIncludes(readFileSync(file, "utf8"), depth + 1));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// unreadable include — same as ssh with a missing optional include
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return contents.length ? contents.join("\n") : `# ${line}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ssh %-tokens we support in HostName/IdentityFile; others are left literal. */
|
||||||
|
function expandTokens(value: string, ctx: { host: string; port: number; user: string }): string {
|
||||||
|
return value.replace(/%[%hpdru]/g, (token) => {
|
||||||
|
switch (token) {
|
||||||
|
case "%%":
|
||||||
|
return "%";
|
||||||
|
case "%h":
|
||||||
|
return ctx.host;
|
||||||
|
case "%p":
|
||||||
|
return String(ctx.port);
|
||||||
|
case "%r":
|
||||||
|
return ctx.user;
|
||||||
|
case "%u":
|
||||||
|
return os.userInfo().username;
|
||||||
|
case "%d":
|
||||||
|
return os.homedir();
|
||||||
|
default:
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function first(value: string | string[] | undefined): string | undefined {
|
||||||
|
return Array.isArray(value) ? value[0] : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Concrete host aliases from ~/.ssh/config, resolved with ssh's
|
||||||
|
* first-match-wins semantics (wildcard blocks contribute settings but are not
|
||||||
|
* listed themselves). Missing or malformed config yields an empty list —
|
||||||
|
* the feature is silently absent, never an error. Match blocks are parsed
|
||||||
|
* but their criteria are not evaluated (matchExec is disabled).
|
||||||
|
*/
|
||||||
|
export function loadSshConfigHosts(): SshConfigHost[] {
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = readFileSync(path.join(SSH_DIR, "config"), "utf8");
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const config = SSHConfig.parse(expandIncludes(text));
|
||||||
|
const aliases: string[] = [];
|
||||||
|
for (const line of config) {
|
||||||
|
if (line.type !== SSHConfig.DIRECTIVE || line.param.toLowerCase() !== "host") continue;
|
||||||
|
const values = Array.isArray(line.value) ? line.value.map((v) => v.val) : [line.value];
|
||||||
|
for (const alias of values) {
|
||||||
|
if (!/[*?!]/.test(alias) && !aliases.includes(alias)) aliases.push(alias);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return aliases.map((alias) => resolveHost(config, alias)).filter((h) => h !== null);
|
||||||
|
} catch (err) {
|
||||||
|
console.log("[ssh-config] skipped:", err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveHost(config: SSHConfig, alias: string): SshConfigHost | null {
|
||||||
|
try {
|
||||||
|
const computed = config.compute(alias, { ignoreCase: true, matchExec: false });
|
||||||
|
const port = Number(first(computed.port)) || 22;
|
||||||
|
const username = first(computed.user) ?? os.userInfo().username;
|
||||||
|
const hostname = expandTokens(first(computed.hostname) ?? alias, {
|
||||||
|
host: alias,
|
||||||
|
port,
|
||||||
|
user: username,
|
||||||
|
});
|
||||||
|
const ctx = { host: hostname, port, user: username };
|
||||||
|
|
||||||
|
const declared = computed.identityfile;
|
||||||
|
const candidates = (Array.isArray(declared) ? declared : declared ? [declared] : [])
|
||||||
|
.map((p) => expandTilde(expandTokens(p, ctx)))
|
||||||
|
.concat(DEFAULT_IDENTITIES.map((name) => path.join(SSH_DIR, name)));
|
||||||
|
const identityFile = candidates.find((p) => existsSync(p));
|
||||||
|
|
||||||
|
const host: SshConfigHost = { name: alias, hostname, port, username };
|
||||||
|
if (identityFile) host.identityFile = identityFile;
|
||||||
|
if (computed.proxyjump || computed.proxycommand) host.unsupported = "proxy";
|
||||||
|
return host;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`[ssh-config] skipped host ${alias}:`, err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
FileEntry,
|
FileEntry,
|
||||||
Result,
|
Result,
|
||||||
SavedConnection,
|
SavedConnection,
|
||||||
|
SshConfigHost,
|
||||||
TransferSnapshot,
|
TransferSnapshot,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
@@ -68,6 +69,10 @@ export type AppRPC = {
|
|||||||
deleteConnection: { params: { id: string }; response: Result };
|
deleteConnection: { params: { id: string }; response: Result };
|
||||||
upsertFolder: { params: { folder: ConnFolder }; response: Result<{ id: string }> };
|
upsertFolder: { params: { folder: ConnFolder }; response: Result<{ id: string }> };
|
||||||
deleteFolder: { params: { id: string }; response: Result };
|
deleteFolder: { params: { id: string }; response: Result };
|
||||||
|
getSshConfigHosts: {
|
||||||
|
params: Record<string, never>;
|
||||||
|
response: Result<{ hosts: SshConfigHost[] }>;
|
||||||
|
};
|
||||||
// settings
|
// settings
|
||||||
getSettings: {
|
getSettings: {
|
||||||
params: Record<string, never>;
|
params: Record<string, never>;
|
||||||
|
|||||||
+12
-1
@@ -14,7 +14,8 @@ export type Result<T = object> = ({ ok: true } & T) | { ok: false; error: string
|
|||||||
|
|
||||||
export type AuthConfig =
|
export type AuthConfig =
|
||||||
| { kind: "password"; password: string }
|
| { kind: "password"; password: string }
|
||||||
| { kind: "key"; privateKeyPath: string; passphrase?: string };
|
| { kind: "key"; privateKeyPath: string; passphrase?: string }
|
||||||
|
| { kind: "agent" };
|
||||||
|
|
||||||
export interface ConnectionConfig {
|
export interface ConnectionConfig {
|
||||||
host: string;
|
host: string;
|
||||||
@@ -35,6 +36,16 @@ export interface ConnFolder {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A concrete host alias resolved from ~/.ssh/config (read-only). */
|
||||||
|
export interface SshConfigHost {
|
||||||
|
name: string; // the Host alias as written
|
||||||
|
hostname: string; // resolved HostName (defaults to alias)
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
identityFile?: string; // first existing IdentityFile, absolute path
|
||||||
|
unsupported?: "proxy"; // ProxyJump/ProxyCommand present — direct connect can't work
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConnectionsFile {
|
export interface ConnectionsFile {
|
||||||
version: 1;
|
version: 1;
|
||||||
folders: ConnFolder[];
|
folders: ConnFolder[];
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ function toDraft(c: SavedConnection): Draft {
|
|||||||
host: c.host,
|
host: c.host,
|
||||||
port: c.port,
|
port: c.port,
|
||||||
username: c.username,
|
username: c.username,
|
||||||
authKind: c.auth.kind,
|
// agent auth only exists on ~/.ssh/config hosts, which never reach this form
|
||||||
|
authKind: c.auth.kind === 'password' ? 'password' : 'key',
|
||||||
password: c.auth.kind === 'password' ? c.auth.password : '',
|
password: c.auth.kind === 'password' ? c.auth.password : '',
|
||||||
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 ?? '') : '',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
import type { ConnFolder, SavedConnection } from '@shared/types';
|
import type { ConnFolder, SavedConnection, SshConfigHost } from '@shared/types';
|
||||||
import { RpcService } from './rpc.service';
|
import { RpcService } from './rpc.service';
|
||||||
import { ToastService } from './toast.service';
|
import { ToastService } from './toast.service';
|
||||||
|
|
||||||
@@ -10,6 +10,8 @@ export class ConnectionsService {
|
|||||||
|
|
||||||
readonly folders = signal<ConnFolder[]>([]);
|
readonly folders = signal<ConnFolder[]>([]);
|
||||||
readonly connections = signal<SavedConnection[]>([]);
|
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). */
|
/** Root-level connections plus one group per folder (folders always shown). */
|
||||||
readonly grouped = computed(() => {
|
readonly grouped = computed(() => {
|
||||||
@@ -24,7 +26,16 @@ export class ConnectionsService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (this.rpc.available) void this.reload();
|
if (this.rpc.available) {
|
||||||
|
void this.reload();
|
||||||
|
void this.reloadSshHosts();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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> {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<!-- session strip -->
|
<!-- session strip -->
|
||||||
<div class="relative flex items-center gap-1.5 border-b border-border px-2 py-1">
|
<div class="relative flex items-center gap-1.5 border-b border-border px-2 py-1">
|
||||||
<button
|
<button
|
||||||
(click)="menuOpen.set(!menuOpen())"
|
(click)="toggleMenu()"
|
||||||
class="flex min-w-0 items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium hover:bg-accent"
|
class="flex min-w-0 items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium hover:bg-accent"
|
||||||
title="Choose what this panel shows"
|
title="Choose what this panel shows"
|
||||||
>
|
>
|
||||||
@@ -128,6 +128,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (connections.sshHosts().length) {
|
||||||
|
<div class="my-1 border-t border-border"></div>
|
||||||
|
<div class="px-3 pt-1.5 pb-0.5 text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
|
||||||
|
~/.ssh/config
|
||||||
|
</div>
|
||||||
|
@for (host of connections.sshHosts(); track host.name) {
|
||||||
|
<button
|
||||||
|
(click)="pickSshHost(host)"
|
||||||
|
[disabled]="host.unsupported"
|
||||||
|
[title]="sshHostTitle(host)"
|
||||||
|
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-5 text-left text-xs hover:bg-accent disabled:opacity-60 disabled:hover:bg-transparent"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaServers" size="0.9rem" class="shrink-0 text-muted-foreground" />
|
||||||
|
<span class="flex-1 truncate">{{ host.name }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
<div class="my-1 border-t border-border"></div>
|
<div class="my-1 border-t border-border"></div>
|
||||||
<button
|
<button
|
||||||
(click)="openManager()"
|
(click)="openManager()"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
viewChild,
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/core';
|
import { NgIcon } from '@ng-icons/core';
|
||||||
import type { FileEntry, SavedConnection } from '@shared/types';
|
import type { FileEntry, SavedConnection, SshConfigHost } from '@shared/types';
|
||||||
import { ConnectionsService } from '../connections.service';
|
import { ConnectionsService } from '../connections.service';
|
||||||
import { DialogsService } from '../dialogs.service';
|
import { DialogsService } from '../dialogs.service';
|
||||||
import { FileActionsService } from '../file-actions.service';
|
import { FileActionsService } from '../file-actions.service';
|
||||||
@@ -84,6 +84,35 @@ export class FilePanel {
|
|||||||
void this.panels.connectPanel(this.side(), connection, connection.name);
|
void this.panels.connectPanel(this.side(), connection, connection.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleMenu(): void {
|
||||||
|
this.menuOpen.set(!this.menuOpen());
|
||||||
|
// re-read ~/.ssh/config on each open so external edits show up
|
||||||
|
if (this.menuOpen()) void this.connections.reloadSshHosts();
|
||||||
|
}
|
||||||
|
|
||||||
|
pickSshHost(host: SshConfigHost): void {
|
||||||
|
this.menuOpen.set(false);
|
||||||
|
this.activate();
|
||||||
|
void this.panels.connectPanel(
|
||||||
|
this.side(),
|
||||||
|
{
|
||||||
|
host: host.hostname,
|
||||||
|
port: host.port,
|
||||||
|
username: host.username,
|
||||||
|
auth: host.identityFile
|
||||||
|
? { kind: 'key', privateKeyPath: host.identityFile }
|
||||||
|
: { kind: 'agent' },
|
||||||
|
initialPath: null,
|
||||||
|
},
|
||||||
|
host.name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
sshHostTitle(host: SshConfigHost): string {
|
||||||
|
if (host.unsupported === 'proxy') return 'Uses ProxyJump/ProxyCommand — not supported';
|
||||||
|
return `${host.username}@${host.hostname}:${host.port}`;
|
||||||
|
}
|
||||||
|
|
||||||
openManager(): void {
|
openManager(): void {
|
||||||
this.menuOpen.set(false);
|
this.menuOpen.set(false);
|
||||||
this.activate();
|
this.activate();
|
||||||
|
|||||||
Reference in New Issue
Block a user