feat(app): add inline file browser tabs (#35829)
This commit is contained in:
parent
7550aa4b89
commit
62de2b3695
19 changed files with 968 additions and 179 deletions
146
packages/app/e2e/regression/review-open-file.spec.ts
Normal file
146
packages/app/e2e/regression/review-open-file.spec.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { expect, test } from "@playwright/test"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
|
const directory = "C:/OpenCode/ReviewOpenFile"
|
||||||
|
const projectID = "proj_review_open_file"
|
||||||
|
const sessionID = "ses_review_open_file"
|
||||||
|
const title = "Review open file"
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
|
||||||
|
test.use({ viewport: { width: 1440, height: 900 } })
|
||||||
|
|
||||||
|
test("opens and searches project files inline", async ({ page }) => {
|
||||||
|
const searches: { query: string; dirs?: string; limit?: number }[] = []
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory,
|
||||||
|
project: {
|
||||||
|
id: projectID,
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "open-file-project",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "test" },
|
||||||
|
},
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: sessionID,
|
||||||
|
slug: sessionID,
|
||||||
|
projectID,
|
||||||
|
directory,
|
||||||
|
title,
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
vcsDiff: [fileDiff("src/changed.ts")],
|
||||||
|
fileList: (path) => {
|
||||||
|
if (path) return []
|
||||||
|
return [
|
||||||
|
fileNode("README.md"),
|
||||||
|
{ name: "src", path: "src", absolute: `${directory}/src`, type: "directory", ignored: false },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
||||||
|
findFiles: (input) => {
|
||||||
|
searches.push(input)
|
||||||
|
return input.query === "nested" ? ["src/nested.ts"] : []
|
||||||
|
},
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
})
|
||||||
|
await page.addInitScript(
|
||||||
|
({ directory, server, sessionID }) => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:server",
|
||||||
|
JSON.stringify({
|
||||||
|
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||||
|
lastProject: { local: directory },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:layout",
|
||||||
|
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.window.browser.dat:tabs",
|
||||||
|
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ directory, server, sessionID },
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||||
|
await expectSessionTitle(page, title)
|
||||||
|
|
||||||
|
const panel = page.locator("#review-panel")
|
||||||
|
const contextButton = page.getByRole("button", { name: "View context usage" })
|
||||||
|
await contextButton.click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
|
||||||
|
await panel.getByRole("button", { name: "Open file" }).click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||||
|
await contextButton.click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
|
||||||
|
await panel.getByRole("button", { name: "Open file" }).click()
|
||||||
|
const filter = panel.getByRole("combobox", { name: "Filter files" })
|
||||||
|
await expect(filter).toBeFocused()
|
||||||
|
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||||
|
await expect(panel.getByText("open-file-project", { exact: true })).toBeVisible()
|
||||||
|
|
||||||
|
await panel.getByRole("button", { name: "README.md" }).click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
|
||||||
|
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
|
||||||
|
|
||||||
|
await panel.getByRole("button", { name: "Open file" }).click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0)
|
||||||
|
await filter.fill("nested")
|
||||||
|
const result = panel.getByRole("option", { name: /nested\.ts/ })
|
||||||
|
await expect(result).toBeVisible()
|
||||||
|
const resultID = await result.getAttribute("id")
|
||||||
|
expect(resultID).toBeTruthy()
|
||||||
|
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
|
||||||
|
await filter.press("Enter")
|
||||||
|
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
|
||||||
|
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
|
||||||
|
expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
|
||||||
|
|
||||||
|
await panel.getByRole("button", { name: "Open file" }).click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
|
||||||
|
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||||
|
await page.keyboard.press("Control+w")
|
||||||
|
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
|
||||||
|
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
|
||||||
|
})
|
||||||
|
|
||||||
|
function fileNode(path: string) {
|
||||||
|
return {
|
||||||
|
name: path,
|
||||||
|
path,
|
||||||
|
absolute: `${directory}/${path}`,
|
||||||
|
type: "file",
|
||||||
|
ignored: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileDiff(file: string) {
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
before: "before\n",
|
||||||
|
after: "after\n",
|
||||||
|
additions: 1,
|
||||||
|
deletions: 1,
|
||||||
|
status: "modified",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ export interface MockServerConfig {
|
||||||
questions?: unknown[] | (() => unknown[])
|
questions?: unknown[] | (() => unknown[])
|
||||||
fileList?: (path: string) => unknown | Promise<unknown>
|
fileList?: (path: string) => unknown | Promise<unknown>
|
||||||
fileContent?: (path: string) => unknown | Promise<unknown>
|
fileContent?: (path: string) => unknown | Promise<unknown>
|
||||||
|
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
|
||||||
sessionStatus?: unknown
|
sessionStatus?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,6 +67,15 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||||
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
|
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
|
||||||
if (path === "/file/content" && config.fileContent)
|
if (path === "/file/content" && config.fileContent)
|
||||||
return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
|
return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
|
||||||
|
if (path === "/find/file" && config.findFiles)
|
||||||
|
return json(
|
||||||
|
route,
|
||||||
|
await config.findFiles({
|
||||||
|
query: url.searchParams.get("query") ?? "",
|
||||||
|
dirs: url.searchParams.get("dirs") ?? undefined,
|
||||||
|
limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
|
||||||
|
}),
|
||||||
|
)
|
||||||
if (path === "/api/reference")
|
if (path === "/api/reference")
|
||||||
return json(route, {
|
return json(route, {
|
||||||
location: {
|
location: {
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,7 @@ export default function FileTree(props: {
|
||||||
kinds?: ReadonlyMap<string, Kind>
|
kinds?: ReadonlyMap<string, Kind>
|
||||||
draggable?: boolean
|
draggable?: boolean
|
||||||
onFileClick?: (file: FileNode) => void
|
onFileClick?: (file: FileNode) => void
|
||||||
|
onFileDoubleClick?: (file: FileNode) => void
|
||||||
|
|
||||||
_filter?: Filter
|
_filter?: Filter
|
||||||
_marks?: Set<string>
|
_marks?: Set<string>
|
||||||
|
|
@ -440,6 +441,7 @@ export default function FileTree(props: {
|
||||||
active={props.active}
|
active={props.active}
|
||||||
draggable={props.draggable}
|
draggable={props.draggable}
|
||||||
onFileClick={props.onFileClick}
|
onFileClick={props.onFileClick}
|
||||||
|
onFileDoubleClick={props.onFileDoubleClick}
|
||||||
_filter={filter()}
|
_filter={filter()}
|
||||||
_marks={marks()}
|
_marks={marks()}
|
||||||
_deeps={deeps()}
|
_deeps={deeps()}
|
||||||
|
|
@ -462,6 +464,7 @@ export default function FileTree(props: {
|
||||||
as="button"
|
as="button"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => props.onFileClick?.(node)}
|
onClick={() => props.onFileClick?.(node)}
|
||||||
|
onDblClick={() => props.onFileDoubleClick?.(node)}
|
||||||
>
|
>
|
||||||
<div class="w-4 shrink-0" />
|
<div class="w-4 shrink-0" />
|
||||||
<Switch>
|
<Switch>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
|
import { createMediaQuery } from "@solid-primitives/media"
|
||||||
|
|
||||||
import { useFile } from "@/context/file"
|
import { useFile } from "@/context/file"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
|
|
@ -14,6 +15,7 @@ import { useSDK } from "@/context/sdk"
|
||||||
import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics"
|
import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
|
import { useSettings } from "@/context/settings"
|
||||||
|
|
||||||
interface SessionContextUsageProps {
|
interface SessionContextUsageProps {
|
||||||
variant?: "button" | "indicator"
|
variant?: "button" | "indicator"
|
||||||
|
|
@ -47,8 +49,10 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
const settings = useSettings()
|
||||||
const providers = useProviders(() => sdk().directory)
|
const providers = useProviders(() => sdk().directory)
|
||||||
const { params, tabs, view } = useSessionLayout()
|
const { params, tabs, view } = useSessionLayout()
|
||||||
|
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||||
|
|
||||||
const variant = createMemo(() => props.variant ?? "button")
|
const variant = createMemo(() => props.variant ?? "button")
|
||||||
const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default")
|
const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default")
|
||||||
|
|
@ -56,6 +60,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||||
tabs,
|
tabs,
|
||||||
pathFromTab: file.pathFromTab,
|
pathFromTab: file.pathFromTab,
|
||||||
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||||
|
fileBrowser: () => settings.general.newLayoutDesigns() && isDesktop() && !!params.id,
|
||||||
})
|
})
|
||||||
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
|
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
|
||||||
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { useFile } from "@/context/file"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
|
|
||||||
export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
|
export function FileVisual(props: { path: string; active?: boolean; temporary?: boolean }): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-x-1.5 min-w-0">
|
<div class="flex items-center gap-x-1.5 min-w-0">
|
||||||
<Show
|
<Show
|
||||||
|
|
@ -22,12 +22,19 @@ export function FileVisual(props: { path: string; active?: boolean }): JSX.Eleme
|
||||||
<FileIcon node={{ path: props.path, type: "file" }} mono class="absolute inset-0 size-4 tab-fileicon-mono" />
|
<FileIcon node={{ path: props.path, type: "file" }} mono class="absolute inset-0 size-4 tab-fileicon-mono" />
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
<span class="text-14-medium truncate">{getFilename(props.path)}</span>
|
<span class="text-14-medium truncate" classList={{ italic: props.temporary }}>
|
||||||
|
{getFilename(props.path)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element {
|
export function SortableTab(props: {
|
||||||
|
tab: string
|
||||||
|
temporary?: boolean
|
||||||
|
onTabClose: (tab: string) => void
|
||||||
|
onTabDoubleClick?: (tab: string) => void
|
||||||
|
}): JSX.Element {
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
|
|
@ -36,7 +43,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
|
||||||
const content = createMemo(() => {
|
const content = createMemo(() => {
|
||||||
const value = path()
|
const value = path()
|
||||||
if (!value) return
|
if (!value) return
|
||||||
return <FileVisual path={value} />
|
return <FileVisual path={value} temporary={props.temporary} />
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
<div use:sortable class="h-full flex items-center" classList={{ "opacity-0": sortable.isActiveDraggable }}>
|
<div use:sortable class="h-full flex items-center" classList={{ "opacity-0": sortable.isActiveDraggable }}>
|
||||||
|
|
@ -61,6 +68,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
|
||||||
}
|
}
|
||||||
hideCloseButton
|
hideCloseButton
|
||||||
onMiddleClick={() => props.onTabClose(props.tab)}
|
onMiddleClick={() => props.onTabClose(props.tab)}
|
||||||
|
onDblClick={() => props.onTabDoubleClick?.(props.tab)}
|
||||||
>
|
>
|
||||||
<Show when={content()}>{(value) => value()}</Show>
|
<Show when={content()}>{(value) => value()}</Show>
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
|
|
|
||||||
|
|
@ -203,12 +203,15 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||||
return promise
|
return promise
|
||||||
}
|
}
|
||||||
|
|
||||||
const search = (query: string, dirs: "true" | "false") =>
|
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
|
||||||
sdk()
|
sdk()
|
||||||
.client.find.files({ query, dirs })
|
.client.find.files({ query, dirs, limit: options?.limit }, { signal: options?.signal })
|
||||||
.then(
|
.then(
|
||||||
(x) => (x.data ?? []).map(path.normalize),
|
(x) => (x.data ?? []).map(path.normalize),
|
||||||
() => [],
|
(error) => {
|
||||||
|
if (options?.signal?.aborted) throw error
|
||||||
|
return []
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const stop = sdk().event.listen((e) => {
|
const stop = sdk().event.listen((e) => {
|
||||||
|
|
@ -284,7 +287,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||||
setScrollLeft,
|
setScrollLeft,
|
||||||
selectedLines,
|
selectedLines,
|
||||||
setSelectedLines,
|
setSelectedLines,
|
||||||
searchFiles: (query: string) => search(query, "false"),
|
searchFiles: (query: string, options?: { limit?: number; signal?: AbortSignal }) =>
|
||||||
|
search(query, "false", options),
|
||||||
searchFilesAndDirectories: (query: string) => search(query, "true"),
|
searchFilesAndDirectories: (query: string) => search(query, "true"),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
82
packages/app/src/context/layout-tabs.test.ts
Normal file
82
packages/app/src/context/layout-tabs.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import {
|
||||||
|
SESSION_OPEN_FILE_TAB,
|
||||||
|
closeSessionTab,
|
||||||
|
openSessionTab,
|
||||||
|
previewSessionTab,
|
||||||
|
type SessionTabState,
|
||||||
|
} from "./layout-tabs"
|
||||||
|
|
||||||
|
const state = (all: string[], active?: string, preview?: string): SessionTabState => ({
|
||||||
|
tabs: { all, active },
|
||||||
|
preview,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("previewSessionTab", () => {
|
||||||
|
test("appends the Open File placeholder", () => {
|
||||||
|
expect(previewSessionTab(state(["file://a.ts"], "file://a.ts"), SESSION_OPEN_FILE_TAB)).toEqual(
|
||||||
|
state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("replaces the current preview in place", () => {
|
||||||
|
expect(
|
||||||
|
previewSessionTab(
|
||||||
|
state(["context", SESSION_OPEN_FILE_TAB, "file://b.ts"], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB),
|
||||||
|
"file://a.ts",
|
||||||
|
),
|
||||||
|
).toEqual(state(["context", "file://a.ts", "file://b.ts"], "file://a.ts", "file://a.ts"))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("activates a durable tab without duplicating it", () => {
|
||||||
|
expect(
|
||||||
|
previewSessionTab(
|
||||||
|
state(["file://a.ts", SESSION_OPEN_FILE_TAB, "file://b.ts"], SESSION_OPEN_FILE_TAB, SESSION_OPEN_FILE_TAB),
|
||||||
|
"file://b.ts",
|
||||||
|
),
|
||||||
|
).toEqual(state(["file://a.ts", "file://b.ts"], "file://b.ts"))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("replaces a restored Open File placeholder", () => {
|
||||||
|
expect(
|
||||||
|
previewSessionTab(state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB), "file://b.ts"),
|
||||||
|
).toEqual(state(["file://a.ts", "file://b.ts"], "file://b.ts", "file://b.ts"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("openSessionTab", () => {
|
||||||
|
test("pins the current preview", () => {
|
||||||
|
expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "file://a.ts")).toEqual(
|
||||||
|
state(["file://a.ts"], "file://a.ts"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("replaces a preview with a directly opened file", () => {
|
||||||
|
expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "file://b.ts")).toEqual(
|
||||||
|
state(["file://b.ts"], "file://b.ts"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps the preview when switching to Review", () => {
|
||||||
|
expect(openSessionTab(state(["file://a.ts"], "file://a.ts", "file://a.ts"), "review")).toEqual(
|
||||||
|
state(["file://a.ts"], "review", "file://a.ts"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("replaces a restored Open File placeholder with a direct open", () => {
|
||||||
|
expect(openSessionTab(state(["file://a.ts", SESSION_OPEN_FILE_TAB], SESSION_OPEN_FILE_TAB), "file://b.ts")).toEqual(
|
||||||
|
state(["file://a.ts", "file://b.ts"], "file://b.ts"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("closeSessionTab", () => {
|
||||||
|
test("clears preview metadata and selects the left neighbor", () => {
|
||||||
|
expect(
|
||||||
|
closeSessionTab(
|
||||||
|
state(["file://a.ts", "file://b.ts", "file://c.ts"], "file://b.ts", "file://b.ts"),
|
||||||
|
"file://b.ts",
|
||||||
|
),
|
||||||
|
).toEqual(state(["file://a.ts", "file://c.ts"], "file://a.ts"))
|
||||||
|
})
|
||||||
|
})
|
||||||
103
packages/app/src/context/layout-tabs.ts
Normal file
103
packages/app/src/context/layout-tabs.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
export const SESSION_OPEN_FILE_TAB = "open-file"
|
||||||
|
|
||||||
|
export type SessionTabs = {
|
||||||
|
active?: string
|
||||||
|
all: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionTabState = {
|
||||||
|
tabs: SessionTabs
|
||||||
|
preview?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionTabPreview = (current: SessionTabState) =>
|
||||||
|
current.preview ?? (current.tabs.all.includes(SESSION_OPEN_FILE_TAB) ? SESSION_OPEN_FILE_TAB : undefined)
|
||||||
|
|
||||||
|
export function previewSessionTab(current: SessionTabState, tab: string): SessionTabState {
|
||||||
|
const preview = sessionTabPreview(current)
|
||||||
|
const previewIndex = preview ? current.tabs.all.indexOf(preview) : -1
|
||||||
|
const existingIndex = current.tabs.all.indexOf(tab)
|
||||||
|
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
if (previewIndex === -1 || preview === tab) {
|
||||||
|
return { tabs: { all: current.tabs.all, active: tab }, preview: preview === tab ? tab : undefined }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tabs: { all: current.tabs.all.filter((item) => item !== preview), active: tab },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previewIndex === -1) {
|
||||||
|
return { tabs: { all: [...current.tabs.all, tab], active: tab }, preview: tab }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tabs: {
|
||||||
|
all: current.tabs.all.map((item, index) => (index === previewIndex ? tab : item)),
|
||||||
|
active: tab,
|
||||||
|
},
|
||||||
|
preview: tab,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openSessionTab(current: SessionTabState, tab: string): SessionTabState {
|
||||||
|
const preview = sessionTabPreview(current)
|
||||||
|
if (tab === "review") {
|
||||||
|
return {
|
||||||
|
tabs: { all: current.tabs.all.filter((item) => item !== tab), active: tab },
|
||||||
|
preview,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tab === "context") {
|
||||||
|
return {
|
||||||
|
tabs: { all: [tab, ...current.tabs.all.filter((item) => item !== tab)], active: tab },
|
||||||
|
preview,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewIndex = preview ? current.tabs.all.indexOf(preview) : -1
|
||||||
|
const existingIndex = current.tabs.all.indexOf(tab)
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
if (previewIndex === -1 || preview === tab) {
|
||||||
|
return { tabs: { all: current.tabs.all, active: tab } }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tabs: { all: current.tabs.all.filter((item) => item !== preview), active: tab },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previewIndex === -1) {
|
||||||
|
return { tabs: { all: [...current.tabs.all, tab], active: tab } }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tabs: {
|
||||||
|
all: current.tabs.all.map((item, index) => (index === previewIndex ? tab : item)),
|
||||||
|
active: tab,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeSessionTab(current: SessionTabState, tab: string): SessionTabState {
|
||||||
|
if (tab === "review") {
|
||||||
|
if (current.tabs.active !== tab) return current
|
||||||
|
return {
|
||||||
|
tabs: { all: current.tabs.all, active: current.tabs.all[0] },
|
||||||
|
preview: current.preview,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const all = current.tabs.all.filter((item) => item !== tab)
|
||||||
|
const preview = current.preview === tab ? undefined : current.preview
|
||||||
|
if (current.tabs.active !== tab) return { tabs: { ...current.tabs, all }, preview }
|
||||||
|
|
||||||
|
const index = current.tabs.all.indexOf(tab)
|
||||||
|
return {
|
||||||
|
tabs: {
|
||||||
|
all,
|
||||||
|
active: current.tabs.all[index - 1] ?? current.tabs.all[index + 1] ?? all[0],
|
||||||
|
},
|
||||||
|
preview,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/u
|
||||||
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
|
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
|
||||||
import { requireServerKey } from "@/utils/session-route"
|
import { requireServerKey } from "@/utils/session-route"
|
||||||
import { type DraftTab, useTabs } from "./tabs"
|
import { type DraftTab, useTabs } from "./tabs"
|
||||||
|
import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./layout-tabs"
|
||||||
|
|
||||||
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
|
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
|
||||||
|
|
||||||
|
|
@ -55,11 +56,6 @@ export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant {
|
||||||
return "gray"
|
return "gray"
|
||||||
}
|
}
|
||||||
|
|
||||||
type SessionTabs = {
|
|
||||||
active?: string
|
|
||||||
all: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type SessionView = {
|
type SessionView = {
|
||||||
scroll: Record<string, SessionScroll>
|
scroll: Record<string, SessionScroll>
|
||||||
reviewOpen?: string[]
|
reviewOpen?: string[]
|
||||||
|
|
@ -87,14 +83,6 @@ export type LayoutRoute =
|
||||||
| { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key }
|
| { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key }
|
||||||
| { type: "session"; sessionId: string; server?: ServerConnection.Key }
|
| { type: "session"; sessionId: string; server?: ServerConnection.Key }
|
||||||
|
|
||||||
function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
|
|
||||||
const all = current?.all ?? []
|
|
||||||
if (tab === "review") return { all: all.filter((x) => x !== "review"), active: tab }
|
|
||||||
if (tab === "context") return { all: [tab, ...all.filter((x) => x !== tab)], active: tab }
|
|
||||||
if (!all.includes(tab)) return { all: [...all, tab], active: tab }
|
|
||||||
return { all, active: tab }
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionPath = (key: string) => {
|
const sessionPath = (key: string) => {
|
||||||
const dir = SessionStateKey.route(key).split("/")[0]
|
const dir = SessionStateKey.route(key).split("/")[0]
|
||||||
if (!dir) return
|
if (!dir) return
|
||||||
|
|
@ -308,6 +296,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||||
)
|
)
|
||||||
const [ephemeral, setEphemeral] = createStore({
|
const [ephemeral, setEphemeral] = createStore({
|
||||||
reviewPanelSource: "other" as ReviewPanelSource,
|
reviewPanelSource: "other" as ReviewPanelSource,
|
||||||
|
sessionTabPreview: {} as Record<string, string | undefined>,
|
||||||
})
|
})
|
||||||
|
|
||||||
const MAX_SESSION_KEYS = 50
|
const MAX_SESSION_KEYS = 50
|
||||||
|
|
@ -366,6 +355,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||||
|
|
||||||
scroll.drop(drop)
|
scroll.drop(drop)
|
||||||
dropSessionState(drop)
|
dropSessionState(drop)
|
||||||
|
setEphemeral(
|
||||||
|
"sessionTabPreview",
|
||||||
|
produce((draft) => {
|
||||||
|
for (const key of drop) delete draft[key]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
for (const key of drop) {
|
for (const key of drop) {
|
||||||
usage.used.delete(key)
|
usage.used.delete(key)
|
||||||
|
|
@ -945,10 +940,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||||
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
|
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
|
||||||
const normalize = (tab: string) => normalizeSessionTab(path(), tab)
|
const normalize = (tab: string) => normalizeSessionTab(path(), tab)
|
||||||
const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all)
|
const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all)
|
||||||
|
const apply = (session: string, next: ReturnType<typeof openSessionTab>) => {
|
||||||
|
batch(() => {
|
||||||
|
setStore("sessionTabs", session, next.tabs)
|
||||||
|
setEphemeral("sessionTabPreview", session, next.preview)
|
||||||
|
})
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
tabs,
|
tabs,
|
||||||
active: createMemo(() => tabs().active),
|
active: createMemo(() => tabs().active),
|
||||||
all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
|
all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
|
||||||
|
preview: createMemo(() => ephemeral.sessionTabPreview[key()]),
|
||||||
setActive(tab: string | undefined) {
|
setActive(tab: string | undefined) {
|
||||||
const session = key()
|
const session = key()
|
||||||
const next = tab ? normalize(tab) : tab
|
const next = tab ? normalize(tab) : tab
|
||||||
|
|
@ -961,40 +963,44 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||||
setAll(all: string[]) {
|
setAll(all: string[]) {
|
||||||
const session = key()
|
const session = key()
|
||||||
const next = normalizeAll(all).filter((tab) => tab !== "review")
|
const next = normalizeAll(all).filter((tab) => tab !== "review")
|
||||||
if (!store.sessionTabs[session]) {
|
batch(() => {
|
||||||
setStore("sessionTabs", session, { all: next, active: undefined })
|
if (!store.sessionTabs[session]) {
|
||||||
} else {
|
setStore("sessionTabs", session, { all: next, active: undefined })
|
||||||
setStore("sessionTabs", session, "all", next)
|
} else {
|
||||||
}
|
setStore("sessionTabs", session, "all", next)
|
||||||
|
}
|
||||||
|
const preview = ephemeral.sessionTabPreview[session]
|
||||||
|
if (preview && !next.includes(preview)) setEphemeral("sessionTabPreview", session, undefined)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
async open(tab: string) {
|
async open(tab: string) {
|
||||||
const session = key()
|
const session = key()
|
||||||
const next = nextSessionTabsForOpen(store.sessionTabs[session], normalize(tab))
|
apply(
|
||||||
setStore("sessionTabs", session, next)
|
session,
|
||||||
|
openSessionTab(
|
||||||
|
{ tabs: store.sessionTabs[session] ?? { all: [] }, preview: ephemeral.sessionTabPreview[session] },
|
||||||
|
normalize(tab),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
previewTab(tab: string) {
|
||||||
|
const session = key()
|
||||||
|
apply(
|
||||||
|
session,
|
||||||
|
previewSessionTab(
|
||||||
|
{ tabs: store.sessionTabs[session] ?? { all: [] }, preview: ephemeral.sessionTabPreview[session] },
|
||||||
|
normalize(tab),
|
||||||
|
),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
close(tab: string) {
|
close(tab: string) {
|
||||||
const session = key()
|
const session = key()
|
||||||
const current = store.sessionTabs[session]
|
const current = store.sessionTabs[session]
|
||||||
if (!current) return
|
if (!current) return
|
||||||
|
apply(
|
||||||
if (tab === "review") {
|
session,
|
||||||
if (current.active !== tab) return
|
closeSessionTab({ tabs: current, preview: ephemeral.sessionTabPreview[session] }, normalize(tab)),
|
||||||
setStore("sessionTabs", session, "active", current.all[0])
|
)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const all = current.all.filter((x) => x !== tab)
|
|
||||||
if (current.active !== tab) {
|
|
||||||
setStore("sessionTabs", session, "all", all)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const index = current.all.findIndex((f) => f === tab)
|
|
||||||
const next = current.all[index - 1] ?? current.all[index + 1] ?? all[0]
|
|
||||||
batch(() => {
|
|
||||||
setStore("sessionTabs", session, "all", all)
|
|
||||||
setStore("sessionTabs", session, "active", next)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
move(tab: string, to: number) {
|
move(tab: string, to: number) {
|
||||||
const session = key()
|
const session = key()
|
||||||
|
|
|
||||||
|
|
@ -1019,6 +1019,7 @@ export default function Page() {
|
||||||
setActiveMessage,
|
setActiveMessage,
|
||||||
focusInput,
|
focusInput,
|
||||||
review: reviewTab,
|
review: reviewTab,
|
||||||
|
fileBrowser: () => newSessionDesign() && isDesktop() && !!params.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
const openReviewFile = createOpenReviewFile({
|
const openReviewFile = createOpenReviewFile({
|
||||||
|
|
@ -2177,6 +2178,7 @@ export default function Page() {
|
||||||
reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
|
reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
|
||||||
reviewCount={reviewCount}
|
reviewCount={reviewCount}
|
||||||
reviewPanel={reviewPanelV2}
|
reviewPanel={reviewPanelV2}
|
||||||
|
fileBrowserState={reviewV2State}
|
||||||
activeDiff={tree.activeDiff}
|
activeDiff={tree.activeDiff}
|
||||||
focusReviewDiff={focusReviewDiff}
|
focusReviewDiff={focusReviewDiff}
|
||||||
reviewSnap={ui.reviewSnap}
|
reviewSnap={ui.reviewSnap}
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,14 @@ function createScrollSync(input: { tab: () => string; view: ReturnType<typeof us
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FileTabContent(props: { tab: string }) {
|
export function FileTabContent(props: { tab: string }) {
|
||||||
|
return (
|
||||||
|
<Tabs.Content value={props.tab}>
|
||||||
|
<SessionFileView tab={props.tab} />
|
||||||
|
</Tabs.Content>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionFileView(props: { tab: string }) {
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const comments = useComments()
|
const comments = useComments()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
@ -439,8 +447,8 @@ export function FileTabContent(props: { tab: string }) {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
const content = () => (
|
||||||
<Tabs.Content value={props.tab} class="mt-3 relative h-full">
|
<div class="mt-3 relative h-full min-h-0">
|
||||||
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll as any}>
|
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll as any}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
|
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
|
||||||
|
|
@ -450,6 +458,8 @@ export function FileTabContent(props: { tab: string }) {
|
||||||
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
|
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</Tabs.Content>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return content()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||||
import { createMemo, createRoot } from "solid-js"
|
import { createMemo, createRoot } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import {
|
import {
|
||||||
|
SESSION_OPEN_FILE_TAB,
|
||||||
createOpenReviewFile,
|
createOpenReviewFile,
|
||||||
createOpenSessionFileTab,
|
createOpenSessionFileTab,
|
||||||
createSessionTabs,
|
createSessionTabs,
|
||||||
|
|
@ -165,4 +166,49 @@ describe("createSessionTabs", () => {
|
||||||
dispose()
|
dispose()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("exposes the Open File tab without treating it as a file tab", () => {
|
||||||
|
createRoot((dispose) => {
|
||||||
|
const [state] = createStore({
|
||||||
|
active: SESSION_OPEN_FILE_TAB as string | undefined,
|
||||||
|
all: ["file://src/a.ts", SESSION_OPEN_FILE_TAB],
|
||||||
|
})
|
||||||
|
const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all }))
|
||||||
|
const result = createSessionTabs({
|
||||||
|
tabs,
|
||||||
|
pathFromTab: (tab) => (tab.startsWith("file://") ? tab.slice("file://".length) : undefined),
|
||||||
|
normalizeTab: (tab) => tab,
|
||||||
|
fileBrowser: () => true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.openFileOpen()).toBe(true)
|
||||||
|
expect(result.panelTabs()).toEqual(["file://src/a.ts", SESSION_OPEN_FILE_TAB])
|
||||||
|
expect(result.openedTabs()).toEqual(["file://src/a.ts"])
|
||||||
|
expect(result.activeTab()).toBe(SESSION_OPEN_FILE_TAB)
|
||||||
|
expect(result.activeFileTab()).toBeUndefined()
|
||||||
|
expect(result.closableTab()).toBe(SESSION_OPEN_FILE_TAB)
|
||||||
|
dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("hides the Open File placeholder when the file browser is unavailable", () => {
|
||||||
|
createRoot((dispose) => {
|
||||||
|
const [state] = createStore({
|
||||||
|
active: SESSION_OPEN_FILE_TAB as string | undefined,
|
||||||
|
all: ["file://src/a.ts", SESSION_OPEN_FILE_TAB],
|
||||||
|
})
|
||||||
|
const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all }))
|
||||||
|
const result = createSessionTabs({
|
||||||
|
tabs,
|
||||||
|
pathFromTab: (tab) => (tab.startsWith("file://") ? tab.slice("file://".length) : undefined),
|
||||||
|
normalizeTab: (tab) => tab,
|
||||||
|
fileBrowser: () => false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.openFileOpen()).toBe(false)
|
||||||
|
expect(result.panelTabs()).toEqual(["file://src/a.ts"])
|
||||||
|
expect(result.activeTab()).toBe("file://src/a.ts")
|
||||||
|
dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { same } from "@/utils/same"
|
import { same } from "@/utils/same"
|
||||||
|
import { SESSION_OPEN_FILE_TAB } from "@/context/layout-tabs"
|
||||||
|
|
||||||
|
export { SESSION_OPEN_FILE_TAB } from "@/context/layout-tabs"
|
||||||
|
|
||||||
const emptyTabs: string[] = []
|
const emptyTabs: string[] = []
|
||||||
|
|
||||||
|
|
@ -16,6 +19,7 @@ type TabsInput = {
|
||||||
normalizeTab: (tab: string) => string
|
normalizeTab: (tab: string) => string
|
||||||
review?: Accessor<boolean>
|
review?: Accessor<boolean>
|
||||||
hasReview?: Accessor<boolean>
|
hasReview?: Accessor<boolean>
|
||||||
|
fileBrowser?: Accessor<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}`
|
export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}`
|
||||||
|
|
@ -27,8 +31,14 @@ export function shouldShowFileTree(input: { visible: boolean; opened: boolean })
|
||||||
export const createSessionTabs = (input: TabsInput) => {
|
export const createSessionTabs = (input: TabsInput) => {
|
||||||
const review = input.review ?? (() => false)
|
const review = input.review ?? (() => false)
|
||||||
const hasReview = input.hasReview ?? (() => false)
|
const hasReview = input.hasReview ?? (() => false)
|
||||||
|
const fileBrowser = input.fileBrowser ?? (() => false)
|
||||||
const contextOpen = createMemo(() => input.tabs().active() === "context" || input.tabs().all().includes("context"))
|
const contextOpen = createMemo(() => input.tabs().active() === "context" || input.tabs().all().includes("context"))
|
||||||
const openedTabs = createMemo(
|
const openFileOpen = createMemo(
|
||||||
|
() =>
|
||||||
|
fileBrowser() &&
|
||||||
|
(input.tabs().active() === SESSION_OPEN_FILE_TAB || input.tabs().all().includes(SESSION_OPEN_FILE_TAB)),
|
||||||
|
)
|
||||||
|
const panelTabs = createMemo(
|
||||||
() => {
|
() => {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
return input
|
return input
|
||||||
|
|
@ -36,6 +46,7 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||||
.all()
|
.all()
|
||||||
.flatMap((tab) => {
|
.flatMap((tab) => {
|
||||||
if (tab === "context" || tab === "review") return []
|
if (tab === "context" || tab === "review") return []
|
||||||
|
if (tab === SESSION_OPEN_FILE_TAB && !fileBrowser()) return []
|
||||||
const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab
|
const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab
|
||||||
if (seen.has(value)) return []
|
if (seen.has(value)) return []
|
||||||
seen.add(value)
|
seen.add(value)
|
||||||
|
|
@ -45,9 +56,13 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||||
emptyTabs,
|
emptyTabs,
|
||||||
{ equals: same },
|
{ equals: same },
|
||||||
)
|
)
|
||||||
|
const openedTabs = createMemo(() => panelTabs().filter((tab) => tab !== SESSION_OPEN_FILE_TAB), emptyTabs, {
|
||||||
|
equals: same,
|
||||||
|
})
|
||||||
const activeTab = createMemo(() => {
|
const activeTab = createMemo(() => {
|
||||||
const active = input.tabs().active()
|
const active = input.tabs().active()
|
||||||
if (active === "context") return active
|
if (active === "context") return active
|
||||||
|
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
|
||||||
if (active === "review" && review()) return active
|
if (active === "review" && review()) return active
|
||||||
if (active && input.pathFromTab(active)) return input.normalizeTab(active)
|
if (active && input.pathFromTab(active)) return input.normalizeTab(active)
|
||||||
|
|
||||||
|
|
@ -65,12 +80,15 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||||
const closableTab = createMemo(() => {
|
const closableTab = createMemo(() => {
|
||||||
const active = activeTab()
|
const active = activeTab()
|
||||||
if (active === "context") return active
|
if (active === "context") return active
|
||||||
|
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
|
||||||
if (!openedTabs().includes(active)) return
|
if (!openedTabs().includes(active)) return
|
||||||
return active
|
return active
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contextOpen,
|
contextOpen,
|
||||||
|
openFileOpen,
|
||||||
|
panelTabs,
|
||||||
openedTabs,
|
openedTabs,
|
||||||
activeTab,
|
activeTab,
|
||||||
activeFileTab,
|
activeFileTab,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { createStore } from "solid-js/store"
|
||||||
import { createMediaQuery } from "@solid-primitives/media"
|
import { createMediaQuery } from "@solid-primitives/media"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||||
import { Mark } from "@opencode-ai/ui/logo"
|
import { Mark } from "@opencode-ai/ui/logo"
|
||||||
|
|
@ -23,10 +24,10 @@ import { useFile, type SelectedLineRange } from "@/context/file"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useSync } from "@/context/sync"
|
|
||||||
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
||||||
import { FileTabContent } from "@/pages/session/file-tabs"
|
import { FileTabContent } from "@/pages/session/file-tabs"
|
||||||
import {
|
import {
|
||||||
|
SESSION_OPEN_FILE_TAB,
|
||||||
createOpenSessionFileTab,
|
createOpenSessionFileTab,
|
||||||
createSessionTabs,
|
createSessionTabs,
|
||||||
getTabReorderIndex,
|
getTabReorderIndex,
|
||||||
|
|
@ -35,6 +36,7 @@ import {
|
||||||
} from "@/pages/session/helpers"
|
} from "@/pages/session/helpers"
|
||||||
import { setSessionHandoff } from "@/pages/session/handoff"
|
import { setSessionHandoff } from "@/pages/session/handoff"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
|
import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab"
|
||||||
|
|
||||||
type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
|
type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
|
||||||
|
|
||||||
|
|
@ -51,6 +53,7 @@ export function SessionSidePanel(props: {
|
||||||
reviewHasFocusableContent: () => boolean
|
reviewHasFocusableContent: () => boolean
|
||||||
reviewCount: () => number
|
reviewCount: () => number
|
||||||
reviewPanel: () => JSX.Element
|
reviewPanel: () => JSX.Element
|
||||||
|
fileBrowserState?: SessionFileBrowserState
|
||||||
activeDiff?: string
|
activeDiff?: string
|
||||||
focusReviewDiff: (path: string) => void
|
focusReviewDiff: (path: string) => void
|
||||||
reviewSnap: boolean
|
reviewSnap: boolean
|
||||||
|
|
@ -59,7 +62,6 @@ export function SessionSidePanel(props: {
|
||||||
}) {
|
}) {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const sync = useSync()
|
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
|
|
@ -155,8 +157,10 @@ export function SessionSidePanel(props: {
|
||||||
normalizeTab,
|
normalizeTab,
|
||||||
review: reviewTab,
|
review: reviewTab,
|
||||||
hasReview: props.canReview,
|
hasReview: props.canReview,
|
||||||
|
fileBrowser: () => !!props.fileBrowserState,
|
||||||
})
|
})
|
||||||
const contextOpen = tabState.contextOpen
|
const contextOpen = tabState.contextOpen
|
||||||
|
const panelTabs = tabState.panelTabs
|
||||||
const openedTabs = tabState.openedTabs
|
const openedTabs = tabState.openedTabs
|
||||||
const activeTab = tabState.activeTab
|
const activeTab = tabState.activeTab
|
||||||
const activeFileTab = tabState.activeFileTab
|
const activeFileTab = tabState.activeFileTab
|
||||||
|
|
@ -180,6 +184,33 @@ export function SessionSidePanel(props: {
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
activeDraggable: undefined as string | undefined,
|
activeDraggable: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
let fileFilter: HTMLInputElement | 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 browserTab = createMemo(() => {
|
||||||
|
if (!props.fileBrowserState) return undefined
|
||||||
|
if (activeTab() === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
|
||||||
|
return activeFileTab()
|
||||||
|
})
|
||||||
|
const browserKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix")))
|
||||||
|
|
||||||
const handleDragStart = (event: unknown) => {
|
const handleDragStart = (event: unknown) => {
|
||||||
const id = getDraggableId(event)
|
const id = getDraggableId(event)
|
||||||
|
|
@ -265,7 +296,7 @@ export function SessionSidePanel(props: {
|
||||||
>
|
>
|
||||||
<DragDropSensors />
|
<DragDropSensors />
|
||||||
<ConstrainDragYAxis />
|
<ConstrainDragYAxis />
|
||||||
<Tabs value={activeTab()} onChange={openTab}>
|
<Tabs value={activeTab()} onChange={activateTab}>
|
||||||
<div class="sticky top-0 shrink-0 flex">
|
<div class="sticky top-0 shrink-0 flex">
|
||||||
<Tabs.List
|
<Tabs.List
|
||||||
ref={(el: HTMLDivElement) => {
|
ref={(el: HTMLDivElement) => {
|
||||||
|
|
@ -316,7 +347,48 @@ export function SessionSidePanel(props: {
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
</Show>
|
</Show>
|
||||||
<SortableProvider ids={openedTabs()}>
|
<SortableProvider ids={openedTabs()}>
|
||||||
<For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
|
<For each={panelTabs()}>
|
||||||
|
{(tab) => (
|
||||||
|
<Show
|
||||||
|
when={tab === SESSION_OPEN_FILE_TAB}
|
||||||
|
fallback={
|
||||||
|
<SortableTab
|
||||||
|
tab={tab}
|
||||||
|
temporary={temporaryTab() === tab}
|
||||||
|
onTabClose={tabs().close}
|
||||||
|
onTabDoubleClick={temporaryTab() === tab ? openTab : undefined}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Tabs.Trigger
|
||||||
|
value={SESSION_OPEN_FILE_TAB}
|
||||||
|
closeButton={
|
||||||
|
<TooltipKeybind
|
||||||
|
title={language.t("common.closeTab")}
|
||||||
|
keybind={command.keybind("tab.close")}
|
||||||
|
placement="bottom"
|
||||||
|
gutter={10}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon="close-small"
|
||||||
|
variant="ghost"
|
||||||
|
class="h-5 w-5"
|
||||||
|
onClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
|
||||||
|
aria-label={language.t("common.closeTab")}
|
||||||
|
/>
|
||||||
|
</TooltipKeybind>
|
||||||
|
}
|
||||||
|
hideCloseButton
|
||||||
|
onMiddleClick={() => tabs().close(SESSION_OPEN_FILE_TAB)}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-1.5 italic">
|
||||||
|
<Icon name="open-file" size="small" />
|
||||||
|
<span>{language.t("command.file.open")}</span>
|
||||||
|
</div>
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
</SortableProvider>
|
</SortableProvider>
|
||||||
<div class="bg-background-stronger h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3">
|
<div class="bg-background-stronger h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3">
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
|
|
@ -330,6 +402,10 @@ export function SessionSidePanel(props: {
|
||||||
iconSize="large"
|
iconSize="large"
|
||||||
class="!rounded-md"
|
class="!rounded-md"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (props.fileBrowserState) {
|
||||||
|
openFileBrowser()
|
||||||
|
return
|
||||||
|
}
|
||||||
void import("@/components/dialog-select-file").then((x) => {
|
void import("@/components/dialog-select-file").then((x) => {
|
||||||
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
|
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
|
||||||
})
|
})
|
||||||
|
|
@ -380,7 +456,20 @@ export function SessionSidePanel(props: {
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={activeFileTab()} keyed>
|
<Show when={browserTab()}>
|
||||||
|
<SessionFileBrowserTab
|
||||||
|
tab={browserTab()!}
|
||||||
|
placeholder={browserTab() === SESSION_OPEN_FILE_TAB}
|
||||||
|
active={file.pathFromTab(browserTab()!)}
|
||||||
|
kinds={browserKinds()}
|
||||||
|
state={props.fileBrowserState!}
|
||||||
|
onSelect={(path) => previewTab(file.tab(path))}
|
||||||
|
onSelectPermanent={(path) => openTab(file.tab(path))}
|
||||||
|
filterRef={(element) => (fileFilter = element)}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!props.fileBrowserState && activeFileTab()} keyed>
|
||||||
{(tab) => <FileTabContent tab={tab} />}
|
{(tab) => <FileTabContent tab={tab} />}
|
||||||
</Show>
|
</Show>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
@ -390,7 +479,9 @@ export function SessionSidePanel(props: {
|
||||||
const path = file.pathFromTab(tab)
|
const path = file.pathFromTab(tab)
|
||||||
return (
|
return (
|
||||||
<div data-component="tabs-drag-preview">
|
<div data-component="tabs-drag-preview">
|
||||||
<Show when={path}>{(p) => <FileVisual active path={p()} />}</Show>
|
<Show when={path}>
|
||||||
|
{(p) => <FileVisual active path={p()} temporary={temporaryTab() === tab} />}
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ export type SessionCommandContext = {
|
||||||
setActiveMessage: (message: UserMessage | undefined) => void
|
setActiveMessage: (message: UserMessage | undefined) => void
|
||||||
focusInput: () => void
|
focusInput: () => void
|
||||||
review?: () => boolean
|
review?: () => boolean
|
||||||
|
fileBrowser?: () => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const withCategory = (category: string) => {
|
const withCategory = (category: string) => {
|
||||||
|
|
@ -83,6 +84,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
normalizeTab,
|
normalizeTab,
|
||||||
review: actions.review,
|
review: actions.review,
|
||||||
hasReview,
|
hasReview,
|
||||||
|
fileBrowser: actions.fileBrowser,
|
||||||
})
|
})
|
||||||
const activeFileTab = tabState.activeFileTab
|
const activeFileTab = tabState.activeFileTab
|
||||||
const closableTab = tabState.closableTab
|
const closableTab = tabState.closableTab
|
||||||
|
|
|
||||||
193
packages/app/src/pages/session/v2/session-file-browser-tab.tsx
Normal file
193
packages/app/src/pages/session/v2/session-file-browser-tab.tsx
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
import { createMemo, createSignal, createUniqueId, Show } from "solid-js"
|
||||||
|
import { createQuery } from "@tanstack/solid-query"
|
||||||
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import {
|
||||||
|
SessionFilePanelV2,
|
||||||
|
SessionFilePanelV2Empty,
|
||||||
|
SessionFilePanelV2Title,
|
||||||
|
} from "@opencode-ai/session-ui/v2/session-file-panel-v2"
|
||||||
|
import { SessionReviewV2Sidebar, SessionReviewV2SidebarToggle } from "@opencode-ai/session-ui/v2/session-review-v2"
|
||||||
|
import FileTree, { type Kind } from "@/components/file-tree"
|
||||||
|
import { useFile } from "@/context/file"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useLayout } from "@/context/layout"
|
||||||
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { displayName } from "@/pages/layout/helpers"
|
||||||
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
|
import { SessionFileView } from "@/pages/session/file-tabs"
|
||||||
|
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
|
||||||
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
|
||||||
|
const emptyFiles: string[] = []
|
||||||
|
|
||||||
|
export type SessionFileBrowserState = {
|
||||||
|
sidebarOpened: () => boolean
|
||||||
|
sidebarWidth: () => number
|
||||||
|
resizeSidebar: (width: number) => void
|
||||||
|
toggleSidebar: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionFileBrowserTab(props: {
|
||||||
|
tab: string
|
||||||
|
placeholder: boolean
|
||||||
|
active?: string
|
||||||
|
kinds: ReadonlyMap<string, Kind>
|
||||||
|
state: SessionFileBrowserState
|
||||||
|
onSelect: (path: string) => void
|
||||||
|
onSelectPermanent: (path: string) => void
|
||||||
|
filterRef?: (element: HTMLInputElement) => void
|
||||||
|
}) {
|
||||||
|
const file = useFile()
|
||||||
|
const language = useLanguage()
|
||||||
|
const layout = useLayout()
|
||||||
|
const sdk = useSDK()
|
||||||
|
const { workspaceKey } = useSessionLayout()
|
||||||
|
const resultsID = `session-file-browser-results-${createUniqueId()}`
|
||||||
|
const [filter, setFilter] = createSignal("")
|
||||||
|
const [explicitHighlight, setExplicitHighlight] = createSignal<string>()
|
||||||
|
const query = createMemo(() => filter().trim())
|
||||||
|
const search = createQuery(() => {
|
||||||
|
const value = query()
|
||||||
|
return {
|
||||||
|
queryKey: ["session-open-file", workspaceKey(), value] as const,
|
||||||
|
enabled: value.length > 0,
|
||||||
|
queryFn: ({ signal }) => file.searchFiles(value, { limit: 200, signal }),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const files = createMemo(() => {
|
||||||
|
if (!query() || search.isPending) return emptyFiles
|
||||||
|
return [...new Set(search.data ?? emptyFiles)]
|
||||||
|
})
|
||||||
|
const highlighted = createMemo(() => {
|
||||||
|
const values = files()
|
||||||
|
if (values.length === 0) return undefined
|
||||||
|
const explicit = explicitHighlight()
|
||||||
|
if (explicit && values.includes(explicit)) return explicit
|
||||||
|
return values[0]
|
||||||
|
})
|
||||||
|
const loading = createMemo(() => query().length > 0 && search.isPending)
|
||||||
|
const project = createMemo(() => {
|
||||||
|
const directory = pathKey(sdk().directory)
|
||||||
|
return layout.projects
|
||||||
|
.list()
|
||||||
|
.find(
|
||||||
|
(item) =>
|
||||||
|
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const title = createMemo(() => displayName(project() ?? { worktree: sdk().directory }))
|
||||||
|
const optionID = (path: string) => `${resultsID}-option-${files().indexOf(path)}`
|
||||||
|
|
||||||
|
const onFilterKeyDown = (event: KeyboardEvent & { currentTarget: HTMLInputElement }) => {
|
||||||
|
if (event.key === "Escape" && query()) {
|
||||||
|
event.preventDefault()
|
||||||
|
setFilter("")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!query()) return
|
||||||
|
applyFileListKeyDown(event, files(), highlighted(), {
|
||||||
|
onHighlight: setExplicitHighlight,
|
||||||
|
onSelect: props.onSelectPermanent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs.Content value={props.tab} class="h-full min-h-0 overflow-hidden">
|
||||||
|
<SessionFilePanelV2
|
||||||
|
toolbar
|
||||||
|
toolbarStart={
|
||||||
|
<>
|
||||||
|
<SessionReviewV2SidebarToggle opened={props.state.sidebarOpened()} onToggle={props.state.toggleSidebar} />
|
||||||
|
<Show when={!props.state.sidebarOpened()}>
|
||||||
|
<SessionFilePanelV2Title>{title()}</SessionFilePanelV2Title>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
sidebar={
|
||||||
|
<SessionReviewV2Sidebar
|
||||||
|
open={props.state.sidebarOpened()}
|
||||||
|
title={<span class="truncate">{title()}</span>}
|
||||||
|
filter={filter()}
|
||||||
|
onFilterChange={setFilter}
|
||||||
|
onFilterKeyDown={onFilterKeyDown}
|
||||||
|
filterAutofocus={props.placeholder}
|
||||||
|
filterRef={props.filterRef}
|
||||||
|
filterControls={resultsID}
|
||||||
|
filterActiveDescendant={highlighted() ? optionID(highlighted()!) : undefined}
|
||||||
|
filterExpanded={query().length > 0 && files().length > 0}
|
||||||
|
width={props.state.sidebarWidth()}
|
||||||
|
onWidthChange={props.state.resizeSidebar}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={query()}
|
||||||
|
fallback={
|
||||||
|
<FileTree
|
||||||
|
path=""
|
||||||
|
class="pt-1"
|
||||||
|
active={props.active}
|
||||||
|
kinds={props.kinds}
|
||||||
|
onFileClick={(node) => props.onSelect(node.path)}
|
||||||
|
onFileDoubleClick={(node) => props.onSelectPermanent(node.path)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={!loading()}
|
||||||
|
fallback={
|
||||||
|
<div role="status" class="px-2 py-2 text-12-regular text-text-weak">
|
||||||
|
{language.t("common.loading")}
|
||||||
|
{language.t("common.loading.ellipsis")}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={files().length > 0}
|
||||||
|
fallback={
|
||||||
|
<div role="status" class="px-2 py-2 text-12-regular text-text-weak">
|
||||||
|
{language.t("palette.empty")}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SessionFileListV2
|
||||||
|
id={resultsID}
|
||||||
|
role="listbox"
|
||||||
|
optionID={optionID}
|
||||||
|
files={files()}
|
||||||
|
kinds={props.kinds}
|
||||||
|
active={props.active}
|
||||||
|
highlighted={highlighted()}
|
||||||
|
onFileClick={(path) => {
|
||||||
|
setExplicitHighlight(path)
|
||||||
|
props.onSelect(path)
|
||||||
|
}}
|
||||||
|
onFileDoubleClick={props.onSelectPermanent}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</SessionReviewV2Sidebar>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={!props.placeholder}
|
||||||
|
fallback={
|
||||||
|
<SessionFilePanelV2Empty>
|
||||||
|
<div class="flex flex-col items-center gap-3 text-center text-text-weak">
|
||||||
|
<Icon name="file-tree" size="large" />
|
||||||
|
<div class="text-14-medium text-text-strong">{language.t("command.file.open")}</div>
|
||||||
|
<div class="text-13-regular">{language.t("session.files.selectToOpen")}</div>
|
||||||
|
</div>
|
||||||
|
</SessionFilePanelV2Empty>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="min-h-0 flex-1">
|
||||||
|
<Show when={props.tab} keyed>
|
||||||
|
{(tab) => <SessionFileView tab={tab} />}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</SessionFilePanelV2>
|
||||||
|
</Tabs.Content>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -43,7 +43,11 @@ export function SessionFileListV2(props: {
|
||||||
active?: string
|
active?: string
|
||||||
highlighted?: string
|
highlighted?: string
|
||||||
kinds?: ReadonlyMap<string, Kind>
|
kinds?: ReadonlyMap<string, Kind>
|
||||||
|
id?: string
|
||||||
|
role?: "listbox"
|
||||||
|
optionID?: (path: string) => string
|
||||||
onFileClick: (path: string) => void
|
onFileClick: (path: string) => void
|
||||||
|
onFileDoubleClick?: (path: string) => void
|
||||||
}) {
|
}) {
|
||||||
const active = () => normalizePath(props.active ?? "")
|
const active = () => normalizePath(props.active ?? "")
|
||||||
const highlighted = () => normalizePath(props.highlighted ?? "")
|
const highlighted = () => normalizePath(props.highlighted ?? "")
|
||||||
|
|
@ -88,6 +92,8 @@ export function SessionFileListV2(props: {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setRoot}
|
ref={setRoot}
|
||||||
|
id={props.id}
|
||||||
|
role={props.role}
|
||||||
data-component="file-tree-v2"
|
data-component="file-tree-v2"
|
||||||
data-total-rows={props.files.length}
|
data-total-rows={props.files.length}
|
||||||
style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
|
style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
|
||||||
|
|
@ -116,6 +122,9 @@ export function SessionFileListV2(props: {
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
id={props.optionID?.(path)}
|
||||||
|
role={props.role ? "option" : undefined}
|
||||||
|
aria-selected={props.role ? selected() : undefined}
|
||||||
data-slot="file-tree-v2-row"
|
data-slot="file-tree-v2-row"
|
||||||
data-path={path}
|
data-path={path}
|
||||||
data-selected={selected() ? "" : undefined}
|
data-selected={selected() ? "" : undefined}
|
||||||
|
|
@ -124,6 +133,7 @@ export function SessionFileListV2(props: {
|
||||||
onFocus={() => setFocused(path)}
|
onFocus={() => setFocused(path)}
|
||||||
onBlur={() => setFocused(undefined)}
|
onBlur={() => setFocused(undefined)}
|
||||||
onClick={() => props.onFileClick(path)}
|
onClick={() => props.onFileClick(path)}
|
||||||
|
onDblClick={() => props.onFileDoubleClick?.(path)}
|
||||||
>
|
>
|
||||||
<span class="filetree-iconpair size-4">
|
<span class="filetree-iconpair size-4">
|
||||||
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--color" />
|
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--color" />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { Show, type JSX, type ParentProps } from "solid-js"
|
||||||
|
import "./session-review-v2.css"
|
||||||
|
|
||||||
|
export function SessionFilePanelV2(props: {
|
||||||
|
sidebar?: JSX.Element
|
||||||
|
toolbar: boolean
|
||||||
|
toolbarStart?: JSX.Element
|
||||||
|
toolbarEnd?: JSX.Element
|
||||||
|
children?: JSX.Element
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div data-component="session-review-v2">
|
||||||
|
<div data-slot="session-review-v2-body">
|
||||||
|
{props.sidebar}
|
||||||
|
<div data-slot="session-review-v2-preview">
|
||||||
|
<Show when={props.toolbar}>
|
||||||
|
<div data-slot="session-review-v2-toolbar">
|
||||||
|
<div data-slot="session-review-v2-toolbar-group" class="session-review-v2-toolbar-group--start">
|
||||||
|
{props.toolbarStart}
|
||||||
|
</div>
|
||||||
|
<Show when={props.toolbarEnd}>
|
||||||
|
{(toolbar) => (
|
||||||
|
<div data-slot="session-review-v2-toolbar-group" class="session-review-v2-toolbar-group--segments">
|
||||||
|
{toolbar()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionFilePanelV2Title(props: ParentProps) {
|
||||||
|
return <div data-slot="session-review-v2-toolbar-title">{props.children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionFilePanelV2Empty(props: ParentProps) {
|
||||||
|
return <div data-slot="session-review-v2-empty">{props.children}</div>
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { Show, createEffect, createMemo, createSignal, type JSX } from "solid-js"
|
import { Show, createEffect, createMemo, createSignal, type JSX } from "solid-js"
|
||||||
import { getWorkerPool } from "../../pierre/worker"
|
import { getWorkerPool } from "../../pierre/worker"
|
||||||
import "./session-review-v2.css"
|
import { SessionFilePanelV2, SessionFilePanelV2Empty } from "./session-file-panel-v2"
|
||||||
|
|
||||||
export const SESSION_REVIEW_V2_SIDEBAR_WIDTH_DEFAULT = 240
|
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_MIN = 200
|
||||||
|
|
@ -45,6 +45,11 @@ export type SessionReviewV2SidebarProps = {
|
||||||
filter: string
|
filter: string
|
||||||
onFilterChange: (value: string) => void
|
onFilterChange: (value: string) => void
|
||||||
onFilterKeyDown?: JSX.EventHandlerUnion<HTMLInputElement, KeyboardEvent>
|
onFilterKeyDown?: JSX.EventHandlerUnion<HTMLInputElement, KeyboardEvent>
|
||||||
|
filterAutofocus?: boolean
|
||||||
|
filterRef?: (element: HTMLInputElement) => void
|
||||||
|
filterControls?: string
|
||||||
|
filterActiveDescendant?: string
|
||||||
|
filterExpanded?: boolean
|
||||||
width?: number
|
width?: number
|
||||||
onWidthChange?: (width: number) => void
|
onWidthChange?: (width: number) => void
|
||||||
minWidth?: number
|
minWidth?: number
|
||||||
|
|
@ -86,6 +91,13 @@ export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) {
|
||||||
value={props.filter}
|
value={props.filter}
|
||||||
onInput={(event) => props.onFilterChange(event.currentTarget.value)}
|
onInput={(event) => props.onFilterChange(event.currentTarget.value)}
|
||||||
onKeyDown={props.onFilterKeyDown}
|
onKeyDown={props.onFilterKeyDown}
|
||||||
|
autofocus={props.filterAutofocus}
|
||||||
|
ref={props.filterRef}
|
||||||
|
role={props.filterControls ? "combobox" : undefined}
|
||||||
|
aria-autocomplete={props.filterControls ? "list" : undefined}
|
||||||
|
aria-controls={props.filterControls}
|
||||||
|
aria-activedescendant={props.filterActiveDescendant}
|
||||||
|
aria-expanded={props.filterControls ? props.filterExpanded : undefined}
|
||||||
showClearButton={props.filter.length > 0}
|
showClearButton={props.filter.length > 0}
|
||||||
clearLabel={i18n.t("ui.list.clearFilter")}
|
clearLabel={i18n.t("ui.list.clearFilter")}
|
||||||
onClearClick={() => props.onFilterChange("")}
|
onClearClick={() => props.onFilterChange("")}
|
||||||
|
|
@ -185,130 +197,125 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
|
||||||
cycle(event.key === "<" ? prev() : next())
|
cycle(event.key === "<" ? prev() : next())
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
const toolbarStart = () => (
|
||||||
<div data-component="session-review-v2">
|
<>
|
||||||
<div data-slot="session-review-v2-body">
|
{props.sidebarToggle}
|
||||||
{props.sidebar}
|
<Show when={showCollapsedMeta()}>
|
||||||
|
<div data-slot="session-review-v2-toolbar-collapsed-meta">
|
||||||
<div data-slot="session-review-v2-preview">
|
<Show when={title()}>
|
||||||
<Show when={props.hasDiffs} fallback={props.empty}>
|
<div data-slot="session-review-v2-toolbar-title">{title()}</div>
|
||||||
<div data-slot="session-review-v2-toolbar">
|
</Show>
|
||||||
<div data-slot="session-review-v2-toolbar-group" class="session-review-v2-toolbar-group--start">
|
{stats()}
|
||||||
{props.sidebarToggle}
|
<Show when={canCycle()}>
|
||||||
<Show when={showCollapsedMeta()}>
|
<span data-slot="session-review-v2-file-position">
|
||||||
<div data-slot="session-review-v2-toolbar-collapsed-meta">
|
{fileIndex() + 1}/{props.files.length}
|
||||||
<Show when={title()}>
|
</span>
|
||||||
<div data-slot="session-review-v2-toolbar-title">{title()}</div>
|
|
||||||
</Show>
|
|
||||||
{stats()}
|
|
||||||
<Show when={canCycle()}>
|
|
||||||
<span data-slot="session-review-v2-file-position">
|
|
||||||
{fileIndex() + 1}/{props.files.length}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<div data-slot="session-review-v2-toolbar-group">
|
|
||||||
<TooltipV2
|
|
||||||
openDelay={2000}
|
|
||||||
value={
|
|
||||||
<>
|
|
||||||
{i18n.t("ui.sessionReviewV2.previousFile")}
|
|
||||||
<KeybindV2 keys={["<"]} variant="neutral" />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
icon="arrow-left"
|
|
||||||
variant="ghost"
|
|
||||||
size="small"
|
|
||||||
class="session-review-v2-file-nav-button"
|
|
||||||
disabled={!canCycle()}
|
|
||||||
onClick={() => {
|
|
||||||
const file = prev()
|
|
||||||
if (!file) return
|
|
||||||
props.onSelectFile(file)
|
|
||||||
}}
|
|
||||||
aria-label={i18n.t("ui.sessionReviewV2.previousFile")}
|
|
||||||
/>
|
|
||||||
</TooltipV2>
|
|
||||||
<TooltipV2
|
|
||||||
openDelay={2000}
|
|
||||||
value={
|
|
||||||
<>
|
|
||||||
{i18n.t("ui.sessionReviewV2.nextFile")}
|
|
||||||
<KeybindV2 keys={[">"]} variant="neutral" />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
icon="arrow-right"
|
|
||||||
variant="ghost"
|
|
||||||
size="small"
|
|
||||||
class="session-review-v2-file-nav-button"
|
|
||||||
disabled={!canCycle()}
|
|
||||||
onClick={() => {
|
|
||||||
const file = next()
|
|
||||||
if (!file) return
|
|
||||||
props.onSelectFile(file)
|
|
||||||
}}
|
|
||||||
aria-label={i18n.t("ui.sessionReviewV2.nextFile")}
|
|
||||||
/>
|
|
||||||
</TooltipV2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div data-slot="session-review-v2-toolbar-group" class="session-review-v2-toolbar-group--segments">
|
|
||||||
<SegmentedControlV2
|
|
||||||
value={props.expandMode}
|
|
||||||
onChange={(value) => {
|
|
||||||
if (value !== "expand" && value !== "collapse") return
|
|
||||||
props.onExpandModeChange(value)
|
|
||||||
}}
|
|
||||||
class="session-review-v2-segmented-control session-review-v2-segmented-control--icon"
|
|
||||||
aria-label={i18n.t("ui.sessionReviewV2.expandMode")}
|
|
||||||
>
|
|
||||||
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.showAllLines")}>
|
|
||||||
<SegmentedControlItemV2 value="expand" aria-label={i18n.t("ui.sessionReviewV2.showAllLines")}>
|
|
||||||
<Icon name="expand" />
|
|
||||||
</SegmentedControlItemV2>
|
|
||||||
</TooltipV2>
|
|
||||||
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
|
|
||||||
<SegmentedControlItemV2 value="collapse" aria-label={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
|
|
||||||
<Icon name="collapse" />
|
|
||||||
</SegmentedControlItemV2>
|
|
||||||
</TooltipV2>
|
|
||||||
</SegmentedControlV2>
|
|
||||||
<Show when={props.onDiffStyleChange}>
|
|
||||||
<SegmentedControlV2
|
|
||||||
value={props.diffStyle}
|
|
||||||
onChange={(value) => {
|
|
||||||
if (value !== "unified" && value !== "split") return
|
|
||||||
props.onDiffStyleChange?.(value)
|
|
||||||
}}
|
|
||||||
class="session-review-v2-segmented-control session-review-v2-segmented-control--icon"
|
|
||||||
aria-label={i18n.t("ui.sessionReviewV2.diffView")}
|
|
||||||
>
|
|
||||||
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
|
|
||||||
<SegmentedControlItemV2 value="unified" aria-label={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
|
|
||||||
<Icon name="unified" />
|
|
||||||
</SegmentedControlItemV2>
|
|
||||||
</TooltipV2>
|
|
||||||
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.splitDiff")}>
|
|
||||||
<SegmentedControlItemV2 value="split" aria-label={i18n.t("ui.sessionReviewV2.splitDiff")}>
|
|
||||||
<Icon name="split" />
|
|
||||||
</SegmentedControlItemV2>
|
|
||||||
</TooltipV2>
|
|
||||||
</SegmentedControlV2>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Show when={props.activeFile} fallback={<div data-slot="session-review-v2-empty">{props.empty}</div>}>
|
|
||||||
{props.preview}
|
|
||||||
</Show>
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<TooltipV2
|
||||||
|
openDelay={2000}
|
||||||
|
value={
|
||||||
|
<>
|
||||||
|
{i18n.t("ui.sessionReviewV2.previousFile")}
|
||||||
|
<KeybindV2 keys={["<"]} variant="neutral" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon="arrow-left"
|
||||||
|
variant="ghost"
|
||||||
|
size="small"
|
||||||
|
class="session-review-v2-file-nav-button"
|
||||||
|
disabled={!canCycle()}
|
||||||
|
onClick={() => cycle(prev())}
|
||||||
|
aria-label={i18n.t("ui.sessionReviewV2.previousFile")}
|
||||||
|
/>
|
||||||
|
</TooltipV2>
|
||||||
|
<TooltipV2
|
||||||
|
openDelay={2000}
|
||||||
|
value={
|
||||||
|
<>
|
||||||
|
{i18n.t("ui.sessionReviewV2.nextFile")}
|
||||||
|
<KeybindV2 keys={[">"]} variant="neutral" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon="arrow-right"
|
||||||
|
variant="ghost"
|
||||||
|
size="small"
|
||||||
|
class="session-review-v2-file-nav-button"
|
||||||
|
disabled={!canCycle()}
|
||||||
|
onClick={() => cycle(next())}
|
||||||
|
aria-label={i18n.t("ui.sessionReviewV2.nextFile")}
|
||||||
|
/>
|
||||||
|
</TooltipV2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
const toolbarEnd = () => (
|
||||||
|
<>
|
||||||
|
<SegmentedControlV2
|
||||||
|
value={props.expandMode}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (value !== "expand" && value !== "collapse") return
|
||||||
|
props.onExpandModeChange(value)
|
||||||
|
}}
|
||||||
|
class="session-review-v2-segmented-control session-review-v2-segmented-control--icon"
|
||||||
|
aria-label={i18n.t("ui.sessionReviewV2.expandMode")}
|
||||||
|
>
|
||||||
|
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.showAllLines")}>
|
||||||
|
<SegmentedControlItemV2 value="expand" aria-label={i18n.t("ui.sessionReviewV2.showAllLines")}>
|
||||||
|
<Icon name="expand" />
|
||||||
|
</SegmentedControlItemV2>
|
||||||
|
</TooltipV2>
|
||||||
|
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
|
||||||
|
<SegmentedControlItemV2 value="collapse" aria-label={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
|
||||||
|
<Icon name="collapse" />
|
||||||
|
</SegmentedControlItemV2>
|
||||||
|
</TooltipV2>
|
||||||
|
</SegmentedControlV2>
|
||||||
|
<Show when={props.onDiffStyleChange}>
|
||||||
|
<SegmentedControlV2
|
||||||
|
value={props.diffStyle}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (value !== "unified" && value !== "split") return
|
||||||
|
props.onDiffStyleChange?.(value)
|
||||||
|
}}
|
||||||
|
class="session-review-v2-segmented-control session-review-v2-segmented-control--icon"
|
||||||
|
aria-label={i18n.t("ui.sessionReviewV2.diffView")}
|
||||||
|
>
|
||||||
|
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
|
||||||
|
<SegmentedControlItemV2 value="unified" aria-label={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
|
||||||
|
<Icon name="unified" />
|
||||||
|
</SegmentedControlItemV2>
|
||||||
|
</TooltipV2>
|
||||||
|
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.splitDiff")}>
|
||||||
|
<SegmentedControlItemV2 value="split" aria-label={i18n.t("ui.sessionReviewV2.splitDiff")}>
|
||||||
|
<Icon name="split" />
|
||||||
|
</SegmentedControlItemV2>
|
||||||
|
</TooltipV2>
|
||||||
|
</SegmentedControlV2>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SessionFilePanelV2
|
||||||
|
sidebar={props.sidebar}
|
||||||
|
toolbar={props.hasDiffs}
|
||||||
|
toolbarStart={toolbarStart()}
|
||||||
|
toolbarEnd={toolbarEnd()}
|
||||||
|
>
|
||||||
|
<Show when={props.hasDiffs} fallback={props.empty}>
|
||||||
|
<Show when={props.activeFile} fallback={<SessionFilePanelV2Empty>{props.empty}</SessionFilePanelV2Empty>}>
|
||||||
|
{props.preview}
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</SessionFilePanelV2>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue