Sfoglia il codice sorgente

feat(tui): add session tab history (#39411)

Kit Langton 1 settimana fa
parent
commit
a2885d1662

+ 18 - 0
packages/tui/src/app.tsx

@@ -99,6 +99,8 @@ const appGlobalBindingCommands = ["session.list", "session.new"] as const
 const sessionTabBindingCommands = [
   "session.tab.next",
   "session.tab.previous",
+  "session.tab.history.back",
+  "session.tab.history.forward",
   "session.tab.next_unread",
   "session.tab.previous_unread",
   "session.tab.close",
@@ -664,6 +666,22 @@ function App(props: { pair?: DialogPairCredentials }) {
         enabled: sessionTabs.enabled,
         run: () => sessionTabs.cycle(-1),
       },
+      {
+        name: "session.tab.history.back",
+        title: "Back in session tab history",
+        category: "Session",
+        palette: undefined,
+        enabled: sessionTabs.enabled,
+        run: () => sessionTabs.history(-1),
+      },
+      {
+        name: "session.tab.history.forward",
+        title: "Forward in session tab history",
+        category: "Session",
+        palette: undefined,
+        enabled: sessionTabs.enabled,
+        run: () => sessionTabs.history(1),
+      },
       {
         name: "session.tab.next_unread",
         title: "Next unread session tab",

+ 4 - 0
packages/tui/src/config/v1/keybind.ts

@@ -89,6 +89,8 @@ export const Definitions = {
   session_list: keybind("<leader>l", "List all sessions"),
   session_tab_next: keybind("ctrl+tab,<leader>right", "Switch to next open session tab"),
   session_tab_previous: keybind("ctrl+shift+tab,<leader>left", "Switch to previous open session tab"),
+  session_tab_history_back: keybind("ctrl+o", "Go back in session tab history"),
+  session_tab_history_forward: keybind("ctrl+i", "Go forward in session tab history"),
   session_tab_next_unread: keybind("<leader>down", "Switch to next unread session tab"),
   session_tab_previous_unread: keybind("<leader>up", "Switch to previous unread session tab"),
   session_tab_close: keybind("<leader>w", "Close current session tab"),
@@ -305,6 +307,8 @@ export const CommandMap = {
   session_list: "session.list",
   session_tab_next: "session.tab.next",
   session_tab_previous: "session.tab.previous",
+  session_tab_history_back: "session.tab.history.back",
+  session_tab_history_forward: "session.tab.history.forward",
   session_tab_next_unread: "session.tab.next_unread",
   session_tab_previous_unread: "session.tab.previous_unread",
   session_tab_close: "session.tab.close",

+ 30 - 0
packages/tui/src/context/session-tabs-model.ts

@@ -5,6 +5,11 @@ export type SessionTab = {
 
 export type SessionTabUnread = "activity" | "error"
 
+export type SessionTabHistory = {
+  entries: readonly string[]
+  index: number
+}
+
 export function sessionTabComplete(unread: SessionTabUnread | undefined, busy: boolean) {
   return unread === "activity" && !busy
 }
@@ -37,6 +42,31 @@ export function cycleSessionTab(tabs: readonly SessionTab[], active: string | un
   return tabs[(start + direction + tabs.length) % tabs.length]
 }
 
+export function recordSessionTabHistory(history: SessionTabHistory, sessionID: string): SessionTabHistory {
+  if (history.entries[history.index] === sessionID) return history
+  const entries = [...history.entries.slice(0, history.index + 1), sessionID]
+  return { entries, index: entries.length - 1 }
+}
+
+export function moveSessionTabHistory(
+  history: SessionTabHistory,
+  tabs: readonly SessionTab[],
+  active: string | undefined,
+  direction: 1 | -1,
+) {
+  if (!active) {
+    const sessionID = history.entries[history.index]
+    return tabs.some((tab) => tab.sessionID === sessionID) ? { history, sessionID } : { history, sessionID: undefined }
+  }
+  const entries = history.entries.map((sessionID, index) => ({ sessionID, index }))
+  const candidates = direction === -1 ? entries.slice(0, history.index).reverse() : entries.slice(history.index + 1)
+  const target = candidates.find(
+    (entry) => entry.sessionID !== active && tabs.some((tab) => tab.sessionID === entry.sessionID),
+  )
+  if (!target) return { history, sessionID: undefined }
+  return { history: { ...history, index: target.index }, sessionID: target.sessionID }
+}
+
 export function adaptiveSessionTabLayout(
   tabs: readonly SessionTab[],
   active: string | undefined,

+ 11 - 0
packages/tui/src/context/session-tabs.tsx

@@ -13,8 +13,11 @@ import { isRecord } from "../util/record"
 import {
   closeSessionTab,
   cycleSessionTab,
+  moveSessionTabHistory,
   openSessionTab,
+  recordSessionTabHistory,
   type SessionTab,
+  type SessionTabHistory,
   type SessionTabUnread,
 } from "./session-tabs-model"
 
@@ -43,6 +46,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
       tabs: [],
       unread: {},
     })
+    let history: SessionTabHistory = { entries: [], index: -1 }
 
     const root = (sessionID: string) => data.session.root(sessionID)
     const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
@@ -155,6 +159,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
       const routeSessionID = route.data.sessionID
       batch(() => {
         const opened = open(routeSessionID)
+        history = recordSessionTabHistory(history, opened.sessionID)
         const changed = clearUnread(opened.sessionID)
         if (opened.changed || changed) untrack(save)
       })
@@ -241,6 +246,12 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
         )
         if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
       },
+      history(direction: 1 | -1) {
+        if (!enabled()) return
+        const next = moveSessionTabHistory(history, store.tabs, current(), direction)
+        history = next.history
+        if (next.sessionID) route.navigate({ type: "session", sessionID: next.sessionID })
+      },
       selectIndex(index: number) {
         if (!enabled()) return
         const tab = store.tabs[index]

+ 2 - 0
packages/tui/test/config.test.tsx

@@ -108,6 +108,8 @@ test("navigates session tabs with leader arrows", () => {
 
   expect(config.keybinds.get("session.tab.next")).toMatchObject([{ key: "ctrl+tab,<leader>right" }])
   expect(config.keybinds.get("session.tab.previous")).toMatchObject([{ key: "ctrl+shift+tab,<leader>left" }])
+  expect(config.keybinds.get("session.tab.history.back")).toMatchObject([{ key: "ctrl+o" }])
+  expect(config.keybinds.get("session.tab.history.forward")).toMatchObject([{ key: "ctrl+i" }])
   expect(config.keybinds.get("session.tab.next_unread")).toMatchObject([{ key: "<leader>down" }])
   expect(config.keybinds.get("session.tab.previous_unread")).toMatchObject([{ key: "<leader>up" }])
 })

+ 43 - 0
packages/tui/test/context/session-tabs-model.test.ts

@@ -3,7 +3,9 @@ import {
   adaptiveSessionTabLayout,
   closeSessionTab,
   cycleSessionTab,
+  moveSessionTabHistory,
   openSessionTab,
+  recordSessionTabHistory,
   sessionTabComplete,
 } from "../../src/context/session-tabs-model"
 
@@ -32,6 +34,47 @@ describe("session tabs", () => {
     expect(cycleSessionTab(tabs, "b", 1)?.sessionID).toBe("a")
   })
 
+  test("moves backward and forward through selection history", () => {
+    const tabs = ["a", "b", "c", "d"].map((sessionID) => ({ sessionID }))
+    const history = ["a", "b", "c", "d"].reduce(recordSessionTabHistory, { entries: [], index: -1 })
+    const backToC = moveSessionTabHistory(history, tabs, "d", -1)
+    const backToB = moveSessionTabHistory(backToC.history, tabs, "c", -1)
+    const forwardToC = moveSessionTabHistory(backToB.history, tabs, "b", 1)
+    const forwardToD = moveSessionTabHistory(forwardToC.history, tabs, "c", 1)
+
+    expect([backToC.sessionID, backToB.sessionID, forwardToC.sessionID, forwardToD.sessionID]).toEqual([
+      "c",
+      "b",
+      "c",
+      "d",
+    ])
+  })
+
+  test("truncates forward history after a new selection", () => {
+    const tabs = ["a", "b", "c", "d"].map((sessionID) => ({ sessionID }))
+    const history = ["a", "b", "c"].reduce(recordSessionTabHistory, { entries: [], index: -1 })
+    const back = moveSessionTabHistory(history, tabs, "c", -1)
+    const branched = recordSessionTabHistory(back.history, "d")
+
+    expect(branched).toEqual({ entries: ["a", "b", "d"], index: 2 })
+    expect(moveSessionTabHistory(branched, tabs, "d", 1).sessionID).toBeUndefined()
+  })
+
+  test("skips closed tabs and duplicate entries for the active tab", () => {
+    const tabs = ["a", "b"].map((sessionID) => ({ sessionID }))
+    const history = ["a", "b", "c", "b"].reduce(recordSessionTabHistory, { entries: [], index: -1 })
+
+    expect(moveSessionTabHistory(history, tabs, "b", -1).sessionID).toBe("a")
+    expect(recordSessionTabHistory(history, "b")).toBe(history)
+  })
+
+  test("returns to the latest history entry when no tab is active", () => {
+    const tabs = ["a", "b"].map((sessionID) => ({ sessionID }))
+    const history = ["a", "b"].reduce(recordSessionTabHistory, { entries: [], index: -1 })
+
+    expect(moveSessionTabHistory(history, tabs, undefined, -1).sessionID).toBe("b")
+  })
+
   test("reveals completion activity only after session work becomes idle", () => {
     expect(sessionTabComplete("activity", true)).toBe(false)
     expect(sessionTabComplete("activity", false)).toBe(true)