Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions packages/app/src/pages/directory-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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) ?? ""
Expand Down Expand Up @@ -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 (
<DataProvider
data={sync.data}
Expand All @@ -59,6 +89,7 @@ export default function Layout(props: ParentProps) {
onQuestionReply={replyToQuestion}
onQuestionReject={rejectQuestion}
onNavigateToSession={navigateToSession}
onOpenFile={openFile}
>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
Expand Down
6 changes: 6 additions & 0 deletions packages/ui/src/components/markdown.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; */
Expand All @@ -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%;
Expand Down
33 changes: 33 additions & 0 deletions packages/ui/src/components/markdown.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
import { useMarked } from "../context/marked"
import { useData } from "../context"
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"

// 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
Expand Down Expand Up @@ -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<HTMLDivElement>()

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) => {
Expand Down Expand Up @@ -258,6 +290,7 @@ export function Markdown(
[local.class ?? ""]: !!local.class,
}}
ref={setRoot}
onClick={handleClick}
{...others}
/>
)
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/context/data.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -57,6 +59,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
onQuestionReply?: QuestionReplyFn
onQuestionReject?: QuestionRejectFn
onNavigateToSession?: NavigateToSessionFn
onOpenFile?: OpenFileFn
}) => {
return {
get store() {
Expand All @@ -69,6 +72,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
replyToQuestion: props.onQuestionReply,
rejectQuestion: props.onQuestionReject,
navigateToSession: props.onNavigateToSession,
openFile: props.onOpenFile,
}
},
})
Loading