{
+ if (!a && !b) return true
+ if (!a || !b) return false
+ return a.start === b.start && a.end === b.end && a.side === b.side && a.endSide === b.endSide
+ }
+
const syncSelected = (range: SelectedLineRange | null) => {
const p = path()
if (!p) return
@@ -364,11 +409,19 @@ export function FileTabContent(props: { tab: string }) {
path,
() => {
commentsUi.note.reset()
+ setNote("selected", null)
},
{ defer: true },
),
)
+ createEffect(() => {
+ const range = selectedLines()
+ if (!note.selected) return
+ if (sameRange(note.selected, range)) return
+ setNote("selected", null)
+ })
+
createEffect(() => {
const focus = comments.focus()
const p = path()
@@ -394,6 +447,7 @@ export function FileTabContent(props: { tab: string }) {
ready: false,
active: false,
}
+ let reveal = ""
createEffect(() => {
const loaded = !!state()?.loaded
@@ -405,6 +459,31 @@ export function FileTabContent(props: { tab: string }) {
scrollSync.queueRestore()
})
+ createEffect(() => {
+ const range = selectedLines()
+ const active = activeFileTab() === props.tab
+ const loaded = !!state()?.loaded
+ const p = path()
+ if (!range || !active || !loaded || !p) {
+ if (!range) reveal = ""
+ return
+ }
+
+ const key = `${p}:${range.start}:${range.end}:${range.side ?? ""}:${range.endSide ?? ""}`
+ if (key === reveal) return
+
+ const run = (count: number) => {
+ if (count > 30) return
+ if (scrollSync.reveal(range)) {
+ reveal = key
+ return
+ }
+ requestAnimationFrame(() => run(count + 1))
+ }
+
+ requestAnimationFrame(() => run(0))
+ })
+
const renderFile = (source: string) => (
item.target)
+ const sorted = fuzzysort
+ .go(
+ query.replace(/[\\/]+/g, "/"),
+ items.map((item) => ({ item, path: item.replace(/[\\/]+/g, "/") })),
+ {
+ key: "path",
+ limit: searchLimit,
+ },
+ )
+ .map((item) => item.obj.item)
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
log.info("search", { query, kind, results: output.length })
diff --git a/packages/ui/src/components/markdown-file-ref.ts b/packages/ui/src/components/markdown-file-ref.ts
new file mode 100644
index 000000000000..b75b438bebd4
--- /dev/null
+++ b/packages/ui/src/components/markdown-file-ref.ts
@@ -0,0 +1,150 @@
+import type { MessageFileRef } from "../context/file-ref"
+
+const mark = "data-file-ref"
+const fmt = new Set(["CODE", "STRONG", "EM", "B", "I", "DEL", "S", "MARK"])
+const skip = new Set(["A", "PRE", "SCRIPT", "STYLE"])
+const plain = /(?:[A-Za-z]:[\\/])?[\w()[\]{}+./\\-]+(?:\.[A-Za-z0-9_+-]+)(?::\d+(?:-\d+)?)?/g
+const rich = /(?:[A-Za-z]:[\\/])?[\w()[\]{} +./\\-]+(?:\.[A-Za-z0-9_+-]+)(?::\d+(?:-\d+)?)?/g
+
+export type FileRefMatch = MessageFileRef & {
+ raw: string
+ absolute: boolean
+}
+
+function edge(text: string, start: number, end: number) {
+ const a = text[start - 1]
+ const b = text[end]
+ const left = !a || /[\s([{"'`>]/.test(a)
+ const right = !b || /[\s)\]}"'`<,.;!?]/.test(b)
+ return left && right
+}
+
+function split(text: string) {
+ const hit = text.match(/:(\d+)(?:-(\d+))?$/)
+ const base = hit ? text.slice(0, -hit[0].length) : text
+ const line = hit ? Number(hit[1]) : undefined
+ const end = hit?.[2] ? Number(hit[2]) : undefined
+ return { base, line, end }
+}
+
+function ok(base: string, rich: boolean) {
+ const text = base.trim()
+ if (!text) return
+ if (text.includes("://")) return
+ if (!rich && /\s/.test(text)) return
+ if (/\s/.test(text) && !/[\\/]/.test(text)) return
+ const norm = text.replace(/\\/g, "/")
+ const last = norm.split("/").filter(Boolean).at(-1)
+ if (!last || !last.includes(".")) return
+ const ext = last.split(".").at(-1)
+ if (!ext || !/^[A-Za-z0-9_+-]+$/.test(ext)) return
+ if (ext.length > 4) return
+ return text
+}
+
+export function isAbsoluteFileRef(path: string) {
+ return /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("/") || path.startsWith("\\\\")
+}
+
+function refs(text: string, richText: boolean) {
+ const rx = new RegExp((richText ? rich : plain).source, "g")
+ const out: Array = []
+ for (let m; (m = rx.exec(text)); ) {
+ const raw = m[0]
+ const start = m.index
+ const stop = start + raw.length
+ if (!edge(text, start, stop)) continue
+ const part = split(raw)
+ const path = ok(part.base, richText)
+ if (!path) continue
+ out.push({
+ raw,
+ path,
+ line: part.line,
+ end: part.end,
+ absolute: isAbsoluteFileRef(path),
+ start,
+ stop,
+ })
+ }
+ return out
+}
+
+function swap(node: Text, richText: boolean, allow: (ref: FileRefMatch) => boolean) {
+ const text = node.textContent
+ if (!text) return false
+ const out = document.createDocumentFragment()
+ let last = 0
+ let hit = false
+ for (const ref of refs(text, richText)) {
+ if (!allow(ref)) continue
+ const start = ref.start
+ const stop = ref.stop
+ if (start > last) out.append(text.slice(last, start))
+ const el = document.createElement("a")
+ el.href = "#"
+ el.setAttribute(mark, "")
+ el.dataset.path = ref.path
+ if (ref.line) el.dataset.line = String(ref.line)
+ if (ref.end) el.dataset.end = String(ref.end)
+ el.textContent = ref.raw
+ out.append(el)
+ last = stop
+ hit = true
+ }
+ if (!hit) return false
+ if (last < text.length) out.append(text.slice(last))
+ node.replaceWith(out)
+ return true
+}
+
+function richText(node: Text, root: HTMLDivElement) {
+ let el = node.parentElement
+ while (el && el !== root) {
+ if (skip.has(el.tagName)) return false
+ if (fmt.has(el.tagName)) return true
+ el = el.parentElement
+ }
+ return false
+}
+
+export function collectFileRefs(root: HTMLDivElement) {
+ const out: FileRefMatch[] = []
+ const walk = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
+ for (let node = walk.nextNode(); node; node = walk.nextNode()) {
+ if (!(node instanceof Text)) continue
+ if (!node.textContent?.trim()) continue
+ const el = node.parentElement
+ if (!el) continue
+ if (el.closest("a, pre, script, style")) continue
+ out.push(...refs(node.textContent, richText(node, root)).map(({ start: _, stop: __, ...ref }) => ref))
+ }
+ return out
+}
+
+export function decorateFileRefs(root: HTMLDivElement, allow: (ref: FileRefMatch) => boolean = () => true) {
+ const walk = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
+ const list: Text[] = []
+ for (let node = walk.nextNode(); node; node = walk.nextNode()) {
+ if (!(node instanceof Text)) continue
+ if (!node.textContent?.trim()) continue
+ const el = node.parentElement
+ if (!el) continue
+ if (el.closest("a, pre, script, style")) continue
+ list.push(node)
+ }
+ for (const node of list) {
+ swap(node, richText(node, root), allow)
+ }
+}
+
+export function readFileRef(target: EventTarget | null): MessageFileRef | undefined {
+ if (!(target instanceof Element)) return
+ const el = target.closest(`[${mark}]`)
+ if (!(el instanceof HTMLAnchorElement)) return
+ const path = el.dataset.path?.trim()
+ if (!path) return
+ const line = el.dataset.line ? Number(el.dataset.line) : undefined
+ const end = el.dataset.end ? Number(el.dataset.end) : undefined
+ return { path, line, end }
+}
diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx
index ceab10df98ac..fcfb0916e77e 100644
--- a/packages/ui/src/components/markdown.tsx
+++ b/packages/ui/src/components/markdown.tsx
@@ -1,10 +1,12 @@
import { useMarked } from "../context/marked"
+import { useFileRef } from "../context/file-ref"
import { useI18n } from "../context/i18n"
import DOMPurify from "dompurify"
import morphdom from "morphdom"
import { checksum } from "@opencode-ai/util/encode"
import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js"
import { isServer } from "solid-js/web"
+import { collectFileRefs, decorateFileRefs, readFileRef } from "./markdown-file-ref"
import { stream } from "./markdown-stream"
type Entry = {
@@ -14,7 +16,6 @@ type Entry = {
const max = 200
const cache = new Map()
-
if (typeof window !== "undefined" && DOMPurify.isSupported) {
DOMPurify.addHook("afterSanitizeAttributes", (node: Element) => {
if (!(node instanceof HTMLAnchorElement)) return
@@ -181,7 +182,11 @@ function decorate(root: HTMLDivElement, labels: CopyLabels) {
markCodeLinks(root)
}
-function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
+function setupCodeCopy(
+ root: HTMLDivElement,
+ getLabels: () => CopyLabels,
+ open?: (href: NonNullable>) => void | Promise,
+) {
const timeouts = new Map>()
const updateLabel = (button: HTMLButtonElement) => {
@@ -194,6 +199,13 @@ function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
const target = event.target
if (!(target instanceof Element)) return
+ const href = readFileRef(target)
+ if (href) {
+ event.preventDefault()
+ await open?.(href)
+ return
+ }
+
const button = target.closest('[data-slot="markdown-copy-button"]')
if (!(button instanceof HTMLButtonElement)) return
const code = button.closest('[data-component="markdown-code"]')?.querySelector("code")
@@ -247,6 +259,7 @@ export function Markdown(
) {
const [local, others] = splitProps(props, ["text", "cacheKey", "streaming", "class", "classList"])
const marked = useMarked()
+ const fileRef = useFileRef()
const i18n = useI18n()
const [root, setRoot] = createSignal()
const [html] = createResource(
@@ -286,6 +299,7 @@ export function Markdown(
)
let copyCleanup: (() => void) | undefined
+ let run = 0
createEffect(() => {
const container = root()
@@ -306,28 +320,64 @@ export function Markdown(
temp.innerHTML = content
decorate(temp, labels)
- morphdom(container, temp, {
- childrenOnly: true,
- onBeforeElUpdated: (fromEl, toEl) => {
- if (
- fromEl instanceof HTMLButtonElement &&
- toEl instanceof HTMLButtonElement &&
- fromEl.getAttribute("data-slot") === "markdown-copy-button" &&
- toEl.getAttribute("data-slot") === "markdown-copy-button" &&
- fromEl.getAttribute("data-copied") === "true"
- ) {
- setCopyState(toEl, labels, true)
- }
- if (fromEl.isEqualNode(toEl)) return false
- return true
- },
- })
-
- if (!copyCleanup)
- copyCleanup = setupCodeCopy(container, () => ({
- copy: i18n.t("ui.message.copy"),
- copied: i18n.t("ui.message.copied"),
- }))
+ const apply = () => {
+ morphdom(container, temp, {
+ childrenOnly: true,
+ onBeforeElUpdated: (fromEl, toEl) => {
+ if (
+ fromEl instanceof HTMLButtonElement &&
+ toEl instanceof HTMLButtonElement &&
+ fromEl.getAttribute("data-slot") === "markdown-copy-button" &&
+ toEl.getAttribute("data-slot") === "markdown-copy-button" &&
+ fromEl.getAttribute("data-copied") === "true"
+ ) {
+ setCopyState(toEl, labels, true)
+ }
+ if (fromEl.isEqualNode(toEl)) return false
+ return true
+ },
+ })
+
+ if (!copyCleanup) {
+ copyCleanup = setupCodeCopy(
+ container,
+ () => ({
+ copy: i18n.t("ui.message.copy"),
+ copied: i18n.t("ui.message.copied"),
+ }),
+ fileRef?.open,
+ )
+ }
+ }
+
+ const id = ++run
+ void (async () => {
+ const all = collectFileRefs(temp)
+ if (!all.length) {
+ if (id === run) apply()
+ return
+ }
+
+ const rel = [...new Set(all.filter((ref) => !ref.absolute).map((ref) => ref.path))]
+ if (!rel.length) {
+ decorateFileRefs(temp)
+ if (id === run) apply()
+ return
+ }
+
+ const keep = new Set(all.filter((ref) => ref.absolute).map((ref) => ref.path))
+ const hits = await Promise.all(
+ rel.map((path) => (fileRef?.match?.(path) ?? Promise.resolve([])).then((hit) => [path, hit] as const)),
+ )
+ for (const [path, hit] of hits) {
+ if (hit.length > 0) keep.add(path)
+ }
+
+ if (id !== run) return
+
+ decorateFileRefs(temp, (ref) => ref.absolute || keep.has(ref.path))
+ apply()
+ })()
})
onCleanup(() => {
diff --git a/packages/ui/src/context/file-ref.tsx b/packages/ui/src/context/file-ref.tsx
new file mode 100644
index 000000000000..094c04202a8d
--- /dev/null
+++ b/packages/ui/src/context/file-ref.tsx
@@ -0,0 +1,22 @@
+import { createContext, useContext, type ParentProps } from "solid-js"
+
+export type MessageFileRef = {
+ path: string
+ line?: number
+ end?: number
+}
+
+type Value = {
+ open: (ref: MessageFileRef) => void | Promise
+ match?: (path: string) => Promise
+}
+
+const ctx = createContext()
+
+export function FileRefProvider(props: ParentProps<{ value: Value }>) {
+ return {props.children}
+}
+
+export function useFileRef() {
+ return useContext(ctx)
+}