From 444be68cc262d12b94ce02e671e4a78456dc0e5c Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:37:32 +1000 Subject: [PATCH 1/5] refactor(app): extract review and file viewers into an extension --- bun.lock | 23 + packages/app/package.json | 1 + packages/app/src/extensions/provider.tsx | 3 +- .../app/src/home/projects/file-manager.ts | 25 +- packages/app/src/index.css | 1 + .../session/commands/use-session-commands.tsx | 15 - .../app/src/session/files/open-in-app.test.ts | 20 - .../session/files/session-mobile-files.css | 35 - .../session/files/session-mobile-files.tsx | 100 --- .../src/session/files/session-side-panel.tsx | 785 ++++-------------- .../src/session/files/virtual-scroll.test.ts | 2 +- .../app/src/session/files/virtual-scroll.ts | 4 - packages/app/src/session/mobile-view-tabs.tsx | 84 ++ packages/app/src/session/review/model.ts | 430 ---------- .../app/src/session/review/panel-state.ts | 49 -- .../app/src/session/review/review-tab.tsx | 180 ---- packages/app/src/session/review/view.tsx | 324 -------- packages/app/src/session/screen.tsx | 69 +- packages/app/src/session/story-model.tsx | 5 +- packages/app/src/session/summary.ts | 30 + packages/plugin-review-desktop/README.md | 25 + packages/plugin-review-desktop/package.json | 32 + packages/plugin-review-desktop/src/env.d.ts | 12 + .../plugin-review-desktop/src/environment.tsx | 34 + .../src}/files/file-tabs.tsx | 37 +- .../src}/files/file-tree-v2-model.test.ts | 9 +- .../src}/files/file-tree-v2-model.ts | 2 +- .../src}/files/file-tree-v2.tsx | 22 +- .../src}/files/file-tree.test.ts | 40 +- .../src}/files/file-tree.tsx | 12 +- .../src}/files/list.tsx | 32 +- .../src}/files/open-in-app-button.tsx | 4 +- .../src}/files/open-in-app-path.test.ts | 0 .../src}/files/open-in-app-path.ts | 0 .../src/files/open-in-app.test.ts | 11 + .../src}/files/open-in-app.tsx | 64 +- .../src}/files/session-file-browser-tab.tsx | 36 +- .../src/files/session-mobile-files.css | 11 + .../src/files/session-mobile-files.tsx | 41 + .../src/files/virtual-scroll.ts | 13 + packages/plugin-review-desktop/src/index.tsx | 206 +++++ .../src/review/mobile.tsx | 34 + .../plugin-review-desktop/src/review/model.ts | 189 +++++ .../src/review/panel-state.ts | 50 ++ .../src}/review/panel.tsx | 71 +- .../src}/review/review-diff-kinds.test.ts | 0 .../src}/review/review-diff-kinds.ts | 4 +- .../plugin-review-desktop/src/review/view.tsx | 56 ++ .../test/boundary.test.ts | 9 + packages/plugin-review-desktop/tsconfig.json | 16 + packages/util/src/file-manager.ts | 24 + 51 files changed, 1264 insertions(+), 2017 deletions(-) delete mode 100644 packages/app/src/session/files/open-in-app.test.ts delete mode 100644 packages/app/src/session/files/session-mobile-files.css delete mode 100644 packages/app/src/session/files/session-mobile-files.tsx delete mode 100644 packages/app/src/session/files/virtual-scroll.ts create mode 100644 packages/app/src/session/mobile-view-tabs.tsx delete mode 100644 packages/app/src/session/review/model.ts delete mode 100644 packages/app/src/session/review/panel-state.ts delete mode 100644 packages/app/src/session/review/review-tab.tsx delete mode 100644 packages/app/src/session/review/view.tsx create mode 100644 packages/app/src/session/summary.ts create mode 100644 packages/plugin-review-desktop/README.md create mode 100644 packages/plugin-review-desktop/package.json create mode 100644 packages/plugin-review-desktop/src/env.d.ts create mode 100644 packages/plugin-review-desktop/src/environment.tsx rename packages/{app/src/session => plugin-review-desktop/src}/files/file-tabs.tsx (92%) rename packages/{app/src/session => plugin-review-desktop/src}/files/file-tree-v2-model.test.ts (94%) rename packages/{app/src/session => plugin-review-desktop/src}/files/file-tree-v2-model.ts (97%) rename packages/{app/src/session => plugin-review-desktop/src}/files/file-tree-v2.tsx (93%) rename packages/{app/src/session => plugin-review-desktop/src}/files/file-tree.test.ts (53%) rename packages/{app/src/session => plugin-review-desktop/src}/files/file-tree.tsx (97%) rename packages/{app/src/session => plugin-review-desktop/src}/files/list.tsx (88%) rename packages/{app/src/session => plugin-review-desktop/src}/files/open-in-app-button.tsx (97%) rename packages/{app/src/session => plugin-review-desktop/src}/files/open-in-app-path.test.ts (100%) rename packages/{app/src/session => plugin-review-desktop/src}/files/open-in-app-path.ts (100%) create mode 100644 packages/plugin-review-desktop/src/files/open-in-app.test.ts rename packages/{app/src/session => plugin-review-desktop/src}/files/open-in-app.tsx (81%) rename packages/{app/src/session => plugin-review-desktop/src}/files/session-file-browser-tab.tsx (81%) create mode 100644 packages/plugin-review-desktop/src/files/session-mobile-files.css create mode 100644 packages/plugin-review-desktop/src/files/session-mobile-files.tsx create mode 100644 packages/plugin-review-desktop/src/files/virtual-scroll.ts create mode 100644 packages/plugin-review-desktop/src/index.tsx create mode 100644 packages/plugin-review-desktop/src/review/mobile.tsx create mode 100644 packages/plugin-review-desktop/src/review/model.ts create mode 100644 packages/plugin-review-desktop/src/review/panel-state.ts rename packages/{app/src/session => plugin-review-desktop/src}/review/panel.tsx (85%) rename packages/{app/src/session => plugin-review-desktop/src}/review/review-diff-kinds.test.ts (100%) rename packages/{app/src/session => plugin-review-desktop/src}/review/review-diff-kinds.ts (93%) create mode 100644 packages/plugin-review-desktop/src/review/view.tsx create mode 100644 packages/plugin-review-desktop/test/boundary.test.ts create mode 100644 packages/plugin-review-desktop/tsconfig.json create mode 100644 packages/util/src/file-manager.ts diff --git a/bun.lock b/bun.lock index 29421cd5cfd6..f74705ab87da 100644 --- a/bun.lock +++ b/bun.lock @@ -64,6 +64,7 @@ "@kobalte/core": "catalog:", "@opencode/client": "workspace:*", "@opencode/plugin": "workspace:*", + "@opencode/plugin-review-desktop": "workspace:*", "@opencode/schema": "workspace:*", "@opencode/session-ui": "workspace:*", "@opencode/ui": "workspace:*", @@ -656,6 +657,26 @@ "puppeteer-core": "25.9.0", }, }, + "packages/plugin-review-desktop": { + "name": "@opencode/plugin-review-desktop", + "version": "0.0.0", + "dependencies": { + "@opencode/client": "workspace:*", + "@opencode/plugin": "workspace:*", + "@opencode/session-ui": "workspace:*", + "@opencode/ui": "workspace:*", + "@opencode/util": "workspace:*", + "@solid-primitives/event-listener": "catalog:", + "@solid-primitives/scheduled": "1.5.3", + "@tanstack/solid-query": "5.91.4", + "@tanstack/solid-virtual": "catalog:", + "solid-js": "catalog:", + }, + "devDependencies": { + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/posts": { "name": "@opencode/posts", "dependencies": { @@ -2245,6 +2266,8 @@ "@opencode/plugin-browser-desktop": ["@opencode/plugin-browser-desktop@workspace:packages/plugin-browser-desktop"], + "@opencode/plugin-review-desktop": ["@opencode/plugin-review-desktop@workspace:packages/plugin-review-desktop"], + "@opencode/posts": ["@opencode/posts@workspace:packages/posts"], "@opencode/protocol": ["@opencode/protocol@workspace:packages/protocol"], diff --git a/packages/app/package.json b/packages/app/package.json index dffb3e7dbd79..72458b52acc3 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -65,6 +65,7 @@ "@kobalte/core": "catalog:", "@opencode/client": "workspace:*", "@opencode/plugin": "workspace:*", + "@opencode/plugin-review-desktop": "workspace:*", "@opencode/schema": "workspace:*", "@opencode/session-ui": "workspace:*", "@opencode/ui": "workspace:*", diff --git a/packages/app/src/extensions/provider.tsx b/packages/app/src/extensions/provider.tsx index 56e13ad28fae..7a6c8821fecc 100644 --- a/packages/app/src/extensions/provider.tsx +++ b/packages/app/src/extensions/provider.tsx @@ -32,6 +32,7 @@ import { base64Encode } from "@opencode/util/encode" import { terminalFontFamily, useSettings } from "@/settings/model" import { extensionTabKey } from "./keys" import { showToast } from "@/shell/notifications/toast" +import { ReviewDesktop } from "@opencode/plugin-review-desktop" import type { SessionServices } from "@opencode/plugin/desktop/workspace" export type Contribution = Claim<{ @@ -97,7 +98,7 @@ function createHost() { const workspaceRemoved = new Set<(value: { serverID: string; directory: string }) => void>() const attempted = new WeakSet() let instanceID = 0 - const builtins = () => platform.extensionPlugins ?? [] + const builtins = () => [ReviewDesktop, ...(platform.extensionPlugins ?? [])] createEffect(() => { const manager = platform.extensionManager if (!manager) return diff --git a/packages/app/src/home/projects/file-manager.ts b/packages/app/src/home/projects/file-manager.ts index 2fba23cc5abc..4634fea1762d 100644 --- a/packages/app/src/home/projects/file-manager.ts +++ b/packages/app/src/home/projects/file-manager.ts @@ -1,24 +1 @@ -export type FileManagerOS = "macos" | "windows" | "linux" | "unknown" - -export function fileManagerApp(os: FileManagerOS): { - label: "session.header.open.finder" | "session.header.open.fileExplorer" | "session.header.open.fileManager" - actionLabel: - | "session.header.reveal.finder" - | "session.header.reveal.fileExplorer" - | "session.header.reveal.containingFolder" - icon: "finder" | "file-explorer" -} { - if (os === "macos") - return { label: "session.header.open.finder", actionLabel: "session.header.reveal.finder", icon: "finder" } - if (os === "windows") - return { - label: "session.header.open.fileExplorer", - actionLabel: "session.header.reveal.fileExplorer", - icon: "file-explorer", - } - return { - label: "session.header.open.fileManager", - actionLabel: "session.header.reveal.containingFolder", - icon: "finder", - } -} +export { fileManagerApp, type FileManagerOS } from "@opencode/util/file-manager" diff --git a/packages/app/src/index.css b/packages/app/src/index.css index cc9631f39522..4f841551bb6b 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -2,6 +2,7 @@ @import "@opencode/session-ui/styles"; @import "@opencode/ui/styles/tokens"; @import "tw-animate-css"; +@source "../../plugin-review-desktop/src"; @font-face { font-family: "JetBrainsMono Nerd Font Mono"; diff --git a/packages/app/src/session/commands/use-session-commands.tsx b/packages/app/src/session/commands/use-session-commands.tsx index 88cfb0901dc5..ea7e0016c9c4 100644 --- a/packages/app/src/session/commands/use-session-commands.tsx +++ b/packages/app/src/session/commands/use-session-commands.tsx @@ -160,13 +160,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => { } } - const openFile = () => { - void openDialog( - () => import("@/shell/commands/dialog"), - (x) => dialog.show(() => ), - ) - } - const closeTab = () => { const tab = actions.session.tabs.closableTab() if (!tab) return @@ -311,14 +304,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const fileCmds = () => { const tab = actions.session.tabs.closableTab() return [ - fileCommand({ - id: "file.open", - title: language.t("command.file.open"), - description: language.t("palette.search.placeholder"), - keybind: "mod+p", - slash: "open", - onSelect: openFile, - }), tab && fileCommand({ id: "file.close", diff --git a/packages/app/src/session/files/open-in-app.test.ts b/packages/app/src/session/files/open-in-app.test.ts deleted file mode 100644 index c1d90a80a6ca..000000000000 --- a/packages/app/src/session/files/open-in-app.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { Schema } from "effect" -import { OPEN_APPS, OpenAppPreferences } from "./open-in-app" -import { Persistence } from "@/runtime/persistence/schema" - -const decode = Schema.decodeUnknownSync(Persistence.withInitial(OpenAppPreferences, { app: "finder" })) - -describe("open app preferences", () => { - test.each([...OPEN_APPS])("preserves the %s preference", (app) => { - expect(decode({ app })).toEqual({ app }) - }) - - test.each([undefined, null, 42, "unknown", {}])("defaults invalid selection %p", (app) => { - expect(decode({ app })).toEqual({ app: "finder" }) - }) - - test("defaults an absent selection", () => { - expect(decode({})).toEqual({ app: "finder" }) - }) -}) diff --git a/packages/app/src/session/files/session-mobile-files.css b/packages/app/src/session/files/session-mobile-files.css deleted file mode 100644 index 7e531c9ca5de..000000000000 --- a/packages/app/src/session/files/session-mobile-files.css +++ /dev/null @@ -1,35 +0,0 @@ -[data-slot="session-mobile-files"] - [data-slot="session-mobile-files-header"] - [data-component="tabs-v2"][data-variant="normal"][data-orientation="horizontal"] - [data-slot="tabs-v2-list"] { - position: static; - - &::before { - inset-inline-start: 0; - width: 100%; - } -} - -[data-slot="session-mobile-files"] [data-component="line-comment-v2"] { - max-width: none; -} - -[data-slot="session-mobile-files"][data-browsing="true"] { - [data-component="session-review-v2-sidebar-root"] { - width: 100%; - } - - [data-slot="session-review-v2-sidebar"] { - width: 100% !important; - border-inline-end: 0; - } - - [data-slot="session-review-v2-preview"] { - display: none; - } -} - -[data-slot="session-mobile-files"] [data-slot="tabs-v2-trigger-close-button"] [data-slot="tabs-close-button"] { - width: 32px; - height: 36px; -} diff --git a/packages/app/src/session/files/session-mobile-files.tsx b/packages/app/src/session/files/session-mobile-files.tsx deleted file mode 100644 index 2a60f5b0b998..000000000000 --- a/packages/app/src/session/files/session-mobile-files.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { Button } from "@opencode/ui/button" -import { Tabs } from "@opencode/ui/tabs" -import { getFilename } from "@opencode/util/path" -import { createMemo, For } from "solid-js" -import { createStore } from "solid-js/store" -import { useLanguage } from "@/runtime/i18n/language" -import { useSessionLayout } from "@/session/session-layout" -import { createSessionTabs, SESSION_OPEN_FILE_TAB } from "@/session/helpers" -import { useFile } from "@/workspaces/files/model" -import { SessionFileBrowserTab } from "./session-file-browser-tab" -import type { Kind } from "./file-tree-v2" -import "./session-mobile-files.css" - -export function SessionMobileFiles() { - const file = useFile() - const language = useLanguage() - const layout = useSessionLayout() - const tabs = createSessionTabs({ - tabs: layout.tabs, - pathFromTab: file.pathFromTab, - normalizeTab: file.tab, - }) - const [store, setStore] = createStore({ browsing: !tabs.activeFileTab() }) - const browsing = () => store.browsing || !tabs.activeFileTab() - const active = createMemo(() => file.pathFromTab(tabs.activeFileTab() ?? "")) - const kinds = new Map() - const open = (path: string) => { - layout.tabs().open(file.tab(path)) - void file.load(path) - setStore("browsing", false) - } - - return ( -
-
- - { - // Kobalte falls back to a file tab when the browse view has no trigger. - if (browsing()) return - const path = file.pathFromTab(tab) - if (path) open(path) - }} - variant="line" - class="min-w-0 flex-1 !h-auto" - > - - - {(tab) => ( - open(file.pathFromTab(tab)!)} - class="shrink-0 max-w-48" - classes={{ button: "min-w-0" }} - closeButton={ - layout.tabs().close(tab)} - /> - } - > - - {getFilename(file.pathFromTab(tab) ?? tab)} - - - )} - - - -
-
- 240, - sidebarTransition: () => false, - resizeSidebar: () => undefined, - toggleSidebar: () => setStore("browsing", !browsing()), - }} - onSelect={open} - onSelectPermanent={open} - /> -
-
- ) -} diff --git a/packages/app/src/session/files/session-side-panel.tsx b/packages/app/src/session/files/session-side-panel.tsx index 611bf38655ad..d75de674dcec 100644 --- a/packages/app/src/session/files/session-side-panel.tsx +++ b/packages/app/src/session/files/session-side-panel.tsx @@ -1,4 +1,4 @@ -import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js" +import { For, Show, createMemo, onCleanup } from "solid-js" import { createMediaQuery } from "@solid-primitives/media" import { DragDropProvider, PointerSensor } from "@dnd-kit/solid" import { isSortable } from "@dnd-kit/solid/sortable" @@ -10,672 +10,203 @@ import { IconButton } from "@opencode/ui/icon-button" import { Icon } from "@opencode/ui/icon" import { Loader } from "@opencode/ui/loader" import { ResizeHandle } from "@opencode/ui/resize-handle" -import { Mark } from "@opencode/ui/logo" -import { Keybind } from "@opencode/ui/keybind" -import { Tooltip } from "@opencode/ui/tooltip" import { Menu } from "@opencode/ui/menu" -import type { FileDiffInfo } from "@opencode/client/promise" - -import FileTree from "@/session/files/file-tree" -import { normalizeFileTreeV2Path } from "@/session/files/file-tree-v2-model" -import { SessionContextUsage } from "@/session/timeline/session-context-usage" - -const reviewTabID = "session-side-panel-review-tab" -const reviewTabPanelID = "session-side-panel-review-tabpanel" -const fileBrowserTabPanelID = "session-side-panel-file-browser-tabpanel" -import { SessionContextTab } from "@/session/files/session-context-tab" -import { SortableTab } from "@/session/files/tab" -import { OpenInAppButton } from "@/session/files/open-in-app-button" -import { useCommand } from "@/shell/commands/command" -import { useFile, type SelectedLineRange } from "@/workspaces/files/model" +import { Tooltip } from "@opencode/ui/tooltip" +import { SortableTab } from "./tab" import { useLanguage } from "@/runtime/i18n/language" import { useLayout } from "@/shell/state/layout" -import { useWorkspaceLocation } from "@/workspaces/location" import { useSettings } from "@/settings/model" -import { createFileTabListSync } from "@/session/files/file-tab-scroll" -import { - SESSION_OPEN_FILE_TAB, - createOpenSessionFileTab, - createSessionTabs, - shouldShowFileTree, - type Sizing, -} from "@/session/helpers" -import { setSessionHandoff } from "@/session/handoff" +import { createFileTabListSync } from "./file-tab-scroll" import { useSessionLayout } from "@/session/session-layout" -import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/session/files/session-file-browser-tab" +import { createSessionTabs, type Sizing } from "@/session/helpers" +import { useFile } from "@/workspaces/files/model" import type { SessionExtensions } from "@/extensions/session" -import { isExtensionTab } from "@/extensions/keys" import { ExtensionPanelContent } from "@/extensions/content" - -type ReviewDiff = FileDiffInfo -type RenderDiff = FileDiffInfo -const FILE_TREE_WIDTH_MIN = 240 - -function renderDiff(value: ReviewDiff): value is RenderDiff { - return typeof value.file === "string" -} +import { SessionContextTab } from "./session-context-tab" export function SessionSidePanel(props: { extensions: SessionExtensions - canReview: boolean - diffs: ReviewDiff[] - diffsReady: boolean - hasReview: boolean - reviewHasFocusableContent: boolean - reviewCount: number - reviewPanel: () => JSX.Element - reviewSidebarToggle: (disabled: boolean) => JSX.Element - fileBrowserState: SessionFileBrowserState - activeDiff?: string - focusReviewDiff: (path: string) => void - reviewPresent?: boolean + present?: boolean size: Sizing stacked?: boolean + mobile?: boolean }) { + const language = useLanguage() const layout = useLayout() const settings = useSettings() const file = useFile() - const language = useLanguage() - const command = useCommand() - const sdk = useWorkspaceLocation() - const { sessionKey, tabs, view, params } = useSessionLayout() - const extensions = props.extensions - const projectDirectory = createMemo(() => sdk().directory) - + const session = useSessionLayout() const isDesktop = createMediaQuery("(min-width: 768px)") - const shown = settings.visibility.fileTree - - const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) - const reviewVisible = createMemo(() => reviewOpen() || !!props.reviewPresent) - const fileOpen = createMemo( - () => - isDesktop() && - shouldShowFileTree({ - visible: shown(), - opened: layout.fileTree.opened(), - }), - ) - const open = createMemo(() => reviewOpen() || fileOpen()) - const visible = createMemo(() => reviewVisible() || fileOpen()) - const fileTreeWidth = createMemo(() => Math.max(FILE_TREE_WIDTH_MIN, layout.fileTree.width())) - const reviewTab = createMemo(() => isDesktop()) - const panelWidth = createMemo(() => { - if (!visible()) return "0px" - if (reviewVisible()) return "auto" - return `${fileTreeWidth()}px` - }) - const treeWidth = createMemo(() => (fileOpen() ? `${fileTreeWidth()}px` : "0px")) - - const diffs = createMemo(() => props.diffs.filter(renderDiff)) - const diffFiles = createMemo(() => diffs().map((d) => d.file)) - const kinds = createMemo(() => { - const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => { - if (!a) return b - if (a === b) return a - return "mix" as const - } - - const out = new Map() - for (const diff of diffs()) { - const file = normalizeFileTreeV2Path(diff.file) - const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix" - - out.set(file, kind) - - const parts = file.split("/") - for (const [idx] of parts.slice(0, -1).entries()) { - const dir = parts.slice(0, idx + 1).join("/") - if (!dir) continue - out.set(dir, merge(out.get(dir), kind)) - } - } - return out - }) - - const empty = (msg: string) => ( -
-
-
-
{msg}
-
-
- ) - - const nofiles = createMemo(() => { - const state = file.tree.state("") - if (!state?.loaded) return false - return file.tree.children("").length === 0 - }) - - const normalizeTab = (tab: string) => { - if (!tab.startsWith("file://")) return tab - return file.tab(tab) - } - - const openReviewPanel = () => { - if (!view().reviewPanel.opened()) view().reviewPanel.open() - } - - const openTab = createOpenSessionFileTab({ - normalizeTab, - openTab: tabs().open, - pathFromTab: file.pathFromTab, - loadFile: file.load, - openReviewPanel, - setActive: tabs().setActive, - }) - - const tabState = createSessionTabs({ - tabs, + const extensions = props.extensions + const opened = () => !!props.mobile || (isDesktop() && session.view().reviewPanel.opened()) + const visible = () => opened() || !!props.present + const sidebar = () => !props.mobile && isDesktop() && settings.visibility.fileTree() && layout.fileTree.opened() + const width = () => Math.max(240, layout.fileTree.width()) + const state = createSessionTabs({ + tabs: session.tabs, pathFromTab: file.pathFromTab, - normalizeTab, - review: reviewTab, - hasReview: () => props.canReview, - fileBrowser: () => true, + normalizeTab: file.tab, extensions: extensions.keys, defaultPanel: extensions.defaultPanel, canClose: extensions.canClose, }) - const contextOpen = tabState.contextOpen - const openFileOpen = tabState.openFileOpen - const panelTabs = tabState.panelTabs - const openedTabs = tabState.openedTabs - const activeTab = tabState.activeTab - const activeFileTab = tabState.activeFileTab - - const fileTreeTab = () => layout.fileTree.tab() - - const setFileTreeTabValue = (value: string) => { - if (value !== "changes" && value !== "all") return - layout.fileTree.setTab(value) - } - - let fileFilter: HTMLInputElement | undefined - let tabList: HTMLDivElement | undefined - const temporaryTab = tabs().preview - const previewTab = (value: string) => { - const next = normalizeTab(value) - tabs().previewTab(next) - const path = file.pathFromTab(next) - if (path) void file.load(path) - openReviewPanel() - queueMicrotask(() => tabs().setActive(next)) - } - const openFileBrowser = () => { - previewTab(SESSION_OPEN_FILE_TAB) - queueMicrotask(() => fileFilter?.focus()) - } - const activateTab = (value: string) => { - const next = normalizeTab(value) - const path = file.pathFromTab(next) - if (path) void file.load(path) - openReviewPanel() - tabs().setActive(next) - } - const fileTab = createMemo(() => { - const active = activeTab() - if (active === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB - if (active && file.pathFromTab(active)) return active - return activeFileTab() - }) - // Keep the file-browser shell mounted while any file tab exists. Kobalte briefly - // selects Review while the tab For replaces a preview trigger, which would - // otherwise dispose the sidebar and reset scroll. - const fileBrowserMounted = createMemo(() => { - return openedTabs().length > 0 || openFileOpen() || !!fileTab() + const panelTabs = createMemo(() => { + const keys = state.panelTabs() + const preferred = extensions.defaultPanel() + return preferred && !keys.includes(preferred) ? [preferred, ...keys] : keys }) - const fileBrowserVisible = createMemo(() => { - const active = activeTab() - return ( - active !== "review" && - active !== "context" && - active !== "empty" && - !extensions.keys().includes(active) - ) - }) - const openFileKeybind = createMemo(() => command.keybindParts("file.open")) - const closeTabKeybind = createMemo(() => command.keybindParts("file.close")) - 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 - }, {}), - }) - }) - + let tabList: HTMLDivElement | undefined return ( - + diff --git a/packages/app/src/session/files/virtual-scroll.test.ts b/packages/app/src/session/files/virtual-scroll.test.ts index 35a57eb8a189..b72597bd24c0 100644 --- a/packages/app/src/session/files/virtual-scroll.test.ts +++ b/packages/app/src/session/files/virtual-scroll.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import { virtualScrollElement } from "./virtual-scroll" +import { virtualScrollElement } from "@opencode/plugin-review-desktop/files/virtual-scroll" test("resolves the connected viewport that owns the virtual root", () => { const stale = document.createElement("div") diff --git a/packages/app/src/session/files/virtual-scroll.ts b/packages/app/src/session/files/virtual-scroll.ts deleted file mode 100644 index 8708781d86a7..000000000000 --- a/packages/app/src/session/files/virtual-scroll.ts +++ /dev/null @@ -1,4 +0,0 @@ -export function virtualScrollElement(root: HTMLElement | undefined) { - if (!root?.isConnected) return null - return root.closest(".scroll-view__viewport") -} diff --git a/packages/app/src/session/mobile-view-tabs.tsx b/packages/app/src/session/mobile-view-tabs.tsx new file mode 100644 index 000000000000..eef7a9d564f9 --- /dev/null +++ b/packages/app/src/session/mobile-view-tabs.tsx @@ -0,0 +1,84 @@ +import { For, Show, Suspense, lazy, createEffect, onCleanup, type JSX } from "solid-js" +import { createStore } from "solid-js/store" +import { Tabs } from "@opencode/ui/tabs" +import { Menu } from "@opencode/ui/menu" +import { Icon } from "@opencode/ui/icon" +import { IconButton } from "@opencode/ui/icon-button" +import { useLanguage } from "@/runtime/i18n/language" + +const StatusDrawer = lazy(async () => { + const { StatusDrawer } = await import("@/shell/status/status-drawer") + return { default: StatusDrawer } +}) +const MobilePanelDrawer = lazy(async () => { + const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer") + return { default: MobilePanelDrawer } +}) + +export function SessionMobileViewTabs(props: { + current: string + items: readonly { id: string; title: string }[] + onSelect(value: string): void + details?: (close: () => void) => JSX.Element + onDetailsOpenChange?: (open: boolean) => void +}) { + const language = useLanguage() + const [store, setStore] = createStore({ status: false, details: false }) + createEffect(() => props.onDetailsOpenChange?.(store.details)) + onCleanup(() => props.onDetailsOpenChange?.(false)) + return ( +
+ + + props.onSelect("session")}> + {language.t("session.tab.session")} + + + {(item) => ( + props.onSelect(item.id)}> + {item.title} + + )} + + props.onSelect("terminal")}> + {language.t("terminal.title")} + + + + + } + variant="ghost-muted" + size="normal" + aria-label={language.t("common.moreOptions")} + /> + + + props.onSelect("usage")}>{language.t("session.tab.usage")} + + setStore("details", true)}>{language.t("session.summary.title")} + + setStore("status", true)}>{language.t("status.popover.trigger")} + + + + + + setStore("status", open)} /> + + + + + setStore("details", open)} + > + {props.details?.(() => setStore("details", false))} + + + +
+ ) +} diff --git a/packages/app/src/session/review/model.ts b/packages/app/src/session/review/model.ts deleted file mode 100644 index 4c8f3f2601ab..000000000000 --- a/packages/app/src/session/review/model.ts +++ /dev/null @@ -1,430 +0,0 @@ -import type { FileDiffInfo } from "@opencode/client/promise" -import type { SessionReviewLineComment } from "@opencode/session-ui/session-review" -import { previewSelectedLines } from "@opencode/session-ui/pierre/selection-bridge" -import { checksum } from "@opencode/util/encode" -import { createQuery, skipToken, useQueryClient } from "@tanstack/solid-query" -import { debounce } from "@solid-primitives/scheduled" -import { createEffect, createMemo, on, onCleanup, type Accessor } from "solid-js" -import { createStore } from "solid-js/store" -import { useComments } from "@/composer/comments" -import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/workspaces/files/model" -import { useLanguage } from "@/runtime/i18n/language" -import { useLayout } from "@/shell/state/layout" -import { useComposerState } from "@/composer/persistence" -import { useWorkspaceLocation } from "@/workspaces/location" -import { useServerSDK } from "@/runtime/server/client" -import { createOpenReviewFile } from "../helpers" -import type { SessionModel } from "../model" -import type { SessionScreenLayout } from "../screen-layout" -import { createReviewPanelState } from "./panel-state" -import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "./review-diff-kinds" -import type { DiffStyle } from "./review-tab" - -export type ChangeMode = "git" | "branch" | "turn" -type VcsMode = "git" | "branch" - -export function createSessionReview(input: { - session: SessionModel - screen: SessionScreenLayout - deferRender: Accessor -}) { - const data = input.session.shared.data - const queryClient = useQueryClient() - const comments = useComments() - const file = useFile() - const language = useLanguage() - const layout = useLayout() - const prompt = useComposerState() - const location = useWorkspaceLocation() - const server = useServerSDK() - const [state, setState] = createStore({ - mobileTab: "session" as "session" | "changes" | "files" | "usage", - detailsOpen: false, - scroll: undefined as HTMLDivElement | undefined, - pendingFile: undefined as string | undefined, - }) - const mode = () => input.session.layout.view().review.mode() ?? "git" - const selectedFile = () => input.session.layout.view().review.file() - createEffect(() => { - const tab = input.session.tabs.activeFileTab() - if (!tab) return - const path = file.pathFromTab(tab) - if (path) void file.load(path) - }) - const vcs = createMemo(() => data.location.vcs.info({ directory: location().directory })) - const options = createMemo(() => { - const list: ChangeMode[] = [] - const project = input.session.project() - if (project?.vcs) list.push("git") - if ( - project?.vcs && - vcs()?.branch.current && - vcs()?.branch.default && - vcs()?.branch.current !== vcs()?.branch.default - ) { - list.push("branch") - } - return list - }) - const mobileChanges = createMemo( - () => !input.session.isDesktop() && !input.screen.terminal.open() && state.mobileTab === "changes", - ) - const vcsMode = createMemo(() => { - const value = mode() - return value === "git" || value === "branch" ? value : undefined - }) - const vcsKey = createMemo( - () => - [ - server.scope, - "session-vcs", - location().directory, - vcs()?.branch.current ?? "", - vcs()?.branch.default ?? "", - ] as const, - ) - const wantsReview = createMemo(() => - input.session.isDesktop() - ? input.screen.files.open() || - (input.screen.review.open() && - (input.session.tabs.activeTab() === "review" || !!input.session.tabs.activeFileTab())) - : mobileChanges(), - ) - const vcsQuery = createQuery(() => { - const value = vcsMode() - return { - queryKey: [...vcsKey(), value] as const, - enabled: server.connection.status() === "connected" && wantsReview() && !!input.session.project()?.vcs, - refetchOnMount: "always" as const, - refetchOnWindowFocus: true, - queryFn: value - ? () => - server.api.vcs - .diff({ - location: { directory: location().directory }, - mode: value === "git" ? "working" : value, - }) - .then((result) => result.data) - : skipToken, - } - }) - const detailsQuery = createQuery(() => ({ - queryKey: [server.scope, "session-details", input.session.workspace.directory()] as const, - enabled: state.detailsOpen && server.connection.status() === "connected" && !!input.session.project()?.vcs, - queryFn: () => - server.api.vcs - .diff({ location: { directory: input.session.workspace.directory() }, mode: "working" }) - .then((result) => result.data) - .catch((error) => { - console.debug("[session-review] failed to load session details diff", { error }) - return [] - }), - })) - const refresh = debounce(() => { - void queryClient.invalidateQueries({ queryKey: vcsKey() }) - void queryClient.invalidateQueries({ - queryKey: [server.scope, "session-details", input.session.workspace.directory()], - }) - }, 100) - createEffect(() => { - const stop = location().event.listen((event) => { - if (event.type === "filesystem.changed") refresh() - }) - onCleanup(stop) - }) - createEffect( - on( - () => input.screen.review.open() || mobileChanges(), - (open, previous) => { - if (!open || previous || !input.screen.files.open() || vcsQuery.isFetching) return - refresh() - }, - { defer: true }, - ), - ) - const diffs = () => { - if (mode() === "git" || mode() === "branch") return vcsQuery.isFetched ? (vcsQuery.data ?? []) : [] - return [] - } - const activeFile = () => { - const list = diffs() - const selected = selectedFile() - if (selected && list.some((diff) => diff.file === selected)) return selected - return list[0]?.file - } - const count = () => diffs().length - const hasChanges = () => count() > 0 - const ready = () => { - if (mode() === "git" || mode() === "branch") return !vcsQuery.isPending - return true - } - const loadDiff = async (path: string, version?: number): Promise => { - const value = vcsMode() - if (!value) return undefined - const root = reviewRootDirectory(input.session.project()?.worktree ?? location().directory) - const directory = reviewDiffDirectory(root, path) - const source = diffs().find((diff) => diff.file === path) - const valid = (diff: FileDiffInfo | undefined): FileDiffInfo | undefined => { - if (!diff || !source) return undefined - if (diff.additions !== source.additions || diff.deletions !== source.deletions) return undefined - if (reviewDiffNeedsLoad(diff)) return undefined - return diff - } - const request = (scope: string, context?: number) => - queryClient - .fetchQuery({ - queryKey: [server.scope, ...vcsKey(), value, "directory", scope, context, version] as const, - staleTime: Number.POSITIVE_INFINITY, - retry: 2, - queryFn: () => - server.api.vcs - .diff({ - location: { directory: scope }, - mode: value === "git" ? "working" : value, - context, - }) - .then((result) => result.data), - }) - .then((result) => result.find((diff) => diff.file === path)) - - if (directory !== root) { - try { - const scoped = valid(await request(directory)) - if (scoped) return scoped - } catch (error) { - console.debug("[session-review] failed to load scoped vcs diff", { mode: value, path, directory, error }) - } - } - try { - const bounded = valid(await request(root, 3)) - if (bounded) return bounded - } catch (error) { - console.debug("[session-review] failed to load bounded vcs diff", { mode: value, path, root, error }) - } - return undefined - } - const selectionPreview = (path: string, selection: FileSelection): string | undefined => { - const content = file.get(path)?.content?.content - if (!content) return undefined - return previewSelectedLines(content, { start: selection.startLine, end: selection.endLine }) - } - const addComment = (comment: SessionReviewLineComment) => { - const selection = selectionFromLines(comment.selection) - const saved = comments.add({ file: comment.file, selection: comment.selection, comment: comment.comment }) - prompt.context.add({ - type: "file", - path: comment.file, - selection, - comment: comment.comment, - commentID: saved.id, - commentOrigin: "review", - preview: comment.preview ?? selectionPreview(comment.file, selection), - }) - } - const updateComment = (comment: { - id: string - file: string - selection: SelectedLineRange - comment: string - preview?: string - }) => { - comments.update(comment.file, comment.id, comment.comment) - prompt.context.updateComment(comment.file, comment.id, { - comment: comment.comment, - ...(comment.preview ? { preview: comment.preview } : {}), - }) - } - const removeComment = (comment: { id: string; file: string }) => { - comments.remove(comment.file, comment.id) - prompt.context.removeComment(comment.file, comment.id) - } - const commentActions = createMemo(() => ({ - moreLabel: language.t("common.moreOptions"), - editLabel: language.t("common.edit"), - deleteLabel: language.t("common.delete"), - saveLabel: language.t("common.save"), - })) - const showAllFiles = () => { - if (layout.fileTree.tab() !== "changes") return - layout.fileTree.setTab("all") - } - const open = () => { - if (!input.session.layout.view().reviewPanel.opened()) input.session.layout.view().reviewPanel.open() - } - const openFile = createOpenReviewFile({ - showAllFiles, - tabForPath: file.tab, - openTab: (tab) => input.session.layout.tabs().open(tab), - setActive: (tab) => input.session.layout.tabs().setActive(tab), - loadFile: file.load, - }) - const reviewDiffId = (path: string): string | undefined => { - const sum = checksum(path) - if (!sum) return undefined - return `session-review-diff-${sum}` - } - const reviewDiffTop = (path: string): number | undefined => { - if (!state.scroll) return undefined - const id = reviewDiffId(path) - if (!id) return undefined - const element = document.getElementById(id) - if (!(element instanceof HTMLElement) || !state.scroll.contains(element)) return undefined - const target = element.getBoundingClientRect() - const root = state.scroll.getBoundingClientRect() - return target.top - root.top + state.scroll.scrollTop - } - const scrollToFile = (path: string) => { - if (!state.scroll) return false - const top = reviewDiffTop(path) - if (top === undefined) return false - input.session.layout.view().setScroll("review", { x: state.scroll.scrollLeft, y: top }) - state.scroll.scrollTo({ top, behavior: "auto" }) - return true - } - const focusFile = (path: string) => { - open() - input.session.layout.view().review.openPath(path) - input.session.layout.view().review.setFile(path) - setState("pendingFile", path) - } - createEffect(() => { - const pending = state.pendingFile - if (!pending || !state.scroll || !ready()) return - const attempt = (count: number) => { - if (state.pendingFile !== pending) return - if (count > 60) { - setState("pendingFile", undefined) - return - } - if (!state.scroll || !scrollToFile(pending)) { - requestAnimationFrame(() => attempt(count + 1)) - return - } - const top = reviewDiffTop(pending) - if (top === undefined || Math.abs(state.scroll.scrollTop - top) > 1) { - requestAnimationFrame(() => attempt(count + 1)) - return - } - setState("pendingFile", undefined) - } - requestAnimationFrame(() => attempt(0)) - }) - createEffect( - on( - input.session.tabs.activeFileTab, - (active) => { - if (!active || layout.fileTree.tab() !== "changes") return - showAllFiles() - }, - { defer: true }, - ), - ) - let treeDirectory: string | undefined - createEffect(() => { - const directory = location().directory - if (!input.session.isDesktop() || !layout.fileTree.opened() || server.connection.status() !== "connected") return - layout.fileTree.tab() - const refreshTree = treeDirectory !== directory - treeDirectory = directory - void (refreshTree ? file.tree.refresh("") : file.tree.list("")) - }) - createEffect( - on( - () => location().directory, - () => { - const tab = input.session.tabs.activeFileTab() - if (!tab) return - const path = file.pathFromTab(tab) - if (path) void file.load(path, { force: true }) - }, - { defer: true }, - ), - ) - createEffect(() => { - if (!layout.ready() || server.connection.status() !== "connected" || !input.session.project()) return - const list = options() - const value = mode() - if (list.includes(value)) return - const next = list[0] - if (next) input.session.layout.view().review.setMode(next) - }) - createEffect( - on( - () => data.session.status(input.session.identity.params.id ?? ""), - (next, previous) => { - if (next !== "idle" || previous === undefined || previous === "idle") return - refresh() - }, - { defer: true }, - ), - ) - createEffect( - on( - input.session.identity.sessionKey, - () => { - setState("mobileTab", "session") - setState("scroll", undefined) - setState("pendingFile", undefined) - }, - { defer: true }, - ), - ) - - const panelState = createReviewPanelState() - const panelRendered = createMemo((previous) => previous || !input.deferRender(), false) - return { - activeFile, - canReview: input.session.canReview, - comments: { - actions: commentActions, - add: addComment, - all: comments.all, - focus: comments.focus, - mentions: file.searchFilesAndDirectories, - remove: removeComment, - changeFocus: (focus: { file: string; id: string } | null) => { - if (!focus) { - const current = comments.focus() - if (current && diffs().some((diff) => diff.file === current.file)) focusFile(current.file) - } - comments.setFocus(focus) - }, - setFocus: comments.setFocus, - update: updateComment, - }, - count, - deferRender: input.deferRender, - details: { - diffs: () => (detailsQuery.isFetched ? (detailsQuery.data ?? []) : undefined), - setOpen: (open: boolean) => setState("detailsOpen", open), - }, - diffVersion: () => vcsQuery.dataUpdatedAt, - diffStyle: { - current: layout.review.diffStyle, - set: (style: DiffStyle) => layout.review.setDiffStyle(style), - }, - diffs, - focusFile, - hasChanges, - loadDiff, - mobile: { - changes: mobileChanges, - setTab: (tab: "session" | "changes" | "files" | "usage") => setState("mobileTab", tab), - tab: () => state.mobileTab, - }, - mode, - noGit: createMemo(() => !!input.session.project() && !input.session.project()?.vcs), - open, - openFile, - options, - panelState, - panelRendered, - ready, - screen: input.screen, - setMode: (value: ChangeMode) => input.session.layout.view().review.setMode(value), - setScroll: (element: HTMLDivElement | undefined) => setState("scroll", element), - view: input.session.layout.view, - } -} - -export type SessionReviewModel = ReturnType diff --git a/packages/app/src/session/review/panel-state.ts b/packages/app/src/session/review/panel-state.ts deleted file mode 100644 index e2339453eaa0..000000000000 --- a/packages/app/src/session/review/panel-state.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { - SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT, - SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX, - SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, - type SessionReviewExpandMode, -} from "@opencode/session-ui/v2/session-review-v2" -import { createSignal } from "solid-js" -import { Schema } from "effect" -import type { Platform } from "@/runtime/platform/platform" -import { Persist, persisted } from "@/runtime/persistence/storage" -import { Persistence } from "@/runtime/persistence/schema" - -const ReviewPanel = Persistence.struct({ - sidebarOpened: Schema.Boolean, - sidebarWidth: Schema.Finite.check( - Schema.isBetween({ minimum: SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, maximum: SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX }), - ), - expandMode: Schema.Literals(["expand", "collapse"]), -}) - -export function createReviewPanelState(platform?: Platform) { - const [store, setStore, , ready] = persisted( - Persist.global("review-panel-v2"), - ReviewPanel, - { sidebarOpened: true, sidebarWidth: SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT, expandMode: "collapse" }, - platform, - ) - // 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, - sidebarTransition: ready, - filter, - setFilter, - expandMode: () => store.expandMode, - setExpandMode: (mode: SessionReviewExpandMode) => setStore("expandMode", mode), - resizeSidebar: (width: number) => - setStore( - "sidebarWidth", - Math.min(SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX, Math.max(SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, width)), - ), - toggleSidebar: () => setStore("sidebarOpened", (opened) => !opened), - } -} - -export type ReviewPanelState = ReturnType diff --git a/packages/app/src/session/review/review-tab.tsx b/packages/app/src/session/review/review-tab.tsx deleted file mode 100644 index 25b855a27226..000000000000 --- a/packages/app/src/session/review/review-tab.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { createEffect, onCleanup, type JSX } from "solid-js" -import { makeEventListener } from "@solid-primitives/event-listener" -import type { FileDiffInfo } from "@opencode/client/promise" -import { SessionReview } from "@opencode/session-ui/session-review" -import type { - SessionReviewCommentActions, - SessionReviewCommentDelete, - SessionReviewCommentUpdate, -} from "@opencode/session-ui/session-review" -import type { SelectedLineRange } from "@/workspaces/files/model" -import { useWorkspaceLocation } from "@/workspaces/location" -import { useServerSDK } from "@/runtime/server/client" -import { useLayout } from "@/shell/state/layout" -import type { LineComment } from "@/composer/comments" - -export type DiffStyle = "unified" | "split" - -type ReviewDiff = FileDiffInfo - -export interface SessionReviewTabProps { - title?: JSX.Element - empty?: JSX.Element - diffs: ReviewDiff[] - view: ReturnType["view"]> - diffStyle: DiffStyle - changeSummary?: boolean - overflow?: "wrap" | "scroll" - disableLineNumbers?: boolean - onDiffStyleChange?: (style: DiffStyle) => void - onViewFile?: (file: string) => void - onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void - onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void - onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void - lineCommentActions?: SessionReviewCommentActions - comments?: LineComment[] - focusedComment?: { file: string; id: string } | null - onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void - focusedFile?: string - onScrollRef?: (el: HTMLDivElement | undefined) => void - commentMentions?: { - items: (query: string) => string[] | Promise - } - classes?: { - root?: string - header?: string - container?: string - } -} - -export function SessionReviewTab(props: SessionReviewTabProps) { - let scroll: HTMLDivElement | undefined - let restoreFrame: number | undefined - let userInteracted = false - let restored: { x: number; y: number } | undefined - - const sdk = useWorkspaceLocation() - const serverSDK = useServerSDK() - const layout = useLayout() - - const readFile = async (path: string) => { - return serverSDK.api.file - .read({ path, location: { directory: sdk().directory } }) - .then((data) => ({ type: "text" as const, content: new TextDecoder().decode(data) })) - .catch((error) => { - console.debug("[session-review] failed to read file", { path, error }) - return undefined - }) - } - - const handleInteraction = () => { - userInteracted = true - - if (restoreFrame !== undefined) { - cancelAnimationFrame(restoreFrame) - restoreFrame = undefined - } - } - - const doRestore = () => { - restoreFrame = undefined - const el = scroll - if (!el || !layout.ready() || userInteracted) return - if (el.clientHeight === 0 || el.clientWidth === 0) return - - const s = props.view.scroll("review") - if (!s || (s.x === 0 && s.y === 0)) return - - const maxY = Math.max(0, el.scrollHeight - el.clientHeight) - const maxX = Math.max(0, el.scrollWidth - el.clientWidth) - - const targetY = Math.min(s.y, maxY) - const targetX = Math.min(s.x, maxX) - - if (el.scrollTop === targetY && el.scrollLeft === targetX) return - - if (el.scrollTop !== targetY) el.scrollTop = targetY - if (el.scrollLeft !== targetX) el.scrollLeft = targetX - restored = { x: el.scrollLeft, y: el.scrollTop } - } - - const queueRestore = () => { - if (userInteracted || restoreFrame !== undefined) return - restoreFrame = requestAnimationFrame(doRestore) - } - - const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { - const el = event.currentTarget - const prev = restored - if (prev && el.scrollTop === prev.y && el.scrollLeft === prev.x) { - restored = undefined - return - } - - restored = undefined - handleInteraction() - if (!layout.ready()) return - if (el.clientHeight === 0 || el.clientWidth === 0) return - - props.view.setScroll("review", { - x: el.scrollLeft, - y: el.scrollTop, - }) - } - - createEffect(() => { - props.diffs.length - props.diffStyle - props.overflow - if (!layout.ready()) return - queueRestore() - }) - - onCleanup(() => { - if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame) - props.onScrollRef?.(undefined) - }) - - return ( - { - scroll = el - makeEventListener(el, "wheel", handleInteraction, { passive: true, capture: true }) - makeEventListener(el, "mousewheel", handleInteraction, { passive: true, capture: true }) - makeEventListener(el, "pointerdown", handleInteraction, { passive: true, capture: true }) - makeEventListener(el, "touchstart", handleInteraction, { passive: true, capture: true }) - makeEventListener(el, "keydown", handleInteraction, { capture: true }) - props.onScrollRef?.(el) - queueRestore() - }} - onScroll={handleScroll} - onDiffRendered={queueRestore} - open={props.view.review.open()} - onOpenChange={props.view.review.setOpen} - classes={{ - root: props.classes?.root ?? "pr-3", - header: props.classes?.header ?? "px-3", - container: props.classes?.container ?? "pl-3", - }} - diffs={props.diffs} - diffStyle={props.diffStyle} - changeSummary={props.changeSummary} - overflow={props.overflow} - disableLineNumbers={props.disableLineNumbers} - onDiffStyleChange={props.onDiffStyleChange} - onViewFile={props.onViewFile} - focusedFile={props.focusedFile} - readFile={readFile} - onLineComment={props.onLineComment} - onLineCommentUpdate={props.onLineCommentUpdate} - onLineCommentDelete={props.onLineCommentDelete} - lineCommentActions={props.lineCommentActions} - lineCommentMention={props.commentMentions} - comments={props.comments} - focusedComment={props.focusedComment} - onFocusedCommentChange={props.onFocusedCommentChange} - /> - ) -} diff --git a/packages/app/src/session/review/view.tsx b/packages/app/src/session/review/view.tsx deleted file mode 100644 index 53fd6aef2e78..000000000000 --- a/packages/app/src/session/review/view.tsx +++ /dev/null @@ -1,324 +0,0 @@ -import { SessionReviewEmptyChangesV2 } from "@opencode/session-ui/v2/session-review-empty-changes-v2" -import { SessionReviewV2SidebarToggle } from "@opencode/session-ui/v2/session-review-v2" -import { Select } from "@opencode/ui/select" -import { Tabs } from "@opencode/ui/tabs" -import { Icon } from "@opencode/ui/icon" -import { IconButton } from "@opencode/ui/icon-button" -import { Menu } from "@opencode/ui/menu" -import { For, Match, Show, Suspense, Switch, lazy, createEffect, onCleanup, type JSX } from "solid-js" -import { createStore } from "solid-js/store" -import { useLanguage } from "@/runtime/i18n/language" -import { useSettings } from "@/settings/model" -import { SessionSidePanel } from "../files/session-side-panel" -import { ReviewPanel } from "./panel" -import { SessionReviewTab } from "./review-tab" -import type { ChangeMode, SessionReviewModel } from "./model" -import type { SessionExtensions } from "@/extensions/session" - -const StatusDrawer = lazy(async () => { - const { StatusDrawer } = await import("@/shell/status/status-drawer") - return { default: StatusDrawer } -}) - -const MobilePanelDrawer = lazy(async () => { - const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer") - return { default: MobilePanelDrawer } -}) - -export function SessionMobileViewTabs(props: { - current: "session" | "changes" | "files" | "usage" | "terminal" - onSelect: (view: "session" | "changes" | "files" | "usage" | "terminal") => void - details?: (close: () => void) => JSX.Element - onDetailsOpenChange?: (open: boolean) => void -}) { - const language = useLanguage() - const [store, setStore] = createStore({ - menu: false, - status: false, - statusLoaded: false, - details: false, - detailsLoaded: false, - pending: undefined as "status" | "details" | undefined, - }) - createEffect(() => props.onDetailsOpenChange?.(store.details)) - onCleanup(() => props.onDetailsOpenChange?.(false)) - let trigger: HTMLButtonElement | undefined - return ( -
- - - - {(view) => ( - props.onSelect(view)} - > - {view === "session" - ? language.t("session.tab.session") - : view === "changes" - ? language.plural("session.review.change", 0) - : view === "files" - ? language.t("session.tab.files") - : language.t("terminal.title")} - - )} - - - - setStore("menu", open)} - > - { - trigger = element - }} - icon={} - variant="ghost-muted" - size="normal" - class="mx-1.5 shrink-0" - state={props.current === "usage" || store.menu ? "pressed" : undefined} - aria-label={language.t("common.moreOptions")} - /> - - { - if (!store.pending) return - event.preventDefault() - if (store.pending === "status") setStore({ status: true, statusLoaded: true }) - if (store.pending === "details") setStore({ details: true, detailsLoaded: true }) - setStore("pending", undefined) - }} - > - props.onSelect("usage")}>{language.t("session.tab.usage")} - - setStore({ pending: "details", menu: false })}> - {language.t("session.summary.title")} - - - setStore({ pending: "status", menu: false })}> - {language.t("status.popover.trigger")} - - - - - - - setStore("status", open)} - returnFocus={() => trigger} - /> - - - - - setStore("details", open)} - returnFocus={() => trigger} - > - {props.details?.(() => setStore("details", false))} - - - -
- ) -} - -export function SessionMobileReview(props: { review: SessionReviewModel }) { - return ( -
- -
- ) -} - -export function SessionDesktopReview(props: { - review: SessionReviewModel - extensions: SessionExtensions - present?: boolean -}) { - return ( - - } - reviewSidebarToggle={(disabled) => ( - - )} - fileBrowserState={props.review.panelState} - activeDiff={props.review.activeFile()} - focusReviewDiff={props.review.focusFile} - reviewPresent={props.present} - size={props.review.screen.size} - stacked={props.review.screen.side.layout().stacked} - /> - - ) -} - -function ReviewContent(props: { review: SessionReviewModel }) { - const settings = useSettings() - return ( - - } - empty={} - diffs={props.review.diffs()} - view={props.review.view()} - diffStyle="unified" - changeSummary - disableLineNumbers={false} - overflow={settings.general.mobileDiffWrap() ? "wrap" : "scroll"} - onViewFile={(file) => { - props.review.openFile(file) - props.review.mobile.setTab("files") - }} - onScrollRef={props.review.setScroll} - focusedFile={props.review.activeFile()} - onLineComment={props.review.comments.add} - onLineCommentUpdate={props.review.comments.update} - onLineCommentDelete={props.review.comments.remove} - lineCommentActions={props.review.comments.actions()} - commentMentions={{ items: props.review.comments.mentions }} - comments={props.review.comments.all()} - focusedComment={props.review.comments.focus()} - onFocusedCommentChange={props.review.comments.setFocus} - classes={{ - root: "[&_[data-slot=session-review-list]]:pb-0 [&_[data-slot=accordion-trigger]]:!rounded-none [&_[data-slot=accordion-trigger]]:!border-x-0 [&_[data-slot=accordion-item]:first-child_[data-slot=accordion-trigger]]:!border-t-0 [&_[data-slot=accordion-item]:last-child:not([data-expanded])_[data-slot=accordion-trigger]]:!border-b-0 [&_[data-slot=accordion-item]:last-child_[data-slot=accordion-content]]:!border-b-0 [&_[data-slot=accordion-item]:last-child_[data-slot=session-review-diff-placeholder]]:!border-b-0 [&_[data-slot=accordion-content]]:!rounded-none [&_[data-slot=accordion-content]]:!border-x-0 [&_[data-slot=session-review-diff-placeholder]]:!rounded-none [&_[data-slot=session-review-diff-placeholder]]:!border-x-0", - header: - "!px-2 !h-10 !pb-0 relative before:pointer-events-none before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-v2-border-border-base before:content-['']", - container: "!px-0", - }} - /> - - ) -} - -function ReviewPanelContent(props: { review: SessionReviewModel }) { - return ( -
- - } - empty={} - diffs={props.review.diffs()} - diffsReady={props.review.ready()} - diffVersion={props.review.diffVersion()} - loadDiff={props.review.loadDiff} - activeFile={props.review.activeFile()} - onSelectFile={props.review.focusFile} - diffStyle={props.review.diffStyle.current()} - onDiffStyleChange={props.review.diffStyle.set} - state={props.review.panelState} - onLineComment={props.review.comments.add} - onLineCommentUpdate={props.review.comments.update} - onLineCommentDelete={props.review.comments.remove} - lineCommentActions={props.review.comments.actions()} - comments={props.review.comments.all()} - focusedComment={props.review.comments.focus()} - onFocusedCommentChange={props.review.comments.changeFocus} - /> - -
- ) -} - -function ReviewTitle(props: { review: SessionReviewModel }) { - const language = useLanguage() - const label = (option: ChangeMode) => { - if (option === "git") return language.t("ui.sessionReview.title.git") - if (option === "branch") return language.t("ui.sessionReview.title.branch") - return language.t("ui.sessionReview.title.lastTurn") - } - return ( - - + language.t(option === "git" ? "ui.sessionReview.title.git" : "ui.sessionReview.title.branch") + } + onSelect={(value) => value && props.review.setMode(value)} + /> + + } + empty={ + + {language.t("session.review.loadingChanges")} +
+ } + > + + + } + diffs={props.review.diffs()} + diffsReady={props.review.ready()} + diffVersion={props.review.diffVersion()} + loadDiff={props.review.loadDiff} + activeFile={props.review.activeFile()} + onSelectFile={props.review.focusFile} + diffStyle={props.review.diffStyle()} + onDiffStyleChange={props.review.setDiffStyle} + state={props.review.panelState} + onLineComment={props.review.comments.add} + onLineCommentUpdate={props.review.comments.update} + onLineCommentDelete={props.review.comments.remove} + lineCommentActions={props.review.comments.actions()} + comments={props.review.comments.all()} + focusedComment={props.review.comments.focus()} + onFocusedCommentChange={props.review.comments.setFocus} + /> + + ) +} diff --git a/packages/plugin-review-desktop/test/boundary.test.ts b/packages/plugin-review-desktop/test/boundary.test.ts new file mode 100644 index 000000000000..2b84ac681d64 --- /dev/null +++ b/packages/plugin-review-desktop/test/boundary.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from "bun:test" +import path from "node:path" + +test("review and file viewers use only public renderer capabilities", async () => { + const root = path.join(import.meta.dir, "../src") + const files = await Array.fromAsync(new Bun.Glob("**/*.{ts,tsx}").scan(root)) + const sources = await Promise.all(files.map(async (file) => ({ file, source: await Bun.file(path.join(root, file)).text() }))) + expect(sources.filter(({ source }) => /["'](?:@\/|@opencode\/(?:app|desktop|core|server)(?:[\/"']))/.test(source)).map(({ file }) => file)).toEqual([]) +}) diff --git a/packages/plugin-review-desktop/tsconfig.json b/packages/plugin-review-desktop/tsconfig.json new file mode 100644 index 000000000000..513120fc4b68 --- /dev/null +++ b/packages/plugin-review-desktop/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "preserve", + "jsxImportSource": "solid-js", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["bun"] + }, + "include": ["src", "test"] +} diff --git a/packages/util/src/file-manager.ts b/packages/util/src/file-manager.ts new file mode 100644 index 000000000000..2fba23cc5abc --- /dev/null +++ b/packages/util/src/file-manager.ts @@ -0,0 +1,24 @@ +export type FileManagerOS = "macos" | "windows" | "linux" | "unknown" + +export function fileManagerApp(os: FileManagerOS): { + label: "session.header.open.finder" | "session.header.open.fileExplorer" | "session.header.open.fileManager" + actionLabel: + | "session.header.reveal.finder" + | "session.header.reveal.fileExplorer" + | "session.header.reveal.containingFolder" + icon: "finder" | "file-explorer" +} { + if (os === "macos") + return { label: "session.header.open.finder", actionLabel: "session.header.reveal.finder", icon: "finder" } + if (os === "windows") + return { + label: "session.header.open.fileExplorer", + actionLabel: "session.header.reveal.fileExplorer", + icon: "file-explorer", + } + return { + label: "session.header.open.fileManager", + actionLabel: "session.header.reveal.containingFolder", + icon: "finder", + } +} From 04e3fc280d3fb419f30f7b24a292132d168c8ed8 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:49:00 +1000 Subject: [PATCH 2/5] fix(review): retain mobile diff presentation and wrapping --- .../file-browser-sidebar-tab-switch.spec.ts | 3 + .../src/files/session-mobile-files.css | 32 +++++-- .../src/files/session-mobile-files.tsx | 75 +++++++++++++--- .../src/files/virtual-scroll.ts | 2 +- packages/plugin-review-desktop/src/index.tsx | 48 +++++++---- .../src/review/mobile.tsx | 85 +++++++++++++++---- .../test/boundary.test.ts | 10 ++- 7 files changed, 199 insertions(+), 56 deletions(-) diff --git a/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts b/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts index 0c5c2b5bc2ce..26bd66605633 100644 --- a/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts +++ b/packages/app/e2e/regression/file-browser-sidebar-tab-switch.spec.ts @@ -59,6 +59,9 @@ test("keeps the file-browser sidebar mounted when switching file tabs", async ({ expect(await readProbe(page)).toBe(PROBE) await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled) }) +test.afterEach(async ({ page }, info) => { + if (info.status === "passed") await page.screenshot({ path: info.outputPath("review-files-extension.png") }) +}) type Probed = HTMLElement & { __e2eProbe?: string } diff --git a/packages/plugin-review-desktop/src/files/session-mobile-files.css b/packages/plugin-review-desktop/src/files/session-mobile-files.css index 563be4f8c533..099b2ed905a9 100644 --- a/packages/plugin-review-desktop/src/files/session-mobile-files.css +++ b/packages/plugin-review-desktop/src/files/session-mobile-files.css @@ -1,11 +1,29 @@ -[data-slot="session-mobile-files"] [data-slot="session-mobile-files-header"] [data-component="tabs-v2"][data-variant="normal"][data-orientation="horizontal"] [data-slot="tabs-v2-list"] { +[data-slot="session-mobile-files"] + [data-slot="session-mobile-files-header"] + [data-component="tabs-v2"][data-variant="normal"][data-orientation="horizontal"] + [data-slot="tabs-v2-list"] { position: static; - &::before { inset-inline-start: 0; width: 100%; } + &::before { + inset-inline-start: 0; + width: 100%; + } +} +[data-slot="session-mobile-files"] [data-component="line-comment-v2"] { + max-width: none; } -[data-slot="session-mobile-files"] [data-component="line-comment-v2"] { max-width: none; } [data-slot="session-mobile-files"][data-browsing="true"] { - [data-component="session-review-v2-sidebar-root"] { width: 100%; } - [data-slot="session-review-v2-sidebar"] { width: 100% !important; border-inline-end: 0; } - [data-slot="session-review-v2-preview"] { display: none; } + [data-component="session-review-v2-sidebar-root"] { + width: 100%; + } + [data-slot="session-review-v2-sidebar"] { + width: 100% !important; + border-inline-end: 0; + } + [data-slot="session-review-v2-preview"] { + display: none; + } +} +[data-slot="session-mobile-files"] [data-slot="tabs-v2-trigger-close-button"] [data-slot="tabs-close-button"] { + width: 32px; + height: 36px; } -[data-slot="session-mobile-files"] [data-slot="tabs-v2-trigger-close-button"] [data-slot="tabs-close-button"] { width: 32px; height: 36px; } diff --git a/packages/plugin-review-desktop/src/files/session-mobile-files.tsx b/packages/plugin-review-desktop/src/files/session-mobile-files.tsx index 01878c9f1b66..5ad940a6d785 100644 --- a/packages/plugin-review-desktop/src/files/session-mobile-files.tsx +++ b/packages/plugin-review-desktop/src/files/session-mobile-files.tsx @@ -14,7 +14,7 @@ export function SessionMobileFiles() { const tabs = environment.services.view.tabs const language = useLanguage() const opened = createMemo(() => tabs.all().filter((tab) => !!file.pathFromTab(tab))) - const activeFileTab = () => opened().includes(tabs.active() ?? "") ? tabs.active() : opened()[0] + const activeFileTab = () => (opened().includes(tabs.active() ?? "") ? tabs.active() : opened()[0]) const [store, setStore] = createStore({ browsing: !activeFileTab() }) const browsing = () => store.browsing || !activeFileTab() const active = createMemo(() => file.pathFromTab(activeFileTab() ?? "")) @@ -26,16 +26,67 @@ export function SessionMobileFiles() { void file.load(path) setStore("browsing", false) } - return
-
- - { if (browsing()) return; const path = file.pathFromTab(tab); if (path) open(path) }} variant="line" class="min-w-0 flex-1 !h-auto"> - - {(tab) => open(file.pathFromTab(tab)!)} class="shrink-0 max-w-48" classes={{ button: "min-w-0" }} closeButton={ tabs.close(tab)} />}>{getFilename(file.pathFromTab(tab) ?? tab)}} - - + return ( +
+
+ + { + if (browsing()) return + const path = file.pathFromTab(tab) + if (path) open(path) + }} + variant="line" + class="min-w-0 flex-1 !h-auto" + > + + + {(tab) => ( + open(file.pathFromTab(tab)!)} + class="shrink-0 max-w-48" + classes={{ button: "min-w-0" }} + closeButton={ + tabs.close(tab)} /> + } + > + + {getFilename(file.pathFromTab(tab) ?? tab)} + + + )} + + + +
+
+ 240, + sidebarTransition: () => false, + resizeSidebar: () => undefined, + toggleSidebar: () => setStore("browsing", !browsing()), + }} + onSelect={open} + onSelectPermanent={open} + /> +
-
240, sidebarTransition: () => false, resizeSidebar: () => undefined, toggleSidebar: () => setStore("browsing", !browsing()) }} onSelect={open} onSelectPermanent={open} />
-
+ ) } diff --git a/packages/plugin-review-desktop/src/files/virtual-scroll.ts b/packages/plugin-review-desktop/src/files/virtual-scroll.ts index 8e532b7e3031..c2db12a1edea 100644 --- a/packages/plugin-review-desktop/src/files/virtual-scroll.ts +++ b/packages/plugin-review-desktop/src/files/virtual-scroll.ts @@ -9,5 +9,5 @@ export function createVirtualScrollElement(root: Accessor queueMicrotask(() => setMounted(true))) - return () => mounted() ? virtualScrollElement(root()) : null + return () => (mounted() ? virtualScrollElement(root()) : null) } diff --git a/packages/plugin-review-desktop/src/index.tsx b/packages/plugin-review-desktop/src/index.tsx index 5e79173a8840..d848f5fb8dff 100644 --- a/packages/plugin-review-desktop/src/index.tsx +++ b/packages/plugin-review-desktop/src/index.tsx @@ -75,7 +75,15 @@ export const ReviewDesktop = Plugin.define({ append: "session.panel.tools", render: ({ session }) => ( - } variant="ghost-muted" size="large" aria-label={ctx.i18n.t("command.file.open")} onClick={() => ctx.ui.panel.open("files", session)} /> + + } + variant="ghost-muted" + size="large" + aria-label={ctx.i18n.t("command.file.open")} + onClick={() => ctx.ui.panel.open("files", session)} + /> + session.services?.files.directory ?? ""} /> ), @@ -110,18 +118,20 @@ function Declarations(props: { setModel(model: SessionReviewModel | undefined): const files = environment.services.files const view = environment.services.view const opened = createMemo(() => view.tabs.all().filter((tab) => !!files.pathFromTab(tab))) - const content = () => }> - review.openFile(path, false)} - onSelectPermanent={review.openFile} - mobile={!view.desktop()} - /> - + const content = () => ( + }> + review.openFile(path, false)} + onSelectPermanent={review.openFile} + mobile={!view.desktop()} + /> + + ) return ( <> - }> + }> + + { if (interacted || frame !== undefined) return - frame = requestAnimationFrame(() => { frame = undefined; const saved = view.scroll("review"); if (scroll && saved) { scroll.scrollTop = saved.y; scroll.scrollLeft = saved.x } }) + frame = requestAnimationFrame(() => { + frame = undefined + const saved = view.scroll("review") + if (scroll && saved) { + scroll.scrollTop = saved.y + scroll.scrollLeft = saved.x + } + }) } - createEffect(() => { props.review.diffs(); if (view.ready()) restore() }) - onCleanup(() => { if (frame !== undefined) cancelAnimationFrame(frame) }) - return {ctx.i18n.t(props.review.ready() ? "session.review.noChanges" : "session.review.loadingChanges")}
} - diffs={props.review.diffs()} diffStyle="unified" changeSummary overflow="scroll" disableLineNumbers={false} - open={state.open} onOpenChange={(value) => update((draft) => { draft.open = value })} - scrollRef={(element) => { scroll = element; restore() }} onDiffRendered={restore} - onScroll={(event) => { interacted = true; view.setScroll("review", { x: event.currentTarget.scrollLeft, y: event.currentTarget.scrollTop }) }} - onViewFile={props.review.openFile} - readFile={(path) => environment.session.server.client.file.read({ path, location: environment.session.location }).then((data) => ({ type: "text" as const, content: new TextDecoder().decode(data) }))} - onLineComment={props.review.comments.add} onLineCommentUpdate={props.review.comments.update} onLineCommentDelete={props.review.comments.remove} - lineCommentActions={props.review.comments.actions()} lineCommentMention={{ items: environment.services.files.searchFilesAndDirectories }} comments={props.review.comments.all()} focusedComment={props.review.comments.focus()} onFocusedCommentChange={props.review.comments.setFocus} - classes={{ root: "[&_[data-slot=session-review-list]]:pb-0 [&_[data-slot=accordion-trigger]]:!rounded-none [&_[data-slot=accordion-trigger]]:!border-x-0", header: "!px-2 !h-10 !pb-0", container: "!px-0" }} - /> + createEffect(() => { + props.review.diffs() + if (view.ready()) restore() + }) + onCleanup(() => { + if (frame !== undefined) cancelAnimationFrame(frame) + }) + return ( + + {ctx.i18n.t(props.review.ready() ? "session.review.noChanges" : "session.review.loadingChanges")} + + } + diffs={props.review.diffs()} + diffStyle="unified" + changeSummary + overflow={environment.services.display.wrapDiff() ? "wrap" : "scroll"} + disableLineNumbers={false} + open={state.open} + onOpenChange={(value) => + update((draft) => { + draft.open = value + }) + } + scrollRef={(element) => { + scroll = element + restore() + }} + onDiffRendered={restore} + onScroll={(event) => { + interacted = true + view.setScroll("review", { x: event.currentTarget.scrollLeft, y: event.currentTarget.scrollTop }) + }} + onViewFile={props.review.openFile} + readFile={(path) => + environment.session.server.client.file + .read({ path, location: environment.session.location }) + .then((data) => ({ type: "text" as const, content: new TextDecoder().decode(data) })) + } + onLineComment={props.review.comments.add} + onLineCommentUpdate={props.review.comments.update} + onLineCommentDelete={props.review.comments.remove} + lineCommentActions={props.review.comments.actions()} + lineCommentMention={{ items: environment.services.files.searchFilesAndDirectories }} + comments={props.review.comments.all()} + focusedComment={props.review.comments.focus()} + onFocusedCommentChange={props.review.comments.setFocus} + classes={{ + root: "[&_[data-slot=session-review-list]]:pb-0 [&_[data-slot=accordion-trigger]]:!rounded-none [&_[data-slot=accordion-trigger]]:!border-x-0 [&_[data-slot=accordion-item]:first-child_[data-slot=accordion-trigger]]:!border-t-0 [&_[data-slot=accordion-item]:last-child:not([data-expanded])_[data-slot=accordion-trigger]]:!border-b-0 [&_[data-slot=accordion-item]:last-child_[data-slot=accordion-content]]:!border-b-0 [&_[data-slot=accordion-item]:last-child_[data-slot=session-review-diff-placeholder]]:!border-b-0 [&_[data-slot=accordion-content]]:!rounded-none [&_[data-slot=accordion-content]]:!border-x-0 [&_[data-slot=session-review-diff-placeholder]]:!rounded-none [&_[data-slot=session-review-diff-placeholder]]:!border-x-0", + header: + "!px-2 !h-10 !pb-0 relative before:pointer-events-none before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-v2-border-border-base before:content-['']", + container: "!px-0", + }} + /> + ) } diff --git a/packages/plugin-review-desktop/test/boundary.test.ts b/packages/plugin-review-desktop/test/boundary.test.ts index 2b84ac681d64..632377daf2e3 100644 --- a/packages/plugin-review-desktop/test/boundary.test.ts +++ b/packages/plugin-review-desktop/test/boundary.test.ts @@ -4,6 +4,12 @@ import path from "node:path" test("review and file viewers use only public renderer capabilities", async () => { const root = path.join(import.meta.dir, "../src") const files = await Array.fromAsync(new Bun.Glob("**/*.{ts,tsx}").scan(root)) - const sources = await Promise.all(files.map(async (file) => ({ file, source: await Bun.file(path.join(root, file)).text() }))) - expect(sources.filter(({ source }) => /["'](?:@\/|@opencode\/(?:app|desktop|core|server)(?:[\/"']))/.test(source)).map(({ file }) => file)).toEqual([]) + const sources = await Promise.all( + files.map(async (file) => ({ file, source: await Bun.file(path.join(root, file)).text() })), + ) + expect( + sources + .filter(({ source }) => /["'](?:@\/|@opencode\/(?:app|desktop|core|server)(?:[\/"']))/.test(source)) + .map(({ file }) => file), + ).toEqual([]) }) From 68c1138585ff5232f8e285ad091d97cb27e376e1 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:52:18 +1000 Subject: [PATCH 3/5] refactor(review): compose shared file navigation controls --- .../src/files/file-tree-v2.tsx | 3 +- .../plugin-review-desktop/src/files/list.tsx | 5 +- .../src/files/session-mobile-files.css | 29 ---- .../src/files/session-mobile-files.tsx | 126 +++++++++--------- packages/plugin-review-desktop/src/index.tsx | 12 +- 5 files changed, 78 insertions(+), 97 deletions(-) delete mode 100644 packages/plugin-review-desktop/src/files/session-mobile-files.css diff --git a/packages/plugin-review-desktop/src/files/file-tree-v2.tsx b/packages/plugin-review-desktop/src/files/file-tree-v2.tsx index 617f1ad6bf5d..9a68f4a50105 100644 --- a/packages/plugin-review-desktop/src/files/file-tree-v2.tsx +++ b/packages/plugin-review-desktop/src/files/file-tree-v2.tsx @@ -1,5 +1,6 @@ import { useFile, useWorkspaceLocation, usePlatform } from "../environment" import { FileIcon } from "@opencode/ui/file-icon" +import { FileTreeItem } from "@opencode/ui/file-tree-item" import "@opencode/ui/file-tree.css" import { createEffect, @@ -81,7 +82,7 @@ const FileTreeNodeV2 = ( return ( resolveOpenInAppPath(props.directory ?? "", path)} > - + )} diff --git a/packages/plugin-review-desktop/src/files/session-mobile-files.css b/packages/plugin-review-desktop/src/files/session-mobile-files.css deleted file mode 100644 index 099b2ed905a9..000000000000 --- a/packages/plugin-review-desktop/src/files/session-mobile-files.css +++ /dev/null @@ -1,29 +0,0 @@ -[data-slot="session-mobile-files"] - [data-slot="session-mobile-files-header"] - [data-component="tabs-v2"][data-variant="normal"][data-orientation="horizontal"] - [data-slot="tabs-v2-list"] { - position: static; - &::before { - inset-inline-start: 0; - width: 100%; - } -} -[data-slot="session-mobile-files"] [data-component="line-comment-v2"] { - max-width: none; -} -[data-slot="session-mobile-files"][data-browsing="true"] { - [data-component="session-review-v2-sidebar-root"] { - width: 100%; - } - [data-slot="session-review-v2-sidebar"] { - width: 100% !important; - border-inline-end: 0; - } - [data-slot="session-review-v2-preview"] { - display: none; - } -} -[data-slot="session-mobile-files"] [data-slot="tabs-v2-trigger-close-button"] [data-slot="tabs-close-button"] { - width: 32px; - height: 36px; -} diff --git a/packages/plugin-review-desktop/src/files/session-mobile-files.tsx b/packages/plugin-review-desktop/src/files/session-mobile-files.tsx index 5ad940a6d785..ef26c697ef45 100644 --- a/packages/plugin-review-desktop/src/files/session-mobile-files.tsx +++ b/packages/plugin-review-desktop/src/files/session-mobile-files.tsx @@ -6,7 +6,7 @@ import { createStore } from "solid-js/store" import { useEnvironment, useLanguage } from "../environment" import { SessionFileBrowserTab } from "./session-file-browser-tab" import type { Kind } from "./file-tree-v2" -import "./session-mobile-files.css" +import { SessionMobileFilePanel } from "@opencode/session-ui/v2/session-file-panel-v2" export function SessionMobileFiles() { const environment = useEnvironment() @@ -27,66 +27,68 @@ export function SessionMobileFiles() { setStore("browsing", false) } return ( -
-
- - { - if (browsing()) return - const path = file.pathFromTab(tab) - if (path) open(path) - }} - variant="line" - class="min-w-0 flex-1 !h-auto" - > - - - {(tab) => ( - open(file.pathFromTab(tab)!)} - class="shrink-0 max-w-48" - classes={{ button: "min-w-0" }} - closeButton={ - tabs.close(tab)} /> - } - > - - {getFilename(file.pathFromTab(tab) ?? tab)} - - - )} - - - -
-
- 240, - sidebarTransition: () => false, - resizeSidebar: () => undefined, - toggleSidebar: () => setStore("browsing", !browsing()), - }} - onSelect={open} - onSelectPermanent={open} - /> -
-
+ + + { + if (browsing()) return + const path = file.pathFromTab(tab) + if (path) open(path) + }} + variant="line" + class="min-w-0 flex-1 !h-auto" + > + + + {(tab) => ( + open(file.pathFromTab(tab)!)} + class="shrink-0 max-w-48" + classes={{ button: "min-w-0" }} + closeButton={ + tabs.close(tab)} /> + } + > + + {getFilename(file.pathFromTab(tab) ?? tab)} + + + )} + + + + + } + > + 240, + sidebarTransition: () => false, + resizeSidebar: () => undefined, + toggleSidebar: () => setStore("browsing", !browsing()), + }} + onSelect={open} + onSelectPermanent={open} + /> + ) } diff --git a/packages/plugin-review-desktop/src/index.tsx b/packages/plugin-review-desktop/src/index.tsx index d848f5fb8dff..ca1f44453aad 100644 --- a/packages/plugin-review-desktop/src/index.tsx +++ b/packages/plugin-review-desktop/src/index.tsx @@ -118,12 +118,18 @@ function Declarations(props: { setModel(model: SessionReviewModel | undefined): const files = environment.services.files const view = environment.services.view const opened = createMemo(() => view.tabs.all().filter((tab) => !!files.pathFromTab(tab))) + const fileTab = createMemo((previous) => { + const active = view.tabs.active() + return active && (active === openFileReference || files.pathFromTab(active)) + ? active + : (previous ?? openFileReference) + }) const content = () => ( }> review.openFile(path, false)} From fbb70f1317500d2ef0f132be63828e7987dbdd29 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 9 Sep 2026 08:10:26 +1000 Subject: [PATCH 4/5] chore(review): name the built-in extension in settings --- packages/plugin-review-desktop/src/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/plugin-review-desktop/src/index.tsx b/packages/plugin-review-desktop/src/index.tsx index ca1f44453aad..06d0979b985d 100644 --- a/packages/plugin-review-desktop/src/index.tsx +++ b/packages/plugin-review-desktop/src/index.tsx @@ -23,6 +23,7 @@ const openFileReference = "open-file" export const ReviewDesktop = Plugin.define({ id: "opencode.review", + name: "session.tab.review", setup(ctx) { const [models, setModels] = createStore>({}) ctx.commands.register(() => { From 37218177cf2042fbf7ee3f12f988a44195c7c1bd Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:35:54 +1000 Subject: [PATCH 5/5] test(app): use review extension tab label --- packages/app/e2e/regression/review-terminal-bottom.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/e2e/regression/review-terminal-bottom.spec.ts b/packages/app/e2e/regression/review-terminal-bottom.spec.ts index 86c2488f140c..3d94999d7e37 100644 --- a/packages/app/e2e/regression/review-terminal-bottom.spec.ts +++ b/packages/app/e2e/regression/review-terminal-bottom.spec.ts @@ -139,7 +139,7 @@ test("uses side placement by default and supports the terminal across the bottom await expectSessionReady(page, { server, sessionID, title }) await expect(page.locator("#review-panel")).toBeVisible() await expectTree(page, 2_773, "action.yml") - await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740") + await expect(page.locator("#review-panel").getByRole("tab", { name: "Files Changed 2740", exact: true })).toBeVisible() await page.keyboard.press("Control+Backquote") await expect(page.locator("#terminal-panel")).toBeVisible() await expectTree(page, 2_773, "action.yml")