Skip to content
13 changes: 13 additions & 0 deletions packages/tui/src/context/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
})

return {
// Freeze startup identity even if managed-service reconnect changes the transport URL.
server: serverIdentity(props.url ?? "http://localhost", service !== undefined),
get api() {
return api
},
Expand All @@ -59,3 +61,14 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
}
},
})

export function serverIdentity(url: string, managed = false) {
if (managed) return "local"
const value = new URL(url)
value.username = ""
value.password = ""
value.search = ""
value.hash = ""
value.pathname = value.pathname.replace(/\/+$/, "") || "/"
return value.toString()
}
26 changes: 17 additions & 9 deletions packages/tui/src/context/session-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@ type TabsState = {
}

type PersistedState = {
global: TabsState
cwd: Record<string, TabsState>
servers?: Record<string, { global: TabsState; cwd: Record<string, TabsState> }>
// The managed local server continues using the legacy fields.
global?: TabsState
cwd?: Record<string, TabsState>
}

type ScrollAnchor = {
Expand Down Expand Up @@ -69,10 +71,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
// 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] = storage.store<PersistedState>("tabs", {
initial: {
global: empty(),
cwd: {},
},
initial: { servers: {} },
key: "sessionID",
})
const [preview, updatePreview] = createStore<{ global?: string; cwd?: string }>({})
Expand Down Expand Up @@ -102,16 +101,25 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})

function state() {
if (config.tabs.scope === "cwd") return store.cwd[paths.cwd] ?? fallback
return store.global
const server = client.server === "local" ? store : store.servers?.[client.server]
if (!server) return fallback
if (config.tabs.scope === "cwd") return server.cwd?.[paths.cwd] ?? fallback
return server.global ?? fallback
}

const previewID = () => preview[config.tabs.scope]
const setPreview = (sessionID: string | undefined) => updatePreview(config.tabs.scope, sessionID)

function update(mutation: (draft: TabsState) => void) {
const scope = config.tabs.scope
void updateStore((draft) => mutation(scope === "cwd" ? (draft.cwd[paths.cwd] ??= empty()) : draft.global)).catch(
void updateStore((draft) => {
const server =
client.server === "local"
? draft
: ((draft.servers ??= {})[client.server] ??= { global: empty(), cwd: {} })
server.cwd ??= {}
mutation(scope === "cwd" ? (server.cwd[paths.cwd] ??= empty()) : (server.global ??= empty()))
}).catch(
// Failed writes lose only tab layout, but silence would hide tabs resetting every launch.
(error) => console.error("Failed to persist session tabs", error),
)
Expand Down
36 changes: 25 additions & 11 deletions packages/tui/src/context/session-terminals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import { useEvent } from "./event"
import { useStorage } from "./storage"

type SessionTerminalsState = {
sessions: Record<string, string | null>
servers?: Record<string, { sessions: Record<string, string | null> }>
// The managed local server continues using the legacy fields.
sessions?: Record<string, string | null>
}

export const { use: useSessionTerminals, provider: SessionTerminalsProvider } = createSimpleContext({
Expand All @@ -21,29 +23,41 @@ export const { use: useSessionTerminals, provider: SessionTerminalsProvider } =
const [focus, setFocus] = createSignal<string>()
const storage = useStorage()
const [store, update] = storage.store<SessionTerminalsState>("session-terminal-selection", {
initial: { sessions: {} },
})
const [terminals, updateTerminals] = storage.memory<Record<string, PersistentPtyInfo[]>>("session-terminals", {
initial: {},
initial: { servers: {} },
})
const [terminals, updateTerminals] = storage.memory<Record<string, PersistentPtyInfo[]>>(
`session-terminals:${client.server}`,
{
initial: {},
},
)
const selected = () => (client.server === "local" ? store.sessions : store.servers?.[client.server]?.sessions)

const refresh = async (sessionID: string) => {
if (!terminals[sessionID]) updateTerminals((draft) => (draft[sessionID] = []))
const result = await client.api.experimental.persistentPty.list({ sessionID })
updateTerminals((draft) => (draft[sessionID] = result))
const selected = store.sessions[sessionID]
if (!selected || result.some((terminal) => terminal.id === selected)) return
const current = selected()?.[sessionID]
if (!current || result.some((terminal) => terminal.id === current)) return
await update((draft) => {
if (draft.sessions[sessionID] !== selected) return
draft.sessions[sessionID] = null
const sessions = client.server === "local" ? draft.sessions : draft.servers?.[client.server]?.sessions
if (!sessions || sessions[sessionID] !== current) return
sessions[sessionID] = null
})
}

const selectTerminal = async (sessionID: string, ptyID: string | null) => {
if (ptyID !== null && !terminals[sessionID]?.some((terminal) => terminal.id === ptyID)) return
setFocus(ptyID ?? undefined)
await update((draft) => {
draft.sessions[sessionID] = ptyID
if (client.server === "local") {
draft.sessions ??= {}
draft.sessions[sessionID] = ptyID
return
}
draft.servers ??= {}
const server = (draft.servers[client.server] ??= { sessions: {} })
server.sessions[sessionID] = ptyID
})
}

Expand All @@ -70,7 +84,7 @@ export const { use: useSessionTerminals, provider: SessionTerminalsProvider } =
get(sessionID: string) {
return {
terminals: terminals[sessionID] ?? [],
selectedTerminalID: store.sessions[sessionID] ?? null,
selectedTerminalID: selected()?.[sessionID] ?? null,
}
},
refresh,
Expand Down
Loading
Loading