feat(app): open the right panel on a new tab (#57094)

This commit is contained in:
usrnk1
2026-10-19 16:26:15 +02:00
committed by GitHub
Unverified
parent 2b5c8beb71
commit 8b3bc17ee3
25 changed files with 588 additions and 125 deletions
@@ -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()
+63 -12
View File
@@ -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) {
+5 -2
View File
@@ -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]) {
+28 -6
View File
@@ -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)),
)
}
+22 -2
View File
@@ -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>
)
}
+1
View File
@@ -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",
+4
View File
@@ -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);
+27 -1
View File
@@ -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() {},
+66 -24
View File
@@ -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),
}
})
+2 -2
View File
@@ -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")),
)
+34 -16
View File
@@ -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) {
+11 -1
View File
@@ -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.
+15 -1
View File
@@ -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()