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/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/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") 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..632377daf2e3 --- /dev/null +++ b/packages/plugin-review-desktop/test/boundary.test.ts @@ -0,0 +1,15 @@ +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", + } +}