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/21] 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/21] 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/21] 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/21] 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: { -
setResizing(true)} - > +
setResizing(true)}>
-
+
@@ -225,62 +212,56 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
-
- - {i18n.t("ui.sessionReviewV2.previousFile")} - - - } - > - { - const file = prev() - if (!file) return - props.onSelectFile(file) - }} - aria-label={i18n.t("ui.sessionReviewV2.previousFile")} - /> - - - {i18n.t("ui.sessionReviewV2.nextFile")} - "]} variant="neutral" /> - - } - > - { - const file = next() - if (!file) return - props.onSelectFile(file) - }} - aria-label={i18n.t("ui.sessionReviewV2.nextFile")} - /> - +
+ + {i18n.t("ui.sessionReviewV2.previousFile")} + + + } + > + { + const file = prev() + if (!file) return + props.onSelectFile(file) + }} + aria-label={i18n.t("ui.sessionReviewV2.previousFile")} + /> + + + {i18n.t("ui.sessionReviewV2.nextFile")} + "]} variant="neutral" /> + + } + > + { + const file = next() + if (!file) return + props.onSelectFile(file) + }} + aria-label={i18n.t("ui.sessionReviewV2.nextFile")} + /> +
-
+
{ @@ -291,18 +272,12 @@ export function SessionReviewV2(props: SessionReviewV2Props) { aria-label={i18n.t("ui.sessionReviewV2.expandMode")} > - + - + @@ -318,18 +293,12 @@ export function SessionReviewV2(props: SessionReviewV2Props) { aria-label={i18n.t("ui.sessionReviewV2.diffView")} > - + - + From 9d43d019afa12c5b9086bf40bf2056b574f72790 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:00:55 +1000 Subject: [PATCH 17/21] refactor(app): integrate v2 review pane into existing session side panel Delete the forked v2 side panel, terminal panel variant, and hand-rolled tab drag machinery that duplicated functionality already shipped in dev (sortable file/terminal tabs, files browser, open-file dialog). The v2 review pane now renders inside the existing SessionSidePanel review slot when the new layout is enabled, using its own embedded sidebar and toolbar-hosted sidebar toggle. Restore accidental platform-guard and keybind changes to dev state and drop the now-unused server file glob fallback. --- .../timeline/session-tab-switch-probe.ts | 4 +- .../e2e/regression/review-image-flash.spec.ts | 6 +- .../app/src/components/dialog-select-file.tsx | 4 +- .../app/src/components/directory-picker.tsx | 2 +- packages/app/src/components/file-tree-v2.tsx | 8 +- packages/app/src/components/session/index.ts | 2 - .../src/components/session/open-in-app-v2.tsx | 92 ---- .../src/components/session/open-in-app.tsx | 229 ---------- .../session/session-sortable-tab-v2.tsx | 39 -- .../session/session-sortable-tab.tsx | 4 +- .../session-sortable-terminal-tab-v2.tsx | 199 -------- .../components/session/session-tab-drag-v2.ts | 146 ------ packages/app/src/components/terminal.tsx | 3 +- packages/app/src/context/file.tsx | 1 - packages/app/src/context/file/tree-store.ts | 10 - packages/app/src/i18n/en.ts | 1 - packages/app/src/pages/session.tsx | 74 +-- .../app/src/pages/session/command-mode.ts | 3 - packages/app/src/pages/session/file-tabs.tsx | 28 +- packages/app/src/pages/session/helpers.ts | 19 +- .../app/src/pages/session/terminal-panel.tsx | 296 ++++-------- .../session/use-session-commands.test.ts | 13 - .../pages/session/use-session-commands.tsx | 10 +- .../session/use-terminal-panel-tabs-v2.ts | 273 ----------- .../pages/session/v2/file-tab-content-v2.tsx | 53 --- .../src/pages/session/v2/files-panel-v2.tsx | 195 -------- .../pages/session/v2/review-panel-v2-state.ts | 29 +- .../src/pages/session/v2/review-panel-v2.tsx | 206 +++++---- .../pages/session/v2/session-file-list-v2.tsx | 18 +- .../session/v2/session-side-panel-v2.tsx | 419 ----------------- .../v2/use-session-side-panel-tabs-v2.ts | 432 ------------------ .../routes/instance/httpapi/groups/file.ts | 1 - .../routes/instance/httpapi/handlers/file.ts | 26 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 2 - packages/sdk/js/src/v2/gen/types.gen.ts | 1 - .../components/line-comment-annotations.tsx | 8 +- .../session-review-empty-open-file-v2.tsx | 38 -- .../src/v2/components/session-review-v2.css | 196 -------- .../src/v2/components/session-review-v2.tsx | 62 +-- packages/ui/src/i18n/en.ts | 2 - packages/ui/src/v2/components/icon.tsx | 4 - .../ui/src/v2/components/split-button-v2.css | 99 ---- .../ui/src/v2/components/split-button-v2.tsx | 48 -- 43 files changed, 278 insertions(+), 3027 deletions(-) delete mode 100644 packages/app/src/components/session/open-in-app-v2.tsx delete mode 100644 packages/app/src/components/session/open-in-app.tsx delete mode 100644 packages/app/src/components/session/session-sortable-tab-v2.tsx delete mode 100644 packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx delete mode 100644 packages/app/src/components/session/session-tab-drag-v2.ts delete mode 100644 packages/app/src/pages/session/command-mode.ts delete mode 100644 packages/app/src/pages/session/use-session-commands.test.ts delete mode 100644 packages/app/src/pages/session/use-terminal-panel-tabs-v2.ts delete mode 100644 packages/app/src/pages/session/v2/file-tab-content-v2.tsx delete mode 100644 packages/app/src/pages/session/v2/files-panel-v2.tsx delete mode 100644 packages/app/src/pages/session/v2/session-side-panel-v2.tsx delete mode 100644 packages/app/src/pages/session/v2/use-session-side-panel-tabs-v2.ts delete mode 100644 packages/session-ui/src/v2/components/session-review-empty-open-file-v2.tsx delete mode 100644 packages/ui/src/v2/components/split-button-v2.css delete mode 100644 packages/ui/src/v2/components/split-button-v2.tsx diff --git a/packages/app/e2e/performance/timeline/session-tab-switch-probe.ts b/packages/app/e2e/performance/timeline/session-tab-switch-probe.ts index ca9a1b336ddb..f61160f7a741 100644 --- a/packages/app/e2e/performance/timeline/session-tab-switch-probe.ts +++ b/packages/app/e2e/performance/timeline/session-tab-switch-probe.ts @@ -18,8 +18,8 @@ async function installSessionSwitchProbe( let running = true const reviewLevels: Record = { panel: "#review-panel", - tabs: '#review-panel [data-component="tabs-v2"]', - body: "#review-panel .session-review-v2-panel-body", + tabs: '#review-panel [data-component="tabs"]', + body: '#review-panel [data-slot="session-review-v2-body"]', review: '#review-panel [data-component="session-review-v2"]', preview: '#review-panel [data-slot="session-review-v2-preview"]', scroll: '#review-panel [data-slot="session-review-v2-diff-scroll"]', diff --git a/packages/app/e2e/regression/review-image-flash.spec.ts b/packages/app/e2e/regression/review-image-flash.spec.ts index 89045d307ae8..dd200384d49a 100644 --- a/packages/app/e2e/regression/review-image-flash.spec.ts +++ b/packages/app/e2e/regression/review-image-flash.spec.ts @@ -138,7 +138,7 @@ async function openReview(page: Page) { await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await expectSessionTitle(page, title) await page.getByRole("button", { name: "Toggle review" }).click() - await expectAppVisible(page.locator('#review-panel [data-component="tabs-v2"].session-review-v2-tabs')) + await expectAppVisible(page.locator('#review-panel [data-component="session-review-v2"]')) await expectAppVisible(page.getByRole("button", { name: /preview\.png/ })) } @@ -153,9 +153,7 @@ async function installReviewFlashProbe(page: Page) { }> = [] const startedAt = performance.now() const sample = () => { - const panel = document.querySelector( - '#review-panel [data-component="tabs-v2"].session-review-v2-tabs', - ) + const panel = document.querySelector('#review-panel [data-component="session-review-v2"]') const rect = panel?.getBoundingClientRect() const center = rect ? document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2) diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 16f22b61e089..49a0d5961aa6 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -14,6 +14,7 @@ import { useServerSync } from "@/context/server-sync" import { useLayout } from "@/context/layout" import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" import { useSettings } from "@/context/settings" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" @@ -269,6 +270,7 @@ function createSessionEntries(props: { export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) { const command = useCommand() const language = useLanguage() + const platform = usePlatform() const settings = useSettings() const layout = useLayout() const file = useFile() @@ -389,7 +391,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil state.cleanup?.() }) - if (filesOnly() && settings.general.newLayoutDesigns()) { + if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) { return ( { if (!selected) input.onSelect(null) } - if (settings.general.newLayoutDesigns()) { + if (platform.platform === "desktop" && settings.general.newLayoutDesigns()) { dialog.show(() => , cancel) return } diff --git a/packages/app/src/components/file-tree-v2.tsx b/packages/app/src/components/file-tree-v2.tsx index f71de89b46d2..cdc6cee16d8d 100644 --- a/packages/app/src/components/file-tree-v2.tsx +++ b/packages/app/src/components/file-tree-v2.tsx @@ -26,7 +26,7 @@ function pathToFileUrl(filepath: string): string { return `file://${encodeFilePath(filepath)}` } -type Kind = "add" | "del" | "mix" +export type Kind = "add" | "del" | "mix" type Filter = { files: Set @@ -123,14 +123,14 @@ function guideLineLeft(level: number) { type ChangeState = "modified" | "added" | "deleted" | "renamed" | "untracked" -const kindLabel = (kind: Kind, showModifiedLabel: boolean) => { +export 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 => { +export const kindChange = (kind: Kind): ChangeState => { if (kind === "add") return "added" if (kind === "del") return "deleted" return "modified" @@ -227,7 +227,7 @@ const FileTreeNodeV2 = ( if (!value) return null if (local.node.type === "file") { return ( - + {kindLabel(value, local.showModifiedLabel ?? false)} ) diff --git a/packages/app/src/components/session/index.ts b/packages/app/src/components/session/index.ts index b5eb690ccf87..8e424f0f36f7 100644 --- a/packages/app/src/components/session/index.ts +++ b/packages/app/src/components/session/index.ts @@ -1,8 +1,6 @@ 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 { SortableTerminalTabV2 } from "./session-sortable-terminal-tab-v2" 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 deleted file mode 100644 index df633a8ded8c..000000000000 --- a/packages/app/src/components/session/open-in-app-v2.tsx +++ /dev/null @@ -1,92 +0,0 @@ -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 deleted file mode 100644 index cd363d00c49a..000000000000 --- a/packages/app/src/components/session/open-in-app.tsx +++ /dev/null @@ -1,229 +0,0 @@ -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-sortable-tab-v2.tsx b/packages/app/src/components/session/session-sortable-tab-v2.tsx deleted file mode 100644 index 05dc57f79f79..000000000000 --- a/packages/app/src/components/session/session-sortable-tab-v2.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { createMemo, Show } from "solid-js" -import type { JSX } from "solid-js" -import { createSortable } from "@thisbeyond/solid-dnd" -import { TooltipKeybind } from "@opencode-ai/ui/tooltip" -import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2" -import { useFile } from "@/context/file" -import { useLanguage } from "@/context/language" -import { useCommand } from "@/context/command" -import { FileVisual } from "./session-sortable-tab" - -export function SortableTabV2(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element { - const file = useFile() - const language = useLanguage() - const command = useCommand() - const sortable = createSortable(props.tab) - const path = createMemo(() => file.pathFromTab(props.tab)) - const content = createMemo(() => { - const value = path() - if (!value) return - return - }) - return ( -
-
- props.onTabClose(props.tab)}> - {(value) => value()} - - props.onTabClose(props.tab)} /> - - -
-
- ) -} diff --git a/packages/app/src/components/session/session-sortable-tab.tsx b/packages/app/src/components/session/session-sortable-tab.tsx index 5a122e5e63be..f04228ca66c7 100644 --- a/packages/app/src/components/session/session-sortable-tab.tsx +++ b/packages/app/src/components/session/session-sortable-tab.tsx @@ -10,7 +10,7 @@ import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" import { useCommand } from "@/context/command" -export function FileVisual(props: { path: string; active?: boolean; textClass?: string }): JSX.Element { +export function FileVisual(props: { path: string; active?: boolean }): JSX.Element { return (
- {getFilename(props.path)} + {getFilename(props.path)}
) } diff --git a/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx b/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx deleted file mode 100644 index d60657dd1e30..000000000000 --- a/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import type { JSX } from "solid-js" -import { Show, createEffect, onCleanup } from "solid-js" -import { createStore } from "solid-js/store" -import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2" -import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" -import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" -import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" -import { Icon } from "@opencode-ai/ui/icon" -import { useTerminal, type LocalPTY } from "@/context/terminal" -import { useLanguage } from "@/context/language" -import { focusTerminalById } from "@/pages/session/helpers" -import { terminalTabLabel } from "@/pages/session/terminal-label" - -export function SortableTerminalTabV2(props: { - terminal: LocalPTY - onClose?: () => void - dragged: boolean - pressed: boolean - dragActive: boolean - onStripPointerDown: (event: PointerEvent) => void -}): JSX.Element { - const terminal = useTerminal() - const language = useLanguage() - const [store, setStore] = createStore({ - editing: false, - title: props.terminal.title, - menuOpen: false, - menuPosition: { x: 0, y: 0 }, - blurEnabled: false, - }) - let input: HTMLInputElement | undefined - let blurFrame: number | undefined - let editRequested = false - - const label = () => - terminalTabLabel({ - title: props.terminal.title, - titleNumber: props.terminal.titleNumber, - t: language.t as (key: string, vars?: Record) => string, - }) - - const close = () => { - const count = terminal.all().length - void terminal.close(props.terminal.id) - if (count === 1) { - props.onClose?.() - } - } - - const focus = () => { - if (store.editing) return - if (document.activeElement instanceof HTMLElement) document.activeElement.blur() - focusTerminalById(props.terminal.id) - } - - const edit = (e?: Event) => { - if (e) { - e.stopPropagation() - e.preventDefault() - } - - setStore("blurEnabled", false) - setStore("title", props.terminal.title) - setStore("editing", true) - } - - const save = () => { - if (!store.blurEnabled) return - - const value = store.title.trim() - if (value && value !== props.terminal.title) { - terminal.update({ id: props.terminal.id, title: value }) - } - setStore("editing", false) - } - - const keydown = (e: KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault() - save() - return - } - if (e.key === "Escape") { - e.preventDefault() - setStore("editing", false) - } - } - - const menu = (e: MouseEvent) => { - e.preventDefault() - setStore("menuPosition", { x: e.clientX, y: e.clientY }) - setStore("menuOpen", true) - } - - createEffect(() => { - if (!store.editing) return - if (!input) return - input.focus() - input.select() - if (blurFrame !== undefined) cancelAnimationFrame(blurFrame) - blurFrame = requestAnimationFrame(() => { - blurFrame = undefined - setStore("blurEnabled", true) - }) - }) - - onCleanup(() => { - if (blurFrame === undefined) return - cancelAnimationFrame(blurFrame) - }) - - return ( -
{ - if (props.dragged) return - props.onStripPointerDown(event) - }} - > -
- e.preventDefault()} - onMiddleClick={close} - onContextMenu={menu} - onDblClick={edit} - > -
{label()}
-
- { - event.preventDefault() - event.stopPropagation() - }} - onClick={(event) => { - event.stopPropagation() - close() - }} - icon={} - aria-label={language.t("terminal.close")} - /> -
-
- -
- setStore("title", e.currentTarget.value)} - onBlur={save} - onKeyDown={keydown} - onMouseDown={(e) => e.stopPropagation()} - class="bg-transparent border-none outline-none text-sm min-w-0 flex-1" - /> -
-
- setStore("menuOpen", open)}> - - { - if (!editRequested) return - e.preventDefault() - editRequested = false - requestAnimationFrame(() => edit()) - }} - > - (editRequested = true)}> - - {language.t("common.rename")} - - - - {language.t("common.close")} - - - - -
-
- ) -} diff --git a/packages/app/src/components/session/session-tab-drag-v2.ts b/packages/app/src/components/session/session-tab-drag-v2.ts deleted file mode 100644 index 6ae639c92dfe..000000000000 --- a/packages/app/src/components/session/session-tab-drag-v2.ts +++ /dev/null @@ -1,146 +0,0 @@ -export type TabDragLayout = { - tabWidthById: Map - dividerWidth: number - listLeft: number -} - -export const ACTIVATION_DISTANCE = 4 -export const HYSTERESIS_DEADBAND = 8 -export const AUTOSCROLL_EDGE = 24 -export const AUTOSCROLL_MAX_SPEED = 8 -export const FLOATER_OVERSHOOT_MAX = 8 - -export function pointerDistance(x1: number, y1: number, x2: number, y2: number) { - const dx = x2 - x1 - const dy = y2 - y1 - return Math.sqrt(dx * dx + dy * dy) -} - -export function captureTabDragLayout(list: HTMLElement, order: string[]) { - const tabWidthById = new Map() - const slots = list.querySelectorAll("[data-session-tab-slot]") - for (const slot of slots) { - const id = slot.dataset.tabKey - if (!id) continue - const tab = slot.querySelector("[data-session-tab]") - if (!tab) continue - tabWidthById.set(id, tab.getBoundingClientRect().width) - } - - let dividerWidth = 0 - if (order.length >= 2) { - const firstId = order[0] - const secondId = order[1] - const firstSlot = Array.from(slots).find((slot) => slot.dataset.tabKey === firstId) - const secondSlot = Array.from(slots).find((slot) => slot.dataset.tabKey === secondId) - if (firstSlot && secondSlot) { - const firstLeft = firstSlot.getBoundingClientRect().left - const secondLeft = secondSlot.getBoundingClientRect().left - const firstWidth = tabWidthById.get(firstId) ?? 0 - dividerWidth = Math.max(secondLeft - firstLeft - firstWidth, 0) - } - } - - return { - tabWidthById, - dividerWidth, - listLeft: getDraggableStripLeft(list), - } -} - -export function syncLayoutScroll(list: HTMLElement, layout: TabDragLayout) { - layout.listLeft = getDraggableStripLeft(list) -} - -function getDraggableStripLeft(list: HTMLElement) { - const slots = Array.from(list.querySelectorAll("[data-session-tab-slot]")) - if (slots.length === 0) return list.getBoundingClientRect().left - return Math.min(...slots.map((slot) => slot.getBoundingClientRect().left)) -} - -function slotWidthAt(order: readonly string[], index: number, layout: TabDragLayout) { - const id = order[index] - if (!id) return 0 - const tabWidth = layout.tabWidthById.get(id) ?? 0 - return index === 0 ? tabWidth : layout.dividerWidth + tabWidth -} - -function slotLeft(order: readonly string[], index: number, layout: TabDragLayout) { - let left = layout.listLeft - for (const [i] of order.entries()) { - if (i >= index) break - left += slotWidthAt(order, i, layout) - } - return left -} - -export function insertIndexFromVirtualLayout( - pointerX: number, - order: readonly string[], - draggedId: string, - currentIndex: number, - layout: TabDragLayout, - deadband = HYSTERESIS_DEADBAND, -) { - if (order.length === 0) return 0 - - const others = order.filter((id) => id !== draggedId) - let target = currentIndex - - if (currentIndex > 0) { - const seam = slotLeft(others, currentIndex, layout) - if (pointerX < seam - deadband) target = currentIndex - 1 - } - - if (target === currentIndex && currentIndex < order.length - 1) { - const seam = slotLeft(others, currentIndex + 1, layout) - if (pointerX >= seam) target = currentIndex + 1 - } - - return target -} - -export function movePlaceholder(order: readonly string[], draggedId: string, toIndex: number) { - const fromIndex = order.indexOf(draggedId) - if (fromIndex === -1 || fromIndex === toIndex) return [...order] - const next = [...order] - next.splice(toIndex, 0, ...next.splice(fromIndex, 1)) - return next -} - -export function draftOrderChanged(initial: readonly string[], final: readonly string[]) { - if (initial.length === 0 || final.length === 0 || initial.length !== final.length) return false - return final.some((key, index) => key !== initial[index]) -} - -function easeOvershoot(overshoot: number) { - return (FLOATER_OVERSHOOT_MAX * overshoot) / (overshoot + FLOATER_OVERSHOOT_MAX) -} - -export function clampFloaterLeft(left: number, width: number, stripLeft: number, stripRight: number) { - const stripWidth = stripRight - stripLeft - if (width >= stripWidth) return stripLeft - - const maxLeft = stripRight - width - if (left > maxLeft) return maxLeft + easeOvershoot(left - maxLeft) - if (left < stripLeft) return stripLeft - easeOvershoot(stripLeft - left) - - return left -} - -export function autoscrollSpeed(pointerX: number, containerLeft: number, containerRight: number) { - const leftEdge = containerLeft + AUTOSCROLL_EDGE - const rightEdge = containerRight - AUTOSCROLL_EDGE - - if (pointerX < leftEdge) { - const depth = (leftEdge - pointerX) / AUTOSCROLL_EDGE - return -Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1)) - } - - if (pointerX > rightEdge) { - const depth = (pointerX - rightEdge) / AUTOSCROLL_EDGE - return Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1)) - } - - return 0 -} diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 7b18992e21fe..8998fc06c89e 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -385,8 +385,7 @@ export const Terminal = (props: TerminalProps) => { } // allow for toggle terminal keybinds in parent - const fallback = settings.general.newLayoutDesigns() ? "mod+j" : DEFAULT_TOGGLE_TERMINAL_KEYBIND - const config = settings.keybinds.get(TOGGLE_TERMINAL_ID) ?? fallback + const config = settings.keybinds.get(TOGGLE_TERMINAL_ID) ?? DEFAULT_TOGGLE_TERMINAL_KEYBIND const keybinds = parseKeybind(config) return matchKeybind(keybinds, event) diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 9352daff1e01..f7668c19492b 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -266,7 +266,6 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ refresh: (input: string) => tree.listDir(input, { force: true }), state: tree.dirState, children: tree.children, - files: tree.allFiles, expand: tree.expandDir, collapse: tree.collapseDir, toggle(input: string) { diff --git a/packages/app/src/context/file/tree-store.ts b/packages/app/src/context/file/tree-store.ts index 8f1527735d31..a86051d286ec 100644 --- a/packages/app/src/context/file/tree-store.ts +++ b/packages/app/src/context/file/tree-store.ts @@ -157,22 +157,12 @@ export function createFileTreeStore(options: TreeStoreOptions) { return out } - const allFiles = () => { - const out: string[] = [] - for (const id of Object.keys(tree.node)) { - const node = tree.node[id] - if (node?.type === "file") out.push(node.path) - } - return out - } - return { listDir, expandDir, collapseDir, dirState, children, - allFiles, node: (path: string) => tree.node[path], isLoaded: (path: string) => Boolean(tree.dir[path]?.loaded), reset, diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 1c94a35010b5..0124fcf6ed76 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -626,7 +626,6 @@ export const dict = { "session.error.notFound.closeTab": "Close Tab", "session.error.serverConnection": "Can't connect to this server", "session.review.filesChanged": "{{count}} Files Changed", - "session.review.filesChangedTab": "Files changed {{count}}", "session.review.change.one": "Change", "session.review.change.other": "Changes", "session.review.loadingChanges": "Loading changes...", diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 6c2d2bac9afa..5a030b5d0df8 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -79,10 +79,9 @@ 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 { SessionSidePanelV2 } from "@/pages/session/v2/session-side-panel-v2" 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, ReviewPanelV2Sidebar } from "@/pages/session/v2/review-panel-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" @@ -1219,8 +1218,6 @@ export default function Page() {
) - const reviewSidebarV2 = () => - const reviewPanel = () => (
- - } - > -
-
- -
- -
- (typeof window === "undefined" ? 400 : window.innerHeight * 0.4)} - /> -
-
-
-
+ (newSessionDesign() ? reviewPanelV2() : reviewPanel())} + activeDiff={tree.activeDiff} + focusReviewDiff={focusReviewDiff} + reviewSnap={ui.reviewSnap} + size={size} + />
- - - + ) } diff --git a/packages/app/src/pages/session/command-mode.ts b/packages/app/src/pages/session/command-mode.ts deleted file mode 100644 index bf0e681faefd..000000000000 --- a/packages/app/src/pages/session/command-mode.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function sessionV2CommandMode(input: { newLayoutDesigns: boolean; sessionID: string | undefined }) { - return input.newLayoutDesigns && !!input.sessionID -} diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index 636d6d9375f4..7d110ee54463 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -171,7 +171,7 @@ function createScrollSync(input: { tab: () => string; view: ReturnType ) - const body = ( - - - {renderFile(contents())} - -
{language.t("common.loading")}...
-
- {(err) =>
{err()}
}
-
-
- ) - - if (props.embedded) { - return
{body}
- } - return ( - {body} + + + {renderFile(contents())} + +
{language.t("common.loading")}...
+
+ {(err) =>
{err()}
}
+
+
) } diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index d91ad6ad5274..53558a95528e 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -4,7 +4,6 @@ import { makeEventListener } from "@solid-primitives/event-listener" import { same } from "@/utils/same" const emptyTabs: string[] = [] -export const SESSION_OPEN_FILE_TAB = "open-file" type Tabs = { active: Accessor @@ -36,7 +35,7 @@ export const createSessionTabs = (input: TabsInput) => { .tabs() .all() .flatMap((tab) => { - if (tab === "context" || tab === "review" || tab === SESSION_OPEN_FILE_TAB) return [] + if (tab === "context" || tab === "review") return [] const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab if (seen.has(value)) return [] seen.add(value) @@ -49,7 +48,6 @@ export const createSessionTabs = (input: TabsInput) => { const activeTab = createMemo(() => { const active = input.tabs().active() if (active === "context") return active - if (active === SESSION_OPEN_FILE_TAB) return active if (active === "review" && review()) return active if (active && input.pathFromTab(active)) return input.normalizeTab(active) @@ -67,7 +65,6 @@ export const createSessionTabs = (input: TabsInput) => { const closableTab = createMemo(() => { const active = activeTab() if (active === "context") return active - if (active === SESSION_OPEN_FILE_TAB) return active if (!openedTabs().includes(active)) return return active }) @@ -81,20 +78,6 @@ export const createSessionTabs = (input: TabsInput) => { } } -export const toggleSessionTerminal = ( - view: { - terminal: { opened: () => boolean; toggle: () => void } - reviewPanel: { opened: () => boolean; open: () => void } - }, - options?: { openReviewPanel?: boolean }, -) => { - const opening = !view.terminal.opened() - if (opening && options?.openReviewPanel && !view.reviewPanel.opened()) { - view.reviewPanel.open() - } - view.terminal.toggle() -} - export const focusTerminalById = (id: string) => { const wrapper = document.getElementById(`terminal-wrapper-${id}`) const terminal = wrapper?.querySelector('[data-component="terminal"]') diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index 19e280ce8aef..54195e2ebc85 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -1,12 +1,7 @@ import { For, Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js" -import { Portal } from "solid-js/web" import { createStore } from "solid-js/store" import { makeEventListener } from "@solid-primitives/event-listener" import { Tabs } from "@opencode-ai/ui/tabs" -import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2" -import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" -import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { IconButton } from "@opencode-ai/ui/icon-button" import { TooltipKeybind } from "@opencode-ai/ui/tooltip" @@ -14,7 +9,7 @@ import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, close import type { DragEvent } from "@thisbeyond/solid-dnd" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" -import { SortableTerminalTab, SortableTerminalTabV2 } from "@/components/session" +import { SortableTerminalTab } from "@/components/session" import { Terminal } from "@/components/terminal" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" @@ -26,9 +21,8 @@ import { terminalTabLabel } from "@/pages/session/terminal-label" import { createSizing, focusTerminalById } from "@/pages/session/helpers" import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" -import { useTerminalPanelTabsV2 } from "@/pages/session/use-terminal-panel-tabs-v2" -export function TerminalPanel(props: { maxHeight?: () => number; variant?: "default" | "v2" } = {}) { +export function TerminalPanel() { const delays = [120, 240] const layout = useLayout() const terminal = useTerminal() @@ -37,10 +31,8 @@ export function TerminalPanel(props: { maxHeight?: () => number; variant?: "defa const command = useCommand() const settings = useSettings() const { workspaceKey, view } = useSessionLayout() - const tabsV2 = useTerminalPanelTabsV2({ terminal, enabled: () => props.variant === "v2" }) const opened = createMemo(() => view().terminal.opened()) - const isV2 = () => props.variant === "v2" const size = createSizing() const height = createMemo(() => layout.terminal.height()) const close = () => view().terminal.close() @@ -53,11 +45,7 @@ export function TerminalPanel(props: { maxHeight?: () => number; variant?: "defa view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight), }) - const max = () => { - const viewportMax = store.view * 0.6 - if (!props.maxHeight) return viewportMax - return Math.min(viewportMax, props.maxHeight()) - } + const max = () => store.view * 0.6 const pane = () => Math.min(height(), max()) onMount(() => { @@ -206,170 +194,6 @@ export function TerminalPanel(props: { maxHeight?: () => number; variant?: "defa }) } - const terminalById = (id: string) => all().find((pty) => pty.id === id) - - const terminalContent = () => ( -
- - {(id) => { - const ops = terminal.bind() - return ( - - {(pty) => ( -
- markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)} - onCleanup={ops.update} - onConnectError={() => recoverTerminal(terminalRecoveryKey(pty()), id, ops.clone)} - /> -
- )} -
- ) - }} -
-
- ) - - const loadingTabsBar = () => ( - - - {(title) => ( -
- {title} -
- )} -
-
-
- {language.t("common.loading")} - {language.t("common.loading.ellipsis")} -
-
- } - > -
-
- - {(title) => ( -
- {title} -
- )} -
-
-
- {language.t("common.loading")} - {language.t("common.loading.ellipsis")} -
-
-
- - ) - - const v1Tabs = () => ( - <> - terminal.open(id)} - class="!h-auto !flex-none" - > - - - {(pty) => } - -
- - - -
-
-
- {terminalContent()} - - ) - - const v2Tabs = () => ( - <> - terminal.open(id)} - class="session-review-v2-tabs flex flex-col h-full min-h-0" - > -
- - - {(id) => ( - - {(pty) => ( - tabsV2.onStripPointerDown(id, event)} - /> - )} - - )} - -
- - } - onClick={terminal.new} - aria-label={language.t("command.terminal.new")} - /> - -
-
-
-
{terminalContent()}
-
- - {(id) => ( - - {(pty) => ( - -
-
-
- {terminalTabLabel({ - title: pty().title, - titleNumber: pty().titleNumber, - t: language.t as (key: string, vars?: Record) => string, - })} -
-
-
-
- )} -
- )} -
- - ) - return (
number; variant?: "defa when={terminal.ready()} fallback={
- {loadingTabsBar()} +
+ + {(title) => ( +
+ {title} +
+ )} +
+
+
+ {language.t("common.loading")} + {language.t("common.loading.ellipsis")} +
+
{language.t("terminal.loading")}
} > - + + +
+ terminal.open(id)} + class="!h-auto !flex-none" > - - -
{v1Tabs()}
- - - {(id) => ( - - {(t) => ( -
- {terminalTabLabel({ - title: t().title, - titleNumber: t().titleNumber, - t: language.t as (key: string, vars?: Record) => string, - })} + + + {(pty) => } + +
+ + + +
+
+ +
+ + {(id) => { + const ops = terminal.bind() + return ( + pty.id === id)}> + {(pty) => ( +
+ markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)} + onCleanup={ops.update} + onConnectError={() => recoverTerminal(terminalRecoveryKey(pty()), id, ops.clone)} + />
)}
+ ) + }} +
+
+
+ + + {(id) => ( + pty.id === id)}> + {(t) => ( +
+ {terminalTabLabel({ + title: t().title, + titleNumber: t().titleNumber, + t: language.t as (key: string, vars?: Record) => string, + })} +
)}
-
- - } - > -
{v2Tabs()}
-
+ )} +
+
+
diff --git a/packages/app/src/pages/session/use-session-commands.test.ts b/packages/app/src/pages/session/use-session-commands.test.ts deleted file mode 100644 index 5b13a065f5d2..000000000000 --- a/packages/app/src/pages/session/use-session-commands.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { sessionV2CommandMode } from "./command-mode" - -describe("sessionV2CommandMode", () => { - test("uses v2 command behavior for any platform when the v2 session layout is active", () => { - expect(sessionV2CommandMode({ newLayoutDesigns: true, sessionID: "ses_123" })).toBe(true) - }) - - test("does not use v2 command behavior outside a session or v2 layout", () => { - expect(sessionV2CommandMode({ newLayoutDesigns: true, sessionID: undefined })).toBe(false) - expect(sessionV2CommandMode({ newLayoutDesigns: false, sessionID: "ses_123" })).toBe(false) - }) -}) diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index a60d68c99ce9..fd2e29d31c69 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -14,10 +14,9 @@ import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" import { showToast } from "@/utils/toast" import { findLast } from "@opencode-ai/core/util/array" -import { createSessionTabs, toggleSessionTerminal } from "@/pages/session/helpers" +import { createSessionTabs } from "@/pages/session/helpers" import { extractPromptFromParts } from "@/utils/prompt" import { UserMessage } from "@opencode-ai/sdk/v2" -import { sessionV2CommandMode } from "@/pages/session/command-mode" import { useSessionLayout } from "@/pages/session/session-layout" import { useTabs } from "@/context/tabs" import { requireServerKey } from "@/utils/session-route" @@ -90,8 +89,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => { }) const activeFileTab = tabState.activeFileTab const closableTab = tabState.closableTab - const sessionV2Commands = () => - sessionV2CommandMode({ newLayoutDesigns: settings.general.newLayoutDesigns(), sessionID: params.id }) const shown = settings.visibility.fileTree const messages = () => { @@ -468,7 +465,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => { onSelect: openFile, }), tab && - !settings.general.newLayoutDesigns() && fileCommand({ id: "tab.close", title: language.t("command.tab.close"), @@ -493,9 +489,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => { viewCommand({ id: "terminal.toggle", title: language.t("command.terminal.toggle"), - keybind: sessionV2Commands() ? "mod+j" : "ctrl+`", + keybind: "ctrl+`", slash: "terminal", - onSelect: () => toggleSessionTerminal(view(), { openReviewPanel: sessionV2Commands() }), + onSelect: () => view().terminal.toggle(), }), viewCommand({ id: "review.toggle", diff --git a/packages/app/src/pages/session/use-terminal-panel-tabs-v2.ts b/packages/app/src/pages/session/use-terminal-panel-tabs-v2.ts deleted file mode 100644 index 08fe8e9de236..000000000000 --- a/packages/app/src/pages/session/use-terminal-panel-tabs-v2.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" -import { createStore } from "solid-js/store" -import { makeEventListener } from "@solid-primitives/event-listener" -import { - ACTIVATION_DISTANCE, - autoscrollSpeed, - captureTabDragLayout, - clampFloaterLeft, - draftOrderChanged, - insertIndexFromVirtualLayout, - movePlaceholder, - pointerDistance, - syncLayoutScroll, - type TabDragLayout, -} from "@/components/session/session-tab-drag-v2" -import type { useTerminal } from "@/context/terminal" - -type Terminal = ReturnType - -type Input = { - terminal: Terminal - enabled?: () => boolean -} - -export function useTerminalPanelTabsV2(input: Input) { - const enabled = () => input.enabled?.() ?? true - const [pressedTab, setPressedTab] = createSignal() - const [drag, setDrag] = createStore({ - active: false, - draggedTab: undefined as string | undefined, - pointerX: 0, - floaterTop: 0, - dragWidth: 0, - grabOffsetX: 0, - draftOrder: [] as string[], - initialOrder: [] as string[], - placeholderIndex: 0, - }) - const [gesture, setGesture] = createStore({ - pending: undefined as - | { - tab: string - startX: number - startY: number - pointerID: number - width: number - grabOffsetX: number - grabOffsetY: number - } - | undefined, - }) - - let tabListRef: HTMLDivElement | undefined - let dragPointerID: number | undefined - let dragLayout: TabDragLayout | undefined - let autoscrollFrame: number | undefined - - const stripTabs = createMemo(() => input.terminal.all().map((pty) => pty.id)) - - const visibleStripTabs = createMemo(() => { - if (!drag.active || drag.draftOrder.length === 0) return stripTabs() - return drag.draftOrder - }) - - const reorderStripTabs = (order: string[]) => { - for (const [index, id] of order.entries()) { - const current = input.terminal.all().findIndex((pty) => pty.id === id) - if (current !== index) input.terminal.move(id, index) - } - } - - const updateInsertIndex = () => { - if (!drag.active || !dragLayout || !drag.draggedTab) return - const nextIndex = insertIndexFromVirtualLayout( - drag.pointerX, - drag.draftOrder, - drag.draggedTab, - drag.placeholderIndex, - dragLayout, - ) - if (nextIndex === drag.placeholderIndex) return - const next = movePlaceholder(drag.draftOrder, drag.draggedTab, nextIndex) - setDrag({ - draftOrder: next, - placeholderIndex: nextIndex, - }) - } - - const syncScroll = () => { - if (!tabListRef || !dragLayout) return - syncLayoutScroll(tabListRef, dragLayout) - updateInsertIndex() - } - - const draggableStripLeft = () => { - if (dragLayout) return dragLayout.listLeft - if (!tabListRef) return 0 - const slots = Array.from(tabListRef.querySelectorAll("[data-session-tab-slot]")) - if (slots.length === 0) return tabListRef.getBoundingClientRect().left - return Math.min(...slots.map((slot) => slot.getBoundingClientRect().left)) - } - - const stopAutoscroll = () => { - if (autoscrollFrame === undefined) return - cancelAnimationFrame(autoscrollFrame) - autoscrollFrame = undefined - } - - const tickAutoscroll = () => { - if (!drag.active || !tabListRef) return - const rect = tabListRef.getBoundingClientRect() - const speed = autoscrollSpeed(drag.pointerX, draggableStripLeft(), rect.right) - if (speed !== 0) { - tabListRef.scrollLeft += speed - syncScroll() - } - autoscrollFrame = requestAnimationFrame(tickAutoscroll) - } - - const startAutoscroll = () => { - stopAutoscroll() - autoscrollFrame = requestAnimationFrame(tickAutoscroll) - } - - const startDrag = (tab: string) => { - const order = stripTabs() - const index = order.indexOf(tab) - const pending = gesture.pending - if (index === -1 || !pending || !tabListRef) return - - dragLayout = captureTabDragLayout(tabListRef, order) - dragPointerID = pending.pointerID - setGesture("pending", undefined) - setDrag({ - active: true, - draggedTab: tab, - pointerX: pending.startX, - floaterTop: pending.startY - pending.grabOffsetY, - dragWidth: pending.width, - grabOffsetX: pending.grabOffsetX, - draftOrder: order, - initialOrder: order, - placeholderIndex: index, - }) - setPressedTab(undefined) - startAutoscroll() - } - - const finishDrag = (commit: boolean) => { - if (commit && drag.active && draftOrderChanged(drag.initialOrder, drag.draftOrder)) { - reorderStripTabs(drag.draftOrder) - } - setDrag({ - active: false, - draggedTab: undefined, - pointerX: 0, - floaterTop: 0, - dragWidth: 0, - grabOffsetX: 0, - draftOrder: [], - initialOrder: [], - placeholderIndex: 0, - }) - setGesture("pending", undefined) - setPressedTab(undefined) - dragPointerID = undefined - dragLayout = undefined - stopAutoscroll() - } - - const onStripPointerDown = (tab: string, event: PointerEvent) => { - if (event.button !== 0 || drag.active) return - const tabEl = (event.currentTarget as HTMLElement).querySelector("[data-session-tab]") - if (!tabEl) return - input.terminal.open(tab) - setPressedTab(tab) - const rect = tabEl.getBoundingClientRect() - setGesture("pending", { - tab, - startX: event.clientX, - startY: event.clientY, - pointerID: event.pointerId, - width: rect.width, - grabOffsetX: event.clientX - rect.left, - grabOffsetY: event.clientY - rect.top, - }) - } - - const onPointerMove = (event: PointerEvent) => { - const pending = gesture.pending - if (pending && !drag.active) { - if (event.pointerId !== pending.pointerID) return - if (pointerDistance(pending.startX, pending.startY, event.clientX, event.clientY) < ACTIVATION_DISTANCE) return - startDrag(pending.tab) - } - if (!drag.active) return - if (dragPointerID !== undefined && event.pointerId !== dragPointerID) return - setDrag("pointerX", event.clientX) - syncScroll() - } - - const onPointerUp = (event: PointerEvent) => { - if (drag.active) { - if (dragPointerID !== undefined && event.pointerId !== dragPointerID) return - setDrag("pointerX", event.clientX) - syncScroll() - finishDrag(true) - return - } - if (!gesture.pending) return - if (event.pointerId !== gesture.pending.pointerID) return - setGesture("pending", undefined) - setPressedTab(undefined) - } - - const onPointerCancel = (event: PointerEvent) => { - if (drag.active) { - if (dragPointerID !== undefined && event.pointerId !== dragPointerID) return - finishDrag(false) - return - } - if (!gesture.pending) return - if (event.pointerId !== gesture.pending.pointerID) return - setGesture("pending", undefined) - setPressedTab(undefined) - } - - onMount(() => { - if (!enabled()) return - - const cleanups = [ - makeEventListener(window, "pointermove", onPointerMove), - makeEventListener(window, "pointerup", onPointerUp), - makeEventListener(window, "pointercancel", onPointerCancel), - ] - return () => { - for (const cleanup of cleanups) cleanup() - } - }) - - createEffect(() => { - if (!enabled() || !drag.active || !tabListRef) return - return makeEventListener(tabListRef, "scroll", syncScroll) - }) - - onCleanup(stopAutoscroll) - - const floaterStyle = () => { - const strip = tabListRef?.getBoundingClientRect() - const left = strip - ? clampFloaterLeft(drag.pointerX - drag.grabOffsetX, drag.dragWidth, draggableStripLeft(), strip.right) - : drag.pointerX - drag.grabOffsetX - return { - position: "fixed" as const, - top: `${drag.floaterTop}px`, - left: `${left}px`, - width: `${drag.dragWidth}px`, - "z-index": "10000", - "pointer-events": "none" as const, - } - } - - return { - drag, - pressedTab, - visibleStripTabs, - setTabListRef: (el: HTMLDivElement) => { - tabListRef = el - }, - onStripPointerDown, - floaterStyle, - } -} diff --git a/packages/app/src/pages/session/v2/file-tab-content-v2.tsx b/packages/app/src/pages/session/v2/file-tab-content-v2.tsx deleted file mode 100644 index f6d176808d42..000000000000 --- a/packages/app/src/pages/session/v2/file-tab-content-v2.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { createMemo, Show } from "solid-js" -import { SessionReviewFilePreviewV2 } from "@opencode-ai/session-ui/v2/session-review-file-preview-v2" -import { useFile } from "@/context/file" -import { useSDK } from "@/context/sdk" -import { FileTabContent } from "@/pages/session/file-tabs" -import { filterRenderableDiff } from "@/pages/session/v2/review-diff-kinds" -import { makeReadFile, type ReviewPanelV2Props } from "@/pages/session/v2/review-panel-v2" - -export function FileTabContentV2(props: { tab: string; review: () => ReviewPanelV2Props }) { - const file = useFile() - const sdk = useSDK() - const review = props.review - const readFile = makeReadFile(sdk) - - const path = createMemo(() => file.pathFromTab(props.tab)) - const diffItem = createMemo(() => { - const value = path() - if (!value) return - return review() - .diffs() - .filter(filterRenderableDiff) - .find((diff) => diff.file === value) - }) - - return ( - // Key on the file path, not the diff object identity, so refreshed diff data - // updates the mounted preview instead of remounting the whole viewer. - }> - {(file) => ( - }> - {(diff) => ( -
- -
- )} -
- )} -
- ) -} diff --git a/packages/app/src/pages/session/v2/files-panel-v2.tsx b/packages/app/src/pages/session/v2/files-panel-v2.tsx deleted file mode 100644 index bc70c6840c92..000000000000 --- a/packages/app/src/pages/session/v2/files-panel-v2.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { createEffect, createMemo, onCleanup, Show, type JSX } from "solid-js" -import { createStore } from "solid-js/store" -import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" -import { SessionReviewV2Sidebar } from "@opencode-ai/session-ui/v2/session-review-v2" -import FileTreeV2 from "@/components/file-tree-v2" -import { useFile } from "@/context/file" -import { useLanguage } from "@/context/language" -import { useSDK } from "@/context/sdk" -import { - REVIEW_PANEL_V2_SIDEBAR_WIDTH_MAX, - REVIEW_PANEL_V2_SIDEBAR_WIDTH_MIN, - type ReviewPanelV2State, -} from "@/pages/session/v2/review-panel-v2-state" -import { - applyFileListKeyDown, - filterRenderableDiff, - normalizePath, - reviewDiffKinds, -} from "@/pages/session/v2/review-diff-kinds" -import { SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2" - -type ReviewDiff = SnapshotFileDiff | VcsFileDiff - -const SEARCH_DEBOUNCE_MS = 120 -const SEARCH_LIMIT = 200 - -export type FilesPanelV2SidebarProps = { - title: string | JSX.Element - state: ReviewPanelV2State - open?: boolean - focusFilterToken?: number - diffs: () => ReviewDiff[] - activeFile?: string - onOpenFile: (path: string) => void - onOpenFilePersist?: (path: string) => void -} - -export function FilesPanelV2Sidebar(props: FilesPanelV2SidebarProps) { - const sdk = useSDK() - const file = useFile() - const language = useLanguage() - const open = createMemo(() => props.open ?? props.state.sidebarOpened()) - const diffs = createMemo(() => props.diffs().filter(filterRenderableDiff)) - const diffFiles = createMemo(() => diffs().map((diff) => diff.file)) - const kinds = createMemo(() => reviewDiffKinds(diffs())) - const query = createMemo(() => props.state.filesFilter().trim()) - const flatMode = createMemo(() => query().length > 0) - const [store, setStore] = createStore({ - files: [] as string[], - loading: false, - error: undefined as string | undefined, - highlightedPath: undefined as string | undefined, - }) - - createEffect(() => { - const value = query() - const directory = sdk().directory - if (!directory || !value) { - setStore({ - files: [], - loading: false, - error: undefined, - highlightedPath: undefined, - }) - return - } - - let cancelled = false - const timeout = setTimeout(() => { - setStore("loading", true) - - void sdk() - .client.find.files({ - query: value, - dirs: "false", - fallback: "glob", - limit: SEARCH_LIMIT, - }) - .then((response: { data?: string[] }) => { - if (cancelled) return - const normalized = (response.data ?? []).map(normalizePath) - const unique = [...new Set(normalized)] - setStore({ - files: unique, - loading: false, - error: undefined, - }) - }) - .catch((error: unknown) => { - if (cancelled) return - console.error(`[files-panel-v2] file search failed query="${value}"`, error) - setStore({ - files: [], - loading: false, - error: error instanceof Error ? error.message : "Search failed. Please try again.", - highlightedPath: undefined, - }) - }) - }, SEARCH_DEBOUNCE_MS) - - onCleanup(() => { - cancelled = true - clearTimeout(timeout) - }) - }) - - createEffect(() => { - const mode = flatMode() - const files = store.files - if (!mode || files.length === 0) { - if (store.highlightedPath) setStore("highlightedPath", undefined) - return - } - if (store.highlightedPath && files.includes(store.highlightedPath)) return - setStore("highlightedPath", files[0]!) - }) - - const onFilterKeyDown = (event: KeyboardEvent & { currentTarget: HTMLInputElement }) => { - if (!flatMode()) return - applyFileListKeyDown(event, store.files, store.highlightedPath, { - onHighlight: (path) => setStore("highlightedPath", path), - onSelect: props.onOpenFile, - }) - } - - const activeFile = createMemo(() => { - const active = props.activeFile - if (!active) return - return normalizePath(file.pathFromTab(active) ?? active) - }) - - return ( - - props.onOpenFile(node.path)} - onFileDoubleClick={(node) => props.onOpenFilePersist?.(node.path)} - /> - } - > - - {language.t("common.loading")} - {language.t("common.loading.ellipsis")} -
- } - > - {store.error}
} - > - 0} - fallback={
{language.t("palette.empty")}
} - > - { - setStore("highlightedPath", path) - props.onOpenFile(path) - }} - onFileDoubleClick={props.onOpenFilePersist} - /> -
-
- - - - ) -} diff --git a/packages/app/src/pages/session/v2/review-panel-v2-state.ts b/packages/app/src/pages/session/v2/review-panel-v2-state.ts index 972f5799ce96..d5894bfb6927 100644 --- a/packages/app/src/pages/session/v2/review-panel-v2-state.ts +++ b/packages/app/src/pages/session/v2/review-panel-v2-state.ts @@ -1,40 +1,39 @@ -import type { SessionReviewExpandMode } from "@opencode-ai/session-ui/v2/session-review-v2" +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 const REVIEW_PANEL_V2_SIDEBAR_WIDTH_DEFAULT = 240 -export const REVIEW_PANEL_V2_SIDEBAR_WIDTH_MIN = 200 -export const REVIEW_PANEL_V2_SIDEBAR_WIDTH_MAX = 480 - export function createReviewPanelV2State() { const [store, setStore] = persisted( Persist.global("review-panel-v2"), createStore({ sidebarOpened: true, - sidebarWidth: REVIEW_PANEL_V2_SIDEBAR_WIDTH_DEFAULT, - filter: "", - filesFilter: "", + 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: () => store.filter, - filesFilter: () => store.filesFilter, + filter, + setFilter, expandMode: () => store.expandMode, - setFilter: (value: string) => setStore("filter", value), - setFilesFilter: (value: string) => setStore("filesFilter", value), setExpandMode: (mode: SessionReviewExpandMode) => setStore("expandMode", mode), resizeSidebar: (width: number) => setStore( "sidebarWidth", - Math.min(REVIEW_PANEL_V2_SIDEBAR_WIDTH_MAX, Math.max(REVIEW_PANEL_V2_SIDEBAR_WIDTH_MIN, width)), + Math.min(SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX, Math.max(SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, width)), ), toggleSidebar: () => setStore("sidebarOpened", (opened) => !opened), - openSidebar: () => setStore("sidebarOpened", true), - closeSidebar: () => setStore("sidebarOpened", false), } } 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 8404c9ac5a4b..036df3a2726e 100644 --- a/packages/app/src/pages/session/v2/review-panel-v2.tsx +++ b/packages/app/src/pages/session/v2/review-panel-v2.tsx @@ -1,6 +1,12 @@ 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/session-ui/v2/session-review-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 { @@ -21,24 +27,9 @@ import { filterReviewFiles, reviewDiffKinds, } from "@/pages/session/v2/review-diff-kinds" -import { - REVIEW_PANEL_V2_SIDEBAR_WIDTH_MAX, - REVIEW_PANEL_V2_SIDEBAR_WIDTH_MIN, - type ReviewPanelV2State, -} from "@/pages/session/v2/review-panel-v2-state" +import type { ReviewPanelV2State } 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 = { @@ -60,10 +51,16 @@ export type ReviewPanelV2Props = { onFocusedCommentChange?: (focus: SessionReviewFocus | null) => void } -function useReviewPanelV2Data(props: ReviewPanelV2Props) { +export function ReviewPanelV2(props: ReviewPanelV2Props) { + const sdk = useSDK() + const diffs = createMemo(() => props.diffs().filter(filterRenderableDiff)) - const diffFiles = createMemo(() => diffs().map((diff) => diff.file)) - const filteredFiles = createMemo(() => filterReviewFiles(diffFiles(), props.state.filter())) + 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(() => { @@ -75,14 +72,16 @@ function useReviewPanelV2Data(props: ReviewPanelV2Props) { }) const activeItem = createMemo(() => diffs().find((diff) => diff.file === activeDiff())) - return { diffs, filteredFiles, searching, kinds, activeDiff, activeItem } -} + 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 + }) -function useReviewPanelV2ActiveFile( - props: ReviewPanelV2Props, - filteredFiles: () => string[], - searching: () => boolean, -) { + // Keep the selected file valid as diffs stream in or the filter changes. createEffect(() => { if (searching()) return const files = filteredFiles() @@ -91,18 +90,81 @@ function useReviewPanelV2ActiveFile( if (active && files.includes(active)) return props.onSelectFile(files[0]!) }) + + return ( + } + empty={props.empty} + sidebarOpen={props.state.sidebarOpened()} + sidebarToggle={ + + } + sidebar={ + 0}> + + + } + 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. + + {(file) => ( + + {(diff) => ( + + )} + + )} + + } + /> + ) } -export function ReviewPanelV2Sidebar(props: ReviewPanelV2Props) { +function ReviewPanelV2Sidebar( + props: ReviewPanelV2Props & { + diffs: () => (ReviewDiff & { file: string })[] + filteredFiles: () => string[] + searching: () => boolean + kinds: () => ReturnType + activeDiff: () => string | undefined + }, +) { const language = useLanguage() - const model = useReviewPanelV2Data(props) - 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) { + const files = props.filteredFiles() + if (!props.searching() || files.length === 0) { if (highlightedPath()) setHighlightedPath(undefined) return } @@ -112,8 +174,8 @@ export function ReviewPanelV2Sidebar(props: ReviewPanelV2Props) { }) const onFilterKeyDown = (event: KeyboardEvent & { currentTarget: HTMLInputElement }) => { - if (!flatMode()) return - applyFileListKeyDown(event, model.filteredFiles(), highlightedPath(), { + if (!props.searching()) return + applyFileListKeyDown(event, props.filteredFiles(), highlightedPath(), { onHighlight: setHighlightedPath, onSelect: props.onSelectFile, }) @@ -123,14 +185,14 @@ export function ReviewPanelV2Sidebar(props: ReviewPanelV2Props) { } + stats={} 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} - maxWidth={REVIEW_PANEL_V2_SIDEBAR_WIDTH_MAX} + minWidth={SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN} + maxWidth={SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX} > props.onSelectFile(node.path)} /> } > 0} + when={props.filteredFiles().length > 0} fallback={
{language.t("palette.empty")}
} > { setHighlightedPath(path) @@ -175,55 +237,3 @@ export function ReviewPanelV2Sidebar(props: ReviewPanelV2Props) {
) } - -export function ReviewPanelV2(props: ReviewPanelV2Props) { - const sdk = useSDK() - const model = useReviewPanelV2Data(props) - const readFile = makeReadFile(sdk) - - return ( - } - empty={props.empty} - sidebarOpen={props.state.sidebarOpened()} - filter={props.state.filter()} - onFilterChange={props.state.setFilter} - activeFile={model.activeDiff()} - files={model.filteredFiles()} - onSelectFile={props.onSelectFile} - diffStyle={props.diffStyle} - onDiffStyleChange={props.onDiffStyleChange} - expandMode={props.state.expandMode()} - onExpandModeChange={props.state.setExpandMode} - hasDiffs={model.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. - - {(file) => ( - - {(diff) => ( - - )} - - )} - - } - /> - ) -} 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 d5afb2dbf7f9..3874fac2fccc 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,28 +2,14 @@ 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" -type FileKind = "add" | "del" | "mix" - -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 + kinds?: ReadonlyMap showModifiedLabel?: boolean onFileClick: (path: string) => void onFileDoubleClick?: (path: string) => void 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 deleted file mode 100644 index 6280e628061b..000000000000 --- a/packages/app/src/pages/session/v2/session-side-panel-v2.tsx +++ /dev/null @@ -1,419 +0,0 @@ -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" -import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" -import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import { Mark } from "@opencode-ai/ui/logo" -import { SessionReviewEmptyOpenFileV2 } from "@opencode-ai/session-ui/v2/session-review-empty-open-file-v2" -import { SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2" -import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" -import { SessionContextUsage } from "@/components/session-context-usage" -import { SessionContextTab, FileVisual } from "@/components/session" -import { OpenInAppV2 } from "@/components/session/open-in-app-v2" -import { getFilename } from "@opencode-ai/core/util/path" -import { decode64 } from "@/utils/base64" -import { useFile, type SelectedLineRange } from "@/context/file" -import { useLanguage } from "@/context/language" -import { useLayout } from "@/context/layout" -import { useSettings } from "@/context/settings" -import { FilesPanelV2Sidebar } from "@/pages/session/v2/files-panel-v2" -import { createFileTabListSync } from "@/pages/session/file-tab-scroll" -import { FileTabContentV2 } from "@/pages/session/v2/file-tab-content-v2" -import type { ReviewPanelV2Props } from "@/pages/session/v2/review-panel-v2" -import { SESSION_OPEN_FILE_TAB, type Sizing } from "@/pages/session/helpers" -import { setSessionHandoff } from "@/pages/session/handoff" -import { useSessionLayout } from "@/pages/session/session-layout" -import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state" -import { useSessionSidePanelTabsV2 } from "@/pages/session/v2/use-session-side-panel-tabs-v2" - -export function SessionSidePanelV2(props: { - canReview: () => boolean - diffs: () => (SnapshotFileDiff | VcsFileDiff)[] - diffsReady: () => boolean - hasReview: () => boolean - reviewCount: () => number - reviewPanel: () => JSX.Element - reviewSidebar: () => JSX.Element - reviewV2State: ReviewPanelV2State - fileTabReview: () => ReviewPanelV2Props - reviewSnap: boolean - size: Sizing -}) { - const settings = useSettings() - const layout = useLayout() - const file = useFile() - const language = useLanguage() - const { sessionKey, tabs, view, params } = useSessionLayout() - - const isDesktop = createMediaQuery("(min-width: 768px)") - const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) - const reviewTab = createMemo(() => isDesktop()) - const panelWidth = createMemo(() => { - if (!reviewOpen()) return "0px" - return "auto" - }) - - const normalizeTab = (tab: string) => { - if (!tab.startsWith("file://")) return tab - return file.tab(tab) - } - - const openReviewPanel = () => { - if (!view().reviewPanel.opened()) view().reviewPanel.open() - } - - const tabsV2 = useSessionSidePanelTabsV2({ - tabs, - tabForPath: file.tab, - normalizeTab, - pathFromTab: file.pathFromTab, - loadFile: file.load, - openReviewPanel, - review: reviewTab, - hasReview: props.canReview, - }) - const contextOpen = tabsV2.tabState.contextOpen - const activeTab = tabsV2.tabState.activeTab - const activeFileTab = tabsV2.tabState.activeFileTab - const [focusFilesFilterToken, setFocusFilesFilterToken] = createSignal(0) - 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, - ) - - createEffect(() => { - const currentActiveTab = activeTab() - const currentTemporaryTab = tabsV2.temporaryTab() - const currentHadOpenFileTab = tabs().all().includes(SESSION_OPEN_FILE_TAB) - const isOpenFileTab = currentActiveTab === SESSION_OPEN_FILE_TAB - if (isOpenFileTab && !tracking.wasOpenFileTab) { - const shouldClearFilter = - tracking.initialized && - !tracking.prevHadOpenFileTab && - tracking.prevActiveTab !== tracking.prevTemporaryTab - if (shouldClearFilter) props.reviewV2State.setFilesFilter("") - setFocusFilesFilterToken((token) => token + 1) - } - setTracking({ - prevActiveTab: currentActiveTab, - prevTemporaryTab: currentTemporaryTab, - prevHadOpenFileTab: currentHadOpenFileTab, - wasOpenFileTab: isOpenFileTab, - initialized: true, - }) - }) - - 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 - }, {}), - }) - }) - - const projectDirectory = createMemo(() => decode64(params.dir) ?? "") - const project = createMemo(() => { - const directory = projectDirectory() - if (!directory) return - return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) - }) - const projectName = createMemo(() => { - const current = project() - if (current) return current.name || getFilename(current.worktree) - return getFilename(projectDirectory()) - }) - const onReviewTab = () => activeTab() === "review" && props.canReview() - - const reviewTabLabel = () => { - const filesChanged = language.t("session.review.filesChangedTab", { count: "" }).trim() - const count = props.reviewCount() - if (count <= 0) return filesChanged - return `${filesChanged} ${count}` - } - - return ( - -
- - - - ) -} diff --git a/packages/app/src/pages/session/v2/use-session-side-panel-tabs-v2.ts b/packages/app/src/pages/session/v2/use-session-side-panel-tabs-v2.ts deleted file mode 100644 index 35f3d0d23e38..000000000000 --- a/packages/app/src/pages/session/v2/use-session-side-panel-tabs-v2.ts +++ /dev/null @@ -1,432 +0,0 @@ -import { batch, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" -import { createStore } from "solid-js/store" -import { makeEventListener } from "@solid-primitives/event-listener" -import { createSessionTabs, SESSION_OPEN_FILE_TAB } from "@/pages/session/helpers" -import { - ACTIVATION_DISTANCE, - autoscrollSpeed, - captureTabDragLayout, - clampFloaterLeft, - draftOrderChanged, - insertIndexFromVirtualLayout, - movePlaceholder, - pointerDistance, - syncLayoutScroll, - type TabDragLayout, -} from "@/components/session/session-tab-drag-v2" - -type TabsStore = { - all: () => string[] - active: () => string | undefined - setAll: (all: string[]) => void - setActive: (tab: string | undefined) => void - open: (tab: string) => void | Promise - close: (tab: string) => void -} - -type Input = { - tabs: () => TabsStore - tabForPath: (path: string) => string - normalizeTab: (tab: string) => string - pathFromTab: (tab: string) => string | undefined - loadFile: (path: string) => void - openReviewPanel: () => void - review: () => boolean - hasReview: () => boolean -} - -function replaceTab(all: string[], from: string, to: string) { - const fromIndex = all.indexOf(from) - if (fromIndex === -1) return all - const next = all.filter((value) => value !== from) - if (next.includes(to)) return next - next.splice(fromIndex, 0, to) - return next -} - -export function useSessionSidePanelTabsV2(input: Input) { - const tabState = createSessionTabs({ - tabs: input.tabs, - pathFromTab: input.pathFromTab, - normalizeTab: input.normalizeTab, - review: input.review, - hasReview: input.hasReview, - }) - - const [temporaryTab, setTemporaryTab] = createSignal() - const [pressedTab, setPressedTab] = createSignal() - const [drag, setDrag] = createStore({ - active: false, - draggedTab: undefined as string | undefined, - pointerX: 0, - floaterTop: 0, - dragWidth: 0, - grabOffsetX: 0, - draftOrder: [] as string[], - initialOrder: [] as string[], - placeholderIndex: 0, - }) - const [gesture, setGesture] = createStore({ - pending: undefined as - | { - tab: string - startX: number - startY: number - pointerID: number - width: number - grabOffsetX: number - grabOffsetY: number - } - | undefined, - }) - - let tabListRef: HTMLDivElement | undefined - let dragPointerID: number | undefined - let dragLayout: TabDragLayout | undefined - let autoscrollFrame: number | undefined - let activeToken = 0 - - const setActiveStable = (tab: string | undefined) => { - activeToken += 1 - const token = activeToken - input.tabs().setActive(tab) - queueMicrotask(() => { - if (token !== activeToken) return - if (input.tabs().active() === tab) return - input.tabs().setActive(tab) - }) - } - - const stripTabs = createMemo(() => { - const seen = new Set() - return input - .tabs() - .all() - .flatMap((tab) => { - if (tab === "context" || tab === "review") return [] - const value = tab.startsWith("file://") ? input.normalizeTab(tab) : tab - if (value !== SESSION_OPEN_FILE_TAB && !input.pathFromTab(value)) return [] - if (seen.has(value)) return [] - seen.add(value) - return [value] - }) - }) - - const visibleStripTabs = createMemo(() => { - if (!drag.active || drag.draftOrder.length === 0) return stripTabs() - return drag.draftOrder - }) - - const closeTab = (tab: string) => { - if (temporaryTab() === tab) setTemporaryTab(undefined) - input.tabs().close(tab) - } - - const persistTab = (tab: string) => { - if (temporaryTab() !== tab) return - setTemporaryTab(undefined) - } - - const setTabs = (all: string[], active: string) => { - batch(() => { - input.tabs().setAll(all) - setActiveStable(active) - }) - } - - const openPlaceholderTab = () => { - const all = input.tabs().all() - const temporary = temporaryTab() - if (all.includes(SESSION_OPEN_FILE_TAB)) { - setActiveStable(SESSION_OPEN_FILE_TAB) - setTemporaryTab(SESSION_OPEN_FILE_TAB) - return - } - if (temporary && all.includes(temporary)) { - const next = replaceTab(all, temporary, SESSION_OPEN_FILE_TAB) - setTabs(next, SESSION_OPEN_FILE_TAB) - setTemporaryTab(SESSION_OPEN_FILE_TAB) - return - } - batch(() => { - input.tabs().open(SESSION_OPEN_FILE_TAB) - setActiveStable(SESSION_OPEN_FILE_TAB) - }) - setTemporaryTab(SESSION_OPEN_FILE_TAB) - } - - const openFileTab = (path: string, options?: { persist?: boolean }) => { - const persist = options?.persist ?? false - const next = input.tabForPath(path) - const all = input.tabs().all() - const active = input.tabs().active() - const existing = all.includes(next) - const temporary = temporaryTab() - const replaceFrom = - active === SESSION_OPEN_FILE_TAB && all.includes(SESSION_OPEN_FILE_TAB) - ? SESSION_OPEN_FILE_TAB - : !persist && temporary && temporary !== next && all.includes(temporary) - ? temporary - : undefined - - const finalize = () => { - input.openReviewPanel() - input.loadFile(path) - setActiveStable(next) - } - - if (replaceFrom) { - const reordered = replaceTab(all, replaceFrom, next) - setTabs(reordered, next) - if (!persist && !existing) setTemporaryTab(next) - else setTemporaryTab(undefined) - finalize() - return - } - - if (existing) { - if (persist && temporary === next) setTemporaryTab(undefined) - if (!persist && temporary === SESSION_OPEN_FILE_TAB) setTemporaryTab(undefined) - finalize() - return - } - - batch(() => { - input.tabs().open(next) - setActiveStable(next) - }) - if (!persist) setTemporaryTab(next) - if (persist && temporary === SESSION_OPEN_FILE_TAB) setTemporaryTab(undefined) - finalize() - } - - const reorderStripTabs = (nextOrder: string[]) => { - const specials = input.tabs().all().filter((tab) => tab === "context" || tab === "review") - input.tabs().setAll([...specials, ...nextOrder]) - } - - const updateInsertIndex = () => { - if (!drag.active || !dragLayout || !drag.draggedTab) return - const nextIndex = insertIndexFromVirtualLayout( - drag.pointerX, - drag.draftOrder, - drag.draggedTab, - drag.placeholderIndex, - dragLayout, - ) - if (nextIndex === drag.placeholderIndex) return - const next = movePlaceholder(drag.draftOrder, drag.draggedTab, nextIndex) - setDrag({ - draftOrder: next, - placeholderIndex: nextIndex, - }) - } - - const syncScroll = () => { - if (!tabListRef || !dragLayout) return - syncLayoutScroll(tabListRef, dragLayout) - updateInsertIndex() - } - - const draggableStripLeft = () => { - if (dragLayout) return dragLayout.listLeft - if (!tabListRef) return 0 - const slots = Array.from(tabListRef.querySelectorAll("[data-session-tab-slot]")) - if (slots.length === 0) return tabListRef.getBoundingClientRect().left - return Math.min(...slots.map((slot) => slot.getBoundingClientRect().left)) - } - - const stopAutoscroll = () => { - if (autoscrollFrame === undefined) return - cancelAnimationFrame(autoscrollFrame) - autoscrollFrame = undefined - } - - const tickAutoscroll = () => { - if (!drag.active || !tabListRef) return - const rect = tabListRef.getBoundingClientRect() - const speed = autoscrollSpeed(drag.pointerX, draggableStripLeft(), rect.right) - if (speed !== 0) { - tabListRef.scrollLeft += speed - syncScroll() - } - autoscrollFrame = requestAnimationFrame(tickAutoscroll) - } - - const startAutoscroll = () => { - stopAutoscroll() - autoscrollFrame = requestAnimationFrame(tickAutoscroll) - } - - const startDrag = (tab: string) => { - const order = stripTabs() - const index = order.indexOf(tab) - const pending = gesture.pending - if (index === -1 || !pending || !tabListRef) return - - dragLayout = captureTabDragLayout(tabListRef, order) - dragPointerID = pending.pointerID - setGesture("pending", undefined) - setDrag({ - active: true, - draggedTab: tab, - pointerX: pending.startX, - floaterTop: pending.startY - pending.grabOffsetY, - dragWidth: pending.width, - grabOffsetX: pending.grabOffsetX, - draftOrder: order, - initialOrder: order, - placeholderIndex: index, - }) - setPressedTab(undefined) - startAutoscroll() - } - - const finishDrag = (commit: boolean) => { - if (commit && drag.active && draftOrderChanged(drag.initialOrder, drag.draftOrder)) { - reorderStripTabs(drag.draftOrder) - } - setDrag({ - active: false, - draggedTab: undefined, - pointerX: 0, - floaterTop: 0, - dragWidth: 0, - grabOffsetX: 0, - draftOrder: [], - initialOrder: [], - placeholderIndex: 0, - }) - setGesture("pending", undefined) - setPressedTab(undefined) - dragPointerID = undefined - dragLayout = undefined - stopAutoscroll() - } - - const onStripPointerDown = (tab: string, event: PointerEvent) => { - if (event.button !== 0 || drag.active) return - const tabEl = (event.currentTarget as HTMLElement).querySelector("[data-session-tab]") - if (!tabEl) return - setActiveStable(tab) - setPressedTab(tab) - const rect = tabEl.getBoundingClientRect() - setGesture("pending", { - tab, - startX: event.clientX, - startY: event.clientY, - pointerID: event.pointerId, - width: rect.width, - grabOffsetX: event.clientX - rect.left, - grabOffsetY: event.clientY - rect.top, - }) - } - - const onPointerMove = (event: PointerEvent) => { - const pending = gesture.pending - if (pending && !drag.active) { - if (event.pointerId !== pending.pointerID) return - if (pointerDistance(pending.startX, pending.startY, event.clientX, event.clientY) < ACTIVATION_DISTANCE) return - startDrag(pending.tab) - } - if (!drag.active) return - if (dragPointerID !== undefined && event.pointerId !== dragPointerID) return - setDrag("pointerX", event.clientX) - syncScroll() - } - - const onPointerUp = (event: PointerEvent) => { - if (drag.active) { - if (dragPointerID !== undefined && event.pointerId !== dragPointerID) return - setDrag("pointerX", event.clientX) - syncScroll() - finishDrag(true) - return - } - if (!gesture.pending) return - if (event.pointerId !== gesture.pending.pointerID) return - setGesture("pending", undefined) - setPressedTab(undefined) - } - - const onPointerCancel = (event: PointerEvent) => { - if (drag.active) { - if (dragPointerID !== undefined && event.pointerId !== dragPointerID) return - finishDrag(false) - return - } - if (!gesture.pending) return - if (event.pointerId !== gesture.pending.pointerID) return - setGesture("pending", undefined) - setPressedTab(undefined) - } - - onMount(() => { - const cleanups = [ - makeEventListener(window, "pointermove", onPointerMove), - makeEventListener(window, "pointerup", onPointerUp), - makeEventListener(window, "pointercancel", onPointerCancel), - ] - return () => { - for (const cleanup of cleanups) cleanup() - } - }) - - createEffect(() => { - const temporary = temporaryTab() - if (!temporary) return - if (stripTabs().includes(temporary)) return - setTemporaryTab(undefined) - }) - - createEffect(() => { - if (!drag.active || !tabListRef) return - return makeEventListener(tabListRef, "scroll", syncScroll) - }) - - onCleanup(stopAutoscroll) - - const handleTabsChange = (value: string) => { - if (value === "review" || value === "context" || value === SESSION_OPEN_FILE_TAB) { - setActiveStable(value) - return - } - if (!input.pathFromTab(value)) return - setActiveStable(value) - } - - const floaterStyle = () => { - const strip = tabListRef?.getBoundingClientRect() - const left = strip - ? clampFloaterLeft(drag.pointerX - drag.grabOffsetX, drag.dragWidth, draggableStripLeft(), strip.right) - : drag.pointerX - drag.grabOffsetX - return { - position: "fixed" as const, - top: `${drag.floaterTop}px`, - left: `${left}px`, - width: `${drag.dragWidth}px`, - "z-index": "10000", - "pointer-events": "none" as const, - } - } - - return { - tabState, - drag, - pressedTab, - temporaryTab, - stripTabs, - visibleStripTabs, - setTabListRef: (el: HTMLDivElement) => { - tabListRef = el - }, - openPlaceholderTab, - openFileTab, - closeTab, - persistTab, - onStripPointerDown, - handleTabsChange, - floaterStyle, - } -} - diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts index 58ab2651a1e5..389bf9192510 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/file.ts @@ -27,7 +27,6 @@ export const FindFileQuery = Schema.Struct({ query: Schema.String, dirs: Schema.optional(Schema.Literals(["true", "false"])), type: Schema.optional(Schema.Literals(["file", "directory"])), - fallback: Schema.optional(Schema.Literals(["none", "glob"])), limit: Schema.optional( Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)), ), 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 090456192f51..6a826022a0a0 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts @@ -11,10 +11,6 @@ import path from "path" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" -function escapeGlob(text: string) { - return text.replaceAll("\\", "\\\\").replace(/[?*\[\]{}()!]/g, "\\$&") -} - export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) => Effect.gen(function* () { const ripgrep = yield* Ripgrep.Service @@ -45,34 +41,16 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl }) const findFile = Effect.fn("FileHttpApi.findFile")(function* (ctx: { - query: { - query: string - dirs?: "true" | "false" - type?: "file" | "directory" - fallback?: "none" | "glob" - limit?: number - } + query: { query: string; dirs?: "true" | "false"; type?: "file" | "directory"; limit?: number } }) { const directory = (yield* InstanceState.context).directory const limit = ctx.query.limit ?? 10 const type = ctx.query.type ?? (ctx.query.dirs === "false" ? "file" : undefined) - const fallback = ctx.query.fallback ?? "none" const started = performance.now() - const found = yield* filesystem( - Effect.gen(function* () { - const fs = yield* FileSystem.Service - const found = yield* fs.find({ query: ctx.query.query, limit, type }) - if (found.length > 0 || fallback !== "glob") return found - const globbed = yield* fs - .glob({ pattern: `**/*${escapeGlob(ctx.query.query)}*`, limit }) - .pipe(Effect.catch(() => Effect.succeed([]))) - return globbed.filter((item) => !type || item.type === type).slice(0, limit) - }), - ) + const found = yield* filesystem(FileSystem.Service.use((fs) => fs.find({ query: ctx.query.query, limit, type }))) yield* Effect.logInfo("find file", { query: ctx.query.query, type, - fallback, directory, limit, results: found.length, diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index aa4a2099e46e..9ed0084aac84 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -1767,7 +1767,6 @@ export class Find extends HeyApiClient { query: string dirs?: "true" | "false" type?: "file" | "directory" - fallback?: "none" | "glob" limit?: number }, options?: Options, @@ -1782,7 +1781,6 @@ export class Find extends HeyApiClient { { in: "query", key: "query" }, { in: "query", key: "dirs" }, { in: "query", key: "type" }, - { in: "query", key: "fallback" }, { in: "query", key: "limit" }, ], }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e3f393bf9882..5e067f3afb23 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -7938,7 +7938,6 @@ export type FindFilesData = { query: string dirs?: "true" | "false" type?: "file" | "directory" - fallback?: "none" | "glob" limit?: number } url: "/find/file" diff --git a/packages/session-ui/src/components/line-comment-annotations.tsx b/packages/session-ui/src/components/line-comment-annotations.tsx index 776784990545..346082f0b1de 100644 --- a/packages/session-ui/src/components/line-comment-annotations.tsx +++ b/packages/session-ui/src/components/line-comment-annotations.tsx @@ -327,7 +327,9 @@ export function createLineCommentController( ): { note: ReturnType> annotations: Accessor>[]> - renderAnnotation: ReturnType>["renderAnnotation"] + renderAnnotation: ReturnType< + typeof createManagedLineCommentAnnotationRenderer + >["renderAnnotation"] renderGutterUtility: ReturnType onLineSelected: (range: SelectedLineRange | null) => void onLineSelectionEnd: (range: SelectedLineRange | null) => void @@ -337,7 +339,9 @@ export function createLineCommentController( ): { note: ReturnType> annotations: Accessor[]> - renderAnnotation: ReturnType>["renderAnnotation"] + renderAnnotation: ReturnType< + typeof createManagedLineCommentAnnotationRenderer + >["renderAnnotation"] renderGutterUtility: ReturnType onLineSelected: (range: SelectedLineRange | null) => void onLineSelectionEnd: (range: SelectedLineRange | null) => void diff --git a/packages/session-ui/src/v2/components/session-review-empty-open-file-v2.tsx b/packages/session-ui/src/v2/components/session-review-empty-open-file-v2.tsx deleted file mode 100644 index b96ffd279d41..000000000000 --- a/packages/session-ui/src/v2/components/session-review-empty-open-file-v2.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useI18n } from "@opencode-ai/ui/context/i18n" -import "./session-review-v2.css" - -function OpenFilePlaceholderIcon() { - return ( - - ) -} - -export function SessionReviewEmptyOpenFileV2() { - const i18n = useI18n() - - return ( -
- -
{i18n.t("ui.sessionReviewV2.empty.openFile.title")}
-
- {i18n.t("ui.sessionReviewV2.empty.openFile.description")} -
-
- ) -} diff --git a/packages/session-ui/src/v2/components/session-review-v2.css b/packages/session-ui/src/v2/components/session-review-v2.css index 113355ea5877..56ad18f1dc99 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.css +++ b/packages/session-ui/src/v2/components/session-review-v2.css @@ -81,10 +81,6 @@ color: var(--text-strong, var(--v2-text-text-base)); } -[data-component="session-review-v2-sidebar-root"][data-variant="files"] [data-slot="session-review-v2-sidebar-title"] { - margin-left: 8px; -} - :is([data-component="session-review-v2"], [data-component="session-review-v2-sidebar-root"]) [data-slot="session-review-v2-sidebar-title"] [data-component="select-v2-root"] { @@ -243,161 +239,6 @@ width: 100%; } -.session-review-v2-sidebar-toggle-slot [data-component="tooltip-v2-trigger"] { - display: inline-flex; -} - -.session-review-v2-tabs-actions [data-component="tooltip-v2-trigger"] { - display: inline-flex; -} - -[data-component="tabs-v2"].session-review-v2-tabs .session-review-v2-tab-close { - margin-left: -5px; - flex-shrink: 0; -} - -.session-review-v2-tabs-bar { - display: flex; - flex-direction: row; - align-items: center; - gap: 8px; - padding: 12px; - flex-shrink: 0; - box-sizing: border-box; - border-bottom: 1px solid var(--border-weaker-base, var(--v2-border-border-weak)); - background-color: var(--background-stronger, var(--v2-background-bg-base)); -} - -.session-review-v2-tabs-bar .session-review-v2-open-in-app { - flex-shrink: 0; - margin-left: auto; -} - -.session-review-v2-sidebar-toggle-slot { - display: flex; - align-items: center; - flex-shrink: 0; -} - -[data-component="tabs-v2"].session-review-v2-tabs { - background-color: var(--background-stronger, var(--v2-background-bg-base)); -} - -[data-component="tabs-v2"].session-review-v2-tabs .session-review-v2-tabs-list, -[data-component="tabs-v2"].session-review-v2-tabs [data-slot="tabs-v2-list"].session-review-v2-tabs-list { - box-sizing: border-box; - flex: 1 1 0; - min-width: 0; - width: 100%; - height: fit-content; - padding: 0; - gap: 8px; - align-items: center; - background-color: transparent; - border-bottom: none; -} - -[data-component="tabs-v2"].session-review-v2-tabs [data-slot="tabs-v2-list"].session-review-v2-tabs-list::before { - display: none; -} - -[data-component="tabs-v2"].session-review-v2-tabs .session-review-v2-tabs-actions { - position: relative; - margin-left: -2px; - flex-shrink: 0; - background-color: var(--background-stronger, var(--v2-background-bg-base)); -} - -[data-component="tabs-v2"].session-review-v2-tabs [data-slot="tabs-v2-trigger"] .tab-fileicon-color, -[data-component="tabs-v2"].session-review-v2-tabs [data-slot="tabs-v2-trigger"] .tab-fileicon-mono { - pointer-events: none; -} - -[data-component="tabs-v2"].session-review-v2-tabs [data-slot="tabs-v2-trigger"] .tab-fileicon-color { - display: none; -} - -[data-component="tabs-v2"].session-review-v2-tabs [data-slot="tabs-v2-trigger"] .tab-fileicon-mono { - display: block; - color: currentColor; -} - -[data-component="tabs-v2"].session-review-v2-tabs [data-slot="tabs-v2-trigger"][data-selected] .tab-fileicon-color, -[data-component="tabs-v2"].session-review-v2-tabs [data-slot="tabs-v2-trigger"]:hover .tab-fileicon-color { - display: block; -} - -[data-component="tabs-v2"].session-review-v2-tabs [data-slot="tabs-v2-trigger"][data-selected] .tab-fileicon-mono, -[data-component="tabs-v2"].session-review-v2-tabs [data-slot="tabs-v2-trigger"]:hover .tab-fileicon-mono { - display: none; -} - -[data-component="tabs-v2"].session-review-v2-tabs .session-review-v2-file-tab--temporary [data-slot="tabs-v2-trigger"] { - font-style: italic; -} - -[data-component="tabs-v2"].session-review-v2-tabs - .session-review-v2-file-tab-slot--pressed - [data-slot="tabs-v2-trigger-wrapper"] { - background-color: var(--v2-background-bg-layer-02); -} - -[data-component="tabs-v2"].session-review-v2-tabs[data-variant="pill"] - [data-slot="tabs-v2-trigger-wrapper"]:hover:not(:disabled):not(:has([data-selected])) { - background-color: var(--v2-background-bg-layer-02); -} - -[data-component="tabs-v2-drag-preview"] { - position: relative; - display: flex; - align-items: center; - height: 28px; - max-width: 320px; - padding-inline: 10px; - overflow: hidden; - color: var(--v2-text-text-base); - isolation: isolate; - font-size: 13px; - font-weight: 440; - line-height: 100%; - letter-spacing: -0.04px; -} - -[data-component="tabs-v2-drag-preview"]::before { - content: ""; - position: absolute; - inset: 0; - height: 28px; - border-radius: 6px; - background-color: var(--v2-background-bg-base); -} - -[data-component="tabs-v2-drag-preview"]::after { - content: ""; - position: absolute; - inset: 0; - height: 28px; - border-radius: 6px; - background-color: var(--v2-background-bg-layer-02); - box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted); -} - -[data-component="tabs-v2-drag-preview"][data-temporary] { - font-style: italic; -} - -[data-component="tabs-v2-drag-preview"] > * { - position: relative; - z-index: 1; -} - -.session-review-v2-panel-body { - display: flex; - flex: 1; - min-height: 0; - overflow: hidden; -} - [data-component="icon-button-v2"].session-review-v2-sidebar-toggle { width: 36px; height: 28px; @@ -563,43 +404,6 @@ color: var(--v2-text-text-muted); } -[data-slot="session-review-v2-empty-open-file"] { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 16px; - padding: 24px; - padding-bottom: 160px; - text-align: center; -} - -[data-slot="session-review-v2-empty-open-file-icon"] { - flex: none; -} - -[data-slot="session-review-v2-empty-open-file-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-open-file-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); -} - [data-slot="session-review-v2-empty-changes"] { flex: 1; display: flex; diff --git a/packages/session-ui/src/v2/components/session-review-v2.tsx b/packages/session-ui/src/v2/components/session-review-v2.tsx index daa5867beba1..7dfa883865ab 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.tsx +++ b/packages/session-ui/src/v2/components/session-review-v2.tsx @@ -12,9 +12,9 @@ import { ScrollView } from "@opencode-ai/ui/scroll-view" import { Show, createEffect, createSignal, onCleanup, type JSX } from "solid-js" import "./session-review-v2.css" -const SIDEBAR_WIDTH_DEFAULT = 240 -const SIDEBAR_WIDTH_MIN = 200 -const SIDEBAR_WIDTH_MAX = 480 +export const SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT = 240 +export const SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN = 200 +export const SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX = 480 export type SessionReviewExpandMode = "expand" | "collapse" @@ -23,30 +23,26 @@ export type SessionReviewV2Props = { stats?: JSX.Element empty?: JSX.Element sidebarOpen?: boolean - filter: string - onFilterChange: (value: string) => void sidebar?: JSX.Element + sidebarToggle?: JSX.Element activeFile?: string files: string[] onSelectFile: (file: string) => void diffStyle: SessionReviewDiffStyle onDiffStyleChange?: (style: SessionReviewDiffStyle) => void - expandMode?: SessionReviewExpandMode - onExpandModeChange?: (mode: SessionReviewExpandMode) => void + expandMode: SessionReviewExpandMode + onExpandModeChange: (mode: SessionReviewExpandMode) => void preview?: JSX.Element hasDiffs: boolean - hideSidebar?: boolean } export type SessionReviewV2SidebarProps = { open: boolean - variant?: "review" | "files" title?: JSX.Element stats?: JSX.Element filter: string onFilterChange: (value: string) => void onFilterKeyDown?: JSX.EventHandlerUnion - focusFilterToken?: number width?: number onWidthChange?: (width: number) => void minWidth?: number @@ -57,20 +53,9 @@ export type SessionReviewV2SidebarProps = { export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) { const i18n = useI18n() const [resizing, setResizing] = createSignal(false) - 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() - }) - }) + const width = () => props.width ?? SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT + const minWidth = () => props.minWidth ?? SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN + const maxWidth = () => props.maxWidth ?? SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX createEffect(() => { if (!resizing()) return @@ -80,7 +65,7 @@ export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) { }) return ( -
+