import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { createMediaQuery } from "@solid-primitives/media" import { DragDropProvider as DndKitProvider, PointerSensor } from "@dnd-kit/solid" import { isSortable } from "@dnd-kit/solid/sortable" import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom" import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers" import { RestrictToElement } from "@dnd-kit/dom/modifiers" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter, type DragEvent, } from "@thisbeyond/solid-dnd" import { Tabs } from "@opencode-ai/ui/tabs" import { IconButton } from "@opencode-ai/ui/icon-button" import { Icon } from "@opencode-ai/ui/icon" import { TooltipKeybind } from "@opencode-ai/ui/tooltip" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Mark } from "@opencode-ai/ui/logo" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" import type { FileDiffInfo } from "@opencode-ai/client/promise" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { useDialog } from "@opencode-ai/ui/context/dialog" import FileTree from "@/components/file-tree" import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model" import { SessionContextUsage } from "@/components/session-context-usage" const reviewTabID = "session-side-panel-review-tab" const reviewTabPanelID = "session-side-panel-review-tabpanel" const fileBrowserTabPanelID = "session-side-panel-file-browser-tabpanel" import { SessionContextTab, SortableTab, SortableTabV2, FileVisual } from "@/components/session" import { OpenInAppV2 } from "@/components/session/open-in-app-v2" import { useCommand } from "@/context/command" import { useFile, type SelectedLineRange } from "@/context/file" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" import { useSettings } from "@/context/settings" import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { FileTabContent } from "@/pages/session/file-tabs" import { SESSION_OPEN_FILE_TAB, createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, shouldShowFileTree, type Sizing, } from "@/pages/session/helpers" import { setSessionHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab" type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff const FILE_TREE_WIDTH_MIN = 240 function renderDiff(value: ReviewDiff): value is RenderDiff { return typeof value.file === "string" } export function SessionSidePanel(props: { canReview: () => boolean diffs: () => ReviewDiff[] diffsReady: () => boolean empty: () => string hasReview: () => boolean reviewHasFocusableContent: () => boolean reviewCount: () => number reviewPanel: () => JSX.Element reviewSidebarToggle?: (disabled: boolean) => JSX.Element fileBrowserState?: SessionFileBrowserState activeDiff?: string focusReviewDiff: (path: string) => void reviewSnap: boolean size: Sizing stacked?: boolean }) { const layout = useLayout() const settings = useSettings() const file = useFile() const language = useLanguage() const command = useCommand() const dialog = useDialog() const sdk = useSDK() const { sessionKey, tabs, view, params } = useSessionLayout() const projectDirectory = createMemo(() => sdk().directory) const isDesktop = createMediaQuery("(min-width: 768px)") const shown = settings.visibility.fileTree const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) const fileOpen = createMemo( () => isDesktop() && shouldShowFileTree({ visible: shown(), opened: layout.fileTree.opened(), }), ) const open = createMemo(() => reviewOpen() || fileOpen()) const fileTreeWidth = createMemo(() => Math.max(FILE_TREE_WIDTH_MIN, layout.fileTree.width())) const reviewTab = createMemo(() => isDesktop()) const panelWidth = createMemo(() => { if (!open()) return "0px" if (reviewOpen()) return "auto" return `${fileTreeWidth()}px` }) const treeWidth = createMemo(() => (fileOpen() ? `${fileTreeWidth()}px` : "0px")) const diffs = createMemo(() => props.diffs().filter(renderDiff)) const diffFiles = createMemo(() => diffs().map((d) => d.file)) const kinds = createMemo(() => { const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => { if (!a) return b if (a === b) return a return "mix" as const } const out = new Map() for (const diff of diffs()) { const file = normalizeFileTreeV2Path(diff.file) const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix" out.set(file, kind) const parts = file.split("/") for (const [idx] of parts.slice(0, -1).entries()) { const dir = parts.slice(0, idx + 1).join("/") if (!dir) continue out.set(dir, merge(out.get(dir), kind)) } } return out }) const empty = (msg: string) => (
{msg}
) const nofiles = createMemo(() => { const state = file.tree.state("") if (!state?.loaded) return false return file.tree.children("").length === 0 }) const normalizeTab = (tab: string) => { if (!tab.startsWith("file://")) return tab return file.tab(tab) } const openReviewPanel = () => { if (!view().reviewPanel.opened()) view().reviewPanel.open() } const openTab = createOpenSessionFileTab({ normalizeTab, openTab: tabs().open, pathFromTab: file.pathFromTab, loadFile: file.load, openReviewPanel, setActive: tabs().setActive, }) const tabState = createSessionTabs({ tabs, pathFromTab: file.pathFromTab, normalizeTab, review: reviewTab, hasReview: props.canReview, fileBrowser: () => !!props.fileBrowserState, }) const contextOpen = tabState.contextOpen const openFileOpen = tabState.openFileOpen const panelTabs = tabState.panelTabs const openedTabs = tabState.openedTabs const activeTab = tabState.activeTab const activeFileTab = tabState.activeFileTab const fileTreeTab = () => layout.fileTree.tab() const setFileTreeTabValue = (value: string) => { if (value !== "changes" && value !== "all") return layout.fileTree.setTab(value) } const showAllFiles = () => { if (fileTreeTab() !== "changes") return layout.fileTree.setTab("all") } let fileFilter: HTMLInputElement | undefined let tabList: HTMLDivElement | undefined const temporaryTab = tabs().preview const previewTab = (value: string) => { const next = normalizeTab(value) tabs().previewTab(next) const path = file.pathFromTab(next) if (path) void file.load(path) openReviewPanel() queueMicrotask(() => tabs().setActive(next)) } const openFileBrowser = () => { previewTab(SESSION_OPEN_FILE_TAB) queueMicrotask(() => fileFilter?.focus()) } const activateTab = (value: string) => { const next = normalizeTab(value) const path = file.pathFromTab(next) if (path) void file.load(path) openReviewPanel() tabs().setActive(next) } const browserTab = createMemo(() => { if (!props.fileBrowserState) return undefined const active = activeTab() if (active === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB if (active && file.pathFromTab(active)) return active return activeFileTab() }) // Keep the file-browser shell mounted while any file tab exists. Kobalte briefly // selects Review while the tab For replaces a preview trigger, which would // otherwise dispose the sidebar and reset scroll. const fileBrowserMounted = createMemo(() => { if (!props.fileBrowserState) return false return openedTabs().length > 0 || openFileOpen() || !!browserTab() }) const fileBrowserVisible = createMemo(() => { const active = activeTab() return active !== "review" && active !== "context" && active !== "empty" }) const openFileKeybind = createMemo(() => command.keybindParts("file.open")) const closeTabKeybind = createMemo(() => command.keybindParts("tab.close")) const [store, setStore] = createStore({ activeDraggable: undefined as string | undefined, }) const handleDragStart = (event: unknown) => { const id = getDraggableId(event) if (!id) return setStore("activeDraggable", id) } const handleDragOver = (event: DragEvent) => { const { draggable, droppable } = event if (!draggable || !droppable) return const currentTabs = tabs().all() const toIndex = getTabReorderIndex(currentTabs, draggable.id.toString(), droppable.id.toString()) if (toIndex === undefined) return tabs().move(draggable.id.toString(), toIndex) } const handleDragEnd = () => { setStore("activeDraggable", undefined) } createEffect(() => { if (!file.ready()) return setSessionHandoff(sessionKey(), { files: tabs() .all() .reduce>((acc, tab) => { const path = file.pathFromTab(tab) if (!path) return acc const selected = file.selectedLines(path) acc[path] = selected && typeof selected === "object" && "start" in selected && "end" in selected ? (selected as SelectedLineRange) : null return acc }, {}), }) }) return (
) }