|
@@ -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>
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|