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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user