Fix hard-wrapped terminal file links (#8100)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
f9c6c26018
commit
fa4e081362
|
|
@ -0,0 +1,115 @@
|
|||
# Wrapped Terminal File-Link Fragments
|
||||
|
||||
## Problem
|
||||
|
||||
A file path hard-wrapped between terminal rows is not clickable when the continuation row also contains sibling content. In the reported three-link line, the middle path ends the first row and continues at the start of the second, while the first and third paths remain clickable.
|
||||
|
||||
The provider builds hard-wrap candidates in `wrapped-terminal-link-ranges.ts:172-224`, and both hover and direct modifier-click consume them through `terminal-link-handlers.ts:106-135` and `terminal-file-link-hit-testing.ts:100-109`.
|
||||
|
||||
## Root cause
|
||||
|
||||
`buildHardWrappedPathLogicalLineCandidates` trims and joins whole physical rows. A continuation row is accepted only when the entire trimmed row is a path fragment (`wrapped-terminal-link-ranges.ts:195-203`). A row such as `transparent-...png · validation-screenshots/03-after-light-theme.png` therefore stops reconstruction. Orca probes the two incomplete middle fragments separately, rejects both as nonexistent, and retains only the complete first and third paths.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Changing file-path parsing, filesystem existence semantics, tooltip copy, or open routing.
|
||||
- Joining arbitrary prose, spaced paths, or multiple sibling links into one path.
|
||||
- Relying on xterm soft-wrap metadata for output that was hard-wrapped by an agent or TUI.
|
||||
- Adding a new IPC method, bypassing the existing existence cache, or changing local/SSH/runtime routing.
|
||||
|
||||
## Design
|
||||
|
||||
1. Add a focused regression using the exact three-link/two-row shape. Make the existence stub return true only for the three complete paths, then assert that provider calls for either physical row return the same middle link and exact multi-row range. Assert that no candidate/link spans either `·` separator.
|
||||
2. Reuse the existing conservative hard-wrap fragment alphabet. From a possible first row, slice its maximal fragment suffix; append zero or more continuation rows only while their whole trimmed text is a fragment; then slice the maximal fragment prefix from the first mixed-content row and stop. The only suffixes accepted without a path-name character are an exact POSIX root (`/`), one backslash for the first half of a UNC root, a bare ASCII drive prefix such as `C:`, and the complete relative prefixes `./`, `../`, and `~/`. A boundary candidate is emitted only when it covers the requested row, has at least two non-empty row fragments, and the fully joined text passes the existing path-start predicate. It may end at the first proper prefix slice of a mixed row or at the last available whole-fragment row; the latter is limited to whitelisted incomplete starts and is skipped when whole-row reconstruction already emitted the same text. Existing whole-row candidates remain responsible for ordinary/deep hard wraps, including a mixed starting row followed only by whole-fragment rows.
|
||||
3. Slice `columns` with each fragment so ranges retain the original xterm cells. Build the async-staleness fingerprint from each source row's full translated text and metadata as well as the selected slice; changing a sibling token must invalidate an in-flight result even if the reconstructed path is unchanged.
|
||||
4. Generate at most one boundary candidate per scanned start row—never every suffix/prefix combination. Preserve the existing bounds of 20 possible start rows and 20 rows per candidate, logical-line deduplication, and longest-non-overlapping-link selection. The existing builder can emit up to 210 whole-row candidates in its all-fragment worst case; this change may add at most 20 boundary candidates, not another quadratic set.
|
||||
5. Keep existence validation in the provider's current local/SSH/runtime path and cache. The valid reconstructed path necessarily adds its desired existence lookup compared with the broken behavior; do not add probes for arbitrary suffix/prefix combinations or change existing overlap/cache behavior in this focused fix.
|
||||
6. Verify direct modifier-click fallback from both halves. This path shares the candidate builder but remains synchronous and uses its existing cache/known-root preference before `openDetectedFilePath` performs normal routing checks.
|
||||
|
||||
## Data flow
|
||||
|
||||
- xterm buffer row under hover/click
|
||||
- bounded hard-wrap start/candidate windows and conservative endpoint slicing
|
||||
- whole-row candidates plus at most one boundary candidate per start row
|
||||
- existing terminal file-link parser
|
||||
- existing local/SSH/runtime path resolution and existence cache
|
||||
- mapped multi-row xterm range
|
||||
- hover tooltip or modifier-click open
|
||||
|
||||
## Edge cases
|
||||
|
||||
- A row boundary immediately after POSIX `/`, drive prefix `C:`, the first `\` of a UNC path, or complete `./`, `../`, and `~/` prefixes must reconstruct the complete path. Other punctuation-only suffixes and bare prose tokens remain ineligible, and the joined text must independently satisfy the full path-start predicate.
|
||||
- A continuation prefix may end before a separator (`·`), prose, or a sibling path; none of that suffix may enter the reconstructed candidate.
|
||||
- A starting suffix may begin after prose or a sibling path; its original xterm column must be retained.
|
||||
- Rows containing only one path fragment must keep the existing deep (up to 20 rows) reconstruction behavior.
|
||||
- Soft-wrapped rows, Unicode/multi-code-unit column mappings, known worktree roots, and spaced paths must remain unchanged. In particular, this change does not broaden fragment extraction to whitespace-containing paths.
|
||||
- Full-row source fingerprints must reject stale async results when text inside or outside the selected fragment changes.
|
||||
- Provider calls for remote paths must still use the owning pane's runtime environment or SSH connection and the connection-scoped existence-cache key; fragment extraction itself must not assume a local filesystem.
|
||||
- Incomplete fragment combinations remain filtered by the existing filesystem existence check.
|
||||
|
||||
## Test plan
|
||||
|
||||
- Candidate/range unit: cover the exact suffix/prefix slices, original xterm columns, full-row fingerprint changes, at most one added boundary candidate per start row, and no candidate spanning a sibling separator.
|
||||
- Provider integration: add the exact reported three-link regression to `terminal-link-handlers.test.ts`; across provider calls for both physical rows, assert all three complete links, the same middle range from each call, no incomplete or giant merged link, and that the complete middle path reaches the normal existence check.
|
||||
- Direct click: exercise hit-testing on the first and second physical halves of the middle path and assert both route the same complete path.
|
||||
- Compatibility: cover a backslash/drive-letter wrapped path and a remote-runtime or SSH existence call, proving the reconstructed path keeps the owning connection/environment.
|
||||
- Regression: run `wrapped-terminal-link-ranges.test.ts`, `terminal-link-handlers.test.ts`, and terminal-link parser tests.
|
||||
- Static: run formatter/check, web typecheck, lint, max-lines ratchet, and relevant repository checks.
|
||||
- Electron: render the exact text at the reproduced 133-column terminal width; verify pointer/tooltip and modifier-click from both middle fragments, then smoke-test the first and third links.
|
||||
|
||||
## UI quality bar
|
||||
|
||||
No visual styling changes. The exact same terminal text and layout must render without overlap, clipping, or altered wrapping. The only visible behavior change is that both physical halves of the middle path show the same pointer affordance and tooltip and activate the same file, consistent with the first and third links and `docs/STYLEGUIDE.md` interaction guidance.
|
||||
|
||||
## Review screenshots
|
||||
|
||||
1. Before, on the base revision: full Electron window hovering the broken middle path at the reproduced width (no tooltip/link affordance).
|
||||
2. After: full Electron window hovering the first physical half of the middle path, with tooltip visible.
|
||||
3. After: full Electron window hovering the continuation half, with the same tooltip/path visible.
|
||||
4. After adjacent-feature smoke: full Electron window hovering the first or third complete sibling link.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Add the failing exact-shape regression and range/click assertions.
|
||||
2. Implement boundary-fragment candidate extraction and cell mapping.
|
||||
3. Run focused tests and static checks.
|
||||
4. Validate the exact scenario in Electron and capture screenshots.
|
||||
5. Open an unmerged PR.
|
||||
|
||||
## Lightweight Eng Review
|
||||
|
||||
- Scope: limited to hard-wrapped path candidate reconstruction; parser, routing, cache, and UI styling stay unchanged.
|
||||
- Architecture/data flow: the shared candidate builder remains the single boundary for hover and click behavior, so local, daemon, SSH, and remote runtime flows receive identical ranges before their existing existence checks.
|
||||
- Failure modes covered:
|
||||
- sibling links accidentally merged into one spaced path
|
||||
- only one physical half hit-tests
|
||||
- incorrect xterm columns after slicing
|
||||
- Windows separators rejected
|
||||
- deep single-fragment rows regress
|
||||
- extra remote/local existence probes on hover
|
||||
- Test coverage required:
|
||||
- exact three-link provider regression from both hovered rows
|
||||
- exact range boundary assertions
|
||||
- direct click from both halves
|
||||
- existing deep-wrap, Unicode, stale-result, parser, SSH/runtime tests
|
||||
- Performance/blast radius: preserve the current 20-start-row/20-rows-per-candidate bounds (up to 210 existing whole-row candidates). Boundary extraction is linear per examined row and adds at most 20 candidates, only where a mixed continuation stops whole-row reconstruction. Resolving the previously missing complete path adds the intended cached existence check; the change adds no IPC method and does not alter local/remote routing or cache keys.
|
||||
- UI quality bar: unchanged rendering and style; consistent pointer, tooltip, and activation across both middle fragments, checked in the real Electron terminal against the style guide.
|
||||
- Required review screenshots:
|
||||
1. exact three-link full-window baseline
|
||||
2. middle first-half hover/tooltip
|
||||
3. middle continuation-half hover/tooltip
|
||||
4. first/third sibling hover smoke
|
||||
- Residual risks: local macOS is the available live Electron environment; Windows separator and SSH/runtime behavior require automated coverage and shared-code review.
|
||||
|
||||
## Terminal Reliability Proof
|
||||
|
||||
- Reliability class: `terminal-link.path-boundary-reconstruction`; the broader manifest entry `xterm-addon.boundary-containment` is related but does not register file-link correctness, so this remains an explicit accepted manifest gap rather than changing that gate's scope in a bug fix.
|
||||
- Product change type: renderer runtime hardening with deterministic regression coverage.
|
||||
- Invariant: one logical hard-wrapped file path maps to the same original xterm cells and owning local/SSH/runtime context from either physical row, without absorbing sibling text.
|
||||
- Failure source: the reproduced three-link line where only the first and third links were clickable.
|
||||
- Oracle: both provider row calls return the same complete middle path/range; direct hit-testing on either half opens that path; root-, drive-, and UNC-boundary candidates retain their exact xterm ranges; no emitted boundary candidate contains `·`.
|
||||
- Provider/platform matrix: local and SSH provider behavior covered; Windows separators/cell mapping covered; daemon and remote-runtime use the same builder and routing but are not live-tested; Linux, Windows, WSL, mobile/relay are accepted live-validation gaps.
|
||||
- Performance budget: the scan stays capped at 20 starts and 20 rows per candidate, emits at most one boundary candidate per start, and rejects non-path starts before reading possible continuation rows. No polling, timers, listeners, subprocesses, or new IPC methods are added; only the newly valid path reaches the existing cached existence probe.
|
||||
- Diagnostics: the full source-row fingerprint rejects stale async results; deterministic range/provider tests are the regression breadcrumb. No new product telemetry or raw terminal logging is warranted.
|
||||
- Gate status: no manifest entry added or promoted. Revisit only if this parser grows beyond bounded local row reconstruction or the regression recurs outside the covered provider/platform matrix.
|
||||
- Rollback/demotion rule: revert boundary-fragment reconstruction if Electron shows sibling-path merging, incorrect hit regions, or material hover latency; keep the exact red regression as the behavioral oracle.
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
export type HardWrappedPathFragmentRow = {
|
||||
text: string
|
||||
sourceText: string
|
||||
columns: number[]
|
||||
isWrapped: boolean
|
||||
lineLength: number
|
||||
}
|
||||
|
||||
const HARD_WRAPPED_PATH_FRAGMENT_PATTERN = /^[A-Za-z0-9._~@%+=:,/\\-]+$/
|
||||
|
||||
export function isHardWrappedPathFragment(text: string): boolean {
|
||||
return HARD_WRAPPED_PATH_FRAGMENT_PATTERN.test(text) && /[A-Za-z0-9]/.test(text)
|
||||
}
|
||||
|
||||
export function isIncompleteHardWrappedPathStart(text: string): boolean {
|
||||
// Why: a terminal row can end immediately after a complete root, drive, or
|
||||
// relative prefix, before the continuation contributes path-name characters.
|
||||
return /^(?:[/\\]|\.{1,2}\/|~\/|[A-Za-z]:)$/.test(text)
|
||||
}
|
||||
|
||||
export function isHardWrappedPathContinuation(text: string): boolean {
|
||||
return isHardWrappedPathFragment(text) || isIncompleteHardWrappedPathStart(text)
|
||||
}
|
||||
|
||||
export function canStartHardWrappedPath(text: string): boolean {
|
||||
if (!isHardWrappedPathFragment(text)) {
|
||||
return /(?:^|[\s•*>-])(?:\/|\.{1,2}\/|[A-Za-z0-9._-]+\/)[A-Za-z0-9._~@%+=:,/\\-]*$/.test(text)
|
||||
}
|
||||
|
||||
return /(?:\/|\\)/.test(text)
|
||||
}
|
||||
|
||||
function sliceHardWrappedPathFragmentRow(
|
||||
row: HardWrappedPathFragmentRow,
|
||||
startIndex: number,
|
||||
endIndex: number
|
||||
): HardWrappedPathFragmentRow {
|
||||
return {
|
||||
...row,
|
||||
text: row.text.slice(startIndex, endIndex),
|
||||
columns: row.columns.slice(startIndex, endIndex + 1)
|
||||
}
|
||||
}
|
||||
|
||||
export function getHardWrappedPathSuffix(
|
||||
row: HardWrappedPathFragmentRow
|
||||
): HardWrappedPathFragmentRow | null {
|
||||
let startIndex = row.text.length
|
||||
while (startIndex > 0 && HARD_WRAPPED_PATH_FRAGMENT_PATTERN.test(row.text[startIndex - 1])) {
|
||||
startIndex--
|
||||
}
|
||||
const suffix = sliceHardWrappedPathFragmentRow(row, startIndex, row.text.length)
|
||||
return isHardWrappedPathContinuation(suffix.text) ? suffix : null
|
||||
}
|
||||
|
||||
export function getHardWrappedPathPrefix(
|
||||
row: HardWrappedPathFragmentRow
|
||||
): HardWrappedPathFragmentRow | null {
|
||||
let endIndex = 0
|
||||
while (
|
||||
endIndex < row.text.length &&
|
||||
HARD_WRAPPED_PATH_FRAGMENT_PATTERN.test(row.text[endIndex])
|
||||
) {
|
||||
endIndex++
|
||||
}
|
||||
const prefix = sliceHardWrappedPathFragmentRow(row, 0, endIndex)
|
||||
return isHardWrappedPathContinuation(prefix.text) ? prefix : null
|
||||
}
|
||||
|
|
@ -1987,6 +1987,84 @@ describe('createFilePathLinkProvider range bounds', () => {
|
|||
expect(continuationLink!.range).toEqual(firstRowLink!.range)
|
||||
})
|
||||
|
||||
it('returns all three sibling links and the same boundary link from either row over SSH', async () => {
|
||||
const firstPath = 'validation-screenshots/01-before-white-terminal-scrollbar-gutter.png'
|
||||
const middleStart = 'validation-screenshots/02-after-'
|
||||
const middleEnd = 'transparent-terminal-scrollbar-gutter.png'
|
||||
const middlePath = middleStart + middleEnd
|
||||
const thirdPath = 'validation-screenshots/03-after-light-theme.png'
|
||||
const rows = [
|
||||
makeBufferLine(`${firstPath} · ${middleStart}`),
|
||||
makeBufferLine(`${middleEnd} · ${thirdPath}`)
|
||||
]
|
||||
const completePaths = new Set([firstPath, middlePath, thirdPath].map((path) => `/repo/${path}`))
|
||||
vi.mocked(getConnectionId).mockReturnValue('ssh-wrapped')
|
||||
fsPathExistsMock.mockImplementation(async ({ filePath }) => completePaths.has(filePath))
|
||||
const { provider } = createProviderSetup(rows, new Map())
|
||||
const provide = (line: number): Promise<ILink[]> =>
|
||||
new Promise((resolve) => provider.provideLinks(line, (links) => resolve(links ?? [])))
|
||||
|
||||
const firstRowLinks = await provide(1)
|
||||
const secondRowLinks = await provide(2)
|
||||
const firstMiddle = firstRowLinks.find((link) => link.text === middlePath)
|
||||
const secondMiddle = secondRowLinks.find((link) => link.text === middlePath)
|
||||
|
||||
expect(firstRowLinks.map((link) => link.text)).toEqual([firstPath, middlePath])
|
||||
expect(secondRowLinks.map((link) => link.text)).toEqual([middlePath, thirdPath])
|
||||
expect(new Set([...firstRowLinks, ...secondRowLinks].map((link) => link.text))).toEqual(
|
||||
new Set([firstPath, middlePath, thirdPath])
|
||||
)
|
||||
expect(firstMiddle?.range).toEqual({
|
||||
start: { x: firstPath.length + ' · '.length + 1, y: 1 },
|
||||
end: { x: middleEnd.length, y: 2 }
|
||||
})
|
||||
expect(secondMiddle?.range).toEqual(firstMiddle?.range)
|
||||
expect([...firstRowLinks, ...secondRowLinks].every((link) => !link.text.includes(' · '))).toBe(
|
||||
true
|
||||
)
|
||||
expect(fsPathExistsMock).toHaveBeenCalledWith({
|
||||
filePath: `/repo/${middlePath}`,
|
||||
connectionId: 'ssh-wrapped'
|
||||
})
|
||||
expect(window.api.shell.pathExists).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the same boundary path from direct clicks on both physical halves', async () => {
|
||||
setPlatform('Macintosh')
|
||||
const firstPath = 'validation-screenshots/01-before-white-terminal-scrollbar-gutter.png'
|
||||
const middleStart = 'validation-screenshots/02-after-'
|
||||
const middleEnd = 'transparent-terminal-scrollbar-gutter.png'
|
||||
const middlePath = middleStart + middleEnd
|
||||
const thirdPath = 'validation-screenshots/03-after-light-theme.png'
|
||||
const rows = [
|
||||
makeBufferLine(`${firstPath} · ${middleStart}`),
|
||||
makeBufferLine(`${middleEnd} · ${thirdPath}`)
|
||||
]
|
||||
const pathExistsCache = new Map([[`active\0/repo/${middlePath}`, true]])
|
||||
const positions = [
|
||||
{ x: firstPath.length + ' · '.length + 2, y: 1 },
|
||||
{ x: 2, y: 2 }
|
||||
]
|
||||
|
||||
for (const position of positions) {
|
||||
const opened = openFilePathLinkAtBufferPosition(makeBuffer(rows), position, 133, {
|
||||
startupCwd: '/repo',
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/repo',
|
||||
runtimeEnvironmentId: null,
|
||||
pathExistsCache
|
||||
})
|
||||
await flushDoubleRaf()
|
||||
|
||||
expect(opened).toBe(true)
|
||||
expect(openFileMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ filePath: `/repo/${middlePath}` }),
|
||||
{ forceContentReload: true }
|
||||
)
|
||||
}
|
||||
expect(openFileMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('maps file link columns through multi-code-unit characters before the path', async () => {
|
||||
const text = 'e\u0301 src/main.ts'
|
||||
const columns = [0, 0, 1]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { buildWrappedLogicalLine } from './wrapped-terminal-link-ranges'
|
||||
import {
|
||||
buildHardWrappedPathLogicalLineCandidates,
|
||||
buildWrappedLogicalLine,
|
||||
rangeForParsedFileLink
|
||||
} from './wrapped-terminal-link-ranges'
|
||||
|
||||
type TestBufferLine = {
|
||||
isWrapped: boolean
|
||||
|
|
@ -13,9 +17,14 @@ type TestBufferLine = {
|
|||
) => string
|
||||
}
|
||||
|
||||
function makeBufferLine(text: string, isWrapped = false): TestBufferLine {
|
||||
function makeBufferLine(
|
||||
text: string,
|
||||
options: { isWrapped?: boolean; columns?: number[] } = {}
|
||||
): TestBufferLine {
|
||||
const columns =
|
||||
options.columns ?? Array.from({ length: text.length + 1 }, (_value, index) => index)
|
||||
return {
|
||||
isWrapped,
|
||||
isWrapped: options.isWrapped ?? false,
|
||||
length: text.length,
|
||||
getCell: () => undefined,
|
||||
translateToString: (
|
||||
|
|
@ -27,7 +36,7 @@ function makeBufferLine(text: string, isWrapped = false): TestBufferLine {
|
|||
if (outColumns) {
|
||||
outColumns.length = 0
|
||||
for (let index = startColumn; index <= endColumn; index++) {
|
||||
outColumns.push(index)
|
||||
outColumns.push(columns[index] ?? index)
|
||||
}
|
||||
}
|
||||
return text.slice(startColumn, endColumn)
|
||||
|
|
@ -37,7 +46,7 @@ function makeBufferLine(text: string, isWrapped = false): TestBufferLine {
|
|||
|
||||
describe('buildWrappedLogicalLine', () => {
|
||||
it('joins ordinary soft-wrapped terminal rows', () => {
|
||||
const rows = [makeBufferLine('src/'), makeBufferLine('file.ts', true)]
|
||||
const rows = [makeBufferLine('src/'), makeBufferLine('file.ts', { isWrapped: true })]
|
||||
|
||||
const logicalLine = buildWrappedLogicalLine({ getLine: (y) => rows[y] }, 2)
|
||||
|
||||
|
|
@ -47,7 +56,7 @@ describe('buildWrappedLogicalLine', () => {
|
|||
|
||||
it('caps pathological soft-wrapped lines before scanning the whole run', () => {
|
||||
const rows = Array.from({ length: 1_000 }, (_value, index) =>
|
||||
makeBufferLine('b'.repeat(80), index > 0)
|
||||
makeBufferLine('b'.repeat(80), { isWrapped: index > 0 })
|
||||
)
|
||||
const observedRows: number[] = []
|
||||
|
||||
|
|
@ -65,3 +74,223 @@ describe('buildWrappedLogicalLine', () => {
|
|||
expect(Math.max(...observedRows)).toBeLessThan(250)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildHardWrappedPathLogicalLineCandidates', () => {
|
||||
const firstPath = 'validation-screenshots/01-before-white-terminal-scrollbar-gutter.png'
|
||||
const middleStart = 'validation-screenshots/02-after-'
|
||||
const middleEnd = 'transparent-terminal-scrollbar-gutter.png'
|
||||
const thirdPath = 'validation-screenshots/03-after-light-theme.png'
|
||||
|
||||
function makeThreeLinkRows(): TestBufferLine[] {
|
||||
return [
|
||||
makeBufferLine(`${firstPath} · ${middleStart}`),
|
||||
makeBufferLine(`${middleEnd} · ${thirdPath}`)
|
||||
]
|
||||
}
|
||||
|
||||
it('reconstructs one boundary path without merging its sibling links', () => {
|
||||
const rows = makeThreeLinkRows()
|
||||
const buffer = { getLine: (y: number) => rows[y] }
|
||||
const firstRowCandidates = buildHardWrappedPathLogicalLineCandidates(buffer, 1)
|
||||
const secondRowCandidates = buildHardWrappedPathLogicalLineCandidates(buffer, 2)
|
||||
const expectedText = middleStart + middleEnd
|
||||
const firstBoundary = firstRowCandidates.filter((candidate) => candidate.text === expectedText)
|
||||
const secondBoundary = secondRowCandidates.filter(
|
||||
(candidate) => candidate.text === expectedText
|
||||
)
|
||||
|
||||
expect(firstBoundary).toHaveLength(1)
|
||||
expect(secondBoundary).toHaveLength(1)
|
||||
expect(firstRowCandidates.filter((candidate) => candidate.rows.length > 1)).toHaveLength(1)
|
||||
expect(secondBoundary[0].fingerprint).toBe(firstBoundary[0].fingerprint)
|
||||
expect(firstBoundary[0].rows.map((row) => row.text)).toEqual([middleStart, middleEnd])
|
||||
expect(firstBoundary[0].text).not.toContain(' · ')
|
||||
expect(rangeForParsedFileLink(firstBoundary[0], 0, expectedText.length)).toEqual({
|
||||
start: { x: firstPath.length + ' · '.length + 1, y: 1 },
|
||||
end: { x: middleEnd.length, y: 2 }
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'POSIX root',
|
||||
firstFragment: '/',
|
||||
secondFragment: 'home/alice/file.ts',
|
||||
expectedPath: '/home/alice/file.ts'
|
||||
},
|
||||
{
|
||||
name: 'Windows drive',
|
||||
firstFragment: 'C:',
|
||||
secondFragment: '\\Users\\Alice\\repo\\file.ts',
|
||||
expectedPath: 'C:\\Users\\Alice\\repo\\file.ts'
|
||||
},
|
||||
{
|
||||
name: 'UNC root',
|
||||
firstFragment: '\\',
|
||||
secondFragment: '\\server\\share\\file.ts',
|
||||
expectedPath: '\\\\server\\share\\file.ts'
|
||||
},
|
||||
{
|
||||
name: 'current-directory prefix',
|
||||
firstFragment: './',
|
||||
secondFragment: 'src/file.ts',
|
||||
expectedPath: './src/file.ts'
|
||||
},
|
||||
{
|
||||
name: 'parent-directory prefix',
|
||||
firstFragment: '../',
|
||||
secondFragment: 'src/file.ts',
|
||||
expectedPath: '../src/file.ts'
|
||||
},
|
||||
{
|
||||
name: 'home-directory prefix',
|
||||
firstFragment: '~/',
|
||||
secondFragment: 'src/file.ts',
|
||||
expectedPath: '~/src/file.ts'
|
||||
}
|
||||
])(
|
||||
'reconstructs a boundary path split after its $name',
|
||||
({ firstFragment, secondFragment, expectedPath }) => {
|
||||
const firstRow = `first.ts · ${firstFragment}`
|
||||
const rows = [makeBufferLine(firstRow), makeBufferLine(`${secondFragment} · third.ts`)]
|
||||
const buffer = { getLine: (y: number) => rows[y] }
|
||||
|
||||
const firstRowCandidates = buildHardWrappedPathLogicalLineCandidates(buffer, 1)
|
||||
const secondRowCandidates = buildHardWrappedPathLogicalLineCandidates(buffer, 2)
|
||||
const firstBoundary = firstRowCandidates.filter(
|
||||
(candidate) => candidate.text === expectedPath
|
||||
)
|
||||
const secondBoundary = secondRowCandidates.filter(
|
||||
(candidate) => candidate.text === expectedPath
|
||||
)
|
||||
|
||||
expect(firstBoundary).toHaveLength(1)
|
||||
expect(secondBoundary).toHaveLength(1)
|
||||
expect(secondBoundary[0].fingerprint).toBe(firstBoundary[0].fingerprint)
|
||||
expect(firstBoundary[0].rows.map((row) => row.text)).toEqual([firstFragment, secondFragment])
|
||||
expect(rangeForParsedFileLink(firstBoundary[0], 0, expectedPath.length)).toEqual({
|
||||
start: { x: firstRow.indexOf(firstFragment) + 1, y: 1 },
|
||||
end: { x: secondFragment.length, y: 2 }
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('rejects an incomplete drive prefix whose joined text is not a path start', () => {
|
||||
const rows = [makeBufferLine('first.ts · C:'), makeBufferLine('readme · third.ts')]
|
||||
|
||||
const candidates = buildHardWrappedPathLogicalLineCandidates(
|
||||
{ getLine: (y: number) => rows[y] },
|
||||
1
|
||||
)
|
||||
|
||||
expect(candidates.some((candidate) => candidate.text === 'C:readme')).toBe(false)
|
||||
expect(candidates.filter((candidate) => candidate.rows.length > 1)).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'POSIX root',
|
||||
firstFragment: '/',
|
||||
secondFragment: 'home/alice/file.ts',
|
||||
expectedPath: '/home/alice/file.ts'
|
||||
},
|
||||
{
|
||||
name: 'Windows drive',
|
||||
firstFragment: 'C:',
|
||||
secondFragment: '\\Users\\Alice\\repo\\file.ts',
|
||||
expectedPath: 'C:\\Users\\Alice\\repo\\file.ts'
|
||||
},
|
||||
{
|
||||
name: 'UNC root',
|
||||
firstFragment: '\\',
|
||||
secondFragment: '\\server\\share\\file.ts',
|
||||
expectedPath: '\\\\server\\share\\file.ts'
|
||||
}
|
||||
])(
|
||||
'reconstructs a boundary path after its $name at end of output',
|
||||
({ firstFragment, secondFragment, expectedPath }) => {
|
||||
const rows = [makeBufferLine(`first.ts · ${firstFragment}`), makeBufferLine(secondFragment)]
|
||||
const buffer = { getLine: (y: number) => rows[y] }
|
||||
|
||||
const firstRowCandidates = buildHardWrappedPathLogicalLineCandidates(buffer, 1)
|
||||
const secondRowCandidates = buildHardWrappedPathLogicalLineCandidates(buffer, 2)
|
||||
|
||||
expect(
|
||||
firstRowCandidates.filter((candidate) => candidate.text === expectedPath)
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
secondRowCandidates.filter((candidate) => candidate.text === expectedPath)
|
||||
).toHaveLength(1)
|
||||
}
|
||||
)
|
||||
|
||||
it('does not duplicate an end-of-output candidate emitted by whole-row reconstruction', () => {
|
||||
const rows = [makeBufferLine('/'), makeBufferLine('home/alice/file.ts')]
|
||||
|
||||
const candidates = buildHardWrappedPathLogicalLineCandidates(
|
||||
{ getLine: (y: number) => rows[y] },
|
||||
2
|
||||
)
|
||||
|
||||
expect(candidates.filter((candidate) => candidate.text === '/home/alice/file.ts')).toHaveLength(
|
||||
1
|
||||
)
|
||||
})
|
||||
|
||||
it('fingerprints full source rows outside the selected boundary fragments', () => {
|
||||
const rows = makeThreeLinkRows()
|
||||
const buffer = { getLine: (y: number) => rows[y] }
|
||||
const expectedText = middleStart + middleEnd
|
||||
const before = buildHardWrappedPathLogicalLineCandidates(buffer, 1).find(
|
||||
(candidate) => candidate.text === expectedText
|
||||
)
|
||||
|
||||
rows[1] = makeBufferLine(`${middleEnd} · validation-screenshots/03-after-dark-theme.png`)
|
||||
const after = buildHardWrappedPathLogicalLineCandidates(buffer, 1).find(
|
||||
(candidate) => candidate.text === expectedText
|
||||
)
|
||||
|
||||
expect(before).toBeDefined()
|
||||
expect(after).toBeDefined()
|
||||
expect(after!.rows.map((row) => row.text)).toEqual(before!.rows.map((row) => row.text))
|
||||
expect(after!.fingerprint).not.toBe(before!.fingerprint)
|
||||
})
|
||||
|
||||
it('rejects non-path starts before scanning their possible continuations', () => {
|
||||
const rows = Array.from({ length: 20 }, () => makeBufferLine('a'.repeat(80)))
|
||||
const observedRows: number[] = []
|
||||
|
||||
const candidates = buildHardWrappedPathLogicalLineCandidates(
|
||||
{
|
||||
getLine: (y: number) => {
|
||||
observedRows.push(y)
|
||||
return rows[y]
|
||||
}
|
||||
},
|
||||
20
|
||||
)
|
||||
|
||||
expect(candidates).toEqual([])
|
||||
expect(observedRows).toHaveLength(21)
|
||||
})
|
||||
|
||||
it('preserves Windows drive paths and original continuation columns', () => {
|
||||
const firstRow = 'result: C:\\Users\\Alice\\Project\\src\\very-'
|
||||
const firstFragment = 'C:\\Users\\Alice\\Project\\src\\very-'
|
||||
const secondFragment = 'long\\file.ts'
|
||||
const secondRow = `${secondFragment} · C:\\other.ts`
|
||||
const secondColumns = Array.from({ length: secondRow.length + 1 }, (_value, index) => index * 2)
|
||||
const rows = [makeBufferLine(firstRow), makeBufferLine(secondRow, { columns: secondColumns })]
|
||||
const candidates = buildHardWrappedPathLogicalLineCandidates(
|
||||
{ getLine: (y: number) => rows[y] },
|
||||
2
|
||||
)
|
||||
const candidate = candidates.find((item) => item.text === `${firstFragment}${secondFragment}`)
|
||||
|
||||
expect(candidate).toBeDefined()
|
||||
expect(rangeForParsedFileLink(candidate!, 0, candidate!.text.length)).toEqual({
|
||||
start: { x: firstRow.indexOf('C:') + 1, y: 1 },
|
||||
end: { x: secondFragment.length * 2, y: 2 }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,13 @@
|
|||
import type { IBufferLine, IBufferRange } from '@xterm/xterm'
|
||||
import {
|
||||
canStartHardWrappedPath,
|
||||
getHardWrappedPathPrefix,
|
||||
getHardWrappedPathSuffix,
|
||||
isHardWrappedPathContinuation,
|
||||
isHardWrappedPathFragment,
|
||||
isIncompleteHardWrappedPathStart,
|
||||
type HardWrappedPathFragmentRow
|
||||
} from './hard-wrapped-terminal-path-fragments'
|
||||
|
||||
type TerminalBufferLineWithColumns = IBufferLine & {
|
||||
translateToString(
|
||||
|
|
@ -12,6 +21,7 @@ type TerminalBufferLineWithColumns = IBufferLine & {
|
|||
type WrappedLogicalRow = {
|
||||
y: number
|
||||
text: string
|
||||
sourceText: string
|
||||
columns: number[]
|
||||
startIndex: number
|
||||
isWrapped: boolean
|
||||
|
|
@ -79,7 +89,7 @@ function translateLineWithColumns(line: IBufferLine): { text: string; columns: n
|
|||
}
|
||||
}
|
||||
|
||||
function trimHardWrappedPathRow(line: IBufferLine): { text: string; columns: number[] } | null {
|
||||
function trimHardWrappedPathRow(line: IBufferLine): HardWrappedPathFragmentRow | null {
|
||||
const translated = translateLineWithColumns(line)
|
||||
const startIndex = translated.text.search(/\S/)
|
||||
if (startIndex === -1) {
|
||||
|
|
@ -93,22 +103,39 @@ function trimHardWrappedPathRow(line: IBufferLine): { text: string; columns: num
|
|||
|
||||
return {
|
||||
text: translated.text.slice(startIndex, endIndex),
|
||||
columns: translated.columns.slice(startIndex, endIndex + 1)
|
||||
sourceText: translated.text,
|
||||
columns: translated.columns.slice(startIndex, endIndex + 1),
|
||||
isWrapped: line.isWrapped,
|
||||
lineLength: line.length
|
||||
}
|
||||
}
|
||||
|
||||
const HARD_WRAPPED_PATH_FRAGMENT_PATTERN = /^[A-Za-z0-9._~@%+=:,/\\-]+$/
|
||||
|
||||
function isHardWrappedPathFragment(text: string): boolean {
|
||||
return HARD_WRAPPED_PATH_FRAGMENT_PATTERN.test(text) && /[A-Za-z0-9]/.test(text)
|
||||
function toWrappedLogicalRow(
|
||||
row: HardWrappedPathFragmentRow,
|
||||
y: number,
|
||||
startIndex: number
|
||||
): WrappedLogicalRow {
|
||||
return {
|
||||
y,
|
||||
text: row.text,
|
||||
sourceText: row.sourceText,
|
||||
columns: row.columns,
|
||||
startIndex,
|
||||
isWrapped: row.isWrapped,
|
||||
lineLength: row.lineLength
|
||||
}
|
||||
}
|
||||
|
||||
function canStartHardWrappedPath(text: string): boolean {
|
||||
if (!isHardWrappedPathFragment(text)) {
|
||||
return /(?:^|[\s•*>-])(?:\/|\.{1,2}\/|[A-Za-z0-9._-]+\/)[A-Za-z0-9._~@%+=:,/\\-]*$/.test(text)
|
||||
}
|
||||
function getWrappedRowsFingerprint(rows: WrappedLogicalRow[]): string {
|
||||
return rows
|
||||
.map(
|
||||
(row) => `${row.y}:${row.isWrapped ? 1 : 0}:${row.lineLength}:${row.sourceText}\0${row.text}`
|
||||
)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
return /(?:\/|\\)/.test(text)
|
||||
function toWrappedLogicalLine(rows: WrappedLogicalRow[], text: string): WrappedLogicalLine {
|
||||
return { text, rows: [...rows], fingerprint: getWrappedRowsFingerprint(rows) }
|
||||
}
|
||||
|
||||
export function buildWrappedLogicalLine(
|
||||
|
|
@ -155,6 +182,7 @@ export function buildWrappedLogicalLine(
|
|||
rows.push({
|
||||
y: rowY,
|
||||
text: translated.text,
|
||||
sourceText: translated.text,
|
||||
columns: translated.columns,
|
||||
startIndex: text.length,
|
||||
isWrapped: line.isWrapped,
|
||||
|
|
@ -163,10 +191,7 @@ export function buildWrappedLogicalLine(
|
|||
text += translated.text
|
||||
}
|
||||
|
||||
const fingerprint = rows
|
||||
.map((row) => `${row.y}:${row.isWrapped ? 1 : 0}:${row.lineLength}:${row.text}`)
|
||||
.join('\n')
|
||||
return { text, rows, fingerprint }
|
||||
return toWrappedLogicalLine(rows, text)
|
||||
}
|
||||
|
||||
export function buildHardWrappedPathLogicalLineCandidates(
|
||||
|
|
@ -186,39 +211,91 @@ export function buildHardWrappedPathLogicalLineCandidates(
|
|||
for (let startY = currentY; startY >= minY; startY--) {
|
||||
const startLine = buffer.getLine(startY)
|
||||
const start = startLine ? trimHardWrappedPathRow(startLine) : null
|
||||
if (!start || !canStartHardWrappedPath(start.text)) {
|
||||
if (!start) {
|
||||
continue
|
||||
}
|
||||
const canStartWholeRow = canStartHardWrappedPath(start.text)
|
||||
const startSuffix = getHardWrappedPathSuffix(start)
|
||||
const canStartBoundary = Boolean(
|
||||
startSuffix &&
|
||||
(canStartHardWrappedPath(startSuffix.text) ||
|
||||
isIncompleteHardWrappedPathStart(startSuffix.text))
|
||||
)
|
||||
// Why: hover calls this for every terminal row; reject non-path starts
|
||||
// before translating their possible continuation rows.
|
||||
if (!canStartWholeRow && !canStartBoundary) {
|
||||
continue
|
||||
}
|
||||
|
||||
let text = ''
|
||||
const rows: WrappedLogicalRow[] = []
|
||||
for (let rowY = startY; rowY < startY + maxRows; rowY++) {
|
||||
const sourceRows: { row: HardWrappedPathFragmentRow; y: number }[] = [{ row: start, y: startY }]
|
||||
for (let rowY = startY + 1; rowY < startY + maxRows; rowY++) {
|
||||
const line = buffer.getLine(rowY)
|
||||
const translated = line ? trimHardWrappedPathRow(line) : null
|
||||
if (!translated) {
|
||||
const row = line ? trimHardWrappedPathRow(line) : null
|
||||
if (!row) {
|
||||
break
|
||||
}
|
||||
if (rowY > startY && !isHardWrappedPathFragment(translated.text)) {
|
||||
sourceRows.push({ row, y: rowY })
|
||||
if (!isHardWrappedPathContinuation(row.text)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
rows.push({
|
||||
y: rowY,
|
||||
text: translated.text,
|
||||
columns: translated.columns,
|
||||
startIndex: text.length,
|
||||
isWrapped: line?.isWrapped ?? false,
|
||||
lineLength: line?.length ?? translated.text.length
|
||||
})
|
||||
text += translated.text
|
||||
|
||||
if (rowY >= currentY) {
|
||||
const fingerprint = rows
|
||||
.map((row) => `${row.y}:${row.isWrapped ? 1 : 0}:${row.lineLength}:${row.text}`)
|
||||
.join('\n')
|
||||
candidates.push({ text, rows: [...rows], fingerprint })
|
||||
let lastWholeCandidateText: string | null = null
|
||||
if (canStartWholeRow) {
|
||||
let text = ''
|
||||
const rows: WrappedLogicalRow[] = []
|
||||
for (const sourceRow of sourceRows) {
|
||||
if (sourceRow.y > startY && !isHardWrappedPathFragment(sourceRow.row.text)) {
|
||||
break
|
||||
}
|
||||
rows.push(toWrappedLogicalRow(sourceRow.row, sourceRow.y, text.length))
|
||||
text += sourceRow.row.text
|
||||
if (sourceRow.y >= currentY) {
|
||||
candidates.push(toWrappedLogicalLine(rows, text))
|
||||
lastWholeCandidateText = text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!startSuffix || !canStartBoundary) {
|
||||
continue
|
||||
}
|
||||
let boundaryText = startSuffix.text
|
||||
const boundaryRows = [toWrappedLogicalRow(startSuffix, startY, 0)]
|
||||
let reachedMixedContinuation = false
|
||||
for (let rowIndex = 1; rowIndex < sourceRows.length; rowIndex++) {
|
||||
const sourceRow = sourceRows[rowIndex]
|
||||
if (isHardWrappedPathContinuation(sourceRow.row.text)) {
|
||||
boundaryRows.push(toWrappedLogicalRow(sourceRow.row, sourceRow.y, boundaryText.length))
|
||||
boundaryText += sourceRow.row.text
|
||||
continue
|
||||
}
|
||||
|
||||
reachedMixedContinuation = true
|
||||
const finalPrefix = getHardWrappedPathPrefix(sourceRow.row)
|
||||
if (finalPrefix && finalPrefix.text.length < sourceRow.row.text.length) {
|
||||
boundaryRows.push(toWrappedLogicalRow(finalPrefix, sourceRow.y, boundaryText.length))
|
||||
boundaryText += finalPrefix.text
|
||||
if (sourceRow.y >= currentY && canStartHardWrappedPath(boundaryText)) {
|
||||
// Why: only the first mixed continuation can close a hard-wrapped path;
|
||||
// emitting more boundary combinations can merge sibling links.
|
||||
candidates.push(toWrappedLogicalLine(boundaryRows, boundaryText))
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
const lastBoundaryRow = boundaryRows.at(-1)!
|
||||
if (
|
||||
!reachedMixedContinuation &&
|
||||
isIncompleteHardWrappedPathStart(startSuffix.text) &&
|
||||
boundaryRows.length >= 2 &&
|
||||
lastBoundaryRow.y >= currentY &&
|
||||
canStartHardWrappedPath(boundaryText) &&
|
||||
lastWholeCandidateText !== boundaryText
|
||||
) {
|
||||
candidates.push(toWrappedLogicalLine(boundaryRows, boundaryText))
|
||||
}
|
||||
}
|
||||
|
||||
return candidates.sort((left, right) => right.rows.length - left.rows.length)
|
||||
|
|
|
|||
Loading…
Reference in New Issue