The dark palette was shadcn's cool zinc (#202023, hue ~286). Anchor it on #1a1a19 instead — cds gray-830, a warm near-neutral (hue 107) — and retone the whole dark neutral family to match, since a warm background under cool panels clashes. Lightness ramp is preserved; chromatic tokens (destructive, success, icon hues, accent formulas) and the light palette are untouched. Also introduce --chrome so the app isn't one flat tone: frame strips (tab bar, status bar, transfer bar, editor/manager header+footer, connection sidebar) now sit one step below the content panes, with popovers and hover above them — the standard dark-UI elevation order. Dark ramp: #151514 chrome, #1a1a19 content, #20201f card, #2e2e2d hover; the "darker" tone drops chrome to true black. Light gains only a zinc-50 chrome step. Co-Authored-By: Claude <noreply@anthropic.com>
385 lines
18 KiB
HTML
385 lines
18 KiB
HTML
<div class="flex h-screen flex-col bg-background text-foreground">
|
|
<!-- header: on macOS this row IS the titlebar (hiddenInset + drag region),
|
|
elsewhere the native titlebar sits above and this is a compact strip -->
|
|
<header
|
|
class="flex shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2"
|
|
[class.h-10]="isMacChrome"
|
|
[class.pl-24]="isMacChrome"
|
|
[class.electrobun-webkit-app-region-drag]="isMacChrome"
|
|
[class.h-9]="!isMacChrome"
|
|
[class.pl-3]="!isMacChrome"
|
|
>
|
|
<ng-icon name="mynaServers" size="0.95rem" class="shrink-0 text-muted-foreground" />
|
|
<h1 class="text-sm font-semibold">{{ i18n.t('connMgr.title') }}</h1>
|
|
<div class="flex-1"></div>
|
|
<button
|
|
(click)="importWinScp()"
|
|
[disabled]="importing()"
|
|
[title]="i18n.t('connMgr.importWinscpHint')"
|
|
class="flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] text-muted-foreground electrobun-webkit-app-region-no-drag 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" /> -->
|
|
}
|
|
{{ i18n.t('connMgr.importWinscp') }}
|
|
</button>
|
|
</header>
|
|
|
|
<div class="flex min-h-0 flex-1">
|
|
<!-- stacks + connections list -->
|
|
<div class="flex w-72 shrink-0 flex-col border-r border-border bg-chrome">
|
|
<div class="flex items-center gap-1 border-b border-border px-2 py-1.5">
|
|
<button
|
|
(click)="newConnection()"
|
|
[title]="i18n.t('connMgr.newConnectionHint')"
|
|
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" /> {{ i18n.t('connMgr.newConnection') }}
|
|
</button>
|
|
<button
|
|
(click)="newStack()"
|
|
[title]="i18n.t('connMgr.newStackHint')"
|
|
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" /> {{ i18n.t('connMgr.newStack') }}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="flex-1 overflow-y-auto py-1">
|
|
<!-- 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"
|
|
>
|
|
<div class="group flex items-center gap-1 px-2 pt-2 pb-0.5">
|
|
<button
|
|
(click)="toggleStack(group.stack)"
|
|
class="grid size-4 shrink-0 place-items-center rounded text-muted-foreground hover:text-foreground"
|
|
[title]="group.stack.collapsed ? i18n.t('connMgr.expand') : i18n.t('connMgr.collapse')"
|
|
>
|
|
<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>
|
|
<div class="min-w-0 flex-1"></div>
|
|
<!-- count and actions swap on hover: they never share the space,
|
|
so a long stack name can't collide with the buttons -->
|
|
<span
|
|
class="shrink-0 text-[10px] text-muted-foreground/70 tabular-nums group-hover:hidden"
|
|
>
|
|
{{ group.connections.length }}
|
|
</span>
|
|
<div class="hidden shrink-0 items-center gap-0.5 group-hover:flex">
|
|
<button
|
|
(click)="renameStack(group.stack)"
|
|
[title]="i18n.t('connMgr.renameStack')"
|
|
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-foreground"
|
|
>
|
|
<ng-icon name="mynaEdit" size="0.75rem" />
|
|
</button>
|
|
<button
|
|
(click)="deleteStack(group.stack)"
|
|
[title]="i18n.t('connMgr.deleteStack')"
|
|
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-destructive"
|
|
>
|
|
<ng-icon name="mynaTrash" size="0.75rem" />
|
|
</button>
|
|
@if (group.connections.length) {
|
|
<button
|
|
(click)="openStack(group.stack.id)"
|
|
[title]="i18n.t('connMgr.openStack')"
|
|
class="grid size-4 place-items-center rounded text-muted-foreground hover:text-primary"
|
|
>
|
|
<ng-icon name="mynaArrowRight" size="0.8rem" />
|
|
</button>
|
|
}
|
|
</div>
|
|
</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">{{ i18n.t('connMgr.empty') }}</div>
|
|
}
|
|
}
|
|
</div>
|
|
}
|
|
|
|
<!-- 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">
|
|
{{ i18n.t('connMgr.connectionsHeader') }}
|
|
</div>
|
|
}
|
|
@for (connection of store.grouped().loose; track connection.id) {
|
|
<button
|
|
(click)="edit(connection)"
|
|
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>
|
|
}
|
|
</div>
|
|
|
|
@if (!store.connections().length && !store.stacks().length) {
|
|
<p class="px-3 py-2 text-[11px] text-muted-foreground">
|
|
{{ i18n.t('connMgr.noneTitle') }}
|
|
</p>
|
|
<p class="px-3 py-2 text-[11px] text-muted-foreground">
|
|
{{ i18n.t('connMgr.noneBody') }}
|
|
</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-4 gap-y-3 overflow-y-auto p-5">
|
|
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
|
<span class="text-muted-foreground">{{ i18n.t('connMgr.name') }}</span>
|
|
<input
|
|
[value]="d.name"
|
|
(input)="patch('name', $any($event.target).value)"
|
|
[placeholder]="i18n.t('connMgr.namePlaceholder')"
|
|
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">{{ i18n.t('connMgr.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">{{ i18n.t('connMgr.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">{{ i18n.t('connMgr.stack') }}</span>
|
|
<select
|
|
(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.stackId">{{ i18n.t('connMgr.stackNone') }}</option>
|
|
@for (stack of store.stacks(); track stack.id) {
|
|
<option [value]="stack.id" [selected]="d.stackId === stack.id">
|
|
{{ stack.name }}
|
|
</option>
|
|
}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<label class="flex flex-col gap-1 text-xs">
|
|
<span class="text-muted-foreground">{{ i18n.t('connMgr.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">{{ i18n.t('connMgr.remotePath') }}</span>
|
|
<input
|
|
[value]="d.initialPath"
|
|
(input)="patch('initialPath', $any($event.target).value)"
|
|
[placeholder]="i18n.t('connMgr.remotePathPlaceholder')"
|
|
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">{{ i18n.t('connMgr.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"
|
|
/>
|
|
{{ i18n.t('connMgr.authPassword') }}
|
|
</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"
|
|
/>
|
|
{{ i18n.t('connMgr.authKey') }}
|
|
</label>
|
|
</div>
|
|
|
|
@if (d.authKind === 'password') {
|
|
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
|
<span class="text-muted-foreground">{{ i18n.t('connMgr.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">{{ i18n.t('connMgr.privateKeyFile') }}</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"
|
|
>
|
|
{{ i18n.t('common.choose') }}
|
|
</button>
|
|
</div>
|
|
</label>
|
|
<label class="flex flex-col gap-1 text-xs">
|
|
<span class="text-muted-foreground">{{ i18n.t('connMgr.passphrase') }}</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>
|
|
}
|
|
|
|
@if (moreOpen()) {
|
|
<label class="col-span-2 flex flex-col gap-1 text-xs">
|
|
<span class="text-muted-foreground">{{ i18n.t('connMgr.localDir') }}</span>
|
|
<input
|
|
[value]="d.localDir"
|
|
(input)="patch('localDir', $any($event.target).value)"
|
|
[placeholder]="i18n.t('connMgr.localDirPlaceholder')"
|
|
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">
|
|
{{ i18n.t('connMgr.localDirHint') }}
|
|
</span>
|
|
</label>
|
|
}
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2 border-t border-border bg-chrome 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" /> {{ i18n.t('common.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() ? i18n.t('connMgr.less') : i18n.t('connMgr.more') }}
|
|
</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"
|
|
>
|
|
{{ i18n.t('common.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" />
|
|
}
|
|
{{ i18n.t('common.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">
|
|
{{ i18n.t('connMgr.selectPrompt') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- in-window dialogs (safe here: they depend only on FocusService) -->
|
|
@if (dialogs.prompt(); as req) {
|
|
<app-prompt-dialog [request]="req" />
|
|
}
|
|
@if (dialogs.confirm(); as req) {
|
|
<app-confirm-dialog [request]="req" />
|
|
}
|
|
|
|
<app-toasts />
|
|
</div>
|