feat(app): v2 review panel overhaul (#31882)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
This commit is contained in:
co-authored by
LukeParkerDev
parent
fbb95a6ee3
commit
7d2618637f
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user