diff --git a/mobile/src/terminal/document/url-tap.test.ts b/mobile/src/terminal/document/url-tap.test.ts deleted file mode 100644 index 8b7f69e7a47..00000000000 --- a/mobile/src/terminal/document/url-tap.test.ts +++ /dev/null @@ -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 - } - }) - }) -}) diff --git a/mobile/src/terminal/terminal-webview-reflow.test.ts b/mobile/src/terminal/terminal-webview-reflow.test.ts index 17428d54e47..be24fbb3819 100644 --- a/mobile/src/terminal/terminal-webview-reflow.test.ts +++ b/mobile/src/terminal/terminal-webview-reflow.test.ts @@ -55,19 +55,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', () => { diff --git a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts index 527bf79b43f..53d580cf196 100644 --- a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts +++ b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts @@ -2,12 +2,11 @@ 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('./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), @@ -242,7 +241,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', () => { diff --git a/mobile/src/terminal/terminal-webview-url-tap.test.ts b/mobile/src/terminal/terminal-webview-url-tap.test.ts index bac5d1c9cbf..edb6523993f 100644 --- a/mobile/src/terminal/terminal-webview-url-tap.test.ts +++ b/mobile/src/terminal/terminal-webview-url-tap.test.ts @@ -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 = 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') } } diff --git a/mobile/src/terminal/terminal-webview-url-tap.ts b/mobile/src/terminal/terminal-webview-url-tap.ts index f5d416d5797..c63cf8d48d6 100644 --- a/mobile/src/terminal/terminal-webview-url-tap.ts +++ b/mobile/src/terminal/terminal-webview-url-tap.ts @@ -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' }); - } - } -`