mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-11 16:52:16 +03:00
fix(tui): keep keymap command objects while their data is unchanged (#59294)
This commit is contained in:
committed by
GitHub
Unverified
parent
6c24ec9532
commit
bf544cd419
@@ -204,6 +204,9 @@ function use(): Keymap {
|
|||||||
function createLayer(input: () => KeymapLayer) {
|
function createLayer(input: () => KeymapLayer) {
|
||||||
const value = useValue()
|
const value = useValue()
|
||||||
const enabled = useInteractivity()
|
const enabled = useInteractivity()
|
||||||
|
// The keymap rebuilds a re-registered layer when a command's `opencode` object is new,
|
||||||
|
// so each command keeps one object while its data is unchanged.
|
||||||
|
const stable = new Map<string, KeymapCommand & { readonly id: string }>()
|
||||||
useBindings(() => {
|
useBindings(() => {
|
||||||
const layer = input()
|
const layer = input()
|
||||||
const { commands, bindings, mode, ...options } = layer
|
const { commands, bindings, mode, ...options } = layer
|
||||||
@@ -213,11 +216,19 @@ function createLayer(input: () => KeymapLayer) {
|
|||||||
enabled: enabled() ? options.enabled : false,
|
enabled: enabled() ? options.enabled : false,
|
||||||
...(mode === "global" ? {} : { mode: mode ?? MODE.base }),
|
...(mode === "global" ? {} : { mode: mode ?? MODE.base }),
|
||||||
commands: grouped.named.map((command) => {
|
commands: grouped.named.map((command) => {
|
||||||
const { id, description, group, palette, bind, run, ...definition } = command
|
const previous = stable.get(command.id)
|
||||||
|
if (previous && sameData(previous, command))
|
||||||
|
Object.assign(
|
||||||
|
previous,
|
||||||
|
Object.fromEntries(Object.entries(command).filter(([, value]) => typeof value === "function")),
|
||||||
|
)
|
||||||
|
else stable.set(command.id, command)
|
||||||
|
const kept = stable.get(command.id)!
|
||||||
|
const { id, description, group, palette, bind, run, ...definition } = kept
|
||||||
return {
|
return {
|
||||||
...definition,
|
...definition,
|
||||||
name: id,
|
name: id,
|
||||||
opencode: command,
|
opencode: kept,
|
||||||
run: (context: CommandContext<Renderable, KeyEvent>) => run(value.input(id), context.event),
|
run: (context: CommandContext<Renderable, KeyEvent>) => run(value.input(id), context.event),
|
||||||
...(description === undefined ? {} : { desc: description }),
|
...(description === undefined ? {} : { desc: description }),
|
||||||
...(group === undefined ? {} : { category: group }),
|
...(group === undefined ? {} : { category: group }),
|
||||||
@@ -250,6 +261,33 @@ function createLayer(input: () => KeymapLayer) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compares data fields only; functions are new closures on every run and take the latest by assignment.
|
||||||
|
function sameData(previous: KeymapCommand, next: KeymapCommand) {
|
||||||
|
const keys = Object.keys(next) as (keyof KeymapCommand)[]
|
||||||
|
return (
|
||||||
|
keys.length === Object.keys(previous).length &&
|
||||||
|
keys.every((key) =>
|
||||||
|
typeof next[key] === "function"
|
||||||
|
? typeof previous[key] === "function"
|
||||||
|
: Object.hasOwn(previous, key) && sameValue(previous[key], next[key]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layers write `slash` inline, so its objects and arrays compare by their fields.
|
||||||
|
function sameValue(previous: unknown, next: unknown): boolean {
|
||||||
|
if (Object.is(previous, next)) return true
|
||||||
|
if (typeof previous !== "object" || typeof next !== "object" || previous === null || next === null) return false
|
||||||
|
if (Array.isArray(previous) !== Array.isArray(next)) return false
|
||||||
|
const before = previous as Record<string, unknown>
|
||||||
|
const after = next as Record<string, unknown>
|
||||||
|
const keys = Object.keys(after)
|
||||||
|
return (
|
||||||
|
keys.length === Object.keys(before).length &&
|
||||||
|
keys.every((key) => Object.hasOwn(before, key) && sameValue(before[key], after[key]))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function groupCommands(commands: readonly KeymapCommand[] = []) {
|
function groupCommands(commands: readonly KeymapCommand[] = []) {
|
||||||
return commands.reduce(
|
return commands.reduce(
|
||||||
(result, command) => {
|
(result, command) => {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
|
import type { KeymapCommand } from "@opencode/plugin/tui/context"
|
||||||
|
import { useKeymap } from "@opentui/keymap/solid"
|
||||||
import { testRender } from "@opentui/solid"
|
import { testRender } from "@opentui/solid"
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
|
import { createSignal } from "solid-js"
|
||||||
import { ConfigProvider } from "../src/config"
|
import { ConfigProvider } from "../src/config"
|
||||||
import { Keymap } from "../src/context/keymap"
|
import { Keymap } from "../src/context/keymap"
|
||||||
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
|
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
|
||||||
@@ -100,6 +103,106 @@ test("returns every formatted command shortcut", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("re-registered commands keep their opencode object until its data changes", async () => {
|
||||||
|
const [title, setTitle] = createSignal("Demo")
|
||||||
|
const [description, setDescription] = createSignal<string | undefined>("Runs the demo")
|
||||||
|
const [version, setVersion] = createSignal(0)
|
||||||
|
let opencode = () => undefined as KeymapCommand | undefined
|
||||||
|
let command = () => undefined as KeymapCommand | undefined
|
||||||
|
|
||||||
|
function Harness() {
|
||||||
|
const keymap = useKeymap()
|
||||||
|
const commands = Keymap.useCommands()
|
||||||
|
Keymap.createLayer(() => ({
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
id: "demo.command",
|
||||||
|
title: title(),
|
||||||
|
...(description() === undefined ? {} : { description: description() }),
|
||||||
|
palette: true,
|
||||||
|
suggested: version() >= 0,
|
||||||
|
run() {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
opencode = () =>
|
||||||
|
keymap.getCommandEntries({ visibility: "registered" }).find((entry) => entry.command.name === "demo.command")
|
||||||
|
?.command.opencode
|
||||||
|
command = () => commands().find((item) => item.id === "demo.command")
|
||||||
|
return <box />
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await testRender(() => (
|
||||||
|
<ConfigProvider config={createTuiResolvedConfig()}>
|
||||||
|
<Keymap.Provider>
|
||||||
|
<Harness />
|
||||||
|
</Keymap.Provider>
|
||||||
|
</ConfigProvider>
|
||||||
|
))
|
||||||
|
try {
|
||||||
|
const first = opencode()
|
||||||
|
setVersion(1)
|
||||||
|
expect(opencode()).toBe(first)
|
||||||
|
|
||||||
|
setTitle("Renamed")
|
||||||
|
expect(opencode()).not.toBe(first)
|
||||||
|
expect(command()?.title).toBe("Renamed")
|
||||||
|
|
||||||
|
setDescription(undefined)
|
||||||
|
expect(command()?.description).toBeUndefined()
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("re-registered commands keep inline slash objects until their fields change", async () => {
|
||||||
|
const [aliases, setAliases] = createSignal(["d"])
|
||||||
|
const [version, setVersion] = createSignal(0)
|
||||||
|
let registered = () => undefined as { opencode?: unknown; slash?: unknown } | undefined
|
||||||
|
let command = () => undefined as KeymapCommand | undefined
|
||||||
|
|
||||||
|
function Harness() {
|
||||||
|
const keymap = useKeymap()
|
||||||
|
const commands = Keymap.useCommands()
|
||||||
|
Keymap.createLayer(() => ({
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
id: "demo.slash",
|
||||||
|
title: "Demo",
|
||||||
|
suggested: version() >= 0,
|
||||||
|
slash: { name: "demo", aliases: aliases(), arguments: true },
|
||||||
|
run() {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
registered = () =>
|
||||||
|
keymap.getCommandEntries({ visibility: "registered" }).find((entry) => entry.command.name === "demo.slash")
|
||||||
|
?.command
|
||||||
|
command = () => commands().find((item) => item.id === "demo.slash")
|
||||||
|
return <box />
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await testRender(() => (
|
||||||
|
<ConfigProvider config={createTuiResolvedConfig()}>
|
||||||
|
<Keymap.Provider>
|
||||||
|
<Harness />
|
||||||
|
</Keymap.Provider>
|
||||||
|
</ConfigProvider>
|
||||||
|
))
|
||||||
|
try {
|
||||||
|
const first = registered()
|
||||||
|
setVersion(1)
|
||||||
|
expect(registered()?.opencode).toBe(first?.opencode)
|
||||||
|
expect(registered()?.slash).toBe(first?.slash)
|
||||||
|
|
||||||
|
setAliases(["d", "dm"])
|
||||||
|
expect(registered()?.opencode).not.toBe(first?.opencode)
|
||||||
|
expect(command()?.slash?.aliases).toEqual(["d", "dm"])
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("global commands stay reachable when the mode changes", async () => {
|
test("global commands stay reachable when the mode changes", async () => {
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
let exercise = () => {}
|
let exercise = () => {}
|
||||||
|
|||||||
Reference in New Issue
Block a user