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.
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./diff-parse"
2
+ export * from "./porcelain"
3
+ export * from "./runner"
@@ -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
+ }