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:
2026-08-16 04:56:20 +02:00
co-authored by Claude
parent b35c30d5ab
commit bb97e5418f
20 changed files with 3626 additions and 0 deletions
@@ -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>
@@ -0,0 +1,271 @@
import { Component, computed, inject, signal } from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service';
import type { SavedConnection, Stack } from '@shared/types';
import { ConnectionsService } from '@core/connections.service';
import { ConfirmDialog } from '../dialogs/confirm-dialog';
import { PromptDialog } from '../dialogs/prompt-dialog';
import { DialogsService } from '@core/dialogs.service';
import { blockNativeContextMenu, IS_MAC_CHROME } from '@core/platform';
import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service';
import { Toasts } from '../toasts/toasts';
interface Draft {
id: string;
name: string;
stackId: string | null;
host: string;
port: number;
username: string;
authKind: 'password' | 'key';
password: string;
privateKeyPath: string;
passphrase: string;
initialPath: string;
localDir: string;
}
function toDraft(c: SavedConnection): Draft {
return {
id: c.id,
name: c.name,
stackId: c.stackId,
host: c.host,
port: c.port,
username: c.username,
// agent auth only exists on ~/.ssh/config hosts, which never reach this form
authKind: c.auth.kind === 'password' ? 'password' : 'key',
password: c.auth.kind === 'password' ? c.auth.password : '',
privateKeyPath: c.auth.kind === 'key' ? c.auth.privateKeyPath : '',
passphrase: c.auth.kind === 'key' ? (c.auth.passphrase ?? '') : '',
initialPath: c.initialPath ?? '',
localDir: c.localDir ?? '',
};
}
function fromDraft(d: Draft): SavedConnection {
return {
id: d.id,
name: d.name.trim() || `${d.username}@${d.host}`,
stackId: d.stackId,
host: d.host.trim(),
port: d.port || 22,
username: d.username.trim(),
auth:
d.authKind === 'password'
? { kind: 'password', password: d.password }
: {
kind: 'key',
privateKeyPath: d.privateKeyPath.trim(),
...(d.passphrase ? { passphrase: d.passphrase } : {}),
},
initialPath: d.initialPath.trim() || null,
localDir: d.localDir.trim() || null,
};
}
/**
* Root component of the connection-manager window (__APP_MODE === 'manager').
* Modal to the main window (enforced bun-side); connect/open-stack requests
* are executed by the MAIN window — this window never touches panels.
*/
@Component({
selector: 'app-root',
imports: [NgIcon, ConfirmDialog, PromptDialog, Toasts],
templateUrl: './connection-manager.html',
host: {
'(window:keydown.escape)': 'onEscape()',
'(window:contextmenu)': 'blockContextMenu($event)',
},
})
export class ConnectionManager {
readonly i18n = inject(I18nService);
readonly store = inject(ConnectionsService);
readonly dialogs = inject(DialogsService);
readonly isMacChrome = IS_MAC_CHROME;
private readonly rpc = inject(RpcService);
// injected for its side effects: fetches settings and applies theme/accent/
// text-size data attributes to this window's document root
private readonly settings = inject(SettingsService);
readonly draft = signal<Draft | null>(null);
readonly busy = signal(false);
readonly importing = signal(false);
/** The More… section (local directory) in the edit form. */
readonly moreOpen = signal(false);
/** Connection id being dragged between stacks. */
readonly draggedId = signal<string | null>(null);
/** Highlighted drop target: a stack id, 'loose', or null. */
readonly dropTarget = signal<string | null>(null);
readonly valid = computed(() => {
const d = this.draft();
if (!d) return false;
if (!d.host.trim() || !d.username.trim()) return false;
if (d.authKind === 'key' && !d.privateKeyPath.trim()) return false;
return true;
});
patch<K extends keyof Draft>(key: K, value: Draft[K]): void {
this.draft.update((d) => (d ? { ...d, [key]: value } : d));
}
newConnection(): void {
this.moreOpen.set(false);
this.draft.set({
id: '',
name: '',
stackId: null,
host: '',
port: 22,
username: 'root',
authKind: 'password',
password: '',
privateKeyPath: '',
passphrase: '',
initialPath: '',
localDir: '',
});
}
edit(connection: SavedConnection): void {
this.draft.set(toDraft(connection));
this.moreOpen.set(!!connection.localDir);
}
close(): void {
void this.rpc.request.managerClose({});
}
onEscape(): void {
// in-window dialogs (confirm/prompt) handle their own Escape first
if (!this.dialogs.anyOpen) this.close();
}
blockContextMenu(event: Event): void {
blockNativeContextMenu(event);
}
async save(): Promise<string | null> {
const d = this.draft();
if (!d || !this.valid()) return null;
const id = await this.store.upsert(fromDraft(d));
if (id) this.patch('id', id);
return id;
}
async saveAndConnect(): Promise<void> {
if (!this.draft()) return;
this.busy.set(true);
try {
const id = await this.save();
if (!id) return;
// the main window connects (and this window closes); a failed connect
// surfaces as an error there
await this.rpc.request.managerConnect({ id });
} finally {
this.busy.set(false);
}
}
deleteCurrent(): void {
const d = this.draft();
if (!d?.id) {
this.draft.set(null);
return;
}
this.dialogs.confirm.set({
title: this.i18n.t('connMgr.deleteConnectionTitle'),
body: this.i18n.t('connMgr.deleteConnectionBody', { name: d.name || d.host }),
confirmLabel: this.i18n.t('common.delete'),
destructive: true,
onConfirm: async () => {
await this.store.remove(d.id);
this.draft.set(null);
},
});
}
async pickKeyFile(): Promise<void> {
const res = await this.rpc.request.pickKeyFile({});
if (res.ok && res.path) this.patch('privateKeyPath', res.path);
}
async importWinScp(): Promise<void> {
this.importing.set(true);
try {
await this.store.importWinScp();
} finally {
this.importing.set(false);
}
}
// ---- stacks ----
openStack(stackId: string): void {
// opened as tabs in the main window; bun closes this window after
void this.rpc.request.managerOpenStack({ id: stackId });
}
toggleStack(stack: Stack): void {
void this.store.upsertStack({ ...stack, collapsed: !stack.collapsed });
}
newStack(): void {
this.dialogs.prompt.set({
title: this.i18n.t('connMgr.newStackTitle'),
placeholder: this.i18n.t('connMgr.newStackPrompt'),
confirmLabel: this.i18n.t('fileActions.createConfirm'),
onConfirm: (name) => void this.store.upsertStack({ id: '', name }),
});
}
renameStack(stack: Stack): void {
this.dialogs.prompt.set({
title: this.i18n.t('connMgr.renameStackTitle'),
initial: stack.name,
confirmLabel: this.i18n.t('connMgr.renameStack'),
onConfirm: (name) => void this.store.upsertStack({ ...stack, name }),
});
}
deleteStack(stack: Stack): void {
this.dialogs.confirm.set({
title: this.i18n.t('connMgr.deleteStackTitle'),
body: this.i18n.t('connMgr.deleteStackBody', { name: stack.name }),
confirmLabel: this.i18n.t('common.delete'),
destructive: true,
onConfirm: () => void this.store.removeStack(stack.id),
});
}
// ---- drag & drop (move a connection between stacks / loose) ----
onDragStart(event: DragEvent, connection: SavedConnection): void {
this.draggedId.set(connection.id);
event.dataTransfer?.setData('text/plain', connection.id);
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
}
onDragOver(event: DragEvent, target: string): void {
if (!this.draggedId()) return;
event.preventDefault();
this.dropTarget.set(target);
}
onDrop(event: DragEvent, stackId: string | null): void {
event.preventDefault();
const id = this.draggedId();
this.draggedId.set(null);
this.dropTarget.set(null);
const connection = this.store.connections().find((c) => c.id === id);
if (!connection || connection.stackId === stackId) return;
void this.store.upsert({ ...connection, stackId });
}
onDragEnd(): void {
this.draggedId.set(null);
this.dropTarget.set(null);
}
}