305 lines
12 KiB
TypeScript
305 lines
12 KiB
TypeScript
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
|
import { isDeepEqual } from "remeda"
|
|
import { createSimpleContext } from "./helper"
|
|
import { useClient } from "./client"
|
|
import { useData } from "./data"
|
|
import { useEvent } from "./event"
|
|
import { useRoute } from "./route"
|
|
import { useConfig } from "../config"
|
|
import { useStorage } from "./storage"
|
|
import { useTuiPaths } from "./runtime"
|
|
import {
|
|
closeSessionTab,
|
|
cycleSessionTab,
|
|
moveSessionTab,
|
|
moveSessionTabHistory,
|
|
openSessionTab,
|
|
recordClosedSessionTab,
|
|
recordSessionTabHistory,
|
|
reopenSessionTab,
|
|
type ClosedSessionTab,
|
|
type SessionTab,
|
|
type SessionTabHistory,
|
|
type SessionTabUnread,
|
|
} from "./session-tabs-model"
|
|
|
|
type TabsState = {
|
|
tabs: SessionTab[]
|
|
unread: Record<string, SessionTabUnread>
|
|
}
|
|
|
|
type PersistedState = {
|
|
global: TabsState
|
|
cwd: Record<string, TabsState>
|
|
}
|
|
|
|
const empty = (): TabsState => ({ tabs: [], unread: {} })
|
|
|
|
// Deliberately after connect settles: the visible session's mount syncs win the first slots.
|
|
const TAB_PREFETCH_DELAY = 300
|
|
|
|
export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimpleContext({
|
|
name: "SessionTabs",
|
|
init: () => {
|
|
const route = useRoute()
|
|
const client = useClient()
|
|
const data = useData()
|
|
const event = useEvent()
|
|
const config = useConfig().data
|
|
const paths = useTuiPaths()
|
|
const enabled = () => config.tabs?.enabled ?? false
|
|
// Keyed reconcile keeps tab object identity across reorders, so strip rows move instead of
|
|
// mutating in place, which per-row animations and drag state depend on.
|
|
const [store, updateStore] = useStorage().store<PersistedState>("tabs", {
|
|
initial: {
|
|
global: empty(),
|
|
cwd: {},
|
|
},
|
|
key: "sessionID",
|
|
})
|
|
const fallback = empty()
|
|
const [promptPulses, setPromptPulses] = createSignal<Record<string, number>>({})
|
|
let history: SessionTabHistory = { entries: [], index: -1 }
|
|
// User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned.
|
|
let closedTabs: ClosedSessionTab[] = []
|
|
|
|
function state() {
|
|
if (config.tabs?.scope === "global") return store.global
|
|
return store.cwd[paths.cwd] ?? fallback
|
|
}
|
|
|
|
function update(mutation: (draft: TabsState) => void) {
|
|
const scope = config.tabs?.scope ?? "cwd"
|
|
void updateStore((draft) => mutation(scope === "cwd" ? (draft.cwd[paths.cwd] ??= empty()) : draft.global)).catch(
|
|
() => {},
|
|
)
|
|
}
|
|
|
|
const root = (sessionID: string) => data.session.root(sessionID)
|
|
const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
|
|
const status = (sessionID: string) => {
|
|
const session = root(sessionID)
|
|
const members = data.session.family(session)
|
|
const family = members.length > 0 ? members : [session]
|
|
return {
|
|
unread: state().unread[session],
|
|
promptPulse: promptPulses()[session] ?? 0,
|
|
attention: family.some(
|
|
(id) => (data.session.permission.list(id)?.length ?? 0) > 0 || (data.session.form.list(id)?.length ?? 0) > 0,
|
|
),
|
|
busy: family.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
|
|
}
|
|
}
|
|
|
|
function markUnread(sessionID: string, unread: SessionTabUnread) {
|
|
if (!enabled()) return
|
|
const session = root(sessionID)
|
|
if (current() === session || !state().tabs.some((tab) => tab.sessionID === session)) return
|
|
if (state().unread[session] === unread) return
|
|
update((draft) => {
|
|
if (!draft.tabs.some((tab) => tab.sessionID === session)) return
|
|
draft.unread[session] = unread
|
|
})
|
|
}
|
|
|
|
createEffect(() => {
|
|
if (!enabled()) return
|
|
if (route.data.type !== "session" || route.data.sessionID === "dummy") return
|
|
const sessionID = root(route.data.sessionID)
|
|
history = recordSessionTabHistory(history, sessionID)
|
|
const title = data.session.get(sessionID)?.title
|
|
const tabs = openSessionTab(state().tabs, { sessionID, title })
|
|
if (tabs === state().tabs && !state().unread[sessionID]) return
|
|
update((draft) => {
|
|
draft.tabs = openSessionTab(draft.tabs, { sessionID, title })
|
|
delete draft.unread[sessionID]
|
|
})
|
|
})
|
|
|
|
createEffect(() => {
|
|
if (!enabled()) return
|
|
const next = state().tabs.reduce<SessionTab[]>((tabs, tab) => {
|
|
const sessionID = root(tab.sessionID)
|
|
return openSessionTab(tabs, { sessionID, title: data.session.get(sessionID)?.title ?? tab.title })
|
|
}, [])
|
|
const unread = Object.entries(state().unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
|
|
const sessionID = root(entry[0])
|
|
result[sessionID] = result[sessionID] === "error" ? "error" : entry[1]
|
|
return result
|
|
}, {})
|
|
if (isDeepEqual(next, state().tabs) && isDeepEqual(unread, state().unread)) return
|
|
update((draft) => {
|
|
draft.tabs = draft.tabs.reduce<SessionTab[]>((tabs, tab) => {
|
|
const sessionID = root(tab.sessionID)
|
|
return openSessionTab(tabs, { sessionID, title: data.session.get(sessionID)?.title ?? tab.title })
|
|
}, [])
|
|
draft.unread = Object.entries(draft.unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
|
|
const sessionID = root(entry[0])
|
|
result[sessionID] = result[sessionID] === "error" ? "error" : entry[1]
|
|
return result
|
|
}, {})
|
|
})
|
|
})
|
|
|
|
// Warm open tabs' session data so first switches render from cache instead of fetching inside
|
|
// the switch gesture. Uses only existing sync methods (each dedupes internally), so reruns on
|
|
// tab-set or connection changes are no-ops for already-warm sessions, and reconnects double as
|
|
// a cache refresh after an SSE gap. The delay lets the current session's own mount syncs get
|
|
// the first connection slots. The effect tracks only the id set: reorders, tab switches, and
|
|
// title updates neither restart the timer nor an in-flight warm pass; the timer callback
|
|
// itself runs untracked, where the current session is skipped.
|
|
const openTabSessions = createMemo(() =>
|
|
state()
|
|
.tabs.map((tab) => tab.sessionID)
|
|
.sort()
|
|
.join("\n"),
|
|
)
|
|
createEffect(() => {
|
|
if (!enabled()) return
|
|
if (client.connection.status() !== "connected") return
|
|
if (openTabSessions() === "") return
|
|
let stale = false
|
|
const timer = setTimeout(async () => {
|
|
const sessions = state()
|
|
.tabs.map((tab) => tab.sessionID)
|
|
.filter((sessionID) => sessionID !== current())
|
|
for (const sessionID of sessions) {
|
|
if (stale) return
|
|
await Promise.allSettled([
|
|
data.session.sync(sessionID),
|
|
data.session.message.sync(sessionID),
|
|
data.session.pending.sync(sessionID),
|
|
data.session.permission.sync(sessionID),
|
|
data.session.form.sync(sessionID),
|
|
])
|
|
}
|
|
}, TAB_PREFETCH_DELAY)
|
|
onCleanup(() => {
|
|
stale = true
|
|
clearTimeout(timer)
|
|
})
|
|
})
|
|
|
|
onCleanup(event.on("session.execution.succeeded", (evt) => markUnread(evt.data.sessionID, "activity")))
|
|
onCleanup(event.on("session.execution.interrupted", (evt) => markUnread(evt.data.sessionID, "activity")))
|
|
onCleanup(event.on("session.execution.failed", (evt) => markUnread(evt.data.sessionID, "error")))
|
|
onCleanup(
|
|
event.on("session.input.admitted", (evt) => {
|
|
if (!enabled() || evt.data.input.type !== "user") return
|
|
const sessionID = root(evt.data.sessionID)
|
|
if (current() === sessionID || !state().tabs.some((tab) => tab.sessionID === sessionID)) return
|
|
setPromptPulses((pulses) => ({ ...pulses, [sessionID]: (pulses[sessionID] ?? 0) + 1 }))
|
|
}),
|
|
)
|
|
onCleanup(
|
|
event.on("session.error", (evt) => {
|
|
if (evt.data.sessionID) markUnread(evt.data.sessionID, "error")
|
|
}),
|
|
)
|
|
onCleanup(
|
|
event.on("session.deleted", (evt) => {
|
|
const target = root(evt.data.sessionID)
|
|
closedTabs = closedTabs.filter((entry) => entry.tab.sessionID !== target)
|
|
remove(evt.data.sessionID, enabled())
|
|
}),
|
|
)
|
|
|
|
function remove(sessionID: string, navigate: boolean) {
|
|
const target = root(sessionID)
|
|
const closed = closeSessionTab(state().tabs, target)
|
|
if (closed.tabs.length === state().tabs.length) return
|
|
const selected = navigate && current() === target
|
|
const previous = selected
|
|
? moveSessionTabHistory(recordSessionTabHistory(history, target), closed.tabs, target, -1)
|
|
: { history, sessionID: undefined }
|
|
const next = previous.sessionID ?? closed.next
|
|
history = previous.history
|
|
update((draft) => {
|
|
draft.tabs = closeSessionTab(draft.tabs, target).tabs
|
|
delete draft.unread[target]
|
|
})
|
|
setPromptPulses((pulses) => {
|
|
if (pulses[target] === undefined) return pulses
|
|
const next = { ...pulses }
|
|
delete next[target]
|
|
return next
|
|
})
|
|
if (selected) route.navigate(next ? { type: "session", sessionID: next } : { type: "home" })
|
|
}
|
|
|
|
return {
|
|
enabled,
|
|
tabs() {
|
|
return state().tabs
|
|
},
|
|
newTab() {
|
|
return route.data.type === "home"
|
|
},
|
|
current,
|
|
status,
|
|
select(sessionID: string) {
|
|
if (!enabled()) return
|
|
route.navigate({ type: "session", sessionID: root(sessionID) })
|
|
},
|
|
close(sessionID?: string) {
|
|
if (!enabled()) return
|
|
const target = sessionID ? root(sessionID) : current()
|
|
if (!target) {
|
|
const previous = moveSessionTabHistory(history, state().tabs, undefined, -1)
|
|
history = previous.history
|
|
const session = previous.sessionID ?? state().tabs.at(-1)?.sessionID
|
|
if (route.data.type === "home" && session) route.navigate({ type: "session", sessionID: session })
|
|
return
|
|
}
|
|
const index = state().tabs.findIndex((tab) => tab.sessionID === target)
|
|
const tab = state().tabs[index]
|
|
if (tab) closedTabs = recordClosedSessionTab(closedTabs, tab, index)
|
|
remove(target, true)
|
|
},
|
|
reopen() {
|
|
if (!enabled()) return
|
|
const result = reopenSessionTab(closedTabs, state().tabs)
|
|
closedTabs = result.stack
|
|
const tabs = result.tabs
|
|
if (!tabs || !result.sessionID) return
|
|
update((draft) => {
|
|
draft.tabs = tabs
|
|
})
|
|
route.navigate({ type: "session", sessionID: result.sessionID })
|
|
},
|
|
move(sessionID: string, index: number) {
|
|
if (!enabled()) return
|
|
const session = root(sessionID)
|
|
if (moveSessionTab(state().tabs, session, index) === state().tabs) return
|
|
update((draft) => {
|
|
draft.tabs = moveSessionTab(draft.tabs, session, index)
|
|
})
|
|
},
|
|
cycle(direction: 1 | -1) {
|
|
if (!enabled()) return
|
|
const tab = cycleSessionTab(state().tabs, current(), direction)
|
|
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
|
|
},
|
|
cycleUnread(direction: 1 | -1) {
|
|
if (!enabled()) return
|
|
const tab = cycleSessionTab(
|
|
state().tabs.filter((tab) => state().unread[tab.sessionID] || status(tab.sessionID).attention),
|
|
current(),
|
|
direction,
|
|
)
|
|
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
|
|
},
|
|
history(direction: 1 | -1) {
|
|
if (!enabled()) return
|
|
const next = moveSessionTabHistory(history, state().tabs, current(), direction)
|
|
history = next.history
|
|
if (next.sessionID) route.navigate({ type: "session", sessionID: next.sessionID })
|
|
},
|
|
selectIndex(index: number) {
|
|
if (!enabled()) return
|
|
const tab = state().tabs[index]
|
|
if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
|
|
},
|
|
}
|
|
},
|
|
})
|