mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-10 07:11:25 +03:00
feat(desktop): move review controls into their panels (#51312)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
c542a43503
commit
e86ec98711
@@ -833,6 +833,32 @@ test("a chosen mode the session stops offering shows as Git, and comes back when
|
||||
await expect(file).toHaveText("beta.ts")
|
||||
})
|
||||
|
||||
test("names a single change source and keeps the review header rows at 48px", async ({ page }) => {
|
||||
// Turn changes are offered only for Git, so another VCS offers one source.
|
||||
await openSession(page, { name: "ReviewHeader", project: { vcs: "hg" }, vcsDiff: [fileDiff("src/alpha.ts")] })
|
||||
await page.getByRole("button", { name: "Toggle review", exact: true }).click()
|
||||
const panel = page.locator("#review-panel")
|
||||
const toggle = panel.getByRole("button", { name: "Toggle file tree" })
|
||||
const header = panel.locator('[data-slot="session-review-v2-sidebar-header"]')
|
||||
const toolbar = panel.locator('[data-slot="session-review-v2-toolbar"]')
|
||||
|
||||
await expect(header.getByText("Git changes", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByRole("button", { name: "Git changes" })).toHaveCount(0)
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(header).toHaveCSS("height", "48px")
|
||||
await expect(toolbar).toHaveCSS("height", "48px")
|
||||
|
||||
await toggle.click()
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(toolbar.getByText("Git changes", { exact: true })).toBeVisible()
|
||||
await expect(toolbar).toHaveCSS("height", "48px")
|
||||
|
||||
await toggle.click()
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(header).toHaveCSS("height", "48px")
|
||||
await expect(toolbar).toHaveCSS("height", "48px")
|
||||
})
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`review toggle stays at the header edge in ${direction}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1000, height: 900 })
|
||||
|
||||
@@ -14,7 +14,6 @@ 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 { SessionReviewV2SidebarToggle } from "@opencode/session-ui/v2/session-review-v2"
|
||||
import {
|
||||
MenuItem,
|
||||
type MountedSession,
|
||||
@@ -160,13 +159,6 @@ export function SideRegion(props: {
|
||||
onCleanup(createTabStripScroll({ el, lead: props.region.lead }))
|
||||
}}
|
||||
>
|
||||
<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">
|
||||
<SessionReviewV2SidebarToggle
|
||||
opened={props.sidebar.opened()}
|
||||
disabled={props.region.selected()?.tab.sidebar === "locked"}
|
||||
onToggle={props.sidebar.toggle}
|
||||
/>
|
||||
</div>
|
||||
<For each={props.region.keys()}>
|
||||
{(key) => (
|
||||
<Show when={props.region.entry(key)}>
|
||||
|
||||
@@ -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 { getFilename } from "@opencode/util/path"
|
||||
import type { ChangeKind } from "../review/contract"
|
||||
import {
|
||||
@@ -104,12 +104,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 ? undefined : (
|
||||
<SessionReviewV2SidebarToggle
|
||||
opened={sidebarOpened()}
|
||||
disabled={props.placeholder}
|
||||
onToggle={props.state.toggle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
sidebar={
|
||||
<SessionReviewV2Sidebar
|
||||
open={sidebarOpened()}
|
||||
transition={props.state.transition()}
|
||||
title={<span class="truncate">{title()}</span>}
|
||||
title={<span class="truncate font-[530]">{title()}</span>}
|
||||
filter={filter()}
|
||||
onFilterChange={setFilter}
|
||||
onFilterKeyDown={onFilterKeyDown}
|
||||
|
||||
@@ -6,6 +6,12 @@ import type { OpenApp } from "./apps"
|
||||
|
||||
export type TreeTab = "changes" | "all"
|
||||
|
||||
/** An open-in-app request in flight: the app it opens, or undefined while none is. */
|
||||
export interface OpenRequest {
|
||||
app(): OpenApp | undefined
|
||||
set(app: OpenApp | undefined): void
|
||||
}
|
||||
|
||||
/** Per-window file state that setup owns and its lazily loaded views share. */
|
||||
export interface FileShared {
|
||||
readonly changes: Accessor<Live<Changes>>
|
||||
@@ -22,6 +28,8 @@ export interface FileShared {
|
||||
readonly installed: Map<string, Promise<boolean>>
|
||||
/** The open-in-app choice. Desktop only. */
|
||||
readonly app?: { current(): OpenApp; set(app: OpenApp): void }
|
||||
/** The "Open in" buttons' request in flight, shared so the tab strip's and a panel header's agree across tab switches. */
|
||||
readonly request: OpenRequest
|
||||
/** The last selection of each file tab, readable before a session's file view state loads. */
|
||||
readonly handoff: {
|
||||
get(session: string, path: string): LineRange | null | undefined
|
||||
|
||||
@@ -32,3 +32,17 @@ export interface FileTree {
|
||||
}
|
||||
|
||||
export const FileTree = Contract.define<FileTree, "file.tree">("file.tree")
|
||||
|
||||
export interface OpenInAppProps {
|
||||
/** The session screen whose workspace opens. */
|
||||
readonly screen: SessionScreen
|
||||
readonly session: MountedSession
|
||||
}
|
||||
|
||||
/** The desktop "Open in" button, for a panel header that shows it in place of the tab strip's. */
|
||||
export interface OpenInApp {
|
||||
/** Renders nothing on the web or for a remote server. While it is mounted, the tab strip leaves out its own. */
|
||||
Button(props: OpenInAppProps): JSX.Element
|
||||
}
|
||||
|
||||
export const OpenInApp = Contract.define<OpenInApp, "file.openInApp">("file.openInApp")
|
||||
|
||||
@@ -3,14 +3,14 @@ import { Browser } from "../browser/contract"
|
||||
import { Changes } from "../review/contract"
|
||||
import { Extension, Store } from "../sdk"
|
||||
import { OpenAppPreferences } from "./apps"
|
||||
import { FileTree } from "./contract"
|
||||
import { FileTree, OpenInApp } from "./contract"
|
||||
import en from "./i18n/en"
|
||||
|
||||
const TreeState = Schema.Struct({ tab: Schema.Literals(["changes", "all"]) })
|
||||
|
||||
export default Extension.define({
|
||||
id: "file",
|
||||
provides: { tree: FileTree },
|
||||
provides: { tree: FileTree, openIn: OpenInApp },
|
||||
// Without review the tree lists only the workspace files; without the browser, HTML opens as a file tab.
|
||||
uses: { changes: Changes, browser: Browser },
|
||||
stores: {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { showToast } from "@opencode/ui/toast"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { createLatest, useExtension, type Context, type OS, type MountedSession, type SessionScreen } from "../sdk"
|
||||
import type { OpenApp } from "./apps"
|
||||
import { useShared } from "./context"
|
||||
import { useShared, type OpenRequest } from "./context"
|
||||
import { openInAppParentPath } from "./path"
|
||||
|
||||
const MAC_OPEN_APPS = [
|
||||
@@ -68,7 +68,7 @@ const showRequestError = (ctx: Context, err: Error | string) => {
|
||||
})
|
||||
}
|
||||
|
||||
export function useOpenInApp(input: { session: MountedSession; path: () => string }) {
|
||||
export function useOpenInApp(input: { session: MountedSession; path: () => string; request?: OpenRequest }) {
|
||||
const ctx = useExtension()
|
||||
const desktop = ctx.desktop
|
||||
const shared = useShared()
|
||||
@@ -110,7 +110,9 @@ export function useOpenInApp(input: { session: MountedSession; path: () => strin
|
||||
})
|
||||
|
||||
const [menu, setMenu] = createStore({ open: false })
|
||||
const [openRequest, setOpenRequest] = createStore<{ app?: OpenApp }>({})
|
||||
const [local, setLocal] = createStore<{ app?: OpenApp }>({})
|
||||
|
||||
const request = input.request ?? { app: () => local.app, set: (app: OpenApp | undefined) => setLocal("app", app) }
|
||||
|
||||
const canOpen = createMemo(() => !!desktop && input.session.server.local)
|
||||
|
||||
@@ -121,7 +123,7 @@ export function useOpenInApp(input: { session: MountedSession; path: () => strin
|
||||
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
|
||||
)
|
||||
|
||||
const opening = createMemo(() => openRequest.app !== undefined)
|
||||
const opening = createMemo(() => request.app() !== undefined)
|
||||
|
||||
const selectApp = (app: OpenApp | "finder") => {
|
||||
if (!options().some((item) => item.id === app)) return
|
||||
@@ -135,19 +137,19 @@ export function useOpenInApp(input: { session: MountedSession; path: () => strin
|
||||
|
||||
const item = options().find((o) => o.id === app)
|
||||
const openWith = item && "openWith" in item ? item.openWith : undefined
|
||||
setOpenRequest("app", app)
|
||||
request.set(app)
|
||||
|
||||
const request =
|
||||
const launched =
|
||||
app === "finder" && reveal
|
||||
? desktop
|
||||
.reveal(target)
|
||||
.then((revealed) => (revealed ? undefined : desktop.launch(openInAppParentPath(target))))
|
||||
: desktop.launch(target, openWith)
|
||||
|
||||
request
|
||||
launched
|
||||
.catch((err) => showRequestError(ctx, err))
|
||||
.finally(() => {
|
||||
setOpenRequest("app", undefined)
|
||||
request.set(undefined)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -187,7 +189,7 @@ type OpenInAppState = ReturnType<typeof useOpenInApp>
|
||||
export default function OpenInAppButton(props: { session: MountedSession; screen: SessionScreen }) {
|
||||
const ctx = useExtension()
|
||||
const directory = () => props.screen.file.root
|
||||
const state = useOpenInApp({ session: props.session, path: directory })
|
||||
const state = useOpenInApp({ session: props.session, path: directory, request: useShared().request })
|
||||
|
||||
return (
|
||||
<Show when={directory() && state.canOpen()}>
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import { batch, createMemo, lazy, on, onCleanup, Show, Suspense, type ParentProps } from "solid-js"
|
||||
import {
|
||||
batch,
|
||||
createMemo,
|
||||
createSignal,
|
||||
lazy,
|
||||
on,
|
||||
onCleanup,
|
||||
Show,
|
||||
Suspense,
|
||||
type ParentProps,
|
||||
type Signal,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { encodeFilePath, getFilename } from "@opencode/util/path"
|
||||
import {
|
||||
@@ -22,7 +34,8 @@ import {
|
||||
import { artifactKind } from "@opencode/util/artifact"
|
||||
import { resolveArtifactPath } from "./artifact"
|
||||
import { FileContext, type FileShared } from "./context"
|
||||
import { FileTree } from "./contract"
|
||||
import type { OpenApp } from "./apps"
|
||||
import { FileTree, OpenInApp } from "./contract"
|
||||
import type File from "./index"
|
||||
import { FileVisual } from "./label"
|
||||
import { fileTabId, fileTabPath, isFileTab, workspaceFileUrl } from "./path"
|
||||
@@ -48,6 +61,7 @@ const setup: Setup<typeof File> = (ctx) => {
|
||||
const tree = ctx.stores.tree
|
||||
const [handoff, setHandoff] = storage.memory<Handoff>("handoff", { initial: { sessions: {} } })
|
||||
const preference = desktop ? ctx.stores.app : undefined
|
||||
const [request, setRequest] = createStore<{ app?: OpenApp }>({})
|
||||
|
||||
// Tab objects per session screen, which stays while it routes another session, reused so neither strip updates nor a
|
||||
// session switch rebuild a trigger. `close` prunes them.
|
||||
@@ -105,6 +119,10 @@ const setup: Setup<typeof File> = (ctx) => {
|
||||
draft.app = app
|
||||
}),
|
||||
},
|
||||
request: {
|
||||
app: () => request.app,
|
||||
set: (app) => setRequest("app", app),
|
||||
},
|
||||
handoff: {
|
||||
get: (session, path) => handoff.sessions[session]?.[path],
|
||||
set: (session, files) =>
|
||||
@@ -166,7 +184,6 @@ const setup: Setup<typeof File> = (ctx) => {
|
||||
draggable: false,
|
||||
closable: "hover",
|
||||
transient: true,
|
||||
sidebar: "locked",
|
||||
group: GROUP,
|
||||
dom: { panel: TABPANEL },
|
||||
}
|
||||
@@ -286,22 +303,58 @@ const setup: Setup<typeof File> = (ctx) => {
|
||||
},
|
||||
})
|
||||
|
||||
if (desktop) {
|
||||
const OpenInAppButton = lazy(() => import("./open-in-app"))
|
||||
const OpenInAppButton = lazy(() => import("./open-in-app"))
|
||||
|
||||
// How many "Open in" buttons each screen's panel headers show; the tab strip shows its own only while none do.
|
||||
const headers = new WeakMap<SessionScreen, Signal<number>>()
|
||||
|
||||
const headerButtons = (screen: SessionScreen) => {
|
||||
const existing = headers.get(screen)
|
||||
|
||||
if (existing) return existing
|
||||
|
||||
const created = createSignal(0)
|
||||
|
||||
headers.set(screen, created)
|
||||
|
||||
return created
|
||||
}
|
||||
|
||||
if (desktop) {
|
||||
onCleanup(onIdle(() => void OpenInAppButton.preload()))
|
||||
ctx.add(Slot, {
|
||||
at: "session.panel.end",
|
||||
render: (input) => (
|
||||
<FileProvider>
|
||||
<Suspense>
|
||||
<OpenInAppButton session={input.session} screen={input.screen} />
|
||||
</Suspense>
|
||||
</FileProvider>
|
||||
<Show when={headerButtons(input.screen)[0]() === 0}>
|
||||
<FileProvider>
|
||||
<Suspense>
|
||||
<OpenInAppButton session={input.session} screen={input.screen} />
|
||||
</Suspense>
|
||||
</FileProvider>
|
||||
</Show>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
ctx.provide(OpenInApp, {
|
||||
Button: bindExtension((props) => {
|
||||
const count = headerButtons(props.screen)
|
||||
|
||||
count[1]((value) => value + 1)
|
||||
onCleanup(() => count[1]((value) => value - 1))
|
||||
|
||||
return (
|
||||
<Show when={desktop}>
|
||||
<FileProvider>
|
||||
<Suspense>
|
||||
<OpenInAppButton session={props.session} screen={props.screen} />
|
||||
</Suspense>
|
||||
</FileProvider>
|
||||
</Show>
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
ctx.add(Slot, {
|
||||
at: "session.panel.sidebar",
|
||||
render: (input) => (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Schema } from "effect"
|
||||
import { FileTree } from "../file/contract"
|
||||
import { FileTree, OpenInApp } from "../file/contract"
|
||||
import { Extension, Store } from "../sdk"
|
||||
import { Changes } from "./contract"
|
||||
import en from "./i18n/en"
|
||||
@@ -19,8 +19,9 @@ const SessionState = Schema.Struct({
|
||||
export default Extension.define({
|
||||
id: "review",
|
||||
provides: { changes: Changes },
|
||||
// The changed files list in the file browser's tree; without it the list stays empty.
|
||||
uses: { tree: FileTree },
|
||||
// The changed files list in the file browser's tree; without it the list stays empty. The toolbar also shows the
|
||||
// file extension's "Open in" button.
|
||||
uses: { tree: FileTree, openIn: OpenInApp },
|
||||
stores: {
|
||||
diff: Store.global(
|
||||
DiffState,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
|
||||
SessionReviewV2,
|
||||
SessionReviewV2Sidebar,
|
||||
SessionReviewV2SidebarToggle,
|
||||
type SessionReviewExpandMode,
|
||||
} from "@opencode/session-ui/v2/session-review-v2"
|
||||
import { SessionReviewFilePreviewV2 } from "@opencode/session-ui/v2/session-review-file-preview-v2"
|
||||
@@ -111,6 +112,14 @@ export default function ReviewPanelContent(props: {
|
||||
}
|
||||
|
||||
function ReviewPanel(props: ReviewPanelProps) {
|
||||
const ctx = useExtension<typeof Review>()
|
||||
|
||||
const openIn = () => {
|
||||
const live = ctx.uses.openIn()
|
||||
|
||||
return live.status === "active" ? live.value : undefined
|
||||
}
|
||||
|
||||
const readFile = async (path: string) =>
|
||||
props.session.server.client.file
|
||||
.read({ path, location: { directory: props.screen.file.root } })
|
||||
@@ -188,6 +197,14 @@ function ReviewPanel(props: ReviewPanelProps) {
|
||||
stats={<DiffChanges changes={diffs()} />}
|
||||
empty={props.empty}
|
||||
sidebarOpen={props.state.sidebar.opened()}
|
||||
sidebarToggle={
|
||||
<SessionReviewV2SidebarToggle opened={props.state.sidebar.opened()} onToggle={props.state.sidebar.toggle} />
|
||||
}
|
||||
toolbarAction={
|
||||
<Show when={openIn()}>
|
||||
{(openIn) => <Dynamic component={openIn().Button} screen={props.screen} session={props.session} />}
|
||||
</Show>
|
||||
}
|
||||
sidebar={
|
||||
// Always mounted: the sidebar header hosts the changes-mode dropdown,
|
||||
// which must stay reachable when the current mode has zero diffs.
|
||||
|
||||
@@ -7,21 +7,36 @@ import type { ChangeMode, ReviewModel } from "./model"
|
||||
|
||||
export function ReviewTitle(props: { review: ReviewModel }) {
|
||||
const ctx = useExtension()
|
||||
|
||||
const label = (option: ChangeMode) => {
|
||||
if (option === "git") return ctx.t("ui.sessionReview.title.git")
|
||||
|
||||
if (option === "branch") return ctx.t("ui.sessionReview.title.branch")
|
||||
|
||||
return ctx.t("ui.sessionReview.title.lastTurn")
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={props.review.canReview() && props.review.options().length > 0}>
|
||||
<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 && props.review.options()[0]}
|
||||
fallback={
|
||||
<Select
|
||||
options={props.review.options()}
|
||||
current={props.review.mode()}
|
||||
label={label}
|
||||
placement="bottom-start"
|
||||
gutter={6}
|
||||
onSelect={(option) => option && props.review.setMode(option)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(only) => (
|
||||
<span 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(only())}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -29,11 +44,15 @@ export function ReviewTitle(props: { review: ReviewModel }) {
|
||||
export function ReviewEmpty(props: { review: ReviewModel; loadingClass: string }) {
|
||||
const ctx = useExtension()
|
||||
const loading = () => !props.review.ready()
|
||||
|
||||
const text = () => {
|
||||
if (props.review.mode() === "git") return ctx.t("empty.git")
|
||||
|
||||
if (props.review.mode() === "branch") return ctx.t("empty.branch")
|
||||
|
||||
return ctx.t("noChanges")
|
||||
}
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={loading()}>
|
||||
@@ -56,6 +75,7 @@ export function ReviewEmpty(props: { review: ReviewModel; loadingClass: string }
|
||||
export function ReviewPanelEmpty(props: { review: ReviewModel }) {
|
||||
const ctx = useExtension()
|
||||
const loading = () => !props.review.ready()
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={loading()}>
|
||||
|
||||
@@ -186,8 +186,6 @@ export interface PanelTab {
|
||||
readonly file?: string
|
||||
/** The tab panel itself joins the tab order, for content without focusable elements. */
|
||||
readonly tabbable?: boolean
|
||||
/** `locked`: forces the panel's inner sidebar open and disables its toggle. */
|
||||
readonly sidebar?: "locked"
|
||||
/** Stable DOM ids for the trigger and the tab panel. */
|
||||
readonly dom?: {
|
||||
/** The trigger's id. */
|
||||
|
||||
@@ -96,7 +96,7 @@ export interface PanelFrame {
|
||||
readonly reserve: Accessor<boolean>
|
||||
/** Plays size animations; false while the user drags a region edge. */
|
||||
readonly animate: Accessor<boolean>
|
||||
/** One inner sidebar preference shared by every side panel, toggled from the tab strip. */
|
||||
/** One inner sidebar preference shared by every side panel; a panel with a sidebar renders its own toggle. */
|
||||
readonly sidebar: PanelSidebar
|
||||
/** This extension's tab ids stored in the session's side strip (the ids `list` receives as `open`). */
|
||||
readonly open: Accessor<readonly string[]>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -15,7 +15,9 @@ import { getWorkerPool } from "../../pierre/worker"
|
||||
import { SessionFilePanelV2, SessionFilePanelV2Empty } from "./session-file-panel-v2"
|
||||
|
||||
export const SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT = 240
|
||||
|
||||
export const SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN = 200
|
||||
|
||||
export const SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX = 480
|
||||
|
||||
export type SessionReviewExpandMode = "expand" | "collapse"
|
||||
@@ -26,10 +28,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
|
||||
@@ -158,21 +162,26 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
|
||||
|
||||
const fileIndex = () => {
|
||||
const files = props.files
|
||||
|
||||
if (files.length === 0) return -1
|
||||
|
||||
const active = props.activeFile
|
||||
const i = active ? files.indexOf(active) : -1
|
||||
|
||||
if (i >= 0) return i
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
const prev = () => {
|
||||
if (!canCycle()) return
|
||||
|
||||
return props.files[(fileIndex() - 1 + props.files.length) % props.files.length]
|
||||
}
|
||||
|
||||
const next = () => {
|
||||
if (!canCycle()) return
|
||||
|
||||
return props.files[(fileIndex() + 1) % props.files.length]
|
||||
}
|
||||
|
||||
@@ -193,11 +202,15 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
|
||||
// pane is mounted, but never while typing in an input or comment editor.
|
||||
makeEventListener(document, "keydown", (event) => {
|
||||
if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) return
|
||||
|
||||
if (event.key !== previousKey() && event.key !== nextKey()) return
|
||||
const target = event.target
|
||||
|
||||
if (target instanceof HTMLElement && (target.isContentEditable || target.closest("input, textarea, select"))) return
|
||||
|
||||
if (!props.hasDiffs || !canCycle()) return
|
||||
const file = event.key === previousKey() ? prev() : next()
|
||||
|
||||
if (!file) return
|
||||
event.preventDefault()
|
||||
cycle(file)
|
||||
@@ -265,6 +278,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
|
||||
|
||||
const toolbarEnd = () => (
|
||||
<>
|
||||
{props.toolbarAction}
|
||||
<SegmentedControl
|
||||
value={props.expandMode}
|
||||
onChange={(value) => {
|
||||
@@ -313,7 +327,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()}
|
||||
>
|
||||
|
||||
@@ -629,61 +629,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;
|
||||
|
||||
Reference in New Issue
Block a user