mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-09 17:02:32 +00:00
feat(app): list recently closed tabs in the side panel's new tab view (#57863)
This commit is contained in:
parent
3b7ba7a17f
commit
91657ca657
21 changed files with 727 additions and 164 deletions
|
|
@ -65,8 +65,8 @@ story.beforeEach(async ({ mount, page }) => {
|
|||
LanguageProvider: language.LanguageProvider,
|
||||
ExtensionHostProvider: host.ExtensionHostProvider,
|
||||
useExtensionHost: host.useExtensionHost,
|
||||
createRegion: (region: Omit<Parameters<typeof panels.createRegion>[0], "closed">) =>
|
||||
panels.createRegion({ ...region, closed: closedTabs }),
|
||||
createRegion: (region: Omit<Parameters<typeof panels.createRegion>[0], "closed" | "open">) =>
|
||||
panels.createRegion({ ...region, closed: closedTabs, open() {} }),
|
||||
definitions: [
|
||||
{ ...browser.default, renderer: () => import(modules.browserRenderer) },
|
||||
{ ...file.default, renderer: () => import(modules.fileRenderer) },
|
||||
|
|
@ -93,7 +93,9 @@ story.beforeEach(async ({ mount, page }) => {
|
|||
reopenSession: () => void
|
||||
reopenSide: () => void
|
||||
switchSession: () => void
|
||||
side: () => { keys: () => readonly string[]; active: () => string | undefined; select: (key: string) => void } | undefined
|
||||
side: () =>
|
||||
| { keys: () => readonly string[]; active: () => string | undefined; select: (key: string) => void }
|
||||
| undefined
|
||||
shortcut: (shortcut: Accessor<string>) => void
|
||||
}) => {
|
||||
const command = commands.useCommand()
|
||||
|
|
|
|||
|
|
@ -435,6 +435,7 @@ export function mountSessionRegion(input: { definitions: readonly Definition[];
|
|||
remap() {},
|
||||
}),
|
||||
closed: createClosedTabs(),
|
||||
open() {},
|
||||
side: () => ({ opened: () => true, toggle() {} }),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -363,8 +363,14 @@ test("context closes the side region only when its button opened it", async ({ p
|
|||
.click()
|
||||
await expect(context).toHaveAttribute("aria-selected", "true")
|
||||
await expect(newTab).toHaveCount(0)
|
||||
// The New tab view now lists the closed Review under "Recently closed" too; reopening it brings back the same tab.
|
||||
await panel.getByRole("button", { name: "Add tab", exact: true }).click()
|
||||
await panel.getByRole("button", { name: /^Review/ }).click()
|
||||
await expect(panel.getByRole("region", { name: "Recently closed", exact: true })).toContainText("Review")
|
||||
await panel
|
||||
.getByRole("region", { name: "Recently closed", exact: true })
|
||||
.getByRole("button", { name: /^Review/ })
|
||||
.click()
|
||||
await expect(panel.locator("#session-side-panel-review-tab")).toHaveAttribute("aria-selected", "true")
|
||||
await context.click()
|
||||
await contextButton.click()
|
||||
await expect(context).toHaveCount(0)
|
||||
|
|
@ -504,7 +510,9 @@ test("file tree expands Windows paths and scrolls long names in both directions"
|
|||
await expect(longRow).toBeVisible()
|
||||
await expect(content).toHaveAttribute("title", longFilename)
|
||||
await expect.poll(() => content.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeGreaterThan(0)
|
||||
await expect.poll(() => longRow.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(0)
|
||||
await expect
|
||||
.poll(() => longRow.evaluate((element) => element.scrollWidth - element.clientWidth))
|
||||
.toBeLessThanOrEqual(0)
|
||||
await expect(content).toHaveCSS("text-overflow", "clip")
|
||||
await expect(status).toHaveText("A")
|
||||
await expect.poll(() => statusInset(status, "right")).toBeLessThanOrEqual(24)
|
||||
|
|
@ -1260,7 +1268,10 @@ test("names a single change source and keeps the review header rows at 48px", as
|
|||
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()
|
||||
// Its launchers, then the closed Review under "Recently closed".
|
||||
const closed = panel.getByRole("region", { name: "Recently closed", exact: true })
|
||||
await expect(panel.getByRole("button", { name: /^Review/ }).first()).toBeVisible()
|
||||
await expect(closed.getByRole("button", { name: /^Review/ })).toBeVisible()
|
||||
})
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { tabKey } from "../utils/app"
|
|||
import { fileDiff, openSession } from "../utils/workspace"
|
||||
import { openReviewLauncher } from "../utils/side-panel"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
|
||||
test("review and terminal tabs follow the session tab and the terminal stays mounted", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
|
|
@ -151,3 +152,65 @@ function sideContentOffset(page: Page, direction: "ltr" | "rtl") {
|
|||
return direction === "rtl" ? Math.abs(frame.right - content.right) : Math.abs(frame.left - content.left)
|
||||
}, direction)
|
||||
}
|
||||
|
||||
test("the new tab view lists recently closed tabs, expands to the rest, and keeps them across a reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
const directory = "C:/OpenCode/RecentlyClosed"
|
||||
const key = `local\u0000${base64Encode(directory)}/ses_recentlyclosed`
|
||||
|
||||
// Twelve files closed earlier, newest first, as the app stores them beside the strip.
|
||||
const closed = Array.from({ length: 12 }, (_, index) => ({
|
||||
key: `file://src/closed-${String(index).padStart(2, "0")}.ts`,
|
||||
title: `closed-${String(index).padStart(2, "0")}.ts`,
|
||||
file: `src/closed-${String(index).padStart(2, "0")}.ts`,
|
||||
closed: 1_000 - index,
|
||||
}))
|
||||
|
||||
await openSession(page, {
|
||||
name: "RecentlyClosed",
|
||||
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
||||
seed: {
|
||||
panes: { ses_recentlyclosed: { review: true } },
|
||||
storage: {
|
||||
"opencode.global.dat:layout": {
|
||||
sessionTabs: { [key]: { all: ["file://README.md"], active: "file://README.md" } },
|
||||
sessionView: { [key]: { scroll: {}, closed } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const panel = page.locator("#review-panel")
|
||||
const recent = panel.getByRole("region", { name: "Recently closed", exact: true })
|
||||
const rows = recent.locator('[data-slot="side-recently-closed-row"]')
|
||||
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
|
||||
|
||||
// The latest five, then all of them once expanded.
|
||||
await panel.getByRole("button", { name: "Add tab", exact: true }).click()
|
||||
await expect(rows).toHaveCount(5)
|
||||
await expect(rows.first()).toContainText("closed-00.ts")
|
||||
await recent.getByRole("button", { name: "Show more", exact: true }).click()
|
||||
await expect(rows).toHaveCount(12)
|
||||
await recent.getByRole("button", { name: "Show less", exact: true }).click()
|
||||
await expect(rows).toHaveCount(5)
|
||||
|
||||
// Closing the last tab closes the region; it opens on its launchers with that tab listed first.
|
||||
const readme = panel.getByRole("tab", { name: "README.md" })
|
||||
await readme.click()
|
||||
await readme.locator("..").getByRole("button", { name: "Close tab" }).click()
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||
await toggle.click()
|
||||
await expect(panel.getByRole("tab")).toHaveCount(0)
|
||||
await expect(rows.first()).toContainText("README.md")
|
||||
|
||||
// The list survives a reload, and reopening a row brings its tab back and takes it off the list.
|
||||
await page.reload()
|
||||
await expect(rows.first()).toContainText("README.md")
|
||||
await rows.first().click()
|
||||
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("aria-selected", "true")
|
||||
await panel.getByRole("button", { name: "Add tab", exact: true }).click()
|
||||
await expect(rows.first()).toContainText("closed-00.ts")
|
||||
await expect(recent).not.toContainText("README.md")
|
||||
})
|
||||
|
|
|
|||
236
packages/app/src/runtime/extension/closed-tabs-view.tsx
Normal file
236
packages/app/src/runtime/extension/closed-tabs-view.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import { For, Show, createMemo, createUniqueId, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { FileIcon } from "@opencode/ui/file-icon"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Keybind } from "@opencode/ui/keybind"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { getDirectory, getFilename } from "@opencode/util/path"
|
||||
import { isIconName } from "@opencode/ui/icons/catalog"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import {
|
||||
closedTabsSwap,
|
||||
createModifierHeld,
|
||||
hasModifier,
|
||||
type ClosedSideTab,
|
||||
type ClosedTabsModifier,
|
||||
} from "./closed-tabs"
|
||||
|
||||
const MENU_LIMIT = 10
|
||||
|
||||
const SECTION_LIMIT = 5
|
||||
|
||||
/** A closed tab's icon, title and, for a file, its folder. */
|
||||
export function ClosedTabVisual(props: { entry: ClosedSideTab }): JSX.Element {
|
||||
const detail = () => (props.entry.file ? getDirectory(props.entry.file).replace(/\/$/, "") : props.entry.detail)
|
||||
const icon = () => (props.entry.icon && isIconName(props.entry.icon) ? props.entry.icon : "file-document")
|
||||
|
||||
return (
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<Show
|
||||
when={props.entry.file}
|
||||
fallback={<Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" />}
|
||||
>
|
||||
{(file) => <FileIcon node={{ path: file(), type: "file" }} class="size-4 shrink-0" />}
|
||||
</Show>
|
||||
<span class="min-w-0 shrink truncate text-v2-text-text-base">
|
||||
{props.entry.file ? getFilename(props.entry.file) : props.entry.title}
|
||||
</span>
|
||||
<Show when={detail()}>{(value) => <span class="min-w-0 truncate text-v2-text-text-muted">{value()}</span>}</Show>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function useReopenKeybind() {
|
||||
const command = useCommand()
|
||||
|
||||
return createMemo(() => command.keybindParts("file.tab.reopen"))
|
||||
}
|
||||
|
||||
/**
|
||||
* The new tab view's "Recently closed": the latest `SECTION_LIMIT` closed tabs, and every kept one once expanded. The
|
||||
* newest shows the ⌘⇧T that reopens it.
|
||||
*/
|
||||
export function ClosedTabsSection(props: { entries: readonly ClosedSideTab[]; onReopen: (key: string) => void }) {
|
||||
const language = useLanguage()
|
||||
const keybind = useReopenKeybind()
|
||||
const [state, setState] = createStore({ expanded: false })
|
||||
const heading = createUniqueId()
|
||||
const shown = () => (state.expanded ? props.entries : props.entries.slice(0, SECTION_LIMIT))
|
||||
|
||||
return (
|
||||
<Show when={props.entries.length > 0}>
|
||||
<section data-slot="side-recently-closed" class="flex flex-col gap-1 pt-6" aria-labelledby={heading}>
|
||||
<h2 id={heading} class="px-2 pb-1 text-[13px] font-[440] text-v2-text-text-muted">
|
||||
{language.t("home.recentlyClosed")}
|
||||
</h2>
|
||||
<For each={shown()}>
|
||||
{(entry, index) => (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="side-recently-closed-row"
|
||||
class="flex h-8 w-full shrink-0 items-center justify-between gap-4 rounded-[6px] px-2 text-start text-[13px] font-[440] hover:bg-v2-background-bg-layer-02"
|
||||
onClick={() => props.onReopen(entry.key)}
|
||||
>
|
||||
<ClosedTabVisual entry={entry} />
|
||||
<Show when={index() === 0 && keybind().length > 0}>
|
||||
<Keybind keys={keybind()} variant="neutral" />
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<Show when={props.entries.length > SECTION_LIMIT}>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="side-recently-closed-more"
|
||||
class="flex h-8 w-fit items-center gap-1 rounded-[6px] px-2 text-[13px] font-[440] text-v2-text-text-muted hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
|
||||
aria-expanded={state.expanded}
|
||||
onClick={() => setState("expanded", (expanded) => !expanded)}
|
||||
>
|
||||
{state.expanded ? language.t("session.tab.showLess") : language.t("session.tab.showMore")}
|
||||
<Icon name="chevron-down" size="small" classList={{ "rotate-180": state.expanded }} />
|
||||
</button>
|
||||
</Show>
|
||||
</section>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function ClosedTabsItems(props: { entries: readonly ClosedSideTab[]; onReopen: (key: string) => void }) {
|
||||
const language = useLanguage()
|
||||
const keybind = useReopenKeybind()
|
||||
|
||||
return (
|
||||
<Menu.Group>
|
||||
<Menu.GroupLabel>{language.t("home.recentlyClosed")}</Menu.GroupLabel>
|
||||
<Show
|
||||
when={props.entries.length > 0}
|
||||
fallback={<div class="px-2 py-1.5 text-[13px] text-v2-text-text-muted">{language.t("palette.empty")}</div>}
|
||||
>
|
||||
<For each={props.entries.slice(0, MENU_LIMIT)}>
|
||||
{(entry, index) => (
|
||||
<Menu.Item
|
||||
class="!gap-6 max-w-80"
|
||||
onSelect={() => props.onReopen(entry.key)}
|
||||
shortcut={
|
||||
<Show when={index() === 0 && keybind().length > 0}>
|
||||
<Keybind keys={keybind()} variant="neutral" />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<ClosedTabVisual entry={entry} />
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Menu.Group>
|
||||
)
|
||||
}
|
||||
|
||||
const MODIFIER_LABEL = {
|
||||
mac: { alt: "⌥", shift: "⇧", ctrl: "⌃", meta: "⌘" },
|
||||
other: { alt: "Alt", shift: "Shift", ctrl: "Ctrl", meta: "Win" },
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Dev builds only, behind `opencode.dev.closedTabsModifier`: the tab strip's "+". Holding the modifier while pointing at it turns it into a chevron whose click lists the
|
||||
* recently closed tabs; a plain click still opens a new tab. Right-click and ↓ on the focused button open the list too.
|
||||
*/
|
||||
export function NewTabButton(props: {
|
||||
modifier: ClosedTabsModifier
|
||||
entries: readonly ClosedSideTab[]
|
||||
onReopen: (key: string) => void
|
||||
onNewTab: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const modifier = props.modifier
|
||||
const global = closedTabsSwap() === "global"
|
||||
const held = createModifierHeld(modifier)
|
||||
const label = MODIFIER_LABEL[/(Mac|iPhone|iPad)/.test(navigator.platform) ? "mac" : "other"][modifier]
|
||||
const [state, setState] = createStore({ hover: false, open: false })
|
||||
const available = () => props.entries.length > 0
|
||||
const swapped = () => state.open || (available() && held() && (global || state.hover))
|
||||
// What the press that is opening the menu asked for, read when Kobalte reports the open.
|
||||
let intent: "menu" | "new" = "new"
|
||||
// A chosen entry reopens once the closing menu has returned focus, as the "+" menu does.
|
||||
let chosen: string | undefined
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
value={
|
||||
swapped() ? (
|
||||
language.t("home.recentlyClosed")
|
||||
) : (
|
||||
<span class="flex items-center gap-2">
|
||||
{language.t("session.tab.add")}
|
||||
<Show when={available()}>
|
||||
<span class="opacity-60">{language.t("session.tab.recentHint", { key: label })}</span>
|
||||
</Show>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
placement="bottom"
|
||||
class="flex items-center"
|
||||
>
|
||||
<Menu
|
||||
appearance="standard"
|
||||
modal={false}
|
||||
placement="bottom-start"
|
||||
gutter={4}
|
||||
open={state.open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) return setState("open", false)
|
||||
|
||||
if (intent === "menu" && available()) return setState("open", true)
|
||||
props.onNewTab()
|
||||
}}
|
||||
>
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
data-slot="side-new-tab"
|
||||
data-swapped={swapped() ? "" : undefined}
|
||||
icon={<Icon name={swapped() ? "chevron-down" : "plus"} />}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
aria-label={swapped() ? language.t("home.recentlyClosed") : language.t("session.tab.add")}
|
||||
aria-haspopup="menu"
|
||||
onPointerEnter={() => setState("hover", true)}
|
||||
onPointerLeave={() => setState("hover", false)}
|
||||
onPointerDown={(event: PointerEvent) => {
|
||||
// The tablist redirects focus entering it to the selected tab, which counts as focus-outside and closes
|
||||
// the menu.
|
||||
event.preventDefault()
|
||||
intent = swapped() || hasModifier(event, modifier) ? "menu" : "new"
|
||||
}}
|
||||
onKeyDown={(event: KeyboardEvent) => {
|
||||
intent = event.key === "ArrowDown" || event.key === "ArrowUp" || swapped() ? "menu" : "new"
|
||||
}}
|
||||
onContextMenu={(event: MouseEvent) => {
|
||||
if (!available()) return
|
||||
event.preventDefault()
|
||||
setState("open", true)
|
||||
}}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
class="min-w-64"
|
||||
onCloseAutoFocus={() => {
|
||||
const key = chosen
|
||||
|
||||
if (!key) return
|
||||
chosen = undefined
|
||||
setTimeout(() => {
|
||||
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
|
||||
props.onReopen(key)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<ClosedTabsItems entries={props.entries} onReopen={(key) => (chosen = key)} />
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,13 +1,89 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { createClosedTabs } from "./closed-tabs"
|
||||
import type {
|
||||
MountedSession,
|
||||
Panel,
|
||||
PanelProps,
|
||||
PanelTab,
|
||||
SessionRef,
|
||||
SessionScreen,
|
||||
} from "@opencode/gui-extensions/sdk"
|
||||
import { CLOSED_TABS_LIMIT, closedEntry, createClosedTabs } from "./closed-tabs"
|
||||
|
||||
test("reopens a session's closed side tabs newest first, once each, up to the limit", () => {
|
||||
const closed = createClosedTabs(2)
|
||||
const reopened: string[] = []
|
||||
// SAFETY: the in-memory store reads only `key`; the rest of a session reference is never touched.
|
||||
const session = (key: string) => ({ key }) as SessionRef
|
||||
|
||||
for (const tab of ["a1", "b1", "a2", "a3"]) closed.push(tab[0]!, () => reopened.push(tab))
|
||||
|
||||
for (const session of ["a", "a", "a", "b", "b"]) closed.reopen(session)
|
||||
|
||||
expect(reopened).toEqual(["a3", "a2"])
|
||||
// SAFETY: `closedEntry` reads only the tab's fields; the session and screen pass through untouched.
|
||||
const props = (tab: Partial<PanelTab>): PanelProps => ({
|
||||
tab: { id: "main", title: "Tab", ...tab },
|
||||
session: {} as MountedSession,
|
||||
screen: {} as SessionScreen,
|
||||
})
|
||||
|
||||
const panel = (fields: Partial<Panel>): Panel => ({
|
||||
id: "main",
|
||||
region: "side",
|
||||
list: () => [],
|
||||
render: () => null,
|
||||
...fields,
|
||||
})
|
||||
|
||||
test("keeps a session's closed tabs newest first, once each, up to the limit", () => {
|
||||
const closed = createClosedTabs()
|
||||
const a = session("a")
|
||||
|
||||
for (const index of Array.from({ length: CLOSED_TABS_LIMIT + 2 }, (_, value) => value))
|
||||
closed.push(a, { key: `file://${index}.ts`, title: `${index}.ts` })
|
||||
closed.push(a, { key: "file://5.ts", title: "5.ts" })
|
||||
closed.push(session("b"), { key: "review:main", title: "Review" })
|
||||
|
||||
const keys = closed.list(a).map((entry) => entry.key)
|
||||
|
||||
expect(keys).toHaveLength(CLOSED_TABS_LIMIT)
|
||||
expect(keys.slice(0, 3)).toEqual([
|
||||
"file://5.ts",
|
||||
`file://${CLOSED_TABS_LIMIT + 1}.ts`,
|
||||
`file://${CLOSED_TABS_LIMIT}.ts`,
|
||||
])
|
||||
expect(keys.filter((key) => key === "file://5.ts")).toHaveLength(1)
|
||||
expect(closed.list(session("b")).map((entry) => entry.key)).toEqual(["review:main"])
|
||||
})
|
||||
|
||||
test("takes the newest closed tab, or the one asked for", () => {
|
||||
const closed = createClosedTabs()
|
||||
const a = session("a")
|
||||
|
||||
closed.push(a, { key: "file://one.ts", title: "one.ts" })
|
||||
closed.push(a, { key: "browser:tab", title: "Docs", data: "https://example.com" })
|
||||
closed.push(a, { key: "context:main", title: "Context" })
|
||||
|
||||
expect(closed.take(a, "browser:tab")?.data).toBe("https://example.com")
|
||||
expect(closed.take(a)?.key).toBe("context:main")
|
||||
expect(closed.list(a).map((entry) => entry.key)).toEqual(["file://one.ts"])
|
||||
expect(closed.take(a, "missing")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("lists a closing tab by its panel's word, its title by default, and never a transient one unasked", () => {
|
||||
const mobile = { title: "Context", order: 0, kind: "menu", icon: "status" } as const
|
||||
|
||||
expect(closedEntry("context:main", panel({ mobile }), props({ title: "Context" }))).toEqual({
|
||||
key: "context:main",
|
||||
title: "Context",
|
||||
icon: "status",
|
||||
file: undefined,
|
||||
detail: undefined,
|
||||
data: undefined,
|
||||
})
|
||||
expect(closedEntry("file://a.ts", panel({}), props({ title: "a.ts", file: "a.ts" }))?.file).toBe("a.ts")
|
||||
|
||||
const browser = panel({ closed: () => ({ icon: "globe", detail: "example.com", data: "https://example.com" }) })
|
||||
|
||||
expect(closedEntry("browser:t", browser, props({ title: "Docs" }))).toMatchObject({
|
||||
title: "Docs",
|
||||
icon: "globe",
|
||||
detail: "example.com",
|
||||
data: "https://example.com",
|
||||
})
|
||||
expect(closedEntry("browser:t", panel({ closed: () => false }), props({}))).toBeUndefined()
|
||||
expect(closedEntry("terminal:1", panel({ transient: true }), props({}))).toBeUndefined()
|
||||
expect(closedEntry("file:open", panel({}), props({ transient: true }))).toBeUndefined()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,24 +1,140 @@
|
|||
import { createSignal } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import type { Panel, PanelProps, SessionRef } from "@opencode/gui-extensions/sdk"
|
||||
import { adjacentTabKey } from "@/shell/titlebar/tab-order"
|
||||
|
||||
/** Side tabs the user closed in this window, newest last, each with how its panel brings it back. */
|
||||
export function createClosedTabs(limit = 25) {
|
||||
const stack: { session: string; reopen: () => void }[] = []
|
||||
/** The most closed tabs a session keeps. */
|
||||
export const CLOSED_TABS_LIMIT = 30
|
||||
|
||||
/** A side tab the user closed, as the new tab view lists it and ⌘⇧T brings it back. */
|
||||
export type ClosedSideTab = {
|
||||
/** `${extension}:${tab id}`. */
|
||||
readonly key: string
|
||||
readonly title: string
|
||||
/** An icon name; stored, so check it before drawing. */
|
||||
readonly icon?: string
|
||||
/** The workspace file the tab showed, if any. */
|
||||
readonly file?: string
|
||||
/** A muted detail after the title. */
|
||||
readonly detail?: string
|
||||
/** What the panel's `restore` needs. */
|
||||
readonly data?: string
|
||||
/** When it closed, in milliseconds since the epoch. */
|
||||
readonly closed: number
|
||||
}
|
||||
|
||||
/** Where a session's closed tabs live, newest first. */
|
||||
export type ClosedTabsStore = {
|
||||
list(session: SessionRef): readonly ClosedSideTab[]
|
||||
write(session: SessionRef, entries: readonly ClosedSideTab[]): void
|
||||
}
|
||||
|
||||
/** Each session's closed side tabs, newest first, up to `CLOSED_TABS_LIMIT`. In memory unless given a store. */
|
||||
export function createClosedTabs(store: ClosedTabsStore = memoryStore()) {
|
||||
return {
|
||||
push(session: string, reopen: () => void) {
|
||||
stack.push({ session, reopen })
|
||||
|
||||
if (stack.length > limit) stack.shift()
|
||||
/** Reactive. */
|
||||
list: store.list,
|
||||
push(session: SessionRef, entry: Omit<ClosedSideTab, "closed">) {
|
||||
store.write(
|
||||
session,
|
||||
[{ ...entry, closed: Date.now() }, ...store.list(session).filter((item) => item.key !== entry.key)].slice(
|
||||
0,
|
||||
CLOSED_TABS_LIMIT,
|
||||
),
|
||||
)
|
||||
},
|
||||
reopen(session: string) {
|
||||
const index = stack.findLastIndex((entry) => entry.session === session)
|
||||
/** Removes and returns the entry under `key`, or the newest one. */
|
||||
take(session: SessionRef, key?: string) {
|
||||
const entries = store.list(session)
|
||||
const entry = key === undefined ? entries[0] : entries.find((item) => item.key === key)
|
||||
|
||||
if (index !== -1) stack.splice(index, 1)[0]?.reopen()
|
||||
if (entry)
|
||||
store.write(
|
||||
session,
|
||||
entries.filter((item) => item !== entry),
|
||||
)
|
||||
|
||||
return entry
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type ClosedTabs = ReturnType<typeof createClosedTabs>
|
||||
|
||||
/** How a closing tab lists, or nothing when its panel says it cannot come back. Transient tabs list only when asked. */
|
||||
export function closedEntry(key: string, panel: Panel, input: PanelProps): Omit<ClosedSideTab, "closed"> | undefined {
|
||||
const info = panel.closed?.(input)
|
||||
|
||||
if (info === false) return
|
||||
|
||||
if (!info && (panel.transient || input.tab.transient)) return
|
||||
|
||||
return {
|
||||
key,
|
||||
title: info?.title ?? input.tab.title,
|
||||
icon: info?.icon ?? panel.mobile?.icon,
|
||||
file: input.tab.file,
|
||||
detail: info?.detail,
|
||||
data: info?.data,
|
||||
}
|
||||
}
|
||||
|
||||
function memoryStore(): ClosedTabsStore {
|
||||
const [entries, setEntries] = createStore<Record<string, readonly ClosedSideTab[]>>({})
|
||||
|
||||
return {
|
||||
list: (session) => entries[session.key] ?? [],
|
||||
write: (session, next) => setEntries(session.key, next),
|
||||
}
|
||||
}
|
||||
|
||||
export type ClosedTabsModifier = "alt" | "shift" | "ctrl" | "meta"
|
||||
|
||||
/**
|
||||
* Dev builds only, while the design is evaluated: the held key that turns the side panel's "+" into a "Recently closed"
|
||||
* menu, from `localStorage["opencode.dev.closedTabsModifier"]`. Off unless set.
|
||||
*/
|
||||
export function closedTabsModifier(): ClosedTabsModifier | undefined {
|
||||
if (!import.meta.env.DEV) return
|
||||
const value = localStorage.getItem("opencode.dev.closedTabsModifier")
|
||||
|
||||
return value === "alt" || value === "shift" || value === "ctrl" || value === "meta" ? value : undefined
|
||||
}
|
||||
|
||||
/** Whether the swap waits for the pointer to rest on "+", or shows whenever the key is down. Hover unless the dev flag says "global". */
|
||||
export function closedTabsSwap(): "hover" | "global" {
|
||||
return import.meta.env.DEV && localStorage.getItem("opencode.dev.closedTabsSwap") === "global" ? "global" : "hover"
|
||||
}
|
||||
|
||||
/** Whether an input event carries the modifier. */
|
||||
export function hasModifier(event: KeyboardEvent | MouseEvent, modifier: ClosedTabsModifier) {
|
||||
if (modifier === "shift") return event.shiftKey
|
||||
|
||||
if (modifier === "ctrl") return event.ctrlKey
|
||||
|
||||
if (modifier === "meta") return event.metaKey
|
||||
|
||||
return event.altKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the modifier is held, from every key and pointer event's modifier state rather than counting presses, so key
|
||||
* repeat, a press made while another window had focus, and a release made elsewhere all settle. Leaving the window
|
||||
* releases it, since its keyup goes to another window.
|
||||
*/
|
||||
export function createModifierHeld(modifier: ClosedTabsModifier) {
|
||||
const [held, setHeld] = createSignal(false)
|
||||
const update = (event: KeyboardEvent | MouseEvent) => setHeld(hasModifier(event, modifier))
|
||||
|
||||
createEventListener(window, ["keydown", "keyup"], update, { capture: true })
|
||||
createEventListener(window, ["pointermove", "pointerdown"], update, { capture: true, passive: true })
|
||||
createEventListener(window, "blur", () => setHeld(false))
|
||||
createEventListener(document, "visibilitychange", () => document.hidden && setHeld(false))
|
||||
|
||||
return held
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a key event belongs to the side panel: its target is in it, or, when the target is the body because closing
|
||||
* the focused tab dropped focus, the user's last focus, click or key was.
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ import { formatKeybindParts, useCommand } from "@/shell/commands/command"
|
|||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { useIsRouting, useLocation } from "@solidjs/router"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { createClosedTabs } from "./closed-tabs"
|
||||
import { closedEntry, createClosedTabs } from "./closed-tabs"
|
||||
import { panelsMoving } from "@/shell/panel-motion"
|
||||
import { createEmbeds } from "./embeds"
|
||||
import { useExtensionHost, type HostApiFactories } from "./host"
|
||||
|
|
@ -651,7 +651,19 @@ export function createExtensionAttachment(apis: HostApis) {
|
|||
|
||||
// Writes made before a session's location is known wait for it rather than being dropped.
|
||||
const located = createLocatedWrites()
|
||||
const closed = createClosedTabs()
|
||||
// Kept per session beside its strip, so the new tab view still lists them after a reload.
|
||||
const closed = createClosedTabs({
|
||||
list(session) {
|
||||
const value = stateKey(session)
|
||||
|
||||
return value ? layout.panel.closed(value) : []
|
||||
},
|
||||
write(session, entries) {
|
||||
const value = stateKey(session)
|
||||
|
||||
if (value) layout.panel.setClosed(value, entries)
|
||||
},
|
||||
})
|
||||
|
||||
const open = (key: string, session: SessionRef, options?: OpenOptions) => {
|
||||
const item = provider(key)
|
||||
|
|
@ -703,9 +715,14 @@ export function createExtensionAttachment(apis: HostApis) {
|
|||
const all = layout.panel.state(value).all
|
||||
const view = mountedSession(session)
|
||||
const currentScreen = screen()
|
||||
const reopen = view && tab && currentScreen && item?.value.reopen?.({ tab, session: view, screen: currentScreen })
|
||||
const entry =
|
||||
view &&
|
||||
tab &&
|
||||
currentScreen &&
|
||||
item &&
|
||||
closedEntry(key, item.value, { tab, session: view, screen: currentScreen })
|
||||
|
||||
if (reopen) closed.push(session.key, reopen)
|
||||
if (entry) closed.push(session, entry)
|
||||
batch(() => {
|
||||
layout.panel.close(value, key)
|
||||
|
||||
|
|
@ -877,6 +894,8 @@ export function createExtensionAttachment(apis: HostApis) {
|
|||
/** The routed `MountedSession`. */
|
||||
current,
|
||||
closed,
|
||||
/** Opens and selects a side tab by key, as a reopened closed tab comes back. */
|
||||
openTab: (key: string, session: SessionRef) => open(key, session, { tab: "select" }),
|
||||
region(value: Region) {
|
||||
setRegion(() => value)
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import { useExtensionHost } from "./host"
|
|||
import { Contribution } from "./render"
|
||||
import { useExtensionAttachment } from "./attachment"
|
||||
import { legacyKeys, panelKey } from "./panel-keys"
|
||||
import type { createClosedTabs } from "./closed-tabs"
|
||||
import { closedEntry, type ClosedTabs } from "./closed-tabs"
|
||||
|
||||
type Tabs = Accessor<{
|
||||
tabs(): SessionTabs
|
||||
|
|
@ -112,7 +112,9 @@ export function createRegion(input: {
|
|||
view: Accessor<MountedSession>
|
||||
screen: SessionScreen
|
||||
tabs: Tabs
|
||||
closed: ReturnType<typeof createClosedTabs>
|
||||
closed: ClosedTabs
|
||||
/** Opens and selects a stored tab by key, as a reopened tab whose panel has no `restore` comes back. */
|
||||
open: (key: string) => void
|
||||
/** Whether the side region is open, and the user's toggle for it. */
|
||||
side: Accessor<{ opened(): boolean; toggle(): void }>
|
||||
/** Runs when a close leaves no stored tab, in the same batch as the close. */
|
||||
|
|
@ -367,9 +369,9 @@ export function createRegion(input: {
|
|||
close(key: string) {
|
||||
const entry = byKey().get(key)
|
||||
const last = stored().length === 1 && stored()[0] === key
|
||||
const reopen = entry?.provider.reopen?.(panelInput(input.view, input.screen, { tab: entry.tab }))
|
||||
const closed = entry && closedEntry(key, entry.provider, panelInput(input.view, input.screen, { tab: entry.tab }))
|
||||
|
||||
if (reopen) input.closed.push(input.view().key, reopen)
|
||||
if (closed) input.closed.push(input.view(), closed)
|
||||
batch(() => {
|
||||
input.tabs().close(key)
|
||||
|
||||
|
|
@ -383,7 +385,28 @@ export function createRegion(input: {
|
|||
}),
|
||||
)
|
||||
},
|
||||
reopen: () => input.closed.reopen(input.view().key),
|
||||
/** The session's closed tabs that are not open again, newest first. */
|
||||
recent: createMemo(() => {
|
||||
const open = new Set(stored())
|
||||
|
||||
return input.closed.list(input.view()).filter((entry) => !open.has(entry.key))
|
||||
}),
|
||||
/** Brings back the closed tab under `key`, or the newest one. */
|
||||
reopen(key?: string) {
|
||||
const entry = input.closed.take(input.view(), key)
|
||||
|
||||
if (!entry) return
|
||||
const extension = entry.key.slice(0, entry.key.indexOf(":"))
|
||||
const provider = providers().find((item) => item.extension === extension)?.value
|
||||
|
||||
if (!provider?.restore) return input.open(entry.key)
|
||||
provider.restore({
|
||||
session: input.view(),
|
||||
screen: input.screen,
|
||||
id: entry.key.slice(extension.length + 1),
|
||||
data: entry.data,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ import { createSessionPanelMenu } from "./panel-menu"
|
|||
import { RegionContent, type Region, type RegionPane } from "./panels"
|
||||
import { ExtensionSlot } from "./render"
|
||||
import { createTabStripScroll } from "./tab-strip-scroll"
|
||||
import { closedTabsModifier, type ClosedSideTab } from "./closed-tabs"
|
||||
import { ClosedTabsSection, NewTabButton } from "./closed-tabs-view"
|
||||
import type { PaneDrag } from "./pane-drag"
|
||||
|
||||
const PANE_HEIGHT_MIN = 120
|
||||
|
|
@ -209,6 +211,18 @@ export function PaneTabs(props: {
|
|||
// The next tab this pane's "+" menu opens lands in this pane.
|
||||
const target = () => tabs().target(props.pane.id)
|
||||
|
||||
const closed = {
|
||||
get entries() {
|
||||
return props.region.recent()
|
||||
},
|
||||
onReopen: (key: string) => {
|
||||
target()
|
||||
props.region.reopen(key)
|
||||
},
|
||||
}
|
||||
|
||||
const modifier = closedTabsModifier()
|
||||
|
||||
// Without a new tab item, "+" opens a host-owned new tab view listing the launchers. It shows while the session and
|
||||
// the pane'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 }>()
|
||||
|
|
@ -313,15 +327,29 @@ export function PaneTabs(props: {
|
|||
</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.pane.active() })}
|
||||
aria-label={language.t("session.tab.add")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Show
|
||||
when={modifier}
|
||||
fallback={
|
||||
<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.pane.active() })}
|
||||
aria-label={language.t("session.tab.add")}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(key) => (
|
||||
<NewTabButton
|
||||
modifier={key()}
|
||||
entries={closed.entries}
|
||||
onReopen={closed.onReopen}
|
||||
onNewTab={() => setAdding({ session: props.view.key, over: props.pane.active() })}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
|
|
@ -357,7 +385,7 @@ export function PaneTabs(props: {
|
|||
|
||||
<Show when={newTab()}>
|
||||
<Tabs.Content value="new" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Launchers items={launchers()} onChoose={target} />
|
||||
<Launchers items={launchers()} onChoose={target} closed={closed} />
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
<Show when={props.placeholder && !newTab() && props.pane.active() === undefined}>
|
||||
|
|
@ -377,7 +405,7 @@ export function PaneTabs(props: {
|
|||
</div>
|
||||
}
|
||||
>
|
||||
<Launchers items={launchers()} onChoose={target} />
|
||||
<Launchers items={launchers()} onChoose={target} closed={closed} />
|
||||
</Show>
|
||||
</Show>
|
||||
</Tabs.Content>
|
||||
|
|
@ -497,12 +525,17 @@ function AddMenu(props: { items: readonly SessionPanelMenuItem[]; onChoose: () =
|
|||
)
|
||||
}
|
||||
|
||||
/** The + menu items as rows, in the empty side region and the new tab view. */
|
||||
function Launchers(props: { items: readonly SessionPanelMenuItem[]; onChoose: () => void }): JSX.Element {
|
||||
/** The + menu items as rows, then the session's recently closed tabs, in the empty side region and the new tab view. */
|
||||
function Launchers(props: {
|
||||
items: readonly SessionPanelMenuItem[]
|
||||
onChoose: () => void
|
||||
closed: { entries: readonly ClosedSideTab[]; onReopen: (key: string) => void }
|
||||
}): 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">
|
||||
<div class="mx-auto flex max-w-[560px] flex-col gap-3 pt-12 pb-10">
|
||||
<For each={props.items}>{(item) => <LauncherRow item={item} onChoose={props.onChoose} />}</For>
|
||||
<ClosedTabsSection entries={props.closed.entries} onReopen={props.closed.onReopen} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -540,6 +540,9 @@ export const dict = {
|
|||
"session.tab.more": "More...",
|
||||
"session.view.select": "Session view",
|
||||
"session.tab.add": "Add tab",
|
||||
"session.tab.recentHint": "Hold {{key}} for recently closed",
|
||||
"session.tab.showMore": "Show more",
|
||||
"session.tab.showLess": "Show less",
|
||||
"session.tab.new": "New tab",
|
||||
"session.tab.unknown": "Unknown Session",
|
||||
"session.panel.reviewAndFiles": "Review and files",
|
||||
|
|
|
|||
|
|
@ -406,7 +406,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||
keybind: settings.keybinds.get("tab.reopenClosed") ?? "mod+shift+t",
|
||||
hidden: true,
|
||||
when: inSide,
|
||||
onSelect: actions.region.reopen,
|
||||
onSelect: () => actions.region.reopen(),
|
||||
}),
|
||||
cycle &&
|
||||
fileCommand({
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ function SessionScreenContent(props: {
|
|||
screen: props.screen,
|
||||
tabs: session.layout.tabs,
|
||||
closed: attachment.closed,
|
||||
open: (key) => attachment.openTab(key, props.view()),
|
||||
side: () => session.layout.view().side,
|
||||
// Closing the last tab closes the side region, which opens with a new tab next time. The user's own hand closed
|
||||
// it, so it goes at once.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { Persist, persisted, removePersisted } from "@/runtime/persistence/stora
|
|||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { TabStorage } from "@/shell/tabs/schema"
|
||||
import { createScrollPersistence, type SessionScroll } from "./scroll"
|
||||
import type { ClosedSideTab } from "@/runtime/extension/closed-tabs"
|
||||
import { SessionStateKey } from "@/runtime/server/scope"
|
||||
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./helpers"
|
||||
import { requireServerKey } from "@/shell/routes/session"
|
||||
|
|
@ -106,8 +107,20 @@ const sessionTabsSchema = Persistence.struct({
|
|||
),
|
||||
})
|
||||
|
||||
const closedTabSchema = Schema.Struct({
|
||||
key: Schema.String,
|
||||
title: Schema.String,
|
||||
icon: Schema.optional(Schema.String),
|
||||
file: Schema.optional(Schema.String),
|
||||
detail: Schema.optional(Schema.String),
|
||||
data: Schema.optional(Schema.String),
|
||||
closed: Schema.Finite,
|
||||
})
|
||||
|
||||
const sessionViewSchema = Persistence.struct({
|
||||
scroll: Persistence.record(Schema.Struct({ x: Schema.Finite, y: Schema.Finite })),
|
||||
// Side tabs the user closed, newest first, for the new tab view's "Recently closed".
|
||||
closed: Persistence.optional(Persistence.array(closedTabSchema)),
|
||||
// Review state from before extensions. An extension copies each session's entry out once; nothing writes these
|
||||
// any more, and they stay so layout rewrites cannot drop a session's entry before it is copied.
|
||||
reviewOpen: Schema.optional(Persistence.array(Schema.String)),
|
||||
|
|
@ -258,6 +271,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
|
||||
if (preview) setEphemeral("sessionTabPreview", session, preview.value)
|
||||
|
||||
// A tab that opens again leaves "Recently closed".
|
||||
const closed = store.sessionView[session]?.closed
|
||||
|
||||
if (closed?.some((entry) => placed.all.includes(entry.key)))
|
||||
setStore(
|
||||
"sessionView",
|
||||
session,
|
||||
"closed",
|
||||
closed.filter((entry) => !placed.all.includes(entry.key)),
|
||||
)
|
||||
|
||||
if (target && placed.all.some((tab) => !previous.all.includes(tab)))
|
||||
setEphemeral("sessionTabTarget", session, undefined)
|
||||
})
|
||||
|
|
@ -588,6 +612,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
setEphemeral("sessionTabPreview", to, ephemeral.sessionTabPreview[from])
|
||||
scroll.drop([to])
|
||||
setStore("sessionView", to, {
|
||||
closed: store.sessionView[from]?.closed?.map((entry) => ({ ...entry })),
|
||||
scroll: Object.fromEntries(
|
||||
Object.entries(store.sessionView[from]?.scroll ?? {}).map(([key, value]) => [
|
||||
key,
|
||||
|
|
@ -598,6 +623,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||
prune(usage.active ?? to)
|
||||
})
|
||||
},
|
||||
/** The side tabs closed in a session, newest first. Reactive. */
|
||||
closed(session: string): readonly ClosedSideTab[] {
|
||||
return store.sessionView[session]?.closed ?? []
|
||||
},
|
||||
setClosed(session: string, entries: readonly ClosedSideTab[]) {
|
||||
usage.used.set(session, Date.now())
|
||||
|
||||
if (store.sessionView[session]) return setStore("sessionView", session, "closed", [...entries])
|
||||
setStore("sessionView", session, { scroll: {}, closed: [...entries] })
|
||||
prune(usage.active ?? session)
|
||||
},
|
||||
scroll(session: string, tab: string) {
|
||||
return scroll.scroll(session, tab)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -211,51 +211,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* 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);
|
||||
|
|
|
|||
|
|
@ -14,15 +14,7 @@ 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 SessionPanelMenuItem,
|
||||
type SessionScreen,
|
||||
} from "../sdk"
|
||||
import { createKeyed, useExtension, usePanel, type PanelTab, type MountedSession, 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"
|
||||
|
|
@ -199,8 +191,6 @@ 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.
|
||||
|
|
@ -900,7 +890,7 @@ export default function SessionBrowserPane(props: {
|
|||
<Show when={empty() && !failed() && !suspended()}>
|
||||
<div dir="auto" class="size-full overflow-y-auto">
|
||||
<Show
|
||||
when={launchers().length > 0 || recent().length > 0}
|
||||
when={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" />
|
||||
|
|
@ -915,26 +905,15 @@ export default function SessionBrowserPane(props: {
|
|||
</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>
|
||||
{/* The pages the browser showed last; the side panel's new tab view offers the other tabs. One visit is
|
||||
enough to list; the list stops at a handful. */}
|
||||
<div class="mx-auto flex w-full max-w-[608px] flex-col gap-4 px-6 pt-12 pb-10">
|
||||
<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>
|
||||
|
|
@ -1069,26 +1048,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -171,19 +171,18 @@ const setup: Setup<typeof definition> = (ctx) => {
|
|||
const view = sessions.current()
|
||||
const value = model()
|
||||
|
||||
// Only once the desktop's pane answers: an empty side region runs it, and lists the other items until then.
|
||||
// Only once the desktop's pane answers.
|
||||
if (!view || !value?.available(view) || !value.attached(view)) return undefined
|
||||
|
||||
// The new tab: its page lists the other items.
|
||||
// A launcher of the side panel's new tab view, between Review and Terminal.
|
||||
return {
|
||||
menu: "session.panel",
|
||||
id: "open",
|
||||
title: ctx.t("tab.new"),
|
||||
title: ctx.t("tab.title"),
|
||||
icon: "globe",
|
||||
// mod+t opens a browser tab only inside the pane, so advertise it only while the pane is on screen.
|
||||
keybind: value.pane()?.visible() ? "browser.newTab" : undefined,
|
||||
order: 40,
|
||||
primary: true,
|
||||
order: 15,
|
||||
run: () => value.open(view),
|
||||
}
|
||||
})
|
||||
|
|
@ -264,12 +263,16 @@ const setup: Setup<typeof definition> = (ctx) => {
|
|||
</Show>
|
||||
),
|
||||
close: (input) => model()?.closeTab(input.session, input.tab.id),
|
||||
// A blank new tab does not come back.
|
||||
reopen(input) {
|
||||
const session = input.session
|
||||
const url = model()?.tab(session, input.tab.id)?.url
|
||||
// A blank new tab does not come back; a page comes back as a new tab on its address.
|
||||
closed(input) {
|
||||
const url = model()?.tab(input.session, input.tab.id)?.url
|
||||
|
||||
return url && url !== "about:blank" ? () => model()?.openURL(session, url) : undefined
|
||||
if (!url || url === "about:blank") return false
|
||||
|
||||
return { icon: "globe", detail: URL.canParse(url) ? new URL(url).host : undefined, data: url }
|
||||
},
|
||||
restore: (input) => {
|
||||
if (input.data) model()?.openURL(input.session, input.data)
|
||||
},
|
||||
focus: (input) => model()?.focusTab(input.session, input.tab.id),
|
||||
// A blank tab starts in its address field, like a browser's new tab.
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ const setup: Setup<typeof definition> = (ctx) => {
|
|||
legacy: { context: "main", "usage:context": "main" },
|
||||
mobile: { title: ctx.t("mobile.title"), order: 40, kind: "menu", icon: "status" },
|
||||
list: (input) => (input.open.includes("main") ? [tab] : []),
|
||||
closed: () => ({ icon: "status" }),
|
||||
render: (props) => {
|
||||
const panel = usePanel()
|
||||
|
||||
|
|
|
|||
|
|
@ -342,12 +342,6 @@ const setup: Setup<typeof File> = (ctx) => {
|
|||
close(input) {
|
||||
tabs.get(input.screen)?.delete(input.tab.id)
|
||||
},
|
||||
reopen(input) {
|
||||
const session = input.session
|
||||
const key = `file:${input.tab.id}`
|
||||
|
||||
return isFileTab(input.tab.id) ? () => layout.open(key, session, { tab: "select" }) : undefined
|
||||
},
|
||||
render: (props) => {
|
||||
const panel = usePanel()
|
||||
|
||||
|
|
|
|||
|
|
@ -150,6 +150,8 @@ const setup: Setup<typeof Review> = (ctx) => {
|
|||
kind: "tab",
|
||||
},
|
||||
list: (input) => (!layout.narrow() && input.session.project ? [tab] : []),
|
||||
// Lists as "Review" rather than the tab's change count.
|
||||
closed: () => ({ title: ctx.t("tab.title"), icon: "review" }),
|
||||
render: (props) => {
|
||||
const frame = usePanel()
|
||||
|
||||
|
|
|
|||
|
|
@ -247,6 +247,18 @@ export interface PanelProps {
|
|||
readonly screen: SessionScreen
|
||||
}
|
||||
|
||||
/** How a closed side tab lists under "Recently closed", from `Panel.closed`. */
|
||||
export type ClosedPanelTab = {
|
||||
/** The row's title; the tab's title by default. */
|
||||
readonly title?: string
|
||||
/** The row's icon; the panel's mobile icon by default. A tab with a `file` shows its file icon. */
|
||||
readonly icon?: IconName
|
||||
/** A muted detail after the title, e.g. a page's host. A tab with a `file` shows its folder. */
|
||||
readonly detail?: string
|
||||
/** What `restore` needs to bring the tab back, e.g. a URL. */
|
||||
readonly data?: string
|
||||
}
|
||||
|
||||
/** A panel: the tabs an extension shows in a session's side region. */
|
||||
export interface Panel {
|
||||
/** The panel's id, unique within the extension. Its mobile view's key is `${extension}:${id}`. */
|
||||
|
|
@ -315,11 +327,28 @@ export interface Panel {
|
|||
*/
|
||||
close?(input: PanelProps): void
|
||||
/**
|
||||
* Read as the user closes a tab: how ⌘⇧T in the side panel brings it back, or nothing if it cannot come back.
|
||||
* Read as the user closes a tab, for the new tab view's "Recently closed" list and ⌘⇧T. Return how the row reads and
|
||||
* the data `restore` needs, or `false` when the tab cannot come back. Without it, a tab that is not `transient`
|
||||
* lists under its title and reopens under the same key. The host stores the entry, so keep `data` small.
|
||||
*
|
||||
* @param input - The closing tab, routed session and owning screen. Capture what reopening needs now.
|
||||
*/
|
||||
reopen?(input: PanelProps): (() => void) | undefined
|
||||
closed?(input: PanelProps): ClosedPanelTab | false | undefined
|
||||
/**
|
||||
* Brings back a tab `closed` listed. Without it, the host opens the same key again.
|
||||
*
|
||||
* @param input - The routed session, owning screen, the closed tab's id and the `data` `closed` returned.
|
||||
*/
|
||||
restore?(input: {
|
||||
/** The routed session the tab closed in. */
|
||||
readonly session: MountedSession
|
||||
/** The session screen that owns the panel. */
|
||||
readonly screen: SessionScreen
|
||||
/** The closed tab's id. */
|
||||
readonly id: string
|
||||
/** What `closed` returned as `data`. */
|
||||
readonly data?: string
|
||||
}): void
|
||||
/**
|
||||
* Runs when the tab becomes selected. `restored` is true for the selection the side region mounts with, e.g. the
|
||||
* tab selected before a reload, and false for every later selection change.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue