wip(app): file tree mode
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user