diff --git a/packages/app/component-tests/browser-pane-restore.spec.ts b/packages/app/component-tests/browser-pane-restore.spec.ts index 03ab801d9286..c3f53266f2ce 100644 --- a/packages/app/component-tests/browser-pane-restore.spec.ts +++ b/packages/app/component-tests/browser-pane-restore.spec.ts @@ -65,8 +65,8 @@ story.beforeEach(async ({ mount, page }) => { LanguageProvider: language.LanguageProvider, ExtensionHostProvider: host.ExtensionHostProvider, useExtensionHost: host.useExtensionHost, - createRegion: (region: Omit[0], "closed">) => - panels.createRegion({ ...region, closed: closedTabs }), + createRegion: (region: Omit[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) => void }) => { const command = commands.useCommand() diff --git a/packages/app/component-tests/extension-host.fixture.tsx b/packages/app/component-tests/extension-host.fixture.tsx index 1a3d4fc20473..7e795a9834ce 100644 --- a/packages/app/component-tests/extension-host.fixture.tsx +++ b/packages/app/component-tests/extension-host.fixture.tsx @@ -435,6 +435,7 @@ export function mountSessionRegion(input: { definitions: readonly Definition[]; remap() {}, }), closed: createClosedTabs(), + open() {}, side: () => ({ opened: () => true, toggle() {} }), }) diff --git a/packages/app/e2e/regression/review.spec.ts b/packages/app/e2e/regression/review.spec.ts index deb8e826f027..38e1d33b0dfa 100644 --- a/packages/app/e2e/regression/review.spec.ts +++ b/packages/app/e2e/regression/review.spec.ts @@ -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) { diff --git a/packages/app/e2e/regression/side-panels.spec.ts b/packages/app/e2e/regression/side-panels.spec.ts index 9cf81de6d4dd..9837d26a0909 100644 --- a/packages/app/e2e/regression/side-panels.spec.ts +++ b/packages/app/e2e/regression/side-panels.spec.ts @@ -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") +}) diff --git a/packages/app/src/runtime/extension/closed-tabs-view.tsx b/packages/app/src/runtime/extension/closed-tabs-view.tsx new file mode 100644 index 000000000000..1fdb09cfaa37 --- /dev/null +++ b/packages/app/src/runtime/extension/closed-tabs-view.tsx @@ -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 ( + + } + > + {(file) => } + + + {props.entry.file ? getFilename(props.entry.file) : props.entry.title} + + {(value) => {value()}} + + ) +} + +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 ( + 0}> +
+

+ {language.t("home.recentlyClosed")} +

+ + {(entry, index) => ( + + )} + + SECTION_LIMIT}> + + +
+
+ ) +} + +function ClosedTabsItems(props: { entries: readonly ClosedSideTab[]; onReopen: (key: string) => void }) { + const language = useLanguage() + const keybind = useReopenKeybind() + + return ( + + {language.t("home.recentlyClosed")} + 0} + fallback={
{language.t("palette.empty")}
} + > + + {(entry, index) => ( + props.onReopen(entry.key)} + shortcut={ + 0}> + + + } + > + + + )} + +
+
+ ) +} + +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 ( + + {language.t("session.tab.add")} + + {language.t("session.tab.recentHint", { key: label })} + + + ) + } + placement="bottom" + class="flex items-center" + > + { + if (!open) return setState("open", false) + + if (intent === "menu" && available()) return setState("open", true) + props.onNewTab() + }} + > + } + 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) + }} + /> + + { + const key = chosen + + if (!key) return + chosen = undefined + setTimeout(() => { + if (document.activeElement instanceof HTMLElement) document.activeElement.blur() + props.onReopen(key) + }) + }} + > + (chosen = key)} /> + + + + + ) +} diff --git a/packages/app/src/runtime/extension/closed-tabs.test.ts b/packages/app/src/runtime/extension/closed-tabs.test.ts index aca4588ad859..7eb5437f253d 100644 --- a/packages/app/src/runtime/extension/closed-tabs.test.ts +++ b/packages/app/src/runtime/extension/closed-tabs.test.ts @@ -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): PanelProps => ({ + tab: { id: "main", title: "Tab", ...tab }, + session: {} as MountedSession, + screen: {} as SessionScreen, +}) + +const panel = (fields: Partial): 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() }) diff --git a/packages/app/src/runtime/extension/closed-tabs.ts b/packages/app/src/runtime/extension/closed-tabs.ts index 4e821f2c3b0c..d9afe28e7bdb 100644 --- a/packages/app/src/runtime/extension/closed-tabs.ts +++ b/packages/app/src/runtime/extension/closed-tabs.ts @@ -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) { + 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 + +/** 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 | 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>({}) + + 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. diff --git a/packages/app/src/runtime/extension/host-apis.tsx b/packages/app/src/runtime/extension/host-apis.tsx index 9e8376d8a112..bc3fcefe6caa 100644 --- a/packages/app/src/runtime/extension/host-apis.tsx +++ b/packages/app/src/runtime/extension/host-apis.tsx @@ -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) diff --git a/packages/app/src/runtime/extension/panels.tsx b/packages/app/src/runtime/extension/panels.tsx index 9c6e17613a38..7d38fe2e7d63 100644 --- a/packages/app/src/runtime/extension/panels.tsx +++ b/packages/app/src/runtime/extension/panels.tsx @@ -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 screen: SessionScreen tabs: Tabs - closed: ReturnType + 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, + }) + }, } } diff --git a/packages/app/src/runtime/extension/side-region.tsx b/packages/app/src/runtime/extension/side-region.tsx index 246aa779c4b4..b032289ed682 100644 --- a/packages/app/src/runtime/extension/side-region.tsx +++ b/packages/app/src/runtime/extension/side-region.tsx @@ -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: { } > - - } - variant="ghost-muted" - size="large" - onClick={() => setAdding({ session: props.view.key, over: props.pane.active() })} - aria-label={language.t("session.tab.add")} - /> - + + } + variant="ghost-muted" + size="large" + onClick={() => setAdding({ session: props.view.key, over: props.pane.active() })} + aria-label={language.t("session.tab.add")} + /> + + } + > + {(key) => ( + setAdding({ session: props.view.key, over: props.pane.active() })} + /> + )} + } > @@ -357,7 +385,7 @@ export function PaneTabs(props: { - + @@ -377,7 +405,7 @@ export function PaneTabs(props: { } > - + @@ -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 (
-
+
{(item) => } +
) diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 7d7b4b4adbf3..f8a498b41ba6 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -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", diff --git a/packages/app/src/session/commands/use-session-commands.tsx b/packages/app/src/session/commands/use-session-commands.tsx index 3a869d8db0b8..6975462681a3 100644 --- a/packages/app/src/session/commands/use-session-commands.tsx +++ b/packages/app/src/session/commands/use-session-commands.tsx @@ -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({ diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index cdae31c38d07..b0b6f9205deb 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -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. diff --git a/packages/app/src/shell/state/layout.tsx b/packages/app/src/shell/state/layout.tsx index 95e18020f363..2d34f2d2c3dd 100644 --- a/packages/app/src/shell/state/layout.tsx +++ b/packages/app/src/shell/state/layout.tsx @@ -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) }, diff --git a/packages/gui-extensions/src/browser/bar.css b/packages/gui-extensions/src/browser/bar.css index 84c02d1a010e..0d889ee23911 100644 --- a/packages/gui-extensions/src/browser/bar.css +++ b/packages/gui-extensions/src/browser/bar.css @@ -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); diff --git a/packages/gui-extensions/src/browser/panel.tsx b/packages/gui-extensions/src/browser/panel.tsx index a216f27b8d97..10c6d59071e1 100644 --- a/packages/gui-extensions/src/browser/panel.tsx +++ b/packages/gui-extensions/src/browser/panel.tsx @@ -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: {
0 || recent().length > 0} + when={recent().length > 0} fallback={
@@ -915,26 +905,15 @@ export default function SessionBrowserPane(props: {
} > - {/* As designed: the other tabs the side panel offers, then the pages the browser showed last. */} -
- 0}> -
- - {(item) => } - -
-
- {/* One visit is enough to list; the list stops at a handful. */} - 0}> -
-
- {extension.t("home.recent")} -
-
- {(visit) => go(visit.url)} />} -
-
-
+ {/* 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. */} +
+
+ {extension.t("home.recent")} +
+
+ {(visit) => go(visit.url)} />} +
@@ -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 ( - - ) -} - /** 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 diff --git a/packages/gui-extensions/src/browser/renderer.tsx b/packages/gui-extensions/src/browser/renderer.tsx index b0a922fc5301..aeab7f208e3e 100644 --- a/packages/gui-extensions/src/browser/renderer.tsx +++ b/packages/gui-extensions/src/browser/renderer.tsx @@ -171,19 +171,18 @@ const setup: Setup = (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 = (ctx) => {
), 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. diff --git a/packages/gui-extensions/src/context/renderer.tsx b/packages/gui-extensions/src/context/renderer.tsx index 5edf8a523281..faf71fec8a1f 100644 --- a/packages/gui-extensions/src/context/renderer.tsx +++ b/packages/gui-extensions/src/context/renderer.tsx @@ -42,6 +42,7 @@ const setup: Setup = (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() diff --git a/packages/gui-extensions/src/file/renderer.tsx b/packages/gui-extensions/src/file/renderer.tsx index 6f3427feac47..2d5fdf278018 100644 --- a/packages/gui-extensions/src/file/renderer.tsx +++ b/packages/gui-extensions/src/file/renderer.tsx @@ -342,12 +342,6 @@ const setup: Setup = (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() diff --git a/packages/gui-extensions/src/review/renderer.tsx b/packages/gui-extensions/src/review/renderer.tsx index af894b44464a..ab4e812faa95 100644 --- a/packages/gui-extensions/src/review/renderer.tsx +++ b/packages/gui-extensions/src/review/renderer.tsx @@ -150,6 +150,8 @@ const setup: Setup = (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() diff --git a/packages/gui-extensions/src/sdk/registries.ts b/packages/gui-extensions/src/sdk/registries.ts index 68e9363067cc..a73cc436981b 100644 --- a/packages/gui-extensions/src/sdk/registries.ts +++ b/packages/gui-extensions/src/sdk/registries.ts @@ -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.