228 lines
9.6 KiB
TypeScript
228 lines
9.6 KiB
TypeScript
export * as ConfigMigration from "./migrate"
|
|
|
|
import { TuiConfigV1 } from "@opencode-ai/tui/config/v1"
|
|
import { TuiKeybind } from "@opencode-ai/tui/config/v1/keybind"
|
|
import { Definitions } from "@opencode-ai/tui/config/keybind"
|
|
import { Effect, FileSystem, Option, Schema } from "effect"
|
|
import {
|
|
createScanner,
|
|
parse,
|
|
parseTree,
|
|
type Node,
|
|
type ParseError,
|
|
} from "jsonc-parser"
|
|
import path from "path"
|
|
import type { Info } from "./schema"
|
|
|
|
const decodeV1 = Schema.decodeUnknownOption(TuiConfigV1.Info)
|
|
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
|
|
const LegacyKeybindTargets = new Set<string>(Object.values(TuiKeybind.CommandMap))
|
|
|
|
export const run = Effect.fn("cli.config.migrate")(function* (input: {
|
|
readonly file: string
|
|
readonly config: string
|
|
readonly state: string
|
|
}) {
|
|
const fs = yield* FileSystem.FileSystem
|
|
if (yield* fs.exists(input.file).pipe(Effect.orElseSucceed(() => false))) {
|
|
const text = yield* fs.readFileString(input.file)
|
|
const errors: ParseError[] = []
|
|
const value: any = parse(text, errors, { allowTrailingComma: true })
|
|
if (errors.length) return
|
|
const config = Option.getOrUndefined(decodeRecord(value))
|
|
if (config === undefined) return
|
|
const keybinds = Option.getOrUndefined(decodeRecord(config.keybinds))
|
|
if (keybinds === undefined) return
|
|
const updated = Object.keys(keybinds).reduce((text, name) => {
|
|
const target =
|
|
TuiKeybind.CommandMap[name as keyof typeof TuiKeybind.CommandMap] ??
|
|
(LegacyKeybindTargets.has(name) ? name : undefined)
|
|
if (target === undefined) return text
|
|
if (target === name && target in Definitions) return text
|
|
const properties = findKeybindProperties(text, name)
|
|
if (!properties.length) return text
|
|
const remove = !(target in Definitions) || (target !== name && target in keybinds)
|
|
// The parser gives the final duplicate precedence, so remove earlier properties before renaming it.
|
|
const updated = properties.slice(0, remove ? properties.length : -1).reduce((text) => {
|
|
const property = findKeybindProperties(text, name)[0]
|
|
return property === undefined ? text : removeProperty(text, property)
|
|
}, text)
|
|
if (remove) return updated
|
|
const key = findKeybindProperties(updated, name)[0]?.children?.[0]
|
|
if (key === undefined) return text
|
|
return updated.slice(0, key.offset) + JSON.stringify(target) + updated.slice(key.offset + key.length)
|
|
}, text)
|
|
if (updated === text) return
|
|
const temp = input.file + ".tmp"
|
|
yield* fs.writeFileString(temp, updated, { mode: 0o600 })
|
|
yield* fs.rename(temp, input.file)
|
|
return
|
|
}
|
|
|
|
const legacyValue = yield* readJson(path.join(input.config, "tui.json"))
|
|
const legacy = Option.getOrUndefined(decodeV1(legacyValue))
|
|
const kv = yield* readJson(path.join(input.state, "kv.json"))
|
|
const migrated = migrateV1(legacy, kv ?? {})
|
|
if (!Object.keys(migrated).length) return
|
|
|
|
const temp = input.file + ".tmp"
|
|
yield* fs.makeDirectory(path.dirname(input.file), { recursive: true })
|
|
yield* fs.writeFileString(temp, JSON.stringify(migrated, null, 2) + "\n", { mode: 0o600 })
|
|
yield* fs.rename(temp, input.file)
|
|
yield* Effect.logInfo("migrated cli config", {
|
|
from: [
|
|
legacyValue === undefined ? undefined : path.join(input.config, "tui.json"),
|
|
kv === undefined ? undefined : path.join(input.state, "kv.json"),
|
|
].filter(Boolean),
|
|
to: input.file,
|
|
})
|
|
})
|
|
|
|
function findKeybindProperties(text: string, name: string) {
|
|
const tree = parseTree(text)
|
|
if (tree === undefined) return []
|
|
const keybinds = tree.children?.findLast((property) => property.children?.[0]?.value === "keybinds")?.children?.[1]
|
|
return keybinds?.children?.filter((property) => property.children?.[0]?.value === name) ?? []
|
|
}
|
|
|
|
function removeProperty(text: string, property: Node) {
|
|
const properties = property.parent?.children ?? []
|
|
const index = properties.indexOf(property)
|
|
const end = property.offset + property.length
|
|
const next = properties[index + 1]
|
|
if (next) {
|
|
const comma = findComma(text, end, next.offset)
|
|
if (comma !== undefined) return text.slice(0, property.offset) + text.slice(comma + 1)
|
|
}
|
|
const previous = properties[index - 1]
|
|
if (previous) {
|
|
const comma = findComma(text, previous.offset + previous.length, property.offset)
|
|
if (comma !== undefined)
|
|
return text.slice(0, comma) + text.slice(comma + 1, property.offset) + text.slice(end)
|
|
}
|
|
const comma = findComma(text, end, (property.parent?.offset ?? 0) + (property.parent?.length ?? 0))
|
|
if (comma !== undefined) return text.slice(0, property.offset) + text.slice(comma + 1)
|
|
return text.slice(0, property.offset) + text.slice(end)
|
|
}
|
|
|
|
function findComma(text: string, start: number, end: number) {
|
|
const scanner = createScanner(text, false)
|
|
scanner.setPosition(start)
|
|
while (true) {
|
|
scanner.scan()
|
|
const offset = scanner.getTokenOffset()
|
|
if (scanner.getTokenLength() === 0 || offset >= end) return
|
|
if (text[offset] === ",") return offset
|
|
}
|
|
}
|
|
|
|
export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<string, any>): Info {
|
|
const plugins = [
|
|
...(legacy?.plugin?.map((plugin) =>
|
|
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
|
) ?? []),
|
|
...Object.entries(legacy?.plugin_enabled ?? {}).map(([id, enabled]) => (enabled ? id : `-${id}`)),
|
|
]
|
|
const themeName = legacy?.theme ?? kv.theme
|
|
const themeMode = kv.theme_mode_lock
|
|
const attentionSoundPack = kv.attention_sound_pack
|
|
const diffView = kv.diff_viewer_view ?? (legacy?.diff_style === "stacked" ? "unified" : undefined)
|
|
const thinking =
|
|
kv.thinking_mode ?? (kv.thinking_visibility === undefined ? undefined : kv.thinking_visibility ? "show" : "hide")
|
|
const keybinds =
|
|
legacy?.keybinds === undefined
|
|
? undefined
|
|
: Object.fromEntries(
|
|
Object.entries(legacy.keybinds).flatMap(([name, value]) => {
|
|
const target = TuiKeybind.CommandMap[name as keyof typeof TuiKeybind.CommandMap] ?? name
|
|
if (!(target in Definitions)) return []
|
|
return [[target, value]]
|
|
}),
|
|
)
|
|
|
|
return {
|
|
...(themeName !== undefined || themeMode !== undefined
|
|
? {
|
|
theme: {
|
|
...(themeName === undefined ? {} : { name: themeName }),
|
|
...(themeMode === undefined ? {} : { mode: themeMode }),
|
|
},
|
|
}
|
|
: {}),
|
|
...(keybinds === undefined ? {} : { keybinds }),
|
|
...(plugins.length ? { plugins } : {}),
|
|
...(legacy?.leader_timeout === undefined ? {} : { leader: { timeout: legacy.leader_timeout } }),
|
|
...(legacy?.scroll_speed === undefined && legacy?.scroll_acceleration?.enabled === undefined
|
|
? {}
|
|
: {
|
|
scroll: {
|
|
...(legacy.scroll_speed === undefined ? {} : { speed: legacy.scroll_speed }),
|
|
...(legacy.scroll_acceleration?.enabled === undefined
|
|
? {}
|
|
: { acceleration: legacy.scroll_acceleration.enabled }),
|
|
},
|
|
}),
|
|
...(legacy?.attention === undefined && attentionSoundPack === undefined
|
|
? {}
|
|
: {
|
|
attention: {
|
|
...legacy?.attention,
|
|
...(attentionSoundPack === undefined ? {} : { sound_pack: attentionSoundPack }),
|
|
},
|
|
}),
|
|
...(legacy?.diff_style === undefined &&
|
|
kv.diff_wrap_mode === undefined &&
|
|
kv.diff_viewer_show_file_tree === undefined &&
|
|
kv.diff_viewer_single_patch === undefined &&
|
|
diffView === undefined
|
|
? {}
|
|
: {
|
|
diffs: {
|
|
...(kv.diff_wrap_mode === undefined ? {} : { wrap: kv.diff_wrap_mode }),
|
|
...(kv.diff_viewer_show_file_tree === undefined ? {} : { tree: kv.diff_viewer_show_file_tree }),
|
|
...(kv.diff_viewer_single_patch === undefined ? {} : { single: kv.diff_viewer_single_patch }),
|
|
...(diffView === undefined ? {} : { view: diffView }),
|
|
},
|
|
}),
|
|
...(kv.terminal_title_enabled === undefined ? {} : { terminal: { title: kv.terminal_title_enabled } }),
|
|
...(kv.file_context_enabled === undefined && kv.paste_summary_enabled === undefined
|
|
? {}
|
|
: {
|
|
prompt: {
|
|
...(kv.file_context_enabled === undefined ? {} : { editor: kv.file_context_enabled }),
|
|
...(kv.paste_summary_enabled === undefined
|
|
? {}
|
|
: { paste: kv.paste_summary_enabled ? ("compact" as const) : ("full" as const) }),
|
|
},
|
|
}),
|
|
...(kv.sidebar === undefined &&
|
|
kv.scrollbar_visible === undefined &&
|
|
thinking === undefined &&
|
|
kv.exploration_grouping === undefined
|
|
? {}
|
|
: {
|
|
session: {
|
|
...(kv.sidebar === undefined ? {} : { sidebar: kv.sidebar }),
|
|
...(kv.scrollbar_visible === undefined ? {} : { scrollbar: kv.scrollbar_visible }),
|
|
...(thinking === undefined ? {} : { thinking }),
|
|
...(kv.exploration_grouping === undefined
|
|
? {}
|
|
: { grouping: kv.exploration_grouping ? ("auto" as const) : ("none" as const) }),
|
|
},
|
|
}),
|
|
...(kv.animations_enabled === undefined ? {} : { animations: kv.animations_enabled }),
|
|
...(legacy?.mouse === undefined ? {} : { mouse: legacy.mouse }),
|
|
...(legacy?.cursor === undefined ? {} : { cursor: legacy.cursor }),
|
|
}
|
|
}
|
|
|
|
const readJson = Effect.fnUntraced(function* (target: string) {
|
|
const fs = yield* FileSystem.FileSystem
|
|
const text = yield* fs.readFileString(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
if (text === undefined) return undefined
|
|
const errors: ParseError[] = []
|
|
const value: any = parse(text, errors, { allowTrailingComma: true })
|
|
if (errors.length) return undefined
|
|
return Option.getOrUndefined(decodeRecord(value))
|
|
})
|