mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-24 14:55:19 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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"}>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user