Stage 2: move the win7 component tree into app/skins/7like/

All 14 components (including title-bar/, which has no modern counterpart)
move under app/skins/7like/. git records every one as a rename — no content
changed except main.ts.

The @core alias from stage 1 paid off exactly as intended: not one of the 67
service imports needed touching despite every file changing depth. The only
relative imports left inside the tree are skin-internal (app -> its dialogs,
connection-manager -> title-bar), and those survived because the move
preserved their relative structure.

Added skins/7like/index.ts exporting SEVEN_ROOTS { main, manager, editor }.
Every skin will export that same shape, so main.ts can pick a tree by skin
and then a root by APP_MODE without knowing either skin's internals. main.ts
selects from that barrel now, with the second axis still hardcoded until the
shadlike tree lands.

Verified: tsc clean, build:ui clean, opaque interior pixel-identical to the
pre-refactor baseline. That last check also proves Tailwind still scans the
templates from their new skins/** paths — a silent utility purge would have
collapsed the layout rather than reproducing it exactly.

The three structural 7.css contracts that a careless move breaks silently are
intact by construction, since no markup changed: app-title-bar is still a
direct child of .window.w7-frame, nothing new nests inside menu[role=tablist],
and the context menu still emits ul[role=menu] > li[role=menuitem] > button.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-16 04:50:53 +02:00
co-authored by Claude
parent 17bb1a14fb
commit 39e019af9f
21 changed files with 21 additions and 4 deletions
@@ -0,0 +1,207 @@
<div class="window glass w7-frame flex h-screen flex-col" [class.active]="windowFocused()">
@if (!ctx) {
<div class="flex flex-1 items-center justify-center">
<div class="max-w-sm rounded-lg border border-border bg-card p-6 text-center shadow-sm">
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.noFileTitle') }}</p>
<p class="mt-1 text-xs text-muted-foreground">
{{ i18n.t('editor.noFileBody') }}
</p>
</div>
</div>
} @else {
<!-- The window is frameless. The X runs the SAME dirty prompt as ⌘W and
the Close button — never a raw window close. -->
<app-title-bar [title]="ctx.name + (dirty() ? ' *' : '')" (closeRequested)="requestClose()" />
<!-- Toolbar strip under the title bar. h-9/pl-3 are load-bearing: without a
height this row collapses onto the icon and the screen reads as broken. -->
<header
class="w7-toolbar flex h-9 shrink-0 items-center gap-2 border-b border-border bg-chrome pr-2 pl-3"
>
<ng-icon name="mynaFileText" size="0.95rem" class="shrink-0 text-muted-foreground" />
<h1 class="min-w-0 truncate font-mono text-sm font-semibold">
{{ ctx.name }}{{ dirty() ? ' •' : '' }}
</h1>
<span class="shrink-0 text-[11px] text-muted-foreground">{{ ctx.sessionTitle }}</span>
<div class="flex-1"></div>
<button
(click)="toggleWrap()"
[title]="i18n.t('editor.wrapHint', { alt: altLabel })"
class="rounded-md px-2 py-1 text-[11px] electrobun-webkit-app-region-no-drag hover:bg-accent"
[class.bg-accent]="wrap()"
[class.text-foreground]="wrap()"
[class.text-muted-foreground]="!wrap()"
>
{{ i18n.t('editor.wrap') }}
</button>
<button
(click)="openFind()"
[title]="i18n.t('editor.findHint', { mod: modLabel })"
class="grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaSearch" size="0.85rem" />
</button>
</header>
@if (findOpen()) {
<div class="flex shrink-0 items-center gap-2 border-b border-border bg-chrome px-3 py-1.5">
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
<input
#findBox
[value]="findQuery()"
(input)="onFindInput($any($event.target).value)"
(keydown)="onFindKeydown($event)"
[placeholder]="i18n.t('editor.findPlaceholder')"
spellcheck="false"
class="h-6 w-56 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:ring-2 focus:ring-ring"
/>
<span
class="text-[11px] tabular-nums"
[class.text-destructive]="findQuery() && !matches().length"
[class.text-muted-foreground]="!findQuery() || matches().length"
>
{{ countLabel() }}
</span>
<button
(click)="findStep(-1)"
[title]="i18n.t('editor.prevMatch')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaChevronUp" size="0.8rem" />
</button>
<button
(click)="findStep(1)"
[title]="i18n.t('editor.nextMatch')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaChevronDown" size="0.8rem" />
</button>
<div class="flex-1"></div>
<button
(click)="closeFind()"
[title]="i18n.t('editor.closeHint')"
class="grid size-5 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ng-icon name="mynaX" size="0.8rem" />
</button>
</div>
}
@if (sessionDead()) {
<div
class="flex shrink-0 items-center gap-2 border-b border-border bg-destructive/10 px-3 py-1.5 text-[11px] text-destructive"
>
<ng-icon name="mynaXCircle" size="0.85rem" class="shrink-0" />
{{ i18n.t('editor.disconnected', { name: ctx.sessionTitle }) }}
</div>
}
@if (loading()) {
<div class="flex flex-1 items-center justify-center">
<ng-icon name="mynaSpinner" size="1.25rem" class="animate-spin text-primary" />
</div>
} @else if (loadError(); as error) {
<div class="flex flex-1 items-center justify-center">
<div class="max-w-md rounded-lg border border-border bg-card p-6 text-center shadow-sm">
<ng-icon name="mynaXCircle" size="1.5rem" class="text-destructive" />
<p class="mt-2 text-sm font-medium">{{ i18n.t('editor.cantOpen', { name: ctx.name }) }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ error }}</p>
<button
(click)="discardAndClose()"
class="mt-4 rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('editor.closeWindow') }}
</button>
</div>
</div>
} @else {
<div class="flex min-h-0 flex-1">
<!-- gutter and textarea share font/leading/vertical padding — that's
the whole alignment contract; keep the classes in lockstep -->
@if (!wrap()) {
<div
#gutter
aria-hidden="true"
[style.width.ch]="gutterDigits() + 3"
class="shrink-0 overflow-hidden border-r border-border py-4 pr-2 text-right font-mono text-xs leading-relaxed whitespace-pre text-muted-foreground select-none"
>{{ gutterText() }}</div>
}
<textarea
#box
[value]="text()"
(input)="onInput($any($event.target).value)"
(scroll)="onScroll()"
spellcheck="false"
[attr.wrap]="wrap() ? 'soft' : 'off'"
class="min-h-0 flex-1 resize-none overflow-auto bg-transparent px-4 py-4 font-mono text-xs leading-relaxed outline-none"
[class.whitespace-pre]="!wrap()"
></textarea>
</div>
<footer class="flex shrink-0 items-center gap-2 border-t border-border bg-chrome px-4 py-2">
<span class="text-[11px] text-muted-foreground">{{ i18n.t('editor.shortcuts', { mod: modLabel }) }}</span>
<span class="text-[11px] text-muted-foreground">·</span>
<span class="text-[11px] tabular-nums text-muted-foreground">
{{ i18n.t('editor.lines', { count: lineCount() }) }}
</span>
<div class="flex-1"></div>
<button
(click)="requestClose()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('editor.close') }}
</button>
<button
(click)="save()"
[disabled]="saving() || !dirty()"
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 (saving()) {
<ng-icon name="mynaSpinner" size="0.85rem" class="animate-spin" />
}
{{ i18n.t('common.save') }}
</button>
</footer>
}
<!-- dirty-close prompt. Component-local on purpose: the shared confirm
dialog pulls in PanelsService, which must not boot in this window. -->
@if (closePrompt()) {
<div class="fixed inset-0 z-40 grid place-items-center bg-black/40" (mousedown)="cancelClose()">
<div
class="w-96 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-xl"
(mousedown)="$event.stopPropagation()"
>
<h2 class="text-sm font-semibold">{{ i18n.t('editor.saveChangesTitle', { name: ctx.name }) }}</h2>
<p class="mt-1.5 text-xs text-muted-foreground">
{{ i18n.t('editor.saveChangesBody') }}
</p>
<div class="mt-4 flex items-center gap-2">
<button
(click)="discardAndClose()"
class="rounded-md px-3 py-1.5 text-xs font-medium text-destructive hover:bg-accent"
>
{{ i18n.t('editor.dontSave') }}
</button>
<div class="flex-1"></div>
<button
(click)="cancelClose()"
class="rounded-md px-3 py-1.5 text-xs font-medium hover:bg-accent"
>
{{ i18n.t('common.cancel') }}
</button>
<button
(click)="saveAndClose()"
[disabled]="saving()"
class="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{{ i18n.t('common.save') }}
</button>
</div>
</div>
</div>
}
}
<app-toasts />
</div>
@@ -0,0 +1,355 @@
import {
Component,
DOCUMENT,
ElementRef,
afterNextRender,
computed,
effect,
inject,
signal,
viewChild,
} from '@angular/core';
import { NgIcon } from '@ng-icons/core';
import { I18nService } from '@core/i18n.service';
import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, MOD_LABEL } from '@core/platform';
import { RpcService } from '@core/rpc.service';
import { SettingsService } from '@core/settings.service';
import { ToastService } from '@core/toast.service';
import { TitleBar } from '../title-bar/title-bar';
import { Toasts } from '../toasts/toasts';
/** Debounce for mirroring the draft to bun (the traffic-light-close safety net). */
const DRAFT_DEBOUNCE_MS = 400;
/**
* Root component of an editor window (window.__APP_MODE === 'editor').
*
* Deliberately injects only Rpc/Toast/Settings: main-window root services
* (PanelsService, ConnectionsService, TransfersService — and the dialogs that
* inject them) have constructor side effects like creating workspaces and
* loading connections that must not run here.
*/
@Component({
selector: 'app-root',
imports: [NgIcon, Toasts, TitleBar],
templateUrl: './editor-window.html',
host: {
'(window:keydown)': 'onKeydown($event)',
'(window:contextmenu)': 'blockContextMenu($event)',
// Aero desaturates an unfocused window's chrome — see .window.active.
// blur already had to flush the draft; both run from the one binding
// (a duplicated key is a TS1117 build error, not a merge).
'(window:focus)': 'windowFocused.set(true)',
'(window:blur)': 'flushDraft(); windowFocused.set(false)',
},
})
export class EditorWindow {
readonly i18n = inject(I18nService);
/** Drives `.window.active` on this window's Aero frame. */
readonly windowFocused = signal(document.hasFocus());
readonly ctx = EDITOR_CONTEXT;
readonly modLabel = MOD_LABEL;
readonly altLabel = ALT_LABEL;
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
readonly text = signal('');
readonly savedContent = signal('');
readonly dirty = computed(() => this.text() !== this.savedContent());
readonly saving = signal(false);
readonly closePrompt = signal(false);
readonly sessionDead = signal(false);
/** Soft-wrap long lines. Hides the gutter: correct wrapped line numbers
* would need per-edit height measurement — not worth it without an editor lib. */
readonly wrap = signal(false);
readonly findOpen = signal(false);
readonly findQuery = signal('');
readonly activeMatch = signal(0);
/** Line count without allocating a split array on every keystroke. */
readonly lineCount = computed(() => {
const t = this.text();
let n = 1;
let i = -1;
while ((i = t.indexOf('\n', i + 1)) !== -1) n++;
return n;
});
readonly gutterText = computed(() =>
Array.from({ length: this.lineCount() }, (_, i) => i + 1).join('\n'),
);
readonly gutterDigits = computed(() => String(this.lineCount()).length);
/** Case-insensitive match start offsets for the find query. */
readonly matches = computed(() => {
const query = this.findQuery().toLowerCase();
if (!query) return [];
const hay = this.text().toLowerCase();
const out: number[] = [];
let i = hay.indexOf(query);
while (i !== -1) {
out.push(i);
i = hay.indexOf(query, i + query.length);
}
return out;
});
readonly countLabel = computed(() =>
this.matches().length ? `${this.activeMatch() + 1}/${this.matches().length}` : '0/0',
);
private readonly box = viewChild<ElementRef<HTMLTextAreaElement>>('box');
private readonly gutter = viewChild<ElementRef<HTMLElement>>('gutter');
private readonly findBox = viewChild<ElementRef<HTMLInputElement>>('findBox');
private cachedCharWidth = 0;
private readonly rpc = inject(RpcService);
private readonly toast = inject(ToastService);
private readonly document = inject(DOCUMENT);
// 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);
private draftTimer: ReturnType<typeof setTimeout> | null = null;
private lastSentDirty = false;
constructor() {
const ctx = this.ctx;
if (!ctx) return; // opened outside the app (plain browser) — error card renders
const baseTitle = `${ctx.name} — ${ctx.sessionTitle}`;
effect(() => {
this.document.title = `${this.dirty() ? '• ' : ''}${baseTitle}`;
});
this.rpc.onEditorCloseRequest(() => this.requestClose());
this.rpc.onSessionClosed((event) => {
if (event.sessionId === ctx.sessionId) this.sessionDead.set(true);
});
afterNextRender(() => void this.load(ctx.sessionId, ctx.path));
}
private async load(sessionId: string, path: string): Promise<void> {
const res = await this.rpc.request.readFile({ sessionId, path });
if (!res.ok) {
this.loadError.set(res.error);
this.loading.set(false);
return;
}
this.savedContent.set(res.content);
this.text.set(res.content);
this.loading.set(false);
queueMicrotask(() => this.box()?.nativeElement.focus());
}
blockContextMenu(event: Event): void {
blockNativeContextMenu(event);
}
onInput(value: string): void {
this.text.set(value);
this.scheduleDraft();
}
/** Keep the line-number gutter locked to the textarea's scroll position. */
onScroll(): void {
const gutter = this.gutter()?.nativeElement;
if (gutter) gutter.scrollTop = this.box()!.nativeElement.scrollTop;
}
toggleWrap(): void {
this.wrap.update((w) => !w);
queueMicrotask(() => this.onScroll());
}
// ---- find ----
openFind(): void {
const el = this.box()?.nativeElement;
// prefill from the current selection when it's a sane single-line snippet
if (el && el.selectionStart !== el.selectionEnd) {
const selected = el.value.slice(el.selectionStart, el.selectionEnd);
if (selected.length < 200 && !selected.includes('\n')) this.findQuery.set(selected);
}
this.findOpen.set(true);
this.activeMatch.set(0);
queueMicrotask(() => {
const input = this.findBox()?.nativeElement;
input?.focus();
input?.select();
});
}
closeFind(): void {
this.findOpen.set(false);
// selection was left on the match — the caret lands there
this.box()?.nativeElement.focus();
}
onFindInput(value: string): void {
this.findQuery.set(value);
const list = this.matches();
if (!list.length) return;
// jump to the first match at/after the caret, else wrap to the first
const from = this.box()?.nativeElement.selectionStart ?? 0;
const at = list.findIndex((start) => start >= from);
this.goTo(at >= 0 ? at : 0);
}
findStep(delta: 1 | -1): void {
const count = this.matches().length;
if (!count) return;
this.goTo((this.activeMatch() + delta + count) % count);
}
onFindKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter') {
event.preventDefault();
this.findStep(event.shiftKey ? -1 : 1);
} else if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
this.closeFind();
}
}
/** Select match `index` and scroll it into the middle of the viewport. */
private goTo(index: number): void {
const el = this.box()?.nativeElement;
const start = this.matches()[index];
if (!el || start === undefined) return;
this.activeMatch.set(index);
el.setSelectionRange(start, start + this.findQuery().length);
const text = this.text();
const lineHeight = parseFloat(getComputedStyle(el).lineHeight) || 20;
let line = 0;
let i = text.indexOf('\n');
while (i !== -1 && i < start) {
line++;
i = text.indexOf('\n', i + 1);
}
el.scrollTop = Math.max(0, line * lineHeight - el.clientHeight / 2);
if (!this.wrap()) {
const column = start - (text.lastIndexOf('\n', start - 1) + 1);
el.scrollLeft = Math.max(0, column * this.charWidth(el) - el.clientWidth / 2);
}
this.onScroll();
}
/** Monospace advance width, measured once per window. */
private charWidth(el: HTMLTextAreaElement): number {
if (!this.cachedCharWidth) {
const canvas = this.document.createElement('canvas').getContext('2d')!;
canvas.font = getComputedStyle(el).font;
this.cachedCharWidth = canvas.measureText('0').width || 7;
}
return this.cachedCharWidth;
}
// ---- draft mirror: bun holds the latest content for the native-close prompt ----
private scheduleDraft(): void {
if (this.dirty() !== this.lastSentDirty) {
// dirty flag flipped — tell bun immediately so the native title updates
this.flushDraft();
return;
}
if (this.draftTimer) clearTimeout(this.draftTimer);
this.draftTimer = setTimeout(() => this.flushDraft(), DRAFT_DEBOUNCE_MS);
}
flushDraft(): void {
if (!this.ctx || this.loading()) return;
if (this.draftTimer) {
clearTimeout(this.draftTimer);
this.draftTimer = null;
}
this.lastSentDirty = this.dirty();
this.rpc.send.editorState({ dirty: this.dirty(), content: this.text() });
}
// ---- save / close ----
async save(): Promise<boolean> {
if (!this.dirty() || this.saving()) return true;
const content = this.text();
this.saving.set(true);
try {
const res = await this.rpc.request.editorSave({ content });
if (!res.ok) {
this.toast.error(res.error);
return false;
}
this.savedContent.set(content);
this.lastSentDirty = false; // bun record already cleaned by the handler
this.toast.success(this.i18n.t('toasts.saved', { name: this.ctx!.name }));
return true;
} finally {
this.saving.set(false);
}
}
/** ⌘W / Close button / bun's editorCloseRequest: prompt if dirty, else close. */
requestClose(): void {
if (this.dirty()) {
this.closePrompt.set(true);
} else {
void this.rpc.request.editorClose({});
}
}
async saveAndClose(): Promise<void> {
if (await this.save()) void this.rpc.request.editorClose({});
}
discardAndClose(): void {
void this.rpc.request.editorClose({});
}
cancelClose(): void {
this.closePrompt.set(false);
queueMicrotask(() => this.box()?.nativeElement.focus());
}
// ---- keyboard ----
onKeydown(event: KeyboardEvent): void {
const mod = event.metaKey || event.ctrlKey;
if (mod && event.key.toLowerCase() === 's') {
event.preventDefault();
void this.save();
} else if (mod && event.key.toLowerCase() === 'f') {
event.preventDefault();
this.openFind();
} else if (mod && event.key.toLowerCase() === 'w') {
// reaches us on Windows/Linux; on macOS the app menu intercepts ⌘W and
// bun routes it back as editorCloseRequest
event.preventDefault();
this.requestClose();
} else if (event.altKey && event.code === 'KeyZ') {
event.preventDefault();
this.toggleWrap();
} else if (event.key === 'Escape' && this.closePrompt()) {
event.preventDefault();
this.cancelClose();
} else if (event.key === 'Escape' && this.findOpen()) {
event.preventDefault();
this.closeFind();
} else if (event.key === 'Tab' && event.target === this.box()?.nativeElement) {
// keep Tab as an input character inside the editor
event.preventDefault();
this.insertTab();
}
}
private insertTab(): void {
const el = this.box()!.nativeElement;
// execCommand keeps the native undo stack intact where supported
if (!this.document.execCommand('insertText', false, '\t')) {
const { selectionStart, selectionEnd, value } = el;
el.value = `${value.slice(0, selectionStart)}\t${value.slice(selectionEnd)}`;
el.selectionStart = el.selectionEnd = selectionStart + 1;
}
this.onInput(el.value);
}
}