Skip to content

Commit 8ce5038

Browse files
committed
Merge branch 'dev' of https://github.com/anomalyco/opencode into interface-toggle
2 parents 446510a + 49028b6 commit 8ce5038

35 files changed

Lines changed: 2371 additions & 452 deletions
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { base64Encode } from "@opencode-ai/core/util/encode"
2+
import { expect, test, type Page } from "@playwright/test"
3+
import { mockOpenCodeServer } from "../utils/mock-server"
4+
import { expectSessionTitle } from "../utils/waits"
5+
6+
const directory = "C:/OpenCode/FileBrowserSidebar"
7+
const projectID = "proj_file_browser_sidebar"
8+
const sessionID = "ses_file_browser_sidebar"
9+
const title = "File browser sidebar"
10+
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
11+
const files = Array.from({ length: 80 }, (_, index) => `file-${String(index).padStart(2, "0")}.ts`)
12+
// Marks the file-browser sidebar DOM node so a remount (fresh node) is detectable.
13+
const PROBE = "original"
14+
15+
test.use({ viewport: { width: 1440, height: 900 } })
16+
17+
// The file-browser sidebar must stay mounted across preview/pinned file-tab
18+
// switches. Remounting resets scroll and filter state.
19+
test("keeps the file-browser sidebar mounted when switching file tabs", async ({ page }) => {
20+
await setup(page)
21+
22+
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
23+
await expectSessionTitle(page, title)
24+
25+
const panel = page.locator("#review-panel")
26+
await panel.getByRole("button", { name: "Open file" }).click()
27+
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
28+
29+
const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]')
30+
await expect(sidebar).toBeVisible()
31+
await expect(panel.getByRole("button", { name: "file-00.ts" })).toBeVisible()
32+
33+
await panel.getByRole("button", { name: "file-00.ts" }).click()
34+
await expect(panel.getByRole("tab", { name: "file-00.ts" })).toHaveAttribute("data-selected", "")
35+
await expect(panel.getByText("contents:file-00.ts", { exact: true })).toBeVisible()
36+
37+
const viewport = panel.locator('[data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
38+
await viewport.hover()
39+
await page.mouse.wheel(0, 100_000)
40+
await expect
41+
.poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
42+
.toBeLessThanOrEqual(1)
43+
const scrolled = await viewport.evaluate((element) => element.scrollTop)
44+
expect(scrolled).toBeGreaterThan(0)
45+
await writeProbe(page)
46+
47+
await panel.getByRole("button", { name: "file-79.ts" }).click()
48+
await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "")
49+
await expect(panel.getByText("contents:file-79.ts", { exact: true })).toBeVisible()
50+
expect(await readProbe(page)).toBe(PROBE)
51+
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
52+
53+
await panel.getByRole("button", { name: "file-78.ts" }).dblclick()
54+
await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "")
55+
await panel.getByRole("button", { name: "file-79.ts" }).click()
56+
await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "")
57+
await panel.getByRole("tab", { name: "file-78.ts" }).click()
58+
await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "")
59+
expect(await readProbe(page)).toBe(PROBE)
60+
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
61+
})
62+
63+
type Probed = HTMLElement & { __e2eProbe?: string }
64+
65+
async function writeProbe(page: Page) {
66+
await page.locator('#review-panel [data-component="session-review-v2-sidebar-root"]').evaluate((el, probe) => {
67+
;(el as Probed).__e2eProbe = probe
68+
}, PROBE)
69+
}
70+
71+
async function readProbe(page: Page) {
72+
return page
73+
.locator('#review-panel [data-component="session-review-v2-sidebar-root"]')
74+
.evaluate((el) => (el as Probed).__e2eProbe)
75+
}
76+
77+
async function setup(page: Page) {
78+
await mockOpenCodeServer(page, {
79+
directory,
80+
project: {
81+
id: projectID,
82+
worktree: directory,
83+
vcs: "git",
84+
name: "file-browser-sidebar",
85+
time: { created: 1700000000000, updated: 1700000000000 },
86+
sandboxes: [],
87+
},
88+
provider: {
89+
all: [
90+
{
91+
id: "opencode",
92+
name: "OpenCode",
93+
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
94+
},
95+
],
96+
connected: ["opencode"],
97+
default: { providerID: "opencode", modelID: "test" },
98+
},
99+
sessions: [
100+
{
101+
id: sessionID,
102+
slug: sessionID,
103+
projectID,
104+
directory,
105+
title,
106+
version: "dev",
107+
time: { created: 1700000000000, updated: 1700000000000 },
108+
},
109+
],
110+
vcsDiff: [],
111+
fileList: (path) => {
112+
if (path) return []
113+
return files.map((name) => ({
114+
name,
115+
path: name,
116+
absolute: `${directory}/${name}`,
117+
type: "file" as const,
118+
ignored: false,
119+
}))
120+
},
121+
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
122+
pageMessages: () => ({ items: [] }),
123+
})
124+
125+
await page.addInitScript(
126+
({ directory, server, sessionID }) => {
127+
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
128+
localStorage.setItem(
129+
"opencode.global.dat:server",
130+
JSON.stringify({
131+
projects: { local: [{ worktree: directory, expanded: true }] },
132+
lastProject: { local: directory },
133+
}),
134+
)
135+
localStorage.setItem(
136+
"opencode.global.dat:layout",
137+
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
138+
)
139+
localStorage.setItem(
140+
"opencode.global.dat:review-panel-v2",
141+
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
142+
)
143+
localStorage.setItem(
144+
"opencode.window.browser.dat:tabs",
145+
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
146+
)
147+
},
148+
{ directory, server, sessionID },
149+
)
150+
}

packages/app/e2e/regression/review-open-file.spec.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,15 +91,17 @@ test("opens and searches project files inline", async ({ page }) => {
9191

9292
const panel = page.locator("#review-panel")
9393
const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]')
94+
const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" })
9495
const contextButton = page.getByRole("button", { name: "View context usage" })
9596
await contextButton.click()
9697
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
9798
await panel.getByRole("button", { name: "Open file" }).click()
9899
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
100+
await expect(sidebarToggle).toBeDisabled()
99101
await expect(sidebar).toBeVisible()
100102
await contextButton.click()
101103
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
102-
await expect(sidebar).toHaveCount(0)
104+
await expect(sidebar).toBeHidden()
103105
await panel.getByRole("button", { name: "Open file" }).click()
104106
const filter = panel.getByRole("combobox", { name: "Filter files" })
105107
await expect(filter).toBeFocused()
@@ -108,6 +110,7 @@ test("opens and searches project files inline", async ({ page }) => {
108110

109111
await panel.getByRole("button", { name: "README.md" }).click()
110112
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
113+
await expect(sidebarToggle).toBeEnabled()
111114
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
112115
await expect(sidebar).toHaveCount(0)
113116

@@ -122,12 +125,17 @@ test("opens and searches project files inline", async ({ page }) => {
122125
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
123126
await filter.press("Enter")
124127
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
128+
await expect(sidebarToggle).toBeEnabled()
125129
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
126130
expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
127131

128132
await panel.getByRole("button", { name: "Open file" }).click()
129133
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
130134
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
135+
await expect(sidebarToggle).toBeDisabled()
136+
await panel.getByRole("tab", { name: /Review/ }).click()
137+
await expect(sidebarToggle).toBeEnabled()
138+
await panel.getByRole("tab", { name: "Open file" }).click()
131139
await page.keyboard.press("Control+w")
132140
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
133141
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")

packages/app/e2e/regression/review-tab-switch.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ test("keeps the v2 review pane mounted when switching session tabs in a workspac
2828
await expectSessionTitle(page, titleA)
2929

3030
await page.getByRole("button", { name: "Toggle review" }).click()
31-
const reviewTab = page.getByRole("tab", { name: /Review/ })
32-
const reviewTabPanel = page.getByRole("tabpanel", { name: /Review/ })
31+
const reviewTab = page.locator("#session-side-panel-review-tab")
32+
const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel")
3333
await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel")
3434
await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel")
3535
const review = page.locator('#review-panel [data-component="session-review-v2"]')

packages/app/e2e/regression/review-terminal-stacked.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
111111
await expectTree(page, 8, "git-0.ts")
112112

113113
await selectMode(page, "Git changes", "Branch changes")
114-
await expect(page.getByRole("tab", { name: "Review 2740" })).toBeVisible()
114+
await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740")
115115
await page.keyboard.press("Control+Backquote")
116116
await expect(page.locator("#terminal-panel")).toBeVisible()
117117
await expectTree(page, 2_773, "action.yml")
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
2+
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
3+
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
4+
import { Field } from "@opencode-ai/ui/v2/field-v2"
5+
import { Icon } from "@opencode-ai/ui/v2/icon"
6+
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2"
7+
import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2"
8+
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
9+
import { For, Show } from "solid-js"
10+
import { useLanguage } from "@/context/language"
11+
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
12+
import { ServerConnection } from "@/context/server"
13+
import { getProjectAvatarSource } from "@/pages/layout/helpers"
14+
import { createEditProjectModel } from "./edit-project"
15+
16+
export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
17+
const language = useLanguage()
18+
const model = createEditProjectModel(props)
19+
20+
return (
21+
<Dialog fit>
22+
<form onSubmit={model.submit} class="contents">
23+
<DialogHeader>
24+
<DialogTitle>{language.t("dialog.project.edit.title")}</DialogTitle>
25+
</DialogHeader>
26+
<DividerV2 />
27+
<DialogBody class="flex max-h-[min(560px,calc(100vh-160px))] w-full flex-col gap-6 overflow-y-auto px-4 pt-4 pb-1">
28+
<Field>
29+
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
30+
<TextInputV2
31+
autofocus
32+
appearance="large"
33+
class="!w-full"
34+
value={model.store.name}
35+
placeholder={model.folderName()}
36+
onInput={(event) => model.setStore("name", event.currentTarget.value)}
37+
/>
38+
</Field>
39+
40+
<div class="flex w-full flex-col gap-2">
41+
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
42+
{language.t("dialog.project.edit.icon")}
43+
</div>
44+
<div class="flex items-center gap-3">
45+
<button
46+
type="button"
47+
aria-label={language.t("dialog.project.edit.icon.alt")}
48+
class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
49+
classList={{
50+
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
51+
}}
52+
onMouseEnter={() => model.setStore("iconHover", true)}
53+
onMouseLeave={() => model.setStore("iconHover", false)}
54+
onDrop={model.drop}
55+
onDragOver={model.dragOver}
56+
onDragLeave={model.dragLeave}
57+
onClick={model.iconClick}
58+
>
59+
<ProjectAvatar
60+
fallback={model.store.name || model.defaultName()}
61+
src={getProjectAvatarSource(props.project.id, {
62+
color: model.store.color,
63+
url: props.project.icon?.url,
64+
override: model.store.iconOverride,
65+
})}
66+
variant={getProjectAvatarVariant(model.store.color)}
67+
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
68+
/>
69+
<span
70+
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
71+
classList={{
72+
"opacity-100": model.store.iconHover,
73+
"opacity-0": !model.store.iconHover,
74+
}}
75+
>
76+
<Icon name={model.store.iconOverride ? "close" : "outline-share"} />
77+
</span>
78+
</button>
79+
<input
80+
ref={(element) => {
81+
model.setIconInput(element)
82+
}}
83+
type="file"
84+
accept="image/*"
85+
class="hidden"
86+
onChange={model.inputChange}
87+
/>
88+
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
89+
<span>{language.t("dialog.project.edit.icon.hint")}</span>
90+
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
91+
</div>
92+
</div>
93+
</div>
94+
95+
<Show when={!model.store.iconOverride}>
96+
<div class="flex w-full flex-col gap-2">
97+
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
98+
{language.t("dialog.project.edit.color")}
99+
</div>
100+
<div class="-ml-1 flex gap-1.5">
101+
<For each={PROJECT_AVATAR_VARIANTS}>
102+
{(color) => (
103+
<button
104+
type="button"
105+
aria-label={language.t("dialog.project.edit.color.select", { color })}
106+
aria-pressed={getProjectAvatarVariant(model.store.color) === color}
107+
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
108+
classList={{
109+
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
110+
getProjectAvatarVariant(model.store.color) === color,
111+
}}
112+
onClick={() => {
113+
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
114+
model.setStore(
115+
"color",
116+
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
117+
)
118+
}}
119+
>
120+
<ProjectAvatar
121+
fallback={model.store.name || model.defaultName()}
122+
variant={getProjectAvatarVariant(color)}
123+
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
124+
/>
125+
</button>
126+
)}
127+
</For>
128+
</div>
129+
</div>
130+
</Show>
131+
132+
<Field>
133+
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
134+
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
135+
<TextareaV2
136+
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
137+
rows={3}
138+
value={model.store.startup}
139+
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
140+
spellcheck={false}
141+
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
142+
/>
143+
</Field>
144+
</DialogBody>
145+
<DialogFooter>
146+
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
147+
{language.t("common.cancel")}
148+
</ButtonV2>
149+
<ButtonV2 type="submit" variant="contrast" disabled={model.save.isPending}>
150+
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
151+
</ButtonV2>
152+
</DialogFooter>
153+
</form>
154+
</Dialog>
155+
)
156+
}

0 commit comments

Comments
 (0)