mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
feat(app): restore option arrow session tab navigation (#54277)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
d3e1de326b
commit
5fa0a42386
@@ -162,6 +162,32 @@ test("keyboard navigation follows the visible tab order and skips unresolved tab
|
||||
await expectPath(page, sessionHref(c.id))
|
||||
})
|
||||
|
||||
test("option arrows move to the previous or next tab, and with shift to the next unread one", async ({ page }) => {
|
||||
await mockWorkspace(page, {
|
||||
name: "Tabs",
|
||||
sessions: [a, b, c],
|
||||
sessionPermissions: {
|
||||
[c.id]: [
|
||||
{ id: "permission-tab-c", sessionID: c.id, action: "shell", resources: ["git status"], metadata: {}, save: [] },
|
||||
],
|
||||
},
|
||||
})
|
||||
await page.goto(sessionHref(a.id))
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(
|
||||
page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(c.id)}"]) [data-slot="tab-unread-dot"]`),
|
||||
).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Alt+ArrowDown")
|
||||
await expectPath(page, sessionHref(b.id))
|
||||
|
||||
await page.keyboard.press("Alt+ArrowUp")
|
||||
await expectPath(page, sessionHref(a.id))
|
||||
|
||||
await page.keyboard.press("Shift+Alt+ArrowDown")
|
||||
await expectPath(page, sessionHref(c.id))
|
||||
})
|
||||
|
||||
for (const row of [
|
||||
{ tabLayout: "horizontal", width: 360, hasTouch: false, direction: "ltr" },
|
||||
{ tabLayout: "vertical", width: 390, hasTouch: true, direction: "rtl" },
|
||||
|
||||
@@ -42,6 +42,10 @@ export const dict = {
|
||||
"command.palette": "Command palette",
|
||||
|
||||
"command.session.new": "New session",
|
||||
"command.session.previous": "Previous session",
|
||||
"command.session.next": "Next session",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.file.open": "Open file",
|
||||
"command.tab.close": "Close tab",
|
||||
"command.tab.reopenClosed": "Reopen closed tab",
|
||||
|
||||
@@ -12,7 +12,6 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ServerConnection, serverName, useServers } from "@/runtime/server/registry"
|
||||
import { displayName } from "@opencode/ui/project-avatar"
|
||||
import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar"
|
||||
import { useSessionTabAvatarState } from "@/shell/layout/project-avatar-state"
|
||||
import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2"
|
||||
import type { SessionInfo } from "@opencode/client/promise"
|
||||
import { sessionTabTitle } from "./tab-title"
|
||||
@@ -28,6 +27,8 @@ export function TabNavItem(props: {
|
||||
server: ServerConnection.Key
|
||||
session: SessionInfo | undefined
|
||||
preparing: boolean
|
||||
unread: boolean
|
||||
loading: boolean
|
||||
fallbackTitle?: string
|
||||
onRename: (title: string) => Promise<void>
|
||||
onClose: () => void
|
||||
@@ -57,13 +58,7 @@ export function TabNavItem(props: {
|
||||
const servers = useServers()
|
||||
const serverCtx = useServerCtx(() => servers.list.find((item) => ServerConnection.key(item) === props.server))
|
||||
|
||||
const avatar = useSessionTabAvatarState(
|
||||
() => props.server,
|
||||
() => props.session?.id ?? "",
|
||||
() => true,
|
||||
)
|
||||
|
||||
const unread = () => !!props.session && !props.active && avatar.unread() && !avatar.loading()
|
||||
const unread = () => !!props.session && !props.active && props.unread
|
||||
|
||||
const project = createMemo(() => {
|
||||
const session = props.session
|
||||
@@ -312,7 +307,7 @@ export function TabNavItem(props: {
|
||||
project={project()}
|
||||
directory={session.location.directory}
|
||||
unread={false}
|
||||
loading={avatar.loading()}
|
||||
loading={props.loading}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { mergeVisibleTabOrder } from "./tab-order"
|
||||
import { adjacentTabKey, mergeVisibleTabOrder } from "./tab-order"
|
||||
|
||||
test("finds the nearest accepted tab in either direction, wrapping around", () => {
|
||||
const order = ["a", "b", "c", "d", "e"]
|
||||
const unread = new Set(["a", "d"])
|
||||
|
||||
expect(adjacentTabKey(order, "b", 1, (key) => unread.has(key))).toBe("d")
|
||||
expect(adjacentTabKey(order, "b", -1, (key) => unread.has(key))).toBe("a")
|
||||
expect(adjacentTabKey(order, "e", 1, (key) => unread.has(key))).toBe("a")
|
||||
expect(adjacentTabKey(order, "a", -1, (key) => unread.has(key))).toBe("d")
|
||||
expect(adjacentTabKey(order, "a", 1, (key) => key === "a")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("merges reordered visible tabs around hidden tabs", () => {
|
||||
expect(mergeVisibleTabOrder(["a", "hidden", "b", "c"], ["a", "b", "c"], ["c", "a", "b"])).toEqual([
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
export function adjacentTabKey(order: string[], current: string | undefined, offset: -1 | 1) {
|
||||
if (!current || order.length === 0) return
|
||||
/** The nearest other tab in the offset's direction, wrapping around, that `include` accepts. */
|
||||
export function adjacentTabKey(
|
||||
order: string[],
|
||||
current: string | undefined,
|
||||
offset: -1 | 1,
|
||||
include: (key: string) => boolean = () => true,
|
||||
) {
|
||||
if (!current) return
|
||||
const index = order.indexOf(current)
|
||||
|
||||
if (index === -1) return
|
||||
|
||||
return order[(index + offset + order.length) % order.length]
|
||||
return Array.from(
|
||||
{ length: order.length - 1 },
|
||||
(_, step) => order[(index + offset * (step + 1) + order.length) % order.length],
|
||||
).find(include)
|
||||
}
|
||||
|
||||
export function mergeVisibleTabOrder(all: string[], current: string[], next: string[]) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useGlobal, useServerCtx, type ServerCtx } from "@/runtime/server/runtim
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { useSessionTabAvatarState } from "@/shell/layout/project-avatar-state"
|
||||
import { createTabComposerState } from "@/composer/persistence"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
@@ -27,6 +28,8 @@ function SessionTabSlot(props: {
|
||||
orientation: "horizontal" | "vertical"
|
||||
session: SessionInfo | undefined
|
||||
preparing: boolean
|
||||
unread: boolean
|
||||
loading: boolean
|
||||
fallbackTitle?: string
|
||||
onRename: (title: string) => Promise<void>
|
||||
onNavigate: (element: HTMLDivElement) => void
|
||||
@@ -64,6 +67,8 @@ function SessionTabSlot(props: {
|
||||
server={props.tab.server}
|
||||
session={props.session}
|
||||
preparing={props.preparing}
|
||||
unread={props.unread}
|
||||
loading={props.loading}
|
||||
fallbackTitle={props.fallbackTitle}
|
||||
onRename={props.onRename}
|
||||
onNavigate={() => props.onNavigate(ref)}
|
||||
@@ -84,6 +89,7 @@ function SessionTabEntry(props: {
|
||||
orientation: "horizontal" | "vertical"
|
||||
serverCtx: ServerCtx | undefined
|
||||
onVisibleChange: (visible: boolean) => void
|
||||
onUnreadChange: (unread: boolean) => void
|
||||
onNavigate: (element: HTMLDivElement) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
@@ -94,6 +100,15 @@ function SessionTabEntry(props: {
|
||||
const cachedSession = createMemo(() => props.serverCtx?.data.session.get(props.tab.sessionId))
|
||||
const persisted = createMemo(() => tabs.info[props.id])
|
||||
|
||||
const status = useSessionTabAvatarState(
|
||||
() => props.tab.server,
|
||||
() => props.tab.sessionId,
|
||||
() => true,
|
||||
)
|
||||
|
||||
// A busy session shows its progress indicator instead of the unread dot.
|
||||
const unread = createMemo(() => status.unread() && !status.loading())
|
||||
|
||||
const [loadedSession] = createResource(
|
||||
() => {
|
||||
if (pending()) return null
|
||||
@@ -135,6 +150,7 @@ function SessionTabEntry(props: {
|
||||
}
|
||||
|
||||
createEffect(() => props.onVisibleChange(visible()))
|
||||
createEffect(() => props.onUnreadChange(unread()))
|
||||
|
||||
createEffect(() => {
|
||||
const ctx = props.serverCtx
|
||||
@@ -186,6 +202,8 @@ function SessionTabEntry(props: {
|
||||
orientation={props.orientation}
|
||||
session={session()}
|
||||
preparing={!!pending()}
|
||||
unread={unread()}
|
||||
loading={status.loading()}
|
||||
fallbackTitle={
|
||||
pending()
|
||||
? language.t("session.tab.session")
|
||||
@@ -263,6 +281,7 @@ export function TitlebarTabStrip(props: {
|
||||
const vertical = () => props.orientation === "vertical"
|
||||
let listRef!: HTMLDivElement
|
||||
const [visibility, setVisibility] = createStore<Record<string, boolean>>({})
|
||||
const [unread, setUnread] = createStore<Record<string, boolean>>({})
|
||||
const visibleTabs = createMemo(() => props.tabs.filter((tab) => tab.type === "draft" || visibility[tabKey(tab)]))
|
||||
const visibleTabIds = () => visibleTabs().map(tabKey)
|
||||
|
||||
@@ -283,11 +302,39 @@ export function TitlebarTabStrip(props: {
|
||||
hidden: true,
|
||||
onSelect: () => selectAdjacentTab(1),
|
||||
},
|
||||
{
|
||||
id: "session.previous",
|
||||
category: language.t("command.category.session"),
|
||||
title: language.t("command.session.previous"),
|
||||
keybind: "alt+arrowup",
|
||||
onSelect: () => selectAdjacentTab(-1),
|
||||
},
|
||||
{
|
||||
id: "session.next",
|
||||
category: language.t("command.category.session"),
|
||||
title: language.t("command.session.next"),
|
||||
keybind: "alt+arrowdown",
|
||||
onSelect: () => selectAdjacentTab(1),
|
||||
},
|
||||
{
|
||||
id: "session.previous.unseen",
|
||||
category: language.t("command.category.session"),
|
||||
title: language.t("command.session.previous.unseen"),
|
||||
keybind: "shift+alt+arrowup",
|
||||
onSelect: () => selectAdjacentTab(-1, (key) => !!unread[key]),
|
||||
},
|
||||
{
|
||||
id: "session.next.unseen",
|
||||
category: language.t("command.category.session"),
|
||||
title: language.t("command.session.next.unseen"),
|
||||
keybind: "shift+alt+arrowdown",
|
||||
onSelect: () => selectAdjacentTab(1, (key) => !!unread[key]),
|
||||
},
|
||||
])
|
||||
|
||||
function selectAdjacentTab(offset: -1 | 1) {
|
||||
function selectAdjacentTab(offset: -1 | 1, include?: (key: string) => boolean) {
|
||||
const current = props.currentTab
|
||||
const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset)
|
||||
const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset, include)
|
||||
const next = props.tabs.find((tab) => tabKey(tab) === key)
|
||||
|
||||
if (next) props.onNavigate(next)
|
||||
@@ -390,6 +437,7 @@ export function TitlebarTabStrip(props: {
|
||||
orientation={vertical() ? "vertical" : "horizontal"}
|
||||
serverCtx={serverCtx()}
|
||||
onVisibleChange={(visible) => setVisibility(id, visible)}
|
||||
onUnreadChange={(value) => setUnread(id, value)}
|
||||
onNavigate={(element) => {
|
||||
ref = element
|
||||
props.onNavigate(tab, element)
|
||||
|
||||
Reference in New Issue
Block a user