|
|
@@ -27,6 +27,8 @@ import { marqueeText } from "../util/marquee"
|
|
|
|
|
|
// A long title fades out over its last cells instead of cutting hard.
|
|
|
const FADE_WIDTH = 4
|
|
|
+// The add button renders as " + " at the end of the strip, so the tab layout leaves it room.
|
|
|
+const ADD_TAB_WIDTH = 3
|
|
|
const MARQUEE_DELAY = 600
|
|
|
const MARQUEE_INTERVAL = 100
|
|
|
|
|
|
@@ -42,6 +44,7 @@ export const EMPTY_SESSION_TAB_STATUS: SessionTabsStatus = {
|
|
|
}
|
|
|
export type SessionTabsController = Pick<ContextController, "tabs" | "current" | "select" | "close" | "move"> & {
|
|
|
newTab?: () => boolean
|
|
|
+ add?: () => void
|
|
|
status(sessionID: string): SessionTabsStatus
|
|
|
}
|
|
|
|
|
|
@@ -103,22 +106,23 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|
|
const separatorUpperPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.04))
|
|
|
const separatorLowerPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.05))
|
|
|
const [hovered, setHovered] = createSignal<string>()
|
|
|
+ const [addHovered, setAddHovered] = createSignal(false)
|
|
|
const marquee = createMarquee(hovered, animations)
|
|
|
const [dragging, setDragging] = createSignal<string>()
|
|
|
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
|
|
|
const newTab = () => tabs.newTab?.() ?? false
|
|
|
- const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current()))
|
|
|
+ const activeID = createMemo(() => (newTab() ? undefined : tabs.current()))
|
|
|
const ordered = createMemo(() => {
|
|
|
const pending = preview()
|
|
|
if (!pending) return tabs.tabs()
|
|
|
return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index)
|
|
|
})
|
|
|
- const items = createMemo(() => (newTab() ? [...ordered(), NEW_SESSION_TAB] : ordered()))
|
|
|
+ const items = ordered
|
|
|
const statuses = createMemo(
|
|
|
() =>
|
|
|
new Map(
|
|
|
items().map((tab) => {
|
|
|
- const status = tab === NEW_SESSION_TAB ? EMPTY_SESSION_TAB_STATUS : tabs.status(tab.sessionID)
|
|
|
+ const status = tabs.status(tab.sessionID)
|
|
|
return [
|
|
|
tab.sessionID,
|
|
|
{
|
|
|
@@ -145,6 +149,8 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|
|
|
|
|
createEffect(() => {
|
|
|
if (!scroll) return
|
|
|
+ // The promoted new-session slot sits below the list, so bring the rail's bottom into view.
|
|
|
+ if (newTab()) return scroll.scrollTo(Math.max(0, items().length * 3 + 1 - scroll.viewport.height))
|
|
|
const index = items().findIndex((tab) => tab.sessionID === activeID())
|
|
|
if (index === -1) return
|
|
|
const top = index * 3
|
|
|
@@ -171,7 +177,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|
|
const selected = () => activeID() === tab.sessionID
|
|
|
const status = createMemo(() => itemStatus(tab))
|
|
|
const [sweepLevel, setSweepLevel] = createSignal(0)
|
|
|
- const session = createMemo(() => (tab === NEW_SESSION_TAB ? undefined : data.session.get(tab.sessionID)))
|
|
|
+ const session = createMemo(() => data.session.get(tab.sessionID))
|
|
|
const project = createMemo(() => {
|
|
|
const value = session()
|
|
|
return value ? data.project.get(value.projectID) : undefined
|
|
|
@@ -188,7 +194,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|
|
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
|
|
|
const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH)
|
|
|
const detail = createMemo(() => {
|
|
|
- if (tab === NEW_SESSION_TAB) return Locale.takeWidth("Start a new session", titleWidth())
|
|
|
const value = session()
|
|
|
return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth())
|
|
|
})
|
|
|
@@ -260,7 +265,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|
|
setDragging(undefined)
|
|
|
const pending = preview()
|
|
|
if (pending?.sessionID === tab.sessionID) tabs.move(pending.sessionID, pending.index)
|
|
|
- if (tab !== NEW_SESSION_TAB) tabs.select(tab.sessionID)
|
|
|
+ tabs.select(tab.sessionID)
|
|
|
}
|
|
|
return (
|
|
|
<box
|
|
|
@@ -277,7 +282,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|
|
}}
|
|
|
onMouseUp={release}
|
|
|
onMouseDrag={(event) => {
|
|
|
- if (!rail || tab === NEW_SESSION_TAB) return
|
|
|
+ if (!rail) return
|
|
|
const target = Math.max(
|
|
|
0,
|
|
|
Math.min(
|
|
|
@@ -386,7 +391,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|
|
onMouseUp={(event) => {
|
|
|
if (hovered() !== tab.sessionID) return
|
|
|
event.stopPropagation()
|
|
|
- tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID)
|
|
|
+ tabs.close(tab.sessionID)
|
|
|
}}
|
|
|
>
|
|
|
{hovered() === tab.sessionID ? "×" : ""}
|
|
|
@@ -417,6 +422,63 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
|
|
)
|
|
|
}}
|
|
|
</For>
|
|
|
+ {/* One slot with two states: a subdued affordance that promotes in place into the
|
|
|
+ active new-session tab, instead of spawning a separate pseudo tab above itself. */}
|
|
|
+ <Show when={tabs.add || newTab()}>
|
|
|
+ <box
|
|
|
+ height={1}
|
|
|
+ width="100%"
|
|
|
+ position="relative"
|
|
|
+ flexDirection="row"
|
|
|
+ paddingLeft={1}
|
|
|
+ backgroundColor={
|
|
|
+ newTab()
|
|
|
+ ? theme.background.action.primary.selected
|
|
|
+ : addHovered()
|
|
|
+ ? theme.background.action.primary.hovered
|
|
|
+ : theme.background.default
|
|
|
+ }
|
|
|
+ onMouseOver={() => setAddHovered(true)}
|
|
|
+ onMouseOut={() => setAddHovered(false)}
|
|
|
+ onMouseUp={() => {
|
|
|
+ if (!newTab()) tabs.add?.()
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <text
|
|
|
+ width={2}
|
|
|
+ fg={newTab() ? activeNumber() : addHovered() ? theme.text.default : idleNumber()}
|
|
|
+ selectable={false}
|
|
|
+ attributes={newTab() ? TextAttributes.BOLD : undefined}
|
|
|
+ >
|
|
|
+ +
|
|
|
+ </text>
|
|
|
+ <text
|
|
|
+ fg={newTab() || addHovered() ? theme.text.default : theme.text.subdued}
|
|
|
+ wrapMode="none"
|
|
|
+ selectable={false}
|
|
|
+ attributes={newTab() ? TextAttributes.BOLD : undefined}
|
|
|
+ >
|
|
|
+ {NEW_SESSION_TAB_TITLE}
|
|
|
+ </text>
|
|
|
+ <Show when={newTab()}>
|
|
|
+ <text
|
|
|
+ position="absolute"
|
|
|
+ right={1}
|
|
|
+ zIndex={2}
|
|
|
+ width={1}
|
|
|
+ fg={theme.text.subdued}
|
|
|
+ selectable={false}
|
|
|
+ onMouseUp={(event) => {
|
|
|
+ if (!addHovered()) return
|
|
|
+ event.stopPropagation()
|
|
|
+ tabs.close()
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {addHovered() ? "×" : ""}
|
|
|
+ </text>
|
|
|
+ </Show>
|
|
|
+ </box>
|
|
|
+ </Show>
|
|
|
</box>
|
|
|
</scrollbox>
|
|
|
</box>
|
|
|
@@ -431,6 +493,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
|
|
const config = useConfig().data
|
|
|
const animations = () => props.animations ?? config.animations ?? true
|
|
|
const [hovered, setHovered] = createSignal<string>()
|
|
|
+ const [addHovered, setAddHovered] = createSignal(false)
|
|
|
const marquee = createMarquee(hovered, animations)
|
|
|
const [dragging, setDragging] = createSignal<string>()
|
|
|
// A drag reorders a local preview and persists one move on release instead of writing
|
|
|
@@ -449,7 +512,10 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
|
|
if (!pending) return tabs.tabs()
|
|
|
return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index)
|
|
|
})
|
|
|
+ // The promoted new-session slot joins the strip as the active tab; the idle plus affordance
|
|
|
+ // and the promoted slot are mutually exclusive states of one control.
|
|
|
const items = createMemo(() => (newTab() ? [...ordered(), NEW_SESSION_TAB] : ordered()))
|
|
|
+ const showPlus = () => Boolean(tabs.add) && !newTab()
|
|
|
createEffect(() => {
|
|
|
const pending = preview()
|
|
|
if (!pending || dragging()) return
|
|
|
@@ -457,7 +523,12 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
|
|
if (index === -1 || index === Math.min(pending.index, tabs.tabs().length - 1)) setPreview(undefined)
|
|
|
})
|
|
|
const layout = createMemo((previous: ReturnType<typeof adaptiveSessionTabLayout> | undefined) =>
|
|
|
- adaptiveSessionTabLayout(items(), activeID(), dimensions().width, previous?.start),
|
|
|
+ adaptiveSessionTabLayout(
|
|
|
+ items(),
|
|
|
+ activeID(),
|
|
|
+ dimensions().width - (showPlus() ? ADD_TAB_WIDTH : 0),
|
|
|
+ previous?.start,
|
|
|
+ ),
|
|
|
)
|
|
|
const statuses = createMemo(
|
|
|
() =>
|
|
|
@@ -704,7 +775,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
|
|
{" "}
|
|
|
</text>
|
|
|
<text width={numberWidth()} fg={numberColor()} selectable={false} attributes={bold()}>
|
|
|
- {sessionTabShortcutLabel(tabNumber() - 1)}
|
|
|
+ {tab === NEW_SESSION_TAB ? "+" : sessionTabShortcutLabel(tabNumber() - 1)}
|
|
|
</text>
|
|
|
<text
|
|
|
width={availableTitleWidth()}
|
|
|
@@ -746,6 +817,19 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
|
|
{" " + layout().after}›
|
|
|
</text>
|
|
|
</Show>
|
|
|
+ <Show when={showPlus()}>
|
|
|
+ <text
|
|
|
+ width={ADD_TAB_WIDTH}
|
|
|
+ fg={addHovered() ? theme.text.default : theme.text.subdued}
|
|
|
+ bg={addHovered() ? theme.background.action.primary.hovered : undefined}
|
|
|
+ selectable={false}
|
|
|
+ onMouseOver={() => setAddHovered(true)}
|
|
|
+ onMouseOut={() => setAddHovered(false)}
|
|
|
+ onMouseUp={() => tabs.add?.()}
|
|
|
+ >
|
|
|
+ {" + "}
|
|
|
+ </text>
|
|
|
+ </Show>
|
|
|
</box>
|
|
|
)
|
|
|
}
|