Groundwork for the continuous interface scale (a root font-size lever): every size the UI derives from must be root-relative, or it freezes while the rest grows. Gate for this commit: at the default 16px root the app renders pixel-identical to before — every conversion is exact (11px = 0.6875rem …). - The 8 panel-chrome tokens in styles.css: px -> rem, and the data-text compact/large tiers are DELETED — a continuous scale replaces discrete tiers. Same for --w7-fs (12px -> 0.75rem) and the win7 --radius (5px -> 0.3125rem, still landing rounded-md exactly on --w7-el-bdr's 3px). --radius-sm/md subtrahends: px -> rem, or corners would sharpen as the UI grew. - All 93 text-[11px]/text-[10px] arbitraries -> rem equivalents, plus the two inline padding:24px empty-state cells in the 7like file panel. - 7like.css gains a scale bridge in skinfix: 7.css is 370 px values with zero rem, and --w7-font reaches only 15 rules' TYPOGRAPHY — under a root lever win7 text would grow inside boxes frozen at 23px/14px/15px. The bridge re-expresses 7.css's size vars and its hardcoded box metrics (buttons via .w7-btn only, inputs, table rows/headers, title-bar controls, menu items, tabs, the 10pt check glyph) in rem at identical scale-1 pixels. 1px hairlines and focus rects stay px deliberately — crisp at any scale, and the tr border exists precisely to stop selection shifting rows. - Two skinfix traps found while writing it, one by reasoning and one by pixels: (a) NO blanket `.win7 button` restatement — skinfix outranks the skin-layer neutralizer that strips 7.css's 75x23 metrics off the ~84 icon buttons, so a blanket rule would re-inflate them all; (b) restating a base rule in skinfix also outranks 7.css's own VARIANTS of it by layer — the title-bar rule silently shrank the close button from 48px to 29px until the harness pixel diff caught it, and the active tab's padding-bottom needed the same treatment. Both variants are restated alongside their base rules. - TS px constants that mirror rem CSS are the latent-bug half of this work: both context menus' flip/clamp math converts through a new @core/metrics remPx() helper reading the LIVE root font-size (the shadlike menu's MENU_W=224 diverged from its w-56 box the moment scale != 1; the 7like menu's [style.width.px] pinned the box at 200px while text grew — it now binds width in rem, so math and box cannot disagree). The editors' cachedCharWidth is now keyed on the computed font string, self-invalidating under any font change, and both || px fallbacks derive from the element's font size instead of assuming a 16px root. Verified: tsc + build clean; modern and win7 live captures and the harness all diff to zero against pre-change baselines in their opaque regions (the harness to exactly zero end to end; modern's only delta is the native GTK titlebar focus state; win7's is the translucent Aero corner over the desktop). 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-[0.6875rem] 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-[0.6875rem] 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-[0.625rem] 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-[0.6875rem] 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-[0.6875rem] 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-[0.6875rem] text-muted-foreground">
|
|
{{ i18n.t('connMgr.noneTitle') }}
|
|
</p>
|
|
<p class="px-3 py-2 text-[0.6875rem] 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-[0.6875rem] 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-[0.625rem] 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>
|