feat(desktop): refine review and file panel controls

This commit is contained in:
usrnk1
2026-09-25 11:50:06 +02:00
parent c1c9a13993
commit 17b217300c
10 changed files with 138 additions and 94 deletions
@@ -1,4 +1,5 @@
import { createSignal, For, Show, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { AppIcon } from "@opencode/ui/app-icon"
import { Icon } from "@opencode/ui/icon"
import { Spinner } from "@opencode/ui/spinner"
@@ -6,11 +7,12 @@ import { Menu } from "@opencode/ui/menu"
import { SplitButton, SplitButtonAction, SplitButtonMenuTrigger } from "@opencode/ui/split-button"
import { Tooltip } from "@opencode/ui/tooltip"
import { useLanguage } from "@/runtime/i18n/language"
import { type OpenApp, useOpenInApp } from "@/session/files/open-in-app"
import { type OpenApp, type OpenInAppState } from "@/session/files/open-in-app"
export function OpenInAppButton(props: { directory: () => string }) {
export function OpenInAppButton(props: { directory: () => string; state: OpenInAppState }) {
const language = useLanguage()
const state = useOpenInApp({ path: props.directory })
const state = props.state
const [menu, setMenu] = createStore({ open: false })
return (
<Show when={props.directory() && state.canOpen()}>
@@ -39,8 +41,8 @@ export function OpenInAppButton(props: { directory: () => string }) {
gutter={4}
modal={false}
placement="bottom-end"
open={state.menu.open}
onOpenChange={(open) => state.setMenu("open", open)}
open={menu.open}
onOpenChange={(open) => setMenu("open", open)}
>
<Menu.Trigger
as={SplitButtonMenuTrigger}
@@ -52,7 +54,7 @@ export function OpenInAppButton(props: { directory: () => string }) {
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="open-in-app-v2-menu">
<OpenInAppMenuItemsV2 state={state} close={() => state.setMenu("open", false)} />
<OpenInAppMenuItemsV2 state={state} close={() => setMenu("open", false)} />
</Menu.Content>
</Menu.Portal>
</Menu>
@@ -61,8 +63,6 @@ export function OpenInAppButton(props: { directory: () => string }) {
)
}
type OpenInAppState = ReturnType<typeof useOpenInApp>
function OpenInAppMenuItemsV2(props: {
state: OpenInAppState
path?: () => string
@@ -167,7 +167,6 @@ export function useOpenInApp(input: { path: () => string }) {
})
const [prefs, setPrefs] = persisted(Persist.global("open.app"), OpenAppPreferences, { app: "finder" })
const [menu, setMenu] = createStore({ open: false })
const [openRequest, setOpenRequest] = createStore({
app: undefined as OpenApp | undefined,
})
@@ -225,14 +224,14 @@ export function useOpenInApp(input: { path: () => string }) {
opening,
current,
options,
menu,
setMenu,
openPath,
selectApp,
copyPath,
}
}
export type OpenInAppState = ReturnType<typeof useOpenInApp>
function checkAppExists(platform: ReturnType<typeof usePlatform>, app: string) {
const cached = appExistence.get(app)
if (cached) return cached
@@ -3,7 +3,7 @@ import { createStore } from "solid-js/store"
import { createQuery, keepPreviousData } from "@tanstack/solid-query"
import { Icon } from "@opencode/ui/icon"
import { SessionFilePanelV2, SessionFilePanelV2Empty } from "@opencode/session-ui/v2/session-file-panel-v2"
import { SessionReviewV2Sidebar } from "@opencode/session-ui/v2/session-review-v2"
import { SessionReviewV2Sidebar, SessionReviewV2SidebarToggle } from "@opencode/session-ui/v2/session-review-v2"
import FileTreeV2, { type Kind } from "@/session/files/file-tree-v2"
import { useFile } from "@/workspaces/files/model"
import { useLanguage } from "@/runtime/i18n/language"
@@ -101,12 +101,24 @@ export function SessionFileBrowserTab(props: {
// unmounts the whole panel on every file-tab switch and resets sidebar scroll.
return (
<SessionFilePanelV2
toolbar={false}
toolbar={!props.mobile && !props.placeholder && !sidebarOpened()}
toolbarStart={<span class="truncate font-[530]">{title()}</span>}
sidebarCollapsed={!sidebarOpened()}
sidebarToggleGap={10}
sidebarToggle={
!props.mobile && (
<SessionReviewV2SidebarToggle
opened={sidebarOpened()}
disabled={props.placeholder}
onToggle={props.state.toggleSidebar}
/>
)
}
sidebar={
<SessionReviewV2Sidebar
open={sidebarOpened()}
transition={props.state.sidebarTransition()}
title={<span class="truncate">{title()}</span>}
title={<span class="truncate font-[530]">{title()}</span>}
filter={filter()}
onFilterChange={setFilter}
onFilterKeyDown={onFilterKeyDown}
@@ -28,6 +28,7 @@ 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 type { OpenInAppState } from "@/session/files/open-in-app"
import { useCommand } from "@/shell/commands/command"
import { useFile, type SelectedLineRange } from "@/workspaces/files/model"
import { useLanguage } from "@/runtime/i18n/language"
@@ -67,7 +68,7 @@ export function SessionSidePanel(props: {
reviewHasFocusableContent: boolean
reviewCount: number
reviewPanel: () => JSX.Element
reviewSidebarToggle: (disabled: boolean) => JSX.Element
openInApp: OpenInAppState
fileBrowserState: SessionFileBrowserState
activeDiff?: string
focusReviewDiff: (path: string) => void
@@ -329,9 +330,6 @@ export function SessionSidePanel(props: {
onCleanup(stop)
}}
>
<div class="session-review-v2-sidebar-toggle-slot h-full shrink-0 sticky start-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
{props.reviewSidebarToggle(activeTab() === SESSION_OPEN_FILE_TAB)}
</div>
<Show when={reviewTab() && props.canReview}>
<Tabs.Trigger
value="review"
@@ -552,7 +550,14 @@ export function SessionSidePanel(props: {
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<OpenInAppButton directory={projectDirectory} />
<Show
when={
activeTab() !== "review" ||
(!props.hasReview && props.fileBrowserState.sidebarOpened())
}
>
<OpenInAppButton directory={projectDirectory} state={props.openInApp} />
</Show>
<Show when={reviewVisible()}>
<div class="size-7 shrink-0" aria-hidden />
</Show>
+21 -3
View File
@@ -5,6 +5,7 @@ import {
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
SessionReviewV2,
SessionReviewV2Sidebar,
SessionReviewV2SidebarToggle,
} from "@opencode/session-ui/v2/session-review-v2"
import { SessionReviewFilePreviewV2 } from "@opencode/session-ui/v2/session-review-file-preview-v2"
import { DiffChanges } from "@opencode/ui/diff-changes"
@@ -31,11 +32,14 @@ import {
} from "@/session/review/review-diff-kinds"
import type { ReviewPanelState } from "@/session/review/panel-state"
import { applyFileListKeyDown, SessionFileList } from "@/session/files/list"
import { OpenInAppButton } from "@/session/files/open-in-app-button"
import type { OpenInAppState } from "@/session/files/open-in-app"
type ReviewDiff = FileDiffInfo
export type ReviewPanelProps = {
title?: JSX.Element
collapsedTitle?: JSX.Element
empty?: JSX.Element
diffs: ReviewDiff[]
diffsReady: boolean
@@ -56,7 +60,7 @@ export type ReviewPanelProps = {
fileList?: "tree" | "flat"
}
export function ReviewPanel(props: ReviewPanelProps) {
export function ReviewPanel(props: ReviewPanelProps & { openInApp: OpenInAppState }) {
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const readFile = async (path: string) =>
@@ -68,12 +72,19 @@ export function ReviewPanel(props: ReviewPanelProps) {
return undefined
})
return <ReviewPanelView {...props} readFile={readFile} />
return (
<ReviewPanelView
{...props}
readFile={readFile}
toolbarAction={<OpenInAppButton directory={() => sdk().directory} state={props.openInApp} />}
/>
)
}
export function ReviewPanelView(
props: ReviewPanelProps & {
readFile?: (path: string) => Promise<{ type: "text"; content: string } | undefined>
toolbarAction?: JSX.Element
},
) {
const diffs = createMemo(() => props.diffs.filter(filterRenderableDiff))
@@ -124,10 +135,16 @@ export function ReviewPanelView(
return (
<SessionReviewV2
title={props.title}
title={props.collapsedTitle ?? props.title}
stats={<DiffChanges changes={diffs()} />}
empty={props.empty}
sidebarOpen={props.state.sidebarOpened()}
sidebarToggle={
<SessionReviewV2SidebarToggle
opened={props.state.sidebarOpened()}
onToggle={props.state.toggleSidebar}
/>
}
sidebar={
// Always mounted: the sidebar header hosts the changes-mode dropdown,
// which must stay reachable when the current mode has zero diffs.
@@ -148,6 +165,7 @@ export function ReviewPanelView(
files={navigationFiles()}
onSelectFile={props.onSelectFile}
diffStyle={props.diffStyle}
toolbarAction={props.toolbarAction}
onDiffStyleChange={props.onDiffStyleChange}
expandMode={props.state.expandMode()}
onExpandModeChange={props.state.setExpandMode}
+30 -18
View File
@@ -1,5 +1,4 @@
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"
@@ -9,6 +8,8 @@ import { For, Match, Show, Suspense, Switch, lazy, createEffect, onCleanup, type
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useOpenInApp, type OpenInAppState } from "@/session/files/open-in-app"
import { SessionSidePanel } from "../files/session-side-panel"
import { ReviewPanel } from "./panel"
import { SessionReviewTab } from "./review-tab"
@@ -132,6 +133,9 @@ export function SessionDesktopReview(props: {
btw: SessionBtwModel
present?: boolean
}) {
const location = useWorkspaceLocation()
const openInApp = useOpenInApp({ path: () => location().directory })
return (
<Suspense>
<SessionSidePanel
@@ -141,14 +145,8 @@ export function SessionDesktopReview(props: {
hasReview={props.review.hasChanges()}
reviewHasFocusableContent={props.review.hasChanges() || props.review.panelState.sidebarOpened()}
reviewCount={props.review.count()}
reviewPanel={() => <ReviewPanelContent review={props.review} />}
reviewSidebarToggle={(disabled) => (
<SessionReviewV2SidebarToggle
opened={props.review.panelState.sidebarOpened()}
disabled={disabled}
onToggle={props.review.panelState.toggleSidebar}
/>
)}
reviewPanel={() => <ReviewPanelContent review={props.review} openInApp={openInApp} />}
openInApp={openInApp}
fileBrowserState={props.review.panelState}
activeDiff={props.review.activeFile()}
focusReviewDiff={props.review.focusFile}
@@ -200,12 +198,14 @@ function ReviewContent(props: { review: SessionReviewModel }) {
)
}
function ReviewPanelContent(props: { review: SessionReviewModel }) {
function ReviewPanelContent(props: { review: SessionReviewModel; openInApp: OpenInAppState }) {
return (
<div class="flex flex-col h-full overflow-hidden bg-v2-background-bg-base contain-strict">
<Show when={props.review.panelRendered()}>
<ReviewPanel
openInApp={props.openInApp}
title={<ReviewTitle review={props.review} />}
collapsedTitle={<ReviewTitle review={props.review} />}
empty={<ReviewPanelEmpty review={props.review} />}
diffs={props.review.diffs()}
diffsReady={props.review.ready()}
@@ -238,14 +238,26 @@ function ReviewTitle(props: { review: SessionReviewModel }) {
}
return (
<Show when={props.review.canReview()}>
<Select
options={props.review.options()}
current={props.review.mode()}
label={label}
placement="bottom-start"
gutter={6}
onSelect={(option) => option && props.review.setMode(option)}
/>
<Show
when={props.review.options().length === 1}
fallback={
<Select
options={props.review.options()}
current={props.review.mode()}
label={label}
placement="bottom-start"
gutter={6}
onSelect={(option) => option && props.review.setMode(option)}
/>
}
>
<span
data-slot="session-review-source-label"
class="inline-flex h-6 items-center ps-2 pe-1 text-[13px] leading-[var(--line-height-compact)] font-[530] tracking-[-0.04px] text-v2-text-text-base"
>
{label(props.review.options()[0]!)}
</span>
</Show>
</Show>
)
}
@@ -3,13 +3,22 @@ import "./session-review-v2.css"
export function SessionFilePanelV2(props: {
sidebar?: JSX.Element
sidebarToggle?: JSX.Element
sidebarToggleGap?: 2 | 10
sidebarCollapsed?: boolean
toolbar: boolean
toolbarStart?: JSX.Element
toolbarEnd?: JSX.Element
children?: JSX.Element
}) {
return (
<div data-component="session-review-v2">
<div
data-component="session-review-v2"
data-sidebar-toggle={props.sidebarToggle ? "" : undefined}
data-sidebar-toggle-gap={props.sidebarToggleGap}
data-sidebar-collapsed={props.sidebarCollapsed ? "" : undefined}
>
<div data-slot="session-review-v2-sidebar-toggle">{props.sidebarToggle}</div>
<div data-slot="session-review-v2-body">
{props.sidebar}
<div data-slot="session-review-v2-preview">
@@ -1,4 +1,5 @@
[data-component="session-review-v2"] {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
@@ -7,6 +8,13 @@
background: var(--v2-background-bg-base);
}
[data-component="session-review-v2"] [data-slot="session-review-v2-sidebar-toggle"] {
position: absolute;
inset-block-start: 9.5px;
inset-inline-start: 8px;
z-index: 2;
}
[data-component="session-review-v2"] [data-slot="session-review-v2-body"] {
display: flex;
flex: 1;
@@ -68,6 +76,14 @@
flex-shrink: 0;
}
[data-component="session-review-v2"][data-sidebar-toggle] [data-slot="session-review-v2-sidebar-header"] {
box-sizing: border-box;
height: 48px;
padding-block: 0;
padding-inline-start: 46px;
border-bottom: 1px solid transparent;
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-title"] {
display: flex;
align-items: center;
@@ -83,6 +99,10 @@
margin-inline-start: 8px;
}
[data-component="session-review-v2"][data-sidebar-toggle] [data-slot="session-review-v2-sidebar-title"] {
margin-inline-start: 0;
}
[data-component="session-review-v2-sidebar-root"]
[data-slot="session-review-v2-sidebar-title"]
[data-component="select-v2-root"] {
@@ -231,16 +251,29 @@
}
[data-component="session-review-v2"] [data-slot="session-review-v2-toolbar"] {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
padding-inline-start: 8px;
height: 48px;
padding-block: 0;
padding-inline: 8px 12px;
flex-shrink: 0;
border-bottom: 1px solid var(--border-weaker-base, var(--v2-border-border-weak));
}
[data-component="session-review-v2"][data-sidebar-toggle][data-sidebar-collapsed]
[data-slot="session-review-v2-toolbar"] {
padding-inline-start: 46px;
}
[data-component="session-review-v2"][data-sidebar-toggle-gap="10"] [data-slot="session-review-v2-sidebar-header"],
[data-component="session-review-v2"][data-sidebar-toggle-gap="10"][data-sidebar-collapsed]
[data-slot="session-review-v2-toolbar"] {
padding-inline-start: 54px;
}
[data-component="session-review-v2"] [data-slot="session-review-v2-toolbar-group"] {
display: flex;
align-items: center;
@@ -26,10 +26,12 @@ export type SessionReviewV2Props = {
empty?: JSX.Element
sidebarOpen?: boolean
sidebar?: JSX.Element
sidebarToggle?: JSX.Element
activeFile?: string
files: string[]
onSelectFile: (file: string) => void
diffStyle: SessionReviewDiffStyle
toolbarAction?: JSX.Element
onDiffStyleChange?: (style: SessionReviewDiffStyle) => void
expandMode: SessionReviewExpandMode
onExpandModeChange: (mode: SessionReviewExpandMode) => void
@@ -265,6 +267,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
const toolbarEnd = () => (
<>
{props.toolbarAction}
<SegmentedControl
value={props.expandMode}
onChange={(value) => {
@@ -313,7 +316,9 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
return (
<SessionFilePanelV2
sidebar={props.sidebar}
toolbar={props.hasDiffs}
sidebarToggle={props.sidebarToggle}
sidebarCollapsed={props.sidebarOpen === false}
toolbar={props.hasDiffs || props.sidebarOpen === false}
toolbarStart={toolbarStart()}
toolbarEnd={toolbarEnd()}
>
+1 -50
View File
@@ -646,61 +646,12 @@
[data-component="tabs"]
.session-review-v2-tabs-bar
[data-slot="tabs-list"]
> .sticky:not(.session-review-v2-sidebar-toggle-slot) {
> .sticky {
left: auto;
right: auto;
inset-inline-end: 0;
}
:root
body
#review-panel
[data-component="tabs"]
.session-review-v2-tabs-bar
[data-slot="tabs-list"]
> .session-review-v2-sidebar-toggle-slot.sticky {
left: auto;
right: auto;
inset-inline-start: 0;
padding-inline-end: 0;
&::before {
content: "";
position: absolute;
top: 50%;
bottom: auto;
inset-inline-start: auto;
inset-inline-end: 100%;
transform: translateY(-50%);
width: 12px;
height: 28px;
background-color: var(--v2-background-bg-base);
background-image: none;
pointer-events: none;
}
&::after {
content: "";
position: absolute;
top: 50%;
inset-inline-start: 100%;
inset-inline-end: auto;
transform: translateY(-50%);
width: 8px;
height: 28px;
pointer-events: none;
background: linear-gradient(90deg, var(--v2-background-bg-base), transparent);
}
&:dir(rtl)::after {
background: linear-gradient(270deg, var(--v2-background-bg-base), transparent);
}
[data-component="icon-button-v2"]::after {
display: none;
}
}
:root body #review-panel [data-component="tabs"] .session-review-v2-open-in-app-slot {
flex-shrink: 0;
margin-inline-start: auto;