mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-07 10:14:49 +00:00
refactor(tui): simplify graduated preview tabs
This commit is contained in:
parent
76b4f07ad5
commit
23d9ca65f9
3 changed files with 6 additions and 160 deletions
|
|
@ -1,84 +1,19 @@
|
|||
import { createMemo, createSignal } from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
type Experiment = {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
// In-flight features anyone can opt into. Each entry is temporary: an
|
||||
// experiment either graduates (delete the entry, make the behavior
|
||||
// unconditional) or dies (delete the entry and the branch it gated).
|
||||
export const experiments: Experiment[] = []
|
||||
|
||||
export function DialogExperiments() {
|
||||
const config = useConfig()
|
||||
const theme = useTheme()
|
||||
const toast = useToast()
|
||||
const [selected, setSelected] = createSignal<Experiment>()
|
||||
const [saving, setSaving] = createSignal(false)
|
||||
|
||||
const enabled = (experiment: Experiment) => config.data.experimental?.[experiment.id] === true
|
||||
|
||||
const options = createMemo(() =>
|
||||
experiments.map((experiment) => ({
|
||||
title: experiment.title,
|
||||
searchText: experiment.description,
|
||||
footer: enabled(experiment) ? "on" : "off",
|
||||
value: experiment,
|
||||
})),
|
||||
)
|
||||
|
||||
// All experiments are booleans, so either direction toggles.
|
||||
async function change(experiment = selected()) {
|
||||
if (saving()) return
|
||||
if (!experiment) return
|
||||
const next = !enabled(experiment)
|
||||
setSaving(true)
|
||||
await config
|
||||
.update((draft) => {
|
||||
if (!draft.experimental || typeof draft.experimental !== "object") draft.experimental = {}
|
||||
draft.experimental[experiment.id] = next
|
||||
})
|
||||
.catch(toast.error)
|
||||
.finally(() => setSaving(false))
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Experiments"
|
||||
options={options()}
|
||||
renderFilter={experiments.length > 0}
|
||||
onMove={(option) => setSelected(option.value)}
|
||||
onSelect={(option) => void change(option.value)}
|
||||
options={[]}
|
||||
renderFilter={false}
|
||||
emptyView={
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={theme.text.subdued}>No experiments available</text>
|
||||
</box>
|
||||
}
|
||||
footerHints={experiments.length > 0 ? [{ title: "←/→", label: "change" }] : []}
|
||||
bindings={
|
||||
experiments.length > 0
|
||||
? [
|
||||
{
|
||||
bind: "left",
|
||||
title: "Previous value",
|
||||
group: "Experiments",
|
||||
run: () => void change(),
|
||||
},
|
||||
{
|
||||
bind: "right",
|
||||
title: "Next value",
|
||||
group: "Experiments",
|
||||
run: () => void change(),
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useKeyboard, useRenderer } from "@opentui/solid"
|
||||
import { isDeepEqual } from "remeda"
|
||||
import { createSimpleContext } from "./helper"
|
||||
|
|
@ -73,9 +74,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||
},
|
||||
key: "sessionID",
|
||||
})
|
||||
const [preview, updatePreview] = storage.memory<{ global?: string; cwd?: string }>("session-tab-preview", {
|
||||
initial: {},
|
||||
})
|
||||
const [preview, updatePreview] = createStore<{ global?: string; cwd?: string }>({})
|
||||
const fallback = empty()
|
||||
const [promptPulses, setPromptPulses] = createSignal<Record<string, number>>({})
|
||||
let history: SessionTabHistory = { entries: [], index: -1 }
|
||||
|
|
@ -107,16 +106,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||
}
|
||||
|
||||
const previewID = () => preview[config.tabs.scope]
|
||||
const setPreview = (sessionID: string | undefined) => {
|
||||
const scope = config.tabs.scope
|
||||
updatePreview((draft) => {
|
||||
if (sessionID === undefined) {
|
||||
delete draft[scope]
|
||||
return
|
||||
}
|
||||
draft[scope] = sessionID
|
||||
})
|
||||
}
|
||||
const setPreview = (sessionID: string | undefined) => updatePreview(config.tabs.scope, sessionID)
|
||||
|
||||
function update(mutation: (draft: TabsState) => void) {
|
||||
const scope = config.tabs.scope
|
||||
|
|
@ -179,11 +169,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||
createEffect(() => {
|
||||
if (enabled()) return
|
||||
promotedSession = undefined
|
||||
if (!preview.global && !preview.cwd) return
|
||||
updatePreview((draft) => {
|
||||
delete draft.global
|
||||
delete draft.cwd
|
||||
})
|
||||
updatePreview({ global: undefined, cwd: undefined })
|
||||
})
|
||||
|
||||
// Shared storage updates must not re-admit a tab unless this client changes route or scope.
|
||||
|
|
|
|||
|
|
@ -5,10 +5,7 @@ import { expect, test } from "bun:test"
|
|||
import { createSignal } from "solid-js"
|
||||
import { ConfigProvider } from "../../src/config"
|
||||
import { EMPTY_SESSION_TAB_STATUS, SessionTabs, type SessionTabsController } from "../../src/component/session-tabs"
|
||||
import { Keymap } from "../../src/context/keymap"
|
||||
import { ThemeProvider } from "../../src/context/theme"
|
||||
import { DialogProvider } from "../../src/ui/dialog"
|
||||
import { ToastProvider } from "../../src/ui/toast"
|
||||
import { emptyThemeSource } from "../fixture/fixture"
|
||||
import { TestTuiContexts } from "../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
|
||||
|
|
@ -65,78 +62,6 @@ test("releasing a transcript selection over tab controls does not activate them"
|
|||
}
|
||||
})
|
||||
|
||||
test("the horizontal tab context menu keeps preview tabs open without selecting them", async () => {
|
||||
const [active, setActive] = createSignal("first")
|
||||
const promoted: string[] = []
|
||||
const controller = {
|
||||
tabs: () => [
|
||||
{ sessionID: "first", title: "First" },
|
||||
{ sessionID: "second", title: "Second" },
|
||||
],
|
||||
current: active,
|
||||
select: setActive,
|
||||
close() {},
|
||||
move() {},
|
||||
isPreview: (sessionID: string) => sessionID === "second",
|
||||
promote: (sessionID: string) => promoted.push(sessionID),
|
||||
status: () => EMPTY_SESSION_TAB_STATUS,
|
||||
} satisfies SessionTabsController
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts>
|
||||
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
|
||||
<Keymap.Provider>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<ToastProvider>
|
||||
<DialogProvider>
|
||||
<SessionTabs controller={controller} animations={false} />
|
||||
</DialogProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width: 60, height: 8 },
|
||||
)
|
||||
|
||||
try {
|
||||
app.renderer.start()
|
||||
await app.waitForFrame((frame) => frame.includes("Second"))
|
||||
|
||||
const first = app
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.findIndex((line) => line.includes("First"))
|
||||
await app.mockMouse.click(app.captureCharFrame().split("\n")[first]!.indexOf("First"), first, MouseButton.RIGHT)
|
||||
await app.waitForFrame((frame) => frame.includes("Rename"))
|
||||
expect(app.captureCharFrame()).toContain("Close")
|
||||
expect(app.captureCharFrame()).not.toContain("Keep open")
|
||||
|
||||
app.mockInput.pressKey("c", { ctrl: true })
|
||||
await app.waitForFrame((frame) => !frame.includes("Rename"))
|
||||
|
||||
const second = app
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.findIndex((line) => line.includes("Second"))
|
||||
await app.mockMouse.click(app.captureCharFrame().split("\n")[second]!.indexOf("Second"), second, MouseButton.RIGHT)
|
||||
await app.waitForFrame((frame) => frame.includes("Keep open"))
|
||||
expect(app.captureCharFrame()).toContain("Rename")
|
||||
expect(app.captureCharFrame()).toContain("Close")
|
||||
expect(active()).toBe("first")
|
||||
const frame = app.captureCharFrame().split("\n")
|
||||
const row = frame.findIndex((line) => line.includes("Keep open"))
|
||||
await app.mockMouse.click(frame[row]!.indexOf("Keep open"), row)
|
||||
await app.waitForFrame((frame) => !frame.includes("Rename"))
|
||||
|
||||
expect(promoted).toEqual(["second"])
|
||||
expect(active()).toBe("first")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("double-clicking a preview tab keeps it open without promoting permanent tabs", async () => {
|
||||
const [active, setActive] = createSignal("first")
|
||||
const promoted: string[] = []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue