mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-24 17:41:37 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b92bbdeb57 | |||
| 2385d48ae9 | |||
| d9c1eaac2c |
@@ -1,10 +1,11 @@
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { Locale } from "../util/locale"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { usePromptStash, type StashEntry } from "../prompt/stash"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
function getRelativeTime(timestamp: number): string {
|
||||
const now = Date.now()
|
||||
@@ -31,15 +32,20 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
|
||||
const stash = usePromptStash()
|
||||
const theme = useTheme("elevated")
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const toast = useToast()
|
||||
|
||||
const [toDelete, setToDelete] = createSignal<number>()
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
let disposed = false
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
const entries = stash.list()
|
||||
// Show most recent first
|
||||
return entries
|
||||
.map((entry, index) => {
|
||||
const isDeleting = toDelete() === index
|
||||
.map((entry) => {
|
||||
const isDeleting = toDelete() === entry.id
|
||||
const lineCount = (entry.prompt.text.match(/\n/g)?.length ?? 0) + 1
|
||||
return {
|
||||
title: isDeleting
|
||||
@@ -47,7 +53,7 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
|
||||
: getStashPreview(entry.prompt.text),
|
||||
bg: isDeleting ? theme.background.action.destructive.focused : undefined,
|
||||
fg: isDeleting ? theme.text.action.destructive.focused : undefined,
|
||||
value: index,
|
||||
value: entry.id,
|
||||
description: getRelativeTime(entry.timestamp),
|
||||
footer: lineCount > 1 ? `~${lineCount} lines` : undefined,
|
||||
}
|
||||
@@ -59,25 +65,29 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
|
||||
<DialogSelect
|
||||
title="Stash"
|
||||
options={options()}
|
||||
preserveSelection
|
||||
onMove={() => {
|
||||
setToDelete(undefined)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
const entries = stash.list()
|
||||
const entry = entries[option.value]
|
||||
if (entry) {
|
||||
stash.remove(option.value)
|
||||
props.onSelect(entry)
|
||||
}
|
||||
dialog.clear()
|
||||
onSelect={async (option) => {
|
||||
if (stash.pending()) return
|
||||
await stash.remove(option.value).then(
|
||||
(entry) => {
|
||||
// A consumed entry still belongs to the caller after this dialog closes.
|
||||
if (entry) props.onSelect(entry)
|
||||
if (!disposed) dialog.clear()
|
||||
},
|
||||
(error) => toast.error(error),
|
||||
)
|
||||
}}
|
||||
actions={[
|
||||
{
|
||||
command: "stash.delete",
|
||||
title: "delete",
|
||||
onTrigger: (option) => {
|
||||
onTrigger: async (option) => {
|
||||
if (stash.pending()) return
|
||||
if (toDelete() === option.value) {
|
||||
stash.remove(option.value)
|
||||
await stash.remove(option.value).catch((error) => toast.error(error))
|
||||
setToDelete(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ import { saveDraft, takeDraft } from "./draft-stash"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { computePromptTraits } from "../../prompt/traits"
|
||||
import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part"
|
||||
import { usePromptStash } from "../../prompt/stash"
|
||||
import { usePromptStash, type StashEntry } from "../../prompt/stash"
|
||||
import { DialogStash } from "../dialog-stash"
|
||||
import { type AutocompleteOption, type AutocompleteRef, Autocomplete } from "./autocomplete"
|
||||
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
@@ -209,6 +209,9 @@ export function Prompt(props: PromptProps) {
|
||||
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
|
||||
const history = usePromptHistory()
|
||||
const stash = usePromptStash()
|
||||
// A pop/list selection can wait on another terminal's write. Keep the
|
||||
// composer read-only until restoration so it cannot overwrite newer input.
|
||||
const disabled = () => props.disabled || stash.pending()
|
||||
const keymap = Keymap.use()
|
||||
const renderer = useRenderer()
|
||||
const exit = useExit()
|
||||
@@ -348,8 +351,8 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
createEffect(() => {
|
||||
if (!input || input.isDestroyed) return
|
||||
if (props.disabled) input.cursorColor = theme.background.surface.offset
|
||||
if (!props.disabled) input.cursorColor = theme.text.default
|
||||
if (disabled()) input.cursorColor = theme.background.surface.offset
|
||||
if (!disabled()) input.cursorColor = theme.text.default
|
||||
if (config.cursor) input.cursorStyle = config.cursor
|
||||
})
|
||||
|
||||
@@ -372,12 +375,13 @@ export function Prompt(props: PromptProps) {
|
||||
function enqueuePaste(run: (changed: () => boolean) => Promise<void>) {
|
||||
pasteQueue = pasteQueue
|
||||
.then(async () => {
|
||||
if (disposed || input.isDestroyed) return
|
||||
if (disposed || input.isDestroyed || disabled()) return
|
||||
const before = { sessionID: props.sessionID, mode: store.mode, text: input.plainText }
|
||||
await run(
|
||||
() =>
|
||||
disposed ||
|
||||
input.isDestroyed ||
|
||||
disabled() ||
|
||||
props.sessionID !== before.sessionID ||
|
||||
store.mode !== before.mode ||
|
||||
input.plainText !== before.text,
|
||||
@@ -642,6 +646,9 @@ export function Prompt(props: PromptProps) {
|
||||
bind: false,
|
||||
palette: true as const,
|
||||
...command,
|
||||
enabled:
|
||||
(!stash.pending() || name.startsWith("session.") || name === "prompt.images.view") &&
|
||||
(!("enabled" in command) || command.enabled),
|
||||
}) satisfies KeymapCommand,
|
||||
),
|
||||
)
|
||||
@@ -733,7 +740,7 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
createEffect(() => {
|
||||
if (!input || input.isDestroyed) return
|
||||
if (props.visible === false || props.disabled || dialog.stack.length > 0) {
|
||||
if (props.visible === false || disabled() || dialog.stack.length > 0) {
|
||||
if (input.focused) input.blur()
|
||||
return
|
||||
}
|
||||
@@ -871,52 +878,56 @@ export function Prompt(props: PromptProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function restoreStash(entry: StashEntry) {
|
||||
if (disposed) {
|
||||
saveDraft(stashSessionID, { prompt: entry.prompt, cursor: stringWidth(entry.prompt.text) })
|
||||
return
|
||||
}
|
||||
ref.set(entry.prompt)
|
||||
}
|
||||
|
||||
const stashCommands = createMemo(() =>
|
||||
[
|
||||
{
|
||||
title: "Stash prompt",
|
||||
name: "prompt.stash",
|
||||
category: "Prompt",
|
||||
enabled: !!store.prompt.text,
|
||||
run: () => {
|
||||
if (!store.prompt.text) return
|
||||
stash.push({ prompt: store.prompt })
|
||||
resetComposer()
|
||||
enabled: !!store.prompt.text && !stash.pending(),
|
||||
run: async () => {
|
||||
if (!store.prompt.text || stash.pending()) return
|
||||
const before = JSON.stringify(store.prompt)
|
||||
dialog.clear()
|
||||
await stash.push({ prompt: store.prompt }).then(
|
||||
() => {
|
||||
if (!disposed && JSON.stringify(store.prompt) === before) resetComposer()
|
||||
},
|
||||
(error) => toast.error(error),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Stash pop",
|
||||
name: "prompt.stash.pop",
|
||||
category: "Prompt",
|
||||
enabled: stash.list().length > 0,
|
||||
run: () => {
|
||||
const entry = stash.pop()
|
||||
if (entry) {
|
||||
input.setText(entry.prompt.text)
|
||||
setStore("prompt", entry.prompt)
|
||||
restoreExtmarksFromPrompt(entry.prompt)
|
||||
input.gotoBufferEnd()
|
||||
}
|
||||
enabled: stash.list().length > 0 && !stash.pending(),
|
||||
run: async () => {
|
||||
if (stash.pending()) return
|
||||
dialog.clear()
|
||||
await stash.pop().then(
|
||||
(entry) => {
|
||||
if (entry) restoreStash(entry)
|
||||
},
|
||||
(error) => toast.error(error),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Stash list",
|
||||
name: "prompt.stash.list",
|
||||
category: "Prompt",
|
||||
enabled: stash.list().length > 0,
|
||||
enabled: stash.list().length > 0 && !stash.pending(),
|
||||
run: () => {
|
||||
dialog.replace(() => (
|
||||
<DialogStash
|
||||
onSelect={(entry) => {
|
||||
input.setText(entry.prompt.text)
|
||||
setStore("prompt", entry.prompt)
|
||||
restoreExtmarksFromPrompt(entry.prompt)
|
||||
input.gotoBufferEnd()
|
||||
}}
|
||||
/>
|
||||
))
|
||||
dialog.replace(() => <DialogStash onSelect={restoreStash} />)
|
||||
},
|
||||
},
|
||||
].map(
|
||||
@@ -939,7 +950,7 @@ export function Prompt(props: PromptProps) {
|
||||
Keymap.createLayer(() => {
|
||||
return {
|
||||
target: inputTarget,
|
||||
enabled: inputTarget() !== undefined && !props.disabled,
|
||||
enabled: inputTarget() !== undefined && !disabled(),
|
||||
bindings: ["prompt.paste"],
|
||||
}
|
||||
})
|
||||
@@ -947,7 +958,7 @@ export function Prompt(props: PromptProps) {
|
||||
Keymap.createLayer(() => {
|
||||
return {
|
||||
target: inputTarget,
|
||||
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.text !== "",
|
||||
enabled: inputTarget() !== undefined && !disabled() && store.prompt.text !== "",
|
||||
bindings: ["prompt.clear"],
|
||||
}
|
||||
})
|
||||
@@ -959,7 +970,7 @@ export function Prompt(props: PromptProps) {
|
||||
cursorVersion()
|
||||
return (
|
||||
inputTarget() !== undefined &&
|
||||
!props.disabled &&
|
||||
!disabled() &&
|
||||
store.mode === "normal" &&
|
||||
!auto()?.visible &&
|
||||
input?.visualCursor.offset === 0
|
||||
@@ -1016,7 +1027,7 @@ export function Prompt(props: PromptProps) {
|
||||
target: inputTarget,
|
||||
enabled: (() => {
|
||||
cursorVersion()
|
||||
return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined
|
||||
return inputTarget() !== undefined && !disabled() && !auto()?.visible && input !== undefined
|
||||
})(),
|
||||
commands: [
|
||||
{
|
||||
@@ -1052,7 +1063,7 @@ export function Prompt(props: PromptProps) {
|
||||
target: inputTarget,
|
||||
enabled: (() => {
|
||||
cursorVersion()
|
||||
return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined
|
||||
return inputTarget() !== undefined && !disabled() && !auto()?.visible && input !== undefined
|
||||
})(),
|
||||
commands: [
|
||||
{
|
||||
@@ -1110,7 +1121,7 @@ export function Prompt(props: PromptProps) {
|
||||
setStore("prompt", "text", input.plainText)
|
||||
syncExtmarksWithPromptParts()
|
||||
}
|
||||
if (props.disabled) return false
|
||||
if (disabled()) return false
|
||||
if (move.creating()) return false
|
||||
if (auto()?.visible) return false
|
||||
const trimmed = store.prompt.text.trim()
|
||||
@@ -1769,7 +1780,7 @@ export function Prompt(props: PromptProps) {
|
||||
}}
|
||||
onCursorChange={() => setCursorVersion((value) => value + 1)}
|
||||
onKeyDown={(e: { preventDefault(): void }) => {
|
||||
if (props.disabled) {
|
||||
if (disabled()) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
@@ -1780,7 +1791,7 @@ export function Prompt(props: PromptProps) {
|
||||
setTimeout(() => setTimeout(() => submit(), 0), 0)
|
||||
}}
|
||||
onPaste={(event: PasteEvent) => {
|
||||
if (props.disabled) {
|
||||
if (disabled()) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
@@ -1821,7 +1832,7 @@ export function Prompt(props: PromptProps) {
|
||||
}, 0)
|
||||
}}
|
||||
onMouseDown={(r: MouseEvent) => {
|
||||
if (props.disabled || r.button !== 0) return
|
||||
if (disabled() || r.button !== 0) return
|
||||
r.target?.focus()
|
||||
const extmark = input.extmarks
|
||||
.getAtOffset(input.cursorOffset)
|
||||
@@ -1831,7 +1842,7 @@ export function Prompt(props: PromptProps) {
|
||||
r.stopPropagation()
|
||||
}}
|
||||
focusedBackgroundColor="transparent"
|
||||
cursorColor={props.disabled ? theme.background.surface.offset : theme.text.default}
|
||||
cursorColor={disabled() ? theme.background.surface.offset : theme.text.default}
|
||||
syntaxStyle={syntax()}
|
||||
/>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
|
||||
|
||||
@@ -8,6 +8,8 @@ import { useTuiApp, useTuiPaths } from "./runtime"
|
||||
|
||||
type Options<Value extends object> = {
|
||||
readonly initial: Value
|
||||
/** Global stores are shared across release channels. Defaults to "channel". */
|
||||
readonly scope?: "global" | "channel"
|
||||
/** Reconcile key for arrays inside the stored value, preserving item identity across updates. Defaults to "id". */
|
||||
readonly key?: string
|
||||
}
|
||||
@@ -48,16 +50,32 @@ function createStorage(root: string, channel: string) {
|
||||
const entries = new Map<string, { readonly value: Entry<object>; readonly reload: () => void }>()
|
||||
const memories = new Map<string, MemoryEntry<object>>()
|
||||
const pending = new Set<Promise<void>>()
|
||||
const directory = path.join(root, segment(channel), "tui")
|
||||
const locks = path.join(root, segment(channel), "locks")
|
||||
mkdirSync(directory, { recursive: true })
|
||||
const location = path.join(root, segment(channel))
|
||||
mkdirSync(path.join(location, "tui"), { recursive: true })
|
||||
const watchers = new Map<string, ReturnType<typeof watch>>()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const storage: Storage = {
|
||||
store<Value extends object>(key: string, options: Options<Value>) {
|
||||
const base = options.scope === "global" ? root : location
|
||||
const directory = path.join(base, "tui")
|
||||
const file = path.join(directory, segment(key) + ".json")
|
||||
const existing = entries.get(file)
|
||||
if (existing) return existing.value as Entry<Value>
|
||||
|
||||
if (!watchers.has(directory)) {
|
||||
mkdirSync(directory, { recursive: true })
|
||||
watchers.set(
|
||||
directory,
|
||||
watch(directory, () => {
|
||||
clearTimeout(timer)
|
||||
// Atomic writes notify for the temporary file before its final rename, and some
|
||||
// platforms coalesce the rename event. Reload after the event burst has settled.
|
||||
timer = setTimeout(() => entries.forEach((entry) => entry.reload()), 50)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const load = () => {
|
||||
try {
|
||||
return clone(JSON.parse(readFileSync(file, "utf8")) as Value)
|
||||
@@ -78,7 +96,7 @@ function createStorage(root: string, channel: string) {
|
||||
await writeJsonAtomic(file, next)
|
||||
batch(() => setStore(merge(next)))
|
||||
},
|
||||
{ dir: locks },
|
||||
{ dir: path.join(base, "locks") },
|
||||
)
|
||||
pending.add(operation)
|
||||
operation.then(
|
||||
@@ -110,18 +128,11 @@ function createStorage(root: string, channel: string) {
|
||||
},
|
||||
}
|
||||
|
||||
let reload: ReturnType<typeof setTimeout> | undefined
|
||||
const watcher = watch(directory, () => {
|
||||
clearTimeout(reload)
|
||||
// Atomic writes notify for the temporary file before its final rename, and some
|
||||
// platforms coalesce the rename event. Reload after the event burst has settled.
|
||||
reload = setTimeout(() => entries.forEach((entry) => entry.reload()), 50)
|
||||
})
|
||||
return {
|
||||
storage,
|
||||
close: () => {
|
||||
clearTimeout(reload)
|
||||
watcher.close()
|
||||
clearTimeout(timer)
|
||||
watchers.forEach((watcher) => watcher.close())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import path from "path"
|
||||
import { onMount } from "solid-js"
|
||||
import { createStore, produce, unwrap } from "solid-js/store"
|
||||
import { readFileSync } from "fs"
|
||||
import { createSignal } from "solid-js"
|
||||
import { unwrap } from "solid-js/store"
|
||||
import { createSimpleContext } from "../context/helper"
|
||||
import { useTuiPaths } from "../context/runtime"
|
||||
import { appendText, readText, writeText } from "../util/persistence"
|
||||
import { useStorage } from "../context/storage"
|
||||
import { parsePromptInfo, type PromptInfo } from "./history"
|
||||
|
||||
export type StashEntry = {
|
||||
id: string
|
||||
prompt: PromptInfo
|
||||
timestamp: number
|
||||
}
|
||||
@@ -29,7 +31,7 @@ export function parsePromptStash(text: string) {
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
.filter((line): line is StashEntry => line !== undefined)
|
||||
.filter((line): line is Omit<StashEntry, "id"> => line !== undefined)
|
||||
.slice(-MAX_STASH_ENTRIES)
|
||||
}
|
||||
|
||||
@@ -37,56 +39,60 @@ export const { use: usePromptStash, provider: PromptStashProvider } = createSimp
|
||||
name: "PromptStash",
|
||||
init: () => {
|
||||
const paths = useTuiPaths()
|
||||
const stashPath = path.join(paths.state, "prompt-stash.jsonl")
|
||||
onMount(async () => {
|
||||
const lines = parsePromptStash(await readText(stashPath).catch(() => ""))
|
||||
setStore("entries", lines)
|
||||
if (lines.length > 0)
|
||||
writeText(stashPath, lines.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {})
|
||||
const storage = useStorage()
|
||||
const [store, update] = storage.store("prompt-stash", {
|
||||
scope: "global",
|
||||
initial: { migrated: false, entries: [] as StashEntry[] },
|
||||
})
|
||||
// Import under the same lock as mutations. The marker survives an empty stash,
|
||||
// so another startup cannot restore prompts that were already consumed.
|
||||
const ready = store.migrated
|
||||
? Promise.resolve()
|
||||
: update((draft) => {
|
||||
if (draft.migrated) return
|
||||
draft.migrated = true
|
||||
try {
|
||||
const text = readFileSync(path.join(paths.state, "prompt-stash.jsonl"), "utf8")
|
||||
draft.entries = parsePromptStash(text).map((entry) => ({ ...entry, id: crypto.randomUUID() }))
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") return
|
||||
throw error
|
||||
}
|
||||
})
|
||||
void ready.catch((error) => console.error("Failed to load prompt stash", error))
|
||||
|
||||
const [store, setStore] = createStore({ entries: [] as StashEntry[] })
|
||||
const [pending, setPending] = createSignal(0)
|
||||
function mutate(mutation: Parameters<typeof update>[0]) {
|
||||
setPending((count) => count + 1)
|
||||
return ready.then(() => update(mutation)).finally(() => setPending((count) => count - 1))
|
||||
}
|
||||
|
||||
return {
|
||||
pending: () => pending() > 0,
|
||||
list() {
|
||||
return store.entries
|
||||
},
|
||||
push(entry: Omit<StashEntry, "timestamp">) {
|
||||
const stash = structuredClone(unwrap({ ...entry, timestamp: Date.now() }))
|
||||
let trimmed = false
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.entries.push(stash)
|
||||
if (draft.entries.length > MAX_STASH_ENTRIES) {
|
||||
draft.entries = draft.entries.slice(-MAX_STASH_ENTRIES)
|
||||
trimmed = true
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
if (trimmed) {
|
||||
writeText(stashPath, store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n").catch(() => {})
|
||||
return
|
||||
}
|
||||
appendText(stashPath, JSON.stringify(stash) + "\n").catch(() => {})
|
||||
async push(entry: Pick<StashEntry, "prompt">) {
|
||||
const stash = structuredClone(unwrap({ ...entry, id: crypto.randomUUID(), timestamp: Date.now() }))
|
||||
await mutate((draft) => {
|
||||
draft.entries.push(stash)
|
||||
draft.entries = draft.entries.slice(-MAX_STASH_ENTRIES)
|
||||
})
|
||||
},
|
||||
pop() {
|
||||
if (store.entries.length === 0) return undefined
|
||||
const entry = store.entries[store.entries.length - 1]
|
||||
setStore(produce((draft) => void draft.entries.pop()))
|
||||
writeText(
|
||||
stashPath,
|
||||
store.entries.length > 0 ? store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n" : "",
|
||||
).catch(() => {})
|
||||
async pop() {
|
||||
let entry: StashEntry | undefined
|
||||
await mutate((draft) => {
|
||||
entry = draft.entries.pop()
|
||||
})
|
||||
return entry
|
||||
},
|
||||
remove(index: number) {
|
||||
if (index < 0 || index >= store.entries.length) return
|
||||
setStore(produce((draft) => void draft.entries.splice(index, 1)))
|
||||
writeText(
|
||||
stashPath,
|
||||
store.entries.length > 0 ? store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n" : "",
|
||||
).catch(() => {})
|
||||
async remove(id: string) {
|
||||
let entry: StashEntry | undefined
|
||||
await mutate((draft) => {
|
||||
const index = draft.entries.findIndex((entry) => entry.id === id)
|
||||
if (index !== -1) entry = draft.entries.splice(index, 1)[0]
|
||||
})
|
||||
return entry
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { Flock } from "@opencode-ai/util/flock"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
import { DialogStash } from "../../src/component/dialog-stash"
|
||||
import { ConfigProvider } from "../../src/config"
|
||||
import { Keymap } from "../../src/context/keymap"
|
||||
import { TuiAppProvider } from "../../src/context/runtime"
|
||||
import { StorageProvider, useStorage } from "../../src/context/storage"
|
||||
import { ThemeProvider } from "../../src/context/theme"
|
||||
import { PromptStashProvider, usePromptStash, type StashEntry } from "../../src/prompt/stash"
|
||||
import { emptyPrompt } from "../../src/prompt/history"
|
||||
import { DialogProvider, useDialog } from "../../src/ui/dialog"
|
||||
import { DialogSelect } from "../../src/ui/dialog-select"
|
||||
import { ToastProvider } from "../../src/ui/toast"
|
||||
import { emptyThemeSource, tmpdir } from "../fixture/fixture"
|
||||
import { TestTuiContexts } from "../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
|
||||
|
||||
const prompt = (text: string) => ({ ...emptyPrompt(), text })
|
||||
|
||||
test("a dismissed stash restores its consumed entry without closing the replacement dialog", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
using fixture = await renderStash(tmp.path)
|
||||
await fixture.stashes[0].push({ prompt: prompt("saved prompt") })
|
||||
fixture.open()
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("saved prompt"))
|
||||
|
||||
const lock = await Flock.acquire(path.join(tmp.path, "tui", "prompt-stash.json"), {
|
||||
dir: path.join(tmp.path, "locks"),
|
||||
})
|
||||
try {
|
||||
fixture.app.mockInput.pressEnter()
|
||||
fixture.app.mockInput.pressEscape()
|
||||
expect(fixture.dialogs[0].stack).toHaveLength(0)
|
||||
fixture.dialogs[0].replace(() => (
|
||||
<DialogSelect title="Replacement dialog" options={[{ title: "Other choice", value: "other" }]} />
|
||||
))
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Replacement dialog"))
|
||||
expect(fixture.restored).toHaveLength(0)
|
||||
} finally {
|
||||
await lock.release()
|
||||
}
|
||||
|
||||
await fixture.app.waitFor(() => fixture.restored.length === 1)
|
||||
expect(fixture.restored[0].prompt.text).toBe("saved prompt")
|
||||
expect(fixture.stashes[0].list()).toHaveLength(0)
|
||||
expect(fixture.dialogs[0].stack).toHaveLength(1)
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Replacement dialog"))
|
||||
})
|
||||
|
||||
test("a live stash update preserves the selected prompt by ID", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
using fixture = await renderStash(tmp.path)
|
||||
await fixture.stashes[0].push({ prompt: prompt("first") })
|
||||
await fixture.stashes[0].push({ prompt: prompt("second") })
|
||||
fixture.open()
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("first") && frame.includes("second"))
|
||||
fixture.app.mockInput.pressArrow("down")
|
||||
|
||||
await fixture.stashes[1].push({ prompt: prompt("third") })
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("third"))
|
||||
fixture.app.mockInput.pressEnter()
|
||||
|
||||
await fixture.app.waitFor(() => fixture.restored.length === 1)
|
||||
expect(fixture.restored[0].prompt.text).toBe("first")
|
||||
expect(fixture.stashes[0].list().map((entry) => entry.prompt.text)).toEqual(["second", "third"])
|
||||
expect(fixture.dialogs[0].stack).toHaveLength(0)
|
||||
})
|
||||
|
||||
async function renderStash(root: string) {
|
||||
const stashes: ReturnType<typeof usePromptStash>[] = []
|
||||
const dialogs: ReturnType<typeof useDialog>[] = []
|
||||
const storages: ReturnType<typeof useStorage>[] = []
|
||||
const restored: StashEntry[] = []
|
||||
function Consumer() {
|
||||
stashes.push(usePromptStash())
|
||||
dialogs.push(useDialog())
|
||||
storages.push(useStorage())
|
||||
return null
|
||||
}
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts directory={root} paths={{ home: root, state: root, worktree: root }}>
|
||||
<ConfigProvider config={createTuiResolvedConfig()}>
|
||||
<Keymap.Provider>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<ToastProvider>
|
||||
{["dev", "beta"].map((channel) => (
|
||||
<TuiAppProvider value={{ name: "test", version: "0.0.0", channel }}>
|
||||
<StorageProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<Consumer />
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</StorageProvider>
|
||||
</TuiAppProvider>
|
||||
))}
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width: 80, height: 24, kittyKeyboard: true },
|
||||
)
|
||||
app.renderer.start()
|
||||
await app.waitFor(() => stashes.length === 2)
|
||||
await Promise.all(storages.map((storage) => storage.flush()))
|
||||
return {
|
||||
app,
|
||||
stashes,
|
||||
dialogs,
|
||||
restored,
|
||||
open: () => dialogs[0].replace(() => <DialogStash onSelect={(entry) => restored.push(entry)} />),
|
||||
[Symbol.dispose]: () => app.renderer.destroy(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createComponent, createRoot } from "solid-js"
|
||||
import path from "node:path"
|
||||
import { TuiAppProvider, TuiPathsProvider } from "../../src/context/runtime"
|
||||
import { StorageProvider, useStorage } from "../../src/context/storage"
|
||||
import { emptyPrompt } from "../../src/prompt/history"
|
||||
import { PromptStashProvider, usePromptStash } from "../../src/prompt/stash"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
const prompt = (text: string) => ({ prompt: { ...emptyPrompt(), text } })
|
||||
|
||||
test("concurrent pushes survive initialization and sync across running TUIs", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await using a = mount(tmp.path, "dev")
|
||||
await using b = mount(tmp.path, "beta")
|
||||
await Promise.all([a.stash.push(prompt("first")), b.stash.push(prompt("second"))])
|
||||
await until(() => a.stash.list().length === 2 && b.stash.list().length === 2)
|
||||
expect(
|
||||
a.stash
|
||||
.list()
|
||||
.map((entry) => entry.prompt.text)
|
||||
.sort(),
|
||||
).toEqual(["first", "second"])
|
||||
await a.stash.push(prompt("later"))
|
||||
await until(() => b.stash.list().at(-1)?.prompt.text === "later")
|
||||
expect((await b.stash.pop())?.prompt.text).toBe("later")
|
||||
await until(() => a.stash.list().length === 2)
|
||||
})
|
||||
|
||||
test("a stale selected ID cannot delete the neighboring prompt", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await using a = mount(tmp.path, "dev")
|
||||
await using b = mount(tmp.path, "beta")
|
||||
await a.stash.push(prompt("first"))
|
||||
await a.stash.push(prompt("second"))
|
||||
await until(() => b.stash.list().length === 2)
|
||||
const id = b.stash.list()[0].id
|
||||
expect((await a.stash.remove(id))?.prompt.text).toBe("first")
|
||||
expect(await b.stash.remove(id)).toBeUndefined()
|
||||
expect((await b.stash.pop())?.prompt.text).toBe("second")
|
||||
})
|
||||
|
||||
test("legacy prompts import once, pop once, and stay consumed after restarting", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Bun.write(
|
||||
path.join(tmp.path, "prompt-stash.jsonl"),
|
||||
JSON.stringify({ ...prompt("legacy"), timestamp: 1 }) + "\n",
|
||||
)
|
||||
{
|
||||
await using a = mount(tmp.path, "dev")
|
||||
await using b = mount(tmp.path, "beta")
|
||||
await until(() => a.stash.list().length === 1 && b.stash.list().length === 1)
|
||||
const entries = await Promise.all([a.stash.pop(), b.stash.pop()])
|
||||
expect(entries.filter(Boolean).map((entry) => entry?.prompt.text)).toEqual(["legacy"])
|
||||
}
|
||||
await using restarted = mount(tmp.path, "dev")
|
||||
expect(await restarted.stash.pop()).toBeUndefined()
|
||||
})
|
||||
|
||||
function mount(root: string, channel: string) {
|
||||
return createRoot((dispose) => {
|
||||
let stash!: ReturnType<typeof usePromptStash>
|
||||
let storage!: ReturnType<typeof useStorage>
|
||||
function Consumer() {
|
||||
stash = usePromptStash()
|
||||
storage = useStorage()
|
||||
return null
|
||||
}
|
||||
createComponent(TuiAppProvider, {
|
||||
value: { name: "test", version: "0.0.0", channel },
|
||||
get children() {
|
||||
return (
|
||||
<TuiPathsProvider value={{ cwd: path.join(root, channel), home: root, state: root, worktree: root }}>
|
||||
<StorageProvider>
|
||||
<PromptStashProvider>
|
||||
<Consumer />
|
||||
</PromptStashProvider>
|
||||
</StorageProvider>
|
||||
</TuiPathsProvider>
|
||||
)
|
||||
},
|
||||
})
|
||||
return {
|
||||
stash,
|
||||
async [Symbol.asyncDispose]() {
|
||||
await storage.flush().finally(dispose)
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function until(predicate: () => boolean) {
|
||||
for (const _ of Array.from({ length: 100 })) {
|
||||
if (predicate()) return
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
expect(predicate()).toBe(true)
|
||||
}
|
||||
Reference in New Issue
Block a user