opencode-git-plugin 0.2.0
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/LICENSE +21 -0
- package/README.md +147 -0
- package/docs/diff.png +0 -0
- package/docs/interactive.png +0 -0
- package/docs/panel.png +0 -0
- package/package.json +62 -0
- package/src/config/constants.ts +16 -0
- package/src/config/index.ts +2 -0
- package/src/config/options.ts +8 -0
- package/src/diff/body.tsx +90 -0
- package/src/diff/header.tsx +77 -0
- package/src/diff/index.ts +2 -0
- package/src/diff/keymap.ts +113 -0
- package/src/diff/open-diff.ts +57 -0
- package/src/diff/panel.tsx +156 -0
- package/src/diff/search.tsx +38 -0
- package/src/git/diff-parse.ts +35 -0
- package/src/git/index.ts +3 -0
- package/src/git/porcelain.ts +10 -0
- package/src/git/runner.ts +45 -0
- package/src/sidebar/file-list.tsx +47 -0
- package/src/sidebar/focus.tsx +37 -0
- package/src/sidebar/header.tsx +55 -0
- package/src/sidebar/index.ts +1 -0
- package/src/sidebar/keymap.ts +143 -0
- package/src/sidebar/list-navigation.tsx +96 -0
- package/src/sidebar/staging.tsx +108 -0
- package/src/sidebar/status.tsx +29 -0
- package/src/sidebar/view.tsx +159 -0
- package/src/sidebar/working-copy.tsx +81 -0
- package/src/state/diff-store.ts +34 -0
- package/src/state/index.ts +1 -0
- package/src/tui.tsx +25 -0
- package/src/types.ts +63 -0
- package/src/ui/format.ts +67 -0
- package/src/ui/index.ts +2 -0
- package/src/ui/syntax.ts +61 -0
- package/tui.ts +1 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { createMemo } from "solid-js"
|
|
2
|
+
import type { Context } from "@opencode/plugin/tui/context"
|
|
3
|
+
import { STAGED_STORE_KEY } from "../config"
|
|
4
|
+
import { parseStagedPorcelain, shellQuote, type GitRunner } from "../git"
|
|
5
|
+
import { friendlyError } from "../ui"
|
|
6
|
+
|
|
7
|
+
export type Staging = ReturnType<typeof createStaging>
|
|
8
|
+
|
|
9
|
+
export function createStaging(
|
|
10
|
+
ctx: Context,
|
|
11
|
+
git: GitRunner,
|
|
12
|
+
location: () => { directory: string },
|
|
13
|
+
refreshWorkingCopy: () => Promise<void>,
|
|
14
|
+
) {
|
|
15
|
+
const [stagedMem, setStagedMem] = ctx.storage.memory(STAGED_STORE_KEY, {
|
|
16
|
+
initial: { directory: "", files: [] as string[] },
|
|
17
|
+
})
|
|
18
|
+
const staged = createMemo(() => new Set(stagedMem.files))
|
|
19
|
+
|
|
20
|
+
if (stagedMem.directory !== location().directory) {
|
|
21
|
+
setStagedMem((draft) => {
|
|
22
|
+
draft.directory = location().directory
|
|
23
|
+
draft.files = []
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let refreshTask: Promise<void> | undefined
|
|
28
|
+
|
|
29
|
+
const fetchStatuses = async () => {
|
|
30
|
+
try {
|
|
31
|
+
const res = await git.run("status --porcelain=v1 -z -uall --no-renames")
|
|
32
|
+
if (res.exit === 0) {
|
|
33
|
+
const files = [...parseStagedPorcelain(res.output)]
|
|
34
|
+
setStagedMem((draft) => {
|
|
35
|
+
draft.files = files
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const scheduleRefresh = async () => {
|
|
43
|
+
if (refreshTask) return refreshTask
|
|
44
|
+
refreshTask = git.enqueue(fetchStatuses).finally(() => {
|
|
45
|
+
refreshTask = undefined
|
|
46
|
+
})
|
|
47
|
+
return refreshTask
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const applyChange = (file: string, stage: boolean) => {
|
|
51
|
+
setStagedMem((draft) => {
|
|
52
|
+
const next = new Set(draft.files)
|
|
53
|
+
if (stage) next.add(file)
|
|
54
|
+
else next.delete(file)
|
|
55
|
+
draft.files = [...next]
|
|
56
|
+
})
|
|
57
|
+
void git.enqueue(async () => {
|
|
58
|
+
try {
|
|
59
|
+
let res = await git.run(stage ? `add -- ${shellQuote(file)}` : `restore --staged -- ${shellQuote(file)}`)
|
|
60
|
+
if (!stage && res.exit !== 0 && /could not resolve HEAD|ambiguous argument ['"]?HEAD|invalid object name ['"]?HEAD/i.test(res.output)) {
|
|
61
|
+
res = await git.run(`rm --cached -- ${shellQuote(file)}`)
|
|
62
|
+
}
|
|
63
|
+
if (res.exit !== 0) {
|
|
64
|
+
ctx.ui.toast.show({
|
|
65
|
+
message: `git ${stage ? "add" : "restore"} failed: ${res.output.trim().slice(0, 200)}`,
|
|
66
|
+
variant: "error",
|
|
67
|
+
})
|
|
68
|
+
await fetchStatuses()
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
await fetchStatuses()
|
|
72
|
+
} catch (e) {
|
|
73
|
+
ctx.ui.toast.show({ message: `git failed: ${friendlyError(e)}`, variant: "error" })
|
|
74
|
+
await fetchStatuses()
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const toggle = (file: string) => {
|
|
81
|
+
applyChange(file, !staged().has(file))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const commit = async () => {
|
|
85
|
+
await scheduleRefresh()
|
|
86
|
+
if (staged().size === 0) {
|
|
87
|
+
ctx.ui.toast.show({ message: "Nothing staged — stage files with → first", variant: "warning" })
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
const message = await ctx.ui.dialog.prompt({ title: "Commit message", placeholder: "Describe the change" })
|
|
91
|
+
if (!message) return
|
|
92
|
+
await git.enqueue(async () => {
|
|
93
|
+
try {
|
|
94
|
+
const res = await git.run(`commit -m ${shellQuote(message)}`)
|
|
95
|
+
if (res.exit !== 0) {
|
|
96
|
+
ctx.ui.toast.show({ message: `git commit failed: ${res.output.trim().slice(0, 200)}`, variant: "error" })
|
|
97
|
+
} else {
|
|
98
|
+
ctx.ui.toast.show({ message: "Committed", variant: "success" })
|
|
99
|
+
}
|
|
100
|
+
} catch (e) {
|
|
101
|
+
ctx.ui.toast.show({ message: `git commit failed: ${friendlyError(e)}`, variant: "error" })
|
|
102
|
+
}
|
|
103
|
+
await refreshWorkingCopy()
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { staged, scheduleRefresh, applyChange, toggle, commit }
|
|
108
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { Show } from "solid-js"
|
|
3
|
+
import type { Context } from "@opencode/plugin/tui/context"
|
|
4
|
+
|
|
5
|
+
export function SidebarStatus(props: {
|
|
6
|
+
ctx: Context
|
|
7
|
+
loading: boolean
|
|
8
|
+
error: string | undefined
|
|
9
|
+
hasFiles: boolean
|
|
10
|
+
branch: string
|
|
11
|
+
}) {
|
|
12
|
+
const ctx = props.ctx
|
|
13
|
+
return (
|
|
14
|
+
<>
|
|
15
|
+
<Show when={props.loading}>
|
|
16
|
+
<text fg={ctx.theme.text.muted}>Loading…</text>
|
|
17
|
+
</Show>
|
|
18
|
+
<Show when={!props.loading && props.error}>
|
|
19
|
+
<text fg={ctx.theme.text.feedback.error.base}>{props.error}</text>
|
|
20
|
+
</Show>
|
|
21
|
+
<Show when={!props.loading && !props.error && !props.hasFiles && !props.branch}>
|
|
22
|
+
<text fg={ctx.theme.text.muted}>Empty repo — no commits yet</text>
|
|
23
|
+
</Show>
|
|
24
|
+
<Show when={!props.loading && !props.error && !props.hasFiles && !!props.branch}>
|
|
25
|
+
<text fg={ctx.theme.text.muted}>Working tree clean</text>
|
|
26
|
+
</Show>
|
|
27
|
+
</>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { createEffect, createSignal, onCleanup, Show } from "solid-js"
|
|
3
|
+
import type { Context } from "@opencode/plugin/tui/context"
|
|
4
|
+
import { createDiffOpener } from "../diff"
|
|
5
|
+
import { createGitRunner } from "../git"
|
|
6
|
+
import { setDiffClosedHandler, useDiffStore } from "../state"
|
|
7
|
+
import type { ChangeFile, LayoutResizeEmitter, RenderNode } from "../types"
|
|
8
|
+
import { FileList } from "./file-list"
|
|
9
|
+
import { bindSidebarFocus } from "./focus"
|
|
10
|
+
import { SidebarHeader } from "./header"
|
|
11
|
+
import { registerSidebarKeymaps } from "./keymap"
|
|
12
|
+
import { createListNavigation } from "./list-navigation"
|
|
13
|
+
import { createStaging } from "./staging"
|
|
14
|
+
import { SidebarStatus } from "./status"
|
|
15
|
+
import { createWorkingCopy } from "./working-copy"
|
|
16
|
+
|
|
17
|
+
export function GitChangesView(props: { ctx: Context }) {
|
|
18
|
+
const { ctx } = props
|
|
19
|
+
const [sidebarWidth, setSidebarWidth] = createSignal(46)
|
|
20
|
+
const [diffView] = useDiffStore(ctx)
|
|
21
|
+
let sidebarNode: RenderNode | undefined
|
|
22
|
+
|
|
23
|
+
const currentLocation = () => {
|
|
24
|
+
const loc = ctx.location ?? ctx.data.location.default()
|
|
25
|
+
return { directory: loc.directory }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const git = createGitRunner(ctx, () => currentLocation().directory)
|
|
29
|
+
const openDiff = createDiffOpener(ctx, currentLocation)
|
|
30
|
+
const workingCopy = createWorkingCopy(ctx, currentLocation, (count) => {
|
|
31
|
+
nav.clampSelection(count)
|
|
32
|
+
void staging.scheduleRefresh()
|
|
33
|
+
})
|
|
34
|
+
const staging = createStaging(ctx, git, currentLocation, workingCopy.refresh)
|
|
35
|
+
const nav = createListNavigation(ctx, workingCopy.files)
|
|
36
|
+
|
|
37
|
+
const openSelectedDiff = () => {
|
|
38
|
+
const f = nav.selectedFile()
|
|
39
|
+
if (f) void openDiff(f).catch(() => {})
|
|
40
|
+
}
|
|
41
|
+
const setSelectedFileStaged = (stage: boolean) => {
|
|
42
|
+
const f = nav.selectedFile()
|
|
43
|
+
if (f) staging.applyChange(f.file, stage)
|
|
44
|
+
}
|
|
45
|
+
const selectFile = (f: ChangeFile, index: number) => {
|
|
46
|
+
nav.setSelected(index)
|
|
47
|
+
if (nav.stageMode()) staging.toggle(f.file)
|
|
48
|
+
else void openDiff(f).catch(() => {})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
void workingCopy.refresh().catch(() => {})
|
|
52
|
+
void staging.scheduleRefresh().catch(() => {})
|
|
53
|
+
|
|
54
|
+
registerSidebarKeymaps(ctx, {
|
|
55
|
+
files: workingCopy.files,
|
|
56
|
+
nav,
|
|
57
|
+
diffOpen: () => diffView.panelOpen,
|
|
58
|
+
requestRefresh: workingCopy.requestRefresh,
|
|
59
|
+
actions: {
|
|
60
|
+
next: nav.selectNext,
|
|
61
|
+
prev: nav.selectPrevious,
|
|
62
|
+
open: openSelectedDiff,
|
|
63
|
+
setStaged: setSelectedFileStaged,
|
|
64
|
+
commit: () => void staging.commit(),
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const restoreListFocus = () => {
|
|
69
|
+
if (!nav.navMode() || diffView.panelOpen) return
|
|
70
|
+
nav.ensure()
|
|
71
|
+
}
|
|
72
|
+
setDiffClosedHandler(() => {
|
|
73
|
+
if (!nav.navMode() && workingCopy.files().length > 0) nav.enter()
|
|
74
|
+
restoreListFocus()
|
|
75
|
+
})
|
|
76
|
+
createEffect((wasOpen: boolean | undefined) => {
|
|
77
|
+
const open = diffView.panelOpen
|
|
78
|
+
if (wasOpen === true && open === false && nav.navMode()) {
|
|
79
|
+
setTimeout(() => restoreListFocus(), 0)
|
|
80
|
+
}
|
|
81
|
+
return open
|
|
82
|
+
})
|
|
83
|
+
bindSidebarFocus(ctx, {
|
|
84
|
+
container: () => sidebarNode,
|
|
85
|
+
active: () => nav.navMode() && !diffView.panelOpen,
|
|
86
|
+
exit: nav.exit,
|
|
87
|
+
})
|
|
88
|
+
onCleanup(() => setDiffClosedHandler(undefined))
|
|
89
|
+
|
|
90
|
+
const setSidebarRef = (el: RenderNode) => {
|
|
91
|
+
sidebarNode = el
|
|
92
|
+
setSidebarWidth(el.width)
|
|
93
|
+
const onNodeResize = () => setSidebarWidth(el.width)
|
|
94
|
+
;(el as unknown as LayoutResizeEmitter).on("resize", onNodeResize)
|
|
95
|
+
onCleanup(() => (el as unknown as LayoutResizeEmitter).off("resize", onNodeResize))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const ready = () => !workingCopy.loading() && !workingCopy.error()
|
|
99
|
+
const hasFiles = () => workingCopy.files().length > 0
|
|
100
|
+
|
|
101
|
+
const sidebarBody = (
|
|
102
|
+
<>
|
|
103
|
+
<SidebarHeader
|
|
104
|
+
ctx={ctx}
|
|
105
|
+
branch={workingCopy.branch()}
|
|
106
|
+
fileCount={workingCopy.files().length}
|
|
107
|
+
ready={ready()}
|
|
108
|
+
additions={workingCopy.additions()}
|
|
109
|
+
deletions={workingCopy.deletions()}
|
|
110
|
+
navMode={nav.navMode()}
|
|
111
|
+
stageMode={nav.stageMode()}
|
|
112
|
+
hasStaged={staging.staged().size > 0}
|
|
113
|
+
onBranchClick={nav.toggleStageMode}
|
|
114
|
+
onCommit={() => void staging.commit()}
|
|
115
|
+
/>
|
|
116
|
+
<SidebarStatus
|
|
117
|
+
ctx={ctx}
|
|
118
|
+
loading={workingCopy.loading()}
|
|
119
|
+
error={workingCopy.error()}
|
|
120
|
+
hasFiles={hasFiles()}
|
|
121
|
+
branch={workingCopy.branch()}
|
|
122
|
+
/>
|
|
123
|
+
<Show when={ready() && hasFiles()}>
|
|
124
|
+
<FileList
|
|
125
|
+
ctx={ctx}
|
|
126
|
+
files={workingCopy.files()}
|
|
127
|
+
staged={staging.staged()}
|
|
128
|
+
selected={nav.selected()}
|
|
129
|
+
navMode={nav.navMode()}
|
|
130
|
+
width={sidebarWidth()}
|
|
131
|
+
onSelect={selectFile}
|
|
132
|
+
/>
|
|
133
|
+
</Show>
|
|
134
|
+
</>
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<Show
|
|
139
|
+
when={nav.navMode()}
|
|
140
|
+
fallback={
|
|
141
|
+
<box flexDirection="column" gap={1} ref={setSidebarRef}>
|
|
142
|
+
{sidebarBody}
|
|
143
|
+
</box>
|
|
144
|
+
}
|
|
145
|
+
>
|
|
146
|
+
<box
|
|
147
|
+
flexDirection="column"
|
|
148
|
+
gap={1}
|
|
149
|
+
border={["top", "bottom"]}
|
|
150
|
+
borderStyle="rounded"
|
|
151
|
+
borderColor={ctx.theme.text.feedback.warning.base}
|
|
152
|
+
focusedBorderColor={ctx.theme.text.feedback.warning.base}
|
|
153
|
+
ref={setSidebarRef}
|
|
154
|
+
>
|
|
155
|
+
{sidebarBody}
|
|
156
|
+
</box>
|
|
157
|
+
</Show>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
|
2
|
+
import type { Context } from "@opencode/plugin/tui/context"
|
|
3
|
+
import { EVENT_REFRESH_DEBOUNCE_MS, REFRESH_STORE_KEY, intervalMs } from "../config"
|
|
4
|
+
import { friendlyError } from "../ui"
|
|
5
|
+
import type { ChangeFile } from "../types"
|
|
6
|
+
|
|
7
|
+
export type WorkingCopy = ReturnType<typeof createWorkingCopy>
|
|
8
|
+
|
|
9
|
+
export function createWorkingCopy(
|
|
10
|
+
ctx: Context,
|
|
11
|
+
location: () => { directory: string },
|
|
12
|
+
onRefreshed: (count: number) => void,
|
|
13
|
+
) {
|
|
14
|
+
const [files, setFiles] = createSignal<ChangeFile[]>([])
|
|
15
|
+
const [branch, setBranch] = createSignal<string>("")
|
|
16
|
+
const [loading, setLoading] = createSignal(true)
|
|
17
|
+
const [error, setError] = createSignal<string | undefined>(undefined)
|
|
18
|
+
|
|
19
|
+
let isRefreshing = false
|
|
20
|
+
let lastEventRefreshAt = 0
|
|
21
|
+
|
|
22
|
+
const refresh = async () => {
|
|
23
|
+
if (isRefreshing) return
|
|
24
|
+
isRefreshing = true
|
|
25
|
+
try {
|
|
26
|
+
const loc = location()
|
|
27
|
+
const [statusRes, infoRes] = await Promise.all([
|
|
28
|
+
ctx.client.vcs.status({ location: loc }),
|
|
29
|
+
ctx.client.vcs.get({ location: loc }),
|
|
30
|
+
])
|
|
31
|
+
const prevByFile = new Map(files().map((f) => [f.file, f]))
|
|
32
|
+
setFiles(
|
|
33
|
+
statusRes.data.map((d) => {
|
|
34
|
+
const p = prevByFile.get(d.file)
|
|
35
|
+
return p && p.additions === d.additions && p.deletions === d.deletions && p.status === d.status
|
|
36
|
+
? p
|
|
37
|
+
: { ...d }
|
|
38
|
+
}),
|
|
39
|
+
)
|
|
40
|
+
setBranch(infoRes.data.branch.current ?? "")
|
|
41
|
+
setError(undefined)
|
|
42
|
+
onRefreshed(statusRes.data.length)
|
|
43
|
+
} catch (e) {
|
|
44
|
+
setError(friendlyError(e))
|
|
45
|
+
} finally {
|
|
46
|
+
setLoading(false)
|
|
47
|
+
isRefreshing = false
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const additions = createMemo(() => files().reduce((n, f) => n + f.additions, 0))
|
|
52
|
+
const deletions = createMemo(() => files().reduce((n, f) => n + f.deletions, 0))
|
|
53
|
+
|
|
54
|
+
const [refreshSignal, bumpRefresh] = ctx.storage.memory(REFRESH_STORE_KEY, { initial: { nonce: 0 } })
|
|
55
|
+
const requestRefresh = () =>
|
|
56
|
+
bumpRefresh((draft) => {
|
|
57
|
+
draft.nonce += 1
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
createEffect((prev: number | undefined) => {
|
|
61
|
+
const nonce = refreshSignal.nonce
|
|
62
|
+
if (prev !== undefined) void refresh().catch(() => {})
|
|
63
|
+
return nonce
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const timer = setInterval(() => void refresh().catch(() => {}), intervalMs(ctx))
|
|
67
|
+
const stopListening = ctx.data.listen(({ details }: { details: { type: string } }) => {
|
|
68
|
+
const t = details.type
|
|
69
|
+
if (!(t.includes("session") || t.includes("vcs") || t.includes("server.connected") || t.includes("location"))) return
|
|
70
|
+
const now = Date.now()
|
|
71
|
+
if (now - lastEventRefreshAt < EVENT_REFRESH_DEBOUNCE_MS) return
|
|
72
|
+
lastEventRefreshAt = now
|
|
73
|
+
void refresh().catch(() => {})
|
|
74
|
+
})
|
|
75
|
+
onCleanup(() => {
|
|
76
|
+
clearInterval(timer)
|
|
77
|
+
stopListening()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
return { files, branch, loading, error, additions, deletions, refresh, requestRefresh }
|
|
81
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Context } from "@opencode/plugin/tui/context"
|
|
2
|
+
import { DIFF_STORE_KEY } from "../config"
|
|
3
|
+
import type { DiffViewState } from "../types"
|
|
4
|
+
|
|
5
|
+
export const initialDiffState: DiffViewState = {
|
|
6
|
+
file: "",
|
|
7
|
+
additions: 0,
|
|
8
|
+
deletions: 0,
|
|
9
|
+
lines: [],
|
|
10
|
+
patch: "",
|
|
11
|
+
message: "",
|
|
12
|
+
truncated: false,
|
|
13
|
+
loading: false,
|
|
14
|
+
query: "",
|
|
15
|
+
matchCursor: 0,
|
|
16
|
+
panelOpen: false,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function useDiffStore(ctx: Context) {
|
|
20
|
+
return ctx.storage.memory(DIFF_STORE_KEY, { initial: initialDiffState })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let diffClosedHandler: (() => void) | undefined
|
|
24
|
+
|
|
25
|
+
export function setDiffClosedHandler(handler: (() => void) | undefined): void {
|
|
26
|
+
diffClosedHandler = handler
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function notifyDiffClosed(): void {
|
|
30
|
+
try {
|
|
31
|
+
diffClosedHandler?.()
|
|
32
|
+
} catch {
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./diff-store"
|
package/src/tui.tsx
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { Plugin } from "@opencode/plugin/tui"
|
|
3
|
+
import { DiffPanel } from "./diff"
|
|
4
|
+
import { GitChangesView } from "./sidebar"
|
|
5
|
+
|
|
6
|
+
export { parseDiff, parseStagedPorcelain } from "./git"
|
|
7
|
+
|
|
8
|
+
export default Plugin.define({
|
|
9
|
+
id: "git-panel.cli",
|
|
10
|
+
setup(ctx) {
|
|
11
|
+
const unregisterSidebar = ctx.ui.slot({
|
|
12
|
+
append: "sidebar.content",
|
|
13
|
+
render: () => <GitChangesView ctx={ctx} />,
|
|
14
|
+
})
|
|
15
|
+
const unregisterPanel = ctx.ui.slot({
|
|
16
|
+
append: "session.panel",
|
|
17
|
+
render: (input) => <DiffPanel ctx={ctx} input={input} />,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
return () => {
|
|
21
|
+
unregisterSidebar()
|
|
22
|
+
unregisterPanel()
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
})
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Context } from "@opencode/plugin/tui/context"
|
|
2
|
+
|
|
3
|
+
export type RenderNode = {
|
|
4
|
+
parent?: RenderNode | null
|
|
5
|
+
width: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type ScrollHandle = {
|
|
9
|
+
scrollTop: number
|
|
10
|
+
scrollTo(options: { x: number; y: number }): void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type ChangeFile = {
|
|
14
|
+
file: string
|
|
15
|
+
additions: number
|
|
16
|
+
deletions: number
|
|
17
|
+
status: "added" | "deleted" | "modified"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type DiffLineKind = "hunk" | "add" | "del" | "context" | "meta"
|
|
21
|
+
export type DiffLine = { kind: DiffLineKind; text: string; line?: number }
|
|
22
|
+
|
|
23
|
+
export type DiffViewState = {
|
|
24
|
+
file: string
|
|
25
|
+
additions: number
|
|
26
|
+
deletions: number
|
|
27
|
+
lines: DiffLine[]
|
|
28
|
+
patch: string
|
|
29
|
+
message: string
|
|
30
|
+
truncated: boolean
|
|
31
|
+
loading: boolean
|
|
32
|
+
query: string
|
|
33
|
+
matchCursor: number
|
|
34
|
+
panelOpen: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type DiffRow = {
|
|
38
|
+
kind: DiffLineKind
|
|
39
|
+
text: string
|
|
40
|
+
line: number | undefined
|
|
41
|
+
state: "current" | "hit" | "plain"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type FileBinds = {
|
|
45
|
+
next?: string
|
|
46
|
+
prev?: string
|
|
47
|
+
open?: string
|
|
48
|
+
stage?: string
|
|
49
|
+
unstage?: string
|
|
50
|
+
commit?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type RendererFocusEvents = {
|
|
54
|
+
on(event: "focused_renderable" | "focused_editor", listener: (...args: never[]) => void): void
|
|
55
|
+
off(event: "focused_renderable" | "focused_editor", listener: (...args: never[]) => void): void
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type LayoutResizeEmitter = {
|
|
59
|
+
on(event: "resize", listener: () => void): void
|
|
60
|
+
off(event: "resize", listener: () => void): void
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type Theme = Context["theme"]
|
package/src/ui/format.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Context } from "@opencode/plugin/tui/context"
|
|
2
|
+
import type { ChangeFile, DiffLineKind } from "../types"
|
|
3
|
+
import { FILE_ROW_BADGE_WIDTH } from "../config"
|
|
4
|
+
|
|
5
|
+
type Theme = Context["theme"]
|
|
6
|
+
type ThemeColor = Theme["text"]["base"]
|
|
7
|
+
|
|
8
|
+
const ELLIPSIS = "..."
|
|
9
|
+
const runtime = globalThis as { Bun?: { stringWidth?: (text: string) => number } }
|
|
10
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
|
11
|
+
|
|
12
|
+
export function displayWidth(text: string): number {
|
|
13
|
+
return runtime.Bun?.stringWidth?.(text) ?? [...text].length
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function truncatePath(path: string, max: number): string {
|
|
17
|
+
if (displayWidth(path) <= max) return path
|
|
18
|
+
const budget = max - ELLIPSIS.length
|
|
19
|
+
if (budget <= 0) return ELLIPSIS.slice(0, Math.max(0, max))
|
|
20
|
+
const parts = Array.from(graphemes.segment(path), (s) => s.segment)
|
|
21
|
+
let width = 0
|
|
22
|
+
let start = parts.length
|
|
23
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
24
|
+
const w = displayWidth(parts[i])
|
|
25
|
+
if (width + w > budget) break
|
|
26
|
+
width += w
|
|
27
|
+
start = i
|
|
28
|
+
}
|
|
29
|
+
return ELLIPSIS + parts.slice(start).join("")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function maxTextWidth(available: number, reserved: number): number {
|
|
33
|
+
return Math.max(8, available - reserved)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function filePathMaxWidth(rowWidth: number, row: { additions: number; deletions: number }): number {
|
|
37
|
+
const gaps = 4
|
|
38
|
+
const fixed = FILE_ROW_BADGE_WIDTH + displayWidth(`+${row.additions}`) + displayWidth(`-${row.deletions}`)
|
|
39
|
+
return maxTextWidth(rowWidth, fixed + gaps)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function friendlyError(e: unknown): string {
|
|
43
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
44
|
+
if (/not a git repository|not a repository|no git|vcs|worktree/i.test(msg)) return "Not a git repository"
|
|
45
|
+
return msg.length > 120 ? `${msg.slice(0, 117)}…` : msg
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function statusBadge(status: ChangeFile["status"], theme: Theme): { label: string; color: ThemeColor } {
|
|
49
|
+
if (status === "added") return { label: "A", color: theme.text.feedback.success.base }
|
|
50
|
+
if (status === "deleted") return { label: "D", color: theme.text.feedback.error.base }
|
|
51
|
+
return { label: "M", color: theme.text.feedback.warning.base }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function diffLineStyle(theme: Theme, kind: DiffLineKind): { fg: ThemeColor; bg: ThemeColor | undefined } {
|
|
55
|
+
if (kind === "hunk") return { fg: theme.diff.text.hunkHeader, bg: undefined }
|
|
56
|
+
if (kind === "add") return { fg: theme.diff.text.added, bg: theme.diff.background.added }
|
|
57
|
+
if (kind === "del") return { fg: theme.diff.text.removed, bg: theme.diff.background.removed }
|
|
58
|
+
if (kind === "meta") return { fg: theme.text.muted, bg: undefined }
|
|
59
|
+
return { fg: theme.diff.text.context, bg: theme.diff.background.context }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function diffLinePrefix(kind: DiffLineKind): string {
|
|
63
|
+
if (kind === "add") return "+"
|
|
64
|
+
if (kind === "del") return "-"
|
|
65
|
+
if (kind === "context") return " "
|
|
66
|
+
return ""
|
|
67
|
+
}
|
package/src/ui/index.ts
ADDED
package/src/ui/syntax.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const SYNTAX_BY_LANGUAGE: Array<[string, string[]]> = [
|
|
2
|
+
["typescript", ["ts", "mts", "cts", "tsx", "mtsx", "js", "mjs", "cjs", "jsx"]],
|
|
3
|
+
["python", ["py"]],
|
|
4
|
+
["rust", ["rs"]],
|
|
5
|
+
["go", ["go"]],
|
|
6
|
+
["java", ["java"]],
|
|
7
|
+
["kotlin", ["kt", "kts"]],
|
|
8
|
+
["ruby", ["rb"]],
|
|
9
|
+
["php", ["php"]],
|
|
10
|
+
["swift", ["swift"]],
|
|
11
|
+
["c", ["c", "h"]],
|
|
12
|
+
["cpp", ["cpp", "cc", "hpp"]],
|
|
13
|
+
["csharp", ["cs"]],
|
|
14
|
+
["css", ["css"]],
|
|
15
|
+
["scss", ["scss"]],
|
|
16
|
+
["less", ["less"]],
|
|
17
|
+
["html", ["html", "htm"]],
|
|
18
|
+
["xml", ["xml"]],
|
|
19
|
+
["vue", ["vue"]],
|
|
20
|
+
["svelte", ["svelte"]],
|
|
21
|
+
["astro", ["astro"]],
|
|
22
|
+
["json", ["json"]],
|
|
23
|
+
["yaml", ["yaml", "yml"]],
|
|
24
|
+
["toml", ["toml"]],
|
|
25
|
+
["ini", ["ini"]],
|
|
26
|
+
["markdown", ["md", "markdown"]],
|
|
27
|
+
["bash", ["sh", "bash", "zsh"]],
|
|
28
|
+
["sql", ["sql"]],
|
|
29
|
+
["lua", ["lua"]],
|
|
30
|
+
["r", ["r"]],
|
|
31
|
+
["julia", ["jl"]],
|
|
32
|
+
["haskell", ["hs"]],
|
|
33
|
+
["ocaml", ["ml", "mli"]],
|
|
34
|
+
["elixir", ["ex", "exs"]],
|
|
35
|
+
["erlang", ["erl"]],
|
|
36
|
+
["clojure", ["clj", "cljs"]],
|
|
37
|
+
["scala", ["scala"]],
|
|
38
|
+
["groovy", ["groovy"]],
|
|
39
|
+
["dart", ["dart"]],
|
|
40
|
+
["zig", ["zig"]],
|
|
41
|
+
["nix", ["nix"]],
|
|
42
|
+
["terraform", ["tf"]],
|
|
43
|
+
["hcl", ["hcl"]],
|
|
44
|
+
["dockerfile", ["dockerfile"]],
|
|
45
|
+
["latex", ["tex"]],
|
|
46
|
+
["diff", ["diff", "patch"]],
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
const LANGUAGE_BY_EXTENSION: Record<string, string> = Object.fromEntries(
|
|
50
|
+
SYNTAX_BY_LANGUAGE.flatMap(([lang, exts]) => exts.map((e) => [`.${e}`, lang])),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
export function syntaxFiletype(file: string): string | undefined {
|
|
54
|
+
const dot = file.lastIndexOf(".")
|
|
55
|
+
if (dot < 0) {
|
|
56
|
+
const base = file.split("/").pop()?.toLowerCase() ?? ""
|
|
57
|
+
if (base === "dockerfile" || base === "makefile") return base
|
|
58
|
+
return undefined
|
|
59
|
+
}
|
|
60
|
+
return LANGUAGE_BY_EXTENSION[file.slice(dot).toLowerCase()]
|
|
61
|
+
}
|
package/tui.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./src/tui"
|