Choose... button beside the private-key field opens the native file dialog starting in ~/.ssh (Utils.openFileDialog via a pickKeyFile RPC). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
268 lines
12 KiB
HTML
268 lines
12 KiB
HTML
<div class="fixed inset-0 z-30 grid place-items-center bg-black/40" (mousedown)="close()">
|
|
<div
|
|
class="flex h-[440px] w-[680px] flex-col rounded-lg border border-border bg-card text-card-foreground shadow-xl"
|
|
(mousedown)="$event.stopPropagation()"
|
|
>
|
|
<!-- title bar -->
|
|
<div class="flex items-center justify-between border-b border-border px-4 py-2.5">
|
|
<h2 class="text-sm font-semibold">Connections</h2>
|
|
<button (click)="close()" class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground">
|
|
<ng-icon name="mynaX" size="1rem" />
|
|
</button>
|
|
</div>
|
|
|
|
<div class="flex min-h-0 flex-1">
|
|
<!-- list -->
|
|
<div class="flex w-60 shrink-0 flex-col border-r border-border">
|
|
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
|
<button
|
|
(click)="newConnection()"
|
|
title="New connection"
|
|
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="mynaPlus" size="0.85rem" /> Connection
|
|
</button>
|
|
<button
|
|
(click)="newFolder()"
|
|
title="New folder"
|
|
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
|
|
</button>
|
|
</div>
|
|
|
|
<div class="flex-1 overflow-y-auto py-1">
|
|
@for (connection of store.grouped().root; track connection.id) {
|
|
<button
|
|
(click)="edit(connection)"
|
|
class="flex w-full items-center gap-1.5 px-3 py-1.5 text-left text-xs hover:bg-accent"
|
|
[class.bg-accent]="draft()?.id === connection.id"
|
|
>
|
|
<ng-icon name="mynaServers" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
|
<span class="truncate">{{ connection.name }}</span>
|
|
</button>
|
|
}
|
|
@for (group of store.grouped().folders; track group.folder.id) {
|
|
<div class="group flex items-center gap-1 px-3 pt-2 pb-0.5">
|
|
<ng-icon name="mynaFolder" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
|
<span class="flex-1 truncate text-[11px] font-medium text-muted-foreground uppercase">
|
|
{{ group.folder.name }}
|
|
</span>
|
|
@if (group.connections.length) {
|
|
<button
|
|
(click)="openFolder(group.connections)"
|
|
title="Open all in tabs"
|
|
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-primary"
|
|
>
|
|
<ng-icon name="mynaArrowRight" size="0.75rem" />
|
|
</button>
|
|
}
|
|
<button
|
|
(click)="deleteFolder(group.folder.id, group.folder.name)"
|
|
title="Delete folder"
|
|
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-destructive"
|
|
>
|
|
<ng-icon name="mynaTrash" size="0.75rem" />
|
|
</button>
|
|
</div>
|
|
@for (connection of group.connections; track connection.id) {
|
|
<button
|
|
(click)="edit(connection)"
|
|
class="flex w-full items-center gap-1.5 py-1.5 pr-3 pl-6 text-left text-xs hover:bg-accent"
|
|
[class.bg-accent]="draft()?.id === connection.id"
|
|
>
|
|
<ng-icon name="mynaServers" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
|
<span class="truncate">{{ connection.name }}</span>
|
|
</button>
|
|
} @empty {
|
|
<div class="py-1 pl-6 text-[11px] text-muted-foreground italic">empty</div>
|
|
}
|
|
}
|
|
@if (!store.connections().length && !store.folders().length) {
|
|
<p class="px-3 py-2 text-[11px] text-muted-foreground">
|
|
No saved connections yet. Create one to get started.
|
|
</p>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- edit form -->
|
|
<div class="flex min-w-0 flex-1 flex-col">
|
|
@if (draft(); as d) {
|
|
<div class="grid flex-1 auto-rows-min grid-cols-2 gap-x-3 gap-y-2.5 overflow-y-auto p-4">
|
|
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
|
<span class="text-muted-foreground">Name</span>
|
|
<input
|
|
[value]="d.name"
|
|
(input)="patch('name', $any($event.target).value)"
|
|
placeholder="my server"
|
|
spellcheck="false"
|
|
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
|
|
/>
|
|
</label>
|
|
|
|
<label class="flex flex-col gap-1 text-xs">
|
|
<span class="text-muted-foreground">Host</span>
|
|
<input
|
|
[value]="d.host"
|
|
(input)="patch('host', $any($event.target).value)"
|
|
placeholder="192.168.1.10"
|
|
spellcheck="false"
|
|
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
|
/>
|
|
</label>
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<label class="flex flex-col gap-1 text-xs">
|
|
<span class="text-muted-foreground">Port</span>
|
|
<input
|
|
type="number"
|
|
[value]="d.port"
|
|
(input)="patch('port', +$any($event.target).value)"
|
|
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
|
/>
|
|
</label>
|
|
<label class="flex flex-col gap-1 text-xs">
|
|
<span class="text-muted-foreground">Folder</span>
|
|
<select
|
|
(change)="patch('folderId', $any($event.target).value || null)"
|
|
class="h-7 rounded-md border border-input bg-background px-1 outline-none focus:ring-2 focus:ring-ring"
|
|
>
|
|
<!-- [selected] per option: binding [value] on the <select> runs
|
|
before the options render and silently shows "(none)" -->
|
|
<option value="" [selected]="!d.folderId">(none)</option>
|
|
@for (folder of store.folders(); track folder.id) {
|
|
<option [value]="folder.id" [selected]="d.folderId === folder.id">
|
|
{{ folder.name }}
|
|
</option>
|
|
}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<label class="flex flex-col gap-1 text-xs">
|
|
<span class="text-muted-foreground">Username</span>
|
|
<input
|
|
[value]="d.username"
|
|
(input)="patch('username', $any($event.target).value)"
|
|
spellcheck="false"
|
|
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
|
/>
|
|
</label>
|
|
<label class="flex flex-col gap-1 text-xs">
|
|
<span class="text-muted-foreground">Remote path (optional)</span>
|
|
<input
|
|
[value]="d.initialPath"
|
|
(input)="patch('initialPath', $any($event.target).value)"
|
|
placeholder="home directory"
|
|
spellcheck="false"
|
|
class="h-7 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
|
/>
|
|
</label>
|
|
|
|
<div class="col-span-2 flex items-center gap-4 text-xs">
|
|
<span class="text-muted-foreground">Auth</span>
|
|
<label class="flex items-center gap-1.5">
|
|
<input
|
|
type="radio"
|
|
name="authKind"
|
|
[checked]="d.authKind === 'password'"
|
|
(change)="patch('authKind', 'password')"
|
|
class="accent-primary"
|
|
/>
|
|
Password
|
|
</label>
|
|
<label class="flex items-center gap-1.5">
|
|
<input
|
|
type="radio"
|
|
name="authKind"
|
|
[checked]="d.authKind === 'key'"
|
|
(change)="patch('authKind', 'key')"
|
|
class="accent-primary"
|
|
/>
|
|
Private key
|
|
</label>
|
|
</div>
|
|
|
|
@if (d.authKind === 'password') {
|
|
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
|
<span class="text-muted-foreground">Password</span>
|
|
<input
|
|
type="password"
|
|
[value]="d.password"
|
|
(input)="patch('password', $any($event.target).value)"
|
|
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
|
|
/>
|
|
</label>
|
|
} @else {
|
|
<label class="flex flex-col gap-1 text-xs">
|
|
<span class="text-muted-foreground">Private key file</span>
|
|
<div class="flex items-center gap-1">
|
|
<input
|
|
[value]="d.privateKeyPath"
|
|
(input)="patch('privateKeyPath', $any($event.target).value)"
|
|
placeholder="~/.ssh/id_ed25519"
|
|
spellcheck="false"
|
|
class="h-7 min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 font-mono outline-none focus:ring-2 focus:ring-ring"
|
|
/>
|
|
<button
|
|
type="button"
|
|
(click)="pickKeyFile()"
|
|
class="h-7 shrink-0 rounded-md border border-border px-2 text-[11px] hover:bg-accent"
|
|
>
|
|
Choose…
|
|
</button>
|
|
</div>
|
|
</label>
|
|
<label class="flex flex-col gap-1 text-xs">
|
|
<span class="text-muted-foreground">Passphrase (optional)</span>
|
|
<input
|
|
type="password"
|
|
[value]="d.passphrase"
|
|
(input)="patch('passphrase', $any($event.target).value)"
|
|
class="h-7 rounded-md border border-input bg-transparent px-2 outline-none focus:ring-2 focus:ring-ring"
|
|
/>
|
|
</label>
|
|
}
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2 border-t border-border px-4 py-2.5">
|
|
<button
|
|
(click)="deleteCurrent()"
|
|
class="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-destructive"
|
|
>
|
|
<ng-icon name="mynaTrash" size="0.85rem" /> Delete
|
|
</button>
|
|
<div class="flex-1"></div>
|
|
<button
|
|
(click)="save()"
|
|
[disabled]="!valid()"
|
|
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent disabled:opacity-50"
|
|
>
|
|
Save
|
|
</button>
|
|
<button
|
|
(click)="saveAndConnect()"
|
|
[disabled]="!valid() || busy()"
|
|
class="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
|
>
|
|
@if (busy()) {
|
|
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
|
|
}
|
|
Connect
|
|
</button>
|
|
</div>
|
|
} @else {
|
|
<div class="grid flex-1 place-items-center">
|
|
<div class="text-center">
|
|
<ng-icon name="mynaServers" size="1.75rem" class="text-muted-foreground" />
|
|
<p class="mt-2 text-xs text-muted-foreground">
|
|
Select a connection or create a new one.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|