mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
feat(app): open the right panel on a new tab (#57094)
This commit is contained in:
committed by
GitHub
Unverified
parent
2b5c8beb71
commit
8b3bc17ee3
@@ -67,7 +67,7 @@ const layout: Layout = {
|
||||
toggle() {},
|
||||
state: () => "closed",
|
||||
stored: () => [],
|
||||
side: { opened: () => false, toggle() {} },
|
||||
side: { opened: () => false, toggle() {}, menu: () => [] },
|
||||
sidebar: { opened: () => true },
|
||||
scroll: { get: () => undefined, set() {} },
|
||||
settings() {},
|
||||
@@ -334,7 +334,7 @@ function standIn(overrides: Partial<Interface>): Interface {
|
||||
toggle() {},
|
||||
state: () => "closed",
|
||||
stored: () => [],
|
||||
side: { opened: () => true, toggle() {} },
|
||||
side: { opened: () => true, toggle() {}, menu: () => [] },
|
||||
sidebar: { opened: () => false },
|
||||
scroll: { get: () => undefined, set() {} },
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import { seed, sessionHref, type SeedInput } from "../utils/app"
|
||||
import { openFileLauncher } from "../utils/side-panel"
|
||||
import { browseFiles, openFileLauncher } from "../utils/side-panel"
|
||||
import { fixture, mockStressTimeline } from "../utils/session-fixture"
|
||||
import { fileNode } from "../utils/workspace"
|
||||
|
||||
@@ -268,7 +268,7 @@ test("the summary's changes row switches the view and keeps the side tabs", asyn
|
||||
|
||||
// After a reload forgets the preview, a stored Open file tab is still the slot a narrow-screen file open replaces.
|
||||
const launcher = panel.getByRole("tab", { name: "Open file", exact: true })
|
||||
await openFileLauncher(panel)
|
||||
await browseFiles(page)
|
||||
await expect(launcher).toHaveAttribute("aria-selected", "true")
|
||||
await expect(readme).toHaveCount(0)
|
||||
await page.reload()
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { SERVER, holdRoute, project, provider, session } from "../utils/app"
|
||||
import { openFileLauncher } from "../utils/side-panel"
|
||||
import { browseFiles, openFileLauncher, openReviewLauncher, reviewTabs } from "../utils/side-panel"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { fileDiff, fileNode, openSession } from "../utils/workspace"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
@@ -24,6 +24,8 @@ for (const view of ["desktop", "mobile"] as const) {
|
||||
else await page.getByRole("tablist", { name: "Session view" }).getByRole("tab", { name: "Changes" }).click()
|
||||
|
||||
const panel = view === "desktop" ? page.locator("#review-panel") : page.locator('[data-component="session-review"]')
|
||||
|
||||
if (view === "desktop") await openReviewLauncher(panel)
|
||||
await expect(panel.getByText("Track, review, and undo changes in this project")).toBeVisible()
|
||||
await expect(panel.getByRole("button", { name: "Git changes" })).toHaveCount(0)
|
||||
const init = panel.getByRole("button", { name: "Create Git repository" })
|
||||
@@ -45,7 +47,10 @@ test("Git initialization belongs to its session and refreshes after a session sw
|
||||
{ id: "ses_init_a", title: "Initialize Alpha" },
|
||||
{ id: "ses_init_b", title: "Initialize Beta" },
|
||||
],
|
||||
seed: { panes: { ses_init_a: { review: true }, ses_init_b: { review: true } } },
|
||||
seed: {
|
||||
panes: { ses_init_a: { review: true }, ses_init_b: { review: true } },
|
||||
storage: reviewTabs("C:/OpenCode/ReviewInitSwitch", "ses_init_a", "ses_init_b"),
|
||||
},
|
||||
})
|
||||
|
||||
// The successful init finishes while another session is routed.
|
||||
@@ -97,7 +102,10 @@ test("a rejected Git init releases its session's button without showing an error
|
||||
{ id: "ses_init_failure_a", title: "Initialize Alpha" },
|
||||
{ id: "ses_init_failure_b", title: "Initialize Beta" },
|
||||
],
|
||||
seed: { panes: { ses_init_failure_a: { review: true }, ses_init_failure_b: { review: true } } },
|
||||
seed: {
|
||||
panes: { ses_init_failure_a: { review: true }, ses_init_failure_b: { review: true } },
|
||||
storage: reviewTabs("C:/OpenCode/ReviewInitFailure", "ses_init_failure_a", "ses_init_failure_b"),
|
||||
},
|
||||
})
|
||||
const request = await holdRoute(page, (url) => url.pathname === "/api/vcs/init", { method: "POST" })
|
||||
const init = page.locator("#review-panel").getByRole("button", { name: "Create Git repository", exact: true })
|
||||
@@ -139,7 +147,10 @@ test("open file tab browses, searches, and tracks missing files", async ({ page
|
||||
},
|
||||
seed: {
|
||||
panes: { ses_reviewopenfile: { review: true } },
|
||||
storage: { "opencode.global.dat:review-panel-v2": { sidebarOpened: false } },
|
||||
storage: {
|
||||
...reviewTabs("C:/OpenCode/ReviewOpenFile", "ses_reviewopenfile"),
|
||||
"opencode.global.dat:review-panel-v2": { sidebarOpened: false },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -151,6 +162,8 @@ test("open file tab browses, searches, and tracks missing files", async ({ page
|
||||
await contextButton.click()
|
||||
await expect(tab("Context")).toHaveAttribute("aria-selected", "true")
|
||||
await openFileLauncher(panel)
|
||||
const filter = panel.getByRole("combobox", { name: "Filter files" })
|
||||
await expect(filter).toBeFocused()
|
||||
await expect(tab("Open file")).toHaveAttribute("aria-selected", "true")
|
||||
await expect(sidebarToggle).toBeDisabled()
|
||||
await expect(sidebar).toBeVisible()
|
||||
@@ -158,9 +171,8 @@ test("open file tab browses, searches, and tracks missing files", async ({ page
|
||||
await expect(tab("Context")).toHaveAttribute("aria-selected", "true")
|
||||
await expect(tab("Open file")).toHaveAttribute("aria-selected", "false")
|
||||
await expect(sidebar).toBeHidden()
|
||||
await openFileLauncher(panel)
|
||||
const filter = panel.getByRole("combobox", { name: "Filter files" })
|
||||
await expect(filter).toBeFocused()
|
||||
// The New tab view hides launchers whose tab is open, so the strip's tab returns to it.
|
||||
await tab("Open file").click()
|
||||
await expect(tab("Open file")).toHaveAttribute("aria-selected", "true")
|
||||
await expect(panel.getByText("ReviewOpenFile", { exact: true })).toBeVisible()
|
||||
|
||||
@@ -192,7 +204,7 @@ test("open file tab browses, searches, and tracks missing files", async ({ page
|
||||
await expect(tab("README.md")).toHaveAttribute("aria-selected", "true")
|
||||
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
|
||||
|
||||
await openFileLauncher(panel)
|
||||
await browseFiles(page)
|
||||
await expect(tab("README.md")).toHaveCount(0)
|
||||
await expect(sidebar).toBeVisible()
|
||||
await filter.fill("nested")
|
||||
@@ -205,7 +217,7 @@ test("open file tab browses, searches, and tracks missing files", async ({ page
|
||||
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
|
||||
expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 })
|
||||
|
||||
await openFileLauncher(panel)
|
||||
await browseFiles(page)
|
||||
await expect(tab("nested.ts")).toHaveCount(1)
|
||||
await expect(tab("Open file")).toHaveAttribute("aria-selected", "true")
|
||||
await expect(sidebarToggle).toBeDisabled()
|
||||
@@ -256,7 +268,7 @@ test("open file tab browses, searches, and tracks missing files", async ({ page
|
||||
await expect(tab("file-79.ts")).toHaveCount(1)
|
||||
await contextButton.click()
|
||||
await expect(tab("Context")).toHaveCount(0)
|
||||
await expect(tab("nested.ts")).toHaveAttribute("aria-selected", "true")
|
||||
await expect(panel.locator("#session-side-panel-review-tab")).toHaveAttribute("aria-selected", "true")
|
||||
})
|
||||
|
||||
for (const listed of [false, true]) {
|
||||
@@ -316,7 +328,7 @@ test("context closes the side region only when its button opened it", async ({ p
|
||||
await expect(context).toHaveCount(0)
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||
|
||||
// Reopening the region by hand makes it the user's, so closing Context leaves Review open.
|
||||
// With another tab open, closing Context leaves the region open on it.
|
||||
await contextButton.click()
|
||||
await expect(context).toHaveAttribute("aria-selected", "true")
|
||||
await toggle.click()
|
||||
@@ -324,6 +336,23 @@ test("context closes the side region only when its button opened it", async ({ p
|
||||
await toggle.click()
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(context).toHaveAttribute("aria-selected", "true")
|
||||
// "+" opens the New tab view over Context; picking a launcher replaces it with the tab it opens.
|
||||
const newTab = panel.getByRole("tab", { name: "New tab", exact: true })
|
||||
await panel.getByRole("button", { name: "Add tab", exact: true }).click()
|
||||
await expect(newTab).toHaveAttribute("aria-selected", "true")
|
||||
await panel.getByRole("button", { name: /^Review/ }).click()
|
||||
await expect(newTab).toHaveCount(0)
|
||||
await expect(panel.locator("#session-side-panel-review-tab")).toHaveAttribute("aria-selected", "true")
|
||||
// Closing Review returns to Context, the tab the view opened over, without bringing the replaced view back.
|
||||
await panel
|
||||
.locator('div[data-value="review:changes"]')
|
||||
.getByRole("button", { name: "Close tab", exact: true })
|
||||
.click()
|
||||
await expect(context).toHaveAttribute("aria-selected", "true")
|
||||
await expect(newTab).toHaveCount(0)
|
||||
await panel.getByRole("button", { name: "Add tab", exact: true }).click()
|
||||
await panel.getByRole("button", { name: /^Review/ }).click()
|
||||
await context.click()
|
||||
await contextButton.click()
|
||||
await expect(context).toHaveCount(0)
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true")
|
||||
@@ -506,7 +535,12 @@ test("a restored file tab keeps the file tree on Files Changed", async ({ page }
|
||||
seed: {
|
||||
panes: { ses_treerestore: { review: true } },
|
||||
settings: { general: { showFileTree: true } },
|
||||
storage: { "opencode.global.dat:layout": { fileTree: { opened: true, width: 240, tab: "changes" } } },
|
||||
storage: {
|
||||
"opencode.global.dat:layout": {
|
||||
...reviewTabs(directory, "ses_treerestore")["opencode.global.dat:layout"],
|
||||
fileTree: { opened: true, width: 240, tab: "changes" },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const panel = page.locator("#review-panel")
|
||||
@@ -767,6 +801,7 @@ test("image files load into the review panel and keep it painted while loading",
|
||||
},
|
||||
})
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await openReviewLauncher(page.locator("#review-panel"))
|
||||
await expect(page.locator('#review-panel [data-component="session-review-v2"]')).toBeVisible()
|
||||
|
||||
// Records every painted frame from before the click until after the image read completes.
|
||||
@@ -947,6 +982,7 @@ test("restores review state and the side-panel tab per session", async ({ page }
|
||||
|
||||
// Each session selects a file other than the first, which the review would show without a stored selection.
|
||||
await toggle.click()
|
||||
await openReviewLauncher(panel)
|
||||
await page.getByRole("button", { name: "gamma.ts" }).click()
|
||||
await selectedFile("gamma.ts")
|
||||
await openFileLauncher(panel)
|
||||
@@ -955,6 +991,7 @@ test("restores review state and the side-panel tab per session", async ({ page }
|
||||
|
||||
await switchSession("Beta review state")
|
||||
await toggle.click()
|
||||
await openReviewLauncher(panel)
|
||||
await expect(review).toHaveAttribute("aria-selected", "true")
|
||||
await page.getByRole("button", { name: "Git changes" }).click()
|
||||
await page.getByRole("option", { name: "Branch changes" }).click()
|
||||
@@ -1024,6 +1061,7 @@ test("shows and restores last turn changes from the session diff", async ({ page
|
||||
)
|
||||
const panel = page.locator("#review-panel")
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await openReviewLauncher(panel)
|
||||
await page.getByRole("button", { name: "Git changes" }).click()
|
||||
await page.getByRole("option", { name: "Last turn changes" }).click()
|
||||
await expect(page.getByRole("button", { name: "Last turn changes" })).toBeVisible()
|
||||
@@ -1066,6 +1104,7 @@ test("keeps the review state a session stored before extensions", async ({ page
|
||||
.getByRole("button", { name: file, exact: true })
|
||||
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await openReviewLauncher(page.locator("#review-panel"))
|
||||
await expect(mode("Branch changes")).toBeVisible()
|
||||
await expect(page.locator('[data-slot="session-review-v2-file-name"]')).toHaveText("beta.ts")
|
||||
// The narrow review lists every file and expands the ones left open.
|
||||
@@ -1120,6 +1159,7 @@ test("desktop review waits for the session's stored mode before it loads changes
|
||||
await page.goto(desktop())
|
||||
await expectSessionTitle(page, title)
|
||||
await toggle.click()
|
||||
await openReviewLauncher(page.locator("#review-panel"))
|
||||
await mode("Git changes").click()
|
||||
await page.getByRole("option", { name: "Branch changes" }).click()
|
||||
await expect(file).toHaveText("beta.ts")
|
||||
@@ -1160,6 +1200,7 @@ test("a chosen mode the session stops offering shows as Git, and comes back when
|
||||
const file = page.locator('[data-slot="session-review-v2-file-name"]')
|
||||
|
||||
await page.getByRole("button", { name: "Toggle review", exact: true }).click()
|
||||
await openReviewLauncher(page.locator("#review-panel"))
|
||||
await mode("Git changes").click()
|
||||
await page.getByRole("option", { name: "Branch changes" }).click()
|
||||
await expect(file).toHaveText("beta.ts")
|
||||
@@ -1181,6 +1222,7 @@ test("names a single change source and keeps the review header rows at 48px", as
|
||||
await openSession(page, { name: "ReviewHeader", project: { vcs: "hg" }, vcsDiff: [fileDiff("src/alpha.ts")] })
|
||||
await page.getByRole("button", { name: "Toggle review", exact: true }).click()
|
||||
const panel = page.locator("#review-panel")
|
||||
await openReviewLauncher(panel)
|
||||
const toggle = panel.getByRole("button", { name: "Toggle file tree" })
|
||||
const header = panel.locator('[data-slot="session-review-v2-sidebar-header"]')
|
||||
const toolbar = panel.locator('[data-slot="session-review-v2-toolbar"]')
|
||||
@@ -1200,6 +1242,15 @@ test("names a single change source and keeps the review header rows at 48px", as
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(header).toHaveCSS("height", "48px")
|
||||
await expect(toolbar).toHaveCSS("height", "48px")
|
||||
|
||||
// Closing the last tab closes the side region; it opens empty, on its launchers, next time.
|
||||
const region = page.getByRole("button", { name: "Toggle review", exact: true })
|
||||
await panel.locator("#session-side-panel-review-tab").hover()
|
||||
await panel.getByRole("button", { name: "Close tab", exact: true }).click()
|
||||
await expect(region).toHaveAttribute("aria-expanded", "false")
|
||||
await region.click()
|
||||
await expect(panel.getByRole("tab")).toHaveCount(0)
|
||||
await expect(panel.getByRole("button", { name: /^Review/ })).toBeVisible()
|
||||
})
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import { tabKey } from "../utils/app"
|
||||
import { fileDiff, openSession } from "../utils/workspace"
|
||||
import { openReviewLauncher } from "../utils/side-panel"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
test("review and terminal tabs follow the session tab and the terminal stays mounted", async ({ page }) => {
|
||||
@@ -30,6 +31,7 @@ test("review and terminal tabs follow the session tab and the terminal stays mou
|
||||
}
|
||||
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await openReviewLauncher(page.locator("#review-panel"))
|
||||
await expect(review).toBeVisible()
|
||||
await expect(chat).toHaveCSS("width", "580px")
|
||||
await expect(reviewTab).toHaveText("Files Changed 2740")
|
||||
@@ -46,6 +48,7 @@ test("review and terminal tabs follow the session tab and the terminal stays mou
|
||||
await expect(terminal).toBeHidden()
|
||||
await expectMarked(terminal)
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await openReviewLauncher(page.locator("#review-panel"))
|
||||
await expect(review).toBeVisible()
|
||||
await expect(chat).toHaveCSS("width", "520px")
|
||||
// The workspace's terminal reopens in this session's strip instead of starting another shell.
|
||||
@@ -110,7 +113,7 @@ test("review content stays aligned with the side region in both directions", asy
|
||||
await openSession(page, { name: "SideStack", vcsDiff: diffs })
|
||||
const toggle = page.getByRole("button", { name: "Toggle review" })
|
||||
await toggle.click()
|
||||
await expect(page.locator("#review-panel")).toBeVisible()
|
||||
await openReviewLauncher(page.locator("#review-panel"))
|
||||
await expectTreeSentinel(page)
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
session,
|
||||
sessionHref,
|
||||
} from "../utils/app"
|
||||
import { openFileLauncher } from "../utils/side-panel"
|
||||
import { openFileLauncher, reviewTabs } from "../utils/side-panel"
|
||||
import { mockServers } from "../utils/mock-server"
|
||||
import { fixture, mockStressTimeline } from "../utils/session-fixture"
|
||||
import { fileNode, mockWorkspace, openSession } from "../utils/workspace"
|
||||
@@ -912,7 +912,10 @@ test("tab shortcuts cycle side-panel tabs when the side panel is focused with mu
|
||||
sessions: [alpha, beta],
|
||||
fileList: (path) => (path ? [] : ["README.md"].map((file) => fileNode(directory, file))),
|
||||
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
||||
seed: { panes: { [alpha.id]: { review: true }, [beta.id]: { review: true } } },
|
||||
seed: {
|
||||
panes: { [alpha.id]: { review: true }, [beta.id]: { review: true } },
|
||||
storage: reviewTabs(directory, alpha.id),
|
||||
},
|
||||
})
|
||||
|
||||
const panel = page.locator("#review-panel")
|
||||
|
||||
@@ -142,7 +142,7 @@ test("opens terminals as side panel tabs from the + menu", async ({ page }) => {
|
||||
await editor.click()
|
||||
await expect(editor).toBeFocused()
|
||||
await panel.getByRole("button", { name: "Add tab", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: /^Terminal/ }).click()
|
||||
await panel.getByRole("button", { name: /^Terminal/ }).click()
|
||||
}
|
||||
|
||||
for (const number of [2, 3]) {
|
||||
|
||||
@@ -1,10 +1,32 @@
|
||||
import type { Locator } from "@playwright/test"
|
||||
import type { Locator, Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
|
||||
/** Opens the side panel's "Open file" launcher tab from the strip's + menu. */
|
||||
/** Opens the side panel's "Open file" launcher tab from the new tab view the strip's + opens. */
|
||||
export async function openFileLauncher(panel: Locator) {
|
||||
await panel.getByRole("button", { name: "Add tab", exact: true }).click()
|
||||
await panel
|
||||
.page()
|
||||
.getByRole("menuitem", { name: /^Open file/ })
|
||||
.click()
|
||||
await panel.getByRole("button", { name: /^Open file/ }).click()
|
||||
}
|
||||
|
||||
/** Opens the file browser with its shortcut, which still works while a file tab hides "Open file" from the New tab view. */
|
||||
export async function browseFiles(page: Page) {
|
||||
await page.keyboard.press("Control+Alt+P")
|
||||
}
|
||||
|
||||
/** Opens the Review tab from the launcher an empty side panel lists. */
|
||||
export async function openReviewLauncher(panel: Locator) {
|
||||
await panel.getByRole("button", { name: /^Review/ }).click()
|
||||
}
|
||||
|
||||
/** Storage that restores the Review tab, selected, in each session's side strip, as the app writes it. */
|
||||
export function reviewTabs(directory: string, ...sessions: string[]) {
|
||||
return {
|
||||
"opencode.global.dat:layout": {
|
||||
sessionTabs: Object.fromEntries(
|
||||
sessions.map((id) => [
|
||||
`local\u0000${base64Encode(directory)}/${id}`,
|
||||
{ all: ["review:changes"], active: "review:changes" },
|
||||
]),
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { createEmbeds } from "./embeds"
|
||||
import { useExtensionHost, type HostApiFactories } from "./host"
|
||||
import { createLocatedWrites } from "./located"
|
||||
import { createSessionPanelMenu } from "./panel-menu"
|
||||
import type { Region } from "./panels"
|
||||
import { deferredHandle, globalStoreTarget, persistedHandle, storeImports, storeName } from "./stores"
|
||||
|
||||
@@ -306,6 +307,7 @@ export function createHostApis() {
|
||||
side: {
|
||||
opened: (session) => current()?.layout.side.opened(session) ?? false,
|
||||
toggle: (session) => write((value) => value.layout.side.toggle(session)),
|
||||
menu: () => current()?.layout.side.menu() ?? [],
|
||||
},
|
||||
sidebar: { opened: () => current()?.layout.sidebar.opened() ?? true },
|
||||
scroll: {
|
||||
@@ -365,6 +367,7 @@ export function createExtensionAttachment(apis: HostApis) {
|
||||
const host = useExtensionHost()
|
||||
const command = useCommand()
|
||||
const location = useLocation()
|
||||
const menu = createSessionPanelMenu()
|
||||
const desktop = createMediaQuery("(min-width: 768px)")
|
||||
const narrow = () => !desktop()
|
||||
// The mounted session screen, from its first render, and its object for the session it routes once it has mounted;
|
||||
@@ -678,7 +681,13 @@ export function createExtensionAttachment(apis: HostApis) {
|
||||
|
||||
if (!value) return located.hold(session, () => close(key, session))
|
||||
const tab = listed(session, value).find((entry) => entry.key === key)?.tab
|
||||
layout.panel.close(value, key)
|
||||
const all = layout.panel.state(value).all
|
||||
batch(() => {
|
||||
layout.panel.close(value, key)
|
||||
|
||||
// Closing the last tab closes the side region, which opens with a new tab next time.
|
||||
if (all.length === 1 && all[0] === key) tabs.setRegion(shellTab(session), "side", false)
|
||||
})
|
||||
const view = mountedSession(session)
|
||||
const currentScreen = screen()
|
||||
|
||||
@@ -808,6 +817,7 @@ export function createExtensionAttachment(apis: HostApis) {
|
||||
side: {
|
||||
opened: sideOpened,
|
||||
toggle: (session) => tabs.setRegion(shellTab(session), "side", !sideOpened(session)),
|
||||
menu,
|
||||
},
|
||||
// Open is the stored preference's default, which holds until a session screen shows the preference.
|
||||
sidebar: { opened: () => sidebar()?.opened() ?? true },
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { MenuItem } from "@opencode/gui-extensions/sdk"
|
||||
import { useExtensionHost } from "./host"
|
||||
|
||||
/** The side region's + menu: every extension's `session.panel` item, in order. */
|
||||
export function createSessionPanelMenu() {
|
||||
const host = useExtensionHost()
|
||||
|
||||
return createMemo(() =>
|
||||
host
|
||||
.list(MenuItem)
|
||||
.flatMap((item) => (item.menu === "session.panel" ? [item] : []))
|
||||
.toSorted((a, b) => (a.order ?? 0) - (b.order ?? 0)),
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,15 @@
|
||||
import { createEffect, createMemo, For, mergeProps, on, onCleanup, Show, type Accessor, type JSX } from "solid-js"
|
||||
import {
|
||||
batch,
|
||||
createEffect,
|
||||
createMemo,
|
||||
For,
|
||||
mergeProps,
|
||||
on,
|
||||
onCleanup,
|
||||
Show,
|
||||
type Accessor,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { Schema } from "effect"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import {
|
||||
@@ -84,6 +95,8 @@ export function createRegion(input: {
|
||||
view: Accessor<MountedSession>
|
||||
screen: SessionScreen
|
||||
tabs: Tabs
|
||||
/** Runs when a close leaves no stored tab, in the same batch as the close. */
|
||||
onEmptied?: () => void
|
||||
}) {
|
||||
const host = useExtensionHost()
|
||||
const stored = () => input.tabs().all()
|
||||
@@ -205,6 +218,8 @@ export function createRegion(input: {
|
||||
entries,
|
||||
/** Drawn strip order by key. Renders iterate keys so a provider's fresh tab objects never remount a trigger. */
|
||||
keys: createMemo(() => drawn().map((entry) => entry.key), [], { equals: same }),
|
||||
/** The strip holds no tab, hidden ones included. */
|
||||
empty: createMemo(() => strip().length === 0),
|
||||
entry: (key: string) => byKey().get(key),
|
||||
active,
|
||||
selected: createMemo(() => {
|
||||
@@ -222,7 +237,12 @@ export function createRegion(input: {
|
||||
},
|
||||
close(key: string) {
|
||||
const entry = byKey().get(key)
|
||||
input.tabs().close(key)
|
||||
const last = stored().length === 1 && stored()[0] === key
|
||||
batch(() => {
|
||||
input.tabs().close(key)
|
||||
|
||||
if (last) input.onEmptied?.()
|
||||
})
|
||||
|
||||
if (entry)
|
||||
entry.provider.close?.(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, Show, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
|
||||
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup, untrack, type JSX } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
|
||||
@@ -15,7 +15,6 @@ import { Keybind } from "@opencode/ui/keybind"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import {
|
||||
MenuItem,
|
||||
type MountedSession,
|
||||
type SessionScreen,
|
||||
type PanelSidebar,
|
||||
@@ -26,8 +25,8 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import type { Sizing } from "@/session/helpers"
|
||||
import { useExtensionHost } from "./host"
|
||||
import { PanelTrigger } from "./panel-trigger"
|
||||
import { createSessionPanelMenu } from "./panel-menu"
|
||||
import { RegionContent, type Region } from "./panels"
|
||||
import { ExtensionSlot } from "./render"
|
||||
import { createTabStripScroll } from "./tab-strip-scroll"
|
||||
@@ -46,7 +45,6 @@ export function SideRegion(props: {
|
||||
}) {
|
||||
const layout = useLayout()
|
||||
const language = useLanguage()
|
||||
const host = useExtensionHost()
|
||||
const { tabs, view, params } = useSessionLayout()
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
@@ -67,13 +65,31 @@ export function SideRegion(props: {
|
||||
|
||||
const treeWidth = createMemo(() => (fileOpen() ? `${fileTreeWidth()}px` : "0px"))
|
||||
|
||||
const menu = createMemo(() =>
|
||||
host
|
||||
.list(MenuItem)
|
||||
.flatMap((item) => (item.menu === "session.panel" ? [item] : []))
|
||||
.toSorted((a, b) => (a.order ?? 0) - (b.order ?? 0)),
|
||||
const menu = createSessionPanelMenu()
|
||||
const primary = createMemo(() => menu().find((item) => item.primary))
|
||||
const launchers = createMemo(() => menu().filter((item) => !item.primary && !item.opened))
|
||||
|
||||
// Opening the side region with no tabs starts a new tab, once per opening and session. Keyed by the session, not the
|
||||
// item, whose object changes with every routed session.
|
||||
const starting = createMemo(() =>
|
||||
tabsOpen() && layout.ready() && props.region.empty() && primary() ? props.view.key : undefined,
|
||||
)
|
||||
|
||||
createEffect(on(starting, (key) => key && untrack(primary)?.run()))
|
||||
|
||||
// Without a new tab item, "+" opens a host-owned new tab view listing the launchers. It shows while the session and
|
||||
// the region's selection are the ones it opened over, so a tab that opens and selects itself replaces it.
|
||||
const [adding, setAdding] = createSignal<{ session: string; over: string | undefined }>()
|
||||
|
||||
const newTab = createMemo(() => {
|
||||
const value = adding()
|
||||
|
||||
return !!value && value.session === props.view.key && value.over === props.region.active()
|
||||
})
|
||||
|
||||
// Once replaced, the view is gone: returning to the tab or session it opened over does not show it again.
|
||||
createEffect(on(newTab, (shown) => !shown && setAdding(undefined), { defer: true }))
|
||||
|
||||
let root: HTMLElement | undefined
|
||||
let tabList: HTMLDivElement | undefined
|
||||
let selectionEvent: Event | undefined
|
||||
@@ -140,8 +156,10 @@ export function SideRegion(props: {
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
value={props.region.active() ?? "empty"}
|
||||
value={newTab() ? "new" : (props.region.active() ?? "empty")}
|
||||
onChange={(value) => {
|
||||
if (value === "new") return
|
||||
|
||||
// Kobalte selects the first tab while triggers register, including while the
|
||||
// "+" menu's click is still dispatching. Persist input events on a tab only; the
|
||||
// region owns fallback selection.
|
||||
@@ -150,8 +168,10 @@ export function SideRegion(props: {
|
||||
selectionEvent.eventPhase !== Event.NONE &&
|
||||
selectionEvent.target instanceof Element &&
|
||||
selectionEvent.target.closest('[role="tab"]')
|
||||
)
|
||||
) {
|
||||
setAdding(undefined)
|
||||
props.region.select(value)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="session-review-v2-tabs-bar sticky top-0 shrink-0 flex items-center">
|
||||
@@ -186,12 +206,66 @@ export function SideRegion(props: {
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
<Show when={newTab()}>
|
||||
<Tabs.Trigger
|
||||
value="new"
|
||||
class="group"
|
||||
onMiddleClick={() => setAdding(undefined)}
|
||||
closeButton={
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setAdding(undefined)
|
||||
}}
|
||||
icon={<Icon name="xmark-small" />}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
}
|
||||
hideCloseButton
|
||||
>
|
||||
{language.t("session.tab.new")}
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
<div class="h-full shrink-0 sticky end-0 z-10 flex items-center justify-center bg-v2-background-bg-base">
|
||||
<Show
|
||||
when={menu().length > 1}
|
||||
fallback={<Show when={menu()[0]}>{(item) => <AddButton item={item()} />}</Show>}
|
||||
when={
|
||||
primary() ?? (launchers().length === 0 && menu().length === 1 ? menu()[0] : undefined)
|
||||
}
|
||||
fallback={
|
||||
<Show
|
||||
when={launchers().length > 0}
|
||||
fallback={
|
||||
<Show when={menu().length > 1}>
|
||||
<AddMenu items={menu()} />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Tooltip
|
||||
value={language.t("session.tab.add")}
|
||||
placement="bottom"
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon={<Icon name="plus" />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
onClick={() =>
|
||||
setAdding({ session: props.view.key, over: props.region.active() })
|
||||
}
|
||||
aria-label={language.t("session.tab.add")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<AddMenu items={menu()} />
|
||||
{(item) => <AddButton item={item()} />}
|
||||
</Show>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
@@ -218,31 +292,49 @@ export function SideRegion(props: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={props.region.active() === undefined}>
|
||||
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">
|
||||
{language.t("session.files.selectToOpen")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={newTab()}>
|
||||
<Tabs.Content value="new" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Launchers items={launchers()} />
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
<RegionContent
|
||||
region={props.region}
|
||||
view={props.view}
|
||||
screen={props.screen}
|
||||
frame={{
|
||||
shown: tabsOpen,
|
||||
present: tabsVisible,
|
||||
placement: () => "side",
|
||||
reserve: () => false,
|
||||
animate: () => !props.size.active(),
|
||||
sidebar: props.sidebar,
|
||||
}}
|
||||
/>
|
||||
<Show when={!newTab() && props.region.active() === undefined}>
|
||||
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
{/* With a new tab item, the new tab it opens is on its way. */}
|
||||
<Show when={!primary()}>
|
||||
<Show
|
||||
when={launchers().length > 0}
|
||||
fallback={
|
||||
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
|
||||
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">
|
||||
{language.t("session.files.selectToOpen")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Launchers items={launchers()} />
|
||||
</Show>
|
||||
</Show>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
{/* Kept mounted under the new tab view, so terminals and editors keep their state. */}
|
||||
<div class="contents" classList={{ hidden: newTab() }} inert={newTab() || undefined}>
|
||||
<RegionContent
|
||||
region={props.region}
|
||||
view={props.view}
|
||||
screen={props.screen}
|
||||
frame={{
|
||||
shown: () => tabsOpen() && !newTab(),
|
||||
present: tabsVisible,
|
||||
placement: () => "side",
|
||||
reserve: () => false,
|
||||
animate: () => !props.size.active(),
|
||||
sidebar: props.sidebar,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tabs>
|
||||
</DragDropProvider>
|
||||
</div>
|
||||
@@ -383,3 +475,41 @@ function AddMenu(props: { items: readonly SessionPanelMenuItem[] }): JSX.Element
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/** The + menu items as rows, in the empty side region and the new tab view. */
|
||||
function Launchers(props: { items: readonly SessionPanelMenuItem[] }): JSX.Element {
|
||||
return (
|
||||
<div class="flex-1 min-h-0 overflow-y-auto px-6">
|
||||
<div class="mx-auto flex max-w-[560px] flex-col gap-3 pt-12">
|
||||
<For each={props.items}>{(item) => <LauncherRow item={item} />}</For>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LauncherRow(props: { item: SessionPanelMenuItem }): JSX.Element {
|
||||
const command = useCommand()
|
||||
const keybind = createMemo(() => (props.item.keybind ? command.keybindParts(props.item.keybind) : []))
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-[41px] w-full items-center justify-between gap-4 rounded-[8px] border-[0.5px] border-v2-border-border-base bg-v2-background-bg-base px-3.5 text-start hover:bg-v2-overlay-simple-overlay-hover"
|
||||
// The new tab view stays until the tab the item opens selects itself, so nothing shows in between.
|
||||
onClick={() => props.item.run()}
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-2 text-[13px] font-[440] leading-[var(--line-height-compact)] tracking-[-0.04px]">
|
||||
<Show when={props.item.icon}>
|
||||
{(icon) => <Icon name={icon()} size="small" class="text-v2-icon-icon-muted" />}
|
||||
</Show>
|
||||
<span class="shrink-0 text-v2-text-text-base">{props.item.title}</span>
|
||||
<Show when={props.item.description}>
|
||||
{(description) => <span class="truncate text-v2-text-text-muted">{description()}</span>}
|
||||
</Show>
|
||||
</span>
|
||||
<Show when={keybind().length > 0}>
|
||||
<Keybind keys={keybind()} variant="neutral" />
|
||||
</Show>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -525,6 +525,7 @@ export const dict = {
|
||||
"session.tab.more": "More...",
|
||||
"session.view.select": "Session view",
|
||||
"session.tab.add": "Add tab",
|
||||
"session.tab.new": "New tab",
|
||||
"session.tab.unknown": "Unknown Session",
|
||||
"session.panel.reviewAndFiles": "Review and files",
|
||||
"session.error.notFound": "This session cannot be found",
|
||||
|
||||
@@ -75,6 +75,10 @@ function SessionScreenContent(props: {
|
||||
view: props.view,
|
||||
screen: props.screen,
|
||||
tabs: session.layout.tabs,
|
||||
// Closing the last tab closes the side region, which opens with a new tab next time.
|
||||
onEmptied: () => {
|
||||
if (session.layout.view().side.opened()) session.layout.view().side.toggle()
|
||||
},
|
||||
})
|
||||
|
||||
onCleanup(attachment.region(region))
|
||||
|
||||
@@ -211,6 +211,51 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* A side panel tab the new tab page offers. */
|
||||
[data-component="browser-launcher-row"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
height: 41px;
|
||||
padding-inline: 14px;
|
||||
border: 0.5px solid var(--v2-border-border-base);
|
||||
border-radius: 8px;
|
||||
background: var(--v2-background-bg-base);
|
||||
color: var(--v2-text-text-base);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
letter-spacing: -0.04px;
|
||||
text-align: start;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
|
||||
[data-slot="browser-launcher-row-text"] {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="browser-launcher-row-title"] {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
[data-slot="browser-launcher-row-description"] {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--v2-text-text-muted);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* Suggestions read the address at full strength; the new tab page mutes it. */
|
||||
[data-component="browser-suggestions"] [data-slot="browser-page-row-url"] {
|
||||
color: var(--v2-text-text-base);
|
||||
|
||||
@@ -68,6 +68,9 @@ type PaneHandle = {
|
||||
|
||||
export type Model = ReturnType<typeof createModel>
|
||||
|
||||
/** A tab showing the new tab page: no page, and none loading. */
|
||||
const blank = (tab: Browser.Tab) => (!tab.url || tab.url === "about:blank") && !tab.loading
|
||||
|
||||
// Attachments belong to the shell session tab, not the session route: native pages and the agent's
|
||||
// browser survive visiting Settings or another tab and close when the session tab does.
|
||||
export function createModel(ctx: SetupContext<typeof definition>) {
|
||||
@@ -153,8 +156,9 @@ export function createModel(ctx: SetupContext<typeof definition>) {
|
||||
)
|
||||
|
||||
const close = (id: string) => {
|
||||
live.get(id)?.dispose()
|
||||
const entry = live.get(id)
|
||||
live.delete(id)
|
||||
entry?.dispose()
|
||||
opening.delete(id)
|
||||
Array.from(recorded.keys())
|
||||
.filter((item) => item.startsWith(`${id}\n`))
|
||||
@@ -290,6 +294,28 @@ export function createModel(ctx: SetupContext<typeof definition>) {
|
||||
}),
|
||||
)
|
||||
|
||||
// A new tab holds the place of the tab the user picks next: once another tab is selected, it closes unless a
|
||||
// page started loading in it. It leaves the strip in the same update, so the picked tab takes its place without
|
||||
// waiting for the desktop to close its page.
|
||||
createKeyed(
|
||||
() =>
|
||||
state.attachments[id]?.browser?.tabs.find((item) => {
|
||||
const placement = layout.state(key(item.id), ref)
|
||||
|
||||
return blank(item) && (placement === "visible" || placement === "active")
|
||||
})?.id,
|
||||
(tabID) =>
|
||||
onCleanup(() => {
|
||||
const item = state.attachments[id]?.browser?.tabs.find((candidate) => candidate.id === tabID)
|
||||
|
||||
if (live.get(id) !== entry || !item || !blank(item) || layout.state(key(tabID), ref) !== "open") return
|
||||
layout.close(key(tabID), ref)
|
||||
|
||||
// The host calls the panel's `close` for the routed session only; another session's page closes here.
|
||||
if (sessions.current()?.key !== ref.key) command(ref, { type: "tabs.close", tabID })
|
||||
}),
|
||||
)
|
||||
|
||||
// Previews made while another session was on screen open once the user returns to this one.
|
||||
createKeyed(
|
||||
() => sessions.current()?.key === ref.key && ctx.screen.current(),
|
||||
|
||||
@@ -356,7 +356,14 @@ export function mountBrowserPane(input: PaneHost) {
|
||||
uses: { pane: { load: () => live } },
|
||||
sessions: { current: session, list: () => Array.from(views.values()).filter((view) => view.key !== "Empty") },
|
||||
screen: { current: () => screen },
|
||||
layout: { narrow: () => false, stored: () => [], state: () => "visible", open() {}, close() {} },
|
||||
layout: {
|
||||
narrow: () => false,
|
||||
stored: () => [],
|
||||
state: () => "visible",
|
||||
open() {},
|
||||
close() {},
|
||||
side: { opened: () => true, toggle() {}, menu: () => [] },
|
||||
},
|
||||
links: { open() {} },
|
||||
stores: {
|
||||
history: {
|
||||
@@ -740,7 +747,7 @@ export function mountBrowserRegion(input: RegionHost) {
|
||||
strip(session.key).all.flatMap((key) =>
|
||||
key.startsWith(`${extension}:`) ? [key.slice(extension.length + 1)] : [],
|
||||
),
|
||||
side: { opened: () => true, toggle() {} },
|
||||
side: { opened: () => true, toggle() {}, menu: () => [] },
|
||||
sidebar: { opened: () => true },
|
||||
scroll: { get: () => undefined, set() {} },
|
||||
settings() {},
|
||||
|
||||
@@ -14,7 +14,15 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { createMemo, For, on, onCleanup, Show, type Accessor, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { createKeyed, useExtension, usePanel, type PanelTab, type MountedSession, type SessionScreen } from "../sdk"
|
||||
import {
|
||||
createKeyed,
|
||||
useExtension,
|
||||
usePanel,
|
||||
type PanelTab,
|
||||
type MountedSession,
|
||||
type SessionPanelMenuItem,
|
||||
type SessionScreen,
|
||||
} from "../sdk"
|
||||
import { addressParts, connection, loopback, resolveAddress, searches } from "./address"
|
||||
import { commentNote, pageNote } from "./comment"
|
||||
import { bare, completes, highlight, suggest, type Visit } from "./history"
|
||||
@@ -190,6 +198,8 @@ export default function SessionBrowserPane(props: {
|
||||
const listed = () => suggestions().length > 0 && !store.dismissed
|
||||
const chosen = () => (listed() ? suggestions()[store.active] : undefined)
|
||||
const recent = () => props.model.visits().slice(0, RECENT)
|
||||
// The side panel's other new tab choices; picking one replaces this tab.
|
||||
const launchers = () => extension.layout.side.menu().filter((item) => !item.primary && !item.opened)
|
||||
|
||||
// A loopback address opens on the machine that runs the server; the system browser here reaches it only when the
|
||||
// server runs here too.
|
||||
@@ -875,33 +885,45 @@ export default function SessionBrowserPane(props: {
|
||||
>
|
||||
<div ref={box} aria-hidden="true" class="pointer-events-none absolute inset-0" />
|
||||
<Show when={empty() && !failed() && !suspended()}>
|
||||
{/* As designed: the heading 80px from the top, the recent list centered above a 160px bottom inset. The
|
||||
minimum height keeps a full list clear of the heading in a short pane, which then scrolls. */}
|
||||
<div dir="auto" class="size-full overflow-y-auto">
|
||||
<div class="relative flex min-h-[max(100%,732px)] items-center justify-center px-6 pb-40">
|
||||
<div class="absolute inset-x-6 top-20 mx-auto flex max-w-[360px] flex-col items-center gap-4 text-center">
|
||||
<Icon name="outline-globe" size="large" class="text-v2-icon-icon-muted" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-[13px] font-[530] leading-[var(--line-height-compact)] text-v2-text-text-base">
|
||||
{extension.t("home.title")}
|
||||
</div>
|
||||
<div class="text-[13px] font-[440] leading-[var(--line-height-compact)] text-v2-text-text-muted">
|
||||
{extension.t("home.description")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* One visit is enough to list; the list stops at a handful. */}
|
||||
<Show when={recent().length > 0}>
|
||||
<div class="flex w-full max-w-[685px] flex-col gap-4">
|
||||
<div class="px-2 text-[13px] font-[440] leading-[var(--line-height-compact)] text-v2-text-text-muted">
|
||||
{extension.t("home.recent")}
|
||||
</div>
|
||||
<Show
|
||||
when={launchers().length > 0 || recent().length > 0}
|
||||
fallback={
|
||||
<div class="mx-auto flex max-w-[360px] flex-col items-center gap-4 px-6 pt-20 text-center">
|
||||
<Icon name="outline-globe" size="large" class="text-v2-icon-icon-muted" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<For each={recent()}>{(visit) => <PageRow visit={visit} onClick={() => go(visit.url)} />}</For>
|
||||
<div class="text-[13px] font-[530] leading-[var(--line-height-compact)] text-v2-text-text-base">
|
||||
{extension.t("home.title")}
|
||||
</div>
|
||||
<div class="text-[13px] font-[440] leading-[var(--line-height-compact)] text-v2-text-text-muted">
|
||||
{extension.t("home.description")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* As designed: the other tabs the side panel offers, then the pages the browser showed last. */}
|
||||
<div class="mx-auto flex w-full max-w-[608px] flex-col gap-12 px-6 pt-12 pb-10">
|
||||
<Show when={launchers().length > 0}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<For each={launchers()}>
|
||||
{(item) => <LauncherRow item={item} keys={item.keybind ? keybinds.keybind(item.keybind) : []} />}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
{/* One visit is enough to list; the list stops at a handful. */}
|
||||
<Show when={recent().length > 0}>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-[13px] font-[440] leading-[var(--line-height-compact)] text-v2-text-text-muted">
|
||||
{extension.t("home.recent")}
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<For each={recent()}>{(visit) => <PageRow visit={visit} onClick={() => go(visit.url)} />}</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={failed() && !suspended()}>
|
||||
@@ -1034,6 +1056,26 @@ export default function SessionBrowserPane(props: {
|
||||
)
|
||||
}
|
||||
|
||||
/** A side panel tab the new tab page offers: its icon, title, detail and shortcut. */
|
||||
function LauncherRow(props: { item: SessionPanelMenuItem; keys: readonly string[] }) {
|
||||
return (
|
||||
<button type="button" data-component="browser-launcher-row" onClick={() => props.item.run()}>
|
||||
<span data-slot="browser-launcher-row-text">
|
||||
<Show when={props.item.icon}>
|
||||
{(icon) => <Icon name={icon()} size="small" class="text-v2-icon-icon-muted" />}
|
||||
</Show>
|
||||
<span data-slot="browser-launcher-row-title">{props.item.title}</span>
|
||||
<Show when={props.item.description}>
|
||||
{(description) => <span data-slot="browser-launcher-row-description">{description()}</span>}
|
||||
</Show>
|
||||
</span>
|
||||
<Show when={props.keys.length > 0}>
|
||||
<Keybind keys={[...props.keys]} variant="neutral" />
|
||||
</Show>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** A page in the suggestions or the new tab page: its icon, title and address, with the query's matches drawn. */
|
||||
function PageRow(props: {
|
||||
visit: Visit
|
||||
|
||||
@@ -126,15 +126,18 @@ const setup: Setup<typeof definition> = (ctx) => {
|
||||
const view = sessions.current()
|
||||
const value = model()
|
||||
|
||||
if (!view || !value?.available(view)) return undefined
|
||||
// Only once the desktop's pane answers: an empty side region runs it, and lists the other items until then.
|
||||
if (!view || !value?.available(view) || !value.attached(view)) return undefined
|
||||
|
||||
// The new tab: its page lists the other items.
|
||||
return {
|
||||
menu: "session.panel",
|
||||
id: "open",
|
||||
title: ctx.t("tab.title"),
|
||||
title: ctx.t("tab.new"),
|
||||
icon: "globe",
|
||||
keybind: "browser.open",
|
||||
order: 20,
|
||||
order: 40,
|
||||
primary: true,
|
||||
run: () => value.open(view),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -222,9 +222,9 @@ export default function FileBrowser(props: {
|
||||
const empty = new Map<string, ChangeKind>()
|
||||
const changes = () => current(shared.changes())
|
||||
|
||||
// Change markers in the tree load while a file tab shows, as the side panel did.
|
||||
// Change markers in the tree load while a file tab or the file browser shows.
|
||||
createKeyed(
|
||||
() => (panel.visible() && !placeholder() ? changes() : undefined),
|
||||
() => (panel.visible() ? changes() : undefined),
|
||||
(service) => onCleanup(service.watch(props.screen, "files")),
|
||||
)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createStore, produce } from "solid-js/store"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { encodeFilePath, getFilename } from "@opencode/util/path"
|
||||
import {
|
||||
Command,
|
||||
createKeyed,
|
||||
bindExtension,
|
||||
LinkHandler,
|
||||
@@ -365,22 +366,40 @@ const setup: Setup<typeof File> = (ctx) => {
|
||||
},
|
||||
})
|
||||
|
||||
ctx.add(MenuItem, {
|
||||
menu: "session.panel",
|
||||
id: "open",
|
||||
get title() {
|
||||
return ctx.t("command.open")
|
||||
},
|
||||
icon: "file-tree",
|
||||
keybind: "file.open",
|
||||
order: 10,
|
||||
run() {
|
||||
const session = sessions.current()
|
||||
const browse = () => {
|
||||
const session = sessions.current()
|
||||
|
||||
if (!session) return
|
||||
if (session) openPicker(session, shared.filter.get(session.key))
|
||||
}
|
||||
|
||||
openPicker(session, shared.filter.get(session.key))
|
||||
},
|
||||
const mac = /(Mac|iPhone|iPad)/.test(navigator.platform)
|
||||
|
||||
ctx.add(
|
||||
Command,
|
||||
(): Command => ({
|
||||
id: "browse",
|
||||
title: ctx.t("command.open"),
|
||||
group: ctx.t("command.category.view"),
|
||||
bind: mac ? "ctrl+meta+p" : "ctrl+alt+p",
|
||||
editable: true,
|
||||
enabled: !!sessions.current(),
|
||||
run: browse,
|
||||
}),
|
||||
)
|
||||
|
||||
ctx.add(MenuItem, (): MenuItem => {
|
||||
const session = sessions.current()
|
||||
|
||||
return {
|
||||
menu: "session.panel",
|
||||
id: "open",
|
||||
title: ctx.t("command.open"),
|
||||
opened: !!session && layout.stored(session).some((id) => id === OPEN || isFileTab(id)),
|
||||
icon: "file-tree",
|
||||
keybind: "file.browse",
|
||||
order: 30,
|
||||
run: browse,
|
||||
}
|
||||
})
|
||||
|
||||
const OpenInAppButton = lazy(() => import("./open-in-app"))
|
||||
@@ -643,8 +662,7 @@ const setup: Setup<typeof File> = (ctx) => {
|
||||
|
||||
const id = focused.get(screen)
|
||||
|
||||
if (id && active(session, id))
|
||||
void screen.file.sync(fileTabPath(screen.file, id), { force: true, silent: true })
|
||||
if (id && active(session, id)) void screen.file.sync(fileTabPath(screen.file, id), { force: true, silent: true })
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ export default {
|
||||
"tab.title": "Review",
|
||||
"tab.count.one": "Files Changed {{count}}",
|
||||
"tab.count.other": "Files Changed {{count}}",
|
||||
"menu.count.one": "{{count}} file changed",
|
||||
"menu.count.other": "{{count}} files changed",
|
||||
"command.open": "Open review",
|
||||
"mobile.title.one": "Change",
|
||||
"mobile.title.other": "Changes",
|
||||
"empty.git": "No uncommitted changes yet",
|
||||
|
||||
@@ -13,8 +13,10 @@ import {
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { FileDiffInfo } from "@opencode/client/promise"
|
||||
import {
|
||||
Command,
|
||||
createKeyed,
|
||||
LinkHandler,
|
||||
MenuItem,
|
||||
Panel,
|
||||
SettingsPage,
|
||||
usePanel,
|
||||
@@ -120,12 +122,10 @@ const setup: Setup<typeof Review> = (ctx) => {
|
||||
get title() {
|
||||
return count() > 0 ? ctx.plural("tab.count", count()) : ctx.t("tab.title")
|
||||
},
|
||||
pinned: true,
|
||||
// Without focusable content the panel itself joins the tab order.
|
||||
get tabbable() {
|
||||
return !(count() > 0 || layout.sidebar.opened())
|
||||
},
|
||||
fallback: true,
|
||||
dom: { tab: "session-side-panel-review-tab", panel: "session-side-panel-review-tabpanel" },
|
||||
}
|
||||
|
||||
@@ -141,8 +141,6 @@ const setup: Setup<typeof Review> = (ctx) => {
|
||||
get wide() {
|
||||
return diff.value.diffStyle === "split"
|
||||
},
|
||||
// The review tab is pinned, never stored; a stored key, such as one from before extensions, leaves the strip.
|
||||
transient: true,
|
||||
legacy: { review: TAB },
|
||||
mobile: {
|
||||
get title() {
|
||||
@@ -206,6 +204,46 @@ const setup: Setup<typeof Review> = (ctx) => {
|
||||
},
|
||||
})
|
||||
|
||||
// Opens the review tab, the side region with it, unless the session has no changes to review.
|
||||
const show = () => {
|
||||
const session = sessions.current()
|
||||
|
||||
if (session?.project && !layout.narrow()) layout.open(KEY, session)
|
||||
}
|
||||
|
||||
const mac = /(Mac|iPhone|iPad)/.test(navigator.platform)
|
||||
|
||||
ctx.add(
|
||||
Command,
|
||||
(): Command => ({
|
||||
id: "open",
|
||||
title: ctx.t("command.open"),
|
||||
group: ctx.t("command.category.view"),
|
||||
bind: mac ? "ctrl+meta+r" : "ctrl+alt+r",
|
||||
editable: true,
|
||||
enabled: !!sessions.current()?.project && !layout.narrow(),
|
||||
run: show,
|
||||
}),
|
||||
)
|
||||
|
||||
ctx.add(MenuItem, (): MenuItem | undefined => {
|
||||
const session = sessions.current()
|
||||
|
||||
if (!session?.project || layout.narrow()) return undefined
|
||||
|
||||
return {
|
||||
menu: "session.panel",
|
||||
id: "open",
|
||||
title: ctx.t("tab.title"),
|
||||
opened: layout.state(KEY, session) !== "closed",
|
||||
description: count() > 0 ? ctx.plural("menu.count", count()) : undefined,
|
||||
icon: "review",
|
||||
keybind: "review.open",
|
||||
order: 10,
|
||||
run: show,
|
||||
}
|
||||
})
|
||||
|
||||
const reveals = new Set<() => void>()
|
||||
|
||||
// A review comment in the composer reveals its diff.
|
||||
@@ -262,10 +300,8 @@ const setup: Setup<typeof Review> = (ctx) => {
|
||||
details: (session) => modelFor(session)?.details(),
|
||||
focus: (session, path) => modelFor(session)?.focusFile(path),
|
||||
open(session) {
|
||||
// The session details' changes row: narrow screens switch to the Changes view; wide ones open the side region.
|
||||
if (layout.narrow()) return layout.open(KEY, session)
|
||||
|
||||
if (!layout.side.opened(session)) layout.side.toggle(session)
|
||||
// The session details' changes row: narrow screens switch to the Changes view; wide ones open the review tab.
|
||||
layout.open(KEY, session)
|
||||
},
|
||||
watch,
|
||||
onReveal(listener) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Schema } from "effect"
|
||||
import type { JSX } from "solid-js"
|
||||
import type { Store } from "solid-js/store"
|
||||
import type { Cleanup, OS, Persisted, StoreFrom } from "./core"
|
||||
import type { IconName, Link } from "./registries"
|
||||
import type { IconName, Link, SessionPanelMenuItem } from "./registries"
|
||||
|
||||
/**
|
||||
* A server the app lists. One ref per id that follows the live connection: read `client`, `data` and `url` from it
|
||||
@@ -712,6 +712,16 @@ export interface Layout {
|
||||
* @param session - Any session.
|
||||
*/
|
||||
toggle(session: SessionRef): void
|
||||
/**
|
||||
* The + menu's items: every extension's `session.panel` `MenuItem`, in `order`. Use it to list them on a page of
|
||||
* your own, such as a new tab page. Empty before the app interface mounts. Reactive.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const launchers = () => ctx.layout.side.menu().filter((item) => !item.primary && !item.opened)
|
||||
* ```
|
||||
*/
|
||||
menu(): readonly SessionPanelMenuItem[]
|
||||
}
|
||||
/**
|
||||
* The inner sidebar preference every side panel shares, which `usePanel().sidebar` also reads inside a panel render.
|
||||
|
||||
@@ -81,6 +81,19 @@ export interface SessionPanelMenuItem extends MenuItemBase {
|
||||
readonly icon?: IconName
|
||||
/** Published command id whose shortcut the item shows. */
|
||||
readonly keybind?: string
|
||||
/** A short muted detail after the title where the item lists as a launcher row, e.g. a count. Omit it for none. */
|
||||
readonly description?: string
|
||||
/**
|
||||
* The tab the item opens is already open. Launcher lists such as the new tab page leave the item out; the + menu
|
||||
* still lists it. Omit it while the tab is closed, or for an item that always opens a new tab.
|
||||
*/
|
||||
readonly opened?: boolean
|
||||
/**
|
||||
* Marks the item that opens a new tab: + runs it directly instead of opening the menu, and the side region runs it
|
||||
* when it opens with no tabs. The first primary item in `order` wins. Without one, + opens the menu and an empty side
|
||||
* region lists the items. Omit it for an ordinary item.
|
||||
*/
|
||||
readonly primary?: true
|
||||
/** Runs the item. */
|
||||
run(): void
|
||||
}
|
||||
@@ -119,7 +132,8 @@ export interface ServerRowMenuItem extends MenuItemBase {
|
||||
|
||||
/**
|
||||
* An item of a host menu, by `menu`. Each menu takes only its own fields, so a field the menu ignores fails to compile.
|
||||
* - `session.panel`: the + menu before the side panel tabs; shows `icon` and `keybind`.
|
||||
* - `session.panel`: the + menu before the side panel tabs; shows `icon`, `keybind` and `description`, `primary`
|
||||
* makes the item the new tab action, and launcher lists skip `opened` items. `Layout.side.menu` lists the items for a launcher of your own.
|
||||
* - `server.add`: the Add server menu.
|
||||
* - `server.row`: the menu of each server row in Settings; `when` and `enabled` filter it per row, and `run` receives
|
||||
* the row's server key.
|
||||
|
||||
@@ -146,7 +146,7 @@ const setup: Setup<typeof Terminal> = (ctx) => {
|
||||
},
|
||||
icon: "terminal",
|
||||
keybind: "terminal.new",
|
||||
order: 30,
|
||||
order: 20,
|
||||
run() {
|
||||
const target = current()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user