fix(desktop): tag and preview Windows absolute paths (C:\, UNC)
This commit is contained in:
@@ -7,9 +7,11 @@ import {
|
||||
clampForDisplay,
|
||||
countDiffLineStats,
|
||||
inlineDiffFromResult,
|
||||
isPreviewableTarget,
|
||||
MAX_TOOL_RENDER_CHARS,
|
||||
prettyJson,
|
||||
type ToolPart
|
||||
type ToolPart,
|
||||
toolPreviewOutcome
|
||||
} from './fallback-model'
|
||||
|
||||
const part = (overrides: Partial<ToolPart>): ToolPart => ({
|
||||
@@ -464,6 +466,27 @@ describe('buildToolView title actions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// #85132: Windows agents write `C:\\...` / UNC paths; those must get the same
|
||||
// artifact preview tag a POSIX `/Users/...` path gets.
|
||||
describe('Windows absolute preview targets', () => {
|
||||
it.each(['C:\\Users\\me\\report.html', 'D:/work/report.htm', '\\\\server\\share\\report.html'])(
|
||||
'tags a written %s as a previewable artifact',
|
||||
path => {
|
||||
const outcome = toolPreviewOutcome(
|
||||
part({ args: { content: '<h1>hi</h1>', path }, result: { bytes_written: 11 }, toolName: 'write_file' })
|
||||
)
|
||||
|
||||
expect(outcome.previewTarget).toBe(path)
|
||||
expect(isPreviewableTarget(outcome.previewTarget)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps non-HTML Windows files out of the preview tag, like POSIX ones', () => {
|
||||
expect(isPreviewableTarget('C:\\Users\\me\\notes.txt')).toBe(isPreviewableTarget('/Users/me/notes.txt'))
|
||||
expect(isPreviewableTarget('C:\\Users\\me\\notes.txt')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clampForDisplay', () => {
|
||||
it('passes short payloads through untouched', () => {
|
||||
expect(clampForDisplay('hello')).toBe('hello')
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isWindowsAbsolutePath } from '@/lib/path-compare'
|
||||
|
||||
import type { ToolPart } from './types'
|
||||
|
||||
export function looksLikeUrl(value: string): boolean {
|
||||
@@ -5,7 +7,7 @@ export function looksLikeUrl(value: string): boolean {
|
||||
}
|
||||
|
||||
export function looksLikePath(value: string): boolean {
|
||||
return /^file:\/\//i.test(value) || /^(?:\/|\.{1,2}\/|~\/).+/.test(value)
|
||||
return /^file:\/\//i.test(value) || /^(?:\/|\.{1,2}\/|~\/).+/.test(value) || isWindowsAbsolutePath(value)
|
||||
}
|
||||
|
||||
export function isPreviewableTarget(target: string): boolean {
|
||||
@@ -17,7 +19,7 @@ export function isPreviewableTarget(target: string): boolean {
|
||||
return Boolean(
|
||||
target &&
|
||||
(/^file:\/\//i.test(target) ||
|
||||
/^(?:\/|\.{1,2}\/|~\/).+\.html?$/i.test(target) ||
|
||||
(looksLikePath(target) && /\.html?$/i.test(target)) ||
|
||||
/^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])/i.test(target))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -148,6 +148,19 @@ describe('remote HTML previews', () => {
|
||||
expect(localPreviewTarget('/tmp/report\\draft.html')?.url).toBe('file:///tmp/report%5Cdraft.html')
|
||||
})
|
||||
|
||||
// #85132: a Windows absolute path is absolute; joining it onto cwd made
|
||||
// `/repo/C:\\Users\\...` which no filesystem has.
|
||||
it.each(['C:\\Users\\me\\report.html', 'C:/Users/me/report.html', '\\\\server\\share\\report.html'])(
|
||||
'treats Windows absolute path %s as absolute instead of joining it onto cwd',
|
||||
raw => {
|
||||
expect(localPreviewTarget(raw, '/repo')).toMatchObject({ label: 'report.html', path: raw, previewKind: 'html' })
|
||||
}
|
||||
)
|
||||
|
||||
it('still joins relative paths onto cwd', () => {
|
||||
expect(localPreviewTarget('out/report.html', '/repo')?.path).toBe('/repo/out/report.html')
|
||||
})
|
||||
|
||||
it('preserves POSIX double-slash file paths', () => {
|
||||
expect(localPreviewTarget('//srv/share/report #1?.html')?.url).toBe('file:////srv/share/report%20%231%3F.html')
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
import { isDesktopFsRemoteMode, readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs'
|
||||
import { isWindowsAbsolutePath } from '@/lib/path-compare'
|
||||
import type { PreviewTarget } from '@/store/preview'
|
||||
|
||||
const HTML_EXTENSIONS = new Set(['.htm', '.html'])
|
||||
@@ -221,7 +222,7 @@ export function localPreviewTarget(rawTarget: string, cwd?: string | null): Prev
|
||||
} catch {
|
||||
path = raw.replace(/^file:\/\//i, '')
|
||||
}
|
||||
} else if (!raw.startsWith('/') && cwd) {
|
||||
} else if (!raw.startsWith('/') && !isWindowsAbsolutePath(raw) && cwd) {
|
||||
path = joinPath(cwd, raw)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { cleanPath, comparisonPath, isUnderPath } from './path-compare'
|
||||
import { cleanPath, comparisonPath, isUnderPath, isWindowsAbsolutePath } from './path-compare'
|
||||
|
||||
describe('isWindowsAbsolutePath', () => {
|
||||
it.each(['C:\\Users\\me', 'd:/work', '\\\\server\\share'])('accepts %s', path => {
|
||||
expect(isWindowsAbsolutePath(path)).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['/home/me', './out', 'out\\report.html', 'C:relative', 'https://x.com'])('rejects %s', path => {
|
||||
expect(isWindowsAbsolutePath(path)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanPath', () => {
|
||||
it('unifies separators and drops trailing slashes', () => {
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
* case. Compare through these rather than `===` / `startsWith`.
|
||||
*/
|
||||
|
||||
/** Windows drive (`C:\\`, `C:/`) or UNC (`\\\\server\\share`) absolute path.
|
||||
* Such a path never gets joined onto a cwd. */
|
||||
export const isWindowsAbsolutePath = (path: string): boolean => /^(?:[A-Za-z]:[\\/]|\\\\)/.test(path)
|
||||
|
||||
/** POSIX-style spelling: one separator, no trailing slash. */
|
||||
export const cleanPath = (path: string): string => path.trim().replace(/\\/g, '/').replace(/\/+$/, '') || '/'
|
||||
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { extractPreviewTargets, previewTargetFromMarkdownHref, stripPreviewTargets } from './preview-targets'
|
||||
import {
|
||||
extractPreviewTargets,
|
||||
previewName,
|
||||
previewTargetFromMarkdownHref,
|
||||
stripPreviewTargets
|
||||
} from './preview-targets'
|
||||
|
||||
describe('previewName', () => {
|
||||
// #85132: `new URL('C:\\...')` parses the drive letter as a scheme, which
|
||||
// labelled the tag with the whole backslash path.
|
||||
it.each([
|
||||
'C:\\Users\\me\\report.html',
|
||||
'C:/Users/me/report.html',
|
||||
'\\\\server\\share\\report.html',
|
||||
'/Users/me/report.html'
|
||||
])('labels %s by its file name', target => {
|
||||
expect(previewName(target)).toBe('report.html')
|
||||
})
|
||||
})
|
||||
|
||||
describe('preview target detection', () => {
|
||||
it('does not infer preview targets from raw paths or URLs', () => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isWindowsAbsolutePath } from '@/lib/path-compare'
|
||||
|
||||
const PREVIEW_MARKDOWN_RE = /\[Preview:[^\]]+\]\((?<href>#preview[:/][^)]+)\)/gi
|
||||
|
||||
export function stripPreviewTargets(text: string): string {
|
||||
@@ -37,6 +39,11 @@ export function previewTargetFromMarkdownHref(href?: string): string | null {
|
||||
}
|
||||
|
||||
export function previewName(target: string): string {
|
||||
// `new URL('C:\\...')` would read the drive letter as a URL scheme.
|
||||
if (isWindowsAbsolutePath(target)) {
|
||||
return target.split(/[\\/]/).filter(Boolean).pop() || target
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(target)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user