diff --git a/packages/app/src/context/open-file-path.tsx b/packages/app/src/context/open-file-path.tsx new file mode 100644 index 000000000000..4ec302d2b4e3 --- /dev/null +++ b/packages/app/src/context/open-file-path.tsx @@ -0,0 +1,20 @@ +import { createSimpleContext } from "@opencode-ai/ui/context" +import { usePlatform } from "@/context/platform" +import { openFilePath, type OpenFileInput } from "@/utils/open-file-path" + +export const { use: useOpenFilePath, provider: OpenFilePathProvider } = createSimpleContext({ + name: "OpenFilePath", + init: (props: { directory: string }) => { + const platform = usePlatform() + + return { + open(input: OpenFileInput) { + return openFilePath({ + directory: props.directory, + input, + platform, + }) + }, + } + }, +}) diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 427b4823b5f8..5a06420d2e08 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -1,46 +1,10 @@ -import { DataProvider } from "@opencode-ai/ui/context" import { showToast } from "@opencode-ai/ui/toast" -import { base64Encode } from "@opencode-ai/util/encode" -import { useLocation, useNavigate, useParams } from "@solidjs/router" +import { useNavigate, useParams } from "@solidjs/router" import { createEffect, createMemo, type ParentProps, Show } from "solid-js" import { useLanguage } from "@/context/language" -import { LocalProvider } from "@/context/local" -import { SDKProvider } from "@/context/sdk" -import { SyncProvider, useSync } from "@/context/sync" +import { DirectoryProviders } from "@/pages/directory-providers" import { decode64 } from "@/utils/base64" -function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { - const location = useLocation() - const navigate = useNavigate() - const params = useParams() - const sync = useSync() - const slug = createMemo(() => base64Encode(props.directory)) - - createEffect(() => { - const next = sync.data.path.directory - if (!next || next === props.directory) return - const path = location.pathname.slice(slug().length + 1) - navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) - }) - - createEffect(() => { - const id = params.id - if (!id) return - void sync.session.sync(id) - }) - - return ( - navigate(`/${slug()}/session/${sessionID}`)} - onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`} - > - {props.children} - - ) -} - export default function Layout(props: ParentProps) { const params = useParams() const language = useLanguage() @@ -71,13 +35,7 @@ export default function Layout(props: ParentProps) { return ( - {(resolved) => ( - resolved}> - - {props.children} - - - )} + {(resolved) => {props.children}} ) } diff --git a/packages/app/src/pages/directory-providers.tsx b/packages/app/src/pages/directory-providers.tsx new file mode 100644 index 000000000000..9469a59f4696 --- /dev/null +++ b/packages/app/src/pages/directory-providers.tsx @@ -0,0 +1,54 @@ +import { DataProvider } from "@opencode-ai/ui/context" +import { base64Encode } from "@opencode-ai/util/encode" +import { useLocation, useNavigate, useParams } from "@solidjs/router" +import { createEffect, createMemo, type ParentProps } from "solid-js" +import { LocalProvider } from "@/context/local" +import { OpenFilePathProvider, useOpenFilePath } from "@/context/open-file-path" +import { SDKProvider } from "@/context/sdk" +import { SyncProvider, useSync } from "@/context/sync" + +function DirectoryData(props: ParentProps<{ directory: string }>) { + const location = useLocation() + const navigate = useNavigate() + const params = useParams() + const sync = useSync() + const slug = createMemo(() => base64Encode(props.directory)) + const open = useOpenFilePath() + + createEffect(() => { + const next = sync.data.path.directory + if (!next || next === props.directory) return + const path = location.pathname.slice(slug().length + 1) + navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) + }) + + createEffect(() => { + const id = params.id + if (!id) return + void sync.session.sync(id) + }) + + return ( + navigate(`/${slug()}/session/${sessionID}`)} + onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`} + onOpenFilePath={open.open} + > + {props.children} + + ) +} + +export function DirectoryProviders(props: ParentProps<{ directory: string }>) { + return ( + props.directory}> + + + {props.children} + + + + ) +} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 917de35b1f20..50335543cbe0 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -56,6 +56,7 @@ import { SessionSidePanel } from "@/pages/session/session-side-panel" import { TerminalPanel } from "@/pages/session/terminal-panel" import { useSessionCommands } from "@/pages/session/use-session-commands" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" +import { useSessionOpenFile } from "@/pages/session/use-session-open-file" import { Identifier } from "@/utils/id" import { Persist, persisted } from "@/utils/persist" import { extractPromptFromParts } from "@/utils/prompt" @@ -958,12 +959,23 @@ export default function Page() { const openReviewFile = createOpenReviewFile({ showAllFiles, + openReviewPanel, tabForPath: file.tab, openTab: tabs().open, setActive: tabs().setActive, + getFile: file.get, + setSelectedLines: file.setSelectedLines, + onLineError: ({ path, line, max }) => { + showToast({ + variant: "default", + title: "Line unavailable", + description: `${path}:${line} is out of range, opened line ${max} instead.`, + }) + }, loadFile: file.load, }) + useSessionOpenFile(openReviewFile) const changesOptions = ["session", "turn"] as const const changesOptionsList = [...changesOptions] diff --git a/packages/app/src/pages/session/helpers.test.ts b/packages/app/src/pages/session/helpers.test.ts index 95f7cd384db8..fa78486d154d 100644 --- a/packages/app/src/pages/session/helpers.test.ts +++ b/packages/app/src/pages/session/helpers.test.ts @@ -15,18 +15,66 @@ describe("createOpenReviewFile", () => { const calls: string[] = [] const openReviewFile = createOpenReviewFile({ showAllFiles: () => calls.push("show"), + openReviewPanel: () => calls.push("review"), tabForPath: (path) => { calls.push(`tab:${path}`) return `file://${path}` }, openTab: (tab) => calls.push(`open:${tab}`), setActive: (tab) => calls.push(`active:${tab}`), + getFile: () => ({ content: { content: "one\ntwo" } }), + setSelectedLines: (path, range) => calls.push(`select:${path}:${range ? `${range.start}-${range.end}` : "none"}`), loadFile: (path) => calls.push(`load:${path}`), }) openReviewFile("src/a.ts") - expect(calls).toEqual(["show", "load:src/a.ts", "tab:src/a.ts", "open:file://src/a.ts", "active:file://src/a.ts"]) + expect(calls).toEqual([ + "tab:src/a.ts", + "show", + "review", + "load:src/a.ts", + "open:file://src/a.ts", + "active:file://src/a.ts", + "select:src/a.ts:none", + ]) + }) + + test("selects the requested line when provided", () => { + const calls: string[] = [] + const openReviewFile = createOpenReviewFile({ + showAllFiles: () => calls.push("show"), + openReviewPanel: () => calls.push("review"), + tabForPath: (path) => `file://${path}`, + openTab: () => calls.push("open"), + setActive: () => calls.push("active"), + getFile: () => ({ content: { content: "one\n".repeat(20) } }), + setSelectedLines: (_path, range) => calls.push(`select:${range?.start}-${range?.end}`), + loadFile: () => calls.push("load"), + }) + + openReviewFile("src/a.ts", 12) + + expect(calls).toContain("select:12-12") + }) + + test("clamps out of range lines", () => { + const calls: string[] = [] + const openReviewFile = createOpenReviewFile({ + showAllFiles: () => undefined, + openReviewPanel: () => undefined, + tabForPath: (path) => `file://${path}`, + openTab: () => undefined, + setActive: () => undefined, + getFile: () => ({ content: { content: "one\ntwo" } }), + setSelectedLines: (_path, range) => calls.push(`select:${range?.start}-${range?.end}`), + onLineError: ({ line, max }) => calls.push(`warn:${line}->${max}`), + loadFile: () => undefined, + }) + + openReviewFile("src/a.ts", 12) + + expect(calls).toEqual(["warn:12->2", "select:2-2"]) }) }) diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index 7e2c1ccf7b38..a552ff7385ba 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -105,19 +105,36 @@ export const createOpenReviewFile = (input: { tabForPath: (path: string) => string openTab: (tab: string) => void setActive: (tab: string) => void + openReviewPanel: () => void + setSelectedLines: (path: string, range: { start: number; end: number } | null) => void + getFile: (path: string) => { content?: { content?: unknown } } | undefined + onLineError?: (input: { path: string; line: number; max: number }) => void loadFile: (path: string) => any | Promise }) => { - return (path: string) => { + const lines = (value: unknown) => { + if (typeof value === "string") return Math.max(1, value.split("\n").length - (value.endsWith("\n") ? 1 : 0)) + if (Array.isArray(value)) return Math.max(1, value.length) + if (value == null) return 0 + const text = String(value) + return Math.max(1, text.split("\n").length - (text.endsWith("\n") ? 1 : 0)) + } + + return (path: string, line?: number) => { + const tab = input.tabForPath(path) batch(() => { input.showAllFiles() + input.openReviewPanel() const maybePromise = input.loadFile(path) - const open = () => { - const tab = input.tabForPath(path) + const openTab = () => { + const max = lines(input.getFile(path)?.content?.content) + const next = typeof line === "number" && max > 0 ? Math.max(1, Math.min(line, max)) : undefined + if (typeof line === "number" && max > 0 && next !== line) input.onLineError?.({ path, line, max }) input.openTab(tab) input.setActive(tab) + input.setSelectedLines(path, next ? { start: next, end: next } : null) } - if (maybePromise instanceof Promise) maybePromise.then(open) - else open() + if (maybePromise instanceof Promise) maybePromise.then(openTab) + else openTab() }) } } diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index c073e621472c..cd943b54ef6a 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -7,6 +7,7 @@ import type { SessionReviewCommentUpdate, } from "@opencode-ai/ui/session-review" import type { SelectedLineRange } from "@/context/file" +import { useOpenFilePath } from "@/context/open-file-path" import { useSDK } from "@/context/sdk" import { useLayout } from "@/context/layout" import type { LineComment } from "@/context/comments" @@ -45,6 +46,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) { const sdk = useSDK() const layout = useLayout() + const open = useOpenFilePath() const readFile = async (path: string) => { return sdk.client.file @@ -156,6 +158,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) { diffStyle={props.diffStyle} onDiffStyleChange={props.onDiffStyleChange} onViewFile={props.onViewFile} + onOpenFile={(file) => open.open({ path: file })} focusedFile={props.focusedFile} readFile={readFile} onLineComment={props.onLineComment} diff --git a/packages/app/src/pages/session/use-session-open-file.ts b/packages/app/src/pages/session/use-session-open-file.ts new file mode 100644 index 000000000000..034569ccb562 --- /dev/null +++ b/packages/app/src/pages/session/use-session-open-file.ts @@ -0,0 +1,15 @@ +import { onCleanup, onMount } from "solid-js" +import { OPEN_FILE_PATH_EVENT } from "@/utils/open-file-path" + +export const useSessionOpenFile = (open: (path: string, line?: number) => void) => { + onMount(() => { + const onOpen = (event: Event) => { + const detail = (event as CustomEvent<{ path?: string; line?: number }>).detail + if (!detail?.path) return + open(detail.path, detail.line) + } + + window.addEventListener(OPEN_FILE_PATH_EVENT, onOpen) + onCleanup(() => window.removeEventListener(OPEN_FILE_PATH_EVENT, onOpen)) + }) +} diff --git a/packages/app/src/utils/open-file-path.ts b/packages/app/src/utils/open-file-path.ts new file mode 100644 index 000000000000..3d9916836883 --- /dev/null +++ b/packages/app/src/utils/open-file-path.ts @@ -0,0 +1,33 @@ +import type { OpenFilePathFn } from "@opencode-ai/ui/context" +import { showToast } from "@opencode-ai/ui/toast" +import type { Platform } from "@/context/platform" + +export type OpenFileInput = Parameters[0] + +export const OPEN_FILE_PATH_EVENT = "opencode:open-file-path" + +export function dispatchOpenFilePath(input: OpenFileInput) { + window.dispatchEvent(new CustomEvent(OPEN_FILE_PATH_EVENT, { detail: input })) +} + +export function resolveOpenFilePath(dir: string, path: string) { + const file = path.replace(/^[\\/]+/, "") + const separator = dir.includes("\\") ? "\\" : "/" + return dir.endsWith(separator) ? dir + file : dir + separator + file +} + +export async function openFilePath(opts: { directory: string; input: OpenFileInput; platform: Platform }) { + if (opts.platform.platform !== "desktop" || !opts.platform.openPath) { + dispatchOpenFilePath(opts.input) + return + } + + await opts.platform.openPath(resolveOpenFilePath(opts.directory, opts.input.path)).catch((err) => { + showToast({ + variant: "error", + title: "Open failed", + description: err instanceof Error ? err.message : String(err), + }) + dispatchOpenFilePath(opts.input) + }) +} diff --git a/packages/ui/src/components/file.tsx b/packages/ui/src/components/file.tsx index 15915dd52d48..261e78bd0317 100644 --- a/packages/ui/src/components/file.tsx +++ b/packages/ui/src/components/file.tsx @@ -523,6 +523,30 @@ function scrollParent(el: HTMLElement): HTMLElement | undefined { } } +function reveal(viewer: Viewer, range: SelectedLineRange | null | undefined) { + if (!range) return + const line = Math.min(range.start, range.end) + requestAnimationFrame(() => { + const root = viewer.getRoot() + const wrap = viewer.wrapper + if (!root || !wrap) return + const path = range.side ? `[data-${range.side}] [data-line="${line}"]` : `[data-line="${line}"]` + const node = root.querySelector(path) ?? root.querySelector(`[data-line="${line}"]`) + if (!(node instanceof HTMLElement)) return + const parent = scrollParent(wrap) + if (!parent) { + node.scrollIntoView({ block: "center", inline: "nearest" }) + return + } + const box = parent.getBoundingClientRect() + const item = node.getBoundingClientRect() + const top = item.top - box.top + parent.scrollTop + const target = top - parent.clientHeight / 2 + item.height / 2 + const max = Math.max(0, parent.scrollHeight - parent.clientHeight) + parent.scrollTop = Math.max(0, Math.min(target, max)) + }) +} + function createLocalVirtualStrategy(host: () => HTMLDivElement | undefined, enabled: () => boolean): VirtualStrategy { let virtualizer: Virtualizer | undefined let root: Document | HTMLElement | undefined @@ -836,6 +860,11 @@ function TextViewer(props: TextFileProps) { }) } + createEffect(() => { + viewer.rendered() + reveal(viewer, local.selectedLines) + }) + useSearchHandle({ search: () => local.search, find: viewer.find, @@ -1027,6 +1056,11 @@ function DiffViewer(props: DiffFileProps) { }) } + createEffect(() => { + viewer.rendered() + reveal(viewer, local.selectedLines) + }) + useSearchHandle({ search: () => local.search, find: viewer.find, diff --git a/packages/ui/src/components/markdown-file-ref.test.ts b/packages/ui/src/components/markdown-file-ref.test.ts new file mode 100644 index 000000000000..2b066cccd307 --- /dev/null +++ b/packages/ui/src/components/markdown-file-ref.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test" +import { parseCodeFileRef, splitCodeText } from "./markdown-file-ref" + +describe("parseCodeFileRef", () => { + test("parses relative path with line and trims punctuation", () => { + expect(parseCodeFileRef("src/app.ts:42,", "")).toEqual({ + path: "src/app.ts", + line: 42, + }) + }) + + test("parses hash-based line suffix", () => { + expect(parseCodeFileRef("src/app.ts#L12", "")).toEqual({ + path: "src/app.ts", + line: 12, + }) + }) + + test("parses file urls and strips project root", () => { + expect(parseCodeFileRef("file:///Users/test/repo/src/main.ts:9", "/Users/test/repo")).toEqual({ + path: "src/main.ts", + line: 9, + }) + }) + + test("normalizes windows paths", () => { + expect(parseCodeFileRef("C:\\repo\\src\\main.ts:7", "")).toEqual({ + path: "C:/repo/src/main.ts", + line: 7, + }) + }) + + test("parses windows file url paths", () => { + expect(parseCodeFileRef("file:///C:/repo/src/main.ts#L11", "")).toEqual({ + path: "C:/repo/src/main.ts", + line: 11, + }) + }) + + test("normalizes line breaks inside long paths", () => { + expect(parseCodeFileRef("clients/notes/reply-to-\nharry-2026-02-27.md", "")).toEqual({ + path: "clients/notes/reply-to-harry-2026-02-27.md", + }) + }) + + test("ignores non-path text", () => { + expect(parseCodeFileRef("hello-world", "")).toBeUndefined() + }) +}) + +describe("splitCodeText", () => { + test("splits plain text file refs", () => { + expect(splitCodeText("See src/app.ts:42 for details", "")).toEqual([ + { text: "See " }, + { text: "src/app.ts:42", file: { path: "src/app.ts", line: 42 } }, + { text: " for details" }, + ]) + }) + + test("keeps plain text without refs intact", () => { + expect(splitCodeText("hello world", "")).toEqual([{ text: "hello world" }]) + }) +}) 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..ac794a856630 --- /dev/null +++ b/packages/ui/src/components/markdown-file-ref.ts @@ -0,0 +1,76 @@ +export type FileRef = { + path: string + line?: number +} + +export type FileText = { + text: string + file?: FileRef +} + +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 (/^\/[a-zA-Z]:\//.test(file)) return file.slice(1) + if (file.startsWith(root + "/")) return file.slice(root.length + 1) + if (file === root) return "" + if (file.startsWith("./")) return file.slice(2) + return file +} + +export function parseCodeFileRef(text: string, directory: string): FileRef | undefined { + let value = text + .trim() + .replace(/\s*\n\s*/g, "") + .replace(/[),.;!?]+$/, "") + let lineFromUrlHash: number | undefined + if (!value) return + + if (value.startsWith("file://")) { + try { + const url = new URL(value) + value = decodeURIComponent(url.pathname) + const match = url.hash.match(/^#L(\d+)$/) + lineFromUrlHash = match ? Number(match[1]) : undefined + } 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: lineFromUrlHash ?? lineFromHash ?? lineFromSuffix } +} + +export function splitCodeText(text: string, directory: string): FileText[] { + return (text.match(/\s+|[^\s]+/g) ?? []).reduce((list, item) => { + const file = parseCodeFileRef(item, directory) + if (file) return [...list, { text: item, file }] + const last = list.at(-1) + if (last && !last.file) { + last.text += item + return list + } + return [...list, { text: item }] + }, []) +} diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index f82723807d6c..4503915201de 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -264,3 +264,34 @@ text-decoration: underline; text-underline-offset: 2px; } + +[data-component="markdown"] button.file-link { + appearance: none; + border: none; + background: transparent; + display: inline; + padding: 0; + margin: 0; + color: inherit; + font: inherit; + text-align: left; + white-space: normal; + cursor: pointer; +} + +[data-component="markdown"] button.file-link:focus-visible { + outline: 1px solid var(--border-interactive-base); + outline-offset: 2px; + border-radius: 3px; +} + +[data-component="markdown"] button.file-link:hover, +[data-component="markdown"] button.file-link:focus-visible { + text-decoration: underline; + text-underline-offset: 2px; +} + +[data-component="markdown"] button.file-link:hover > code { + text-decoration: underline; + text-underline-offset: 2px; +} diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index ceab10df98ac..0dda68a268ce 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -1,5 +1,7 @@ import { useMarked } from "../context/marked" import { useI18n } from "../context/i18n" +import { useData } from "../context/data" +import { parseCodeFileRef, splitCodeText, type FileRef } from "./markdown-file-ref" import DOMPurify from "dompurify" import morphdom from "morphdom" import { checksum } from "@opencode-ai/util/encode" @@ -105,6 +107,16 @@ function createCopyButton(labels: CopyLabels) { return button } +function createFileButton(file: FileRef, text: string) { + 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)) + button.textContent = text + return button +} + function setCopyState(button: HTMLButtonElement, labels: CopyLabels, copied: boolean) { if (copied) { button.setAttribute("data-copied", "true") @@ -144,7 +156,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 ?? "") @@ -153,35 +165,72 @@ 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 file = parseCodeFileRef(code.textContent ?? "", directory) + if (!file) continue + + const button = createFileButton(file, "") + code.parentNode?.replaceChild(button, code) + button.appendChild(code) + } +} - 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) +function markTextLinks(root: HTMLDivElement, directory: string, openable: boolean) { + if (!openable) return + const walk = document.createTreeWalker(root, NodeFilter.SHOW_TEXT) + const list: Text[] = [] + let node = walk.nextNode() + while (node) { + if (node instanceof Text) list.push(node) + node = walk.nextNode() + } + + for (const item of list) { + const parent = item.parentElement + const text = item.textContent ?? "" + if (!parent || !text.trim()) continue + if (parent.closest("pre, code, a, button")) continue + const parts = splitCodeText(text, directory) + if (!parts.some((part) => part.file)) continue + const frag = document.createDocumentFragment() + for (const part of parts) { + if (!part.file) { + frag.appendChild(document.createTextNode(part.text)) + continue + } + frag.appendChild(createFileButton(part.file, part.text)) + } + item.parentNode?.replaceChild(frag, item) } } -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) + markTextLinks(root, directory, openable) } -function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) { +function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels, onFileOpen?: (input: FileRef) => void) { const timeouts = new Map>() const updateLabel = (button: HTMLButtonElement) => { @@ -194,6 +243,18 @@ function setupCodeCopy(root: HTMLDivElement, getLabels: () => 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") + const raw = file.getAttribute("data-file-line") + const line = raw ? Number(raw) : undefined + if (!path || !onFileOpen) return + event.preventDefault() + event.stopPropagation() + 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") @@ -247,6 +308,7 @@ export function Markdown( ) { const [local, others] = splitProps(props, ["text", "cacheKey", "streaming", "class", "classList"]) const marked = useMarked() + const data = useData() const i18n = useI18n() const [root, setRoot] = createSignal() const [html] = createResource( @@ -304,7 +366,7 @@ export function Markdown( } const temp = document.createElement("div") temp.innerHTML = content - decorate(temp, labels) + decorate(temp, labels, data.directory, !!data.openFilePath) morphdom(container, temp, { childrenOnly: true, @@ -323,11 +385,15 @@ export function Markdown( }, }) - if (!copyCleanup) - copyCleanup = setupCodeCopy(container, () => ({ + if (copyCleanup) copyCleanup() + copyCleanup = setupCodeCopy( + container, + () => ({ copy: i18n.t("ui.message.copy"), copied: i18n.t("ui.message.copied"), - })) + }), + data.openFilePath, + ) }) onCleanup(() => { diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index d9893503fbda..68a6e8d7e344 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -412,6 +412,29 @@ white-space: pre-wrap; overflow-wrap: anywhere; } + + button.file-link { + appearance: none; + border: none; + background: transparent; + color: inherit; + font: inherit; + padding: 0; + cursor: pointer; + text-align: left; + } + + button.file-link:hover, + button.file-link:focus-visible { + text-decoration: underline; + text-underline-offset: 2px; + } + + button.file-link:focus-visible { + outline: 1px solid var(--border-interactive-base); + outline-offset: 2px; + border-radius: 3px; + } } [data-component="edit-trigger"], @@ -1209,6 +1232,27 @@ white-space: nowrap; } + button[data-slot="apply-patch-filename"] { + appearance: none; + border: none; + background: transparent; + padding: 0; + margin: 0; + text-align: left; + color: inherit; + font: inherit; + line-height: inherit; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; + + &:focus-visible { + outline: 1px solid var(--border-interactive-base); + outline-offset: 2px; + border-radius: 2px; + } + } + [data-slot="apply-patch-trigger-actions"] { flex-shrink: 0; display: flex; diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 1555a09a079b..24786976e33b 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -52,9 +52,11 @@ import { IconButton } from "./icon-button" import { TextShimmer } from "./text-shimmer" import { AnimatedCountList } from "./tool-count-summary" import { ToolStatusTitle } from "./tool-status-title" +import { ToolFile } from "./tool-file" import { animate } from "motion" import { useLocation } from "@solidjs/router" import { attached, inline, kind } from "./message-file" +import { splitCodeText, type FileRef } from "./markdown-file-ref" function ShellSubmessage(props: { text: string; animate?: boolean }) { let widthRef: HTMLSpanElement | undefined @@ -248,6 +250,36 @@ function getDirectory(path: string | undefined) { return relativizeProjectPath(_getDirectory(path), data.directory) } +function openProjectFile( + path: string | undefined, + directory: string, + openFilePath?: (input: FileRef) => void, + line?: number, +) { + if (!path) return + const file = relativizeProjectPath(path, directory).replace(/^\//, "") + if (!file) return + openFilePath?.({ path: file, line }) +} + +function LinkText(props: { text: string }) { + const data = useData() + const parts = createMemo(() => splitCodeText(props.text, data.directory)) + + return ( + + {(part) => { + if (!part.file || !data.openFilePath) return part.text + return ( + + ) + }} + + ) +} + import type { IconProps } from "./icon" export type ToolInfo = { @@ -1185,7 +1217,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 ( @@ -1199,15 +1236,11 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre
-
- -
- - {`\u202A${getDirectory(props.path)}\u202C`} - - {getFilename(props.path)} -
-
+
{props.actions} @@ -1769,7 +1802,9 @@ ToolRegistry.register({
-              {text()}
+              
+                
+              
             
@@ -1781,6 +1816,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)) @@ -1821,6 +1857,7 @@ ToolRegistry.register({ openProjectFile(path(), data.directory, data.openFilePath)} actions={ @@ -1853,6 +1890,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)) @@ -1887,7 +1925,10 @@ ToolRegistry.register({ } > - + openProjectFile(path(), data.directory, data.openFilePath)} + >
(props.metadata.files ?? []) as ApplyPatchFile[]) @@ -1994,15 +2036,11 @@ ToolRegistry.register({
-
- -
- - {`\u202A${getDirectory(file.relativePath)}\u202C`} - - {getFilename(file.relativePath)} -
-
+ openProjectFile(file.relativePath, data.directory, data.openFilePath)} + />
@@ -2083,6 +2121,7 @@ ToolRegistry.register({ > openProjectFile(single()!.relativePath, data.directory, data.openFilePath)} actions={ diff --git a/packages/ui/src/components/session-review.css b/packages/ui/src/components/session-review.css index 6b5b9ac8627c..c30f4738b5b8 100644 --- a/packages/ui/src/components/session-review.css +++ b/packages/ui/src/components/session-review.css @@ -131,6 +131,19 @@ white-space: nowrap; } + button[data-slot="session-review-filename"] { + border: none; + padding: 0; + background: transparent; + cursor: pointer; + text-align: left; + + &:hover, + &:focus-visible { + text-decoration: underline; + } + } + [data-slot="session-review-view-button"] { display: flex; align-items: center; diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 83d2980f61a2..33390fd9bf84 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -87,6 +87,7 @@ export interface SessionReviewProps { actions?: JSX.Element diffs: ReviewDiff[] onViewFile?: (file: string) => void + onOpenFile?: (file: string) => void readFile?: (path: string) => Promise } @@ -166,7 +167,8 @@ export const SessionReview = (props: SessionReviewProps) => { handleChange(next) } - const openFileLabel = () => i18n.t("ui.sessionReview.openFile") + const openReviewLabel = () => i18n.t("ui.sessionReview.openInReview") + const openExternalLabel = () => i18n.t("ui.sessionReview.openExternally") const selectionSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions" @@ -407,17 +409,34 @@ export const SessionReview = (props: SessionReviewProps) => { {`\u202A${getDirectory(file)}\u202C`} - {getFilename(file)} - - + {getFilename(file)}} + > + + + + + + diff --git a/packages/ui/src/components/tool-file.tsx b/packages/ui/src/components/tool-file.tsx new file mode 100644 index 000000000000..d97d744fb058 --- /dev/null +++ b/packages/ui/src/components/tool-file.tsx @@ -0,0 +1,28 @@ +import { Show } from "solid-js" +import { FileIcon } from "./file-icon" +import { getFilename } from "@opencode-ai/util/path" + +export function ToolFile(props: { path: string; dir?: string; onClick?: () => void }) { + return ( +
+ +
+ + {`\u202A${props.dir}\u202C`} + + {getFilename(props.path)}}> + + +
+
+ ) +} diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index e116199eb233..5fe5dc8aa906 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -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, } }, }) diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 18823aeaa19b..b4a197341dd0 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -14,6 +14,8 @@ export const dict: Record = { "ui.sessionReview.largeDiff.meta": "Limit: {{limit}} changed lines. Current: {{current}} changed lines.", "ui.sessionReview.largeDiff.renderAnyway": "Render anyway", "ui.sessionReview.openFile": "Open file", + "ui.sessionReview.openInReview": "Open in review", + "ui.sessionReview.openExternally": "Open externally", "ui.sessionReview.selection.line": "line {{line}}", "ui.sessionReview.selection.lines": "lines {{start}}-{{end}}",