mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Merge C7.1 at 0ce0fc99a2 into ota-c7-5-page-terminal
One conflict, resolved in favour of this branch: `terminal-document-flip.test.ts` is modified there and deleted here. C7.1's commit gave its docstring ruling 18's wording; this lane's first commit retired the test and its `terminal-document-pre-flip-script.txt` fixture under that same ruling, because C7.5 is the lane that changes a module and the flip pin holds exactly while none does. The deletion stands, and the standing pin is the whole-document byte golden. Everything else merged on its own. Kept from C7.1: the order-list guard, the tokens test-support module the equivalence comparator was split into, the tightened comparator, `substituteDocumentConstants`, the named theme type, and the deletions of `URL_TAP_WEBVIEW_JS`, `document/url-tap.test.ts` and `terminal-webview-html-source.test-support.ts`. Kept from this branch: the host-seams module ahead of the scope in the generator, and the two source greps in the reflow and scroll-routing tests that C7.1 also touched — both files auto-merged with C7.1's reads of the generated document and this lane's reads of the controller and the ready promises side by side. The document did not move: rebuilt from the merged modules it is 724,002 bytes and the golden is byte for byte what it was, 105,968. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -56,6 +56,21 @@ async function documentConstantSubstitutions() {
|
||||
return substitutions
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces each constant's name with its literal.
|
||||
*
|
||||
* The replacement is a function, not the literal itself: as a string, `$&`, `` $` ``, `$'` and
|
||||
* `$n` are replacement patterns, so a constant whose value contains one would be spliced with the
|
||||
* match rather than written out. A function replacer has no such reading.
|
||||
*/
|
||||
export function substituteDocumentConstants(text, substitutions) {
|
||||
let substituted = text
|
||||
for (const [name, literal] of Object.entries(substitutions)) {
|
||||
substituted = substituted.replaceAll(new RegExp(`\\b${name}\\b`, 'g'), () => literal)
|
||||
}
|
||||
return substituted
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a line is a lint directive.
|
||||
*
|
||||
@@ -120,10 +135,7 @@ export async function emitTerminalDocumentModule(modulePath) {
|
||||
}
|
||||
kept.push(line.startsWith('export ') ? line.slice('export '.length) : line)
|
||||
}
|
||||
let text = kept.join('\n')
|
||||
for (const [name, literal] of Object.entries(await documentConstantSubstitutions())) {
|
||||
text = text.replaceAll(new RegExp(`\\b${name}\\b`, 'g'), literal)
|
||||
}
|
||||
const text = substituteDocumentConstants(kept.join('\n'), await documentConstantSubstitutions())
|
||||
const substituted = await esbuild.transform(text, {
|
||||
loader: 'js',
|
||||
format: 'esm',
|
||||
|
||||
@@ -2,7 +2,10 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { emitTerminalDocumentModule } from './build-terminal-document-script.mjs'
|
||||
import {
|
||||
emitTerminalDocumentModule,
|
||||
substituteDocumentConstants
|
||||
} from './build-terminal-document-script.mjs'
|
||||
import { terminalBackgroundFallback } from '../src/terminal/document/document-constants'
|
||||
|
||||
/**
|
||||
@@ -82,3 +85,21 @@ describe('emitting one terminal document module', () => {
|
||||
).toBe(' const R = /a/g;')
|
||||
})
|
||||
})
|
||||
|
||||
describe('substituting a build-time constant', () => {
|
||||
it('writes a value containing a replacement pattern out as it stands', () => {
|
||||
// `$&` is the matched text to `String.replaceAll`'s string form, which would splice the
|
||||
// constant's own name in here and ship a document that says something else.
|
||||
const literal = JSON.stringify('a $& b')
|
||||
expect(substituteDocumentConstants('const v = marker;', { marker: literal })).toBe(
|
||||
'const v = "a $& b";'
|
||||
)
|
||||
})
|
||||
|
||||
// `$n` is not listed: the pattern has no capture group, so it is already literal under either
|
||||
// form and a case for it could not tell them apart.
|
||||
it.each([['$&'], ["$'"], ['$`']])('is not read as the replacement pattern %s', (pattern) => {
|
||||
const literal = JSON.stringify(`x${pattern}y`)
|
||||
expect(substituteDocumentConstants('marker', { marker: literal })).toBe(literal)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { readdirSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
TERMINAL_DOCUMENT_MODULE_ORDER,
|
||||
TERMINAL_DOCUMENT_SCOPE_MODULE
|
||||
} from '../../../scripts/terminal-document-module-order.mjs'
|
||||
|
||||
/**
|
||||
* Every module in this directory is in the document, and everything in the order list is here.
|
||||
*
|
||||
* The generator emits exactly what the order list names, so a module added here and forgotten
|
||||
* there is dead code that reads as live, and a name left in the list after its file goes makes the
|
||||
* generator throw at build time rather than at review time. Both directions are asserted.
|
||||
*
|
||||
* `document-constants` is the one file that is deliberately not emitted: its exports are
|
||||
* substituted into the modules that import them as literals, so the document carries its values
|
||||
* without carrying the module.
|
||||
*/
|
||||
const NOT_EMITTED = 'document-constants'
|
||||
|
||||
function documentModuleNames(): string[] {
|
||||
return readdirSync(new URL('.', import.meta.url))
|
||||
.filter((entry) => entry.endsWith('.ts'))
|
||||
.filter((entry) => !entry.endsWith('.test.ts') && !entry.endsWith('.test-support.ts'))
|
||||
.map((entry) => entry.slice(0, -'.ts'.length))
|
||||
.sort()
|
||||
}
|
||||
|
||||
describe('the document module order', () => {
|
||||
it('names every module the directory holds, and only those', () => {
|
||||
const expected = [
|
||||
NOT_EMITTED,
|
||||
TERMINAL_DOCUMENT_SCOPE_MODULE,
|
||||
...TERMINAL_DOCUMENT_MODULE_ORDER
|
||||
].sort()
|
||||
expect(documentModuleNames()).toEqual(expected)
|
||||
})
|
||||
|
||||
it('names each module once, so the generator cannot emit one twice', () => {
|
||||
const listed = [TERMINAL_DOCUMENT_SCOPE_MODULE, ...TERMINAL_DOCUMENT_MODULE_ORDER]
|
||||
expect(listed).toHaveLength(new Set(listed).size)
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,18 @@
|
||||
import { tokenizer } from 'acorn'
|
||||
import { transformSync } from 'esbuild'
|
||||
import {
|
||||
describeToken,
|
||||
readScriptTokens,
|
||||
type DocumentToken
|
||||
} from './terminal-document-tokens.test-support'
|
||||
|
||||
/**
|
||||
* Whether two versions of the in-WebView document script are the same program, allowing only the
|
||||
* scope qualifier that moving it into modules requires.
|
||||
*
|
||||
* C7.1 turns the document's one 2,758-line IIFE into modules the web page can import. The 57
|
||||
* variables the script reassigns cannot stay free variables across ES modules — assigning an
|
||||
* imported binding is a syntax error — so they become fields of one scope object, and every read
|
||||
* and write of them gains a qualifier. Nothing else about the program may change.
|
||||
* C7.1 turns the document's one 2,758-line IIFE into modules the web page can import. A variable
|
||||
* the script assigns across what became a module boundary cannot stay a free variable — assigning
|
||||
* an imported binding is a syntax error — so those become fields of one scope object, 73
|
||||
* declaration sites in all, and every read and write of them gains a qualifier. Nothing else about
|
||||
* the program may change.
|
||||
*
|
||||
* Byte comparison cannot make that claim once the source is formatter-owned: `oxfmt` writes the
|
||||
* repository's style, which drops the semicolons the hand-written document carries, so the emitted
|
||||
@@ -23,10 +27,13 @@ import { transformSync } from 'esbuild'
|
||||
/**
|
||||
* The differences moving the script into modules is allowed to make, each counted on its own.
|
||||
*
|
||||
* Four classes and no others. Three are the repository's own rules rewriting the document's ES5
|
||||
* style the moment its source is a linted module — measured, not assumed: `curly` braces 279
|
||||
* brace-less bodies, `no-unused-vars` unbinds 38 catch clauses, and 446 `var` declarators become
|
||||
* `const`, `let` or a scope field. The fourth is the move itself. Semicolons and whitespace are the
|
||||
* Eight classes and no others. Six are the repository's own rules and the printer rewriting the
|
||||
* document's ES5 style the moment its source is a linted module — measured over the whole script,
|
||||
* not assumed: `curly` braces 279 brace-less bodies, `no-unused-vars` unbinds 36 catch clauses, 373
|
||||
* `var` declarators become `const` or `let`, `unicorn/prefer-number-properties` moves 17 globals
|
||||
* onto `Number`, the printer spells out 4 shorthand properties whose value gained a qualifier, and
|
||||
* it stops renaming 7 bindings that are no longer shadows. The other two are the move itself: 609
|
||||
* qualified references and 73 declarations onto the scope. Semicolons and whitespace are the
|
||||
* formatter's and never reach the token stream at all.
|
||||
*
|
||||
* Counted separately because the flip commit pins each number: a total would let one class absorb
|
||||
@@ -57,6 +64,31 @@ export type TerminalDocumentNormalisations = {
|
||||
readonly unshadowedNames: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The bindings the printer renamed on the baseline and leaves alone in the modules, listed.
|
||||
*
|
||||
* A parameter named for a document variable shadowed it while both lived in one function scope, so
|
||||
* the printer gave the inner one a decimal suffix; once the outer name is a scope field there is no
|
||||
* shadow and the inner one keeps its own name. Listed rather than matched by shape: a rule that
|
||||
* accepted any `name2` facing `name` would also accept an unrelated rename that happens to end in a
|
||||
* digit, which is a changed program, not a normalisation.
|
||||
*
|
||||
* One entry covers all seven sites the whole script has: the `term` parameter of
|
||||
* `attachTerminalQueryReplyBridge` in `query-reply.ts` and its six uses.
|
||||
*/
|
||||
const UNSHADOWED_RENAMES: readonly {
|
||||
readonly baseline: string
|
||||
readonly generated: string
|
||||
readonly module: string
|
||||
}[] = [{ baseline: 'term2', generated: 'term', module: 'query-reply' }]
|
||||
|
||||
/** Whether this exact baseline-to-generated pair is one of the listed unshadowed renames. */
|
||||
function isListedUnshadowedRename(baseline: string, generated: string): boolean {
|
||||
return UNSHADOWED_RENAMES.some(
|
||||
(entry) => entry.baseline === baseline && entry.generated === generated
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The globals `unicorn/prefer-number-properties` moves onto `Number`.
|
||||
*
|
||||
@@ -65,114 +97,76 @@ export type TerminalDocumentNormalisations = {
|
||||
* behind a `typeof … === 'number'` check or is parsing a string, which is what the `Number` form
|
||||
* does with no coercion of its own.
|
||||
*/
|
||||
/**
|
||||
* Whether `printed` is the printer's disambiguated form of `original`: the same name with a decimal
|
||||
* suffix it appends when two bindings of that name are visible at once.
|
||||
*/
|
||||
function isPrinterDisambiguation(printed: string, original: string): boolean {
|
||||
if (!printed.startsWith(original) || printed.length === original.length) {
|
||||
return false
|
||||
}
|
||||
return /^[2-9][0-9]*$/.test(printed.slice(original.length))
|
||||
}
|
||||
|
||||
const NUMBER_GLOBALS = new Set(['isFinite', 'isNaN', 'parseInt', 'parseFloat'])
|
||||
|
||||
export type TerminalDocumentEquivalence =
|
||||
| { readonly equivalent: true; readonly normalisations: TerminalDocumentNormalisations }
|
||||
| { readonly equivalent: false; readonly reason: string }
|
||||
|
||||
/** One token as this comparison reads it: what kind it is, and the text it carried. */
|
||||
type DocumentToken = { readonly label: string; readonly text: string }
|
||||
|
||||
/**
|
||||
* Acorn's `Token` class declares `type`, `start` and `end` and not `value`, which it does carry,
|
||||
* so the field is read through a narrowing check rather than asserted onto the declared type.
|
||||
*/
|
||||
function readDocumentToken(token: unknown): DocumentToken | null {
|
||||
if (typeof token !== 'object' || token === null || !('type' in token) || !('value' in token)) {
|
||||
return null
|
||||
}
|
||||
const type: unknown = token.type
|
||||
if (typeof type !== 'object' || type === null || !('label' in type)) {
|
||||
return null
|
||||
}
|
||||
const label: unknown = type.label
|
||||
if (typeof label !== 'string') {
|
||||
return null
|
||||
}
|
||||
const value: unknown = token.value
|
||||
return { label, text: value === undefined || value === null ? '' : String(value) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Both sides are printed by the generator's own printer before being read.
|
||||
*
|
||||
* Otherwise every choice the printer makes — semicolons, property shorthand, quote style — reads as
|
||||
* a difference in the program, when it is a difference in who typed it. Printing both sides with
|
||||
* one printer removes that whole class by construction rather than by a rule per symptom, and
|
||||
* leaves only what the four normalisations and the qualifier cover.
|
||||
*/
|
||||
const STRICT_DIRECTIVE = 'use strict'
|
||||
|
||||
function significantTokens(source: string): DocumentToken[] {
|
||||
// Read strict on both sides. A loose script has to defend Annex B's block-scoped function
|
||||
// declarations, and the printer does that by hoisting a `var` and renaming the function; a module
|
||||
// does not, so one side would carry a rename the other cannot. Neither name escapes its block, so
|
||||
// the two readings agree on behaviour and only the strict one can be compared.
|
||||
const printed = transformSync(`'${STRICT_DIRECTIVE}';\n${source}`, {
|
||||
loader: 'js',
|
||||
target: 'chrome74',
|
||||
minify: false
|
||||
}).code
|
||||
const kept: DocumentToken[] = []
|
||||
for (const raw of tokenizer(printed, { ecmaVersion: 2020 })) {
|
||||
const token = readDocumentToken(raw)
|
||||
if (token === null) {
|
||||
throw new Error('acorn produced a token this comparison cannot read')
|
||||
}
|
||||
if (token.label === ';' || token.label === 'eof') {
|
||||
continue
|
||||
}
|
||||
kept.push(token)
|
||||
}
|
||||
if (kept[0]?.text !== STRICT_DIRECTIVE) {
|
||||
throw new Error('the strict directive this comparison prepends did not survive printing')
|
||||
}
|
||||
return kept.slice(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* The tokens of one side, or the reason it could not be read.
|
||||
*
|
||||
* A script that does not parse is a refusal with the printer's own message rather than an
|
||||
* exception out of the comparison: a generator that emitted something broken should say so where
|
||||
* the other differences are reported.
|
||||
*/
|
||||
function readScriptTokens(
|
||||
source: string,
|
||||
side: string
|
||||
): { ok: true; tokens: DocumentToken[] } | { ok: false; reason: string } {
|
||||
try {
|
||||
return { ok: true, tokens: significantTokens(source) }
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `${side} does not parse: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function describeToken(token: DocumentToken | undefined): string {
|
||||
return token === undefined ? '(end of script)' : `${token.label} ${token.text}`.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* `baseline` is the script as it stood before the move, `candidate` the one the modules generate.
|
||||
*
|
||||
* The qualifier is read from `qualifier`, not assumed, so the test names the object it expects and
|
||||
* a rename cannot quietly satisfy this.
|
||||
*/
|
||||
/** The statement heads `curly` braces: everything whose body may be a single unbraced statement. */
|
||||
const BRACEABLE_HEAD_KEYWORDS = new Set(['if', 'for', 'while'])
|
||||
|
||||
/**
|
||||
* Whether the `{` at `open` is the body of a braceable head rather than some other block.
|
||||
*
|
||||
* `else` and `do` are followed by their body directly. The rest put a parenthesised head first, so
|
||||
* the `)` is walked back to its `(` and the keyword before that is what decides. Without this a
|
||||
* bare block anywhere in the generated script would be absorbed as a linter-added body, when it is
|
||||
* a statement the baseline does not have.
|
||||
*/
|
||||
function isBraceableHeadBody(tokens: readonly DocumentToken[], open: number): boolean {
|
||||
const previous = tokens[open - 1]
|
||||
if (previous === undefined) {
|
||||
return false
|
||||
}
|
||||
if (previous.label === 'else' || previous.label === 'do') {
|
||||
return true
|
||||
}
|
||||
if (previous.label !== ')') {
|
||||
return false
|
||||
}
|
||||
let depth = 0
|
||||
for (let i = open - 1; i >= 0; i--) {
|
||||
const label = tokens[i]?.label
|
||||
if (label === ')') {
|
||||
depth += 1
|
||||
continue
|
||||
}
|
||||
if (label === '(') {
|
||||
depth -= 1
|
||||
if (depth === 0) {
|
||||
return BRACEABLE_HEAD_KEYWORDS.has(tokens[i - 1]?.label ?? '')
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** The index of the `}` closing the `{` at `open`, or -1 when the generated script has none. */
|
||||
function matchingCloseIndex(tokens: readonly DocumentToken[], open: number): number {
|
||||
let depth = 0
|
||||
for (let i = open; i < tokens.length; i++) {
|
||||
const label = tokens[i]?.label
|
||||
if (label === '{') {
|
||||
depth += 1
|
||||
continue
|
||||
}
|
||||
if (label === '}') {
|
||||
depth -= 1
|
||||
if (depth === 0) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
export function compareTerminalDocumentScripts(
|
||||
baseline: string,
|
||||
candidate: string,
|
||||
@@ -196,15 +190,23 @@ export function compareTerminalDocumentScripts(
|
||||
let numberProperties = 0
|
||||
let shorthandProperties = 0
|
||||
let unshadowedNames = 0
|
||||
// Braces arrive in pairs around one statement, so a counter is enough: a close is only ever
|
||||
// absorbed while an inserted open is outstanding, which bounds how far this can mask a real one.
|
||||
let openInsertedBraces = 0
|
||||
// The generated index each inserted `{` expects its `}` at, innermost last. Recording the index
|
||||
// rather than counting means an absorbed close is the one that closes that body and no other.
|
||||
const insertedBraceCloses: number[] = []
|
||||
let lastMatched: DocumentToken | undefined
|
||||
let left = 0
|
||||
let right = 0
|
||||
while (left < before.length && right < after.length) {
|
||||
const expected = before[left]
|
||||
const actual = after[right]
|
||||
// Ahead of the equality check on purpose: the baseline's next token is a `}` too wherever a
|
||||
// braced body ends a block, and this index is known to close the inserted body, so matching
|
||||
// them as a pair would consume the wrong one and leave the counts right for the wrong reason.
|
||||
if (actual.label === '}' && insertedBraceCloses.at(-1) === right) {
|
||||
insertedBraceCloses.pop()
|
||||
right += 1
|
||||
continue
|
||||
}
|
||||
if (expected.label === actual.label && expected.text === actual.text) {
|
||||
lastMatched = expected
|
||||
left += 1
|
||||
@@ -216,7 +218,7 @@ export function compareTerminalDocumentScripts(
|
||||
if (
|
||||
expected.label === 'name' &&
|
||||
actual.label === 'name' &&
|
||||
isPrinterDisambiguation(expected.text, actual.text)
|
||||
isListedUnshadowedRename(expected.text, actual.text)
|
||||
) {
|
||||
unshadowedNames += 1
|
||||
lastMatched = actual
|
||||
@@ -294,16 +296,16 @@ export function compareTerminalDocumentScripts(
|
||||
left += 3
|
||||
continue
|
||||
}
|
||||
if (actual.label === '{') {
|
||||
bracedBodies += 1
|
||||
openInsertedBraces += 1
|
||||
right += 1
|
||||
continue
|
||||
}
|
||||
if (actual.label === '}' && openInsertedBraces > 0) {
|
||||
openInsertedBraces -= 1
|
||||
right += 1
|
||||
continue
|
||||
// `if (a) b;` -> `if (a) { b; }`: the body the repository's `curly` rule braced. Only a
|
||||
// braceable head's body qualifies, and only that body's own close is absorbed.
|
||||
if (actual.label === '{' && isBraceableHeadBody(after, right)) {
|
||||
const close = matchingCloseIndex(after, right)
|
||||
if (close !== -1) {
|
||||
bracedBodies += 1
|
||||
insertedBraceCloses.push(close)
|
||||
right += 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
return {
|
||||
equivalent: false,
|
||||
@@ -311,8 +313,8 @@ export function compareTerminalDocumentScripts(
|
||||
}
|
||||
}
|
||||
// A body braced at the very end of the script leaves its close after the baseline has run out.
|
||||
while (openInsertedBraces > 0 && after[right]?.label === '}') {
|
||||
openInsertedBraces -= 1
|
||||
while (insertedBraceCloses.at(-1) === right && after[right]?.label === '}') {
|
||||
insertedBraceCloses.pop()
|
||||
right += 1
|
||||
}
|
||||
if (left !== before.length || right !== after.length) {
|
||||
@@ -321,8 +323,11 @@ export function compareTerminalDocumentScripts(
|
||||
reason: `length: ${before.length - left} token(s) left in the baseline, ${after.length - right} in the generated script`
|
||||
}
|
||||
}
|
||||
if (openInsertedBraces !== 0) {
|
||||
return { equivalent: false, reason: `${openInsertedBraces} inserted brace(s) never closed` }
|
||||
if (insertedBraceCloses.length !== 0) {
|
||||
return {
|
||||
equivalent: false,
|
||||
reason: `${insertedBraceCloses.length} inserted brace(s) never closed`
|
||||
}
|
||||
}
|
||||
return {
|
||||
equivalent: true,
|
||||
|
||||
@@ -144,6 +144,45 @@ describe('terminal document script equivalence', () => {
|
||||
).toEqual({ ...NONE, scopeFieldDeclarations: 1, unshadowedNames: 2 })
|
||||
})
|
||||
|
||||
it('refuses a numeric-suffix rename that is not a listed unshadowed binding', () => {
|
||||
// The shape `value2` -> `value` is what the printer does to a shadow, but this pair is not one
|
||||
// of the document's, so it is a renamed local: a changed program, not a normalisation.
|
||||
expect(
|
||||
normalisationsOf('function f() { return value2; }', 'function f() { return value; }')
|
||||
).toBe('token 6: expected name value2, generated name value')
|
||||
})
|
||||
|
||||
it('refuses a bare block the baseline does not have', () => {
|
||||
// A block that is nobody's body cannot be the `curly` rule's work, so absorbing it would hide
|
||||
// a statement boundary the baseline never had.
|
||||
expect(normalisationsOf('let value = 1; use(value);', '{ let value = 1; } use(value);')).toBe(
|
||||
'token 0: expected name let, generated {'
|
||||
)
|
||||
})
|
||||
|
||||
it('counts a braced body only for a head that can carry an unbraced one', () => {
|
||||
expect(normalisationsOf('if (a) b();', 'if (a) { b(); }')).toEqual({
|
||||
...NONE,
|
||||
bracedBodies: 1
|
||||
})
|
||||
expect(normalisationsOf('for (;;) b();', 'for (;;) { b(); }')).toEqual({
|
||||
...NONE,
|
||||
bracedBodies: 1
|
||||
})
|
||||
expect(normalisationsOf('while (a) b();', 'while (a) { b(); }')).toEqual({
|
||||
...NONE,
|
||||
bracedBodies: 1
|
||||
})
|
||||
expect(normalisationsOf('if (a) b(); else c();', 'if (a) { b(); } else { c(); }')).toEqual({
|
||||
...NONE,
|
||||
bracedBodies: 2
|
||||
})
|
||||
expect(normalisationsOf('do b(); while (a);', 'do { b(); } while (a);')).toEqual({
|
||||
...NONE,
|
||||
bracedBodies: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a changed literal', () => {
|
||||
expect(normalisationsOf('var a = 1;', 'var a = 2')).toBe(
|
||||
'token 3: expected num 1, generated num 2'
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { tokenizer } from 'acorn'
|
||||
import { transformSync } from 'esbuild'
|
||||
|
||||
/**
|
||||
* Reading a version of the in-WebView document script as a token stream.
|
||||
*
|
||||
* Kept apart from the comparison that consumes it: this side answers what the script says, and
|
||||
* says nothing about which differences between two of them are allowed.
|
||||
*/
|
||||
/** One token as this comparison reads it: what kind it is, and the text it carried. */
|
||||
export type DocumentToken = { readonly label: string; readonly text: string }
|
||||
|
||||
/**
|
||||
* Acorn's `Token` class declares `type`, `start` and `end` and not `value`, which it does carry,
|
||||
* so the field is read through a narrowing check rather than asserted onto the declared type.
|
||||
*/
|
||||
function readDocumentToken(token: unknown): DocumentToken | null {
|
||||
if (typeof token !== 'object' || token === null || !('type' in token) || !('value' in token)) {
|
||||
return null
|
||||
}
|
||||
const type: unknown = token.type
|
||||
if (typeof type !== 'object' || type === null || !('label' in type)) {
|
||||
return null
|
||||
}
|
||||
const label: unknown = type.label
|
||||
if (typeof label !== 'string') {
|
||||
return null
|
||||
}
|
||||
const value: unknown = token.value
|
||||
return { label, text: value === undefined || value === null ? '' : String(value) }
|
||||
}
|
||||
|
||||
/** The directive prepended to both sides, and checked to have survived printing. */
|
||||
const STRICT_DIRECTIVE = 'use strict'
|
||||
|
||||
/**
|
||||
* Both sides are printed by the generator's own printer before being read.
|
||||
*
|
||||
* Otherwise every choice the printer makes — semicolons, property shorthand, quote style — reads as
|
||||
* a difference in the program, when it is a difference in who typed it. Printing both sides with
|
||||
* one printer removes that whole class by construction rather than by a rule per symptom, and
|
||||
* leaves only what the eight counted classes cover.
|
||||
*/
|
||||
function significantTokens(source: string): DocumentToken[] {
|
||||
// Read strict on both sides. A loose script has to defend Annex B's block-scoped function
|
||||
// declarations, and the printer does that by hoisting a `var` and renaming the function; a module
|
||||
// does not, so one side would carry a rename the other cannot. Neither name escapes its block, so
|
||||
// the two readings agree on behaviour and only the strict one can be compared.
|
||||
const printed = transformSync(`'${STRICT_DIRECTIVE}';\n${source}`, {
|
||||
loader: 'js',
|
||||
target: 'chrome74',
|
||||
minify: false
|
||||
}).code
|
||||
const kept: DocumentToken[] = []
|
||||
for (const raw of tokenizer(printed, { ecmaVersion: 2020 })) {
|
||||
const token = readDocumentToken(raw)
|
||||
if (token === null) {
|
||||
throw new Error('acorn produced a token this comparison cannot read')
|
||||
}
|
||||
if (token.label === ';' || token.label === 'eof') {
|
||||
continue
|
||||
}
|
||||
kept.push(token)
|
||||
}
|
||||
if (kept[0]?.text !== STRICT_DIRECTIVE) {
|
||||
throw new Error('the strict directive this comparison prepends did not survive printing')
|
||||
}
|
||||
return kept.slice(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* The tokens of one side, or the reason it could not be read.
|
||||
*
|
||||
* A script that does not parse is a refusal with the printer's own message rather than an
|
||||
* exception out of the comparison: a generator that emitted something broken should say so where
|
||||
* the other differences are reported.
|
||||
*/
|
||||
export function readScriptTokens(
|
||||
source: string,
|
||||
side: string
|
||||
): { ok: true; tokens: DocumentToken[] } | { ok: false; reason: string } {
|
||||
try {
|
||||
return { ok: true, tokens: significantTokens(source) }
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `${side} does not parse: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function describeToken(token: DocumentToken | undefined): string {
|
||||
return token === undefined ? '(end of script)' : `${token.label} ${token.text}`.trim()
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { terminalBackgroundFallback } from './document-constants'
|
||||
import { scope } from './document-scope'
|
||||
import { scope, type TerminalDocumentTheme } from './document-scope'
|
||||
|
||||
/** A terminal colour with no alpha: what the contrast maths works on. */
|
||||
export type TerminalDocumentRgb = { r: number; g: number; b: number }
|
||||
@@ -152,6 +152,14 @@ export function normalizeTerminalTheme(input: TerminalDocumentThemeMessage) {
|
||||
return Object.assign({}, scope.defaultTheme, next)
|
||||
}
|
||||
|
||||
/**
|
||||
* What `applyTerminalTheme` writes through. Both slots are written, so a target may arrive without
|
||||
* a theme; nothing else on the terminal is touched.
|
||||
*/
|
||||
export type TerminalDocumentThemeTarget = {
|
||||
options: { theme?: TerminalDocumentTheme; minimumContrastRatio: number }
|
||||
}
|
||||
|
||||
export function applyTerminalTheme(input: TerminalDocumentThemeMessage) {
|
||||
scope.terminalThemeInput = input
|
||||
scope.terminalTheme = normalizeTerminalTheme(input)
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { emitTerminalDocumentModule } from '../../../scripts/build-terminal-document-script.mjs'
|
||||
import { URL_TAP_WEBVIEW_JS } from '../terminal-webview-url-tap'
|
||||
import { compareTerminalDocumentScripts } from './terminal-document-equivalence.test-support'
|
||||
|
||||
/**
|
||||
* The URL-tap group is three modules, not one: at 303 lines it was over the file cap, and the
|
||||
* document's own order interleaves the OSC 8 lookup with the file-URL parsing. The split follows
|
||||
* that order, so the group's text is the three emissions joined.
|
||||
*/
|
||||
const modulePaths = ['./url-tap.ts', './osc-link-tap.ts', './surface-tap.ts'].map((relative) =>
|
||||
fileURLToPath(new URL(relative, import.meta.url))
|
||||
)
|
||||
|
||||
describe('the url-tap group', () => {
|
||||
it('emits the script the document carries, modulo the five normalisations', async () => {
|
||||
const emitted = (await Promise.all(modulePaths.map(emitTerminalDocumentModule))).join('\n')
|
||||
expect(compareTerminalDocumentScripts(URL_TAP_WEBVIEW_JS, emitted, 'scope')).toEqual({
|
||||
equivalent: true,
|
||||
normalisations: {
|
||||
// The terminal twice through its internals, and the captured OSC 8 links with their row
|
||||
// offset; the two patterns and the length bound are build-time constants, not state.
|
||||
qualifiedReferences: 10,
|
||||
scopeFieldDeclarations: 0,
|
||||
rebindings: 41,
|
||||
bracedBodies: 25,
|
||||
// Every read of xterm's internals, the two URL parses and the text capture.
|
||||
unboundCatches: 6,
|
||||
// All four take a digit run a capture group already matched.
|
||||
numberProperties: 4,
|
||||
shorthandProperties: 0,
|
||||
unshadowedNames: 0
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,32 +0,0 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
|
||||
const COMPOSER_FILE = './terminal-webview-html.ts'
|
||||
const DOCUMENT_DIRECTORY = './document/'
|
||||
/** The parts of the document that are still markup rather than program. */
|
||||
const SHELL_FILES = [
|
||||
'./terminal-webview-html/document-shell.ts',
|
||||
'./terminal-webview-html/document-close.ts',
|
||||
'./terminal-webview-html/theme.ts'
|
||||
]
|
||||
|
||||
function readSource(relativePath: string): string {
|
||||
return readFileSync(new URL(relativePath, import.meta.url), 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the TypeScript source the in-WebView document is built from.
|
||||
*
|
||||
* Why a directory and not a list: the document's script is generated from every module under
|
||||
* `document/`, so a new one cannot join the emitted document while staying invisible to the tests
|
||||
* that search this source.
|
||||
*/
|
||||
export function readTerminalWebViewHtmlSource(): string {
|
||||
const modules = readdirSync(new URL(DOCUMENT_DIRECTORY, import.meta.url))
|
||||
.filter((name) => name.endsWith('.ts') && !name.endsWith('.test.ts'))
|
||||
.sort()
|
||||
.map((name) => readSource(DOCUMENT_DIRECTORY + name))
|
||||
if (modules.length < 30) {
|
||||
throw new Error(`expected the document's modules, found ${modules.length}`)
|
||||
}
|
||||
return [readSource(COMPOSER_FILE), ...SHELL_FILES.map(readSource), ...modules].join('\n')
|
||||
}
|
||||
@@ -59,19 +59,15 @@ describe('terminal WebView reflow', () => {
|
||||
expect(htmlSource).toContain('notify({ type: "measure-result", cols: null, rows: null });')
|
||||
})
|
||||
|
||||
// Why: the raw-source assertions above pass even if the reflow module is
|
||||
// dropped from the XTERM_HTML concatenation (a broken/removed import or an
|
||||
// emptied TERMINAL_REFLOW_JS leaves the `${...}` placeholder in the template
|
||||
// but never injects the routine). That was the regression class reported when
|
||||
// a sibling refactor extracted the tap dispatcher next to the reflow inject.
|
||||
// Guard the *assembled* document so the routine and its dispatch are really
|
||||
// present in what the WebView runs.
|
||||
// Why: the assertions above read the reflow module's own emission, which still reads whole if
|
||||
// the generator drops the module from the document or emits it twice. That was the regression
|
||||
// class reported when a sibling refactor extracted the tap dispatcher next to reflow. Guard the
|
||||
// assembled document so the routine, once, and its dispatch are really in what the WebView runs.
|
||||
describe('assembled XTERM_HTML', () => {
|
||||
it('still injects the reflow routine (placeholder fully expanded)', () => {
|
||||
it('carries the reflow routine exactly once', () => {
|
||||
expect(XTERM_HTML).toContain('function reflow(cols, rows) {')
|
||||
expect(XTERM_HTML).toContain('term.resize(nextCols, nextRows);')
|
||||
// No unexpanded template placeholder for the injected reflow JS.
|
||||
expect(XTERM_HTML).not.toContain('TERMINAL_REFLOW_JS}')
|
||||
expect(XTERM_HTML).toContain('scope.term.resize(nextCols, nextRows);')
|
||||
expect(XTERM_HTML.split(reflowSource).length - 1).toBe(1)
|
||||
})
|
||||
|
||||
it('still routes the reflow message to the injected routine', () => {
|
||||
|
||||
@@ -2,14 +2,13 @@ import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { XTERM_HTML } from './terminal-webview-html'
|
||||
|
||||
// The in-WebView JS lives in terminal-webview-html.ts; the RN wrapper in
|
||||
// TerminalWebView.tsx. Concatenate both so assertions resolve regardless of file.
|
||||
// The RN wrapper and the pending-message queue are TypeScript; everything the WebView runs is the
|
||||
// generated document. Concatenated so assertions resolve regardless of file.
|
||||
const source =
|
||||
readFileSync(new URL('./TerminalWebView.tsx', import.meta.url), 'utf8') +
|
||||
readFileSync(new URL('./use-terminal-webview-controller.ts', import.meta.url), 'utf8') +
|
||||
readFileSync(new URL('./terminal-webview-ready-promises.ts', import.meta.url), 'utf8') +
|
||||
readFileSync(new URL('./terminal-webview-pending-messages.ts', import.meta.url), 'utf8') +
|
||||
readFileSync(new URL('./terminal-webview-url-tap.ts', import.meta.url), 'utf8') +
|
||||
XTERM_HTML
|
||||
const sessionSource = readFileSync(
|
||||
new URL('../session/use-mobile-session-terminal-input.ts', import.meta.url),
|
||||
@@ -246,7 +245,7 @@ describe('TerminalWebView scroll routing', () => {
|
||||
expect(tapHandlerBlock).toContain(
|
||||
'if (focusKeyboard || !isClickMouseTrackingMode(getMouseTrackingMode()))'
|
||||
)
|
||||
expect(tapHandlerBlock).toContain("notify({ type: 'terminal-tap' });")
|
||||
expect(tapHandlerBlock).toContain('notify({ type: "terminal-tap" });')
|
||||
})
|
||||
|
||||
it('allows x10 mouse gesture reports through the mobile session gate', () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
documentScopePreamble,
|
||||
generatedDocumentModule
|
||||
} from './document/generated-document-region.test-support'
|
||||
import type { TerminalDocumentThemeTarget } from './document/terminal-theme'
|
||||
|
||||
const themeSource = await generatedDocumentModule('terminal-theme')
|
||||
|
||||
@@ -31,7 +32,7 @@ function loadContrastFloorResolver(): (bg: unknown) => number {
|
||||
return documentDeclaredFunction(loadThemeInjected(), 'resolveTerminalContrastFloor')
|
||||
}
|
||||
|
||||
function loadThemeApplier(term: object): (input: unknown) => void {
|
||||
function loadThemeApplier(term: TerminalDocumentThemeTarget): (input: unknown) => void {
|
||||
const context = loadThemeInjected({
|
||||
term,
|
||||
document: {
|
||||
@@ -78,9 +79,7 @@ describe('mobile terminal-webview contrast floor gate', () => {
|
||||
})
|
||||
|
||||
it('writes the resolved floor onto a live terminal when the theme changes', () => {
|
||||
const term: { options: { theme?: unknown; minimumContrastRatio: number } } = {
|
||||
options: { minimumContrastRatio: 1 }
|
||||
}
|
||||
const term: TerminalDocumentThemeTarget = { options: { minimumContrastRatio: 1 } }
|
||||
const applyTerminalTheme = loadThemeApplier(term)
|
||||
|
||||
applyTerminalTheme({ theme: { background: '#ffffff' } })
|
||||
@@ -93,7 +92,7 @@ describe('mobile terminal-webview contrast floor gate', () => {
|
||||
// #10754: the desktop user can lower or disable the floor. Mobile mirrors the desktop gate, so the
|
||||
// published value has to win here or the same session renders differently on the phone.
|
||||
describe('published desktop override', () => {
|
||||
function applyOn(term: { options: { minimumContrastRatio: number } }, input: unknown): void {
|
||||
function applyOn(term: TerminalDocumentThemeTarget, input: unknown): void {
|
||||
loadThemeApplier(term)(input)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { createContext, Script } from 'node:vm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TappedFilePath } from './terminal-path-tap'
|
||||
import { generatedDocumentModule } from './document/generated-document-region.test-support'
|
||||
import {
|
||||
documentDeclaredFunction,
|
||||
generatedDocumentModule
|
||||
} from './document/generated-document-region.test-support'
|
||||
import {
|
||||
TERMINAL_HTTP_URL_MAX_LENGTH,
|
||||
TERMINAL_HTTP_URL_REGEX_SOURCE,
|
||||
URL_TAP_WEBVIEW_JS,
|
||||
findFileUrlAtColumn,
|
||||
findUrlAtColumn,
|
||||
resolveTerminalOscFileTap,
|
||||
@@ -13,7 +15,12 @@ import {
|
||||
} from './terminal-webview-url-tap'
|
||||
import { XTERM_HTML } from './terminal-webview-html'
|
||||
|
||||
const pathTapSource = await generatedDocumentModule('path-tap')
|
||||
// The three modules the document carries the URL-tap group as, in its own order.
|
||||
const urlTapGroupSource = (
|
||||
await Promise.all(
|
||||
['path-tap', 'url-tap', 'osc-link-tap', 'surface-tap'].map(generatedDocumentModule)
|
||||
)
|
||||
).join('\n')
|
||||
|
||||
type FileTapResolverCase = {
|
||||
name: string
|
||||
@@ -101,19 +108,15 @@ function createInjectedFileTapResolvers(): {
|
||||
resolveTerminalFileUrlTap: InjectedFileTapResolver
|
||||
resolveTerminalOscFileTap: InjectedFileTapResolver
|
||||
} {
|
||||
const context = createContext({ URL })
|
||||
const context: Record<string, unknown> = createContext({ URL })
|
||||
new Script(
|
||||
`${pathTapSource}\n${URL_TAP_WEBVIEW_JS}\n` +
|
||||
`${urlTapGroupSource}\n` +
|
||||
'this.__resolveTerminalFileUrlTap = resolveTerminalFileUrlTap;\n' +
|
||||
'this.__resolveTerminalOscFileTap = resolveTerminalOscFileTap;'
|
||||
).runInContext(context)
|
||||
const injected = context as {
|
||||
__resolveTerminalFileUrlTap: InjectedFileTapResolver
|
||||
__resolveTerminalOscFileTap: InjectedFileTapResolver
|
||||
}
|
||||
return {
|
||||
resolveTerminalFileUrlTap: injected.__resolveTerminalFileUrlTap,
|
||||
resolveTerminalOscFileTap: injected.__resolveTerminalOscFileTap
|
||||
resolveTerminalFileUrlTap: documentDeclaredFunction(context, '__resolveTerminalFileUrlTap'),
|
||||
resolveTerminalOscFileTap: documentDeclaredFunction(context, '__resolveTerminalOscFileTap')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,212 +43,3 @@ function findTerminalUrlAtColumn(lineText: string, col: number, source: string):
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const URL_TAP_WEBVIEW_JS = `
|
||||
var URL_TAP_RE_SOURCE = ${JSON.stringify(TERMINAL_HTTP_URL_REGEX_SOURCE)};
|
||||
var FILE_URL_TAP_RE_SOURCE = ${JSON.stringify(TERMINAL_FILE_URL_REGEX_SOURCE)};
|
||||
var URL_TAP_MAX_LENGTH = ${TERMINAL_HTTP_URL_MAX_LENGTH};
|
||||
function findUrlAtColumn(lineText, col) {
|
||||
return findTerminalUrlAtColumn(lineText, col, URL_TAP_RE_SOURCE);
|
||||
}
|
||||
function findFileUrlAtColumn(lineText, col) {
|
||||
return findTerminalUrlAtColumn(lineText, col, FILE_URL_TAP_RE_SOURCE);
|
||||
}
|
||||
function findTerminalUrlAtColumn(lineText, col, source) {
|
||||
if (typeof lineText !== 'string' || lineText.length === 0) return null;
|
||||
var re = new RegExp(source, 'gi');
|
||||
var match;
|
||||
while ((match = re.exec(lineText)) !== null) {
|
||||
var end = match.index + match[0].length;
|
||||
if (match[0].length <= URL_TAP_MAX_LENGTH && col >= match.index && col < end) return match[0];
|
||||
if (match[0].length === 0) re.lastIndex++;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function fileUrlAtViewportPoint(clientX, clientY) {
|
||||
var cell = viewportToCell(clientX, clientY);
|
||||
if (!cell) return null;
|
||||
return findFileUrlAtColumn(getLineText(cell.row), cellColToStringIndex(cell.row, cell.col));
|
||||
}
|
||||
function urlAtViewportPoint(clientX, clientY) {
|
||||
var cell = viewportToCell(clientX, clientY);
|
||||
if (!cell) return null;
|
||||
// Map the cell column to a string index so wide chars earlier on the line
|
||||
// don't shift the match column off the tapped URL.
|
||||
return findUrlAtColumn(getLineText(cell.row), cellColToStringIndex(cell.row, cell.col));
|
||||
}
|
||||
|
||||
// Why: OSC 8 links can render as labels like "#1234"; the URI lives in
|
||||
// xterm's internal link service, so every access is guarded and falls through.
|
||||
function oscLinkService() {
|
||||
try {
|
||||
var core = term && term._core;
|
||||
if (!core) return null;
|
||||
return core._oscLinkService
|
||||
|| (core._inputHandler && core._inputHandler._oscLinkService)
|
||||
|| null;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
function oscLinkAtViewportPoint(clientX, clientY) {
|
||||
try {
|
||||
var cell = viewportToCell(clientX, clientY);
|
||||
if (!cell) return null;
|
||||
var line = term.buffer.active.getLine(cell.row);
|
||||
if (!line) return null;
|
||||
var urlId = oscLinkIdAtCell(line, cell.col);
|
||||
if (!urlId) return initialOscLinkAtCell(cell.row, cell.col);
|
||||
var svc = oscLinkService();
|
||||
if (!svc || !svc.getLinkData) return initialOscLinkAtCell(cell.row, cell.col);
|
||||
var data = svc.getLinkData(urlId);
|
||||
var uri = data && data.uri;
|
||||
return terminalOscLinkTarget(uri);
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
function initialOscLinkAtCell(row, col) {
|
||||
for (var i = 0; i < initialOscLinks.length; i++) {
|
||||
var link = initialOscLinks[i];
|
||||
if (!link || typeof link.uri !== 'string') continue;
|
||||
if (link.row < initialOscLinkRowOffset) continue;
|
||||
var shiftedRow = link.row - initialOscLinkRowOffset;
|
||||
if (shiftedRow === row && col >= link.startCol && col < link.endCol && initialOscLinkTextStillMatches(link, shiftedRow)) return terminalOscLinkTarget(link.uri);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function terminalOscLinkTarget(uri) {
|
||||
if (typeof uri !== 'string') return null;
|
||||
if (/^https?:/i.test(uri)) return { kind: 'url', url: uri };
|
||||
var fileTap = resolveTerminalOscFileTap(uri);
|
||||
return fileTap ? { kind: 'file', fileTap: fileTap } : null;
|
||||
}
|
||||
function resolveTerminalOscFileTap(uri) {
|
||||
return resolveTerminalFileUrlTap(uri) || parseOscPathLikeTarget(uri);
|
||||
}
|
||||
function resolveTerminalFileUrlTap(uri) {
|
||||
var parsed;
|
||||
try {
|
||||
parsed = new URL(uri);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
if (parsed.protocol !== 'file:') return null;
|
||||
var filePath;
|
||||
try {
|
||||
filePath = decodeURIComponent(parsed.pathname || '');
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
if (parsed.hostname && !isLocalFileUriHostname(parsed.hostname)) {
|
||||
filePath = '//' + parsed.hostname + filePath;
|
||||
} else if (/^\\/[A-Za-z]:\\//.test(filePath)) {
|
||||
filePath = filePath.slice(1);
|
||||
}
|
||||
if (!filePath) return null;
|
||||
var hashTarget = parseFileUrlLineHash(parsed.hash || '');
|
||||
if (hashTarget) {
|
||||
return { pathText: filePath, line: hashTarget.line, column: hashTarget.column };
|
||||
}
|
||||
if (/%3a/i.test(parsed.pathname || '')) {
|
||||
return { pathText: filePath, line: null, column: null };
|
||||
}
|
||||
return parseFilePathTrailingLineTarget(filePath) || { pathText: filePath, line: null, column: null };
|
||||
}
|
||||
function isLocalFileUriHostname(hostname) {
|
||||
var normalized = String(hostname).toLowerCase();
|
||||
return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1' || normalized === '[::1]';
|
||||
}
|
||||
function parseOscPathLikeTarget(value) {
|
||||
if (!/^(?:~[\\\\/]|[\\\\/]|\\.{1,2}[\\\\/]|[A-Za-z]:[\\\\/]|[A-Za-z0-9._-]+[\\\\/]|(?=[A-Za-z0-9._-]*\\.[A-Za-z0-9]))/.test(value)) return null;
|
||||
return parsePathLineCol(value);
|
||||
}
|
||||
function parseFileUrlLineHash(hash) {
|
||||
var match = /^#?L(\\d+)(?:C(\\d+))?$/i.exec(hash);
|
||||
if (!match) return null;
|
||||
var line = parseInt(match[1], 10);
|
||||
var column = match[2] ? parseInt(match[2], 10) : null;
|
||||
if (line < 1 || (column !== null && column < 1)) return null;
|
||||
return { line: line, column: column };
|
||||
}
|
||||
function parseFilePathTrailingLineTarget(filePath) {
|
||||
var match = /^(.*?)(?::(\\d+))(?::(\\d+))?$/.exec(filePath);
|
||||
if (!match || !match[1] || match[1].charAt(match[1].length - 1) === '/' || match[1].charAt(match[1].length - 1) === '\\\\') return null;
|
||||
var line = parseInt(match[2], 10);
|
||||
var column = match[3] ? parseInt(match[3], 10) : null;
|
||||
if (line < 1 || (column !== null && column < 1)) return null;
|
||||
return { pathText: match[1], line: line, column: column };
|
||||
}
|
||||
function captureInitialOscLinkTexts() {
|
||||
if (!Array.isArray(initialOscLinks)) return;
|
||||
for (var i = 0; i < initialOscLinks.length; i++) {
|
||||
var link = initialOscLinks[i];
|
||||
if (!link || typeof link.text === 'string') continue;
|
||||
link.text = initialOscLinkTextAtRow(link, link.row);
|
||||
}
|
||||
}
|
||||
function initialOscLinkTextStillMatches(link, row) {
|
||||
if (typeof link.text !== 'string') return false;
|
||||
return link.text.length > 0 && initialOscLinkTextAtRow(link, row) === link.text;
|
||||
}
|
||||
function initialOscLinkTextAtRow(link, row) {
|
||||
try {
|
||||
var lineText = getLineText(row);
|
||||
var start = cellColToStringIndex(row, link.startCol);
|
||||
var end = cellColToStringIndex(row, link.endCol);
|
||||
return lineText.slice(start, end);
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
function oscLinkIdAtCell(line, col) {
|
||||
try {
|
||||
var bufCell = line.getCell(col);
|
||||
return bufCell && bufCell.extended && bufCell.extended.urlId ? bufCell.extended.urlId : 0;
|
||||
} catch (e) { return 0; }
|
||||
}
|
||||
|
||||
function notifyTerminalSurfaceTap(originX, originY, focusKeyboard) {
|
||||
var tappedOscLink = oscLinkAtViewportPoint(originX, originY);
|
||||
if (tappedOscLink && tappedOscLink.kind === 'file') {
|
||||
notify({
|
||||
type: 'terminal-file-tap',
|
||||
pathText: tappedOscLink.fileTap.pathText,
|
||||
line: tappedOscLink.fileTap.line,
|
||||
column: tappedOscLink.fileTap.column
|
||||
});
|
||||
return;
|
||||
}
|
||||
var tappedFileUrl = fileUrlAtViewportPoint(originX, originY);
|
||||
var tappedFileUrlPath = tappedFileUrl ? resolveTerminalFileUrlTap(tappedFileUrl) : null;
|
||||
if (tappedFileUrlPath) {
|
||||
notify({
|
||||
type: 'terminal-file-tap',
|
||||
pathText: tappedFileUrlPath.pathText,
|
||||
line: tappedFileUrlPath.line,
|
||||
column: tappedFileUrlPath.column
|
||||
});
|
||||
return;
|
||||
}
|
||||
var tappedUrl = tappedOscLink && tappedOscLink.kind === 'url' ? tappedOscLink.url : urlAtViewportPoint(originX, originY);
|
||||
if (tappedUrl) {
|
||||
notify({ type: 'open-url', url: tappedUrl });
|
||||
return;
|
||||
}
|
||||
var tappedPath = filePathAtViewportPoint(originX, originY);
|
||||
if (tappedPath) {
|
||||
notify({
|
||||
type: 'terminal-file-tap',
|
||||
pathText: tappedPath.pathText,
|
||||
line: tappedPath.line,
|
||||
column: tappedPath.column
|
||||
});
|
||||
return;
|
||||
}
|
||||
var clickInput = buildMouseClickInput(originX, originY);
|
||||
if (clickInput) {
|
||||
notify({ type: 'terminal-input', bytes: clickInput });
|
||||
}
|
||||
// Touch still needs native input focus after the TUI consumes its mouse click.
|
||||
if (focusKeyboard || !isClickMouseTrackingMode(getMouseTrackingMode())) {
|
||||
notify({ type: 'terminal-tap' });
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user