Browse Source

feat(tui): add session tab context menu (#42068)

Kit Langton 4 days ago
parent
commit
10ebf70a07
1 changed files with 172 additions and 10 deletions
  1. 172 10
      packages/tui/src/component/session-tabs.tsx

+ 172 - 10
packages/tui/src/component/session-tabs.tsx

@@ -1,4 +1,4 @@
-import { RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
+import { RGBA, ScrollBoxRenderable, TextAttributes, type MouseEvent } from "@opentui/core"
 import { For, Show, createComputed, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
 import { For, Show, createComputed, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
 import { useTerminalDimensions } from "@opentui/solid"
 import { useTerminalDimensions } from "@opentui/solid"
 import { useConfig } from "../config"
 import { useConfig } from "../config"
@@ -18,12 +18,15 @@ import {
 } from "../context/session-tabs-model"
 } from "../context/session-tabs-model"
 import { createAnimatable, spring, tween } from "../ui/animation"
 import { createAnimatable, spring, tween } from "../ui/animation"
 import { Locale } from "../util/locale"
 import { Locale } from "../util/locale"
-import { stringWidth } from "../util/string-width"
 import { TabPulse, unreadGlowIntensity } from "./tab-pulse"
 import { TabPulse, unreadGlowIntensity } from "./tab-pulse"
 import { tint } from "../theme/color"
 import { tint } from "../theme/color"
 import { SESSION_SIDEBAR_WIDTH } from "../ui/layout"
 import { SESSION_SIDEBAR_WIDTH } from "../ui/layout"
 import { projectName } from "../util/project"
 import { projectName } from "../util/project"
 import { marqueeCycleWidth, marqueeOverflows, marqueeText } from "../util/marquee"
 import { marqueeCycleWidth, marqueeOverflows, marqueeText } from "../util/marquee"
+import { useDialog } from "../ui/dialog"
+import { DialogSessionRename } from "./dialog-session-rename"
+import { Keymap } from "../context/keymap"
+import { moveSelection } from "../ui/select-controller"
 
 
 // A long title fades out over its last cells instead of cutting hard.
 // A long title fades out over its last cells instead of cutting hard.
 const FADE_WIDTH = 4
 const FADE_WIDTH = 4
@@ -31,6 +34,15 @@ const FADE_WIDTH = 4
 const ADD_TAB_WIDTH = 3
 const ADD_TAB_WIDTH = 3
 const MARQUEE_DELAY = 600
 const MARQUEE_DELAY = 600
 const MARQUEE_INTERVAL = 100
 const MARQUEE_INTERVAL = 100
+const CONTEXT_MENU_WIDTH = 16
+const RIGHT_MOUSE_BUTTON = 2
+
+type TabContextMenuState = {
+  x: number
+  y: number
+  sessionID?: string
+  title?: string
+}
 
 
 type ContextController = ReturnType<typeof useSessionTabs>
 type ContextController = ReturnType<typeof useSessionTabs>
 export type SessionTabsStatus = Omit<ReturnType<ContextController["status"]>, "unread"> & {
 export type SessionTabsStatus = Omit<ReturnType<ContextController["status"]>, "unread"> & {
@@ -154,6 +166,96 @@ function createTabMarquee(animations: () => boolean) {
   return { ...marquee, hovered, enter, leave }
   return { ...marquee, hovered, enter, leave }
 }
 }
 
 
+function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsController; onClose: () => void }) {
+  const dimensions = useTerminalDimensions()
+  const theme = useTheme("elevated")
+  const dialog = useDialog()
+  const keymap = Keymap.use()
+  const actions = createMemo(() => {
+    const sessionID = props.state.sessionID
+    return [
+      ...(props.tabs.add ? [{ title: "New tab", run: () => props.tabs.add?.() }] : []),
+      ...(sessionID
+        ? [
+            {
+              title: "Rename",
+              run: () => DialogSessionRename.show(dialog, sessionID, props.state.title),
+            },
+            { title: "Close", run: () => props.tabs.close(sessionID) },
+          ]
+        : []),
+    ]
+  })
+  const [selected, setSelected] = createSignal(0)
+  const top = () => Math.max(0, Math.min(props.state.y + 1, dimensions().height - actions().length))
+  const left = () => Math.max(0, Math.min(props.state.x, dimensions().width - CONTEXT_MENU_WIDTH))
+  const run = (index: number) => {
+    props.onClose()
+    actions()[index]?.run()
+  }
+
+  createEffect(() => {
+    const popMode = keymap.mode.push("modal")
+    onCleanup(popMode)
+  })
+  Keymap.createLayer(() => ({
+    mode: "modal",
+    commands: [
+      { bind: "escape", title: "Close tab menu", group: "Tabs", run: props.onClose },
+      {
+        bind: "up",
+        title: "Previous tab menu item",
+        group: "Tabs",
+        run: () => setSelected(moveSelection(selected(), { count: actions().length, delta: -1, policy: "wrap" })),
+      },
+      {
+        bind: "down",
+        title: "Next tab menu item",
+        group: "Tabs",
+        run: () => setSelected(moveSelection(selected(), { count: actions().length, delta: 1, policy: "wrap" })),
+      },
+      { bind: "return", title: "Select tab menu item", group: "Tabs", run: () => run(selected()) },
+    ],
+  }))
+  return (
+    <box
+      position="absolute"
+      left={left()}
+      top={top()}
+      height={actions().length}
+      width={CONTEXT_MENU_WIDTH}
+      zIndex={2500}
+      flexDirection="column"
+      backgroundColor={theme.background.default}
+      onMouseDown={(event) => {
+        event.preventDefault()
+        event.stopPropagation()
+      }}
+    >
+      <For each={actions()}>
+        {(action, index) => (
+          <box
+            width="100%"
+            paddingLeft={1}
+            paddingRight={1}
+            backgroundColor={selected() === index() ? theme.background.action.primary.hovered : undefined}
+            onMouseOver={() => setSelected(index())}
+            onMouseUp={(event) => {
+              event.preventDefault()
+              event.stopPropagation()
+              run(index())
+            }}
+          >
+            <text fg={theme.text.default} selectable={false}>
+              {action.title}
+            </text>
+          </box>
+        )}
+      </For>
+    </box>
+  )
+}
+
 export function SessionTabs(
 export function SessionTabs(
   props: { controller?: SessionTabsController; animations?: boolean; orientation?: "horizontal" | "vertical" } = {},
   props: { controller?: SessionTabsController; animations?: boolean; orientation?: "horizontal" | "vertical" } = {},
 ) {
 ) {
@@ -181,6 +283,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
   const hovered = marquee.hovered
   const hovered = marquee.hovered
   const [dragging, setDragging] = createSignal<string>()
   const [dragging, setDragging] = createSignal<string>()
   const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
   const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
+  const [contextMenu, setContextMenu] = createSignal<TabContextMenuState>()
   const newTab = () => tabs.newTab?.() ?? false
   const newTab = () => tabs.newTab?.() ?? false
   const activeID = createMemo(() => (newTab() ? undefined : tabs.current()))
   const activeID = createMemo(() => (newTab() ? undefined : tabs.current()))
   const ordered = createMemo(() => {
   const ordered = createMemo(() => {
@@ -212,7 +315,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
       ),
       ),
   )
   )
   const itemStatus = (tab: SessionTab) => statuses().get(tab.sessionID)!
   const itemStatus = (tab: SessionTab) => statuses().get(tab.sessionID)!
-  let rail: { screenY: number } | undefined
+  let rail: { screenX: number; screenY: number } | undefined
   let scroll: ScrollBoxRenderable | undefined
   let scroll: ScrollBoxRenderable | undefined
 
 
   createEffect(() => {
   createEffect(() => {
@@ -242,6 +345,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
       height="100%"
       height="100%"
       flexShrink={0}
       flexShrink={0}
       flexDirection="column"
       flexDirection="column"
+      position="relative"
       paddingTop={1}
       paddingTop={1}
       backgroundColor={theme.background.default}
       backgroundColor={theme.background.default}
     >
     >
@@ -354,11 +458,27 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
                   backgroundColor={background()}
                   backgroundColor={background()}
                   onMouseOver={() => marquee.enter(tab.sessionID, title(), restingTitleWidth())}
                   onMouseOver={() => marquee.enter(tab.sessionID, title(), restingTitleWidth())}
                   onMouseOut={() => marquee.leave(tab.sessionID)}
                   onMouseOut={() => marquee.leave(tab.sessionID)}
-                  onMouseDown={() => {
+                  onMouseDown={(event) => {
+                    if (event.button === RIGHT_MOUSE_BUTTON) {
+                      setDragging(undefined)
+                      if (!rail) return
+                      setContextMenu({
+                        x: event.x - rail.screenX,
+                        y: event.y - rail.screenY,
+                        sessionID: tab.sessionID,
+                        title: tab.title,
+                      })
+                      event.preventDefault()
+                      event.stopPropagation()
+                      return
+                    }
                     marquee.enter(tab.sessionID, title(), restingTitleWidth())
                     marquee.enter(tab.sessionID, title(), restingTitleWidth())
                     setDragging(tab.sessionID)
                     setDragging(tab.sessionID)
                   }}
                   }}
-                  onMouseUp={release}
+                  onMouseUp={(event) => {
+                    if (event.button === RIGHT_MOUSE_BUTTON) return
+                    release()
+                  }}
                   onMouseDrag={(event) => {
                   onMouseDrag={(event) => {
                     if (!rail) return
                     if (!rail) return
                     const target = Math.max(
                     const target = Math.max(
@@ -467,6 +587,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
                         fg={theme.text.subdued}
                         fg={theme.text.subdued}
                         selectable={false}
                         selectable={false}
                         onMouseUp={(event) => {
                         onMouseUp={(event) => {
+                          if (event.button === RIGHT_MOUSE_BUTTON) return
                           if (hovered() !== tab.sessionID) return
                           if (hovered() !== tab.sessionID) return
                           event.stopPropagation()
                           event.stopPropagation()
                           tabs.close(tab.sessionID)
                           tabs.close(tab.sessionID)
@@ -518,7 +639,15 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
               }
               }
               onMouseOver={() => setAddHovered(true)}
               onMouseOver={() => setAddHovered(true)}
               onMouseOut={() => setAddHovered(false)}
               onMouseOut={() => setAddHovered(false)}
-              onMouseUp={() => {
+              onMouseDown={(event: MouseEvent) => {
+                if (event.button !== RIGHT_MOUSE_BUTTON) return
+                if (!rail) return
+                setContextMenu({ x: event.x - rail.screenX, y: event.y - rail.screenY })
+                event.preventDefault()
+                event.stopPropagation()
+              }}
+              onMouseUp={(event: MouseEvent) => {
+                if (event.button === RIGHT_MOUSE_BUTTON) return
                 if (!newTab()) tabs.add?.()
                 if (!newTab()) tabs.add?.()
               }}
               }}
             >
             >
@@ -547,6 +676,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
                   fg={theme.text.subdued}
                   fg={theme.text.subdued}
                   selectable={false}
                   selectable={false}
                   onMouseUp={(event) => {
                   onMouseUp={(event) => {
+                    if (event.button === RIGHT_MOUSE_BUTTON) return
                     if (!addHovered()) return
                     if (!addHovered()) return
                     event.stopPropagation()
                     event.stopPropagation()
                     tabs.close()
                     tabs.close()
@@ -559,6 +689,9 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
           </Show>
           </Show>
         </box>
         </box>
       </scrollbox>
       </scrollbox>
+      <Show when={contextMenu()}>
+        {(state) => <TabContextMenu state={state()} tabs={tabs} onClose={() => setContextMenu(undefined)} />}
+      </Show>
     </box>
     </box>
   )
   )
 }
 }
@@ -578,7 +711,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
   // per slot crossing; the preview holds after release until the store reflects the move,
   // per slot crossing; the preview holds after release until the store reflects the move,
   // so the strip never flashes the pre-drag order while the write is in flight.
   // so the strip never flashes the pre-drag order while the write is in flight.
   const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
   const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
-  let strip: { screenX: number } | undefined
+  const [contextMenu, setContextMenu] = createSignal<TabContextMenuState>()
+  let strip: { screenX: number; screenY: number } | undefined
   const hueStep = () => (mode() === "light" ? 800 : 200)
   const hueStep = () => (mode() === "light" ? 800 : 200)
   const accent = () => theme.hue.accent[hueStep()]
   const accent = () => theme.hue.accent[hueStep()]
   const activeNumber = () => theme.hue.interactive[hueStep()]
   const activeNumber = () => theme.hue.interactive[hueStep()]
@@ -825,11 +959,26 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
               backgroundColor={background()}
               backgroundColor={background()}
               onMouseOver={() => marquee.enter(tab.sessionID, title(), restingTitleWidth())}
               onMouseOver={() => marquee.enter(tab.sessionID, title(), restingTitleWidth())}
               onMouseOut={() => marquee.leave(tab.sessionID)}
               onMouseOut={() => marquee.leave(tab.sessionID)}
-              onMouseDown={() => {
+              onMouseDown={(event) => {
+                if (event.button === RIGHT_MOUSE_BUTTON) {
+                  setDragging(undefined)
+                  setContextMenu({
+                    x: event.x - (strip?.screenX ?? 0),
+                    y: event.y - (strip?.screenY ?? 0),
+                    sessionID: tab === NEW_SESSION_TAB ? undefined : tab.sessionID,
+                    title: tab === NEW_SESSION_TAB ? undefined : tab.title,
+                  })
+                  event.preventDefault()
+                  event.stopPropagation()
+                  return
+                }
                 marquee.enter(tab.sessionID, title(), restingTitleWidth())
                 marquee.enter(tab.sessionID, title(), restingTitleWidth())
                 setDragging(tab.sessionID)
                 setDragging(tab.sessionID)
               }}
               }}
-              onMouseUp={release}
+              onMouseUp={(event) => {
+                if (event.button === RIGHT_MOUSE_BUTTON) return
+                release()
+              }}
               onMouseDrag={(event) => {
               onMouseDrag={(event) => {
                 if (tab === NEW_SESSION_TAB) return
                 if (tab === NEW_SESSION_TAB) return
                 const slot = slotAt(event.x)
                 const slot = slotAt(event.x)
@@ -880,6 +1029,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
                   fg={closeColor()}
                   fg={closeColor()}
                   selectable={false}
                   selectable={false}
                   onMouseUp={(event) => {
                   onMouseUp={(event) => {
+                    if (event.button === RIGHT_MOUSE_BUTTON) return
                     // The close mark only renders while hovered; without motion events a click can
                     // The close mark only renders while hovered; without motion events a click can
                     // land here first, and must select the tab instead of closing it invisibly.
                     // land here first, and must select the tab instead of closing it invisibly.
                     if (hovered() !== tab.sessionID) return
                     if (hovered() !== tab.sessionID) return
@@ -907,11 +1057,23 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
           selectable={false}
           selectable={false}
           onMouseOver={() => setAddHovered(true)}
           onMouseOver={() => setAddHovered(true)}
           onMouseOut={() => setAddHovered(false)}
           onMouseOut={() => setAddHovered(false)}
-          onMouseUp={() => tabs.add?.()}
+          onMouseDown={(event) => {
+            if (event.button !== RIGHT_MOUSE_BUTTON) return
+            setContextMenu({ x: event.x - (strip?.screenX ?? 0), y: event.y - (strip?.screenY ?? 0) })
+            event.preventDefault()
+            event.stopPropagation()
+          }}
+          onMouseUp={(event) => {
+            if (event.button === RIGHT_MOUSE_BUTTON) return
+            tabs.add?.()
+          }}
         >
         >
           {" + "}
           {" + "}
         </text>
         </text>
       </Show>
       </Show>
+      <Show when={contextMenu()}>
+        {(state) => <TabContextMenu state={state()} tabs={tabs} onClose={() => setContextMenu(undefined)} />}
+      </Show>
     </box>
     </box>
   )
   )
 }
 }