diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 037b08c723a..2e41b29ecbd 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -3,6 +3,7 @@ import { useNavigate, useParams } from "@solidjs/router" import { SDKProvider, useSDK } from "@/context/sdk" import { SyncProvider, useSync } from "@/context/sync" import { LocalProvider } from "@/context/local" +import { useLayout } from "@/context/layout" import { DataProvider } from "@opencode-ai/ui/context" import { iife } from "@opencode-ai/util/iife" @@ -14,6 +15,7 @@ import { useLanguage } from "@/context/language" export default function Layout(props: ParentProps) { const params = useParams() const navigate = useNavigate() + const layout = useLayout() const language = useLanguage() const directory = createMemo(() => { return decode64(params.dir) ?? "" @@ -51,6 +53,34 @@ export default function Layout(props: ParentProps) { navigate(`/${params.dir}/session/${sessionID}`) } + const openFile = (path: string) => { + // Only works when we're in a session context + const sessionId = params.id + if (!sessionId) return + + const sessionKey = `${params.dir}${sessionId ? "/" + sessionId : ""}` + const tabs = layout.tabs(sessionKey) + + // Normalize path the same way file.tab() does + let normalized = path + if (normalized.startsWith("file://")) { + normalized = normalized.slice(7) + } + const root = directory() + if (root && normalized.startsWith(root)) { + normalized = normalized.slice(root.length) + } + if (normalized.startsWith("./")) { + normalized = normalized.slice(2) + } + if (normalized.startsWith("/")) { + normalized = normalized.slice(1) + } + + const tabValue = `file://${normalized}` + tabs.open(tabValue) + } + return ( {props.children} diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index ef43187336e..544699dace5 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -166,6 +166,7 @@ font-feature-settings: var(--font-family-mono--font-feature-settings); color: var(--syntax-string); font-weight: var(--font-weight-medium); + cursor: pointer; /* font-size: 13px; */ /* padding: 2px 2px; */ @@ -175,6 +176,11 @@ /* box-shadow: 0 0 0 0.5px var(--border-weak-base); */ } + :not(pre) > code:hover { + text-decoration: underline; + text-underline-offset: 2px; + } + /* Tables */ table { width: 100%; diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index e3102214bf5..34ca94362dc 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -1,4 +1,5 @@ import { useMarked } from "../context/marked" +import { useData } from "../context" import { useI18n } from "../context/i18n" import DOMPurify from "dompurify" import morphdom from "morphdom" @@ -6,6 +7,21 @@ import { checksum } from "@opencode-ai/util/encode" import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js" import { isServer } from "solid-js/web" +// Matches common file path patterns: +// - ./path/file.ext, ../path/file.ext, /absolute/path.ext +// - relative/path/file.ext, file.ext +function isFilePath(text: string): boolean { + // Must have a file extension + if (!/\.\w+$/.test(text)) return false + // Match paths starting with ./, ../, or / + if (/^\.{1,2}\/[\w\-./]+$/.test(text)) return true + // Match absolute paths + if (/^\/[\w\-./]+$/.test(text)) return true + // Match relative paths (word/word.ext pattern, must have at least one slash or be a simple filename) + if (/^[\w\-]+(\/[\w\-./]+)?$/.test(text)) return true + return false +} + type Entry = { hash: string html: string @@ -169,8 +185,24 @@ 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() + + const handleClick = (e: MouseEvent) => { + if (!data?.openFile) return + + const target = e.target as HTMLElement + // Check if clicked on inline code (not inside a code block) + if (target.tagName === "CODE" && target.parentElement?.tagName !== "PRE") { + const text = target.textContent?.trim() + if (text && isFilePath(text)) { + e.preventDefault() + data.openFile(text) + } + } + } + const [html] = createResource( () => local.text, async (markdown) => { @@ -258,6 +290,7 @@ export function Markdown( [local.class ?? ""]: !!local.class, }} ref={setRoot} + onClick={handleClick} {...others} /> ) diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index dcb9adb39c8..24e3c7bfa1a 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -48,6 +48,8 @@ export type QuestionRejectFn = (input: { requestID: string }) => void export type NavigateToSessionFn = (sessionID: string) => void +export type OpenFileFn = (path: string) => void + export const { use: useData, provider: DataProvider } = createSimpleContext({ name: "Data", init: (props: { @@ -57,6 +59,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ onQuestionReply?: QuestionReplyFn onQuestionReject?: QuestionRejectFn onNavigateToSession?: NavigateToSessionFn + onOpenFile?: OpenFileFn }) => { return { get store() { @@ -69,6 +72,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ replyToQuestion: props.onQuestionReply, rejectQuestion: props.onQuestionReject, navigateToSession: props.onNavigateToSession, + openFile: props.onOpenFile, } }, })