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,156 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { createEffect, createMemo, Show } from "solid-js"
|
|
3
|
+
import { generateSyntax } from "@opencode/theme/tui"
|
|
4
|
+
import type { Context, PanelInput } from "@opencode/plugin/tui/context"
|
|
5
|
+
import { DIFF_PANEL_NAME, SETTINGS_STORE_KEY } from "../config"
|
|
6
|
+
import { notifyDiffClosed, useDiffStore } from "../state"
|
|
7
|
+
import { friendlyError } from "../ui"
|
|
8
|
+
import type { ScrollHandle } from "../types"
|
|
9
|
+
import { DiffBody } from "./body"
|
|
10
|
+
import { DiffHeader } from "./header"
|
|
11
|
+
import { registerDiffKeymap } from "./keymap"
|
|
12
|
+
import { createDiffSearch } from "./search"
|
|
13
|
+
|
|
14
|
+
export function DiffPanel(props: { ctx: Context; input: PanelInput }) {
|
|
15
|
+
const ctx = props.ctx
|
|
16
|
+
const input = () => props.input
|
|
17
|
+
const theme = ctx.theme
|
|
18
|
+
const [diffState, setDiffState] = useDiffStore(ctx)
|
|
19
|
+
const [settings, setSettings] = ctx.storage.store(SETTINGS_STORE_KEY, {
|
|
20
|
+
initial: { syntax: false },
|
|
21
|
+
})
|
|
22
|
+
const toggleSyntax = () => {
|
|
23
|
+
void setSettings((draft) => {
|
|
24
|
+
draft.syntax = !draft.syntax
|
|
25
|
+
}).catch(() => {})
|
|
26
|
+
}
|
|
27
|
+
const isSyntaxViewActive = () =>
|
|
28
|
+
settings.syntax && !diffState.truncated && diffState.query === "" && diffState.patch !== ""
|
|
29
|
+
const syntaxHighlightStyle = createMemo(() => (settings.syntax ? generateSyntax(theme) : undefined))
|
|
30
|
+
const search = createDiffSearch(diffState)
|
|
31
|
+
|
|
32
|
+
createEffect(() => {
|
|
33
|
+
if (input().name !== DIFF_PANEL_NAME && diffState.panelOpen) {
|
|
34
|
+
setDiffState((draft) => {
|
|
35
|
+
draft.panelOpen = false
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
createEffect(() => {
|
|
41
|
+
if (input().name === DIFF_PANEL_NAME && !input().focused) {
|
|
42
|
+
try {
|
|
43
|
+
input().focus()
|
|
44
|
+
} catch {
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
let scrollEl: ScrollHandle | undefined
|
|
50
|
+
|
|
51
|
+
const safeScrollTo = (y: number) => {
|
|
52
|
+
const el = scrollEl
|
|
53
|
+
if (!el) return
|
|
54
|
+
try {
|
|
55
|
+
el.scrollTo({ x: 0, y: Math.max(0, y) })
|
|
56
|
+
} catch {
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
createEffect(() => {
|
|
61
|
+
const line = search.currentMatchIndex()
|
|
62
|
+
if (line < 0 || input().name !== DIFF_PANEL_NAME || !scrollEl) return
|
|
63
|
+
const target = Math.max(0, line - 10)
|
|
64
|
+
setTimeout(() => safeScrollTo(target), 50)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const scrollSyntaxBy = (delta: number) => {
|
|
68
|
+
if (!scrollEl) return
|
|
69
|
+
safeScrollTo(scrollEl.scrollTop + delta)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const stepSearchMatch = (dir: 1 | -1) => {
|
|
73
|
+
const m = search.matchIndices()
|
|
74
|
+
if (m.length === 0) return
|
|
75
|
+
setDiffState((draft) => {
|
|
76
|
+
draft.matchCursor = (draft.matchCursor + dir + m.length) % m.length
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const clearSearch = () => {
|
|
81
|
+
setDiffState((draft) => {
|
|
82
|
+
draft.query = ""
|
|
83
|
+
draft.matchCursor = 0
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const closeDiff = () => {
|
|
88
|
+
setDiffState((draft) => {
|
|
89
|
+
draft.panelOpen = false
|
|
90
|
+
})
|
|
91
|
+
ctx.ui.panel.close()
|
|
92
|
+
notifyDiffClosed()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const promptSearchQuery = async () => {
|
|
96
|
+
try {
|
|
97
|
+
const q = await ctx.ui.dialog.prompt({
|
|
98
|
+
title: "Search in diff",
|
|
99
|
+
placeholder: "Type to highlight matches",
|
|
100
|
+
value: diffState.query,
|
|
101
|
+
})
|
|
102
|
+
if (q === undefined) return
|
|
103
|
+
setDiffState((draft) => {
|
|
104
|
+
draft.query = q
|
|
105
|
+
draft.matchCursor = 0
|
|
106
|
+
})
|
|
107
|
+
} catch (e) {
|
|
108
|
+
ctx.ui.toast.show({ message: `Search failed: ${friendlyError(e)}`, variant: "error" })
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
registerDiffKeymap(ctx, {
|
|
113
|
+
input,
|
|
114
|
+
searching: () => diffState.query.length > 0,
|
|
115
|
+
hasMatches: () => search.matchIndices().length > 0,
|
|
116
|
+
syntaxActive: isSyntaxViewActive,
|
|
117
|
+
search: () => void promptSearchQuery(),
|
|
118
|
+
stepMatch: stepSearchMatch,
|
|
119
|
+
clearSearch,
|
|
120
|
+
close: closeDiff,
|
|
121
|
+
toggleSyntax,
|
|
122
|
+
scrollBy: scrollSyntaxBy,
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<Show when={input().name === DIFF_PANEL_NAME}>
|
|
127
|
+
<box flexDirection="column" gap={1} width="100%" height="100%">
|
|
128
|
+
<DiffHeader
|
|
129
|
+
theme={theme}
|
|
130
|
+
width={input().width}
|
|
131
|
+
file={diffState.file}
|
|
132
|
+
additions={diffState.additions}
|
|
133
|
+
deletions={diffState.deletions}
|
|
134
|
+
query={diffState.query}
|
|
135
|
+
matchCursor={diffState.matchCursor}
|
|
136
|
+
matchCount={search.matchIndices().length}
|
|
137
|
+
syntax={settings.syntax}
|
|
138
|
+
onSearch={() => void promptSearchQuery()}
|
|
139
|
+
onToggleSyntax={toggleSyntax}
|
|
140
|
+
onClose={closeDiff}
|
|
141
|
+
/>
|
|
142
|
+
<DiffBody
|
|
143
|
+
theme={theme}
|
|
144
|
+
state={diffState}
|
|
145
|
+
rows={search.visibleRows()}
|
|
146
|
+
focused={input().focused}
|
|
147
|
+
syntaxActive={isSyntaxViewActive()}
|
|
148
|
+
syntaxStyle={syntaxHighlightStyle()}
|
|
149
|
+
onScrollRef={(el) => {
|
|
150
|
+
scrollEl = el
|
|
151
|
+
}}
|
|
152
|
+
/>
|
|
153
|
+
</box>
|
|
154
|
+
</Show>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createMemo } from "solid-js"
|
|
2
|
+
import type { DiffRow, DiffViewState } from "../types"
|
|
3
|
+
|
|
4
|
+
export function createDiffSearch(state: DiffViewState) {
|
|
5
|
+
const matchIndices = createMemo(() => {
|
|
6
|
+
const q = state.query.toLowerCase()
|
|
7
|
+
if (!q) return []
|
|
8
|
+
const out: number[] = []
|
|
9
|
+
state.lines.forEach((line, i) => {
|
|
10
|
+
if (line.text.toLowerCase().includes(q)) out.push(i)
|
|
11
|
+
})
|
|
12
|
+
return out
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const currentMatchIndex = createMemo(() => {
|
|
16
|
+
const m = matchIndices()
|
|
17
|
+
if (m.length === 0) return -1
|
|
18
|
+
return m[((state.matchCursor % m.length) + m.length) % m.length]
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
let previousRows: DiffRow[] = []
|
|
22
|
+
const visibleRows = createMemo(() => {
|
|
23
|
+
const q = state.query.toLowerCase()
|
|
24
|
+
const cur = currentMatchIndex()
|
|
25
|
+
const prev = previousRows
|
|
26
|
+
const next = state.lines.map((line, idx) => {
|
|
27
|
+
const hit = q !== "" && line.text.toLowerCase().includes(q)
|
|
28
|
+
const rowState = (idx === cur ? "current" : hit ? "hit" : "plain") as DiffRow["state"]
|
|
29
|
+
const p = prev[idx]
|
|
30
|
+
if (p && p.kind === line.kind && p.text === line.text && p.line === line.line && p.state === rowState) return p
|
|
31
|
+
return { kind: line.kind, text: line.text, line: line.line, state: rowState }
|
|
32
|
+
})
|
|
33
|
+
previousRows = next
|
|
34
|
+
return next
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
return { matchIndices, currentMatchIndex, visibleRows }
|
|
38
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { DiffLine } from "../types"
|
|
2
|
+
|
|
3
|
+
const DIFF_PREAMBLE_RE = /^(diff --git |index |new file mode|deleted file mode|similarity index|rename from|rename to|old mode|new mode)/
|
|
4
|
+
const HUNK_HEADER_RE = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@ ?(.*)$/
|
|
5
|
+
|
|
6
|
+
export function formatHunkHeader(raw: string): string {
|
|
7
|
+
const m = raw.match(HUNK_HEADER_RE)
|
|
8
|
+
if (!m) return raw
|
|
9
|
+
const [, newStart, trailingContext] = m
|
|
10
|
+
return trailingContext ? `⋯ ${trailingContext}` : `⋯ line ${newStart}`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function parseDiff(patch: string): DiffLine[] {
|
|
14
|
+
const out: DiffLine[] = []
|
|
15
|
+
let newLine = 0
|
|
16
|
+
for (const line of patch.split("\n")) {
|
|
17
|
+
if (line === "") continue
|
|
18
|
+
if (line.startsWith("@@")) {
|
|
19
|
+
const m = line.match(HUNK_HEADER_RE)
|
|
20
|
+
if (m) newLine = parseInt(m[1], 10)
|
|
21
|
+
out.push({ kind: "hunk", text: formatHunkHeader(line) })
|
|
22
|
+
} else if (line.startsWith("+++") || line.startsWith("---")) {
|
|
23
|
+
} else if (DIFF_PREAMBLE_RE.test(line)) {
|
|
24
|
+
} else if (line.startsWith("Binary files")) {
|
|
25
|
+
out.push({ kind: "meta", text: line })
|
|
26
|
+
} else if (line.startsWith("+")) {
|
|
27
|
+
out.push({ kind: "add", text: line.slice(1), line: newLine++ })
|
|
28
|
+
} else if (line.startsWith("-")) {
|
|
29
|
+
out.push({ kind: "del", text: line.slice(1) })
|
|
30
|
+
} else {
|
|
31
|
+
out.push({ kind: "context", text: line.startsWith(" ") ? line.slice(1) : line, line: newLine++ })
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return out
|
|
35
|
+
}
|
package/src/git/index.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function parseStagedPorcelain(porcelain: string): Set<string> {
|
|
2
|
+
const staged = new Set<string>()
|
|
3
|
+
for (const record of porcelain.split("\0")) {
|
|
4
|
+
if (record.length < 4) continue
|
|
5
|
+
const indexStatus = record[0]
|
|
6
|
+
if (indexStatus === " " || indexStatus === "?") continue
|
|
7
|
+
staged.add(record.slice(3))
|
|
8
|
+
}
|
|
9
|
+
return staged
|
|
10
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Context } from "@opencode/plugin/tui/context"
|
|
2
|
+
import { GIT_POLL_MS, GIT_TIMEOUT_MS } from "../config"
|
|
3
|
+
|
|
4
|
+
export function shellQuote(s: string): string {
|
|
5
|
+
return `'${s.replace(/'/g, `'\\''`)}'`
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type GitRunner = {
|
|
9
|
+
enqueue<T>(op: () => Promise<T>): Promise<T>
|
|
10
|
+
run(args: string): Promise<{ exit: number | undefined; output: string }>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createGitRunner(ctx: Context, directory: () => string): GitRunner {
|
|
14
|
+
let queue: Promise<void> = Promise.resolve()
|
|
15
|
+
const enqueue = <T,>(op: () => Promise<T>): Promise<T> => {
|
|
16
|
+
const run = queue.then(op)
|
|
17
|
+
queue = run.then(
|
|
18
|
+
() => undefined,
|
|
19
|
+
() => undefined,
|
|
20
|
+
)
|
|
21
|
+
return run
|
|
22
|
+
}
|
|
23
|
+
const run = async (args: string): Promise<{ exit: number | undefined; output: string }> => {
|
|
24
|
+
const location = { directory: directory() }
|
|
25
|
+
const created = await ctx.client.shell.create({
|
|
26
|
+
location,
|
|
27
|
+
command: `git ${args}`,
|
|
28
|
+
cwd: location.directory,
|
|
29
|
+
})
|
|
30
|
+
const id = created.data.id
|
|
31
|
+
try {
|
|
32
|
+
let final = await ctx.client.shell.get({ id, location })
|
|
33
|
+
for (let elapsed = 0; final.data.status === "running" && elapsed < GIT_TIMEOUT_MS; elapsed += GIT_POLL_MS) {
|
|
34
|
+
await new Promise((resolve) => setTimeout(resolve, GIT_POLL_MS))
|
|
35
|
+
final = await ctx.client.shell.get({ id, location })
|
|
36
|
+
}
|
|
37
|
+
if (final.data.status === "running") throw new Error(`Git command timed out after ${GIT_TIMEOUT_MS / 1000}s`)
|
|
38
|
+
const out = await ctx.client.shell.output({ id, location })
|
|
39
|
+
return { exit: final.data.exit, output: out.data.output }
|
|
40
|
+
} finally {
|
|
41
|
+
await ctx.client.shell.remove({ id, location }).catch(() => {})
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { enqueue, run }
|
|
45
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { For } from "solid-js"
|
|
3
|
+
import type { Context } from "@opencode/plugin/tui/context"
|
|
4
|
+
import { FILE_ROW_BADGE_WIDTH } from "../config"
|
|
5
|
+
import { filePathMaxWidth, statusBadge, truncatePath } from "../ui"
|
|
6
|
+
import type { ChangeFile } from "../types"
|
|
7
|
+
|
|
8
|
+
export function FileList(props: {
|
|
9
|
+
ctx: Context
|
|
10
|
+
files: ChangeFile[]
|
|
11
|
+
staged: Set<string>
|
|
12
|
+
selected: number
|
|
13
|
+
navMode: boolean
|
|
14
|
+
width: number
|
|
15
|
+
onSelect: (file: ChangeFile, index: number) => void
|
|
16
|
+
}) {
|
|
17
|
+
const ctx = props.ctx
|
|
18
|
+
return (
|
|
19
|
+
<box flexDirection="column" gap={0}>
|
|
20
|
+
<For each={props.files}>
|
|
21
|
+
{(f, i) => {
|
|
22
|
+
const b = statusBadge(f.status, ctx.theme)
|
|
23
|
+
const label = () => (props.staged.has(f.file) ? `[${b.label}]` : ` ${b.label} `)
|
|
24
|
+
return (
|
|
25
|
+
<box flexDirection="row" gap={1} onMouseUp={() => props.onSelect(f, i())}>
|
|
26
|
+
<text fg={b.color} width={FILE_ROW_BADGE_WIDTH}>
|
|
27
|
+
<b>{label()}</b>
|
|
28
|
+
</text>
|
|
29
|
+
<text
|
|
30
|
+
fg={props.navMode && i() === props.selected ? ctx.theme.text.base : ctx.theme.text.muted}
|
|
31
|
+
wrapMode="none"
|
|
32
|
+
>
|
|
33
|
+
{truncatePath(
|
|
34
|
+
f.file,
|
|
35
|
+
filePathMaxWidth(props.width, { additions: f.additions, deletions: f.deletions }),
|
|
36
|
+
)}
|
|
37
|
+
</text>
|
|
38
|
+
<box flexGrow={1} />
|
|
39
|
+
<text fg={ctx.theme.diff.text.added}>+{f.additions}</text>
|
|
40
|
+
<text fg={ctx.theme.diff.text.removed}>-{f.deletions}</text>
|
|
41
|
+
</box>
|
|
42
|
+
)
|
|
43
|
+
}}
|
|
44
|
+
</For>
|
|
45
|
+
</box>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { onCleanup } from "solid-js"
|
|
2
|
+
import type { Context } from "@opencode/plugin/tui/context"
|
|
3
|
+
import { DIFF_PANEL_NAME } from "../config"
|
|
4
|
+
import type { RendererFocusEvents, RenderNode } from "../types"
|
|
5
|
+
|
|
6
|
+
export function bindSidebarFocus(
|
|
7
|
+
ctx: Context,
|
|
8
|
+
opts: { container: () => RenderNode | undefined; active: () => boolean; exit: () => void },
|
|
9
|
+
): void {
|
|
10
|
+
const isInsideSidebar = (node: RenderNode | null | undefined) => {
|
|
11
|
+
const container = opts.container()
|
|
12
|
+
let current = node
|
|
13
|
+
while (current) {
|
|
14
|
+
if (current === container) return true
|
|
15
|
+
current = current.parent
|
|
16
|
+
}
|
|
17
|
+
return false
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const rendererEvents = ctx.renderer as unknown as RendererFocusEvents
|
|
21
|
+
const handleFocusedRenderable = (current: RenderNode | null) => {
|
|
22
|
+
if (!opts.active()) return
|
|
23
|
+
if (ctx.ui.panel.current()?.name === DIFF_PANEL_NAME) return
|
|
24
|
+
if (!isInsideSidebar(current)) opts.exit()
|
|
25
|
+
}
|
|
26
|
+
const handleFocusedEditor = (editor: unknown) => {
|
|
27
|
+
if (editor == null) return
|
|
28
|
+
if (opts.active()) opts.exit()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
rendererEvents.on("focused_renderable", handleFocusedRenderable)
|
|
32
|
+
rendererEvents.on("focused_editor", handleFocusedEditor)
|
|
33
|
+
onCleanup(() => {
|
|
34
|
+
rendererEvents.off("focused_renderable", handleFocusedRenderable)
|
|
35
|
+
rendererEvents.off("focused_editor", handleFocusedEditor)
|
|
36
|
+
})
|
|
37
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { Show } from "solid-js"
|
|
3
|
+
import type { Context } from "@opencode/plugin/tui/context"
|
|
4
|
+
|
|
5
|
+
export function SidebarHeader(props: {
|
|
6
|
+
ctx: Context
|
|
7
|
+
branch: string
|
|
8
|
+
fileCount: number
|
|
9
|
+
ready: boolean
|
|
10
|
+
additions: number
|
|
11
|
+
deletions: number
|
|
12
|
+
navMode: boolean
|
|
13
|
+
stageMode: boolean
|
|
14
|
+
hasStaged: boolean
|
|
15
|
+
onBranchClick: () => void
|
|
16
|
+
onCommit: () => void
|
|
17
|
+
}) {
|
|
18
|
+
const ctx = props.ctx
|
|
19
|
+
return (
|
|
20
|
+
<box flexDirection="column" gap={0}>
|
|
21
|
+
<box flexDirection="row" gap={1}>
|
|
22
|
+
<text fg={ctx.theme.text.base}>
|
|
23
|
+
<b>Git Panel</b>
|
|
24
|
+
</text>
|
|
25
|
+
<box flexGrow={1} />
|
|
26
|
+
<Show when={props.branch && props.fileCount > 0}>
|
|
27
|
+
<text fg={ctx.theme.text.muted} onMouseUp={() => props.onBranchClick()}>
|
|
28
|
+
▶ {props.branch}{" "}
|
|
29
|
+
</text>
|
|
30
|
+
<text fg={ctx.theme.text.base}>({props.fileCount})</text>
|
|
31
|
+
</Show>
|
|
32
|
+
<Show when={props.ready && props.fileCount > 0}>
|
|
33
|
+
<text fg={ctx.theme.diff.text.added}>+{props.additions}</text>
|
|
34
|
+
<text fg={ctx.theme.diff.text.removed}>-{props.deletions}</text>
|
|
35
|
+
</Show>
|
|
36
|
+
</box>
|
|
37
|
+
<Show when={props.ready && props.fileCount > 0 && props.navMode}>
|
|
38
|
+
<box flexDirection="row" gap={1}>
|
|
39
|
+
<box flexGrow={1} />
|
|
40
|
+
<Show
|
|
41
|
+
when={props.stageMode}
|
|
42
|
+
fallback={<text fg={ctx.theme.text.muted}>[← → : stage/unstage]</text>}
|
|
43
|
+
>
|
|
44
|
+
<text fg={ctx.theme.text.feedback.warning.base}>[click : stage/unstage]</text>
|
|
45
|
+
</Show>
|
|
46
|
+
<Show when={props.hasStaged}>
|
|
47
|
+
<text fg={ctx.theme.text.feedback.success.base} onMouseUp={() => props.onCommit()}>
|
|
48
|
+
[c : Commit]
|
|
49
|
+
</text>
|
|
50
|
+
</Show>
|
|
51
|
+
</box>
|
|
52
|
+
</Show>
|
|
53
|
+
</box>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { GitChangesView } from "./view"
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { Accessor } from "solid-js"
|
|
2
|
+
import type { Context, KeymapCommand } from "@opencode/plugin/tui/context"
|
|
3
|
+
import { LIST_NAV_MODE } from "../config"
|
|
4
|
+
import type { ChangeFile, FileBinds } from "../types"
|
|
5
|
+
import type { ListNavigation } from "./list-navigation"
|
|
6
|
+
|
|
7
|
+
export type FileActions = {
|
|
8
|
+
next(): void
|
|
9
|
+
prev(): void
|
|
10
|
+
open(): void
|
|
11
|
+
setStaged(stage: boolean): void
|
|
12
|
+
commit(): void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function buildFileCommands(actions: FileActions, binds: FileBinds, palette: boolean, hasFiles: boolean): KeymapCommand[] {
|
|
16
|
+
return [
|
|
17
|
+
{
|
|
18
|
+
id: "git-panel.next",
|
|
19
|
+
title: "Git Panel: Next File",
|
|
20
|
+
group: "Git Panel",
|
|
21
|
+
bind: binds.next,
|
|
22
|
+
enabled: hasFiles,
|
|
23
|
+
run: () => actions.next(),
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: "git-panel.prev",
|
|
27
|
+
title: "Git Panel: Previous File",
|
|
28
|
+
group: "Git Panel",
|
|
29
|
+
bind: binds.prev,
|
|
30
|
+
enabled: hasFiles,
|
|
31
|
+
run: () => actions.prev(),
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "git-panel.open",
|
|
35
|
+
title: "Git Panel: Open Diff",
|
|
36
|
+
group: "Git Panel",
|
|
37
|
+
bind: binds.open,
|
|
38
|
+
palette: palette ? true : undefined,
|
|
39
|
+
enabled: hasFiles,
|
|
40
|
+
run: () => actions.open(),
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: "git-panel.stage",
|
|
44
|
+
title: "Git Panel: Stage File",
|
|
45
|
+
group: "Git Panel",
|
|
46
|
+
bind: binds.stage,
|
|
47
|
+
palette: palette ? true : undefined,
|
|
48
|
+
enabled: hasFiles,
|
|
49
|
+
run: () => actions.setStaged(true),
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "git-panel.unstage",
|
|
53
|
+
title: "Git Panel: Unstage File",
|
|
54
|
+
group: "Git Panel",
|
|
55
|
+
bind: binds.unstage,
|
|
56
|
+
palette: palette ? true : undefined,
|
|
57
|
+
enabled: hasFiles,
|
|
58
|
+
run: () => actions.setStaged(false),
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: "git-panel.commit",
|
|
62
|
+
title: "Git Panel: Commit Staged",
|
|
63
|
+
group: "Git Panel",
|
|
64
|
+
bind: binds.commit,
|
|
65
|
+
palette: palette ? true : undefined,
|
|
66
|
+
run: () => actions.commit(),
|
|
67
|
+
},
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function registerSidebarKeymaps(
|
|
72
|
+
ctx: Context,
|
|
73
|
+
deps: {
|
|
74
|
+
files: Accessor<ChangeFile[]>
|
|
75
|
+
nav: ListNavigation
|
|
76
|
+
diffOpen: () => boolean
|
|
77
|
+
actions: FileActions
|
|
78
|
+
requestRefresh: () => void
|
|
79
|
+
},
|
|
80
|
+
): void {
|
|
81
|
+
const { files, nav, actions } = deps
|
|
82
|
+
|
|
83
|
+
ctx.keymap.layer(() => {
|
|
84
|
+
const hasFiles = files().length > 0
|
|
85
|
+
return {
|
|
86
|
+
mode: "global",
|
|
87
|
+
bindings: ["git-panel.focus-list", "git-panel.next", "git-panel.prev", "git-panel.open", "git-panel.stage", "git-panel.unstage", "git-panel.commit"],
|
|
88
|
+
commands: [
|
|
89
|
+
{
|
|
90
|
+
id: "git-panel.refresh",
|
|
91
|
+
title: "Git Panel: Refresh",
|
|
92
|
+
group: "Git Panel",
|
|
93
|
+
palette: true,
|
|
94
|
+
run: () => deps.requestRefresh(),
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: "git-panel.focus-list",
|
|
98
|
+
title: "Git Panel: Focus List",
|
|
99
|
+
group: "Git Panel",
|
|
100
|
+
bind: "<leader>G",
|
|
101
|
+
palette: true,
|
|
102
|
+
enabled: hasFiles,
|
|
103
|
+
run: () => nav.enter(),
|
|
104
|
+
},
|
|
105
|
+
...buildFileCommands(actions, {}, true, hasFiles),
|
|
106
|
+
],
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
ctx.keymap.layer(() => {
|
|
111
|
+
const hasFiles = files().length > 0
|
|
112
|
+
const canExitNav = nav.navMode() && !deps.diffOpen() && !nav.stageMode()
|
|
113
|
+
return {
|
|
114
|
+
mode: LIST_NAV_MODE,
|
|
115
|
+
priority: 10,
|
|
116
|
+
bindings: ["git-panel.next", "git-panel.prev", "git-panel.open", "git-panel.stage", "git-panel.unstage", "git-panel.commit", "git-panel.exit-stage-mode", "git-panel.unfocus-list"],
|
|
117
|
+
commands: [
|
|
118
|
+
...buildFileCommands(
|
|
119
|
+
actions,
|
|
120
|
+
{ next: "down", prev: "up", open: "return", stage: "right", unstage: "left", commit: "c" },
|
|
121
|
+
false,
|
|
122
|
+
hasFiles,
|
|
123
|
+
),
|
|
124
|
+
{
|
|
125
|
+
id: "git-panel.exit-stage-mode",
|
|
126
|
+
title: "Git Panel: Exit Stage Mode",
|
|
127
|
+
group: "Git Panel",
|
|
128
|
+
bind: "escape",
|
|
129
|
+
enabled: nav.stageMode(),
|
|
130
|
+
run: () => nav.setStageMode(false),
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
id: "git-panel.unfocus-list",
|
|
134
|
+
title: "Git Panel: Exit List Navigation",
|
|
135
|
+
group: "Git Panel",
|
|
136
|
+
bind: "escape",
|
|
137
|
+
enabled: canExitNav,
|
|
138
|
+
run: () => nav.exit(),
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createSignal, onCleanup, type Accessor } from "solid-js"
|
|
2
|
+
import type { Context } from "@opencode/plugin/tui/context"
|
|
3
|
+
import { LIST_NAV_MODE } from "../config"
|
|
4
|
+
import type { ChangeFile } from "../types"
|
|
5
|
+
|
|
6
|
+
export type ListNavigation = ReturnType<typeof createListNavigation>
|
|
7
|
+
|
|
8
|
+
export function createListNavigation(ctx: Context, files: Accessor<ChangeFile[]>) {
|
|
9
|
+
const [navMode, setNavMode] = createSignal(false)
|
|
10
|
+
const [stageMode, setStageMode] = createSignal(false)
|
|
11
|
+
const [selected, setSelected] = createSignal(0)
|
|
12
|
+
|
|
13
|
+
let popListNavMode: (() => void) | null = null
|
|
14
|
+
|
|
15
|
+
const pushListNavMode = () => {
|
|
16
|
+
popListNavMode = ctx.keymap.mode.push(LIST_NAV_MODE)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const enter = () => {
|
|
20
|
+
if (popListNavMode) {
|
|
21
|
+
try {
|
|
22
|
+
if (ctx.keymap.mode.current() === LIST_NAV_MODE) return
|
|
23
|
+
} catch {
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
popListNavMode = null
|
|
27
|
+
}
|
|
28
|
+
pushListNavMode()
|
|
29
|
+
setNavMode(true)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const exit = () => {
|
|
33
|
+
try {
|
|
34
|
+
if (ctx.keymap.mode.current() === LIST_NAV_MODE) popListNavMode?.()
|
|
35
|
+
} catch {
|
|
36
|
+
}
|
|
37
|
+
popListNavMode = null
|
|
38
|
+
setNavMode(false)
|
|
39
|
+
setStageMode(false)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ensure = () => {
|
|
43
|
+
if (!navMode()) return
|
|
44
|
+
try {
|
|
45
|
+
if (ctx.keymap.mode.current() !== LIST_NAV_MODE) {
|
|
46
|
+
popListNavMode = null
|
|
47
|
+
pushListNavMode()
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const selectNext = () => {
|
|
54
|
+
const count = files().length
|
|
55
|
+
if (count > 0) setSelected((prev) => (prev + 1) % count)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const selectPrevious = () => {
|
|
59
|
+
const count = files().length
|
|
60
|
+
if (count > 0) setSelected((prev) => (prev - 1 + count) % count)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const clampSelection = (count: number) => {
|
|
64
|
+
setSelected((prev) => Math.min(prev, Math.max(0, count - 1)))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const selectedFile = (): ChangeFile | undefined => {
|
|
68
|
+
const list = files()
|
|
69
|
+
return list[Math.min(selected(), list.length - 1)]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const toggleStageMode = () => {
|
|
73
|
+
if (files().length === 0) return
|
|
74
|
+
const next = !stageMode()
|
|
75
|
+
setStageMode(next)
|
|
76
|
+
if (next) enter()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
onCleanup(exit)
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
navMode,
|
|
83
|
+
stageMode,
|
|
84
|
+
setStageMode,
|
|
85
|
+
selected,
|
|
86
|
+
setSelected,
|
|
87
|
+
enter,
|
|
88
|
+
exit,
|
|
89
|
+
ensure,
|
|
90
|
+
selectNext,
|
|
91
|
+
selectPrevious,
|
|
92
|
+
clampSelection,
|
|
93
|
+
selectedFile,
|
|
94
|
+
toggleStageMode,
|
|
95
|
+
}
|
|
96
|
+
}
|