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
40 changes: 40 additions & 0 deletions packages/app/src/components/dialog-select-file-ref.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Dialog } from "@opencode-ai/ui/dialog"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { List } from "@opencode-ai/ui/list"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { useLanguage } from "@/context/language"

export function DialogSelectFileRef(props: { paths: string[]; onSelect: (path: string) => void }) {
const dialog = useDialog()
const language = useLanguage()
const items = () => props.paths.map((path) => ({ path }))
return (
<Dialog title={language.t("session.header.searchFiles")} transition>
<List
search={{ placeholder: language.t("session.header.searchFiles"), autofocus: true, hideIcon: true }}
emptyMessage={language.t("palette.empty")}
key={(item) => item.path}
items={items}
filterKeys={["path"]}
onSelect={(item) => {
if (!item) return
dialog.close()
props.onSelect(item.path)
}}
>
{(item) => (
<div class="w-full flex items-center gap-x-3 rounded-md pl-1">
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{getDirectory(item.path)}
</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(item.path)}</span>
</div>
</div>
)}
</List>
</Dialog>
)
}
4 changes: 4 additions & 0 deletions packages/app/src/context/file/path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ describe("file path helpers", () => {
const path = createPathHelpers(() => "/repo")
expect(path.normalize("file:///repo/src/app.ts?x=1#h")).toBe("src/app.ts")
expect(path.normalize("/repo/src/app.ts")).toBe("src/app.ts")
expect(path.normalize("/other/place/app.ts")).toBe("other/place/app.ts")
expect(path.normalize("./src/app.ts")).toBe("src/app.ts")
expect(path.normalizeDir("src/components///")).toBe("src/components")
expect(path.tab("src/app.ts")).toBe("file://src/app.ts")
expect(path.pathFromTab("file://src/app.ts")).toBe("src/app.ts")
expect(path.pathFromTab("file:///other/place/app.ts")).toBe("other/place/app.ts")
expect(path.pathFromTab("other://src/app.ts")).toBeUndefined()
})

Expand All @@ -19,6 +21,8 @@ describe("file path helpers", () => {
expect(path.normalize("C:/repo/src/app.ts")).toBe("src/app.ts")
expect(path.normalize("file://C:/repo/src/app.ts")).toBe("src/app.ts")
expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src\\app.ts")
expect(path.normalize("D:\\other\\app.ts")).toBe("D:\\other\\app.ts")
expect(path.normalize("file:///D:/other/app.ts")).toBe("D:/other/app.ts")
})

test("keeps query/hash stripping behavior stable", () => {
Expand Down
174 changes: 134 additions & 40 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { FileRefProvider, type MessageFileRef } from "@opencode-ai/ui/context/file-ref"
import { useMutation } from "@tanstack/solid-query"
import {
batch,
Expand Down Expand Up @@ -27,7 +28,9 @@ import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@opencode-ai/ui/toast"
import { checksum } from "@opencode-ai/util/encode"
import { getFilename } from "@opencode-ai/util/path"
import { useSearchParams } from "@solidjs/router"
import { DialogSelectFileRef } from "@/components/dialog-select-file-ref"
import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments"
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
Expand Down Expand Up @@ -963,6 +966,95 @@ export default function Page() {
setActive: tabs().setActive,
loadFile: file.load,
})
const roots = createMemo(() => {
const out = [sdk.directory]
const project = sync.project
if (!project) return out
for (const dir of [project.worktree, ...(project.sandboxes ?? [])]) {
if (!out.includes(dir)) out.push(dir)
}
return out
})

const openSessionFile = (path: string, ref?: MessageFileRef) => {
const next = file.normalize(path).replace(/\\/g, "/")
const tab = file.tab(next)
if (ref?.line) {
file.setSelectedLines(next, { start: ref.line, end: ref.end ?? ref.line })
} else {
file.setSelectedLines(next, null)
}
tabs().open(tab)
file.load(next, { force: true })
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.setTab("all")
tabs().setActive(tab)
}

const within = (root: string, file: string) => {
const a = root.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase()
const b = file.replace(/\\/g, "/").toLowerCase()
return b === a || b.startsWith(a + "/")
}

const findFiles = (query: string, directory: string) =>
sdk.client.find.files({ query, directory, dirs: "false", limit: 50 }).then(
(x) => (x.data ?? []).map((item) => `${directory.replace(/\\/g, "/")}/${item.replace(/\\/g, "/")}`),
() => [],
)

const matchFileRef = async (input: string) => {
const raw = input.trim()
if (!raw) return []

const full = raw.replace(/\\/g, "/").replace(/^\.\//, "")
if (/^[A-Za-z]:\//.test(full) || full.startsWith("/") || full.startsWith("//")) {
return [full]
}

const root = roots().find((item) => within(item, full))
const exact = root
? full.slice(root.replace(/\\/g, "/").replace(/\/+$/, "").length).replace(/^\//, "")
: file.normalize(full).replace(/\\/g, "/")
if (root) {
return [`${root.replace(/\\/g, "/")}/${exact}`]
}

const name = full.split("/").filter(Boolean).at(-1) ?? full
const [a, b] = await Promise.all([
Promise.all(roots().map((directory) => findFiles(full, directory))).then((all) => all.flat()),
name !== full
? Promise.all(roots().map((directory) => findFiles(name, directory))).then((all) => all.flat())
: Promise.resolve([]),
])
const list = [...new Set([...a, ...b])]
const norm = (item: string) => item.replace(/\\/g, "/")
const hit = full.includes("/")
? list.filter((item) => norm(item) === exact || norm(item).endsWith(`/${exact}`))
: list.filter((item) => getFilename(item).toLowerCase() === name.toLowerCase())

return hit
}

const openFileRef = async (ref: MessageFileRef) => {
const hit = await matchFileRef(ref.path)

if (!hit?.length) {
showToast({
variant: "error",
title: language.t("toast.file.loadFailed.title"),
description: ref.path,
})
return
}

if (hit.length === 1) {
openSessionFile(hit[0], ref)
return
}

dialog.show(() => <DialogSelectFileRef paths={hit} onSelect={(path: string) => openSessionFile(path, ref)} />)
}

const changesOptions = ["session", "turn"] as const
const changesOptionsList = [...changesOptions]
Expand Down Expand Up @@ -1745,46 +1837,48 @@ export default function Page() {
<Switch>
<Match when={params.id}>
<Show when={messagesReady()}>
<MessageTimeline
mobileChanges={mobileChanges()}
mobileFallback={reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8",
header: "px-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
})}
actions={actions}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
setScrollRef={setScrollRef}
onScheduleScrollState={scheduleScrollState}
onAutoScrollHandleScroll={autoScroll.handleScroll}
onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture}
onUserScroll={markUserScroll}
onTurnBackfillScroll={historyWindow.onScrollerScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
centered={centered()}
setContentRef={(el) => {
content = el
autoScroll.contentRef(el)

const root = scroller
if (root) scheduleScrollState(root)
}}
turnStart={historyWindow.turnStart()}
historyMore={historyMore()}
historyLoading={historyLoading()}
onLoadEarlier={() => {
void historyWindow.loadAndReveal()
}}
renderedUserMessages={historyWindow.renderedUserMessages()}
anchor={anchor}
/>
<FileRefProvider value={{ open: openFileRef, match: matchFileRef }}>
<MessageTimeline
mobileChanges={mobileChanges()}
mobileFallback={reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8",
header: "px-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
})}
actions={actions}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
setScrollRef={setScrollRef}
onScheduleScrollState={scheduleScrollState}
onAutoScrollHandleScroll={autoScroll.handleScroll}
onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture}
onUserScroll={markUserScroll}
onTurnBackfillScroll={historyWindow.onScrollerScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
centered={centered()}
setContentRef={(el) => {
content = el
autoScroll.contentRef(el)

const root = scroller
if (root) scheduleScrollState(root)
}}
turnStart={historyWindow.turnStart()}
historyMore={historyMore()}
historyLoading={historyLoading()}
onLoadEarlier={() => {
void historyWindow.loadAndReveal()
}}
renderedUserMessages={historyWindow.renderedUserMessages()}
anchor={anchor}
/>
</FileRefProvider>
</Show>
</Match>
<Match when={true}>
Expand Down
79 changes: 79 additions & 0 deletions packages/app/src/pages/session/file-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,44 @@ function createScrollSync(input: { tab: () => string; view: ReturnType<typeof us
if (el.scrollLeft !== pos.x) el.scrollLeft = pos.x
}

const reveal = (range: SelectedLineRange) => {
const el = scroll
if (!el) return false

const host = el.querySelector("diffs-container")
if (!(host instanceof HTMLElement)) return false

const root = host.shadowRoot
if (!root) return false

const first =
range.start <= range.end
? { line: range.start, side: range.side }
: { line: range.end, side: range.endSide ?? range.side }
const rows = Array.from(
root.querySelectorAll(`[data-line="${first.line}"], [data-alt-line="${first.line}"]`),
).filter((item): item is HTMLElement => item instanceof HTMLElement)
const line =
rows.find((item) => {
if (!first.side) return false
const type = item.dataset.lineType
if (type === "change-deletion") return first.side === "deletions"
if (type === "change-addition" || type === "change-additions") return first.side === "additions"
const code = item.closest("[data-code]")
if (!(code instanceof HTMLElement)) return first.side === "additions"
return code.hasAttribute("data-deletions") ? first.side === "deletions" : first.side === "additions"
}) ?? rows[0]
if (!(line instanceof HTMLElement)) return false

sync()

const top = line.getBoundingClientRect().top - el.getBoundingClientRect().top + el.scrollTop
const y = Math.max(0, top - Math.max(24, Math.floor(el.clientHeight / 3)))
if (Math.abs(el.scrollTop - y) > 1) el.scrollTo({ top: y, behavior: "auto" })
save({ x: code[0]?.scrollLeft ?? el.scrollLeft, y })
return true
}

const queueRestore = () => {
if (restoreFrame !== undefined) return

Expand Down Expand Up @@ -174,6 +212,7 @@ function createScrollSync(input: { tab: () => string; view: ReturnType<typeof us
return {
handleScroll,
queueRestore,
reveal,
setViewport,
}
}
Expand Down Expand Up @@ -290,6 +329,12 @@ export function FileTabContent(props: { tab: string }) {
selected: null as SelectedLineRange | null,
})

const sameRange = (a: SelectedLineRange | null | undefined, b: SelectedLineRange | null | undefined) => {
if (!a && !b) return true
if (!a || !b) return false
return a.start === b.start && a.end === b.end && a.side === b.side && a.endSide === b.endSide
}

const syncSelected = (range: SelectedLineRange | null) => {
const p = path()
if (!p) return
Expand Down Expand Up @@ -364,11 +409,19 @@ export function FileTabContent(props: { tab: string }) {
path,
() => {
commentsUi.note.reset()
setNote("selected", null)
},
{ defer: true },
),
)

createEffect(() => {
const range = selectedLines()
if (!note.selected) return
if (sameRange(note.selected, range)) return
setNote("selected", null)
})

createEffect(() => {
const focus = comments.focus()
const p = path()
Expand All @@ -394,6 +447,7 @@ export function FileTabContent(props: { tab: string }) {
ready: false,
active: false,
}
let reveal = ""

createEffect(() => {
const loaded = !!state()?.loaded
Expand All @@ -405,6 +459,31 @@ export function FileTabContent(props: { tab: string }) {
scrollSync.queueRestore()
})

createEffect(() => {
const range = selectedLines()
const active = activeFileTab() === props.tab
const loaded = !!state()?.loaded
const p = path()
if (!range || !active || !loaded || !p) {
if (!range) reveal = ""
return
}

const key = `${p}:${range.start}:${range.end}:${range.side ?? ""}:${range.endSide ?? ""}`
if (key === reveal) return

const run = (count: number) => {
if (count > 30) return
if (scrollSync.reveal(range)) {
reveal = key
return
}
requestAnimationFrame(() => run(count + 1))
}

requestAnimationFrame(() => run(0))
})

const renderFile = (source: string) => (
<div class="relative overflow-hidden pb-40">
<Dynamic
Expand Down
Loading
Loading