fix: address Copilot review (9 comments)

#1 GitView: hide/disable 'open in editor' for external linked worktrees
   (path outside session workspace is rejected by the fence); add
   isWithinWorkspace client helper for the gate.
#2 DiffTab: untracked diff read resolves against diff.repoRoot (child-repo
   paths are repo-root-relative, not cwd-relative); fs.read route threads
   selectedRepoOf into resolveGitPath so the host resolves the right base.
#3 GitView: chooseRepo handler mirrors chooseWorktree — clears status/
   history/log and bumps generation before async refresh, preventing stale
   rows from acting on the newly selected repository.
#4 git.ts: repoRoots/repoRoot compare roots via pathIdentity() (platform-
   aware) instead of raw includes(), fixing win32 forward/backslash
   mismatch that silently fell back to roots[0].
#5 markdown-images: mask fenced code blocks and inline code spans before
   rewriting; only rewrite reference definitions whose label is actually
   referenced by image syntax (not plain links). +3 tests.
#6 package.json: downgrade unrun ^0.3.1 → ^0.2.39 (0.3.x requires Node
   ^22.13, incompatible with declared engines >=20).
#7 market-manifest.spec: shell:true on win32 so execFileSync resolves the
   pnpm .cmd shim.
#8 smoke.spec: build html-route URL via encodeHtmlUrl() (production
   encoder) so the Windows drive separator is correct.
#9 index.ts git.worktrees route: thread selectedRepoOf so a workspace
   container's selected child repo's linked worktrees appear.
This commit is contained in:
HuanLinOTO
2026-08-23 22:02:47 +08:00
parent 9410e5bb2a
commit 751fb954a2
12 changed files with 793 additions and 28 deletions
+38 -3
View File
@@ -50,6 +50,14 @@ function normalizeLocalPath(path: string): string {
* local paths pass through. Remote (http/https/data/mailto) and `#`-anchor
* destinations are left untouched for `MarkdownText`. Reference-style images
* (`![x][id]` + `[id]: url`) are covered by rewriting their definition lines.
*
* Code spans (`` `...` ``) and fenced code blocks (``` ```...``` ```) are
* masked before rewriting so documentation that demonstrates `![alt](./img.png)`
* is not mutated into a `/sidebar/file` URL. Reference definitions are only
* rewritten when their label is actually referenced by an image (collapsed
* `[![][id]]`, full `![alt][id]`, or shortcut `![]` referencing the next
* definition) — a plain link `[text][id]` must not have its destination
* redirected to the media route.
* @param text - The raw markdown source (inline + reference images).
* @param scope - The session scope (sessionId + cwd) for the media route.
* @param filePath - The absolute path of the opened `.md` file.
@@ -79,11 +87,38 @@ export function rewriteLocalImageUrls(
const candidate = isAbsolutePath(trimmed) ? trimmed : directory + trimmed
return mediaUrl(normalizeLocalPath(candidate))
}
const inline = text.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_match, alt, dest) => {
// Mask fenced code blocks and inline code spans so image-looking text
// inside documentation examples is never rewritten. The sentinel uses a
// character unlikely to appear in prose; the original spans are restored
// after the image rewrite.
const masks: string[] = []
const masked = text
.replace(/```[\s\S]*?```/g, (block) => { masks.push(block); return `\u0000${masks.length - 1}\u0000` })
.replace(/`[^`\n]*`/g, (span) => { masks.push(span); return `\u0000${masks.length - 1}\u0000` })
const inline = masked.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_match, alt, dest) => {
return `![${alt}](${resolve(dest)})`
})
// Reference-style images: rewrite their `[id]: <url>` definitions.
return inline.replace(/^(\s*\[[^\]]+\]:\s*)(<[^>]+>|[^\s]+)/gm, (_match, head, dest) => {
// Collect labels referenced by image syntax (full `![alt][id]` and
// collapsed `![][id]`) so only those reference definitions are rewritten.
// A shortcut reference (`![alt]` with no `[id]`) resolves to the label
// text `alt` itself; include it too. Plain links `[text][id]` never match
// the leading `!` and are left untouched.
const imageLabels = new Set<string>()
const labelRe = /!\[([^\]]*)\](?:\[((?:[^\][]|\[[^\]]*\])*)\])?/g
let labelMatch: RegExpExecArray | null
while ((labelMatch = labelRe.exec(inline)) !== null) {
const alt = labelMatch[1] ?? ''
const ref = labelMatch[2]
imageLabels.add(ref !== undefined && ref !== '' ? ref.toLowerCase() : alt.toLowerCase())
}
const refsRewritten = inline.replace(/^(\s*\[([^\]]+)\]:\s*)(<[^>]+>|[^\s]+)/gm, (match, head: string, label: string, dest: string) => {
if (!imageLabels.has(label.toLowerCase())) return match
return `${head}${resolve(dest.replace(/^<|>$/g, ''))}`
})
return refsRewritten.replace(/\u0000(\d+)\u0000/g, (_m, index: string) => masks[Number(index)] ?? '')
}