mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
feat(tui): open sessions from session IDs in messages (#63224)
This commit is contained in:
@@ -9,6 +9,7 @@ import { Keymap } from "../../context/keymap"
|
||||
import { useTheme, useThemes } from "../../context/theme"
|
||||
import { usePlugin } from "../../plugin/context"
|
||||
import { DIALOG_INSET, DialogBleed, DialogFooter, DialogHeader, DialogLayout } from "../../ui/dialog"
|
||||
import { useSessionLinks } from "../../ui/session-link"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
|
||||
@@ -172,6 +173,7 @@ export function Answer(props: {
|
||||
>
|
||||
<box paddingLeft={DIALOG_INSET} paddingRight={DIALOG_INSET} paddingTop={1} paddingBottom={1}>
|
||||
<markdown
|
||||
ref={useSessionLinks()}
|
||||
syntaxStyle={syntax()}
|
||||
renderNode={props.markdown()}
|
||||
content={props.answer}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useRoute, useRouteData } from "../../context/route"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useData } from "../../context/data"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
import { useSessionLinks } from "../../ui/session-link"
|
||||
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
|
||||
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
|
||||
import { PatchDiff } from "../../component/patch-diff"
|
||||
@@ -2223,6 +2224,7 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
|
||||
<Show when={content()}>
|
||||
<box paddingTop={1} paddingLeft={3}>
|
||||
<markdown
|
||||
ref={useSessionLinks()}
|
||||
syntaxStyle={syntax()}
|
||||
renderNode={plugins.markdown()}
|
||||
streaming={true}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Locale } from "../../util/locale"
|
||||
import { use } from "./render-context"
|
||||
import { generateThinkingSyntax } from "./thinking-syntax"
|
||||
import { canonicalToolName } from "../../util/tool-display"
|
||||
import { useSessionLinks } from "../../ui/session-link"
|
||||
|
||||
export const INLINE_TOOL_ICON_WIDTH = 2
|
||||
|
||||
@@ -184,6 +185,7 @@ export function TextPart(props: {
|
||||
<box paddingLeft={3} flexShrink={0}>
|
||||
{/* Configure custom nodes before parsing; apply content before streaming so completion keeps the final tokens. */}
|
||||
<markdown
|
||||
ref={useSessionLinks()}
|
||||
syntaxStyle={syntax()}
|
||||
renderNode={plugins.markdown()}
|
||||
content={props.part.text.trim()}
|
||||
|
||||
@@ -40,8 +40,12 @@ export function Link(props: LinkProps) {
|
||||
* link resolves the whole link. Modified clicks are left to the terminal's own hyperlink handling.
|
||||
*/
|
||||
export function clickedLink(renderer: CliRenderer, event: MouseEvent) {
|
||||
if (!event.isDragging || event.modifiers.ctrl || event.modifiers.alt || event.modifiers.shift) return
|
||||
const selection = renderer.getSelection()
|
||||
if (!selection?.isStart || selection.behavior !== "cell") return
|
||||
if (!plainClick(renderer, event)) return
|
||||
return renderer.getLinkAt(event.x, event.y) ?? undefined
|
||||
}
|
||||
|
||||
export function plainClick(renderer: CliRenderer, event: MouseEvent) {
|
||||
if (!event.isDragging || event.modifiers.ctrl || event.modifiers.alt || event.modifiers.shift) return false
|
||||
const selection = renderer.getSelection()
|
||||
return selection?.isStart === true && selection.behavior === "cell"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
CodeRenderable,
|
||||
TextAttributes,
|
||||
detectLinks,
|
||||
type ChunkRenderContext,
|
||||
type CliRenderer,
|
||||
type MarkdownRenderable,
|
||||
type MouseEvent,
|
||||
type Renderable,
|
||||
type TextChunk,
|
||||
} from "@opentui/core"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { createEffect, createSignal, untrack } from "solid-js"
|
||||
import { parseSessionMention, sessionMentions } from "@opencode/util/session-mention"
|
||||
import { displayLabel } from "@opencode/util/session-title-fallback"
|
||||
import { useData } from "../context/data"
|
||||
import { useRoute } from "../context/route"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { useDialog } from "./dialog"
|
||||
import { plainClick } from "./link"
|
||||
|
||||
type Rendered = { text: string; ranges: { start: number; end: number; sessionID: string }[] }
|
||||
|
||||
/**
|
||||
* Returns a `<markdown>` ref that shows session IDs as links titled after their sessions and
|
||||
* opens them on click, like the web app. The current session, archived sessions and sessions
|
||||
* that cannot be loaded stay plain IDs. Links re-render when a label changes.
|
||||
*/
|
||||
export function useSessionLinks() {
|
||||
const data = useData()
|
||||
const route = useRoute()
|
||||
const dialog = useDialog()
|
||||
const renderer = useRenderer()
|
||||
const labelOf = (sessionID: string) => {
|
||||
if (route.data.type === "session" && route.data.sessionID === sessionID) return
|
||||
const session = data.session.get(sessionID)
|
||||
// Sync dedupes in-flight and finished loads; a miss marks the session missing.
|
||||
if (!session && !data.session.missing(sessionID))
|
||||
untrack(() => void data.session.sync(sessionID).catch(() => undefined))
|
||||
return session && session.time.archived === undefined ? displayLabel(session) : undefined
|
||||
}
|
||||
// Labels each ID was last rendered with, so the effect below re-renders only on a change.
|
||||
const used = new Map<string, string | undefined>()
|
||||
const [version, setVersion] = createSignal(0)
|
||||
const rendered = new WeakMap<CodeRenderable, Rendered>()
|
||||
let markdown: MarkdownRenderable | undefined
|
||||
|
||||
// OpenTUI calls this as a method of each prose block, so `this` is the block. Fenced code never gets it.
|
||||
function linkChunks(this: CodeRenderable, chunks: TextChunk[], context: ChunkRenderContext) {
|
||||
const style = context.syntaxStyle.mergeStyles("markup.link.url")
|
||||
// Like the web app, a code span links only when it is exactly a session ID, so a command such
|
||||
// as `opencode --session ses_…` stays copyable. Marked by displayed offset, since detectLinks
|
||||
// can split chunks.
|
||||
const raw = context.highlights.filter(([, , group]) => group === "markup.raw" || group.startsWith("markup.raw."))
|
||||
let position = 0
|
||||
const code = chunks.flatMap((chunk, index) => {
|
||||
const start = position
|
||||
position += chunk.text.length
|
||||
const source = context.sourceRanges?.[index]
|
||||
const span = source && raw.find(([from, to]) => from <= source.start && source.end <= to)
|
||||
const text = span && context.content.slice(span[0], span[1]).replace(/^`+\s?|\s?`+$/g, "")
|
||||
return text !== undefined && !parseSessionMention(text) ? [{ start, end: position }] : []
|
||||
})
|
||||
position = 0
|
||||
const parts = detectLinks(chunks, context).flatMap((chunk): { chunk: TextChunk; sessionID?: string }[] => {
|
||||
const start = position
|
||||
position += chunk.text.length
|
||||
const plain = chunk.link || code.some((range) => range.start <= start && start < range.end)
|
||||
const mentions = plain ? [] : sessionMentions(chunk.text)
|
||||
if (mentions.length === 0) return [{ chunk }]
|
||||
// Odd parts are the mentions.
|
||||
const cuts = [0, ...mentions.flatMap((mention) => [mention.start, mention.end]), chunk.text.length]
|
||||
return cuts.slice(1).flatMap((end, index) => {
|
||||
const text = chunk.text.slice(cuts[index], end)
|
||||
const mention = index % 2 ? mentions[(index - 1) / 2]! : undefined
|
||||
const label = mention && labelOf(mention.id)
|
||||
if (mention && (!used.has(mention.id) || used.get(mention.id) !== label)) {
|
||||
used.set(mention.id, label)
|
||||
setVersion((value) => value + 1)
|
||||
}
|
||||
if (!mention || label === undefined) return text ? [{ chunk: { ...chunk, text } }] : []
|
||||
const link = {
|
||||
...chunk,
|
||||
// Raw markdown mode shows the source.
|
||||
text: this.conceal ? `@${label}` : text,
|
||||
fg: style.fg ?? chunk.fg,
|
||||
attributes: style.attributes | TextAttributes.UNDERLINE,
|
||||
}
|
||||
return [{ chunk: link, sessionID: mention.id }]
|
||||
})
|
||||
})
|
||||
let offset = 0
|
||||
const ranges = parts.flatMap((part) => {
|
||||
const start = offset
|
||||
offset += part.chunk.text.length
|
||||
return part.sessionID ? [{ start, end: offset, sessionID: part.sessionID }] : []
|
||||
})
|
||||
rendered.set(this, { text: parts.map((part) => part.chunk.text).join(""), ranges })
|
||||
return parts.map((part) => part.chunk)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
version()
|
||||
if (!markdown || [...used].every(([sessionID, label]) => labelOf(sessionID) === label)) return
|
||||
// Toggling the callback marks each block's highlight dirty while keeping the identity OpenTUI
|
||||
// checks when terminal capabilities refresh link styling.
|
||||
const refresh = (renderable: Renderable): void => {
|
||||
if (renderable instanceof CodeRenderable && renderable.onChunks === linkChunks) {
|
||||
renderable.onChunks = undefined
|
||||
renderable.onChunks = linkChunks
|
||||
renderable.requestRender()
|
||||
}
|
||||
renderable.getChildren().forEach(refresh)
|
||||
}
|
||||
untrack(() => refresh(markdown!))
|
||||
})
|
||||
|
||||
// Solid calls refs before setting content, so every block Markdown creates picks up `linkChunks`.
|
||||
return (renderable: MarkdownRenderable) => {
|
||||
markdown = renderable
|
||||
// oxlint-disable-next-line no-restricted-globals -- OpenTUI has no public option for the markdown chunk callback.
|
||||
Reflect.set(renderable, "_linkifyMarkdownChunks", linkChunks)
|
||||
renderable.onMouseUp = (event) => {
|
||||
const sessionID = clickedSession(renderer, event, rendered)
|
||||
if (!sessionID) return
|
||||
event.stopPropagation()
|
||||
if (dialog.stack.length > 0) dialog.clear()
|
||||
route.navigate({ type: "session", sessionID })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clickedSession(renderer: CliRenderer, event: MouseEvent, rendered: WeakMap<CodeRenderable, Rendered>) {
|
||||
const target = event.target
|
||||
if (!plainClick(renderer, event) || !(target instanceof CodeRenderable)) return
|
||||
const entry = rendered.get(target)
|
||||
// The streaming preview can briefly replace the highlighted text the ranges describe.
|
||||
if (!entry || entry.text !== target.plainText) return
|
||||
// Concealed markdown markers are absent from plainText, so map the cell through the wrapped
|
||||
// layout: rows report display columns, and a logical line starts on its row with wrap index 0.
|
||||
const info = target.lineInfo
|
||||
const row = event.y - target.y
|
||||
const line = info.lineSources[row]
|
||||
if (line === undefined) return
|
||||
const column = info.lineStartCols[row]! - info.lineStartCols[row - info.lineWraps[row]!]! + event.x - target.x
|
||||
const lines = entry.text.split("\n")
|
||||
const lineStart = lines.slice(0, line).reduce((sum, text) => sum + text.length + 1, 0)
|
||||
const lineEnd = lineStart + lines[line]!.length
|
||||
return entry.ranges.find((range) => {
|
||||
if (range.start < lineStart || range.end > lineEnd) return false
|
||||
const start = stringWidth(entry.text.slice(lineStart, range.start))
|
||||
return column >= start && column < start + stringWidth(entry.text.slice(range.start, range.end))
|
||||
})?.sessionID
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { SyntaxStyle, TextAttributes } from "@opentui/core"
|
||||
import { testRender, useRenderer } from "@opentui/solid"
|
||||
import { onCleanup, onMount, type JSX } from "solid-js"
|
||||
import { ConfigProvider } from "../../src/config"
|
||||
import { ClientProvider } from "../../src/context/client"
|
||||
import { DataProvider, useData } from "../../src/context/data"
|
||||
import { Keymap } from "../../src/context/keymap"
|
||||
import { RouteProvider, useRoute } from "../../src/context/route"
|
||||
import { TuiAppProvider } from "../../src/context/runtime"
|
||||
import { StorageProvider } from "../../src/context/storage"
|
||||
import { ThemeProvider } from "../../src/context/theme"
|
||||
import { DialogProvider, useDialog } from "../../src/ui/dialog"
|
||||
import { useSessionLinks } from "../../src/ui/session-link"
|
||||
import { ToastProvider } from "../../src/ui/toast"
|
||||
import { emptyThemeSource, tmpdir } from "../fixture/fixture"
|
||||
import { createApi, createFetch, directory, json } from "../fixture/tui-client"
|
||||
import { waitForHighlightedFrame } from "../fixture/frame"
|
||||
import { TestTuiContexts } from "../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
|
||||
|
||||
const session = (id: string, title: string, archived?: number) => ({
|
||||
id,
|
||||
title,
|
||||
projectID: "project",
|
||||
location: { directory },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0, ...(archived === undefined ? {} : { archived }) },
|
||||
})
|
||||
const id = (name: string) => `ses_${name.repeat(26).slice(0, 26)}`
|
||||
const current = id("current")
|
||||
const known = session(id("known"), "Fix the flaky reconnect test on Windows")
|
||||
const archived = session(id("archived"), "Old work", 1)
|
||||
const unknown = id("unknown")
|
||||
const missing = id("missing")
|
||||
|
||||
test.each(["top-level", "coalesced"] as const)("session links in %s markdown", async (mode) => {
|
||||
await using state = await tmpdir()
|
||||
const lookup = Promise.withResolvers<void>()
|
||||
const requested: string[] = []
|
||||
const fetched = new Map([missing, unknown].map((sessionID) => [sessionID, Promise.withResolvers<void>()]))
|
||||
const calls = createFetch(async (url) => {
|
||||
const sessionID = url.pathname.match(/^\/api\/session\/(ses_\w+)$/)?.[1]
|
||||
if (!sessionID) return
|
||||
requested.push(sessionID)
|
||||
fetched.get(sessionID)?.resolve()
|
||||
if (sessionID === unknown) return lookup.promise.then(() => json({ data: session(unknown, "Looked up") }))
|
||||
return json(
|
||||
{ _tag: "SessionNotFoundError", sessionID, message: `Session not found: ${sessionID}` },
|
||||
{ status: 404 },
|
||||
)
|
||||
})
|
||||
let data!: ReturnType<typeof useData>
|
||||
let route!: ReturnType<typeof useRoute>
|
||||
|
||||
function Transcript() {
|
||||
data = useData()
|
||||
route = useRoute()
|
||||
data.session.remember({ ...known })
|
||||
data.session.remember({ ...archived })
|
||||
const renderer = useRenderer()
|
||||
const syntaxStyle = SyntaxStyle.fromStyles(
|
||||
{ default: { fg: "#ffffff" }, "markup.link.url": { fg: "#00ff00" } },
|
||||
renderer.nativeScene,
|
||||
)
|
||||
onCleanup(() => syntaxStyle.destroy())
|
||||
return (
|
||||
<markdown
|
||||
ref={useSessionLinks()}
|
||||
internalBlockMode={mode}
|
||||
syntaxStyle={syntaxStyle}
|
||||
content={[
|
||||
`**中文** see ${known.id} now`,
|
||||
"",
|
||||
`- current ${current}`,
|
||||
`- archived ${archived.id}`,
|
||||
`- unknown ${unknown}`,
|
||||
`- missing ${missing}`,
|
||||
"",
|
||||
"```",
|
||||
known.id,
|
||||
"```",
|
||||
].join("\n")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const app = await mount(state.path, calls.fetch, () => <Transcript />, 40, 16)
|
||||
// Highlighting runs on the tree-sitter worker and lookups on the fixture server.
|
||||
const linked = () =>
|
||||
app
|
||||
.captureSpans()
|
||||
.lines.flatMap((line) => line.spans)
|
||||
.filter((span) => span.attributes & TextAttributes.UNDERLINE)
|
||||
.map((span) => span.text.trim())
|
||||
.join(" ")
|
||||
|
||||
try {
|
||||
await Promise.all([...fetched.values()].map((request) => request.promise))
|
||||
// The client marks the session missing once it has read the 404.
|
||||
await app.waitFor(() => data.session.missing(missing))
|
||||
await waitForHighlightedFrame(app, () => linked().length > 0)
|
||||
const frame = app.captureCharFrame()
|
||||
// The long title wraps; the current, archived, missing and still-loading sessions stay IDs.
|
||||
expect(linked()).toBe("@Fix the flaky reconnect test on Windows")
|
||||
for (const plain of [current, archived.id, unknown, missing, known.id]) expect(frame).toContain(plain)
|
||||
expect(requested.toSorted()).toEqual([missing, unknown])
|
||||
|
||||
lookup.resolve()
|
||||
// Joins the lookup already in flight rather than fetching again.
|
||||
await data.session.sync(unknown)
|
||||
await waitForHighlightedFrame(app, () => linked().includes("@Looked up"))
|
||||
data.session.remember({ ...known, title: "Renamed" })
|
||||
await waitForHighlightedFrame(app, () => linked() === "@Renamed @Looked up")
|
||||
// Lookups are cached, misses included.
|
||||
expect(requested.toSorted()).toEqual([missing, unknown])
|
||||
|
||||
const rows = app.captureCharFrame().split("\n")
|
||||
const clicked = rows.findIndex((row) => row.includes("@Looked up"))
|
||||
app.renderer.clearSelection()
|
||||
await app.mockMouse.click(rows[clicked]!.indexOf("@Looked up") + 4, clicked)
|
||||
expect(route.data).toEqual({ type: "session", sessionID: unknown })
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("a wrapped title opens its session from either row, and plain text does not", async () => {
|
||||
await using state = await tmpdir()
|
||||
const calls = createFetch(async () => undefined)
|
||||
let route!: ReturnType<typeof useRoute>
|
||||
|
||||
function Transcript() {
|
||||
useData().session.remember({ ...known })
|
||||
route = useRoute()
|
||||
const renderer = useRenderer()
|
||||
const syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } }, renderer.nativeScene)
|
||||
onCleanup(() => syntaxStyle.destroy())
|
||||
return (
|
||||
<markdown
|
||||
ref={useSessionLinks()}
|
||||
internalBlockMode="top-level"
|
||||
syntaxStyle={syntaxStyle}
|
||||
content={`**中文** see ${known.id} now`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const app = await mount(state.path, calls.fetch, () => <Transcript />, 30, 6)
|
||||
|
||||
try {
|
||||
await waitForHighlightedFrame(app, (frame) => frame.includes("@Fix"))
|
||||
const rows = app.captureCharFrame().split("\n")
|
||||
const head = rows.findIndex((row) => row.includes("@Fix"))
|
||||
const tail = rows.findIndex((row) => row.includes("Windows"))
|
||||
expect(tail).toBeGreaterThan(head)
|
||||
|
||||
await app.mockMouse.click(rows[head]!.indexOf("see"), head)
|
||||
await app.mockMouse.click(rows[tail]!.indexOf("now"), tail)
|
||||
await app.mockMouse.drag(rows[head]!.indexOf("@Fix"), head, rows[head]!.indexOf("@Fix") + 3, head)
|
||||
expect(route.data).toEqual({ type: "session", sessionID: current })
|
||||
|
||||
app.renderer.clearSelection()
|
||||
await app.mockMouse.click(rows[tail]!.indexOf("Windows") + 2, tail)
|
||||
expect(route.data).toEqual({ type: "session", sessionID: known.id })
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("clicking a session link in a dialog closes it and opens the session", async () => {
|
||||
await using state = await tmpdir()
|
||||
const calls = createFetch(async () => undefined)
|
||||
let route!: ReturnType<typeof useRoute>
|
||||
let dialog!: ReturnType<typeof useDialog>
|
||||
|
||||
function Answer() {
|
||||
const renderer = useRenderer()
|
||||
const syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } }, renderer.nativeScene)
|
||||
onCleanup(() => syntaxStyle.destroy())
|
||||
return <markdown ref={useSessionLinks()} syntaxStyle={syntaxStyle} content={`See ${known.id}`} />
|
||||
}
|
||||
function Probe() {
|
||||
useData().session.remember({ ...known })
|
||||
route = useRoute()
|
||||
dialog = useDialog()
|
||||
onMount(() => dialog.replace(() => <Answer />))
|
||||
return null
|
||||
}
|
||||
|
||||
const app = await mount(state.path, calls.fetch, () => <Probe />, 80, 12)
|
||||
|
||||
try {
|
||||
await waitForHighlightedFrame(app, (frame) => frame.includes("@Fix"))
|
||||
const rows = app.captureCharFrame().split("\n")
|
||||
const y = rows.findIndex((row) => row.includes("@Fix"))
|
||||
await app.mockMouse.click(rows[y]!.indexOf("@Fix") + 2, y)
|
||||
expect(route.data).toEqual({ type: "session", sessionID: known.id })
|
||||
expect(dialog.stack).toHaveLength(0)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test.each([true, false])("code spans link only when they are exactly an ID, conceal %p", async (conceal) => {
|
||||
await using state = await tmpdir()
|
||||
const calls = createFetch(async () => undefined)
|
||||
|
||||
function Transcript() {
|
||||
useData().session.remember({ ...known })
|
||||
const renderer = useRenderer()
|
||||
const syntaxStyle = SyntaxStyle.fromStyles({ default: { fg: "#ffffff" } }, renderer.nativeScene)
|
||||
onCleanup(() => syntaxStyle.destroy())
|
||||
return (
|
||||
<markdown
|
||||
ref={useSessionLinks()}
|
||||
internalBlockMode="top-level"
|
||||
conceal={conceal}
|
||||
syntaxStyle={syntaxStyle}
|
||||
content={`Run \`opencode --session ${known.id}\` or open \`${known.id}\`.`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const app = await mount(state.path, calls.fetch, () => <Transcript />, 120, 4)
|
||||
const linked = () =>
|
||||
app
|
||||
.captureSpans()
|
||||
.lines.flatMap((line) => line.spans)
|
||||
.filter((span) => span.attributes & TextAttributes.UNDERLINE)
|
||||
.map((span) => span.text.trim())
|
||||
|
||||
try {
|
||||
await waitForHighlightedFrame(app, () => linked().length > 0)
|
||||
expect(linked()).toEqual([conceal ? `@${known.title}` : known.id])
|
||||
expect(app.captureCharFrame()).toContain(`opencode --session ${known.id}`)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
async function mount(
|
||||
state: string,
|
||||
fetch: typeof globalThis.fetch,
|
||||
view: () => JSX.Element,
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts paths={{ state }}>
|
||||
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
|
||||
<StorageProvider>
|
||||
<ConfigProvider config={createTuiResolvedConfig()}>
|
||||
<Keymap.Provider>
|
||||
<ToastProvider>
|
||||
<RouteProvider initialRoute={{ type: "session", sessionID: current }}>
|
||||
<ClientProvider api={createApi(fetch)}>
|
||||
<DataProvider directory={directory}>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<DialogProvider>{view()}</DialogProvider>
|
||||
</ThemeProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</StorageProvider>
|
||||
</TuiAppProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width, height },
|
||||
)
|
||||
// Frame waits need the render loop; highlights and lookups land between frames.
|
||||
app.renderer.start()
|
||||
return app
|
||||
}
|
||||
Reference in New Issue
Block a user