diff --git a/packages/app/src/components/dialog-select-file-ref.tsx b/packages/app/src/components/dialog-select-file-ref.tsx new file mode 100644 index 000000000000..e69c496ab003 --- /dev/null +++ b/packages/app/src/components/dialog-select-file-ref.tsx @@ -0,0 +1,40 @@ +import { Dialog } from "@opencode-ai/ui/dialog" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { List } from "@opencode-ai/ui/list" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { useLanguage } from "@/context/language" + +export function DialogSelectFileRef(props: { paths: string[]; onSelect: (path: string) => void }) { + const dialog = useDialog() + const language = useLanguage() + const items = () => props.paths.map((path) => ({ path })) + return ( + + item.path} + items={items} + filterKeys={["path"]} + onSelect={(item) => { + if (!item) return + dialog.close() + props.onSelect(item.path) + }} + > + {(item) => ( +
+ +
+ + {getDirectory(item.path)} + + {getFilename(item.path)} +
+
+ )} +
+
+ ) +} diff --git a/packages/app/src/context/file/path.test.ts b/packages/app/src/context/file/path.test.ts index feef6d466ef4..6c16d941d88e 100644 --- a/packages/app/src/context/file/path.test.ts +++ b/packages/app/src/context/file/path.test.ts @@ -6,10 +6,12 @@ describe("file path helpers", () => { const path = createPathHelpers(() => "/repo") expect(path.normalize("file:///repo/src/app.ts?x=1#h")).toBe("src/app.ts") expect(path.normalize("/repo/src/app.ts")).toBe("src/app.ts") + expect(path.normalize("/other/place/app.ts")).toBe("other/place/app.ts") expect(path.normalize("./src/app.ts")).toBe("src/app.ts") expect(path.normalizeDir("src/components///")).toBe("src/components") expect(path.tab("src/app.ts")).toBe("file://src/app.ts") expect(path.pathFromTab("file://src/app.ts")).toBe("src/app.ts") + expect(path.pathFromTab("file:///other/place/app.ts")).toBe("other/place/app.ts") expect(path.pathFromTab("other://src/app.ts")).toBeUndefined() }) @@ -19,6 +21,8 @@ describe("file path helpers", () => { expect(path.normalize("C:/repo/src/app.ts")).toBe("src/app.ts") expect(path.normalize("file://C:/repo/src/app.ts")).toBe("src/app.ts") expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src\\app.ts") + expect(path.normalize("D:\\other\\app.ts")).toBe("D:\\other\\app.ts") + expect(path.normalize("file:///D:/other/app.ts")).toBe("D:/other/app.ts") }) test("keeps query/hash stripping behavior stable", () => { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 917de35b1f20..68c39f071f86 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,5 +1,6 @@ import type { Project, UserMessage } from "@opencode-ai/sdk/v2" import { useDialog } from "@opencode-ai/ui/context/dialog" +import { FileRefProvider, type MessageFileRef } from "@opencode-ai/ui/context/file-ref" import { useMutation } from "@tanstack/solid-query" import { batch, @@ -27,7 +28,9 @@ import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { Button } from "@opencode-ai/ui/button" import { showToast } from "@opencode-ai/ui/toast" import { checksum } from "@opencode-ai/util/encode" +import { getFilename } from "@opencode-ai/util/path" import { useSearchParams } from "@solidjs/router" +import { DialogSelectFileRef } from "@/components/dialog-select-file-ref" import { NewSessionView, SessionHeader } from "@/components/session" import { useComments } from "@/context/comments" import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch" @@ -963,6 +966,95 @@ export default function Page() { setActive: tabs().setActive, loadFile: file.load, }) + const roots = createMemo(() => { + const out = [sdk.directory] + const project = sync.project + if (!project) return out + for (const dir of [project.worktree, ...(project.sandboxes ?? [])]) { + if (!out.includes(dir)) out.push(dir) + } + return out + }) + + const openSessionFile = (path: string, ref?: MessageFileRef) => { + const next = file.normalize(path).replace(/\\/g, "/") + const tab = file.tab(next) + if (ref?.line) { + file.setSelectedLines(next, { start: ref.line, end: ref.end ?? ref.line }) + } else { + file.setSelectedLines(next, null) + } + tabs().open(tab) + file.load(next, { force: true }) + if (!view().reviewPanel.opened()) view().reviewPanel.open() + layout.fileTree.setTab("all") + tabs().setActive(tab) + } + + const within = (root: string, file: string) => { + const a = root.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase() + const b = file.replace(/\\/g, "/").toLowerCase() + return b === a || b.startsWith(a + "/") + } + + const findFiles = (query: string, directory: string) => + sdk.client.find.files({ query, directory, dirs: "false", limit: 50 }).then( + (x) => (x.data ?? []).map((item) => `${directory.replace(/\\/g, "/")}/${item.replace(/\\/g, "/")}`), + () => [], + ) + + const matchFileRef = async (input: string) => { + const raw = input.trim() + if (!raw) return [] + + const full = raw.replace(/\\/g, "/").replace(/^\.\//, "") + if (/^[A-Za-z]:\//.test(full) || full.startsWith("/") || full.startsWith("//")) { + return [full] + } + + const root = roots().find((item) => within(item, full)) + const exact = root + ? full.slice(root.replace(/\\/g, "/").replace(/\/+$/, "").length).replace(/^\//, "") + : file.normalize(full).replace(/\\/g, "/") + if (root) { + return [`${root.replace(/\\/g, "/")}/${exact}`] + } + + const name = full.split("/").filter(Boolean).at(-1) ?? full + const [a, b] = await Promise.all([ + Promise.all(roots().map((directory) => findFiles(full, directory))).then((all) => all.flat()), + name !== full + ? Promise.all(roots().map((directory) => findFiles(name, directory))).then((all) => all.flat()) + : Promise.resolve([]), + ]) + const list = [...new Set([...a, ...b])] + const norm = (item: string) => item.replace(/\\/g, "/") + const hit = full.includes("/") + ? list.filter((item) => norm(item) === exact || norm(item).endsWith(`/${exact}`)) + : list.filter((item) => getFilename(item).toLowerCase() === name.toLowerCase()) + + return hit + } + + const openFileRef = async (ref: MessageFileRef) => { + const hit = await matchFileRef(ref.path) + + if (!hit?.length) { + showToast({ + variant: "error", + title: language.t("toast.file.loadFailed.title"), + description: ref.path, + }) + return + } + + if (hit.length === 1) { + openSessionFile(hit[0], ref) + return + } + + dialog.show(() => openSessionFile(path, ref)} />) + } const changesOptions = ["session", "turn"] as const const changesOptionsList = [...changesOptions] @@ -1745,46 +1837,48 @@ export default function Page() { - { - content = el - autoScroll.contentRef(el) - - const root = scroller - if (root) scheduleScrollState(root) - }} - turnStart={historyWindow.turnStart()} - historyMore={historyMore()} - historyLoading={historyLoading()} - onLoadEarlier={() => { - void historyWindow.loadAndReveal() - }} - renderedUserMessages={historyWindow.renderedUserMessages()} - anchor={anchor} - /> + + { + content = el + autoScroll.contentRef(el) + + const root = scroller + if (root) scheduleScrollState(root) + }} + turnStart={historyWindow.turnStart()} + historyMore={historyMore()} + historyLoading={historyLoading()} + onLoadEarlier={() => { + void historyWindow.loadAndReveal() + }} + renderedUserMessages={historyWindow.renderedUserMessages()} + anchor={anchor} + /> + diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index 8208b6c99852..be4b2a601dea 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -139,6 +139,44 @@ function createScrollSync(input: { tab: () => string; view: ReturnType { + const el = scroll + if (!el) return false + + const host = el.querySelector("diffs-container") + if (!(host instanceof HTMLElement)) return false + + const root = host.shadowRoot + if (!root) return false + + const first = + range.start <= range.end + ? { line: range.start, side: range.side } + : { line: range.end, side: range.endSide ?? range.side } + const rows = Array.from( + root.querySelectorAll(`[data-line="${first.line}"], [data-alt-line="${first.line}"]`), + ).filter((item): item is HTMLElement => item instanceof HTMLElement) + const line = + rows.find((item) => { + if (!first.side) return false + const type = item.dataset.lineType + if (type === "change-deletion") return first.side === "deletions" + if (type === "change-addition" || type === "change-additions") return first.side === "additions" + const code = item.closest("[data-code]") + if (!(code instanceof HTMLElement)) return first.side === "additions" + return code.hasAttribute("data-deletions") ? first.side === "deletions" : first.side === "additions" + }) ?? rows[0] + if (!(line instanceof HTMLElement)) return false + + sync() + + const top = line.getBoundingClientRect().top - el.getBoundingClientRect().top + el.scrollTop + const y = Math.max(0, top - Math.max(24, Math.floor(el.clientHeight / 3))) + if (Math.abs(el.scrollTop - y) > 1) el.scrollTo({ top: y, behavior: "auto" }) + save({ x: code[0]?.scrollLeft ?? el.scrollLeft, y }) + return true + } + const queueRestore = () => { if (restoreFrame !== undefined) return @@ -174,6 +212,7 @@ function createScrollSync(input: { tab: () => string; view: ReturnType { + 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) +}