import { Component, DOCUMENT, ElementRef, afterNextRender, computed, effect, inject, signal, viewChild, } from '@angular/core'; import { NgIcon } from '@ng-icons/core'; import { I18nService } from '../i18n.service'; import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, MOD_LABEL } from '../platform'; import { RpcService } from '../rpc.service'; import { SettingsService } from '../settings.service'; import { ToastService } from '../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:blur)': 'flushDraft()', '(window:contextmenu)': 'blockContextMenu($event)', }, }) export class EditorWindow { readonly i18n = inject(I18nService); readonly ctx = EDITOR_CONTEXT; readonly modLabel = MOD_LABEL; readonly altLabel = ALT_LABEL; readonly loading = signal(true); readonly loadError = signal(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>('box'); private readonly gutter = viewChild>('gutter'); private readonly findBox = viewChild>('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 | 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 { 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 { 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 { 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); } }