opencode-git-plugin 0.2.0 → 0.3.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/src/git/runner.ts DELETED
@@ -1,45 +0,0 @@
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
- }
@@ -1,47 +0,0 @@
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
- }
@@ -1,37 +0,0 @@
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
- }
@@ -1,55 +0,0 @@
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
- }
@@ -1 +0,0 @@
1
- export { GitChangesView } from "./view"
@@ -1,143 +0,0 @@
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
- }
@@ -1,96 +0,0 @@
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
- }
@@ -1,108 +0,0 @@
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
- }
@@ -1,29 +0,0 @@
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
- }