diff --git a/src/main/daemon/osc7-file-uri-sender-formats.test.ts b/src/main/daemon/osc7-file-uri-sender-formats.test.ts index afd16a5d1b6..8d396d40eca 100644 --- a/src/main/daemon/osc7-file-uri-sender-formats.test.ts +++ b/src/main/daemon/osc7-file-uri-sender-formats.test.ts @@ -4,7 +4,11 @@ import { parseFileUriPathParts, type ParseFileUriPathOptions } from './osc7-file-uri' -import { OSC7_PARSER_AGREEMENT_CASES } from '../../shared/osc7-parser-agreement-cases' +import { + OSC7_KEEPS_FOLDER, + OSC7_PARSER_AGREEMENT_CASES +} from '../../shared/osc7-parser-agreement-cases' +import { TerminalOscCwdTitleScanner } from './terminal-osc-cwd-title-scanner' const FLAVORS: Record = { posix: { pathFlavor: 'posix' }, @@ -291,7 +295,19 @@ describe('OSC 7 file URIs from nushell', () => { }) describe('OSC 7 file URIs both parsers must agree on (daemon side)', () => { - it.each(OSC7_PARSER_AGREEMENT_CASES)('%s', (_case, uri, path) => { - expect(parseFileUriPath(uri, { pathFlavor: 'posix' })).toBe(path) + it.each(OSC7_PARSER_AGREEMENT_CASES)('$name, as a payload', ({ uri, folder }) => { + expect(parseFileUriPath(uri, { pathFlavor: 'posix' })).toBe(folder) }) + + // Real traffic: the daemon tracks the pane's folder from raw PTY bytes through this scanner. + it.each(OSC7_PARSER_AGREEMENT_CASES)( + '$name, through the PTY scanner', + ({ uri, folder, viaScanner }) => { + const scanner = new TerminalOscCwdTitleScanner({ pathFlavor: 'posix' }) + scanner.scan('\x1b]7;file:///before\x07') + scanner.scan(`\x1b]7;${uri}\x07`) + const kept = viaScanner === OSC7_KEEPS_FOLDER || folder === null + expect(scanner.cwd).toBe(kept ? '/before' : folder) + } + ) }) diff --git a/src/renderer/src/components/terminal-pane/parse-osc7.test.ts b/src/renderer/src/components/terminal-pane/parse-osc7.test.ts index 5d14041c02c..36733afcb0e 100644 --- a/src/renderer/src/components/terminal-pane/parse-osc7.test.ts +++ b/src/renderer/src/components/terminal-pane/parse-osc7.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest' +import { Terminal } from '@xterm/headless' import { OSC7_PARSER_AGREEMENT_CASES } from '../../../../shared/osc7-parser-agreement-cases' import { parseOsc7 } from './parse-osc7' @@ -68,7 +69,29 @@ describe('parseOsc7', () => { }) describe('OSC 7 payloads both parsers must agree on (renderer side)', () => { - it.each(OSC7_PARSER_AGREEMENT_CASES)('%s', (_case, uri, path) => { - expect(parseOsc7(uri)).toBe(path) + it.each(OSC7_PARSER_AGREEMENT_CASES)('$name, as a payload', ({ uri, folder }) => { + expect(parseOsc7(uri)).toBe(folder) }) + + // Real traffic: the renderer only sees what xterm's OSC parser delivers. + it.each(OSC7_PARSER_AGREEMENT_CASES)( + '$name, through xterm', + async ({ uri, folder, viaXterm, xtermCancels }) => { + const term = new Terminal({ allowProposedApi: true }) + const payloads: string[] = [] + term.parser.registerOscHandler(7, (data) => { + payloads.push(data) + return true + }) + await new Promise((resolve) => term.write(`\x1b]7;${uri}\x07`, resolve)) + term.dispose() + if (xtermCancels) { + expect(payloads).toEqual([]) + return + } + expect(payloads.map((data) => parseOsc7(data))).toEqual([ + viaXterm === undefined ? folder : viaXterm + ]) + } + ) }) diff --git a/src/shared/osc7-parser-agreement-cases.ts b/src/shared/osc7-parser-agreement-cases.ts index bdf6f30b202..9d7ddfef2ea 100644 --- a/src/shared/osc7-parser-agreement-cases.ts +++ b/src/shared/osc7-parser-agreement-cases.ts @@ -1,17 +1,69 @@ -// One report must name one folder in both OSC 7 parsers: the daemon's (checkpoints, restores) and -// the renderer's (split, new terminal). Each side's tests run these cases, including what nushell -// sends raw. -export const OSC7_PARSER_AGREEMENT_CASES: readonly [string, string, string | null][] = [ - ['a raw tab', 'file://Desktop/tmp/a\tb', '/tmp/a\tb'], - ['a raw newline', 'file://Desktop/tmp/a\nb', '/tmp/a\nb'], - ['a raw carriage return', 'file://Desktop/tmp/a\rb', '/tmp/a\rb'], - ['a trailing space', 'file://Desktop/tmp/a ', '/tmp/a '], - ['a space inside', 'file://Desktop/tmp/a b', '/tmp/a b'], - ['a lone %', 'file://Desktop/tmp/100%', '/tmp/100%'], - ['a # and ?', 'file://Desktop/tmp/h#a?b', '/tmp/h#a?b'], - ['an encoded newline', 'file:///tmp/a%0Ab', '/tmp/a\nb'], - ['a leading raw NUL', 'file:///\u0000tmp', null], - ['a trailing raw NUL', 'file:///tmp/a\u0000', null], - ['an encoded NUL', 'file:///tmp/%00', null], - ['non-UTF-8 hex', 'file:///tmp/%FF', null] +// One report must name one folder in both OSC 7 parsers: the daemon's (checkpoints, restores) reads +// raw PTY bytes; the renderer's (split, new terminal) reads what xterm's OSC parser hands it. Each +// side's tests run these cases, including what nushell sends raw. +/** No report reached the parser, or it was refused: the pane keeps the folder it had. */ +export const OSC7_KEEPS_FOLDER = 'keeps-folder' + +export type Osc7AgreementCase = { + name: string + /** The report as the shell writes it, between `ESC ]7;` and BEL. */ + uri: string + /** What both parsers name for this payload, or null when they refuse it. */ + folder: string | null + /** + * Known renderer gap: xterm's OSC parser drops raw C0 bytes, ends the sequence at a raw ESC and + * cancels it at CAN or SUB, before the renderer sees the payload. + */ + viaXterm?: string | null + /** xterm cancels the sequence (CAN, SUB), so the renderer's OSC 7 handler never runs. */ + xtermCancels?: true + /** + * The daemon's PTY scanner, when it differs from `folder`: a raw ESC that is not ST invalidates + * the report. + */ + viaScanner?: typeof OSC7_KEEPS_FOLDER +} + +export const OSC7_PARSER_AGREEMENT_CASES: readonly Osc7AgreementCase[] = [ + { name: 'a raw tab', uri: 'file://Desktop/tmp/a\tb', folder: '/tmp/a\tb', viaXterm: '/tmp/ab' }, + { + name: 'a raw newline', + uri: 'file://Desktop/tmp/a\nb', + folder: '/tmp/a\nb', + viaXterm: '/tmp/ab' + }, + { + name: 'a raw carriage return', + uri: 'file://Desktop/tmp/a\rb', + folder: '/tmp/a\rb', + viaXterm: '/tmp/ab' + }, + { name: 'a trailing space', uri: 'file://Desktop/tmp/a ', folder: '/tmp/a ' }, + { name: 'a space inside', uri: 'file://Desktop/tmp/a b', folder: '/tmp/a b' }, + { name: 'a lone %', uri: 'file://Desktop/tmp/100%', folder: '/tmp/100%' }, + { name: 'a # and ?', uri: 'file://Desktop/tmp/h#a?b', folder: '/tmp/h#a?b' }, + { name: 'an encoded newline', uri: 'file:///tmp/a%0Ab', folder: '/tmp/a\nb' }, + { name: 'a leading raw NUL', uri: 'file:///\u0000tmp', folder: null, viaXterm: '/tmp' }, + { name: 'a trailing raw NUL', uri: 'file:///tmp/a\u0000', folder: null, viaXterm: '/tmp/a' }, + { name: 'an encoded NUL', uri: 'file:///tmp/%00', folder: null }, + { name: 'non-UTF-8 hex', uri: 'file:///tmp/%FF', folder: null }, + { + name: 'a raw ESC', + uri: 'file:///tmp/a\u001bb', + folder: '/tmp/a\u001bb', + viaXterm: '/tmp/a', + viaScanner: OSC7_KEEPS_FOLDER + }, + { + name: 'a raw CAN', + uri: 'file:///tmp/a\u0018b', + folder: '/tmp/a\u0018b', + xtermCancels: true + }, + { + name: 'a raw SUB', + uri: 'file:///tmp/a\u001ab', + folder: '/tmp/a\u001ab', + xtermCancels: true + } ]