mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 07:48:24 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00bb8291ea | |||
| 6633529389 |
@@ -722,7 +722,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
},
|
||||
{
|
||||
name: "open.menu",
|
||||
title: "Open session or project",
|
||||
title: "Open session or worktree",
|
||||
category: "Session",
|
||||
slash: { name: "open", aliases: ["projects", "project"] },
|
||||
run: async () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
|
||||
import type { WorktreeListOutput } from "@opencode-ai/client"
|
||||
import { useRoute } from "../context/route"
|
||||
import { DialogWorktreeName } from "./dialog-worktree-name"
|
||||
import { Slug } from "@opencode-ai/core/util/slug"
|
||||
|
||||
export type MoveSessionSelection =
|
||||
| { type: "directory"; directory: string; subdirectory: boolean }
|
||||
@@ -27,6 +28,9 @@ type ProjectDirectory = WorktreeListOutput[number]
|
||||
|
||||
type DialogMoveSessionProps = {
|
||||
projectID: string
|
||||
title?: string
|
||||
compact?: boolean
|
||||
randomWorktree?: boolean
|
||||
current?: MoveSessionSelection
|
||||
onSelect: (selection: MoveSessionSelection) => void
|
||||
onCurrentChange?: (selection: MoveSessionSelection) => void
|
||||
@@ -51,7 +55,8 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
const [removing, setRemoving] = createSignal(props.initialRemoving)
|
||||
const [replacementCurrent, setReplacementCurrent] = createSignal<string>()
|
||||
const [loadError, setLoadError] = createSignal<unknown>()
|
||||
onMount(() => dialog.setSize("xlarge"))
|
||||
const randomWorktree = Slug.create()
|
||||
onMount(() => dialog.setSize(props.compact ? "large" : "xlarge"))
|
||||
|
||||
function reopen(initialRemoving?: string) {
|
||||
dialog.replace(() => (
|
||||
@@ -122,8 +127,6 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
if (!a.strategy && !b.strategy) return a.directory.length - b.directory.length
|
||||
return 0
|
||||
})
|
||||
if (roots.length === 0) return []
|
||||
|
||||
const subdirectories = sessionData.session
|
||||
.list()
|
||||
.filter(
|
||||
@@ -150,7 +153,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
})
|
||||
const titleWidth = Math.max(1, dialogSelectContentWidth(Math.min(dialogWidth("xlarge"), dimensions().width - 2)))
|
||||
|
||||
return list.map((item) => {
|
||||
const options: DialogSelectOption<MoveSessionSelection | undefined>[] = list.map((item) => {
|
||||
const title = abbreviateHome(item.location, paths.home)
|
||||
const suffix =
|
||||
item.location === item.root.directory ? undefined : path.sep + path.relative(item.root.directory, item.location)
|
||||
@@ -183,6 +186,19 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
truncateTitle: "left" as const,
|
||||
}
|
||||
})
|
||||
if (props.randomWorktree) {
|
||||
return [
|
||||
{
|
||||
title: "+ New random worktree",
|
||||
footer: randomWorktree,
|
||||
value: { type: "new", name: randomWorktree },
|
||||
category: "Create",
|
||||
titleWidth,
|
||||
},
|
||||
...options,
|
||||
]
|
||||
}
|
||||
return options
|
||||
})
|
||||
|
||||
const current = createMemo(() => {
|
||||
@@ -300,11 +316,11 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
||||
return (
|
||||
<box minHeight={showError() ? 5 : fullHeight()}>
|
||||
<DialogSelect
|
||||
title="Move session"
|
||||
title={props.title ?? "Move session"}
|
||||
titleView={
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||
Move session
|
||||
{props.title ?? "Move session"}
|
||||
</text>
|
||||
<Show when={working() || directories.loading || loadedProject.loading}>
|
||||
<Spinner />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createMemo, createResource, createSignal } from "solid-js"
|
||||
import type { SessionInfo } from "@opencode-ai/client"
|
||||
import path from "path"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import { dialogWidth, useDialog } from "../ui/dialog"
|
||||
@@ -19,11 +20,17 @@ import { stringWidth } from "../util/string-width"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { Spinner } from "./spinner"
|
||||
import { projectName } from "../util/project"
|
||||
import { DialogMoveSession } from "./dialog-move-session"
|
||||
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { errorMessage } from "../util/error"
|
||||
|
||||
const RECENT_LIMIT = 8
|
||||
const RECENT_LIMIT = 3
|
||||
export const DialogOpenKey = Symbol("DialogOpen")
|
||||
|
||||
type OpenTarget = { type: "session"; sessionID: string } | { type: "project"; directory: string }
|
||||
type OpenTarget =
|
||||
| { type: "session"; sessionID: string }
|
||||
| { type: "location"; directory: string; projectID?: string; vcs?: "git" | "hg" }
|
||||
|
||||
export async function loadDialogOpen(data: ReturnType<typeof useData>, client: ReturnType<typeof useClient>) {
|
||||
const [, sessions] = await Promise.all([
|
||||
@@ -49,8 +56,10 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
|
||||
const paths = useTuiPaths()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const toast = useToast()
|
||||
const [filter, setFilter] = createSignal("")
|
||||
const [selectionMoved, setSelectionMoved] = createSignal(false)
|
||||
const [selected, setSelected] = createSignal<OpenTarget>()
|
||||
|
||||
const [matched] = createResource(
|
||||
() => {
|
||||
@@ -84,14 +93,12 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
|
||||
|
||||
const options = createMemo(() => {
|
||||
const tabs = openTabs()
|
||||
// With an empty query the menu shows what is not already one keystroke away: open tabs are
|
||||
// visible in the strip, so recents exclude them. Typing widens the pool to every session so
|
||||
// matching a loaded tab by name still switches to it.
|
||||
const recent = filter().trim()
|
||||
? sessions()
|
||||
: sessions()
|
||||
.filter((session) => !tabs.has(session.id))
|
||||
.slice(0, RECENT_LIMIT)
|
||||
const exact = matched()
|
||||
const recent = sessions()
|
||||
.filter((session) => !tabs.has(session.id))
|
||||
.slice(0, RECENT_LIMIT)
|
||||
.concat(exact && !tabs.has(exact.id) ? [exact] : [])
|
||||
.filter((session, index, items) => items.findIndex((item) => item.id === session.id) === index)
|
||||
const sessionOptions = recent.map((session) => {
|
||||
const project = data.project.get(session.projectID)
|
||||
const name = projectName(project)
|
||||
@@ -102,7 +109,7 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
|
||||
title: withTimestampedFallback(session),
|
||||
searchText: session.id,
|
||||
value: { type: "session", sessionID: session.id } as OpenTarget,
|
||||
category: "Sessions",
|
||||
category: "Recent sessions",
|
||||
footer: `${name ? `${Locale.truncate(name, 20)} · ` : ""}${timeAgo(session.time.updated)}`,
|
||||
onSelect: () => location.set(session.location),
|
||||
gutter: running
|
||||
@@ -113,47 +120,171 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
|
||||
}
|
||||
})
|
||||
|
||||
const current = location.current?.project
|
||||
const seen = new Set<string>()
|
||||
const projectOptions = data.project
|
||||
.list()
|
||||
.filter((project) => {
|
||||
if (project.canonical === "/" || seen.has(project.canonical)) return false
|
||||
seen.add(project.canonical)
|
||||
return true
|
||||
const current = location.current
|
||||
const locations = new Map<
|
||||
string,
|
||||
{
|
||||
directory: string
|
||||
title: string
|
||||
updated: number
|
||||
category: "Recent worktrees" | "Recent folders"
|
||||
projectID?: string
|
||||
vcs?: "git" | "hg"
|
||||
}
|
||||
>()
|
||||
for (const project of data.project.list()) {
|
||||
if (project.canonical === "/" || isDisposableLocation(project.canonical) || locations.has(project.canonical))
|
||||
continue
|
||||
locations.set(project.canonical, {
|
||||
directory: project.canonical,
|
||||
title: projectName(project) ?? project.canonical,
|
||||
updated: project.time.updated,
|
||||
category: "Recent worktrees",
|
||||
projectID: project.id,
|
||||
vcs: project.vcs,
|
||||
})
|
||||
.map((project) => {
|
||||
const title = projectName(project) ?? project.canonical
|
||||
const footer = abbreviateHome(project.canonical, paths.home)
|
||||
}
|
||||
for (const session of sessions()) {
|
||||
const project = data.project.get(session.projectID)
|
||||
const managedProject = project && project.canonical !== "/" ? project : undefined
|
||||
const worktree = managedProject && !session.subpath
|
||||
const directory = worktree ? session.location.directory : (managedProject?.canonical ?? session.location.directory)
|
||||
if (isDisposableLocation(directory)) continue
|
||||
const existing = locations.get(directory)
|
||||
if (existing) {
|
||||
existing.updated = Math.max(existing.updated, session.time.updated)
|
||||
continue
|
||||
}
|
||||
locations.set(directory, {
|
||||
directory,
|
||||
title:
|
||||
worktree && directory !== managedProject.canonical
|
||||
? [projectName(managedProject), path.basename(directory)].filter(Boolean).join(" · ")
|
||||
: (projectName(project) ?? (path.basename(directory) || directory)),
|
||||
updated: session.time.updated,
|
||||
category: managedProject ? "Recent worktrees" : "Recent folders",
|
||||
projectID: managedProject?.id,
|
||||
vcs: managedProject?.vcs,
|
||||
})
|
||||
}
|
||||
const locationOptions = [...locations.values()]
|
||||
.toSorted((a, b) => b.updated - a.updated)
|
||||
.map((item) => {
|
||||
const footer = abbreviateHome(item.directory, paths.home)
|
||||
const width =
|
||||
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(title)
|
||||
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(item.title)
|
||||
return {
|
||||
title,
|
||||
title: item.title,
|
||||
footer: truncateFilePath(footer, width),
|
||||
searchText: footer,
|
||||
value: { type: "project", directory: project.canonical } as OpenTarget,
|
||||
category: "Projects",
|
||||
value: {
|
||||
type: "location",
|
||||
directory: item.directory,
|
||||
projectID: item.projectID,
|
||||
vcs: item.vcs,
|
||||
} as OpenTarget,
|
||||
category: item.category,
|
||||
gutter:
|
||||
project.canonical === current?.canonical
|
||||
item.directory === current?.directory || item.directory === current?.project.canonical
|
||||
? () => <text fg={theme.text.formfield.selected}>●</text>
|
||||
: undefined,
|
||||
}
|
||||
})
|
||||
|
||||
return [...sessionOptions, ...projectOptions]
|
||||
return [
|
||||
...sessionOptions,
|
||||
...locationOptions.filter((item) => item.category === "Recent worktrees"),
|
||||
...locationOptions.filter((item) => item.category === "Recent folders"),
|
||||
]
|
||||
})
|
||||
|
||||
function openLocation(directory: string) {
|
||||
dialog.clear()
|
||||
const target = { directory }
|
||||
route.navigate({ type: "home", location: target })
|
||||
location.set(target)
|
||||
}
|
||||
|
||||
function openWorktrees(target: OpenTarget | undefined) {
|
||||
if (target?.type !== "location" || !target.projectID || target.vcs !== "git") return
|
||||
const projectID = target.projectID
|
||||
dialog.replace(() => (
|
||||
<DialogMoveSession
|
||||
projectID={projectID}
|
||||
title="Open worktree"
|
||||
compact={true}
|
||||
randomWorktree={true}
|
||||
onSelect={(selection) => {
|
||||
if (selection.type === "directory") {
|
||||
openLocation(selection.directory)
|
||||
return
|
||||
}
|
||||
void client.api.worktree
|
||||
.create({
|
||||
projectID,
|
||||
strategy: "git",
|
||||
directory: path.join(paths.worktree, projectID.slice(0, 6)),
|
||||
name: selection.name,
|
||||
})
|
||||
.then((result) => openLocation(result.directory))
|
||||
.catch((error) =>
|
||||
toast.show({ variant: "error", title: "Creating worktree failed", message: errorMessage(error) }),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
function browse() {
|
||||
dialog.replace(() => (
|
||||
<DialogPrompt
|
||||
title="Open folder"
|
||||
placeholder="Absolute path"
|
||||
value={location.current?.directory ?? paths.home}
|
||||
onConfirm={(value) => {
|
||||
const directory = value.trim().replace(/^~(?=$|[\\/])/, paths.home)
|
||||
if (!directory) return
|
||||
void client.api.file
|
||||
.list({ location: { directory } })
|
||||
.then(() => openLocation(directory))
|
||||
.catch((error) =>
|
||||
toast.show({ variant: "error", title: "Could not open folder", message: errorMessage(error) }),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Open"
|
||||
placeholder="Search sessions and projects…"
|
||||
placeholder="Search sessions and worktrees…"
|
||||
options={options()}
|
||||
current={currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined}
|
||||
focusCurrent={false}
|
||||
sectionNavigation={true}
|
||||
preserveSelection={selectionMoved()}
|
||||
onMove={() => setSelectionMoved(true)}
|
||||
onMove={(option) => {
|
||||
setSelectionMoved(true)
|
||||
setSelected(option.value)
|
||||
}}
|
||||
onFilter={setFilter}
|
||||
footer={
|
||||
<text fg={theme.text.default}>
|
||||
enter <span style={{ fg: theme.text.subdued }}>open</span>
|
||||
{" "}→ <span style={{ fg: theme.text.subdued }}>worktrees</span>
|
||||
{" "}/ <span style={{ fg: theme.text.subdued }}>browse</span>
|
||||
</text>
|
||||
}
|
||||
bindings={[
|
||||
{
|
||||
bind: "right",
|
||||
title: "Open worktrees",
|
||||
group: "Dialog",
|
||||
run: () => openWorktrees(selected() ?? options()[0]?.value),
|
||||
},
|
||||
{ bind: "/", title: "Browse folders", group: "Dialog", run: browse },
|
||||
]}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={theme.text.subdued}>
|
||||
@@ -169,9 +300,7 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
|
||||
route.navigate({ type: "session", sessionID: option.value.sessionID })
|
||||
return
|
||||
}
|
||||
const target = { directory: option.value.directory }
|
||||
route.navigate({ type: "home", location: target })
|
||||
location.set(target)
|
||||
openLocation(option.value.directory)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
@@ -189,3 +318,7 @@ function timeAgo(timestamp: number) {
|
||||
if (months < 12) return `${months}mo`
|
||||
return `${Math.floor(days / 365)}y`
|
||||
}
|
||||
|
||||
function isDisposableLocation(directory: string) {
|
||||
return /^opencode-(?:test|e2e-project)-/.test(path.basename(directory))
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ export const Definitions = {
|
||||
"session.move": keybind("none", "Move session"),
|
||||
"session.new": keybind("<leader>n", "Create a new session"),
|
||||
"session.list": keybind("<leader>l", "List all sessions"),
|
||||
"open.menu": keybind("ctrl+o", "Open recent sessions and projects"),
|
||||
"open.menu": keybind("ctrl+o", "Open recent sessions and worktrees"),
|
||||
"session.tab.next": keybind("ctrl+tab,alt+down", "Switch to next open session tab"),
|
||||
"session.tab.previous": keybind("ctrl+shift+tab,alt+up", "Switch to previous open session tab"),
|
||||
"session.tab.history.back": keybind("none", "Go back in session tab history"),
|
||||
|
||||
@@ -75,7 +75,7 @@ test("finds and opens an exact session ID outside the recent list", async () =>
|
||||
})
|
||||
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Search sessions and projects"))
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Search sessions and worktrees"))
|
||||
await fixture.app.mockInput.typeText(sessionID)
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("TUI plugin slot API v2"))
|
||||
|
||||
@@ -158,7 +158,7 @@ test("waits for sessions before showing the populated picker", async () => {
|
||||
|
||||
try {
|
||||
await fixture.app.renderOnce()
|
||||
expect(fixture.app.captureCharFrame()).not.toContain("Search sessions and projects")
|
||||
expect(fixture.app.captureCharFrame()).not.toContain("Search sessions and worktrees")
|
||||
|
||||
resolveSessions(
|
||||
json({
|
||||
@@ -276,6 +276,148 @@ test("option arrows stay in the only visible section", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("search keeps sessions limited to recents", async () => {
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname === "/api/project") return json([])
|
||||
if (url.pathname !== "/api/session") return undefined
|
||||
return json({
|
||||
data: Array.from({ length: 9 }, (_, index) => ({
|
||||
id: `ses_${index}`,
|
||||
projectID: `proj_${index}`,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 10 - index, updated: 10 - index },
|
||||
title: index === 8 ? "Ancient hidden session" : `Recent session ${index}`,
|
||||
location: { directory: `/tmp/location-${index}` },
|
||||
})),
|
||||
cursor: {},
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Recent sessions"))
|
||||
await fixture.app.mockInput.typeText("Ancient hidden session")
|
||||
await fixture.app.waitForFrame(
|
||||
(frame) => frame.includes("No matches") && frame.split("Ancient hidden session").length === 2,
|
||||
)
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("opens worktrees with right and creates a random worktree", async () => {
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
|
||||
if (url.pathname !== "/api/project") return undefined
|
||||
return json([
|
||||
{
|
||||
id: "proj_test",
|
||||
canonical: "/tmp/opencode",
|
||||
vcs: "git",
|
||||
name: "OpenCode",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("OpenCode") && frame.includes("Recent worktrees"))
|
||||
fixture.app.mockInput.pressArrow("right")
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Open worktree") && frame.includes("New random worktree"))
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "home")
|
||||
expect(fixture.route.data).toEqual({ type: "home", location: { directory: "/tmp/opencode/created" } })
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("opens the folder prompt with slash", async () => {
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
|
||||
if (url.pathname === "/api/project") return json([])
|
||||
return undefined
|
||||
})
|
||||
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Search sessions and worktrees"))
|
||||
await fixture.app.mockInput.typeText("/")
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Open folder") && frame.includes("/tmp/opencode/home"))
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("hides disposable test projects from recent locations", async () => {
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
|
||||
if (url.pathname !== "/api/project") return undefined
|
||||
return json([
|
||||
{
|
||||
id: "proj_real",
|
||||
canonical: "/workspace/opencode",
|
||||
name: "OpenCode",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
{
|
||||
id: "proj_test",
|
||||
canonical: "/tmp/opencode-e2e-project-BX8Aug",
|
||||
time: { created: 1, updated: 3 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
try {
|
||||
const frame = await fixture.app.waitForFrame((value) => value.includes("OpenCode"))
|
||||
expect(frame).not.toContain("opencode-e2e-project-BX8Aug")
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("shows a session checkout as a recent worktree", async () => {
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname === "/api/session")
|
||||
return json({
|
||||
data: [
|
||||
{
|
||||
id: "ses_worktree",
|
||||
projectID: "proj_opencode",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 2, updated: 3 },
|
||||
title: "Refine Open screen",
|
||||
location: { directory: "/workspace/worktrees/open-screen" },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
})
|
||||
if (url.pathname !== "/api/project") return undefined
|
||||
return json([
|
||||
{
|
||||
id: "proj_opencode",
|
||||
canonical: "/workspace/opencode",
|
||||
vcs: "git",
|
||||
name: "OpenCode",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
try {
|
||||
const frame = await fixture.app.waitForFrame(
|
||||
(value) => value.includes("Recent worktrees") && value.includes("OpenCode · open-screen"),
|
||||
)
|
||||
expect(frame).toContain("/workspace/worktrees/open-screen")
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
async function renderOpen(
|
||||
handler: FetchHandler,
|
||||
beforeOpen?: (contexts: {
|
||||
|
||||
Reference in New Issue
Block a user