feat(ui): make file references clickable in session output

Add clickable file links for inline markdown path references and edit/write/apply_patch file entries so desktop and web users can open changed files directly from the timeline.
This commit is contained in:
anduimagui
2026-02-26 18:38:48 +00:00
parent 38704acacd
commit 5fee541d52
6 changed files with 197 additions and 31 deletions

View File

@@ -21,6 +21,13 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
directory={props.directory}
onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)}
onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`}
onOpenFilePath={(input) => {
window.dispatchEvent(
new CustomEvent("opencode:open-file-path", {
detail: input,
}),
)
}}
>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>

View File

@@ -504,6 +504,18 @@ export default function Page() {
loadFile: file.load,
})
onMount(() => {
const open = (event: Event) => {
const detail = (event as CustomEvent<{ path?: string }>).detail
const path = detail?.path
if (!path) return
openReviewFile(path)
}
window.addEventListener("opencode:open-file-path", open)
onCleanup(() => window.removeEventListener("opencode:open-file-path", open))
})
const changesOptions = ["session", "turn"] as const
const changesOptionsList = [...changesOptions]

View File

@@ -258,3 +258,19 @@
text-decoration: underline;
text-underline-offset: 2px;
}
[data-component="markdown"] button.file-link {
appearance: none;
border: none;
background: transparent;
padding: 0;
margin: 0;
color: inherit;
font: inherit;
cursor: pointer;
}
[data-component="markdown"] button.file-link:hover > code {
text-decoration: underline;
text-underline-offset: 2px;
}

View File

@@ -1,5 +1,6 @@
import { useMarked } from "../context/marked"
import { useI18n } from "../context/i18n"
import { useData } from "../context/data"
import DOMPurify from "dompurify"
import morphdom from "morphdom"
import { checksum } from "@opencode-ai/util/encode"
@@ -49,6 +50,11 @@ type CopyLabels = {
copied: string
}
type FileRef = {
path: string
line?: number
}
const urlPattern = /^https?:\/\/[^\s<>()`"']+$/
function codeUrl(text: string) {
@@ -62,6 +68,53 @@ function codeUrl(text: string) {
}
}
function looksLikePath(path: string) {
if (!path) return false
if (path.startsWith("./") || path.startsWith("../") || path.startsWith("/")) return true
if (/^[a-zA-Z]:[\\/]/.test(path)) return true
return path.includes("/") || path.includes("\\")
}
function normalizeProjectPath(path: string, directory: string) {
if (!path) return path
const file = path.replace(/\\/g, "/")
const root = directory.replace(/\\/g, "/")
if (file.startsWith(root + "/")) return file.slice(root.length + 1)
if (file === root) return ""
if (file.startsWith("./")) return file.slice(2)
return file
}
function codeFileRef(text: string, directory: string): FileRef | undefined {
let value = text.trim().replace(/[),.;!?]+$/, "")
if (!value) return
if (value.startsWith("file://")) {
try {
const url = new URL(value)
value = decodeURIComponent(url.pathname)
} catch {
return
}
}
const hash = value.match(/#L(\d+)$/)
const lineFromHash = hash ? Number(hash[1]) : undefined
if (hash) value = value.slice(0, -hash[0].length)
const line = value.match(/:(\d+)(?::\d+)?$/)
const lineFromSuffix = line ? Number(line[1]) : undefined
if (line) {
const maybePath = value.slice(0, -line[0].length)
if (looksLikePath(maybePath)) value = maybePath
}
if (!looksLikePath(value)) return
const path = normalizeProjectPath(value, directory)
if (!path) return
return { path, line: lineFromHash ?? lineFromSuffix }
}
function createIcon(path: string, slot: string) {
const icon = document.createElement("div")
icon.setAttribute("data-component", "icon")
@@ -130,7 +183,7 @@ function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) {
}
}
function markCodeLinks(root: HTMLDivElement) {
function markCodeLinks(root: HTMLDivElement, directory: string, openable: boolean) {
const codeNodes = Array.from(root.querySelectorAll(":not(pre) > code"))
for (const code of codeNodes) {
const href = codeUrl(code.textContent ?? "")
@@ -139,35 +192,46 @@ function markCodeLinks(root: HTMLDivElement) {
? code.parentElement
: null
if (!href) {
if (parentLink) parentLink.replaceWith(code)
if (href) {
if (parentLink) {
parentLink.href = href
} else {
const link = document.createElement("a")
link.href = href
link.className = "external-link"
link.target = "_blank"
link.rel = "noopener noreferrer"
code.parentNode?.replaceChild(link, code)
link.appendChild(code)
}
continue
}
if (parentLink) {
parentLink.href = href
continue
}
if (parentLink) parentLink.replaceWith(code)
if (!openable) continue
const link = document.createElement("a")
link.href = href
link.className = "external-link"
link.target = "_blank"
link.rel = "noopener noreferrer"
code.parentNode?.replaceChild(link, code)
link.appendChild(code)
const file = codeFileRef(code.textContent ?? "", directory)
if (!file) continue
const button = document.createElement("button")
button.type = "button"
button.className = "file-link"
button.setAttribute("data-file-path", file.path)
if (file.line) button.setAttribute("data-file-line", String(file.line))
code.parentNode?.replaceChild(button, code)
button.appendChild(code)
}
}
function decorate(root: HTMLDivElement, labels: CopyLabels) {
function decorate(root: HTMLDivElement, labels: CopyLabels, directory: string, openable: boolean) {
const blocks = Array.from(root.querySelectorAll("pre"))
for (const block of blocks) {
ensureCodeWrapper(block, labels)
}
markCodeLinks(root)
markCodeLinks(root, directory, openable)
}
function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) {
function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels, onFileOpen?: (input: FileRef) => void) {
const timeouts = new Map<HTMLButtonElement, ReturnType<typeof setTimeout>>()
const updateLabel = (button: HTMLButtonElement) => {
@@ -179,6 +243,18 @@ function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) {
const target = event.target
if (!(target instanceof Element)) return
const file = target.closest("button.file-link")
if (file instanceof HTMLButtonElement) {
const path = file.getAttribute("data-file-path")
if (!path || !onFileOpen) return
event.preventDefault()
event.stopPropagation()
const raw = file.getAttribute("data-file-line")
const line = raw ? Number(raw) : undefined
onFileOpen({ path, line })
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")
@@ -194,8 +270,6 @@ function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) {
timeouts.set(button, timeout)
}
decorate(root, labels)
const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]'))
for (const button of buttons) {
if (button instanceof HTMLButtonElement) updateLabel(button)
@@ -232,6 +306,7 @@ export function Markdown(
) {
const [local, others] = splitProps(props, ["text", "cacheKey", "class", "classList"])
const marked = useMarked()
const data = useData()
const i18n = useI18n()
const [root, setRoot] = createSignal<HTMLDivElement>()
const [html] = createResource(
@@ -274,10 +349,15 @@ export function Markdown(
const temp = document.createElement("div")
temp.innerHTML = content
decorate(temp, {
copy: i18n.t("ui.message.copy"),
copied: i18n.t("ui.message.copied"),
})
decorate(
temp,
{
copy: i18n.t("ui.message.copy"),
copied: i18n.t("ui.message.copied"),
},
data.directory,
!!data.openFilePath,
)
morphdom(container, temp, {
childrenOnly: true,
@@ -290,10 +370,14 @@ export function Markdown(
if (copySetupTimer) clearTimeout(copySetupTimer)
copySetupTimer = setTimeout(() => {
if (copyCleanup) copyCleanup()
copyCleanup = setupCodeCopy(container, {
copy: i18n.t("ui.message.copy"),
copied: i18n.t("ui.message.copied"),
})
copyCleanup = setupCodeCopy(
container,
{
copy: i18n.t("ui.message.copy"),
copied: i18n.t("ui.message.copied"),
},
data.openFilePath,
)
}, 150)
})

View File

@@ -162,6 +162,17 @@ function getDirectory(path: string | undefined) {
return relativizeProjectPath(_getDirectory(path), data.directory)
}
function openProjectFile(
path: string | undefined,
directory: string,
openFilePath?: (input: { path: string }) => void,
) {
if (!path) return
const file = relativizeProjectPaths(path, directory).replace(/^\//, "")
if (!file) return
openFilePath?.({ path: file })
}
import type { IconProps } from "./icon"
export type ToolInfo = {
@@ -927,7 +938,12 @@ export const ToolRegistry = {
render: getTool,
}
function ToolFileAccordion(props: { path: string; actions?: JSX.Element; children: JSX.Element }) {
function ToolFileAccordion(props: {
path: string
actions?: JSX.Element
children: JSX.Element
onPathClick?: () => void
}) {
const value = createMemo(() => props.path || "tool-file")
return (
@@ -947,7 +963,17 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre
<Show when={props.path.includes("/")}>
<span data-slot="apply-patch-directory">{`\u202A${getDirectory(props.path)}\u202C`}</span>
</Show>
<span data-slot="apply-patch-filename">{getFilename(props.path)}</span>
<span
data-slot="apply-patch-filename"
classList={{ clickable: !!props.onPathClick }}
onClick={(event) => {
if (!props.onPathClick) return
event.stopPropagation()
props.onPathClick()
}}
>
{getFilename(props.path)}
</span>
</div>
</div>
<div data-slot="apply-patch-trigger-actions">
@@ -1463,6 +1489,7 @@ ToolRegistry.register({
ToolRegistry.register({
name: "edit",
render(props) {
const data = useData()
const i18n = useI18n()
const fileComponent = useFileComponent()
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
@@ -1505,6 +1532,7 @@ ToolRegistry.register({
<Show when={path()}>
<ToolFileAccordion
path={path()}
onPathClick={() => openProjectFile(path(), data.directory, data.openFilePath)}
actions={
<Show when={!pending() && props.metadata.filediff}>{(diff) => <DiffChanges changes={diff()} />}</Show>
}
@@ -1535,6 +1563,7 @@ ToolRegistry.register({
ToolRegistry.register({
name: "write",
render(props) {
const data = useData()
const i18n = useI18n()
const fileComponent = useFileComponent()
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
@@ -1571,7 +1600,10 @@ ToolRegistry.register({
}
>
<Show when={props.input.content && path()}>
<ToolFileAccordion path={path()}>
<ToolFileAccordion
path={path()}
onPathClick={() => openProjectFile(path(), data.directory, data.openFilePath)}
>
<div data-component="write-content">
<Dynamic
component={fileComponent}
@@ -1608,6 +1640,7 @@ interface ApplyPatchFile {
ToolRegistry.register({
name: "apply_patch",
render(props) {
const data = useData()
const i18n = useI18n()
const fileComponent = useFileComponent()
const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[])
@@ -1684,7 +1717,16 @@ ToolRegistry.register({
<Show when={file.relativePath.includes("/")}>
<span data-slot="apply-patch-directory">{`\u202A${getDirectory(file.relativePath)}\u202C`}</span>
</Show>
<span data-slot="apply-patch-filename">{getFilename(file.relativePath)}</span>
<span
data-slot="apply-patch-filename"
class="clickable"
onClick={(event) => {
event.stopPropagation()
openProjectFile(file.relativePath, data.directory, data.openFilePath)
}}
>
{getFilename(file.relativePath)}
</span>
</div>
</div>
<div data-slot="apply-patch-trigger-actions">
@@ -1770,6 +1812,7 @@ ToolRegistry.register({
>
<ToolFileAccordion
path={file().relativePath}
onPathClick={() => openProjectFile(file().relativePath, data.directory, data.openFilePath)}
actions={
<Switch>
<Match when={file().type === "add"}>

View File

@@ -26,6 +26,8 @@ export type NavigateToSessionFn = (sessionID: string) => void
export type SessionHrefFn = (sessionID: string) => string
export type OpenFilePathFn = (input: { path: string; line?: number }) => void
export const { use: useData, provider: DataProvider } = createSimpleContext({
name: "Data",
init: (props: {
@@ -33,6 +35,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
directory: string
onNavigateToSession?: NavigateToSessionFn
onSessionHref?: SessionHrefFn
onOpenFilePath?: OpenFilePathFn
}) => {
return {
get store() {
@@ -43,6 +46,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
navigateToSession: props.onNavigateToSession,
sessionHref: props.onSessionHref,
openFilePath: props.onOpenFilePath,
}
},
})