wip(app): file tree mode

This commit is contained in:
adamelmore
2026-01-25 21:57:30 -06:00
parent d9eed4c6ca
commit ebeed03115
5 changed files with 907 additions and 658 deletions

View File

@@ -2,7 +2,16 @@ import { useFile } from "@/context/file"
import { Collapsible } from "@opencode-ai/ui/collapsible" import { Collapsible } from "@opencode-ai/ui/collapsible"
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { createEffect, For, Match, splitProps, Switch, type ComponentProps, type ParentProps } from "solid-js" import {
createEffect,
createMemo,
For,
Match,
splitProps,
Switch,
type ComponentProps,
type ParentProps,
} from "solid-js"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import type { FileNode } from "@opencode-ai/sdk/v2" import type { FileNode } from "@opencode-ai/sdk/v2"
@@ -11,15 +20,45 @@ export default function FileTree(props: {
class?: string class?: string
nodeClass?: string nodeClass?: string
level?: number level?: number
allowed?: readonly string[]
onFileClick?: (file: FileNode) => void onFileClick?: (file: FileNode) => void
}) { }) {
const file = useFile() const file = useFile()
const level = props.level ?? 0 const level = props.level ?? 0
const filter = createMemo(() => {
const allowed = props.allowed
if (!allowed) return
const files = new Set(allowed)
const dirs = new Set<string>()
for (const item of allowed) {
const parts = item.split("/")
const parents = parts.slice(0, -1)
for (const [idx] of parents.entries()) {
const dir = parents.slice(0, idx + 1).join("/")
if (dir) dirs.add(dir)
}
}
return { files, dirs }
})
createEffect(() => { createEffect(() => {
void file.tree.list(props.path) void file.tree.list(props.path)
}) })
const nodes = createMemo(() => {
const nodes = file.tree.children(props.path)
const current = filter()
if (!current) return nodes
return nodes.filter((node) => {
if (node.type === "file") return current.files.has(node.path)
return current.dirs.has(node.path)
})
})
const Node = ( const Node = (
p: ParentProps & p: ParentProps &
ComponentProps<"div"> & ComponentProps<"div"> &
@@ -81,7 +120,7 @@ export default function FileTree(props: {
return ( return (
<div class={`flex flex-col ${props.class ?? ""}`}> <div class={`flex flex-col ${props.class ?? ""}`}>
<For each={file.tree.children(props.path)}> <For each={nodes()}>
{(node) => { {(node) => {
const expanded = () => file.tree.state(node.path)?.expanded ?? false const expanded = () => file.tree.state(node.path)?.expanded ?? false
return ( return (
@@ -102,7 +141,12 @@ export default function FileTree(props: {
</Node> </Node>
</Collapsible.Trigger> </Collapsible.Trigger>
<Collapsible.Content> <Collapsible.Content>
<FileTree path={node.path} level={level + 1} onFileClick={props.onFileClick} /> <FileTree
path={node.path}
level={level + 1}
allowed={props.allowed}
onFileClick={props.onFileClick}
/>
</Collapsible.Content> </Collapsible.Content>
</Collapsible> </Collapsible>
</Match> </Match>

View File

@@ -77,6 +77,7 @@ interface SessionReviewTabProps {
comments?: LineComment[] comments?: LineComment[]
focusedComment?: { file: string; id: string } | null focusedComment?: { file: string; id: string } | null
onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
onScrollRef?: (el: HTMLDivElement) => void
classes?: { classes?: {
root?: string root?: string
header?: string header?: string
@@ -146,6 +147,7 @@ function SessionReviewTab(props: SessionReviewTabProps) {
<SessionReview <SessionReview
scrollRef={(el) => { scrollRef={(el) => {
scroll = el scroll = el
props.onScrollRef?.(el)
restoreScroll() restoreScroll()
}} }}
onScroll={handleScroll} onScroll={handleScroll}
@@ -1015,8 +1017,71 @@ export default function Page() {
const showTabs = createMemo(() => view().reviewPanel.opened()) const showTabs = createMemo(() => view().reviewPanel.opened())
const [fileTreeTab, setFileTreeTab] = createSignal<"changes" | "all">("changes")
const [reviewScroll, setReviewScroll] = createSignal<HTMLDivElement | undefined>(undefined)
const [pendingDiff, setPendingDiff] = createSignal<string | undefined>(undefined)
createEffect(() => {
if (!layout.fileTree.opened()) return
setFileTreeTab("changes")
})
const setFileTreeTabValue = (value: string) => {
if (value !== "changes" && value !== "all") return
setFileTreeTab(value)
}
const reviewDiffId = (path: string) => {
const sum = checksum(path)
if (!sum) return
return `session-review-diff-${sum}`
}
const scrollToReviewDiff = (path: string, behavior: ScrollBehavior) => {
const root = reviewScroll()
if (!root) return
const id = reviewDiffId(path)
if (!id) return
const el = document.getElementById(id)
if (!(el instanceof HTMLElement)) return
if (!root.contains(el)) return
const a = el.getBoundingClientRect()
const b = root.getBoundingClientRect()
const top = a.top - b.top + root.scrollTop
root.scrollTo({ top, behavior })
}
const focusReviewDiff = (path: string) => {
const current = view().review.open() ?? []
if (!current.includes(path)) view().review.setOpen([...current, path])
setPendingDiff(path)
requestAnimationFrame(() => scrollToReviewDiff(path, "smooth"))
}
createEffect(() => {
const pending = pendingDiff()
if (!pending) return
if (!reviewScroll()) return
if (!diffsReady()) return
requestAnimationFrame(() => {
scrollToReviewDiff(pending, "smooth")
setPendingDiff(undefined)
})
})
const activeTab = createMemo(() => { const activeTab = createMemo(() => {
const active = tabs().active() const active = tabs().active()
if (layout.fileTree.opened() && fileTreeTab() === "all") {
if (active && active !== "review" && active !== "context") return normalizeTab(active)
const first = openedTabs()[0]
if (first) return first
return "review"
}
if (active) return normalizeTab(active) if (active) return normalizeTab(active)
if (hasReview()) return "review" if (hasReview()) return "review"
@@ -1033,12 +1098,27 @@ export default function Page() {
tabs().setActive(activeTab()) tabs().setActive(activeTab())
}) })
createEffect(() => {
if (!layout.fileTree.opened()) return
if (fileTreeTab() !== "all") return
const first = openedTabs()[0]
if (!first) return
const active = tabs().active()
if (active && active !== "review" && active !== "context") return
tabs().setActive(first)
})
createEffect(() => { createEffect(() => {
const id = params.id const id = params.id
if (!id) return if (!id) return
if (!hasReview()) return if (!hasReview()) return
const wants = isDesktop() ? view().reviewPanel.opened() && activeTab() === "review" : store.mobileTab === "review" const wants = isDesktop()
? view().reviewPanel.opened() &&
(layout.fileTree.opened() ? fileTreeTab() === "changes" : activeTab() === "review")
: store.mobileTab === "review"
if (!wants) return if (!wants) return
if (diffsReady()) return if (diffsReady()) return
@@ -1814,27 +1894,48 @@ export default function Page() {
aria-label={language.t("session.panel.reviewAndFiles")} aria-label={language.t("session.panel.reviewAndFiles")}
class="relative flex-1 min-w-0 h-full border-l border-border-weak-base flex" class="relative flex-1 min-w-0 h-full border-l border-border-weak-base flex"
> >
<Show when={layout.fileTree.opened()}> <div class="flex-1 min-w-0 h-full">
<div class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}> <Show when={layout.fileTree.opened() && fileTreeTab() === "changes"}>
<div class="h-full bg-background-base border-r border-border-weak-base flex flex-col"> <div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict">
<div class="hidden h-12 shrink-0 flex items-center px-3 border-b border-border-weak-base text-12-medium text-text-weak"> <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
Files <Switch>
</div> <Match when={hasReview()}>
<div class="flex-1 min-h-0 overflow-y-auto no-scrollbar p-2"> <Show
<FileTree path="" onFileClick={(node) => openTab(file.tab(node.path))} /> when={diffsReady()}
</div> fallback={
</div> <div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
<ResizeHandle }
direction="horizontal" >
size={layout.fileTree.width()} <SessionReviewTab
min={200} diffs={diffs}
max={480} view={view}
collapseThreshold={160} diffStyle={layout.review.diffStyle()}
onResize={layout.fileTree.resize} onDiffStyleChange={layout.review.setDiffStyle}
onCollapse={layout.fileTree.close} onScrollRef={setReviewScroll}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
onViewFile={(path) => {
const value = file.tab(path)
tabs().open(value)
file.load(path)
}}
/> />
</Show>
</Match>
<Match when={true}>
<div class="h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-13-regular text-text-weak max-w-56">No changes in this session yet</div>
</div>
</Match>
</Switch>
</div>
</div> </div>
</Show> </Show>
<Show when={!layout.fileTree.opened() || fileTreeTab() === "all"}>
<DragDropProvider <DragDropProvider
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
@@ -1846,7 +1947,7 @@ export default function Page() {
<Tabs value={activeTab()} onChange={openTab}> <Tabs value={activeTab()} onChange={openTab}>
<div class="sticky top-0 shrink-0 flex"> <div class="sticky top-0 shrink-0 flex">
<Tabs.List> <Tabs.List>
<Show when={true}> <Show when={!layout.fileTree.opened()}>
<Tabs.Trigger value="review"> <Tabs.Trigger value="review">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<Show when={diffs()}> <Show when={diffs()}>
@@ -1863,7 +1964,7 @@ export default function Page() {
</div> </div>
</Tabs.Trigger> </Tabs.Trigger>
</Show> </Show>
<Show when={contextOpen()}> <Show when={!layout.fileTree.opened() && contextOpen()}>
<Tabs.Trigger <Tabs.Trigger
value="context" value="context"
closeButton={ closeButton={
@@ -1905,7 +2006,7 @@ export default function Page() {
</div> </div>
</Tabs.List> </Tabs.List>
</div> </div>
<Show when={true}> <Show when={!layout.fileTree.opened()}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict"> <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "review"}> <Show when={activeTab() === "review"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden"> <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
@@ -1924,6 +2025,7 @@ export default function Page() {
view={view} view={view}
diffStyle={layout.review.diffStyle()} diffStyle={layout.review.diffStyle()}
onDiffStyleChange={layout.review.setDiffStyle} onDiffStyleChange={layout.review.setDiffStyle}
onScrollRef={setReviewScroll}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
comments={comments.all()} comments={comments.all()}
focusedComment={comments.focus()} focusedComment={comments.focus()}
@@ -1939,7 +2041,9 @@ export default function Page() {
<Match when={true}> <Match when={true}>
<div class="h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6"> <div class="h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" /> <Mark class="w-14 opacity-10" />
<div class="text-13-regular text-text-weak max-w-56">No changes in this session yet</div> <div class="text-13-regular text-text-weak max-w-56">
No changes in this session yet
</div>
</div> </div>
</Match> </Match>
</Switch> </Switch>
@@ -1947,7 +2051,17 @@ export default function Page() {
</Show> </Show>
</Tabs.Content> </Tabs.Content>
</Show> </Show>
<Show when={contextOpen()}>
<Show when={layout.fileTree.opened() && fileTreeTab() === "all" && openedTabs().length === 0}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-13-regular text-text-weak max-w-56">Select a file to open</div>
</div>
</Tabs.Content>
</Show>
<Show when={!layout.fileTree.opened() && contextOpen()}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict"> <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "context"}> <Show when={activeTab() === "context"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden"> <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
@@ -1980,7 +2094,9 @@ export default function Page() {
const isImage = createMemo(() => { const isImage = createMemo(() => {
const c = state()?.content const c = state()?.content
return ( return (
c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml" c?.encoding === "base64" &&
c?.mimeType?.startsWith("image/") &&
c?.mimeType !== "image/svg+xml"
) )
}) })
const isSvg = createMemo(() => { const isSvg = createMemo(() => {
@@ -2279,7 +2395,10 @@ export default function Page() {
if (target && e.currentTarget.contains(target)) return if (target && e.currentTarget.contains(target)) return
// Delay to allow click handlers to fire first // Delay to allow click handlers to fire first
setTimeout(() => { setTimeout(() => {
if (!document.activeElement || !e.currentTarget.contains(document.activeElement)) { if (
!document.activeElement ||
!e.currentTarget.contains(document.activeElement)
) {
setCommenting(null) setCommenting(null)
} }
}, 0) }, 0)
@@ -2480,6 +2599,57 @@ export default function Page() {
</Show> </Show>
</DragOverlay> </DragOverlay>
</DragDropProvider> </DragDropProvider>
</Show>
</div>
<Show when={layout.fileTree.opened()}>
<div class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}>
<div class="h-full border-l border-border-weak-base flex flex-col overflow-hidden">
<Tabs value={fileTreeTab()} onChange={setFileTreeTabValue} class="h-full">
<Tabs.List class="h-auto">
<Tabs.Trigger value="changes" class="w-1/2" classes={{ button: "w-full" }}>
Changes
</Tabs.Trigger>
<Tabs.Trigger value="all" class="w-1/2 !border-r-0" classes={{ button: "w-full" }}>
All files
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="changes" class="bg-background-base p-2">
<Switch>
<Match when={hasReview()}>
<Show
when={diffsReady()}
fallback={<div class="px-2 py-2 text-12-regular text-text-weak">Loading...</div>}
>
<FileTree
path=""
allowed={diffs().map((d) => d.file)}
onFileClick={(node) => focusReviewDiff(node.path)}
/>
</Show>
</Match>
<Match when={true}>
<div class="px-2 py-2 text-12-regular text-text-weak">No changes</div>
</Match>
</Switch>
</Tabs.Content>
<Tabs.Content value="all" class="bg-background-base p-2">
<FileTree path="" onFileClick={(node) => openTab(file.tab(node.path))} />
</Tabs.Content>
</Tabs>
</div>
<ResizeHandle
direction="horizontal"
edge="start"
size={layout.fileTree.width()}
min={200}
max={480}
collapseThreshold={160}
onResize={layout.fileTree.resize}
onCollapse={layout.fileTree.close}
/>
</div>
</Show>
</aside> </aside>
</Show> </Show>
</div> </div>

View File

@@ -21,6 +21,12 @@
transform: translateX(50%); transform: translateX(50%);
cursor: col-resize; cursor: col-resize;
&[data-edge="start"] {
inset-inline-start: 0;
inset-inline-end: auto;
transform: translateX(-50%);
}
&::after { &::after {
width: 3px; width: 3px;
inset-block: 0; inset-block: 0;
@@ -36,6 +42,12 @@
transform: translateY(-50%); transform: translateY(-50%);
cursor: row-resize; cursor: row-resize;
&[data-edge="end"] {
inset-block-start: auto;
inset-block-end: 0;
transform: translateY(50%);
}
&::after { &::after {
height: 3px; height: 3px;
inset-inline: 0; inset-inline: 0;

View File

@@ -2,6 +2,7 @@ import { splitProps, type JSX } from "solid-js"
export interface ResizeHandleProps extends Omit<JSX.HTMLAttributes<HTMLDivElement>, "onResize"> { export interface ResizeHandleProps extends Omit<JSX.HTMLAttributes<HTMLDivElement>, "onResize"> {
direction: "horizontal" | "vertical" direction: "horizontal" | "vertical"
edge?: "start" | "end"
size: number size: number
min: number min: number
max: number max: number
@@ -13,6 +14,7 @@ export interface ResizeHandleProps extends Omit<JSX.HTMLAttributes<HTMLDivElemen
export function ResizeHandle(props: ResizeHandleProps) { export function ResizeHandle(props: ResizeHandleProps) {
const [local, rest] = splitProps(props, [ const [local, rest] = splitProps(props, [
"direction", "direction",
"edge",
"size", "size",
"min", "min",
"max", "max",
@@ -25,6 +27,7 @@ export function ResizeHandle(props: ResizeHandleProps) {
const handleMouseDown = (e: MouseEvent) => { const handleMouseDown = (e: MouseEvent) => {
e.preventDefault() e.preventDefault()
const edge = local.edge ?? (local.direction === "vertical" ? "start" : "end")
const start = local.direction === "horizontal" ? e.clientX : e.clientY const start = local.direction === "horizontal" ? e.clientX : e.clientY
const startSize = local.size const startSize = local.size
let current = startSize let current = startSize
@@ -34,7 +37,14 @@ export function ResizeHandle(props: ResizeHandleProps) {
const onMouseMove = (moveEvent: MouseEvent) => { const onMouseMove = (moveEvent: MouseEvent) => {
const pos = local.direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY const pos = local.direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY
const delta = local.direction === "vertical" ? start - pos : pos - start const delta =
local.direction === "vertical"
? edge === "end"
? pos - start
: start - pos
: edge === "start"
? start - pos
: pos - start
current = startSize + delta current = startSize + delta
const clamped = Math.min(local.max, Math.max(local.min, current)) const clamped = Math.min(local.max, Math.max(local.min, current))
local.onResize(clamped) local.onResize(clamped)
@@ -61,6 +71,7 @@ export function ResizeHandle(props: ResizeHandleProps) {
{...rest} {...rest}
data-component="resize-handle" data-component="resize-handle"
data-direction={local.direction} data-direction={local.direction}
data-edge={local.edge ?? (local.direction === "vertical" ? "start" : "end")}
classList={{ classList={{
...(local.classList ?? {}), ...(local.classList ?? {}),
[local.class ?? ""]: !!local.class, [local.class ?? ""]: !!local.class,

View File

@@ -9,6 +9,7 @@ import { StickyAccordionHeader } from "./sticky-accordion-header"
import { useDiffComponent } from "../context/diff" import { useDiffComponent } from "../context/diff"
import { useI18n } from "../context/i18n" import { useI18n } from "../context/i18n"
import { getDirectory, getFilename } from "@opencode-ai/util/path" import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { checksum } from "@opencode-ai/util/encode"
import { createEffect, createMemo, createSignal, For, Match, Show, Switch, type JSX } from "solid-js" import { createEffect, createMemo, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2" import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2"
@@ -118,6 +119,12 @@ function dataUrlFromValue(value: unknown): string | undefined {
return `data:${mime};base64,${content}` return `data:${mime};base64,${content}`
} }
function diffId(file: string): string | undefined {
const sum = checksum(file)
if (!sum) return
return `session-review-diff-${sum}`
}
type SessionReviewSelection = { type SessionReviewSelection = {
file: string file: string
range: SelectedLineRange range: SelectedLineRange
@@ -489,7 +496,12 @@ export const SessionReview = (props: SessionReviewProps) => {
} }
return ( return (
<Accordion.Item value={diff.file} data-slot="session-review-accordion-item"> <Accordion.Item
value={diff.file}
id={diffId(diff.file)}
data-file={diff.file}
data-slot="session-review-accordion-item"
>
<StickyAccordionHeader> <StickyAccordionHeader>
<Accordion.Trigger> <Accordion.Trigger>
<div data-slot="session-review-trigger-content"> <div data-slot="session-review-trigger-content">