feat(app): v2 review panel overhaul (#31882)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
This commit is contained in:
parent
fbb95a6ee3
commit
7d2618637f
35 changed files with 3438 additions and 214 deletions
421
packages/app/src/components/file-tree-v2.tsx
Normal file
421
packages/app/src/components/file-tree-v2.tsx
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
import { useFile } from "@/context/file"
|
||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import "@opencode-ai/ui/v2/file-tree-v2.css"
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
For,
|
||||
Match,
|
||||
on,
|
||||
Show,
|
||||
splitProps,
|
||||
Switch,
|
||||
untrack,
|
||||
type ComponentProps,
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import {
|
||||
dirsToExpand,
|
||||
pathToFileUrl,
|
||||
shouldListRoot,
|
||||
visibleKind,
|
||||
withFileDragImage,
|
||||
type Filter,
|
||||
type Kind,
|
||||
} from "@/components/file-tree"
|
||||
|
||||
export type { Kind } from "@/components/file-tree"
|
||||
|
||||
const MAX_DEPTH = 128
|
||||
|
||||
function visibleNodesForPath(path: string, children: (dir: string) => FileNode[], current: Filter | undefined) {
|
||||
const nodes = children(path)
|
||||
if (!current) return nodes
|
||||
|
||||
const parent = (item: string) => {
|
||||
const idx = item.lastIndexOf("/")
|
||||
if (idx === -1) return ""
|
||||
return item.slice(0, idx)
|
||||
}
|
||||
|
||||
const leaf = (item: string) => {
|
||||
const idx = item.lastIndexOf("/")
|
||||
return idx === -1 ? item : item.slice(idx + 1)
|
||||
}
|
||||
|
||||
const out = nodes.filter((node) => {
|
||||
if (node.type === "file") return current.files.has(node.path)
|
||||
return current.dirs.has(node.path)
|
||||
})
|
||||
|
||||
const seen = new Set(out.map((node) => node.path))
|
||||
|
||||
for (const dir of current.dirs) {
|
||||
if (parent(dir) !== path) continue
|
||||
if (seen.has(dir)) continue
|
||||
out.push({
|
||||
name: leaf(dir),
|
||||
path: dir,
|
||||
absolute: dir,
|
||||
type: "directory",
|
||||
ignored: false,
|
||||
})
|
||||
seen.add(dir)
|
||||
}
|
||||
|
||||
for (const item of current.files) {
|
||||
if (parent(item) !== path) continue
|
||||
if (seen.has(item)) continue
|
||||
out.push({
|
||||
name: leaf(item),
|
||||
path: item,
|
||||
absolute: item,
|
||||
type: "file",
|
||||
ignored: false,
|
||||
})
|
||||
seen.add(item)
|
||||
}
|
||||
|
||||
out.sort((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type === "directory" ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
const INDENT_STEP = 16
|
||||
|
||||
function rowPaddingLeft(level: number, type: FileNode["type"]) {
|
||||
if (type === "directory") return 8 + level * INDENT_STEP
|
||||
if (level === 0) return 8
|
||||
return 8 + level * INDENT_STEP - INDENT_STEP
|
||||
}
|
||||
|
||||
function guideLineLeft(level: number) {
|
||||
return rowPaddingLeft(level, "directory") + 8
|
||||
}
|
||||
|
||||
export const kindLabel = (kind: Kind) => {
|
||||
if (kind === "add") return "A"
|
||||
if (kind === "del") return "D"
|
||||
return ""
|
||||
}
|
||||
|
||||
export const kindChange = (kind: Kind) => {
|
||||
if (kind === "add") return "added"
|
||||
if (kind === "del") return "deleted"
|
||||
return "modified"
|
||||
}
|
||||
|
||||
const FileTreeNodeV2 = (
|
||||
p: ParentProps &
|
||||
ComponentProps<"div"> &
|
||||
ComponentProps<"button"> & {
|
||||
node: FileNode
|
||||
level: number
|
||||
active?: string
|
||||
draggable: boolean
|
||||
kinds?: ReadonlyMap<string, Kind>
|
||||
marks?: Set<string>
|
||||
as?: "div" | "button"
|
||||
},
|
||||
) => {
|
||||
const [local, rest] = splitProps(p, [
|
||||
"node",
|
||||
"level",
|
||||
"active",
|
||||
"draggable",
|
||||
"kinds",
|
||||
"marks",
|
||||
"as",
|
||||
"children",
|
||||
"class",
|
||||
"classList",
|
||||
])
|
||||
const kind = () => visibleKind(local.node, local.kinds, local.marks)
|
||||
|
||||
return (
|
||||
<Dynamic
|
||||
component={local.as ?? "div"}
|
||||
data-slot="file-tree-v2-row"
|
||||
data-selected={local.node.path === local.active ? "" : undefined}
|
||||
data-ignored={local.node.ignored ? "" : undefined}
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
style={`padding-left: ${rowPaddingLeft(local.level, local.node.type)}px`}
|
||||
draggable={local.draggable}
|
||||
onDragStart={(event: DragEvent) => {
|
||||
if (!local.draggable) return
|
||||
event.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
|
||||
event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy"
|
||||
withFileDragImage(event)
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{local.children}
|
||||
<span class="flex-1 min-w-0 text-12-medium whitespace-nowrap truncate">{local.node.name}</span>
|
||||
{(() => {
|
||||
const value = kind()
|
||||
if (!value || local.node.type !== "file") return null
|
||||
return (
|
||||
<span data-slot="file-tree-v2-change" data-change={kindChange(value)}>
|
||||
{kindLabel(value)}
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
</Dynamic>
|
||||
)
|
||||
}
|
||||
|
||||
// V2-styled fork of FileTree for the review sidebar. Unlike the v1 tree it never
|
||||
// lists unloaded subdirectories, so callers must pass `allowed` (nodes are
|
||||
// synthesized from that list) for nested content to appear.
|
||||
export default function FileTreeV2(props: {
|
||||
path: string
|
||||
active?: string
|
||||
level?: number
|
||||
allowed?: readonly string[]
|
||||
kinds?: ReadonlyMap<string, Kind>
|
||||
draggable?: boolean
|
||||
onFileClick?: (file: FileNode) => void
|
||||
|
||||
_filter?: Filter
|
||||
_marks?: Set<string>
|
||||
_deeps?: Map<string, number>
|
||||
_kinds?: ReadonlyMap<string, Kind>
|
||||
_chain?: readonly string[]
|
||||
}) {
|
||||
const file = useFile()
|
||||
const level = props.level ?? 0
|
||||
const draggable = () => props.draggable ?? true
|
||||
|
||||
const key = (p: string) =>
|
||||
file
|
||||
.normalize(p)
|
||||
.replace(/[\\/]+$/, "")
|
||||
.replaceAll("\\", "/")
|
||||
const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)]
|
||||
|
||||
const filter = createMemo(() => {
|
||||
if (props._filter) return props._filter
|
||||
|
||||
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 }
|
||||
})
|
||||
|
||||
const marks = createMemo(() => {
|
||||
if (props._marks) return props._marks
|
||||
|
||||
const out = new Set<string>(props.kinds?.keys() ?? [])
|
||||
if (out.size === 0) return
|
||||
return out
|
||||
})
|
||||
|
||||
const kinds = createMemo(() => {
|
||||
if (props._kinds) return props._kinds
|
||||
return props.kinds
|
||||
})
|
||||
|
||||
const deeps = createMemo(() => {
|
||||
if (props._deeps) return props._deeps
|
||||
|
||||
const out = new Map<string, number>()
|
||||
|
||||
const root = props.path
|
||||
if (!(file.tree.state(root)?.expanded ?? false)) return out
|
||||
|
||||
const seen = new Set<string>()
|
||||
const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = []
|
||||
|
||||
const push = (dir: string, lvl: number) => {
|
||||
const id = key(dir)
|
||||
if (seen.has(id)) return
|
||||
seen.add(id)
|
||||
|
||||
const kids = file.tree
|
||||
.children(dir)
|
||||
.filter((node) => node.type === "directory" && (file.tree.state(node.path)?.expanded ?? false))
|
||||
.map((node) => node.path)
|
||||
|
||||
stack.push({ dir, lvl, i: 0, kids, max: lvl })
|
||||
}
|
||||
|
||||
push(root, level - 1)
|
||||
|
||||
while (stack.length > 0) {
|
||||
const top = stack[stack.length - 1]!
|
||||
|
||||
if (top.i < top.kids.length) {
|
||||
const next = top.kids[top.i]!
|
||||
top.i++
|
||||
push(next, top.lvl + 1)
|
||||
continue
|
||||
}
|
||||
|
||||
out.set(top.dir, top.max)
|
||||
stack.pop()
|
||||
|
||||
const parent = stack[stack.length - 1]
|
||||
if (!parent) continue
|
||||
parent.max = Math.max(parent.max, top.max)
|
||||
}
|
||||
|
||||
return out
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const current = filter()
|
||||
const dirs = dirsToExpand({
|
||||
level,
|
||||
filter: current,
|
||||
expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false,
|
||||
})
|
||||
// Nodes come from the `allowed` filter; skip listing so directories that only
|
||||
// exist on the diff's base branch do not each fail with an error toast.
|
||||
for (const dir of dirs) file.tree.expand(dir, { list: false })
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.path,
|
||||
(path) => {
|
||||
const dir = untrack(() => file.tree.state(path))
|
||||
if (!shouldListRoot({ level, dir })) return
|
||||
void file.tree.list(path)
|
||||
},
|
||||
{ defer: false },
|
||||
),
|
||||
)
|
||||
|
||||
const nodes = createMemo(() => visibleNodesForPath(props.path, file.tree.children, filter()))
|
||||
|
||||
return (
|
||||
// group/file-tree-v2 scopes the group-hover guide lines below; hosts may add
|
||||
// an outer group with the same name to widen the hover area.
|
||||
<div data-component="file-tree-v2" class="group/file-tree-v2">
|
||||
<For each={nodes()}>
|
||||
{(node) => {
|
||||
const expanded = () => file.tree.state(node.path)?.expanded ?? false
|
||||
const deep = () => deeps().get(node.path) ?? -1
|
||||
const hasChildren = () => visibleNodesForPath(node.path, file.tree.children, filter()).length > 0
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={node.type === "directory"}>
|
||||
<Collapsible
|
||||
variant="ghost"
|
||||
class="w-full"
|
||||
data-scope="file-tree-v2"
|
||||
forceMount={false}
|
||||
open={expanded()}
|
||||
onOpenChange={(open) =>
|
||||
open ? file.tree.expand(node.path, { list: false }) : file.tree.collapse(node.path)
|
||||
}
|
||||
>
|
||||
<Collapsible.Trigger>
|
||||
<FileTreeNodeV2
|
||||
node={node}
|
||||
level={level}
|
||||
active={props.active}
|
||||
draggable={draggable()}
|
||||
kinds={kinds()}
|
||||
marks={marks()}
|
||||
>
|
||||
<div
|
||||
data-slot="file-tree-v2-chevron"
|
||||
data-expanded={expanded() ? "" : undefined}
|
||||
class="size-4 flex items-center justify-center"
|
||||
>
|
||||
<Icon name="chevron-down" />
|
||||
</div>
|
||||
</FileTreeNodeV2>
|
||||
</Collapsible.Trigger>
|
||||
<Show when={hasChildren()}>
|
||||
<Collapsible.Content class="relative">
|
||||
<div
|
||||
classList={{
|
||||
"absolute top-0 bottom-0 w-px pointer-events-none bg-border-weak-base opacity-0 transition-opacity duration-150 ease-out motion-reduce:transition-none": true,
|
||||
"group-hover/file-tree-v2:opacity-100": expanded() && deep() === level,
|
||||
"group-hover/file-tree-v2:opacity-50": !(expanded() && deep() === level),
|
||||
}}
|
||||
style={`left: ${guideLineLeft(level)}px`}
|
||||
/>
|
||||
<Show
|
||||
when={level < MAX_DEPTH && !chain.includes(key(node.path))}
|
||||
fallback={<div class="px-2 py-1 text-12-regular text-text-weak">...</div>}
|
||||
>
|
||||
<FileTreeV2
|
||||
path={node.path}
|
||||
level={level + 1}
|
||||
allowed={props.allowed}
|
||||
kinds={props.kinds}
|
||||
active={props.active}
|
||||
draggable={props.draggable}
|
||||
onFileClick={props.onFileClick}
|
||||
_filter={filter()}
|
||||
_marks={marks()}
|
||||
_deeps={deeps()}
|
||||
_kinds={kinds()}
|
||||
_chain={chain}
|
||||
/>
|
||||
</Show>
|
||||
</Collapsible.Content>
|
||||
</Show>
|
||||
</Collapsible>
|
||||
</Match>
|
||||
<Match when={node.type === "file"}>
|
||||
<FileTreeNodeV2
|
||||
node={node}
|
||||
level={level}
|
||||
active={props.active}
|
||||
draggable={draggable()}
|
||||
kinds={kinds()}
|
||||
marks={marks()}
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => props.onFileClick?.(node)}
|
||||
>
|
||||
<Show when={level > 0}>
|
||||
<div class="w-4 shrink-0" />
|
||||
</Show>
|
||||
<Show
|
||||
when={!node.ignored}
|
||||
fallback={<FileIcon node={node} class="size-4 filetree-icon filetree-icon--mono" mono />}
|
||||
>
|
||||
<span class="filetree-iconpair size-4">
|
||||
<FileIcon node={node} class="size-4 filetree-icon filetree-icon--color" />
|
||||
<FileIcon node={node} class="size-4 filetree-icon filetree-icon--mono" mono />
|
||||
</span>
|
||||
</Show>
|
||||
</FileTreeNodeV2>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -21,13 +21,13 @@ import type { FileNode } from "@opencode-ai/sdk/v2"
|
|||
|
||||
const MAX_DEPTH = 128
|
||||
|
||||
function pathToFileUrl(filepath: string): string {
|
||||
export function pathToFileUrl(filepath: string): string {
|
||||
return `file://${encodeFilePath(filepath)}`
|
||||
}
|
||||
|
||||
type Kind = "add" | "del" | "mix"
|
||||
export type Kind = "add" | "del" | "mix"
|
||||
|
||||
type Filter = {
|
||||
export type Filter = {
|
||||
files: Set<string>
|
||||
dirs: Set<string>
|
||||
}
|
||||
|
|
@ -78,7 +78,7 @@ const kindDotColor = (kind: Kind) => {
|
|||
return "background-color: var(--icon-diff-modified-base)"
|
||||
}
|
||||
|
||||
const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
|
||||
export const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
|
||||
const kind = kinds?.get(node.path)
|
||||
if (!kind) return
|
||||
if (!marks?.has(node.path)) return
|
||||
|
|
@ -99,7 +99,7 @@ const buildDragImage = (target: HTMLElement) => {
|
|||
return image
|
||||
}
|
||||
|
||||
const withFileDragImage = (event: DragEvent) => {
|
||||
export const withFileDragImage = (event: DragEvent) => {
|
||||
const image = buildDragImage(event.currentTarget as HTMLElement)
|
||||
if (!image) return
|
||||
document.body.appendChild(image)
|
||||
|
|
|
|||
|
|
@ -85,7 +85,15 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
|
|||
active: null as CommentFocus | null,
|
||||
})
|
||||
|
||||
const all = () => aggregate(store.comments)
|
||||
// Reuse the previous array when contents are unchanged so consumers keep a stable
|
||||
// identity; a fresh array per call cascaded into diff annotation re-renders.
|
||||
let lastAll: LineComment[] = []
|
||||
const all = () => {
|
||||
const next = aggregate(store.comments)
|
||||
if (next.length === lastAll.length && next.every((item, index) => item === lastAll[index])) return lastAll
|
||||
lastAll = next
|
||||
return next
|
||||
}
|
||||
|
||||
const setRef = (
|
||||
key: "focus" | "active",
|
||||
|
|
|
|||
|
|
@ -127,10 +127,14 @@ export function createFileTreeStore(options: TreeStoreOptions) {
|
|||
return promise
|
||||
}
|
||||
|
||||
const expandDir = (input: string) => {
|
||||
// `list: false` marks a directory expanded without fetching its children, for
|
||||
// trees whose nodes are synthesized from a filter; listing directories that
|
||||
// only exist on a diff's base branch fails and surfaces error toasts.
|
||||
const expandDir = (input: string, behavior?: { list?: boolean }) => {
|
||||
const dir = options.normalizeDir(input)
|
||||
ensureDir(dir)
|
||||
setTree("dir", dir, "expanded", true)
|
||||
if (behavior?.list === false) return
|
||||
void listDir(dir)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,8 +24,10 @@ import { debounce } from "@solid-primitives/scheduled"
|
|||
import { useLocal } from "@/context/local"
|
||||
import { FileProvider, selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { SessionReviewLineComment } from "@opencode-ai/session-ui/session-review"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||
import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode-ai/ui/scroll-view"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
|
|
@ -77,6 +79,10 @@ import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/
|
|||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { syncSessionModel } from "@/pages/session/session-model-helpers"
|
||||
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||
import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2"
|
||||
import { SessionReviewEmptyNoGitV2 } from "@opencode-ai/session-ui/v2/session-review-empty-no-git-v2"
|
||||
import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2"
|
||||
import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
|
||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||
|
|
@ -1052,22 +1058,22 @@ export default function Page() {
|
|||
loadFile: file.load,
|
||||
})
|
||||
|
||||
const changesLabel = (option: ChangeMode) => {
|
||||
if (option === "git") return language.t("ui.sessionReview.title.git")
|
||||
if (option === "branch") return language.t("ui.sessionReview.title.branch")
|
||||
return language.t("ui.sessionReview.title.lastTurn")
|
||||
}
|
||||
|
||||
const changesTitle = () => {
|
||||
if (!canReview()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const label = (option: ChangeMode) => {
|
||||
if (option === "git") return language.t("ui.sessionReview.title.git")
|
||||
if (option === "branch") return language.t("ui.sessionReview.title.branch")
|
||||
return language.t("ui.sessionReview.title.lastTurn")
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
options={changesOptions()}
|
||||
current={store.changes}
|
||||
label={label}
|
||||
label={changesLabel}
|
||||
onSelect={(option) => option && setStore("changes", option)}
|
||||
variant="ghost"
|
||||
size="small"
|
||||
|
|
@ -1076,6 +1082,24 @@ export default function Page() {
|
|||
)
|
||||
}
|
||||
|
||||
const changesTitleV2 = () => {
|
||||
if (!canReview()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
options={changesOptions()}
|
||||
current={store.changes}
|
||||
label={changesLabel}
|
||||
placement="bottom-start"
|
||||
gutter={6}
|
||||
onSelect={(option) => option && setStore("changes", option)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const empty = (text: string) => (
|
||||
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||
<div class="text-14-regular text-text-weak max-w-56">{text}</div>
|
||||
|
|
@ -1122,6 +1146,16 @@ export default function Page() {
|
|||
)
|
||||
}
|
||||
|
||||
const reviewEmptyV2 = () => {
|
||||
if ((store.changes === "git" || store.changes === "branch") && !reviewReady()) {
|
||||
return <div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
|
||||
}
|
||||
if (store.changes === "turn" && nogit()) {
|
||||
return <SessionReviewEmptyNoGitV2 pending={gitMutation.isPending} onInitGit={initGit} />
|
||||
}
|
||||
return <SessionReviewEmptyChangesV2 />
|
||||
}
|
||||
|
||||
const reviewContent = (input: {
|
||||
diffStyle: DiffStyle
|
||||
onDiffStyleChange?: (style: DiffStyle) => void
|
||||
|
|
@ -1155,6 +1189,63 @@ export default function Page() {
|
|||
</Show>
|
||||
)
|
||||
|
||||
const reviewV2State = createReviewPanelV2State()
|
||||
|
||||
// Getters defer reactive reads to the consuming scope. Eager reads here ran inside
|
||||
// the side panel's Show children and remounted the whole review panel on unrelated
|
||||
// updates such as session switches.
|
||||
const reviewPanelV2Props = () => ({
|
||||
get title() {
|
||||
return changesTitleV2()
|
||||
},
|
||||
get empty() {
|
||||
return reviewEmptyV2()
|
||||
},
|
||||
diffs: reviewDiffs,
|
||||
diffsReady: reviewReady,
|
||||
get activeFile() {
|
||||
return tree.activeDiff
|
||||
},
|
||||
onSelectFile: focusReviewDiff,
|
||||
get diffStyle() {
|
||||
return layout.review.diffStyle()
|
||||
},
|
||||
onDiffStyleChange: layout.review.setDiffStyle,
|
||||
state: reviewV2State,
|
||||
onLineComment: (comment: SessionReviewLineComment) => addCommentToContext({ ...comment, origin: "review" }),
|
||||
onLineCommentUpdate: updateCommentInContext,
|
||||
onLineCommentDelete: removeCommentFromContext,
|
||||
get lineCommentActions() {
|
||||
return reviewCommentActions()
|
||||
},
|
||||
get comments() {
|
||||
return comments.all()
|
||||
},
|
||||
get focusedComment() {
|
||||
return comments.focus()
|
||||
},
|
||||
onFocusedCommentChange: (focus: { file: string; id: string } | null) => {
|
||||
// The preview clears the focus once it has opened the comment; persist the
|
||||
// focused file as the active selection so the preview stays on it. Skip
|
||||
// files outside the current diff set (their focus is cleared unhandled).
|
||||
if (!focus) {
|
||||
const current = comments.focus()
|
||||
if (current && reviewDiffs().some((diff) => diff.file === current.file)) focusReviewDiff(current.file)
|
||||
}
|
||||
comments.setFocus(focus)
|
||||
},
|
||||
})
|
||||
|
||||
const reviewPanelV2 = () => (
|
||||
<div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict">
|
||||
{/* The route remounts per session; defer the diff render off the switch critical path
|
||||
like the legacy review tab does. */}
|
||||
<Show when={!store.deferRender}>
|
||||
<ReviewPanelV2 {...reviewPanelV2Props()} />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
||||
const reviewPanel = () => (
|
||||
<div
|
||||
classList={{
|
||||
|
|
@ -2078,7 +2169,7 @@ export default function Page() {
|
|||
empty={reviewEmptyText}
|
||||
hasReview={hasReview}
|
||||
reviewCount={reviewCount}
|
||||
reviewPanel={reviewPanel}
|
||||
reviewPanel={() => (newSessionDesign() ? reviewPanelV2() : reviewPanel())}
|
||||
activeDiff={tree.activeDiff}
|
||||
focusReviewDiff={focusReviewDiff}
|
||||
reviewSnap={ui.reviewSnap}
|
||||
|
|
|
|||
23
packages/app/src/pages/session/v2/review-diff-kinds.test.ts
Normal file
23
packages/app/src/pages/session/v2/review-diff-kinds.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { filterReviewFiles, reviewDiffKinds } from "./review-diff-kinds"
|
||||
|
||||
describe("reviewDiffKinds", () => {
|
||||
test("maps file and directory kinds", () => {
|
||||
const kinds = reviewDiffKinds([
|
||||
{ file: "src/a.ts", additions: 1, deletions: 0, status: "added" },
|
||||
{ file: "src/b.ts", additions: 0, deletions: 2, status: "deleted" },
|
||||
])
|
||||
|
||||
expect(kinds.get("src/a.ts")).toBe("add")
|
||||
expect(kinds.get("src/b.ts")).toBe("del")
|
||||
expect(kinds.get("src")).toBe("mix")
|
||||
})
|
||||
})
|
||||
|
||||
describe("filterReviewFiles", () => {
|
||||
test("filters by path substring", () => {
|
||||
const files = ["src/a.ts", "src/b.ts", "lib/c.ts"]
|
||||
expect(filterReviewFiles(files, "b.ts")).toEqual(["src/b.ts"])
|
||||
expect(filterReviewFiles(files, "")).toEqual(files)
|
||||
})
|
||||
})
|
||||
42
packages/app/src/pages/session/v2/review-diff-kinds.ts
Normal file
42
packages/app/src/pages/session/v2/review-diff-kinds.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { Kind } from "@/components/file-tree-v2"
|
||||
|
||||
export type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
|
||||
|
||||
export function normalizePath(p: string) {
|
||||
return p.replaceAll("\\", "/").replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
export function filterRenderableDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
|
||||
return typeof value.file === "string"
|
||||
}
|
||||
|
||||
export function reviewDiffKinds(diffs: RenderDiff[]) {
|
||||
const merge = (a: Kind | undefined, b: Kind) => {
|
||||
if (!a) return b
|
||||
if (a === b) return a
|
||||
return "mix" as const
|
||||
}
|
||||
|
||||
const out = new Map<string, Kind>()
|
||||
for (const diff of diffs) {
|
||||
const file = normalizePath(diff.file)
|
||||
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
|
||||
|
||||
out.set(file, kind)
|
||||
|
||||
const parts = file.split("/")
|
||||
parts.slice(0, -1).forEach((_, idx) => {
|
||||
const dir = parts.slice(0, idx + 1).join("/")
|
||||
if (!dir) return
|
||||
out.set(dir, merge(out.get(dir), kind))
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function filterReviewFiles(files: string[], query: string) {
|
||||
const value = query.trim().toLowerCase()
|
||||
if (!value) return files
|
||||
return files.filter((file) => file.toLowerCase().includes(value))
|
||||
}
|
||||
40
packages/app/src/pages/session/v2/review-panel-v2-state.ts
Normal file
40
packages/app/src/pages/session/v2/review-panel-v2-state.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import {
|
||||
SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT,
|
||||
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
|
||||
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
|
||||
type SessionReviewExpandMode,
|
||||
} from "@opencode-ai/session-ui/v2/session-review-v2"
|
||||
import { createSignal } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
export function createReviewPanelV2State() {
|
||||
const [store, setStore] = persisted(
|
||||
Persist.global("review-panel-v2"),
|
||||
createStore({
|
||||
sidebarOpened: true,
|
||||
sidebarWidth: SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT,
|
||||
expandMode: "collapse" as SessionReviewExpandMode,
|
||||
}),
|
||||
)
|
||||
// The filter is transient by design: a persisted filter would silently hide
|
||||
// files after a reload.
|
||||
const [filter, setFilter] = createSignal("")
|
||||
|
||||
return {
|
||||
sidebarOpened: () => store.sidebarOpened,
|
||||
sidebarWidth: () => store.sidebarWidth,
|
||||
filter,
|
||||
setFilter,
|
||||
expandMode: () => store.expandMode,
|
||||
setExpandMode: (mode: SessionReviewExpandMode) => setStore("expandMode", mode),
|
||||
resizeSidebar: (width: number) =>
|
||||
setStore(
|
||||
"sidebarWidth",
|
||||
Math.min(SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX, Math.max(SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, width)),
|
||||
),
|
||||
toggleSidebar: () => setStore("sidebarOpened", (opened) => !opened),
|
||||
}
|
||||
}
|
||||
|
||||
export type ReviewPanelV2State = ReturnType<typeof createReviewPanelV2State>
|
||||
234
packages/app/src/pages/session/v2/review-panel-v2.tsx
Normal file
234
packages/app/src/pages/session/v2/review-panel-v2.tsx
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import { createMemo, createSignal, Show, type JSX } from "solid-js"
|
||||
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import {
|
||||
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
|
||||
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
|
||||
SessionReviewV2,
|
||||
SessionReviewV2Sidebar,
|
||||
SessionReviewV2SidebarToggle,
|
||||
} from "@opencode-ai/session-ui/v2/session-review-v2"
|
||||
import { SessionReviewFilePreviewV2 } from "@opencode-ai/session-ui/v2/session-review-file-preview-v2"
|
||||
import { DiffChanges } from "@opencode-ai/ui/v2/diff-changes-v2"
|
||||
import type {
|
||||
SessionReviewComment,
|
||||
SessionReviewCommentActions,
|
||||
SessionReviewCommentDelete,
|
||||
SessionReviewCommentUpdate,
|
||||
SessionReviewDiffStyle,
|
||||
SessionReviewFocus,
|
||||
SessionReviewLineComment,
|
||||
} from "@opencode-ai/session-ui/session-review"
|
||||
import FileTreeV2 from "@/components/file-tree-v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import {
|
||||
filterRenderableDiff,
|
||||
filterReviewFiles,
|
||||
reviewDiffKinds,
|
||||
type RenderDiff,
|
||||
} from "@/pages/session/v2/review-diff-kinds"
|
||||
import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
|
||||
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
|
||||
|
||||
type ReviewDiff = SnapshotFileDiff | VcsFileDiff
|
||||
|
||||
export type ReviewPanelV2Props = {
|
||||
title?: JSX.Element
|
||||
empty?: JSX.Element
|
||||
diffs: () => ReviewDiff[]
|
||||
diffsReady: () => boolean
|
||||
activeFile?: string
|
||||
onSelectFile: (path: string) => void
|
||||
diffStyle: SessionReviewDiffStyle
|
||||
onDiffStyleChange?: (style: SessionReviewDiffStyle) => void
|
||||
state: ReviewPanelV2State
|
||||
onLineComment?: (comment: SessionReviewLineComment) => void
|
||||
onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void
|
||||
onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void
|
||||
lineCommentActions?: SessionReviewCommentActions
|
||||
comments?: SessionReviewComment[]
|
||||
focusedComment?: SessionReviewFocus | null
|
||||
onFocusedCommentChange?: (focus: SessionReviewFocus | null) => void
|
||||
}
|
||||
|
||||
export function ReviewPanelV2(props: ReviewPanelV2Props) {
|
||||
const sdk = useSDK()
|
||||
|
||||
const diffs = createMemo(() => props.diffs().filter(filterRenderableDiff))
|
||||
const filteredFiles = createMemo(() =>
|
||||
filterReviewFiles(
|
||||
diffs().map((diff) => diff.file),
|
||||
props.state.filter(),
|
||||
),
|
||||
)
|
||||
const searching = createMemo(() => props.state.filter().trim().length > 0)
|
||||
const kinds = createMemo(() => reviewDiffKinds(diffs()))
|
||||
const activeDiff = createMemo(() => {
|
||||
// A focused comment takes over the preview until the preview applies it and
|
||||
// clears the focus; the owner then persists the file as the active selection.
|
||||
const focus = props.focusedComment
|
||||
if (focus && diffs().some((diff) => diff.file === focus.file)) return focus.file
|
||||
const active = props.activeFile
|
||||
if (searching()) return active
|
||||
const files = filteredFiles()
|
||||
if (active && files.includes(active)) return active
|
||||
return files[0]
|
||||
})
|
||||
const activeItem = createMemo(() => diffs().find((diff) => diff.file === activeDiff()))
|
||||
|
||||
const readFile = async (path: string) =>
|
||||
sdk()
|
||||
.client.file.read({ path })
|
||||
.then((x) => x.data)
|
||||
.catch((error) => {
|
||||
console.debug("[session-review-v2] failed to read file", { path, error })
|
||||
return undefined
|
||||
})
|
||||
|
||||
return (
|
||||
<SessionReviewV2
|
||||
title={props.title}
|
||||
stats={<DiffChanges changes={diffs()} />}
|
||||
empty={props.empty}
|
||||
sidebarOpen={props.state.sidebarOpened()}
|
||||
sidebarToggle={
|
||||
<SessionReviewV2SidebarToggle opened={props.state.sidebarOpened()} onToggle={props.state.toggleSidebar} />
|
||||
}
|
||||
sidebar={
|
||||
// Always mounted: the sidebar header hosts the changes-mode dropdown,
|
||||
// which must stay reachable when the current mode has zero diffs.
|
||||
<ReviewPanelV2Sidebar
|
||||
title={props.title}
|
||||
state={props.state}
|
||||
diffsReady={props.diffsReady}
|
||||
onSelectFile={props.onSelectFile}
|
||||
diffs={diffs}
|
||||
filteredFiles={filteredFiles}
|
||||
searching={searching}
|
||||
kinds={kinds}
|
||||
activeDiff={activeDiff}
|
||||
/>
|
||||
}
|
||||
activeFile={activeDiff()}
|
||||
files={filteredFiles()}
|
||||
onSelectFile={props.onSelectFile}
|
||||
diffStyle={props.diffStyle}
|
||||
onDiffStyleChange={props.onDiffStyleChange}
|
||||
expandMode={props.state.expandMode()}
|
||||
onExpandModeChange={props.state.setExpandMode}
|
||||
hasDiffs={diffs().length > 0}
|
||||
preview={
|
||||
// Key on the file path, not the diff object identity, so refreshed diff data
|
||||
// updates the mounted preview instead of remounting the whole viewer.
|
||||
<Show when={activeDiff()} keyed>
|
||||
{(file) => (
|
||||
<Show when={activeItem()}>
|
||||
{(diff) => (
|
||||
<SessionReviewFilePreviewV2
|
||||
file={file}
|
||||
diff={diff()}
|
||||
diffStyle={props.diffStyle}
|
||||
expandMode={props.state.expandMode()}
|
||||
readFile={readFile}
|
||||
onLineComment={props.onLineComment}
|
||||
onLineCommentUpdate={props.onLineCommentUpdate}
|
||||
onLineCommentDelete={props.onLineCommentDelete}
|
||||
lineCommentActions={props.lineCommentActions}
|
||||
comments={props.comments}
|
||||
focusedComment={props.focusedComment}
|
||||
onFocusedCommentChange={props.onFocusedCommentChange}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ReviewPanelV2Sidebar(props: {
|
||||
title?: JSX.Element
|
||||
state: ReviewPanelV2State
|
||||
diffsReady: () => boolean
|
||||
onSelectFile: (path: string) => void
|
||||
diffs: () => RenderDiff[]
|
||||
filteredFiles: () => string[]
|
||||
searching: () => boolean
|
||||
kinds: () => ReturnType<typeof reviewDiffKinds>
|
||||
activeDiff: () => string | undefined
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [explicitHighlight, setExplicitHighlight] = createSignal<string | undefined>()
|
||||
const highlightedPath = createMemo(() => {
|
||||
if (!props.searching()) return undefined
|
||||
const files = props.filteredFiles()
|
||||
if (files.length === 0) return undefined
|
||||
const explicit = explicitHighlight()
|
||||
if (explicit && files.includes(explicit)) return explicit
|
||||
return files[0]
|
||||
})
|
||||
|
||||
const onFilterKeyDown = (event: KeyboardEvent & { currentTarget: HTMLInputElement }) => {
|
||||
if (!props.searching()) return
|
||||
applyFileListKeyDown(event, props.filteredFiles(), highlightedPath(), {
|
||||
onHighlight: setExplicitHighlight,
|
||||
onSelect: props.onSelectFile,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionReviewV2Sidebar
|
||||
open={props.state.sidebarOpened()}
|
||||
title={props.title}
|
||||
stats={<DiffChanges changes={props.diffs()} />}
|
||||
filter={props.state.filter()}
|
||||
onFilterChange={props.state.setFilter}
|
||||
onFilterKeyDown={onFilterKeyDown}
|
||||
width={props.state.sidebarWidth()}
|
||||
onWidthChange={props.state.resizeSidebar}
|
||||
minWidth={SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN}
|
||||
maxWidth={SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX}
|
||||
>
|
||||
<Show
|
||||
when={props.diffsReady()}
|
||||
fallback={
|
||||
<div class="px-2 py-2 text-12-regular text-text-weak">
|
||||
{language.t("common.loading")}
|
||||
{language.t("common.loading.ellipsis")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={props.searching()}
|
||||
fallback={
|
||||
<FileTreeV2
|
||||
path=""
|
||||
allowed={props.filteredFiles()}
|
||||
kinds={props.kinds()}
|
||||
draggable={false}
|
||||
active={props.activeDiff()}
|
||||
onFileClick={(node) => props.onSelectFile(node.path)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={props.filteredFiles().length > 0}
|
||||
fallback={<div class="px-2 py-2 text-12-regular text-text-weak">{language.t("palette.empty")}</div>}
|
||||
>
|
||||
<SessionFileListV2
|
||||
files={props.filteredFiles()}
|
||||
kinds={props.kinds()}
|
||||
active={props.activeDiff()}
|
||||
highlighted={highlightedPath()}
|
||||
onFileClick={(path) => {
|
||||
setExplicitHighlight(path)
|
||||
props.onSelectFile(path)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</SessionReviewV2Sidebar>
|
||||
)
|
||||
}
|
||||
109
packages/app/src/pages/session/v2/session-file-list-v2.tsx
Normal file
109
packages/app/src/pages/session/v2/session-file-list-v2.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import "@opencode-ai/ui/v2/file-tree-v2.css"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { createEffect, For, Show } from "solid-js"
|
||||
import { kindChange, kindLabel, type Kind } from "@/components/file-tree-v2"
|
||||
import { normalizePath } from "@/pages/session/v2/review-diff-kinds"
|
||||
|
||||
// Drives the highlight/selection of the flat search-result list from the filter
|
||||
// input's keyboard events.
|
||||
export function applyFileListKeyDown(
|
||||
event: KeyboardEvent,
|
||||
files: readonly string[],
|
||||
highlighted: string | undefined,
|
||||
options: { onHighlight: (path: string) => void; onSelect: (path: string) => void },
|
||||
) {
|
||||
if (files.length === 0) return
|
||||
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
const currentIndex = highlighted ? files.indexOf(highlighted) : -1
|
||||
const delta = event.key === "ArrowDown" ? 1 : -1
|
||||
const start = currentIndex === -1 ? (delta > 0 ? 0 : files.length - 1) : currentIndex + delta
|
||||
const index = Math.max(0, Math.min(files.length - 1, start))
|
||||
options.onHighlight(files[index]!)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key !== "Enter") return
|
||||
const target = highlighted ?? files[0]
|
||||
if (!target) return
|
||||
options.onSelect(target)
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
// Flat variant of FileTreeV2 for filtered results: reuses its data-component and
|
||||
// row data-slots on purpose so file-tree-v2.css styles both. data-highlighted has
|
||||
// no CSS of its own — it folds into data-selected below and only exists as the
|
||||
// scrollIntoView query hook.
|
||||
export function SessionFileListV2(props: {
|
||||
files: readonly string[]
|
||||
active?: string
|
||||
highlighted?: string
|
||||
kinds?: ReadonlyMap<string, Kind>
|
||||
onFileClick: (path: string) => void
|
||||
}) {
|
||||
const active = () => normalizePath(props.active ?? "")
|
||||
const highlighted = () => normalizePath(props.highlighted ?? "")
|
||||
let rootRef: HTMLDivElement | undefined
|
||||
|
||||
createEffect(() => {
|
||||
highlighted()
|
||||
if (!rootRef) return
|
||||
queueMicrotask(() => {
|
||||
const row = rootRef?.querySelector<HTMLElement>('[data-slot="file-tree-v2-row"][data-highlighted]')
|
||||
row?.scrollIntoView({ block: "nearest" })
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(el) => {
|
||||
rootRef = el
|
||||
}}
|
||||
data-component="file-tree-v2"
|
||||
>
|
||||
<For each={props.files}>
|
||||
{(path) => {
|
||||
const normalized = normalizePath(path)
|
||||
const selected = () => {
|
||||
if (highlighted()) return highlighted() === normalized
|
||||
return active() === normalized
|
||||
}
|
||||
const highlightedRow = () => highlighted() === normalized
|
||||
const kind = () => props.kinds?.get(normalized)
|
||||
const directory = () => (normalized.includes("/") ? getDirectory(normalized) : undefined)
|
||||
const filename = () => getFilename(normalized)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="file-tree-v2-row"
|
||||
data-selected={selected() ? "" : undefined}
|
||||
data-highlighted={highlightedRow() ? "" : undefined}
|
||||
style="padding-left: 8px"
|
||||
onClick={() => props.onFileClick(path)}
|
||||
>
|
||||
<span class="filetree-iconpair size-4">
|
||||
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--color" />
|
||||
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--mono" mono />
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-1 items-center overflow-hidden whitespace-nowrap">
|
||||
<Show when={directory()}>
|
||||
{(value) => <span class="text-12-medium text-text-muted truncate min-w-0 shrink">{value()}</span>}
|
||||
</Show>
|
||||
<span class="text-12-medium text-text-base truncate min-w-0 shrink-0">{filename()}</span>
|
||||
</span>
|
||||
<Show when={kind()}>
|
||||
{(value) => (
|
||||
<span data-slot="file-tree-v2-change" data-change={kindChange(value())}>
|
||||
{kindLabel(value())}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue