diff --git a/src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.test.ts b/src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.test.ts index 910b01ad031..f61f7e090f5 100644 --- a/src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.test.ts +++ b/src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.test.ts @@ -98,6 +98,15 @@ describe('edge-wrapped terminal HTTP links', () => { } ) + it('stops scanning back at a prose row that cannot continue a URL', () => { + const prose = makeBufferLine('see https://example.com and more words') + const hovered = makeBufferLine('tail') + const getLine = vi.fn((y: number) => (y === 199 ? hovered : prose)) + + buildEdgeWrappedHttpLogicalLineCandidates({ getLine }, 200) + expect(new Set(getLine.mock.calls.map(([y]) => y)).size).toBeLessThan(5) + }) + it('opens a URL when a wide glyph starts the continuation row', () => { const prefix = 'https://example.com/wide/' const firstRow = `${prefix}${'c'.repeat(COLS - 1 - prefix.length)}` diff --git a/src/shared/edge-wrapped-terminal-http-links.ts b/src/shared/edge-wrapped-terminal-http-links.ts index 3b946e893c1..45a0cdeb774 100644 --- a/src/shared/edge-wrapped-terminal-http-links.ts +++ b/src/shared/edge-wrapped-terminal-http-links.ts @@ -8,6 +8,8 @@ const HTTP_STATUS_ROW_PATTERN = /^HTTP\/\d(?:\.\d)?:\d{3}(?:\s|$)/i const VERTICAL_LAYOUT_FRAME_PATTERN = /[│┃║╎╏┆┇┊┋|]/ const EMPTY_LABEL_ROW_PATTERN = /^[^\s:][^:]*:$/ const LABEL_WITH_SPACING_ROW_PATTERN = /^[^\s:][^:]*:\s/ +const NON_BLANK_ROW_PATTERN = /\S/ +const SINGLE_TOKEN_ROW_PATTERN = /^ *\S+\s*$/ function maxEdgeWrappedHttpRows(lineLength: number, padding: number): number { // Width-2 cells give at least floor(columns / 2) characters per continued row; add two fragment rows to bound scanning by URL length. @@ -147,8 +149,21 @@ export function buildEdgeWrappedHttpLogicalLineCandidates( return translated } const minY = Math.max(0, currentY - maxRows + 1) + let laterRowText: string | undefined for (let startY = currentY; startY >= minY; startY--) { + // Earlier starts must continue through the later row: frame-free, and one token unless it is the hovered row. + if ( + startY < currentY && + (!laterRowText || + VERTICAL_LAYOUT_FRAME_PATTERN.test(laterRowText) || + !(startY + 1 === currentY ? NON_BLANK_ROW_PATTERN : SINGLE_TOKEN_ROW_PATTERN).test( + laterRowText + )) + ) { + break + } const startText = buffer.getLine(startY)?.translateToString(false) + laterRowText = startText const schemeIndex = startText ? findHttpSchemeIndex(startText) : -1 if (!startText || schemeIndex === -1 || VERTICAL_LAYOUT_FRAME_PATTERN.test(startText)) { continue diff --git a/src/shared/hard-wrapped-terminal-http-links.test.ts b/src/shared/hard-wrapped-terminal-http-links.test.ts index 8c82d2652e0..691843c8fe0 100644 --- a/src/shared/hard-wrapped-terminal-http-links.test.ts +++ b/src/shared/hard-wrapped-terminal-http-links.test.ts @@ -81,6 +81,19 @@ describe('hard-wrapped terminal HTTP candidate bounds', () => { expect(onTranslateWithColumns).not.toHaveBeenCalled() }) + it('stops scanning back at a table border instead of walking every framed row', () => { + const rows = Array.from({ length: TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS }, (_value, index) => + bufferLine( + index % 10 === 0 ? '├──────────────────────┤' : `│ https://example.com/${index} │`, + () => undefined + ) + ) + const getLine = vi.fn((y: number) => rows[y]) + + buildHardWrappedHttpLogicalLineCandidates({ getLine }, TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS) + expect(new Set(getLine.mock.calls.map(([y]) => y)).size).toBeLessThan(20) + }) + it('reconstructs framed URL rows aligned by cells when a fragment has wide characters', () => { const rowTexts = ['|http://a/ab|', '|a你你你你你|', '|tailzzzzzzz|'] const rows = rowTexts.map(bufferLineWithCellColumns) diff --git a/src/shared/hard-wrapped-terminal-http-links.ts b/src/shared/hard-wrapped-terminal-http-links.ts index 441aeeb7ab1..bf0d13c2de9 100644 --- a/src/shared/hard-wrapped-terminal-http-links.ts +++ b/src/shared/hard-wrapped-terminal-http-links.ts @@ -11,11 +11,28 @@ const HTTP_FRAGMENT_PATTERN = /^[^\s"'!*(){}|\\^<>`│┃║╎╏┆┇┊┋]* const VERTICAL_LAYOUT_FRAME_PATTERN = /[│┃║╎╏┆┇┊┋|]/ const NON_LAYOUT_SUFFIX_PATTERN = /[^\s│┃║╎╏┆┇┊┋|]/ const HARD_WRAP_CONTINUATION_SUFFIX_PATTERN = /[/?&=#%+:-]$/ +const LAYOUT_SUFFIX_CHAR_PATTERN = /[\s│┃║╎╏┆┇┊┋|]/ const MIN_HARD_WRAPPED_HTTP_ROWS = 3 const MIN_HARD_WRAP_FILL_RATIO = 0.8 type TranslatedLine = ReturnType +// Continuation rows end in frame + prefix + URL fragment + layout-only suffix with a right frame. +function canBeFramedContinuationRow(text: string): boolean { + let contentEnd = text.length + let hasRightFrame = false + while (contentEnd > 0 && LAYOUT_SUFFIX_CHAR_PATTERN.test(text[contentEnd - 1]!)) { + hasRightFrame ||= VERTICAL_LAYOUT_FRAME_PATTERN.test(text[contentEnd - 1]!) + contentEnd-- + } + return ( + hasRightFrame && + contentEnd > 0 && + Boolean(HTTP_FRAGMENT_PATTERN.exec(text[contentEnd - 1]!)?.[0]) && + VERTICAL_LAYOUT_FRAME_PATTERN.test(text.slice(0, contentEnd)) + ) +} + function findFrameIndexBefore(text: string, beforeIndex: number): number { for (let index = beforeIndex - 1; index >= 0; index--) { if (VERTICAL_LAYOUT_FRAME_PATTERN.test(text[index]!)) { @@ -189,6 +206,12 @@ export function buildHardWrappedHttpLogicalLineCandidates( if (candidate) { candidates.push(candidate) } + // Earlier starts must continue through this row; stop at borders, prose, and blank rows. + const rowText = + translatedLines.get(startY)?.text ?? buffer.getLine(startY)?.translateToString(false) + if (rowText === undefined || !canBeFramedContinuationRow(rowText)) { + break + } } return candidates.sort( (left, right) => right.rows.length - left.rows.length || right.text.length - left.text.length