diff --git a/package-lock.json b/package-lock.json index 332eeb7..9452d21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "@noble/ciphers": "^2.4.0", "@noble/curves": "^2.4.0", "@noble/hashes": "^2.4.0", - "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "astro": "^7.4.2" } @@ -1526,10 +1526,10 @@ "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", "license": "ISC" }, - "node_modules/@xterm/addon-fit": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", - "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", "license": "MIT" }, "node_modules/@xterm/xterm": { diff --git a/package.json b/package.json index 83e6eb8..1d631fc 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "@noble/ciphers": "^2.4.0", "@noble/curves": "^2.4.0", "@noble/hashes": "^2.4.0", - "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "astro": "^7.4.2" } diff --git a/src/scripts/terminal.ts b/src/scripts/terminal.ts index 3aadd67..763aebf 100644 --- a/src/scripts/terminal.ts +++ b/src/scripts/terminal.ts @@ -1,6 +1,6 @@ // The real terminal: xterm.js connected to the shared shell server over SSH. Loaded only once the first command is sent. -import { Terminal, type IBufferLine, type IMarker } from '@xterm/xterm'; -import { FitAddon } from '@xterm/addon-fit'; +import { Terminal, type IBufferCell, type IBufferLine, type IMarker } from '@xterm/xterm'; +import { WebLinksAddon } from '@xterm/addon-web-links'; import css from '@xterm/xterm/css/xterm.css?inline'; import { connect } from './ssh'; @@ -11,6 +11,8 @@ const SCROLLBACK = 10000; const ANSI = ['#2e3436', '#cc0000', '#4e9a06', '#c4a000', '#3465a4', '#75507b', '#06989a', '#d3d7cf', '#555753', '#ef2929', '#8ae234', '#fce94f', '#729fcf', '#ad7fa8', '#34e2e2', '#eeeeec']; const CUBE = [0, 95, 135, 175, 215, 255]; +// URLs that become links, on the screen and in the page (the web-links addon's default pattern). +const URL_PATTERN = /(https?|HTTPS?):[/]{2}[^\s"'!*(){}|\\\^<>`]*[^\s"':,.!?{}|\\\^~\[\]`()<>]/; const boringData = 'LS0tLS1PUlRWQSBCQ1JBRkZVIENFVklOR1IgWFJMLS0tLS0KbzNPeW9hQW1uUDFlTUt4Z3F3Uk5OTk5OT1Q1aW96SE5OTk5Sb3o5aE1ETk5OTk5OTk5OT05OTk5aak5OTk5nbXAydGdNSgpEbEFHSGtCRE5OTlBQRDhVdU8vVDRKRE8xSnRVYUxIMUQyOWIvNlFPVzk3NWlyOERmTmZDRmppRE5OTlh3eVh2UmU1RmJ1ClhqTk5OTmdtcDJ0Z01KRGxBR0hrQkROTk5QUEQ4VXVPL1Q0SkRPMUp0VWFMSDFEMjliLzZRT1c5NzVpcjhEZk5mQ0ZqaUQKTk5OUk44eXRHaFIrMTlMTkVoeHo3cmQyU00xQ2lIb1IrYmpsQk5SQjVWb1BhaWFXUWpyUlU4b3VNTlVJbk5ycXVHSVFvMgp3L2JaUmEzaXo5N2tQalBqOVlQOU5OTk5WS1dpbzNFTm96OTNuVElsTUY1M25LV21ZenloTWxPd3JKcTNuSjRnWndWbFp0ClJQTmpEPQotLS0tLVJBUSBCQ1JBRkZVIENFVklOR1IgWFJMLS0tLS0K'; /** Continues the page's `prompt` with a terminal in which `command` was just entered, and returns that terminal. */ @@ -25,6 +27,22 @@ export function start(prompt: HTMLElement, command: string) { const element = screen.appendChild(document.createElement('div')); prompt.after(history, screen); + // Links to a section of this very page scroll there, all others open in a new tab. + // Hovering shows where a link leads (and inverts it, see style.css). + const page = (url: URL | Location) => url.origin + url.pathname.replace(/index\.html$/, ''); + const section = (uri: string) => { + const url = new URL(uri, location.href); + return url.hash && page(url) === page(location) ? url.hash : undefined; + }; + const link = { + activate(_: MouseEvent, uri: string) { + const hash = section(uri); + if (hash) location.href = hash; + else open(uri, '_blank', 'noopener'); + }, + hover: (_: MouseEvent, uri: string) => (element.title = uri), + leave: () => element.removeAttribute('title'), + }; const { color, fontFamily, fontSize } = getComputedStyle(screen); const term = new Terminal({ fontFamily, @@ -33,35 +51,74 @@ export function start(prompt: HTMLElement, command: string) { cursorBlink: !matchMedia('(prefers-reduced-motion: reduce)').matches, cursorInactiveStyle: 'block', // typing anywhere on the page reaches the terminal, focused or not theme: { background: '#00000000', foreground: color, cursor: color, green: color }, // xterm.js rejects 'transparent' + linkHandler: link, // OSC 8 hyperlinks }); - const fit = new FitAddon(); - term.loadAddon(fit); + term.loadAddon(new WebLinksAddon(link.activate, { urlRegex: URL_PATTERN, hover: link.hover, leave: link.leave })); term.open(element); - fit.fit(); + const grid = element.querySelector('.xterm-screen')!; + const rows = element.querySelector('.xterm-rows')!; + // Rows and columns fill the element, without the fit addon's room for a scrollbar, which is hidden here. + const fit = () => { + const cell = grid.getBoundingClientRect(); + const { width, height } = element.getBoundingClientRect(); + term.resize(Math.floor(width / (cell.width / term.cols)), Math.floor(height / (cell.height / term.rows))); + }; + fit(); // The wheel scrolls the page, except in full-screen apps, where xterm.js turns it into arrow keys. screen.addEventListener('wheel', (e) => term.buffer.active.type === 'normal' && e.stopPropagation(), { capture: true }); - const grid = element.querySelector('.xterm-screen')!; + // How wide characters are in the page's font, to space them on the cell grid exactly like xterm.js does. + const measure = screen.appendChild(document.createElement('span')); + measure.style.cssText = 'position:absolute;visibility:hidden;letter-spacing:0;white-space:pre'; + const advances = new Map(); + const advance = (chars: string) => { + if (!advances.has(chars)) { + measure.textContent = chars.repeat(10); + advances.set(chars, measure.getBoundingClientRect().width / 10); + } + return advances.get(chars)!; + }; + let cellWidth = 0; + let spacing = 0; // xterm.js's letter-spacing for characters as wide as 'W' + let used = 1; // screen rows in use, like a terminal window filling up let moved: IMarker | undefined; // the last scrollback line already moved into the page function layout() { const buffer = term.buffer.active; const rowHeight = grid.clientHeight / term.rows; + cellWidth = grid.getBoundingClientRect().width / term.cols; + spacing = parseFloat(rows.style.letterSpacing) || 0; if (buffer.type === 'normal') { used = buffer.baseY ? term.rows : Math.max(used, buffer.cursorY + 1); const last = moved && !moved.isDisposed ? moved.line : -1; for (let y = last; y >= buffer.baseY; y--) history.lastChild!.remove(); // pulled back onto a taller screen - for (let y = last + 1; y < buffer.baseY; y++) history.append(pageLine(buffer.getLine(y)!)); + for (let y = last + 1; y < buffer.baseY; y++) history.append(pageLine(buffer.getLine(y)!, y)); while (history.childNodes.length > SCROLLBACK) history.firstChild!.remove(); if (last !== buffer.baseY - 1) { moved?.dispose(); moved = buffer.baseY ? term.registerMarker(-1 - buffer.cursorY) : undefined; } + hyperlinks = hyperlinks.filter((l) => !l.start.marker.isDisposed); // gone with their lines } history.style.lineHeight = `${rowHeight}px`; + history.style.letterSpacing = `${spacing}px`; screen.style.height = `${(buffer.type === 'normal' ? used : term.rows) * rowHeight}px`; } + // OSC 8 hyperlinks are remembered by position, so they stay links when their lines move into the page. + type Position = { marker: IMarker; x: number }; + let hyperlinks: { uri: string; start: Position; end?: Position }[] = []; + term.parser.registerOscHandler(8, (data) => { + if (term.buffer.active.type === 'normal') { + const here = () => ({ marker: term.registerMarker(0), x: term.buffer.active.cursorX }); + const open = hyperlinks.at(-1); + if (open && !open.end) open.end = here(); // a link ends where the next one starts or the closing sequence is + const uri = data.slice(data.indexOf(';') + 1); + if (/^https?:\/\//i.test(uri)) hyperlinks.push({ uri, start: here() }); // only web links, as on the screen + } + return false; // xterm.js still handles the link on the screen + }); + // A line as page text, with the colors and styles the terminal shows it in. const palette = ANSI.map((c, i) => (i === 2 ? color : c)); // green is the page's, as in the theme const colorOf = (n: number, rgb: boolean) => { @@ -72,8 +129,17 @@ export function start(prompt: HTMLElement, command: string) { return `rgb(${gray},${gray},${gray})`; }; const cell = term.buffer.normal.getNullCell(); - function pageLine(line: IBufferLine) { + const foreground = (c: IBufferCell) => // '' for the default color; bold is bright + c.isFgDefault() ? '' : colorOf(c.getFgColor() + (c.isBold() && c.isFgPalette() && c.getFgColor() < 8 ? 8 : 0), c.isFgRGB()); + const anchor = (uri: string, text = '') => { + const hash = section(uri); + return Object.assign(document.createElement('a'), { href: hash ?? uri, textContent: text }, hash ? {} : { target: '_blank', rel: 'noopener' }); + }; + function pageLine(line: IBufferLine, y: number) { const row = document.createElement('div'); + const linked = hyperlinks // the OSC 8 links on this line, as ranges of cells + .filter(({ start, end }) => start.marker.line <= y && (!end || end.marker.line >= y)) + .map(({ uri, start, end }) => ({ uri, from: start.marker.line === y ? start.x : 0, to: end?.marker.line === y ? end.x : Infinity })); let end = line.length; // up to the last visible cell, so copied text has no trailing spaces while (end && !line.getCell(end - 1, cell)!.getChars().trim() && cell.isBgDefault() && !cell.isInverse()) end--; let span: HTMLElement | undefined; @@ -81,25 +147,40 @@ export function start(prompt: HTMLElement, command: string) { for (let x = 0; x < end; x++) { line.getCell(x, cell); if (!cell.getWidth()) continue; // the second half of a wide character - const fgIndex = cell.getFgColor() + (cell.isBold() && cell.isFgPalette() && cell.getFgColor() < 8 ? 8 : 0); // bold is bright - let fg = cell.isFgDefault() ? '' : colorOf(fgIndex, cell.isFgRGB()); + let fg = foreground(cell); let bg = cell.isBgDefault() ? '' : colorOf(cell.getBgColor(), cell.isBgRGB()); if (cell.isInverse()) [fg, bg] = [bg || '#000', fg || color]; if (cell.isDim()) fg = `color-mix(in srgb, ${fg || color} 50%, transparent)`; const decoration = [cell.isUnderline() && 'underline', cell.isStrikethrough() && 'line-through'].filter(Boolean).join(' '); + const uri = linked.find(({ from, to }) => x >= from && x < to)?.uri; + const chars = cell.getChars() || ' '; + const own = spacing + cellWidth * (cell.getWidth() - 1) + advance('W') - advance(chars); // e.g. wide or fallback-font characters const style = [ - fg && `color:${fg}`, + fg && `--fg:${fg}`, // the page's color variable, so links invert in their own color on hover bg && `background:${bg}`, cell.isBold() && 'font-weight:bold', cell.isItalic() && 'font-style:italic', - decoration && `text-decoration:${decoration}`, + decoration && `text-decoration:${decoration}${uri ? ' dashed' : ''}`, // xterm.js underlines OSC 8 links dashed cell.isInvisible() && 'visibility:hidden', + Math.abs(own - spacing) > 0.01 && `letter-spacing:${own}px`, ].filter(Boolean).join(';'); - if (style !== current) { - span = row.appendChild(document.createElement('span')); - span.style.cssText = current = style; + if (`${style} ${uri}` !== current) { + span = row.appendChild(uri ? anchor(uri) : document.createElement('span')); + span.style.cssText = style; + current = `${style} ${uri}`; } - span!.textContent += cell.getChars() || ' '; + span!.textContent += chars; + } + // Plain URLs, as the web-links addon finds them on the screen. + for (const span of row.querySelectorAll('span')) { + const text = span.textContent!; + const parts: (string | Node)[] = []; + let i = 0; + for (const match of text.matchAll(new RegExp(URL_PATTERN, 'g'))) { + parts.push(text.slice(i, match.index), anchor(match[0], match[0])); + i = match.index + match[0].length; + } + if (parts.length) span.replaceChildren(...parts, text.slice(i)); } return row; } @@ -125,7 +206,7 @@ export function start(prompt: HTMLElement, command: string) { }); term.onResize(layout); new ResizeObserver(() => { - fit.fit(); + fit(); if (follow) reveal(); }).observe(element); layout(); @@ -157,12 +238,14 @@ export function start(prompt: HTMLElement, command: string) { } return; } - // The shell is ready once its first output has settled; a command sent earlier would be echoed twice. + // The shell is ready once its line editor turns on bracketed paste, or else once its output has settled; + // a command sent earlier would be echoed twice. + const ready = new TextDecoder().decode(data).includes('\x1b[?2004h'); clearTimeout(idle); idle = setTimeout(() => { phase = 'echo'; session.write(`${command}\r${typeahead}`); - }, 300); + }, ready ? 0 : 1000); }, onClose(error) { term.write(`${term.buffer.active.cursorX ? '\r\n' : ''}[${error?.message ?? 'connection closed'}]\r\n`); diff --git a/src/styles/style.css b/src/styles/style.css index 2278ae9..bf5cd18 100644 --- a/src/styles/style.css +++ b/src/styles/style.css @@ -69,11 +69,14 @@ a:hover, a:focus-visible { /* Terminal: continues right below the first prompt. Lines scrolled off its screen become page text, the screen shows the rows in use and xterm.js fits rows and columns to the window. */ .prompt:has(+ .history) { margin-bottom: 0; } -.history { white-space: pre-wrap; } +.history { white-space: pre; overflow-x: clip; } /* rows of the terminal's width; cut, not wrapped, if the window narrows */ .history > div { min-height: 1lh; } /* keeps empty lines */ +.history span { color: var(--fg); } .screen { overflow: clip; } .screen > div { height: 100dvh; } .screen .xterm-viewport, .screen .scrollbar { display: none; } /* the page scrolls instead */ +/* hovered links (xterm.js underlines them inline) invert in their own color, like the page's links */ +.screen .xterm-rows span[style*="text-decoration: underline"] { background-color: currentColor; -webkit-text-fill-color: #000; text-shadow: none; } @media (prefers-reduced-motion: no-preference) { /* typing still reaches the terminal when it is not focused, so its cursor keeps blinking like the page's */ .screen .xterm-rows:not(.xterm-focus) .xterm-cursor-block { animation: blink 1.1s steps(1) infinite; }