From 170b9ba310f94899619247801f65503a54e2345a Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:24:24 +0530 Subject: [PATCH 01/13] review panel stuff --- packages/app/src/components/file-tree-v2.tsx | 501 ++++++++++++++++++ packages/app/src/components/session/index.ts | 1 + .../src/components/session/open-in-app-v2.tsx | 92 ++++ .../src/components/session/open-in-app.tsx | 232 ++++++++ .../src/components/session/session-header.tsx | 247 +-------- .../session/session-sortable-tab-v2.tsx | 39 ++ .../session/session-sortable-tab.tsx | 4 +- packages/app/src/context/command.tsx | 114 ++-- packages/app/src/context/file.tsx | 5 +- packages/app/src/context/file/tree-store.ts | 10 + packages/app/src/i18n/en.ts | 1 + packages/app/src/pages/session.tsx | 113 +++- .../src/pages/session/v2/files-panel-v2.tsx | 56 ++ .../session/v2/review-diff-kinds.test.ts | 23 + .../src/pages/session/v2/review-diff-kinds.ts | 41 ++ .../pages/session/v2/review-panel-v2-state.ts | 41 ++ .../src/pages/session/v2/review-panel-v2.tsx | 169 ++++++ .../session/v2/session-side-panel-v2.tsx | 334 ++++++++++++ packages/ui/src/components/collapsible.css | 6 + packages/ui/src/i18n/en.ts | 11 + .../ui/src/v2/components/basic-tool-v2.css | 2 +- .../ui/src/v2/components/diff-changes-v2.css | 6 +- .../v2/components/diff-changes-v2.stories.tsx | 2 +- .../ui/src/v2/components/diff-changes-v2.tsx | 2 +- .../ui/src/v2/components/file-tree-v2.css | 153 ++++++ packages/ui/src/v2/components/icon.tsx | 20 + .../line-comment-annotations-v2.tsx | 350 ++++++++++++ .../ui/src/v2/components/line-comment-v2.css | 30 +- .../v2/components/segmented-control-v2.css | 15 +- .../session-review-file-preview-v2.tsx | 245 +++++++++ .../src/v2/components/session-review-v2.css | 471 ++++++++++++++++ .../src/v2/components/session-review-v2.tsx | 335 ++++++++++++ .../ui/src/v2/components/split-button-v2.css | 99 ++++ .../ui/src/v2/components/split-button-v2.tsx | 48 ++ packages/ui/src/v2/components/tabs-v2.css | 34 +- .../ui/src/v2/components/text-input-v2.css | 19 + .../ui/src/v2/components/text-input-v2.tsx | 9 +- packages/ui/src/v2/components/tooltip-v2.css | 6 +- packages/ui/src/v2/components/tooltip-v2.tsx | 7 +- 39 files changed, 3546 insertions(+), 347 deletions(-) create mode 100644 packages/app/src/components/file-tree-v2.tsx create mode 100644 packages/app/src/components/session/open-in-app-v2.tsx create mode 100644 packages/app/src/components/session/open-in-app.tsx create mode 100644 packages/app/src/components/session/session-sortable-tab-v2.tsx create mode 100644 packages/app/src/pages/session/v2/files-panel-v2.tsx create mode 100644 packages/app/src/pages/session/v2/review-diff-kinds.test.ts create mode 100644 packages/app/src/pages/session/v2/review-diff-kinds.ts create mode 100644 packages/app/src/pages/session/v2/review-panel-v2-state.ts create mode 100644 packages/app/src/pages/session/v2/review-panel-v2.tsx create mode 100644 packages/app/src/pages/session/v2/session-side-panel-v2.tsx create mode 100644 packages/ui/src/v2/components/file-tree-v2.css create mode 100644 packages/ui/src/v2/components/line-comment-annotations-v2.tsx create mode 100644 packages/ui/src/v2/components/session-review-file-preview-v2.tsx create mode 100644 packages/ui/src/v2/components/session-review-v2.css create mode 100644 packages/ui/src/v2/components/session-review-v2.tsx create mode 100644 packages/ui/src/v2/components/split-button-v2.css create mode 100644 packages/ui/src/v2/components/split-button-v2.tsx diff --git a/packages/app/src/components/file-tree-v2.tsx b/packages/app/src/components/file-tree-v2.tsx new file mode 100644 index 000000000000..20207a197e69 --- /dev/null +++ b/packages/app/src/components/file-tree-v2.tsx @@ -0,0 +1,501 @@ +import { useFile } from "@/context/file" +import { encodeFilePath } from "@/context/file/path" +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 { dirsToExpand, shouldListRoot } from "@/components/file-tree" + +const MAX_DEPTH = 128 + +function pathToFileUrl(filepath: string): string { + return `file://${encodeFilePath(filepath)}` +} + +type Kind = "add" | "del" | "mix" + +type Filter = { + files: Set + dirs: Set +} + +function visibleNodesForPath( + path: string, + children: (dir: string) => FileNode[], + current: Filter | undefined, + query?: string, +) { + const nodes = children(path) + if (!current) { + const value = query?.trim().toLowerCase() + if (!value) return nodes + return nodes.filter((node) => { + if (node.type === "directory") return true + return node.name.toLowerCase().includes(value) + }) + } + + 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) + }) + + const value = query?.trim().toLowerCase() + if (!value) return out + + return out.filter((node) => { + if (node.type === "directory") return true + return node.name.toLowerCase().includes(value) + }) +} + +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 +} + +type ChangeState = "modified" | "added" | "deleted" | "renamed" | "untracked" + +const kindLabel = (kind: Kind, showModifiedLabel: boolean) => { + if (kind === "add") return "A" + if (kind === "del") return "D" + if (showModifiedLabel) return "M" + return "" +} + +const kindChangeState = (kind: Kind): ChangeState => { + if (kind === "add") return "added" + if (kind === "del") return "deleted" + return "modified" +} + +const visibleKind = (node: FileNode, kinds?: ReadonlyMap, marks?: Set) => { + const kind = kinds?.get(node.path) + if (!kind) return + if (!marks?.has(node.path)) return + return kind +} + +const buildDragImage = (target: HTMLElement) => { + const icon = target.querySelector('[data-component="file-icon"]') ?? target.querySelector("svg") + const text = target.querySelector("span") + if (!icon || !text) return + + const image = document.createElement("div") + image.className = + "flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong" + image.style.position = "absolute" + image.style.top = "-1000px" + image.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML + return image +} + +const withFileDragImage = (event: DragEvent) => { + const image = buildDragImage(event.currentTarget as HTMLElement) + if (!image) return + document.body.appendChild(image) + event.dataTransfer?.setDragImage(image, 0, 12) + setTimeout(() => document.body.removeChild(image), 0) +} + +const FileTreeNodeV2 = ( + p: ParentProps & + ComponentProps<"div"> & + ComponentProps<"button"> & { + node: FileNode + level: number + active?: string + nodeClass?: string + draggable: boolean + kinds?: ReadonlyMap + marks?: Set + showModifiedLabel?: boolean + as?: "div" | "button" + }, +) => { + const [local, rest] = splitProps(p, [ + "node", + "level", + "active", + "nodeClass", + "draggable", + "kinds", + "marks", + "showModifiedLabel", + "as", + "children", + "class", + "classList", + ]) + const kind = () => visibleKind(local.node, local.kinds, local.marks) + + return ( + { + 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} + {local.node.name} + {(() => { + const value = kind() + if (!value) return null + if (local.node.type === "file") { + return ( + + {kindLabel(value, local.showModifiedLabel ?? false)} + + ) + } + return + ) +} + +export default function FileTreeV2(props: { + path: string + class?: string + nodeClass?: string + active?: string + level?: number + allowed?: readonly string[] + modified?: readonly string[] + kinds?: ReadonlyMap + query?: string + showModifiedLabel?: boolean + draggable?: boolean + onFileClick?: (file: FileNode) => void + + _filter?: Filter + _marks?: Set + _deeps?: Map + _kinds?: ReadonlyMap + _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() + + 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() + for (const item of props.modified ?? []) out.add(item) + for (const item of props.kinds?.keys() ?? []) out.add(item) + 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() + + const root = props.path + if (!(file.tree.state(root)?.expanded ?? false)) return out + + const seen = new Set() + 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, + }) + for (const dir of dirs) file.tree.expand(dir) + }) + + 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(), props.query)) + + return ( +
+ + {(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(), props.query).length > 0 + return ( + + + (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))} + > + + +
+ +
+
+
+ + +
+ ...
} + > + +
+ + +
+
+ + props.onFileClick?.(node)} + > + 0}> +
+ + + } + > + + + + + + + + + ) + }} + +
+ ) +} diff --git a/packages/app/src/components/session/index.ts b/packages/app/src/components/session/index.ts index 8e424f0f36f7..9e44cea17be6 100644 --- a/packages/app/src/components/session/index.ts +++ b/packages/app/src/components/session/index.ts @@ -1,6 +1,7 @@ export { SessionHeader } from "./session-header" export { SessionContextTab } from "./session-context-tab" export { SortableTab, FileVisual } from "./session-sortable-tab" +export { SortableTabV2 } from "./session-sortable-tab-v2" export { SortableTerminalTab } from "./session-sortable-terminal-tab" export { NewSessionView } from "./session-new-view" export { NewSessionDesignView } from "./session-new-design-view" diff --git a/packages/app/src/components/session/open-in-app-v2.tsx b/packages/app/src/components/session/open-in-app-v2.tsx new file mode 100644 index 000000000000..643f4fa6ea56 --- /dev/null +++ b/packages/app/src/components/session/open-in-app-v2.tsx @@ -0,0 +1,92 @@ +import { For, Show } from "solid-js" +import { AppIcon } from "@opencode-ai/ui/app-icon" +import { Icon } from "@opencode-ai/ui/icon" +import { Spinner } from "@opencode-ai/ui/spinner" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { SplitButtonV2, SplitButtonV2Action, SplitButtonV2MenuTrigger } from "@opencode-ai/ui/v2/split-button-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { useLanguage } from "@/context/language" +import { OPEN_APPS, type OpenApp, useOpenInApp } from "@/components/session/open-in-app" + +export function OpenInAppV2(props: { directory: () => string }) { + const language = useLanguage() + const state = useOpenInApp(props) + + return ( + + + + state.openDir(state.current().id)} + disabled={state.opening()} + aria-label={language.t("session.header.open.ariaLabel", { app: state.current().label })} + > + }> + + + + + state.setMenu("open", open)} + > + + + + + + + {language.t("session.header.openIn")} + { + if (!OPEN_APPS.includes(value as OpenApp)) return + state.selectApp(value as OpenApp) + }} + > + + {(option) => ( + { + state.setMenu("open", false) + state.openDir(option.id) + }} + > + + {option.label} + + )} + + + + + { + state.setMenu("open", false) + state.copyPath() + }} + > + + {language.t("session.header.open.copyPath")} + + + + + + + ) +} diff --git a/packages/app/src/components/session/open-in-app.tsx b/packages/app/src/components/session/open-in-app.tsx new file mode 100644 index 000000000000..0d5cd9067122 --- /dev/null +++ b/packages/app/src/components/session/open-in-app.tsx @@ -0,0 +1,232 @@ +import { createEffect, createMemo } from "solid-js" +import { createStore } from "solid-js/store" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { useServer } from "@/context/server" +import { Persist, persisted } from "@/utils/persist" +import { showToast } from "@/utils/toast" + +export const OPEN_APPS = [ + "vscode", + "cursor", + "zed", + "textmate", + "antigravity", + "finder", + "terminal", + "iterm2", + "ghostty", + "warp", + "xcode", + "android-studio", + "powershell", + "sublime-text", +] as const + +export type OpenApp = (typeof OPEN_APPS)[number] +export type OpenAppOS = "macos" | "windows" | "linux" | "unknown" + +export const MAC_OPEN_APPS = [ + { + id: "vscode", + label: "session.header.open.app.vscode", + icon: "vscode", + openWith: "Visual Studio Code", + }, + { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" }, + { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" }, + { id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" }, + { + id: "antigravity", + label: "session.header.open.app.antigravity", + icon: "antigravity", + openWith: "Antigravity", + }, + { id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" }, + { id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" }, + { id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" }, + { id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" }, + { id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" }, + { + id: "android-studio", + label: "session.header.open.app.androidStudio", + icon: "android-studio", + openWith: "Android Studio", + }, + { + id: "sublime-text", + label: "session.header.open.app.sublimeText", + icon: "sublime-text", + openWith: "Sublime Text", + }, +] as const + +export const WINDOWS_OPEN_APPS = [ + { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" }, + { + id: "powershell", + label: "session.header.open.app.powershell", + icon: "powershell", + openWith: "powershell", + }, + { + id: "sublime-text", + label: "session.header.open.app.sublimeText", + icon: "sublime-text", + openWith: "Sublime Text", + }, +] as const + +export const LINUX_OPEN_APPS = [ + { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" }, + { + id: "sublime-text", + label: "session.header.open.app.sublimeText", + icon: "sublime-text", + openWith: "Sublime Text", + }, +] as const + +export function detectOpenAppOS(platform: ReturnType): OpenAppOS { + if (platform.platform === "desktop" && platform.os) return platform.os + if (typeof navigator !== "object") return "unknown" + const value = navigator.platform || navigator.userAgent + if (/Mac/i.test(value)) return "macos" + if (/Win/i.test(value)) return "windows" + if (/Linux/i.test(value)) return "linux" + return "unknown" +} + +export function openAppFileManager(os: OpenAppOS) { + if (os === "macos") return { label: "session.header.open.finder", icon: "finder" as const } + if (os === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const } + return { label: "session.header.open.fileManager", icon: "finder" as const } +} + +export function openAppsForOS(os: OpenAppOS) { + if (os === "macos") return MAC_OPEN_APPS + if (os === "windows") return WINDOWS_OPEN_APPS + return LINUX_OPEN_APPS +} + +const showRequestError = (language: ReturnType, err: unknown) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) +} + +export function useOpenInApp(input: { directory: () => string }) { + const platform = usePlatform() + const server = useServer() + const language = useLanguage() + + const os = createMemo(() => detectOpenAppOS(platform)) + const apps = createMemo(() => openAppsForOS(os())) + const fileManager = createMemo(() => openAppFileManager(os())) + + const [exists, setExists] = createStore>>({ + finder: true, + }) + + createEffect(() => { + if (platform.platform !== "desktop") return + if (!platform.checkAppExists) return + + const list = apps() + + setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial>) + + void Promise.all( + list.map((app) => + Promise.resolve(platform.checkAppExists?.(app.openWith)) + .then((value) => Boolean(value)) + .catch(() => false) + .then((ok) => [app.id, ok] as const), + ), + ).then((entries) => { + setExists(Object.fromEntries(entries) as Partial>) + }) + }) + + const options = createMemo(() => { + return [ + { id: "finder", label: language.t(fileManager().label), icon: fileManager().icon }, + ...apps() + .filter((app) => exists[app.id]) + .map((app) => ({ ...app, label: language.t(app.label) })), + ] as const + }) + + const [prefs, setPrefs] = persisted( + Persist.global("open.app"), + createStore({ app: "finder" as OpenApp | "finder" }), + ) + const [menu, setMenu] = createStore({ open: false }) + const [openRequest, setOpenRequest] = createStore({ + app: undefined as OpenApp | undefined, + }) + + const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) + const current = createMemo( + () => + options().find((o) => o.id === prefs.app) ?? + options()[0] ?? + ({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const), + ) + const opening = createMemo(() => openRequest.app !== undefined) + + const selectApp = (app: OpenApp | "finder") => { + if (!options().some((item) => item.id === app)) return + setPrefs("app", app) + } + + const openDir = (app: OpenApp | "finder") => { + if (opening() || !canOpen() || !platform.openPath) return + const directory = input.directory() + if (!directory) return + + const item = options().find((o) => o.id === app) + const openWith = item && "openWith" in item ? item.openWith : undefined + setOpenRequest("app", app) + platform + .openPath(directory, openWith) + .catch((err: unknown) => showRequestError(language, err)) + .finally(() => { + setOpenRequest("app", undefined) + }) + } + + const copyPath = () => { + const directory = input.directory() + if (!directory) return + navigator.clipboard + .writeText(directory) + .then(() => { + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("session.share.copy.copied"), + description: directory, + }) + }) + .catch((err: unknown) => showRequestError(language, err)) + } + + return { + canOpen, + opening, + current, + options, + menu, + setMenu, + openDir, + selectApp, + copyPath, + } +} diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index b983633d8069..4c456cc80bff 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -5,17 +5,14 @@ import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Keybind } from "@opencode-ai/ui/keybind" import { Spinner } from "@opencode-ai/ui/spinner" -import { showToast } from "@/utils/toast" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { getFilename } from "@opencode-ai/core/util/path" -import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js" -import { createStore } from "solid-js/store" +import { createMemo, createSignal, For, onMount, Show } from "solid-js" import { Portal } from "solid-js/web" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" -import { useServer } from "@/context/server" import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" @@ -23,118 +20,14 @@ import { focusTerminalById } from "@/pages/session/helpers" import { useSessionLayout } from "@/pages/session/session-layout" import { messageAgentColor } from "@/utils/agent" import { decode64 } from "@/utils/base64" -import { Persist, persisted } from "@/utils/persist" +import { OPEN_APPS, type OpenApp, useOpenInApp } from "@/components/session/open-in-app" import { StatusPopover, StatusPopoverV2 } from "../status-popover" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" -const OPEN_APPS = [ - "vscode", - "cursor", - "zed", - "textmate", - "antigravity", - "finder", - "terminal", - "iterm2", - "ghostty", - "warp", - "xcode", - "android-studio", - "powershell", - "sublime-text", -] as const - -type OpenApp = (typeof OPEN_APPS)[number] -type OS = "macos" | "windows" | "linux" | "unknown" - -const MAC_APPS = [ - { - id: "vscode", - label: "session.header.open.app.vscode", - icon: "vscode", - openWith: "Visual Studio Code", - }, - { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" }, - { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" }, - { id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" }, - { - id: "antigravity", - label: "session.header.open.app.antigravity", - icon: "antigravity", - openWith: "Antigravity", - }, - { id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" }, - { id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" }, - { id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" }, - { id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" }, - { id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" }, - { - id: "android-studio", - label: "session.header.open.app.androidStudio", - icon: "android-studio", - openWith: "Android Studio", - }, - { - id: "sublime-text", - label: "session.header.open.app.sublimeText", - icon: "sublime-text", - openWith: "Sublime Text", - }, -] as const - -const WINDOWS_APPS = [ - { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" }, - { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" }, - { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" }, - { - id: "powershell", - label: "session.header.open.app.powershell", - icon: "powershell", - openWith: "powershell", - }, - { - id: "sublime-text", - label: "session.header.open.app.sublimeText", - icon: "sublime-text", - openWith: "Sublime Text", - }, -] as const - -const LINUX_APPS = [ - { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" }, - { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" }, - { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" }, - { - id: "sublime-text", - label: "session.header.open.app.sublimeText", - icon: "sublime-text", - openWith: "Sublime Text", - }, -] as const - -const detectOS = (platform: ReturnType): OS => { - if (platform.platform === "desktop" && platform.os) return platform.os - if (typeof navigator !== "object") return "unknown" - const value = navigator.platform || navigator.userAgent - if (/Mac/i.test(value)) return "macos" - if (/Win/i.test(value)) return "windows" - if (/Linux/i.test(value)) return "linux" - return "unknown" -} - -const showRequestError = (language: ReturnType, err: unknown) => { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : String(err), - }) -} - export function SessionHeader() { const layout = useLayout() const command = useCommand() - const server = useServer() const platform = usePlatform() const language = useLanguage() const settings = useSettings() @@ -154,58 +47,13 @@ export function SessionHeader() { return getFilename(projectDirectory()) }) const hotkey = createMemo(() => command.keybind("file.open")) - const os = createMemo(() => detectOS(platform)) + const openIn = useOpenInApp({ directory: projectDirectory }) const isDesktopV2 = createMemo(() => platform.platform === "desktop" && settings.general.newLayoutDesigns()) const search = createMemo(() => (isDesktopV2() ? settings.general.showSearch() : true)) const tree = createMemo(() => (isDesktopV2() ? settings.general.showFileTree() : true)) const term = createMemo(() => (isDesktopV2() ? settings.general.showTerminal() : true)) const status = createMemo(() => (isDesktopV2() ? settings.general.showStatus() : true)) - const [exists, setExists] = createStore>>({ - finder: true, - }) - - const apps = createMemo(() => { - if (os() === "macos") return MAC_APPS - if (os() === "windows") return WINDOWS_APPS - return LINUX_APPS - }) - - const fileManager = createMemo(() => { - if (os() === "macos") return { label: "session.header.open.finder", icon: "finder" as const } - if (os() === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const } - return { label: "session.header.open.fileManager", icon: "finder" as const } - }) - - createEffect(() => { - if (platform.platform !== "desktop") return - if (!platform.checkAppExists) return - - const list = apps() - - setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial>) - - void Promise.all( - list.map((app) => - Promise.resolve(platform.checkAppExists?.(app.openWith)) - .then((value) => Boolean(value)) - .catch(() => false) - .then((ok) => [app.id, ok] as const), - ), - ).then((entries) => { - setExists(Object.fromEntries(entries) as Partial>) - }) - }) - - const options = createMemo(() => { - return [ - { id: "finder", label: language.t(fileManager().label), icon: fileManager().icon }, - ...apps() - .filter((app) => exists[app.id]) - .map((app) => ({ ...app, label: language.t(app.label) })), - ] as const - }) - const toggleTerminal = () => { const next = !view().terminal.opened() view().terminal.toggle() @@ -216,20 +64,6 @@ export function SessionHeader() { focusTerminalById(id) } - const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp })) - const [menu, setMenu] = createStore({ open: false }) - const [openRequest, setOpenRequest] = createStore({ - app: undefined as OpenApp | undefined, - }) - - const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) - const current = createMemo( - () => - options().find((o) => o.id === prefs.app) ?? - options()[0] ?? - ({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const), - ) - const opening = createMemo(() => openRequest.app !== undefined) const tint = createMemo(() => messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent), ) @@ -242,43 +76,6 @@ export function SessionHeader() { onReviewToggle: () => view().reviewPanel.toggle(), })) - const selectApp = (app: OpenApp) => { - if (!options().some((item) => item.id === app)) return - setPrefs("app", app) - } - - const openDir = (app: OpenApp) => { - if (opening() || !canOpen() || !platform.openPath) return - const directory = projectDirectory() - if (!directory) return - - const item = options().find((o) => o.id === app) - const openWith = item && "openWith" in item ? item.openWith : undefined - setOpenRequest("app", app) - platform - .openPath(directory, openWith) - .catch((err: unknown) => showRequestError(language, err)) - .finally(() => { - setOpenRequest("app", undefined) - }) - } - - const copyPath = () => { - const directory = projectDirectory() - if (!directory) return - navigator.clipboard - .writeText(directory) - .then(() => { - showToast({ - variant: "success", - icon: "circle-check", - title: language.t("session.share.copy.copied"), - description: directory, - }) - }) - .catch((err: unknown) => showRequestError(language, err)) - } - const [centerMount, setCenterMount] = createSignal(null) const [rightMount, setRightMount] = createSignal(null) onMount(() => { @@ -328,13 +125,13 @@ export function SessionHeader() { + } + > + {store.error}
} + > + 0} + fallback={
{language.t("palette.empty")}
} + > + { + setStore("highlightedPath", path) + props.onOpenFile(path) + }} + onFileDoubleClick={props.onOpenFilePersist} + /> +
+ + + + + props.onOpenFile(node.path)} + onFileDoubleClick={(node) => props.onOpenFilePersist?.(node.path)} + /> + ) } diff --git a/packages/app/src/pages/session/v2/review-panel-v2.tsx b/packages/app/src/pages/session/v2/review-panel-v2.tsx index 7787ad499a23..5d9325e0208b 100644 --- a/packages/app/src/pages/session/v2/review-panel-v2.tsx +++ b/packages/app/src/pages/session/v2/review-panel-v2.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, Show, type JSX } from "solid-js" +import { createEffect, createMemo, createSignal, Show, type JSX } from "solid-js" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" import { SessionReviewV2, SessionReviewV2Sidebar } from "@opencode-ai/ui/v2/session-review-v2" import { SessionReviewFilePreviewV2 } from "@opencode-ai/ui/v2/session-review-file-preview-v2" @@ -25,6 +25,7 @@ import { REVIEW_PANEL_V2_SIDEBAR_WIDTH_MIN, type ReviewPanelV2State, } from "@/pages/session/v2/review-panel-v2-state" +import { SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2" type ReviewDiff = SnapshotFileDiff | VcsFileDiff @@ -51,20 +52,27 @@ function useReviewPanelV2Data(props: ReviewPanelV2Props) { const diffs = createMemo(() => props.diffs().filter(filterRenderableDiff)) const diffFiles = createMemo(() => diffs().map((diff) => diff.file)) const filteredFiles = createMemo(() => filterReviewFiles(diffFiles(), props.state.filter())) + const searching = createMemo(() => props.state.filter().trim().length > 0) const kinds = createMemo(() => reviewDiffKinds(diffs())) const activeDiff = createMemo(() => { 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())) - return { diffs, filteredFiles, kinds, activeDiff, activeItem } + return { diffs, filteredFiles, searching, kinds, activeDiff, activeItem } } -function useReviewPanelV2ActiveFile(props: ReviewPanelV2Props, filteredFiles: () => string[]) { +function useReviewPanelV2ActiveFile( + props: ReviewPanelV2Props, + filteredFiles: () => string[], + searching: () => boolean, +) { createEffect(() => { + if (searching()) return const files = filteredFiles() const active = props.activeFile if (files.length === 0) return @@ -76,7 +84,43 @@ function useReviewPanelV2ActiveFile(props: ReviewPanelV2Props, filteredFiles: () export function ReviewPanelV2Sidebar(props: ReviewPanelV2Props) { const language = useLanguage() const model = useReviewPanelV2Data(props) - useReviewPanelV2ActiveFile(props, model.filteredFiles) + const flatMode = createMemo(() => model.searching()) + const [highlightedPath, setHighlightedPath] = createSignal() + useReviewPanelV2ActiveFile(props, model.filteredFiles, model.searching) + + createEffect(() => { + const files = model.filteredFiles() + if (!flatMode() || files.length === 0) { + if (highlightedPath()) setHighlightedPath(undefined) + return + } + const highlighted = highlightedPath() + if (highlighted && files.includes(highlighted)) return + setHighlightedPath(files[0]!) + }) + + const onFilterKeyDown = (event: KeyboardEvent & { currentTarget: HTMLInputElement }) => { + if (!flatMode()) return + const files = model.filteredFiles() + if (files.length === 0) return + + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + const highlighted = highlightedPath() + 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)) + setHighlightedPath(files[index]!) + event.preventDefault() + return + } + + if (event.key !== "Enter") return + const target = highlightedPath() ?? files[0] + if (!target) return + props.onSelectFile(target) + event.preventDefault() + } return ( } filter={props.state.filter()} onFilterChange={props.state.setFilter} + onFilterKeyDown={onFilterKeyDown} width={props.state.sidebarWidth()} onWidthChange={props.state.resizeSidebar} minWidth={REVIEW_PANEL_V2_SIDEBAR_WIDTH_MIN} @@ -99,14 +144,33 @@ export function ReviewPanelV2Sidebar(props: ReviewPanelV2Props) { } > - props.onSelectFile(node.path)} - /> + + 0} + fallback={
{language.t("palette.empty")}
} + > + { + setHighlightedPath(path) + props.onSelectFile(path) + }} + /> +
+
+ + props.onSelectFile(node.path)} + /> +
) diff --git a/packages/app/src/pages/session/v2/session-file-list-v2.tsx b/packages/app/src/pages/session/v2/session-file-list-v2.tsx new file mode 100644 index 000000000000..5621463d1579 --- /dev/null +++ b/packages/app/src/pages/session/v2/session-file-list-v2.tsx @@ -0,0 +1,104 @@ +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" + +type FileKind = "add" | "del" | "mix" + +function normalizePath(path: string) { + return path.replaceAll("\\", "/").replace(/\/+$/, "") +} + +function kindLabel(kind: FileKind, showModifiedLabel: boolean) { + if (kind === "add") return "A" + if (kind === "del") return "D" + if (!showModifiedLabel) return "" + return "M" +} + +function kindChange(kind: FileKind) { + if (kind === "add") return "added" + if (kind === "del") return "deleted" + return "modified" +} + +export function SessionFileListV2(props: { + files: readonly string[] + active?: string + highlighted?: string + kinds?: ReadonlyMap + showModifiedLabel?: boolean + onFileClick: (path: string) => void + onFileDoubleClick?: (path: string) => void +}) { + const active = () => normalizePath(props.active ?? "") + const highlighted = () => normalizePath(props.highlighted ?? "") + const showModifiedLabel = () => props.showModifiedLabel ?? false + let rootRef: HTMLDivElement | undefined + + createEffect(() => { + highlighted() + if (!rootRef) return + queueMicrotask(() => { + const row = rootRef?.querySelector('[data-slot="file-tree-v2-row"][data-highlighted]') + row?.scrollIntoView({ block: "nearest" }) + }) + }) + + return ( +
{ + rootRef = el + }} + data-component="file-tree-v2" + data-show-modified-label={showModifiedLabel() ? "" : undefined} + > + + {(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 = () => getDirectory(normalized) + const filename = () => getFilename(normalized) + return ( + + ) + }} + +
+ ) +} diff --git a/packages/app/src/pages/session/v2/session-side-panel-v2.tsx b/packages/app/src/pages/session/v2/session-side-panel-v2.tsx index 4859f240116a..f0b1a50320d9 100644 --- a/packages/app/src/pages/session/v2/session-side-panel-v2.tsx +++ b/packages/app/src/pages/session/v2/session-side-panel-v2.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, For, onCleanup, Show, type JSX } from "solid-js" +import { createEffect, createMemo, createSignal, For, onCleanup, Show, type JSX } from "solid-js" import { Portal } from "solid-js/web" import { createMediaQuery } from "@solid-primitives/media" import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2" @@ -78,10 +78,37 @@ export function SessionSidePanelV2(props: { const contextOpen = tabsV2.tabState.contextOpen const activeTab = tabsV2.tabState.activeTab const activeFileTab = tabsV2.tabState.activeFileTab + const [focusFilesFilterToken, setFocusFilesFilterToken] = createSignal(0) + let previousActiveTab: string | undefined + let previousTemporaryTab: string | undefined + let previousHadOpenFileTab = false + let initializedOpenFileTracking = false + let wasOpenFileTab = false const filesSidebarOpen = createMemo( () => props.reviewV2State.sidebarOpened() || activeTab() === SESSION_OPEN_FILE_TAB, ) + createEffect(() => { + const currentActiveTab = activeTab() + const currentTemporaryTab = tabsV2.temporaryTab() + const currentTabs = tabs().all() + const currentHadOpenFileTab = currentTabs.includes(SESSION_OPEN_FILE_TAB) + const isOpenFileTab = currentActiveTab === SESSION_OPEN_FILE_TAB + if (isOpenFileTab && !wasOpenFileTab) { + const shouldClearFilter = + initializedOpenFileTracking && + !previousHadOpenFileTab && + previousActiveTab !== previousTemporaryTab + if (shouldClearFilter) props.reviewV2State.setFilesFilter("") + setFocusFilesFilterToken((token) => token + 1) + } + initializedOpenFileTracking = true + previousActiveTab = currentActiveTab + previousTemporaryTab = currentTemporaryTab + previousHadOpenFileTab = currentHadOpenFileTab + wasOpenFileTab = isOpenFileTab + }) + createEffect(() => { if (!file.ready()) return @@ -272,6 +299,7 @@ export function SessionSidePanelV2(props: { title={projectName()} state={props.reviewV2State} open={filesSidebarOpen()} + focusFilterToken={focusFilesFilterToken()} diffs={props.diffs} activeFile={activeFileTab()} onOpenFile={(path) => tabsV2.openFileTab(path)} diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts index 8ffa0318a770..c5e028b5a5fd 100644 --- a/packages/core/src/filesystem/search.ts +++ b/packages/core/src/filesystem/search.ts @@ -159,6 +159,10 @@ function collectPaths( }) } +function escapeGlob(text: string) { + return text.replaceAll("\\", "\\\\").replace(/[?*[\]{}()!]/g, "\\$&") +} + function searchFff( pick: Fff.Picker, kind: "file" | "directory" | "all", @@ -326,6 +330,57 @@ export const layer: Layer.Layer rg.files(input) const tree: Interface["tree"] = (input) => rg.tree(input) + const fallbackFileSearch = Effect.fn("Search.fileFallback")(function* (input: { + cwd: string + query: string + kind: "file" | "directory" | "all" + limit: number + error: unknown + }) { + return yield* Effect.gen(function* () { + const dir = FSUtil.resolve(input.cwd) + const pattern = `**/*${escapeGlob(input.query)}*` + yield* Effect.logWarning("fff file search fallback to glob", { + dir, + query: input.query, + kind: input.kind, + pattern, + error: input.error, + }) + const scanned = yield* Effect.tryPromise({ + try: () => Glob.scan(pattern, { cwd: dir, include: "all", dot: true }), + catch: (cause) => new Error("glob fallback scan failed", { cause }), + }) + const typed = yield* Effect.forEach( + scanned, + Effect.fnUntraced(function* (relative) { + const absolute = path.join(dir, relative) + const stat = yield* fs.stat(absolute).pipe(Effect.catch(() => Effect.succeed(undefined))) + const type = stat?.type === "Directory" ? "directory" : stat?.type === "File" ? "file" : undefined + if (!type) return + if (input.kind !== "all" && input.kind !== type) return + return { path: normalize(relative), type } satisfies FileResult + }), + { concurrency: 32, discard: false }, + ) + const seen = new Set() + const deduped = typed.flatMap((item): FileResult[] => { + if (!item) return [] + if (seen.has(item.path)) return [] + seen.add(item.path) + return [item] + }) + deduped.sort((a, b) => a.path.length - b.path.length || a.path.localeCompare(b.path)) + return deduped.slice(0, input.limit) + }).pipe( + Effect.catch((error) => + Effect.logWarning("glob fallback file search failed", { query: input.query, error }).pipe( + Effect.as([] as FileResult[]), + ), + ), + ) + }) + // in 99% of use cases user that is opened opencode at certain directory will // conduct a file search in this direcotry, it could be switched later but // mostly always we will need a file picker for cwd @@ -360,36 +415,57 @@ export const layer: Layer.Layer - searchFff(entry.pick, kind, query, { - pageIndex: 0, - currentFile: input.current, // supports both relative and absolute (relative preferred) - pageSize: limit, - }), - ).pipe( - Effect.catch((error) => - Effect.logWarning(`fff ${kind} search failed`, { dir, query, error }).pipe( - Effect.andThen(Effect.fail(error)), + if (!query) return [] + + const rows = yield* Effect.gen(function* () { + const entry = yield* acquire(input.cwd) + if (!entry) return yield* Effect.fail(new Error("fff is unavailable")) + yield* entry.ready + const fffResult = yield* fffSync(`${kind} search`, () => + searchFff(entry.pick, kind, query, { + pageIndex: 0, + currentFile: input.current, // supports both relative and absolute (relative preferred) + pageSize: limit, + }), + ).pipe( + Effect.catch((error) => + Effect.logWarning(`fff ${kind} search failed`, { dir, query, error }).pipe( + Effect.andThen(Effect.fail(error)), + ), ), + ) + if (!fffResult.ok) { + yield* Effect.logWarning(`fff ${kind} search failed`, { dir, query, error: fffResult.error }) + return yield* Effect.fail(new Error(fffResult.error)) + } + if (fffResult.value.length === 0) { + return yield* fallbackFileSearch({ + cwd: input.cwd, + query, + kind, + limit, + error: "fff returned no matches", + }) + } + return fffResult.value + }).pipe( + Effect.catch((error) => + fallbackFileSearch({ + cwd: input.cwd, + query, + kind, + limit, + error, + }), ), ) - if (!fffResult.ok) { - yield* Effect.logWarning(`fff ${kind} search failed`, { dir, query, error: fffResult.error }) - return yield* Effect.fail(new Error(fffResult.error)) - } - - const rows = fffResult.value remember( state, dir, query, - rows.map((row) => path.join(dir, row.path)), + rows.map((row: FileResult) => path.join(dir, row.path)), ) return rows.slice(0, limit) }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts index 66146b1d5b91..adb58f26e82e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts @@ -39,7 +39,7 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl const started = performance.now() const fff = yield* search.file({ cwd: directory, query: ctx.query.query, limit, kind }).pipe(Effect.orDie) yield* Effect.logInfo("find file", { - engine: "fff", + engine: "search.file", query: ctx.query.query, kind, directory, diff --git a/packages/ui/src/v2/components/session-review-v2.tsx b/packages/ui/src/v2/components/session-review-v2.tsx index 434fdcd2f219..74ef650ec08f 100644 --- a/packages/ui/src/v2/components/session-review-v2.tsx +++ b/packages/ui/src/v2/components/session-review-v2.tsx @@ -44,6 +44,8 @@ export type SessionReviewV2SidebarProps = { stats?: JSX.Element filter: string onFilterChange: (value: string) => void + onFilterKeyDown?: JSX.EventHandlerUnion + focusFilterToken?: number width?: number onWidthChange?: (width: number) => void minWidth?: number @@ -57,6 +59,17 @@ export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) { const width = () => props.width ?? SIDEBAR_WIDTH_DEFAULT const minWidth = () => props.minWidth ?? SIDEBAR_WIDTH_MIN const maxWidth = () => props.maxWidth ?? SIDEBAR_WIDTH_MAX + let filterInputRef: HTMLInputElement | undefined + + createEffect(() => { + const token = props.focusFilterToken + if (!props.open || !token || token <= 0) return + queueMicrotask(() => { + if (!props.open) return + filterInputRef?.focus() + filterInputRef?.select() + }) + }) createEffect(() => { if (!resizing()) return @@ -84,9 +97,16 @@ export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) {
{ + filterInputRef = el + }} type="search" value={props.filter} onInput={(event) => props.onFilterChange(event.currentTarget.value)} + onKeyDown={props.onFilterKeyDown} + showClearButton={props.filter.length > 0} + clearLabel={i18n.t("ui.list.clearFilter")} + onClearClick={() => props.onFilterChange("")} placeholder={i18n.t("ui.sessionReviewV2.filterFiles")} aria-label={i18n.t("ui.sessionReviewV2.filterFiles")} leadingIcon={ diff --git a/packages/ui/src/v2/components/text-input-v2.css b/packages/ui/src/v2/components/text-input-v2.css index c4d6db2934b2..3da53c3cca0d 100644 --- a/packages/ui/src/v2/components/text-input-v2.css +++ b/packages/ui/src/v2/components/text-input-v2.css @@ -100,6 +100,12 @@ color: var(--v2-text-text-faint); } +[data-component="text-input-v2"] [data-slot="text-input-v2-input"][type="search"]::-webkit-search-cancel-button { + -webkit-appearance: none; + appearance: none; + display: none; +} + [data-component="text-input-v2"][data-numeric] [data-slot="text-input-v2-input"] { font-variant-numeric: tabular-nums; } @@ -153,6 +159,14 @@ color: currentColor; } +[data-component="text-input-v2"] [data-slot="text-input-v2-icon-button"][data-variant="clear"] { + width: 28px; + height: 28px; + padding: 0; + border-radius: 6px; + margin-right: -8px; +} + [data-component="text-input-v2"][data-invalid]:not([data-disabled]) [data-slot="text-input-v2-input"] { color: var(--v2-state-fg-danger); caret-color: var(--v2-state-fg-danger); diff --git a/packages/ui/src/v2/components/text-input-v2.tsx b/packages/ui/src/v2/components/text-input-v2.tsx index 0bae5ea26ff6..375b54669db7 100644 --- a/packages/ui/src/v2/components/text-input-v2.tsx +++ b/packages/ui/src/v2/components/text-input-v2.tsx @@ -7,9 +7,14 @@ export interface TextInputV2Props extends Omit, "type"> leadingIcon?: JSX.Element /** Show the trailing copy action. */ showCopyButton?: boolean + /** Show the trailing clear action. */ + showClearButton?: boolean /** Accessible label for the copy button. */ copyLabel?: string + /** Accessible label for the clear button. */ + clearLabel?: string onCopyClick?: (event: MouseEvent) => void + onClearClick?: (event: MouseEvent) => void /** Apply tabular numerals to the field value. */ numeric?: boolean /** Error styling for the field and value text. */ @@ -25,8 +30,11 @@ export function TextInputV2(props: TextInputV2Props) { "classList", "leadingIcon", "showCopyButton", + "showClearButton", "copyLabel", + "clearLabel", "onCopyClick", + "onClearClick", "numeric", "invalid", "appearance", @@ -58,15 +66,26 @@ export function TextInputV2(props: TextInputV2Props) { data-slot="text-input-v2-input" />
- + From 7b828939b6654bf2c41ac4ef11fe2d85f2352f5d Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:30:25 +0530 Subject: [PATCH 06/13] no diff empty state --- packages/app/src/pages/session.tsx | 7 ++-- packages/ui/src/i18n/en.ts | 2 + packages/ui/src/v2/components/icon.tsx | 4 ++ .../session-review-empty-changes-v2.tsx | 19 ++++++++++ .../src/v2/components/session-review-v2.css | 37 +++++++++++++++++++ 5 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/v2/components/session-review-empty-changes-v2.tsx diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index e36442674707..fb82014947a5 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -61,6 +61,7 @@ import { useServer } from "@/context/server" import { syncSessionModel } from "@/pages/session/session-model-helpers" import { SessionSidePanel } from "@/pages/session/session-side-panel" import { SessionSidePanelV2 } from "@/pages/session/v2/session-side-panel-v2" +import { SessionReviewEmptyChangesV2 } from "@opencode-ai/ui/v2/session-review-empty-changes-v2" import { SessionReviewEmptyNoGitV2 } from "@opencode-ai/ui/v2/session-review-empty-no-git-v2" import { ReviewPanelV2, ReviewPanelV2Sidebar } from "@/pages/session/v2/review-panel-v2" import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state" @@ -1013,17 +1014,17 @@ export default function Page() { const reviewEmptyV2 = (input: { loadingClass: string }) => { if (store.changes === "git" || store.changes === "branch") { if (!reviewReady()) return
{language.t("session.review.loadingChanges")}
- return empty(reviewEmptyText()) + return } if (store.changes === "turn") { if (nogit()) { return } - return empty(reviewEmptyText()) + return } - return empty(reviewEmptyText()) + return } const reviewContent = (input: { diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 42dcba5aa553..6da0a7f50a5e 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -31,6 +31,8 @@ export const dict: Record = { "ui.sessionReviewV2.empty.noGit.actionLoading": "Creating Git repository...", "ui.sessionReviewV2.empty.openFile.title": "Open file", "ui.sessionReviewV2.empty.openFile.description": "Search or select a file from file tree", + "ui.sessionReviewV2.empty.changes.title": "No file changes yet", + "ui.sessionReviewV2.empty.changes.description": "Project changes will appear here", "ui.sessionReview.openFile": "Open file", "ui.sessionReview.selection.line": "line {{line}}", diff --git a/packages/ui/src/v2/components/icon.tsx b/packages/ui/src/v2/components/icon.tsx index e2110d05b106..e7a1937a1139 100644 --- a/packages/ui/src/v2/components/icon.tsx +++ b/packages/ui/src/v2/components/icon.tsx @@ -89,6 +89,10 @@ const icons = { viewBox: "0 0 16 16", body: ``, }, + review: { + viewBox: "0 0 20 20", + body: ``, + }, } const spriteID = "opencode-v2-icon-sprite" diff --git a/packages/ui/src/v2/components/session-review-empty-changes-v2.tsx b/packages/ui/src/v2/components/session-review-empty-changes-v2.tsx new file mode 100644 index 000000000000..ae3344f54590 --- /dev/null +++ b/packages/ui/src/v2/components/session-review-empty-changes-v2.tsx @@ -0,0 +1,19 @@ +import { useI18n } from "../../context/i18n" +import { Icon } from "./icon" +import "./session-review-v2.css" + +export function SessionReviewEmptyChangesV2() { + const i18n = useI18n() + + return ( +
+ +
+ {i18n.t("ui.sessionReviewV2.empty.changes.title")} +
+
+ {i18n.t("ui.sessionReviewV2.empty.changes.description")} +
+
+ ) +} diff --git a/packages/ui/src/v2/components/session-review-v2.css b/packages/ui/src/v2/components/session-review-v2.css index ac2c72e219a0..00e365bb18cd 100644 --- a/packages/ui/src/v2/components/session-review-v2.css +++ b/packages/ui/src/v2/components/session-review-v2.css @@ -570,3 +570,40 @@ letter-spacing: -0.04px; color: var(--v2-text-text-muted); } + +[data-slot="session-review-v2-empty-changes"] { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + padding: 24px; + text-align: center; +} + +[data-slot="session-review-v2-empty-changes"] [data-slot="icon-svg"] { + flex: none; + color: var(--v2-icon-icon-muted); +} + +[data-slot="session-review-v2-empty-changes-title"] { + flex: none; + margin-top: 4px; + font-size: 13px; + font-weight: 530; + line-height: 100%; + letter-spacing: -0.04px; + color: var(--v2-text-text-base); +} + +[data-slot="session-review-v2-empty-changes-description"] { + flex: none; + max-width: 282px; + font-size: 13px; + font-weight: 440; + line-height: 20px; + text-align: center; + letter-spacing: -0.04px; + color: var(--v2-text-text-muted); +} From ee75f58d84eeb391c6e44212e3d468e513bfd288 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:38:51 +0530 Subject: [PATCH 07/13] reuse scrollview --- packages/ui/src/components/scroll-view.tsx | 38 ++++++++++++++++--- .../src/v2/components/session-review-v2.css | 28 +++++++++++++- .../src/v2/components/session-review-v2.tsx | 9 ++++- 3 files changed, 67 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx index 3ff00f117d22..a94ab1c995c1 100644 --- a/packages/ui/src/components/scroll-view.tsx +++ b/packages/ui/src/components/scroll-view.tsx @@ -1,11 +1,14 @@ -import { onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js" +import { onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js" import { createResizeObserver } from "@solid-primitives/resize-observer" import { createStore } from "solid-js/store" import { useI18n } from "../context/i18n" +export type ScrollViewThumbVisibility = "hover" | "scroll" + export interface ScrollViewProps extends ComponentProps<"div"> { viewportRef?: (el: HTMLDivElement) => void orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb + thumbVisibility?: ScrollViewThumbVisibility } export const scrollKey = (event: Pick) => { @@ -29,10 +32,10 @@ export const scrollKey = (event: Pick state.isHovered const isDragging = () => state.isDragging + const isScrolling = () => state.isScrolling const thumbHeight = () => state.thumbHeight const thumbTop = () => state.thumbTop const showThumb = () => state.showThumb + let scrollIdleTimer: ReturnType | undefined + + const markScrolling = () => { + if (local.thumbVisibility !== "scroll") return + setState("isScrolling", true) + if (scrollIdleTimer !== undefined) clearTimeout(scrollIdleTimer) + scrollIdleTimer = setTimeout(() => setState("isScrolling", false), 800) + } + + const thumbVisible = () => { + if (isDragging()) return true + if (local.thumbVisibility === "scroll") return isScrolling() + return isHovered() + } + + onCleanup(() => { + if (scrollIdleTimer !== undefined) clearTimeout(scrollIdleTimer) + }) + const updateThumb = () => { if (!viewportRef) return const { scrollTop, scrollHeight, clientHeight } = viewportRef @@ -197,9 +221,13 @@ export function ScrollView(props: ScrollViewProps) { class="scroll-view__viewport" onScroll={(e) => { updateThumb() + markScrolling() if (typeof events.onScroll === "function") events.onScroll(e as any) }} - onWheel={events.onWheel as any} + onWheel={(e) => { + markScrolling() + if (typeof events.onWheel === "function") events.onWheel(e as any) + }} onTouchStart={events.onTouchStart as any} onTouchMove={events.onTouchMove as any} onTouchEnd={events.onTouchEnd as any} @@ -223,7 +251,7 @@ export function ScrollView(props: ScrollViewProps) { ref={thumbRef} onPointerDown={onThumbPointerDown} class="scroll-view__thumb" - data-visible={isHovered() || isDragging()} + data-visible={thumbVisible()} data-dragging={isDragging()} style={{ height: `${thumbHeight()}px`, diff --git a/packages/ui/src/v2/components/session-review-v2.css b/packages/ui/src/v2/components/session-review-v2.css index 00e365bb18cd..3c49b4b82c2f 100644 --- a/packages/ui/src/v2/components/session-review-v2.css +++ b/packages/ui/src/v2/components/session-review-v2.css @@ -115,10 +115,36 @@ [data-slot="session-review-v2-sidebar-tree"] { flex: 1; min-height: 0; - overflow: auto; +} + +:is([data-component="session-review-v2"], [data-component="session-review-v2-sidebar-root"]) + [data-slot="session-review-v2-sidebar-tree"] + .scroll-view__viewport { padding: 0 8px 12px; } +:is([data-component="session-review-v2"], [data-component="session-review-v2-sidebar-root"]) + [data-slot="session-review-v2-sidebar-tree"] + .scroll-view__thumb { + width: 16px; +} + +:is([data-component="session-review-v2"], [data-component="session-review-v2-sidebar-root"]) + [data-slot="session-review-v2-sidebar-tree"] + .scroll-view__thumb::after { + width: 6px; + background-color: var(--v2-border-border-muted, var(--border-weak-base)); +} + +:is([data-component="session-review-v2"], [data-component="session-review-v2-sidebar-root"]) + [data-slot="session-review-v2-sidebar-tree"] + .scroll-view__thumb:hover::after, +:is([data-component="session-review-v2"], [data-component="session-review-v2-sidebar-root"]) + [data-slot="session-review-v2-sidebar-tree"] + .scroll-view__thumb[data-dragging="true"]::after { + background-color: var(--v2-border-border-strong, var(--border-strong-base)); +} + [data-component="session-review-v2"] [data-slot="session-review-v2-preview"] { display: flex; flex-direction: column; diff --git a/packages/ui/src/v2/components/session-review-v2.tsx b/packages/ui/src/v2/components/session-review-v2.tsx index 74ef650ec08f..8721f9e41697 100644 --- a/packages/ui/src/v2/components/session-review-v2.tsx +++ b/packages/ui/src/v2/components/session-review-v2.tsx @@ -8,6 +8,7 @@ import { IconButtonV2 } from "./icon-button-v2" import { TooltipV2 } from "./tooltip-v2" import type { SessionReviewDiffStyle } from "../../components/session-review" import { ResizeHandle } from "../../components/resize-handle" +import { ScrollView } from "../../components/scroll-view" import { Show, createEffect, createSignal, onCleanup, type JSX } from "solid-js" import "./session-review-v2.css" @@ -127,9 +128,13 @@ export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) { } /> -
+ {props.children} -
+
From 7d18e457e5d2d0296b20823428729c5c9c9a40d9 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:39:25 +0530 Subject: [PATCH 08/13] correctness fixes --- packages/app/src/components/file-tree-v2.tsx | 9 +- .../pages/session/v2/file-tab-content-v2.tsx | 13 +- .../src/pages/session/v2/files-panel-v2.tsx | 146 ++++++------------ .../src/pages/session/v2/review-diff-kinds.ts | 33 +++- .../src/pages/session/v2/review-panel-v2.tsx | 72 ++++----- .../pages/session/v2/session-file-list-v2.tsx | 5 +- .../session/v2/session-side-panel-v2.tsx | 75 +++++---- packages/core/src/filesystem/search.ts | 25 +-- .../routes/instance/httpapi/groups/file.ts | 1 + .../routes/instance/httpapi/handlers/file.ts | 14 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 2 + packages/sdk/js/src/v2/gen/types.gen.ts | 1 + .../src/v2/components/session-review-v2.tsx | 12 +- packages/ui/src/v2/components/tooltip-v2.tsx | 2 +- 14 files changed, 195 insertions(+), 215 deletions(-) diff --git a/packages/app/src/components/file-tree-v2.tsx b/packages/app/src/components/file-tree-v2.tsx index e8ead54c1410..21802201c72b 100644 --- a/packages/app/src/components/file-tree-v2.tsx +++ b/packages/app/src/components/file-tree-v2.tsx @@ -177,6 +177,7 @@ const FileTreeNodeV2 = ( kinds?: ReadonlyMap marks?: Set showModifiedLabel?: boolean + showFolderChangeIndicator?: boolean as?: "div" | "button" }, ) => { @@ -189,6 +190,7 @@ const FileTreeNodeV2 = ( "kinds", "marks", "showModifiedLabel", + "showFolderChangeIndicator", "as", "children", "class", @@ -230,6 +232,7 @@ const FileTreeNodeV2 = ( ) } + if (local.showFolderChangeIndicator === false) return null return - - props.onOpenFile(node.path)} - onFileDoubleClick={(node) => props.onOpenFilePersist?.(node.path)} - /> - ) } diff --git a/packages/app/src/pages/session/v2/review-diff-kinds.ts b/packages/app/src/pages/session/v2/review-diff-kinds.ts index 1c6cdbfc1ad5..99957f5bba4e 100644 --- a/packages/app/src/pages/session/v2/review-diff-kinds.ts +++ b/packages/app/src/pages/session/v2/review-diff-kinds.ts @@ -4,6 +4,10 @@ export type ReviewDiffKind = "add" | "del" | "mix" 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" } @@ -15,11 +19,9 @@ export function reviewDiffKinds(diffs: RenderDiff[]) { return "mix" as const } - const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "") - const out = new Map() for (const diff of diffs) { - const file = normalize(diff.file) + const file = normalizePath(diff.file) const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix" out.set(file, kind) @@ -39,3 +41,28 @@ export function filterReviewFiles(files: string[], query: string) { if (!value) return files return files.filter((file) => file.toLowerCase().includes(value)) } + +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() +} diff --git a/packages/app/src/pages/session/v2/review-panel-v2.tsx b/packages/app/src/pages/session/v2/review-panel-v2.tsx index 5d9325e0208b..68c36f45c124 100644 --- a/packages/app/src/pages/session/v2/review-panel-v2.tsx +++ b/packages/app/src/pages/session/v2/review-panel-v2.tsx @@ -16,6 +16,7 @@ import FileTreeV2 from "@/components/file-tree-v2" import { useLanguage } from "@/context/language" import { useSDK } from "@/context/sdk" import { + applyFileListKeyDown, filterRenderableDiff, filterReviewFiles, reviewDiffKinds, @@ -27,6 +28,17 @@ import { } from "@/pages/session/v2/review-panel-v2-state" import { SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2" +export function makeReadFile(sdk: ReturnType) { + return 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 + }) +} + type ReviewDiff = SnapshotFileDiff | VcsFileDiff export type ReviewPanelV2Props = { @@ -101,25 +113,10 @@ export function ReviewPanelV2Sidebar(props: ReviewPanelV2Props) { const onFilterKeyDown = (event: KeyboardEvent & { currentTarget: HTMLInputElement }) => { if (!flatMode()) return - const files = model.filteredFiles() - if (files.length === 0) return - - if (event.key === "ArrowDown" || event.key === "ArrowUp") { - const highlighted = highlightedPath() - 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)) - setHighlightedPath(files[index]!) - event.preventDefault() - return - } - - if (event.key !== "Enter") return - const target = highlightedPath() ?? files[0] - if (!target) return - props.onSelectFile(target) - event.preventDefault() + applyFileListKeyDown(event, model.filteredFiles(), highlightedPath(), { + onHighlight: setHighlightedPath, + onSelect: props.onSelectFile, + }) } return ( @@ -144,7 +141,20 @@ export function ReviewPanelV2Sidebar(props: ReviewPanelV2Props) { } > - + props.onSelectFile(node.path)} + /> + } + > 0} fallback={
{language.t("palette.empty")}
} @@ -161,16 +171,6 @@ export function ReviewPanelV2Sidebar(props: ReviewPanelV2Props) { />
- - props.onSelectFile(node.path)} - /> -
) @@ -179,16 +179,7 @@ export function ReviewPanelV2Sidebar(props: ReviewPanelV2Props) { export function ReviewPanelV2(props: ReviewPanelV2Props) { const sdk = useSDK() const model = useReviewPanelV2Data(props) - - const readFile = async (path: string) => { - return sdk.client.file - .read({ path }) - .then((x) => x.data) - .catch((error) => { - console.debug("[session-review-v2] failed to read file", { path, error }) - return undefined - }) - } + const readFile = makeReadFile(sdk) return ( } activeFile={model.activeDiff()} files={model.filteredFiles()} onSelectFile={props.onSelectFile} diff --git a/packages/app/src/pages/session/v2/session-file-list-v2.tsx b/packages/app/src/pages/session/v2/session-file-list-v2.tsx index 5621463d1579..436440f1567c 100644 --- a/packages/app/src/pages/session/v2/session-file-list-v2.tsx +++ b/packages/app/src/pages/session/v2/session-file-list-v2.tsx @@ -2,13 +2,10 @@ 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 { normalizePath } from "@/pages/session/v2/review-diff-kinds" type FileKind = "add" | "del" | "mix" -function normalizePath(path: string) { - return path.replaceAll("\\", "/").replace(/\/+$/, "") -} - function kindLabel(kind: FileKind, showModifiedLabel: boolean) { if (kind === "add") return "A" if (kind === "del") return "D" diff --git a/packages/app/src/pages/session/v2/session-side-panel-v2.tsx b/packages/app/src/pages/session/v2/session-side-panel-v2.tsx index f0b1a50320d9..2e94d715e279 100644 --- a/packages/app/src/pages/session/v2/session-side-panel-v2.tsx +++ b/packages/app/src/pages/session/v2/session-side-panel-v2.tsx @@ -1,4 +1,5 @@ import { createEffect, createMemo, createSignal, For, onCleanup, Show, type JSX } from "solid-js" +import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" import { createMediaQuery } from "@solid-primitives/media" import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2" @@ -49,10 +50,9 @@ export function SessionSidePanelV2(props: { const isDesktop = createMediaQuery("(min-width: 768px)") const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) - const open = createMemo(() => reviewOpen()) const reviewTab = createMemo(() => isDesktop()) const panelWidth = createMemo(() => { - if (!open()) return "0px" + if (!reviewOpen()) return "0px" return "auto" }) @@ -79,11 +79,13 @@ export function SessionSidePanelV2(props: { const activeTab = tabsV2.tabState.activeTab const activeFileTab = tabsV2.tabState.activeFileTab const [focusFilesFilterToken, setFocusFilesFilterToken] = createSignal(0) - let previousActiveTab: string | undefined - let previousTemporaryTab: string | undefined - let previousHadOpenFileTab = false - let initializedOpenFileTracking = false - let wasOpenFileTab = false + const [tracking, setTracking] = createStore({ + prevActiveTab: undefined as string | undefined, + prevTemporaryTab: undefined as string | undefined, + prevHadOpenFileTab: false, + wasOpenFileTab: false, + initialized: false, + }) const filesSidebarOpen = createMemo( () => props.reviewV2State.sidebarOpened() || activeTab() === SESSION_OPEN_FILE_TAB, ) @@ -91,22 +93,23 @@ export function SessionSidePanelV2(props: { createEffect(() => { const currentActiveTab = activeTab() const currentTemporaryTab = tabsV2.temporaryTab() - const currentTabs = tabs().all() - const currentHadOpenFileTab = currentTabs.includes(SESSION_OPEN_FILE_TAB) + const currentHadOpenFileTab = tabs().all().includes(SESSION_OPEN_FILE_TAB) const isOpenFileTab = currentActiveTab === SESSION_OPEN_FILE_TAB - if (isOpenFileTab && !wasOpenFileTab) { + if (isOpenFileTab && !tracking.wasOpenFileTab) { const shouldClearFilter = - initializedOpenFileTracking && - !previousHadOpenFileTab && - previousActiveTab !== previousTemporaryTab + tracking.initialized && + !tracking.prevHadOpenFileTab && + tracking.prevActiveTab !== tracking.prevTemporaryTab if (shouldClearFilter) props.reviewV2State.setFilesFilter("") setFocusFilesFilterToken((token) => token + 1) } - initializedOpenFileTracking = true - previousActiveTab = currentActiveTab - previousTemporaryTab = currentTemporaryTab - previousHadOpenFileTab = currentHadOpenFileTab - wasOpenFileTab = isOpenFileTab + setTracking({ + prevActiveTab: currentActiveTab, + prevTemporaryTab: currentTemporaryTab, + prevHadOpenFileTab: currentHadOpenFileTab, + wasOpenFileTab: isOpenFileTab, + initialized: true, + }) }) createEffect(() => { @@ -155,11 +158,11 @@ export function SessionSidePanelV2(props: {