blob: 544de72ea1ee455e00aa6309330ab1df9cfc0b9f [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
Key,
Input,
isKeyRelease,
isKeyRepeat,
matchesKey,
truncateToWidth,
visibleWidth,
type Component,
} from '@earendil-works/pi-tui';
import type { UiLocale } from '@maka/core/ui-locale';
import { ansi, stripAnsi } from './tui-ansi.js';
import { TUI_COPY_RESOURCES } from './tui-copy-catalog.js';
const VIEWER_CHROME_ROWS = 2;
export interface TranscriptDocument {
lines: readonly string[];
anchors: readonly { id: string; line: number }[];
}
export interface TranscriptViewerInput {
/** Produces the current read-only CLI transcript projection at this width. */
renderTranscript(width: number, expanded: boolean): TranscriptDocument;
locale?: UiLocale;
viewportRows(): number;
onClose(): void;
onChange(): void;
}
/**
* Full-screen, read-only navigation over the CLI transcript projection.
*
* The normal editor keeps ownership of its navigation keys. This component only
* sees them while its capturing overlay is focused, so opening the viewer does
* not create a second set of global editor bindings or a second history source.
*/
export class TranscriptViewerOverlay implements Component {
focused = false;
private top = 0;
private documentRows = 0;
private bodyRows = 0;
private followsEnd = true;
private document: TranscriptDocument = { lines: [], anchors: [] };
private expanded = true;
private anchor: { id: string; offset: number } | undefined;
private search = new Input();
private searching = false;
private query = '';
private matches: number[] = [];
private searchOrigin:
| { top: number; followsEnd: boolean; anchor: { id: string; offset: number } | undefined }
| undefined;
private matchedLine: number | undefined;
constructor(private readonly input: TranscriptViewerInput) {}
invalidate(): void {}
handleInput(data: string): void {
if (isKeyRelease(data)) return;
if (matchesKey(data, Key.ctrl('o'))) {
if (!isKeyRepeat(data)) this.input.onClose();
return;
}
if (this.searching) {
if (matchesKey(data, Key.escape)) {
this.searching = false;
this.query = '';
this.matches = [];
if (this.searchOrigin) Object.assign(this, this.searchOrigin);
this.input.onChange();
return;
}
if (matchesKey(data, Key.enter)) {
this.searching = false;
this.input.onChange();
return;
}
if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) {
this.nextMatch(matchesKey(data, Key.up) ? -1 : 1);
return;
}
this.search.handleInput(data);
const query = this.search.getValue().trim().toLocaleLowerCase();
if (query !== this.query) {
this.query = query;
this.findMatches();
const target =
this.matches.find((line) => line >= (this.searchOrigin?.top ?? 0)) ?? this.matches[0];
if (target !== undefined) this.goToMatch(target);
}
this.input.onChange();
return;
}
if (matchesKey(data, Key.escape) && this.query) {
this.query = '';
this.matches = [];
if (this.searchOrigin) Object.assign(this, this.searchOrigin);
this.input.onChange();
return;
}
if (matchesKey(data, Key.escape) || matchesKey(data, 'q')) {
this.input.onClose();
return;
}
if (matchesKey(data, '/')) {
this.searchOrigin = {
top: this.top,
followsEnd: this.followsEnd,
anchor: this.anchor ? { ...this.anchor } : undefined,
};
this.search.setValue(this.query);
this.searching = true;
this.input.onChange();
return;
}
if (this.query && (matchesKey(data, 'n') || matchesKey(data, Key.shift('n')))) {
this.nextMatch(matchesKey(data, Key.shift('n')) ? -1 : 1);
return;
}
if (matchesKey(data, Key.ctrl('e'))) {
if (!isKeyRepeat(data)) {
this.expanded = !this.expanded;
// A detail that disappears on collapse returns to its own heading.
if (!this.expanded && this.anchor) this.anchor.offset = 0;
this.input.onChange();
}
return;
}
if (matchesKey(data, Key.up)) {
this.scrollBy(-1);
return;
}
if (matchesKey(data, Key.down)) {
this.scrollBy(1);
return;
}
if (matchesKey(data, Key.pageUp)) {
this.scrollBy(-Math.max(1, this.bodyRows));
return;
}
if (matchesKey(data, Key.pageDown)) {
this.scrollBy(Math.max(1, this.bodyRows));
return;
}
if (matchesKey(data, Key.home)) {
this.followsEnd = false;
this.top = 0;
this.captureAnchor();
this.input.onChange();
return;
}
if (matchesKey(data, Key.end)) {
this.followsEnd = true;
this.top = this.maxTop();
this.captureAnchor();
this.input.onChange();
}
}
render(width: number): string[] {
const safeWidth = Math.max(1, width);
const viewportRows = Math.max(1, Math.floor(this.input.viewportRows()));
// A two-row terminal is still more useful with one document row than with
// navigation chrome only. The footer appears once header + body + footer fit.
const showFooter = viewportRows > 2;
this.bodyRows = Math.max(0, viewportRows - (showFooter ? VIEWER_CHROME_ROWS : 1));
this.document = this.input.renderTranscript(safeWidth, this.expanded);
const document = this.document.lines;
this.documentRows = document.length;
if (!this.followsEnd && this.anchor) {
const index = this.document.anchors.findIndex((entry) => entry.id === this.anchor!.id);
if (index >= 0) {
const entry = this.document.anchors[index]!;
const end = this.document.anchors[index + 1]?.line ?? document.length;
this.top = entry.line + Math.min(this.anchor.offset, Math.max(0, end - entry.line - 1));
}
}
const maxTop = this.maxTop();
this.top = this.followsEnd ? maxTop : clamp(this.top, 0, maxTop);
this.captureAnchor();
this.findMatches();
const visible = document.slice(this.top, this.top + this.bodyRows);
const start = visible.length === 0 ? 0 : this.top + 1;
const end = visible.length === 0 ? 0 : this.top + visible.length;
const copy = TUI_COPY_RESOURCES['transcript-reader'][this.input.locale ?? 'en'];
const header = padLine(
`${ansi.bold(copy.title)} ${ansi.dim(`${start}-${end}/${document.length} · ${copy.scope}`)}`,
safeWidth,
);
const matchingLines = new Set(this.matches);
const body = [
...visible.map((line, index) =>
padLine(
matchingLines.has(this.top + index) ? ansi.reverse(stripAnsi(line)) : line,
safeWidth,
),
),
...Array.from({ length: Math.max(0, this.bodyRows - visible.length) }, () =>
' '.repeat(safeWidth),
),
];
if (!showFooter) return [header, ...body];
this.search.focused = this.focused && this.searching;
const footer = padLine(
this.searching
? `/ ${this.search.render(Math.max(1, safeWidth - 3))[0] ?? ''}`
: ansi.dim(
this.query
? `${this.matches.length} ${copy.matches} · n/N · Esc ${copy.back}`
: copy.hint,
),
safeWidth,
);
return [header, ...body, footer];
}
private scrollBy(delta: number): void {
this.matchedLine = undefined;
const maxTop = this.maxTop();
this.top = clamp(this.top + delta, 0, maxTop);
// Follow the tail whenever the clamped position is the end, including the
// no-op case where a short transcript cannot move at all: a stray Up key
// must not pin the viewer at the head once the transcript grows.
this.followsEnd = this.top === maxTop;
this.captureAnchor();
this.input.onChange();
}
private captureAnchor(): void {
const entry = [...this.document.anchors].reverse().find((entry) => entry.line <= this.top);
this.anchor = entry ? { id: entry.id, offset: this.top - entry.line } : undefined;
}
private findMatches(): void {
this.matches = this.query
? this.document.lines.flatMap((line, index) =>
stripAnsi(line).toLocaleLowerCase().includes(this.query) ? [index] : [],
)
: [];
}
private goToMatch(line: number): void {
this.matchedLine = line;
this.top = clamp(line, 0, this.maxTop());
this.followsEnd = false;
this.captureAnchor();
}
private nextMatch(direction: number): void {
const current = this.matchedLine ?? this.top;
const line =
direction > 0
? (this.matches.find((line) => line > current) ?? this.matches[0])
: ([...this.matches].reverse().find((line) => line < current) ?? this.matches.at(-1));
if (line !== undefined) this.goToMatch(line);
this.input.onChange();
}
private maxTop(): number {
return Math.max(0, this.documentRows - this.bodyRows);
}
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function padLine(text: string, width: number): string {
const safeWidth = Math.max(1, width);
const trimmed = visibleWidth(text) > safeWidth ? truncateToWidth(text, safeWidth, '') : text;
return `${trimmed}${' '.repeat(Math.max(0, safeWidth - visibleWidth(trimmed)))}`;
}