Stage 4: restore the modern tree into app/skins/shadlike/
Both UIs now live side by side. The 18 modern component files come straight
from `git show main:<path>` — recovered, not rewritten, which is possible
because main's tip IS the merge base of this branch. There is no title-bar
among them: that component is win7-only, since the modern skin defers to the
native window frame.
Their imports were rewritten to @core the same way stage 1 did, leaving only
skin-internal relative imports behind. Added skins/shadlike/index.ts exporting
SHAD_ROOTS with the same shape as SEVEN_ROOTS, so main.ts can select a tree
without knowing either skin's internals.
Restored the skin axis in core/platform.ts, deleted in fe028a9 and needed
again by the modern tree: __APP_SKIN on the Window global, APP_SKIN,
IS_WIN7_CHROME, and IS_MAC_CHROME = IS_MAC && !IS_WIN7_CHROME. That last one
is what reserves pl-24 for the macOS traffic lights in the fused hiddenInset
header — and correctly stops reserving it under win7, where titleBarStyle
'hidden' removes the traffic lights entirely.
The tree is not reachable yet; the build is the gate. I checked that the gate
is real rather than assuming it: injecting a type error into an unreferenced
shadlike file does fail `bun run build:ui`, so ngtsc type-checks the whole
program including components nothing imports. Without that check, "the build
passes" would have proven nothing about 18 files no one references.
win7 remains pixel-identical to the pre-refactor baseline.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user