Editor: line numbers, wrap toggle, find bar
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,8 +26,69 @@
|
||||
</h1>
|
||||
<span class="shrink-0 text-[11px] text-muted-foreground">{{ ctx.sessionTitle }}</span>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
(click)="toggleWrap()"
|
||||
title="Wrap long lines — hides line numbers (⌥Z)"
|
||||
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()"
|
||||
>
|
||||
Wrap
|
||||
</button>
|
||||
<button
|
||||
(click)="openFind()"
|
||||
title="Find (⌘F)"
|
||||
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 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="Find in file"
|
||||
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="Previous match (⇧↵)"
|
||||
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="Next match (↵)"
|
||||
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="Close (Esc)"
|
||||
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 (loading()) {
|
||||
<div class="flex flex-1 items-center justify-center">
|
||||
<ng-icon name="mynaSpinner" size="1.25rem" class="animate-spin text-primary" />
|
||||
@@ -47,16 +108,35 @@
|
||||
</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"
|
||||
class="min-h-0 flex-1 resize-none bg-transparent p-4 font-mono text-xs leading-relaxed outline-none"
|
||||
[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 px-4 py-2">
|
||||
<span class="text-[11px] text-muted-foreground">⌘S save · ⌘W close</span>
|
||||
<span class="text-[11px] text-muted-foreground">⌘S save · ⌘F find · ⌘W close</span>
|
||||
<span class="text-[11px] text-muted-foreground">·</span>
|
||||
<span class="text-[11px] tabular-nums text-muted-foreground">
|
||||
{{ lineCount() }} {{ lineCount() === 1 ? 'line' : 'lines' }}
|
||||
</span>
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
(click)="requestClose()"
|
||||
|
||||
@@ -45,8 +45,47 @@ export class EditorWindow {
|
||||
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);
|
||||
@@ -92,6 +131,102 @@ export class EditorWindow {
|
||||
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 {
|
||||
@@ -164,14 +299,23 @@ export class EditorWindow {
|
||||
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();
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
mynaCheckCircle,
|
||||
mynaChevronDown,
|
||||
mynaChevronRight,
|
||||
mynaChevronUp,
|
||||
mynaCode,
|
||||
mynaCog,
|
||||
mynaDots,
|
||||
@@ -46,6 +47,7 @@ export const APP_ICONS = {
|
||||
mynaCheckCircle,
|
||||
mynaChevronDown,
|
||||
mynaChevronRight,
|
||||
mynaChevronUp,
|
||||
mynaCode,
|
||||
mynaCog,
|
||||
mynaDots,
|
||||
|
||||
Reference in New Issue
Block a user