opencode-vim 0.0.23 → 0.0.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/dist/tui.js +3326 -0
- package/package.json +6 -6
- package/src/clipboard.tsx +0 -50
- package/src/modules/snippets/index.tsx +0 -17
- package/src/modules/snippets/loader.ts +0 -147
- package/src/modules/snippets/search.ts +0 -118
- package/src/modules/snippets/skill-loader.ts +0 -81
- package/src/modules/snippets/state.ts +0 -40
- package/src/modules/snippets/trigger.ts +0 -68
- package/src/modules/snippets/types.ts +0 -50
- package/src/modules/snippets/view.tsx +0 -674
- package/src/modules/vim/actions.ts +0 -82
- package/src/modules/vim/config.ts +0 -137
- package/src/modules/vim/edit.test.ts +0 -51
- package/src/modules/vim/edit.ts +0 -33
- package/src/modules/vim/graphemes.ts +0 -38
- package/src/modules/vim/index.tsx +0 -200
- package/src/modules/vim/keys.ts +0 -38
- package/src/modules/vim/log.ts +0 -37
- package/src/modules/vim/map.test.ts +0 -45
- package/src/modules/vim/map.ts +0 -90
- package/src/modules/vim/state.test.ts +0 -18
- package/src/modules/vim/state.tsx +0 -46
- package/src/modules/vim/view.tsx +0 -130
- package/src/modules/vim/vimee.test.ts +0 -211
- package/src/modules/vim/vimee.ts +0 -1211
- package/src/plugin.tsx +0 -116
- package/src/prompt/host.tsx +0 -38
- package/src/prompt/modules.ts +0 -16
- package/src/prompt/root.tsx +0 -69
- package/src/prompt/types.ts +0 -42
- package/src/session.tsx +0 -291
- package/src/transcript-items.tsx +0 -168
- package/src/transcript.tsx +0 -179
- package/src/update.ts +0 -120
- package/tui.tsx +0 -340
- package/view.tsx +0 -29
package/src/transcript-items.tsx
DELETED
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
// Mirrors OpenCode's routes/session/{rows,group-view,anchor-view}.tsx.
|
|
2
|
-
// TSX keeps OpenTUI classes shared with the host under the runtime loader.
|
|
3
|
-
import type { Context } from "@opencode/plugin/tui/context"
|
|
4
|
-
import { MarkdownRenderable, TextBufferRenderable, type Renderable } from "@opentui/core"
|
|
5
|
-
|
|
6
|
-
type Message = ReturnType<Context["data"]["session"]["message"]["list"]>[number]
|
|
7
|
-
type GroupKind = "reasoning" | "exploration" | "activity" | "instructions"
|
|
8
|
-
export type TranscriptItem = { id: string; author: string; text: string }
|
|
9
|
-
export type TranscriptRange = TranscriptItem & {
|
|
10
|
-
node: Renderable
|
|
11
|
-
top: number
|
|
12
|
-
bottom: number
|
|
13
|
-
toggle?: Renderable
|
|
14
|
-
parentID?: string
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
type Part = TranscriptItem & { messageID: string; kind: string; group?: GroupKind }
|
|
18
|
-
|
|
19
|
-
function parts(messages: Message[]) {
|
|
20
|
-
const result: Part[] = []
|
|
21
|
-
for (const message of messages) {
|
|
22
|
-
if (message.type === "assistant") {
|
|
23
|
-
const ordinal = { text: 0, reasoning: 0 }
|
|
24
|
-
for (const part of message.content) {
|
|
25
|
-
const partID = part.type === "tool" ? part.id : `${part.type}:${ordinal[part.type]++}`
|
|
26
|
-
let text = part.type === "tool" ? "" : part.text
|
|
27
|
-
if (part.type === "reasoning") text = text.replace("[REDACTED]", "")
|
|
28
|
-
if (part.type !== "tool" && !text.trim()) continue
|
|
29
|
-
result.push({ id: `session-part:${message.id}:${partID}`, messageID: message.id, kind: part.type,
|
|
30
|
-
author: part.type === "text" ? "Assistant" : part.type === "reasoning" ? "Reasoning" : part.name,
|
|
31
|
-
text,
|
|
32
|
-
group: part.type === "reasoning" ? "reasoning"
|
|
33
|
-
: part.type === "tool" && ["read", "glob", "grep", "webfetch", "websearch"].includes(part.name.toLowerCase())
|
|
34
|
-
? "exploration" : undefined })
|
|
35
|
-
}
|
|
36
|
-
// Turn footers prevent groups from spanning into the next assistant turn.
|
|
37
|
-
if ((message.finish && message.finish !== "tool-calls" && message.finish !== "unknown") || message.error || message.retry) {
|
|
38
|
-
result.push({ id: `footer:${message.id}`, messageID: message.id, kind: "footer", author: "", text: "" })
|
|
39
|
-
}
|
|
40
|
-
} else {
|
|
41
|
-
result.push({ id: message.id, messageID: message.id, kind: "message", author: message.type === "user" ? "You" : message.type,
|
|
42
|
-
text: message.type === "user" ? message.text : "" })
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
return result
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function textOf(node: Renderable): string {
|
|
49
|
-
if (!node.visible) return ""
|
|
50
|
-
if (node instanceof MarkdownRenderable) return node.content
|
|
51
|
-
if (node instanceof TextBufferRenderable) return node.plainText
|
|
52
|
-
let text = ""
|
|
53
|
-
let previous: Renderable | undefined
|
|
54
|
-
for (const child of node.getChildren()) {
|
|
55
|
-
const value = textOf(child)
|
|
56
|
-
if (!value) continue
|
|
57
|
-
if (text) text += previous?.y === child.y ? " " : "\n"
|
|
58
|
-
text += value
|
|
59
|
-
previous = child
|
|
60
|
-
}
|
|
61
|
-
return text
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function groupAt(node: Renderable) {
|
|
65
|
-
const header = node.getChildren()[0]
|
|
66
|
-
if (!header) return
|
|
67
|
-
const body = header.getChildren()[0]
|
|
68
|
-
let label: string
|
|
69
|
-
if (body instanceof TextBufferRenderable) {
|
|
70
|
-
if (!body.plainText.startsWith("⋯ ")) return
|
|
71
|
-
label = body.plainText.slice(2).trim()
|
|
72
|
-
} else {
|
|
73
|
-
const cells = body?.getChildren() ?? []
|
|
74
|
-
if (cells.length < 2 || !cells.slice(1).every((cell) => cell instanceof TextBufferRenderable)) return
|
|
75
|
-
label = cells.slice(1).map((cell) => (cell as TextBufferRenderable).plainText).join(" ").trim()
|
|
76
|
-
}
|
|
77
|
-
let kind: GroupKind
|
|
78
|
-
if (/^(Thought|Thinking)(?:$|[: ·…])/.test(label)) kind = "reasoning"
|
|
79
|
-
else if (/^Explor(?:ed|ing):/.test(label)) kind = "exploration"
|
|
80
|
-
else if (/^Instructions:/.test(label)) kind = "instructions"
|
|
81
|
-
else if (/^(?:\d+ (?:commands?|edits?|thoughts?|reads?|tools?|instructions?)(?:, |$))+$/.test(label)
|
|
82
|
-
|| /^(?:Running|Preparing) .+…$/.test(label)) kind = "activity"
|
|
83
|
-
else return
|
|
84
|
-
return { header, kind, label, children: node.getChildren().slice(1) }
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export function transcriptItems(rows: Renderable[], messages: Message[]) {
|
|
88
|
-
const sources = parts(messages)
|
|
89
|
-
const ranges: TranscriptRange[] = []
|
|
90
|
-
let cursor = 0
|
|
91
|
-
let lastSource = -1
|
|
92
|
-
|
|
93
|
-
function add(node: Renderable, source?: Part, parentID?: string) {
|
|
94
|
-
const text = source?.text || textOf(node)
|
|
95
|
-
if (!text.trim()) return
|
|
96
|
-
ranges.push({ id: source?.id ?? node.id, author: source?.author ?? "Tool", text, node,
|
|
97
|
-
top: node.y, bottom: node.y + node.height, parentID })
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function group(node: Renderable, parentID?: string): boolean {
|
|
101
|
-
const info = groupAt(node)
|
|
102
|
-
if (!info) return false
|
|
103
|
-
const id = node.id
|
|
104
|
-
ranges.push({ id, author: info.label, text: info.label, node: info.header,
|
|
105
|
-
top: info.header.y, bottom: info.header.y + info.header.height, toggle: info.header, parentID })
|
|
106
|
-
let children = info.children
|
|
107
|
-
// Only exploration groups have EntryAnchors directly under GroupAnchor.
|
|
108
|
-
if (info.kind !== "exploration" && children.length) {
|
|
109
|
-
children = [...children[0].getChildren(), ...children.slice(1)]
|
|
110
|
-
}
|
|
111
|
-
for (const child of children) {
|
|
112
|
-
if (!child.visible || !child.height) continue
|
|
113
|
-
if (!group(child, id)) add(child, undefined, id)
|
|
114
|
-
}
|
|
115
|
-
return true
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
for (const row of rows) {
|
|
119
|
-
if (row.id === "session-navigation-slack") break
|
|
120
|
-
if (!row.visible || !row.height) continue
|
|
121
|
-
const anchor = row.getChildren()[0]
|
|
122
|
-
const info = anchor && groupAt(anchor)
|
|
123
|
-
// Groups can consume a message's first parts without claiming its row ID.
|
|
124
|
-
let index = sources.findIndex((part) => part.id === row.id)
|
|
125
|
-
if (index === -1) {
|
|
126
|
-
index = sources.findIndex((part, position) => position >= cursor && part.messageID === row.id)
|
|
127
|
-
if (index !== -1 && !info) {
|
|
128
|
-
const text = textOf(row).trim()
|
|
129
|
-
const match = sources.findIndex((part, position) => position >= cursor && part.messageID === row.id && part.text.trim() === text)
|
|
130
|
-
if (text && match !== -1) index = match
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
if (index !== -1) cursor = index
|
|
134
|
-
if (!info && index === -1 && sources[cursor]?.kind === "footer") {
|
|
135
|
-
lastSource = cursor++
|
|
136
|
-
continue
|
|
137
|
-
}
|
|
138
|
-
const container = anchor && !(anchor instanceof TextBufferRenderable) && !(anchor instanceof MarkdownRenderable)
|
|
139
|
-
if (index === -1 && !info && sources[cursor]?.group && container) index = cursor
|
|
140
|
-
if (anchor && info) {
|
|
141
|
-
group(anchor)
|
|
142
|
-
// Track grouped sources to detect newer cached rows that aren't mounted yet.
|
|
143
|
-
while (cursor < sources.length) {
|
|
144
|
-
const part = sources[cursor]
|
|
145
|
-
if (info.kind === "activity" ? part.kind !== "tool" && part.kind !== "reasoning" : part.group !== info.kind) break
|
|
146
|
-
lastSource = cursor++
|
|
147
|
-
}
|
|
148
|
-
} else if (index !== -1) {
|
|
149
|
-
const source = sources[index]
|
|
150
|
-
if (source.kind === "footer") {
|
|
151
|
-
lastSource = cursor++
|
|
152
|
-
} else if (source.group && container && anchor.getChildren().length) {
|
|
153
|
-
// With grouping disabled, the GroupAnchor holds individual EntryAnchors.
|
|
154
|
-
for (const child of anchor.getChildren()) {
|
|
155
|
-
add(child, sources[cursor])
|
|
156
|
-
cursor++
|
|
157
|
-
}
|
|
158
|
-
lastSource = cursor - 1
|
|
159
|
-
} else {
|
|
160
|
-
add(row, source)
|
|
161
|
-
lastSource = cursor++
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
let last = sources.length - 1
|
|
166
|
-
while (last >= 0 && (sources[last].kind === "message" || sources[last].kind === "footer") && !sources[last].text) last--
|
|
167
|
-
return { ranges, hasLatest: lastSource >= last }
|
|
168
|
-
}
|
package/src/transcript.tsx
DELETED
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
// TSX ensures the plugin loader shares the host's OpenTUI classes.
|
|
2
|
-
import type { Context } from "@opencode/plugin/tui/context"
|
|
3
|
-
import { MouseEvent, ScrollBoxRenderable, type OptimizedBuffer, type Renderable } from "@opentui/core"
|
|
4
|
-
import { groupAt, transcriptItems, type TranscriptRange } from "./transcript-items"
|
|
5
|
-
|
|
6
|
-
export function createTranscriptSelection(context: Context, sessionID: string, select: (id: string | undefined) => void) {
|
|
7
|
-
let scroll: ScrollBoxRenderable | undefined
|
|
8
|
-
let requestedID: string | undefined
|
|
9
|
-
let selectedParents: string[] = []
|
|
10
|
-
let navigation: {
|
|
11
|
-
from: string | undefined
|
|
12
|
-
offset: number
|
|
13
|
-
latest?: boolean
|
|
14
|
-
loading?: { key: string; frame: number; up: boolean; height: number }
|
|
15
|
-
} | undefined
|
|
16
|
-
|
|
17
|
-
function choose(range: TranscriptRange, view: NonNullable<ReturnType<typeof inspect>>) {
|
|
18
|
-
requestedID = range.id
|
|
19
|
-
select(requestedID)
|
|
20
|
-
let delta = 0
|
|
21
|
-
if (range.bottom <= view.top) delta = range.top - view.top
|
|
22
|
-
else if (range.top >= view.bottom) {
|
|
23
|
-
delta = range.top - view.top - Math.max(0, view.bottom - view.top - (range.bottom - range.top))
|
|
24
|
-
}
|
|
25
|
-
if (delta) {
|
|
26
|
-
const slack = view.scroll.getRenderable("session-navigation-slack")?.height ?? 0
|
|
27
|
-
const maximum = Math.max(0, view.scroll.scrollHeight - slack - view.scroll.viewport.height)
|
|
28
|
-
const position = Math.max(0, Math.min(maximum, view.scroll.scrollTop + delta))
|
|
29
|
-
if (slack) context.keymap.dispatch(delta < 0 ? "session.line.up" : "session.line.down")
|
|
30
|
-
view.scroll.scrollTo(position)
|
|
31
|
-
}
|
|
32
|
-
context.renderer.requestRender()
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function advance() {
|
|
36
|
-
if (!navigation) return
|
|
37
|
-
const view = inspect()
|
|
38
|
-
const ranges = view?.ranges ?? []
|
|
39
|
-
const from = navigation.from ? ranges.findIndex((range) => range.id === navigation!.from) : ranges.length
|
|
40
|
-
if (navigation.from && from === -1) { navigation = undefined; return }
|
|
41
|
-
const target = navigation.latest ? ranges.length - 1 : from + navigation.offset
|
|
42
|
-
const loading = navigation.loading
|
|
43
|
-
// OpenCode compensates for prepended rows after two animation frames.
|
|
44
|
-
if (loading && context.renderer.frameId < loading.frame) {
|
|
45
|
-
context.renderer.requestRender()
|
|
46
|
-
return
|
|
47
|
-
}
|
|
48
|
-
if (view && loading?.up && view.scroll.scrollHeight > loading.height) {
|
|
49
|
-
const maximum = Math.max(0, view.scroll.scrollHeight - view.scroll.viewport.height)
|
|
50
|
-
const compensated = Math.max(0, Math.min(maximum, view.scroll.scrollHeight - loading.height - 1))
|
|
51
|
-
if (view.scroll.scrollTop < compensated) return
|
|
52
|
-
}
|
|
53
|
-
if (view && target >= 0 && target < ranges.length && (!navigation.latest || view.hasLatest)) {
|
|
54
|
-
navigation = undefined
|
|
55
|
-
choose(ranges[target], view)
|
|
56
|
-
return
|
|
57
|
-
}
|
|
58
|
-
const up = !navigation.latest && target < 0
|
|
59
|
-
const messages = context.data.session.message.list(sessionID)
|
|
60
|
-
const key = `${messages[0]?.id}:${messages.length}:${ranges[0]?.id}:${ranges.at(-1)?.id}:${view?.scroll.scrollHeight}`
|
|
61
|
-
if (loading?.key === key) return
|
|
62
|
-
navigation.loading = { key, frame: context.renderer.frameId + 2, up, height: view?.scroll.scrollHeight ?? 0 }
|
|
63
|
-
view?.scroll.scrollTo(up ? 0 : view.scroll.scrollHeight)
|
|
64
|
-
context.keymap.dispatch(up ? "session.line.up" : "session.line.down")
|
|
65
|
-
context.renderer.requestRender()
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function latest() {
|
|
69
|
-
if (!context.data.session.message.list(sessionID).length) {
|
|
70
|
-
navigation = undefined
|
|
71
|
-
requestedID = undefined
|
|
72
|
-
select(undefined)
|
|
73
|
-
return
|
|
74
|
-
}
|
|
75
|
-
navigation = { from: undefined, offset: 0, latest: true }
|
|
76
|
-
advance()
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function move(direction: "next" | "previous", count: number) {
|
|
80
|
-
if (navigation && (navigation.latest || (navigation.offset < 0 && direction === "next") || (navigation.offset > 0 && direction === "previous"))) {
|
|
81
|
-
navigation = undefined
|
|
82
|
-
}
|
|
83
|
-
navigation ??= { from: requestedID ?? inspect()?.selected?.id, offset: 0 }
|
|
84
|
-
navigation.offset += direction === "next" ? count : -count
|
|
85
|
-
if (navigation.loading) navigation.loading.key = ""
|
|
86
|
-
advance()
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function inspect() {
|
|
90
|
-
const messages = context.data.session.message.list(sessionID)
|
|
91
|
-
const ids = new Set(messages.map((message) => message.id))
|
|
92
|
-
function isRow(node: Renderable) {
|
|
93
|
-
if (ids.has(node.id) || (node.id?.startsWith("session-part:") && ids.has(node.id.split(":")[1]))) return true
|
|
94
|
-
const anchor = node.getChildren()[0]
|
|
95
|
-
return Boolean(anchor && groupAt(anchor))
|
|
96
|
-
}
|
|
97
|
-
function findScroll(node: Renderable): ScrollBoxRenderable | undefined {
|
|
98
|
-
if (!node.visible) return
|
|
99
|
-
if (node instanceof ScrollBoxRenderable && node.getChildren().some(isRow)) return node
|
|
100
|
-
for (const child of node.getChildren()) {
|
|
101
|
-
const found = findScroll(child)
|
|
102
|
-
if (found) return found
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
if (!scroll || scroll.isDestroyed) scroll = findScroll(context.renderer.root)
|
|
106
|
-
if (!scroll) { select(undefined); return }
|
|
107
|
-
const { ranges, hasLatest } = transcriptItems(scroll.getChildren(), messages)
|
|
108
|
-
const top = scroll.viewport.y
|
|
109
|
-
const bottom = top + scroll.viewport.height
|
|
110
|
-
let selected = ranges.find((range) => range.id === requestedID)
|
|
111
|
-
if (!selected && requestedID) {
|
|
112
|
-
for (const id of selectedParents) {
|
|
113
|
-
selected = ranges.find((range) => range.id === id)
|
|
114
|
-
if (selected) { requestedID = id; break }
|
|
115
|
-
}
|
|
116
|
-
if (!selected) requestedID = undefined
|
|
117
|
-
}
|
|
118
|
-
selected ??= ranges.find((range) => range.top <= top + 1 && range.bottom > top + 1)
|
|
119
|
-
?? ranges.find((range) => range.bottom > top && range.top < bottom)
|
|
120
|
-
selectedParents = []
|
|
121
|
-
let parent = selected?.parentID
|
|
122
|
-
while (parent) {
|
|
123
|
-
selectedParents.push(parent)
|
|
124
|
-
parent = ranges.find((range) => range.id === parent)?.parentID
|
|
125
|
-
}
|
|
126
|
-
select(selected?.id)
|
|
127
|
-
return { scroll, selected, ranges, hasLatest, top, bottom }
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
return {
|
|
131
|
-
latest, move,
|
|
132
|
-
get(id: string | undefined) {
|
|
133
|
-
if (!scroll || scroll.isDestroyed) return
|
|
134
|
-
return transcriptItems(scroll.getChildren(), context.data.session.message.list(sessionID)).ranges.find((range) => range.id === id)
|
|
135
|
-
},
|
|
136
|
-
toggle() {
|
|
137
|
-
const range = inspect()?.selected
|
|
138
|
-
if (!range?.toggle) return false
|
|
139
|
-
navigation = undefined
|
|
140
|
-
requestedID = range.id
|
|
141
|
-
const node = range.toggle
|
|
142
|
-
node.processMouseEvent(new MouseEvent(node, { type: "up", button: 0, x: node.x, y: node.y,
|
|
143
|
-
modifiers: { shift: false, alt: false, ctrl: false } }))
|
|
144
|
-
context.renderer.requestRender()
|
|
145
|
-
return true
|
|
146
|
-
},
|
|
147
|
-
followViewport() { navigation = undefined; requestedID = undefined; selectedParents = [] },
|
|
148
|
-
focus() { inspect()?.scroll.focus() },
|
|
149
|
-
sync() { advance(); navigation = undefined; return inspect() },
|
|
150
|
-
draw(buffer: OptimizedBuffer, yankID?: string) {
|
|
151
|
-
advance()
|
|
152
|
-
const view = inspect()
|
|
153
|
-
if (!view?.selected) return
|
|
154
|
-
const top = Math.max(0, view.top, view.selected.top)
|
|
155
|
-
const bottom = Math.min(buffer.height, view.bottom, view.selected.bottom)
|
|
156
|
-
if (view.selected.id === yankID) {
|
|
157
|
-
const node = view.selected.node
|
|
158
|
-
const left = Math.max(0, view.scroll.viewport.x, node.x)
|
|
159
|
-
const right = Math.min(buffer.width, view.scroll.viewport.x + view.scroll.viewport.width, node.x + node.width)
|
|
160
|
-
const foreground = context.theme.background.base.buffer
|
|
161
|
-
const background = context.theme.text.feedback.info.base.buffer
|
|
162
|
-
const colors = buffer.buffers
|
|
163
|
-
// Change only colors to preserve wide-character continuation cells.
|
|
164
|
-
for (let y = top; y < bottom; y++) {
|
|
165
|
-
for (let x = left; x < right; x++) {
|
|
166
|
-
const offset = (y * buffer.width + x) * 4
|
|
167
|
-
colors.fg.set(foreground, offset)
|
|
168
|
-
colors.bg.set(background, offset)
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
const x = view.scroll.x - 1
|
|
173
|
-
if (x < 0) return
|
|
174
|
-
for (let y = top; y < bottom; y++) {
|
|
175
|
-
buffer.drawText("▎", x, y, context.theme.text.feedback.info.base, context.theme.background.base)
|
|
176
|
-
}
|
|
177
|
-
},
|
|
178
|
-
}
|
|
179
|
-
}
|
package/src/update.ts
DELETED
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
import { rm } from "node:fs/promises"
|
|
2
|
-
import { basename, dirname, join } from "node:path"
|
|
3
|
-
import { fileURLToPath } from "node:url"
|
|
4
|
-
import type { TuiPluginMeta } from "@opencode-ai/plugin/tui"
|
|
5
|
-
|
|
6
|
-
type PackageJson = {
|
|
7
|
-
name?: string
|
|
8
|
-
version?: string
|
|
9
|
-
dependencies?: Record<string, string>
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
type UpdateResult =
|
|
13
|
-
| { updated: true; name: string; current: string; latest: string }
|
|
14
|
-
| { updated: false; error: "remove_failed"; name: string; current: string; latest: string }
|
|
15
|
-
| { updated: false }
|
|
16
|
-
|
|
17
|
-
export async function checkAutoUpdate(meta: TuiPluginMeta, signal: AbortSignal): Promise<UpdateResult> {
|
|
18
|
-
if (meta.source !== "npm") return { updated: false }
|
|
19
|
-
|
|
20
|
-
const packageDir = await findPackageDir()
|
|
21
|
-
if (!packageDir) return { updated: false }
|
|
22
|
-
|
|
23
|
-
const pkg = await readPackageJson(join(packageDir, "package.json"))
|
|
24
|
-
if (!pkg.name || !pkg.version) return { updated: false }
|
|
25
|
-
|
|
26
|
-
const latest = await fetchLatestVersion(pkg.name, signal)
|
|
27
|
-
if (!latest || !isVersionNewer(latest, pkg.version)) return { updated: false }
|
|
28
|
-
|
|
29
|
-
const removeDir = await updateRemoveDir(packageDir, pkg.name)
|
|
30
|
-
try {
|
|
31
|
-
await rm(removeDir, { recursive: true, force: true })
|
|
32
|
-
} catch {
|
|
33
|
-
return { updated: false, error: "remove_failed", name: pkg.name, current: pkg.version, latest }
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
return { updated: true, name: pkg.name, current: pkg.version, latest }
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
async function findPackageDir() {
|
|
40
|
-
let dir = dirname(fileURLToPath(import.meta.url))
|
|
41
|
-
for (;;) {
|
|
42
|
-
const packagePath = join(dir, "package.json")
|
|
43
|
-
const pkg = await readPackageJson(packagePath)
|
|
44
|
-
if (pkg?.name === "opencode-vim") return dir
|
|
45
|
-
|
|
46
|
-
const parent = dirname(dir)
|
|
47
|
-
if (parent === dir) return undefined
|
|
48
|
-
dir = parent
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
async function updateRemoveDir(packageDir: string, name: string) {
|
|
53
|
-
const nodeModulesDir = dirname(packageDir)
|
|
54
|
-
if (basename(nodeModulesDir) !== "node_modules") return packageDir
|
|
55
|
-
|
|
56
|
-
const wrapperDir = dirname(nodeModulesDir)
|
|
57
|
-
const wrapperPkg = await readPackageJson(join(wrapperDir, "package.json"))
|
|
58
|
-
return wrapperPkg?.dependencies?.[name] ? wrapperDir : packageDir
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async function readPackageJson(path: string): Promise<PackageJson | undefined> {
|
|
62
|
-
try {
|
|
63
|
-
const file = Bun.file(path)
|
|
64
|
-
if (!(await file.exists())) return undefined
|
|
65
|
-
const data = await file.json()
|
|
66
|
-
return data && typeof data === "object" ? (data as PackageJson) : undefined
|
|
67
|
-
} catch {
|
|
68
|
-
return undefined
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async function fetchLatestVersion(name: string, signal: AbortSignal) {
|
|
73
|
-
try {
|
|
74
|
-
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`, { signal })
|
|
75
|
-
if (!response.ok) return undefined
|
|
76
|
-
const data = await response.json()
|
|
77
|
-
return data && typeof data === "object" && typeof data.version === "string" ? data.version : undefined
|
|
78
|
-
} catch {
|
|
79
|
-
return undefined
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function isVersionNewer(latest: string, current: string) {
|
|
84
|
-
const next = parseVersion(latest)
|
|
85
|
-
const prev = parseVersion(current)
|
|
86
|
-
if (!next || !prev) return false
|
|
87
|
-
|
|
88
|
-
for (let i = 0; i < 3; i++) {
|
|
89
|
-
if (next.parts[i] !== prev.parts[i]) return next.parts[i] > prev.parts[i]
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
if (!next.pre.length && prev.pre.length) return true
|
|
93
|
-
if (next.pre.length && !prev.pre.length) return false
|
|
94
|
-
|
|
95
|
-
for (let i = 0; i < Math.max(next.pre.length, prev.pre.length); i++) {
|
|
96
|
-
const a = next.pre[i]
|
|
97
|
-
const b = prev.pre[i]
|
|
98
|
-
if (a === undefined) return false
|
|
99
|
-
if (b === undefined) return true
|
|
100
|
-
if (a === b) continue
|
|
101
|
-
|
|
102
|
-
const aNumber = /^\d+$/.test(a) ? Number(a) : undefined
|
|
103
|
-
const bNumber = /^\d+$/.test(b) ? Number(b) : undefined
|
|
104
|
-
if (aNumber !== undefined && bNumber !== undefined) return aNumber > bNumber
|
|
105
|
-
if (aNumber !== undefined) return false
|
|
106
|
-
if (bNumber !== undefined) return true
|
|
107
|
-
return a > b
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
return false
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function parseVersion(version: string) {
|
|
114
|
-
const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.+)?$/)
|
|
115
|
-
if (!match) return undefined
|
|
116
|
-
return {
|
|
117
|
-
parts: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
118
|
-
pre: match[4]?.split(".") ?? [],
|
|
119
|
-
}
|
|
120
|
-
}
|