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:
@@ -12,7 +12,7 @@
|
||||
</div>
|
||||
|
||||
<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 items-center gap-1 border-b border-border px-2 py-1.5">
|
||||
<button
|
||||
@@ -23,64 +23,139 @@
|
||||
<ng-icon name="mynaPlus" size="0.85rem" /> Connection
|
||||
</button>
|
||||
<button
|
||||
(click)="newFolder()"
|
||||
title="New folder"
|
||||
(click)="newStack()"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
</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"
|
||||
<!-- stacks -->
|
||||
@for (group of store.grouped().stacks; track group.stack.id) {
|
||||
<div
|
||||
(dragover)="onDragOver($event, group.stack.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" />
|
||||
<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) {
|
||||
<div class="group flex items-center gap-1 px-2 pt-2 pb-0.5">
|
||||
<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"
|
||||
(click)="toggleStack(group.stack)"
|
||||
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground hover:text-foreground"
|
||||
[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>
|
||||
<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>
|
||||
@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
|
||||
(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.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>
|
||||
}
|
||||
}
|
||||
@if (!store.connections().length && !store.folders().length) {
|
||||
</div>
|
||||
|
||||
@if (!store.connections().length && !store.stacks().length) {
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
@@ -122,17 +197,17 @@
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="text-muted-foreground">Folder</span>
|
||||
<span class="text-muted-foreground">Stack</span>
|
||||
<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"
|
||||
>
|
||||
<!-- [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 value="" [selected]="!d.stackId">(none)</option>
|
||||
@for (stack of store.stacks(); track stack.id) {
|
||||
<option [value]="stack.id" [selected]="d.stackId === stack.id">
|
||||
{{ stack.name }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
@@ -223,6 +298,22 @@
|
||||
/>
|
||||
</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 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
|
||||
</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>
|
||||
<button
|
||||
(click)="save()"
|
||||
|
||||
Reference in New Issue
Block a user