stage-tui 1.0.7

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,310 @@
1
+ import type { InputRenderable, TextareaRenderable } from "@opentui/core"
2
+ import { useCallback, useMemo, useRef, useState } from "react"
3
+
4
+ import { generateAiCommitSummary } from "../ai-commit"
5
+ import type { StageConfig } from "../config"
6
+ import { GitClient, type RepoSnapshot } from "../git"
7
+ import { type FocusTarget, type TopAction } from "../ui/types"
8
+ import { buildFileRow, inferFiletype } from "../ui/utils"
9
+ import { useBranchDialogController } from "./use-branch-dialog-controller"
10
+ import { useCommitHistoryController } from "./use-commit-history-controller"
11
+ import {
12
+ useFileDiffLoader,
13
+ useGitInitialization,
14
+ useGitSnapshotPolling,
15
+ useSnapshotSelectionSync,
16
+ } from "./use-git-tui-effects"
17
+ import { useGitTuiKeyboard } from "./use-git-tui-keyboard"
18
+ type RendererLike = {
19
+ destroy: () => void
20
+ }
21
+
22
+ export function useGitTuiController(renderer: RendererLike, config: StageConfig) {
23
+ const branchNameRef = useRef<InputRenderable>(null)
24
+ const summaryRef = useRef<InputRenderable>(null)
25
+ const descriptionRef = useRef<TextareaRenderable>(null)
26
+
27
+ const [git, setGit] = useState<GitClient | null>(null)
28
+ const [snapshot, setSnapshot] = useState<RepoSnapshot | null>(null)
29
+ const [fatalError, setFatalError] = useState<string | null>(null)
30
+
31
+ const [focus, setFocus] = useState<FocusTarget>("files")
32
+ const [fileIndex, setFileIndex] = useState(0)
33
+ const [excludedPaths, setExcludedPaths] = useState<Set<string>>(new Set())
34
+
35
+ const [summary, setSummary] = useState("")
36
+ const [descriptionRenderKey, setDescriptionRenderKey] = useState(0)
37
+ const [diffText, setDiffText] = useState("")
38
+ const [diffMessage, setDiffMessage] = useState<string | null>("No file selected")
39
+
40
+ const [commitDialogOpen, setCommitDialogOpen] = useState(false)
41
+ const [shortcutsDialogOpen, setShortcutsDialogOpen] = useState(false)
42
+ const [busy, setBusy] = useState<string | null>(null)
43
+ const [statusMessage, setStatusMessage] = useState("Initializing...")
44
+
45
+ const isBusy = busy !== null
46
+ const selectedFile = snapshot?.files[fileIndex] ?? null
47
+ const selectedFilePath = selectedFile?.path ?? null
48
+ const diffFiletype = inferFiletype(selectedFile?.path)
49
+
50
+ const fileRows = useMemo(
51
+ () => (snapshot?.files ?? []).map((file) => buildFileRow(file, excludedPaths)),
52
+ [excludedPaths, snapshot],
53
+ )
54
+ const gitOptions = useMemo(
55
+ () => ({
56
+ hideWhitespaceChanges: config.ui.hideWhitespaceChanges,
57
+ historyLimit: config.history.limit,
58
+ autoStageOnCommit: config.git.autoStageOnCommit,
59
+ }),
60
+ [config.git.autoStageOnCommit, config.history.limit, config.ui.hideWhitespaceChanges],
61
+ )
62
+
63
+ const refreshSnapshot = useCallback(async (): Promise<void> => {
64
+ if (!git) return
65
+ const next = await git.snapshot()
66
+ setSnapshot(next)
67
+ }, [git])
68
+
69
+ const getIncludedPaths = useCallback(
70
+ () =>
71
+ (snapshot?.files ?? [])
72
+ .map((file) => file.path)
73
+ .filter((path) => !excludedPaths.has(path)),
74
+ [excludedPaths, snapshot],
75
+ )
76
+
77
+ const runTask = useCallback(
78
+ async (label: string, task: () => Promise<void>): Promise<boolean> => {
79
+ if (isBusy) return false
80
+ setBusy(label)
81
+ setStatusMessage(`${label}...`)
82
+ try {
83
+ await task()
84
+ setStatusMessage(`${label} complete`)
85
+ return true
86
+ } catch (error) {
87
+ const message = error instanceof Error ? error.message : String(error)
88
+ setStatusMessage(`Error: ${message}`)
89
+ return false
90
+ } finally {
91
+ setBusy(null)
92
+ }
93
+ },
94
+ [isBusy],
95
+ )
96
+
97
+ const openCommitDialog = useCallback(() => {
98
+ if (!git) {
99
+ setStatusMessage("Repository not ready.")
100
+ return
101
+ }
102
+ if ((snapshot?.files.length ?? 0) === 0) {
103
+ setStatusMessage("No working changes to commit.")
104
+ return
105
+ }
106
+ if (config.ai.enabled) {
107
+ const includedPaths = getIncludedPaths()
108
+ if (includedPaths.length === 0) {
109
+ setStatusMessage("No files selected for commit.")
110
+ return
111
+ }
112
+
113
+ void (async () => {
114
+ const succeeded = await runTask("AI COMMIT", async () => {
115
+ const summary = await generateAiCommitSummary({
116
+ git,
117
+ files: snapshot?.files ?? [],
118
+ selectedPaths: includedPaths,
119
+ aiConfig: config.ai,
120
+ })
121
+ await git.commit(summary, "", Array.from(excludedPaths), includedPaths)
122
+ setStatusMessage(`Committed: ${summary}`)
123
+ setSummary("")
124
+ setDescriptionRenderKey((value) => value + 1)
125
+ setCommitDialogOpen(false)
126
+ setFocus("files")
127
+ setExcludedPaths(new Set())
128
+ await refreshSnapshot()
129
+ })
130
+
131
+ if (!succeeded) {
132
+ setCommitDialogOpen(true)
133
+ setFocus("commit-summary")
134
+ }
135
+ })()
136
+ return
137
+ }
138
+ setCommitDialogOpen(true)
139
+ setFocus("commit-summary")
140
+ }, [config.ai, excludedPaths, getIncludedPaths, git, refreshSnapshot, runTask, snapshot])
141
+
142
+ const runTopAction = useCallback(
143
+ async (action: TopAction): Promise<void> => {
144
+ if (!git) return
145
+
146
+ if (action === "commit") {
147
+ openCommitDialog()
148
+ return
149
+ }
150
+
151
+ await runTask(action.toUpperCase(), async () => {
152
+ if (action === "refresh") {
153
+ await refreshSnapshot()
154
+ return
155
+ }
156
+ if (action === "fetch") {
157
+ await git.fetch()
158
+ await refreshSnapshot()
159
+ return
160
+ }
161
+ if (action === "pull") {
162
+ await git.pull()
163
+ await refreshSnapshot()
164
+ return
165
+ }
166
+ await git.push()
167
+ await refreshSnapshot()
168
+ })
169
+ },
170
+ [git, openCommitDialog, refreshSnapshot, runTask],
171
+ )
172
+
173
+ const commitChanges = useCallback(async (): Promise<void> => {
174
+ if (!git) return
175
+ const effectiveSummary = summaryRef.current?.value ?? summary
176
+ const description = descriptionRef.current?.plainText ?? ""
177
+ const includedPaths = getIncludedPaths()
178
+
179
+ await runTask("COMMIT", async () => {
180
+ await git.commit(effectiveSummary, description, Array.from(excludedPaths), includedPaths)
181
+ setSummary("")
182
+ setDescriptionRenderKey((value) => value + 1)
183
+ setCommitDialogOpen(false)
184
+ setFocus("files")
185
+ setExcludedPaths(new Set())
186
+ await refreshSnapshot()
187
+ })
188
+ }, [excludedPaths, getIncludedPaths, git, refreshSnapshot, runTask, summary])
189
+
190
+ const branchDialog = useBranchDialogController({
191
+ git,
192
+ snapshot,
193
+ refreshSnapshot,
194
+ runTask,
195
+ setFocus,
196
+ branchNameRef,
197
+ })
198
+ const commitHistory = useCommitHistoryController({
199
+ git,
200
+ refreshSnapshot,
201
+ runTask,
202
+ setFocus,
203
+ })
204
+
205
+ useGitInitialization({ setGit, setFatalError, setStatusMessage, gitOptions })
206
+ useGitSnapshotPolling({ git, refreshSnapshot, setStatusMessage })
207
+ useSnapshotSelectionSync({
208
+ snapshot,
209
+ fileIndex,
210
+ setFileIndex,
211
+ setExcludedPaths,
212
+ autoStageOnCommit: config.git.autoStageOnCommit,
213
+ })
214
+ useFileDiffLoader({ git, selectedFile, setDiffText, setDiffMessage })
215
+
216
+ const toggleSelectedFileInCommit = useCallback(() => {
217
+ if (!selectedFilePath) return
218
+ setExcludedPaths((current) => {
219
+ const next = new Set(current)
220
+ if (next.has(selectedFilePath)) {
221
+ next.delete(selectedFilePath)
222
+ } else {
223
+ next.add(selectedFilePath)
224
+ }
225
+ return next
226
+ })
227
+ }, [selectedFilePath])
228
+
229
+ useGitTuiKeyboard({
230
+ renderer,
231
+ commitDialogOpen,
232
+ branchDialogOpen: branchDialog.branchDialogOpen,
233
+ branchDialogMode: branchDialog.branchDialogMode,
234
+ historyDialogOpen: commitHistory.historyDialogOpen,
235
+ historyDialogMode: commitHistory.historyMode,
236
+ shortcutsDialogOpen,
237
+ setCommitDialogOpen,
238
+ setFocus,
239
+ focus,
240
+ fileCount: snapshot?.files.length ?? 0,
241
+ moveToPreviousFile: () => setFileIndex((current) => getPreviousIndex(current, snapshot?.files.length ?? 0)),
242
+ moveToNextFile: () => setFileIndex((current) => getNextIndex(current, snapshot?.files.length ?? 0)),
243
+ openBranchDialog: branchDialog.openBranchDialog,
244
+ closeBranchDialog: branchDialog.closeBranchDialog,
245
+ showBranchDialogList: branchDialog.showBranchDialogList,
246
+ submitBranchSelection: branchDialog.submitBranchSelection,
247
+ submitBranchStrategy: branchDialog.submitBranchStrategy,
248
+ openHistoryDialog: commitHistory.openHistoryDialog,
249
+ closeHistoryDialog: commitHistory.closeHistoryDialog,
250
+ backToHistoryCommitList: commitHistory.backToCommitList,
251
+ submitHistoryCommitSelection: commitHistory.submitHistoryCommitSelection,
252
+ submitHistoryAction: commitHistory.submitHistoryAction,
253
+ commitChanges,
254
+ createBranchAndCheckout: branchDialog.createBranchAndCheckout,
255
+ openCommitDialog,
256
+ openShortcutsDialog: () => setShortcutsDialogOpen(true),
257
+ closeShortcutsDialog: () => setShortcutsDialogOpen(false),
258
+ runTopAction,
259
+ toggleSelectedFileInCommit,
260
+ })
261
+
262
+ const tracking = snapshot
263
+ ? {
264
+ loading: false,
265
+ upstream: snapshot.upstream,
266
+ ahead: snapshot.ahead,
267
+ behind: snapshot.behind,
268
+ }
269
+ : {
270
+ loading: true,
271
+ upstream: null,
272
+ ahead: 0,
273
+ behind: 0,
274
+ }
275
+
276
+ return {
277
+ summaryRef,
278
+ descriptionRef,
279
+ focus,
280
+ currentBranch: snapshot?.branch ?? "...",
281
+ branchNameRef,
282
+ ...branchDialog,
283
+ ...commitHistory,
284
+ fileRows,
285
+ fileIndex,
286
+ selectedFilePath,
287
+ diffText,
288
+ diffMessage,
289
+ diffFiletype,
290
+ commitDialogOpen,
291
+ shortcutsDialogOpen,
292
+ summary,
293
+ descriptionRenderKey,
294
+ statusMessage,
295
+ fatalError,
296
+ isBusy,
297
+ tracking,
298
+ onSummaryInput: setSummary,
299
+ }
300
+ }
301
+
302
+ function getNextIndex(current: number, total: number): number {
303
+ if (total <= 0) return 0
304
+ return (current + 1) % total
305
+ }
306
+
307
+ function getPreviousIndex(current: number, total: number): number {
308
+ if (total <= 0) return 0
309
+ return (current - 1 + total) % total
310
+ }
@@ -0,0 +1,168 @@
1
+ import { useEffect, useRef, type Dispatch, type SetStateAction } from "react"
2
+
3
+ import { GitClient, type ChangedFile, type GitClientOptions, type RepoSnapshot } from "../git"
4
+
5
+ type SetGit = Dispatch<SetStateAction<GitClient | null>>
6
+ type SetString = Dispatch<SetStateAction<string>>
7
+ type SetNullableString = Dispatch<SetStateAction<string | null>>
8
+ type SetNumber = Dispatch<SetStateAction<number>>
9
+ type SetExcludedPaths = Dispatch<SetStateAction<Set<string>>>
10
+
11
+ type UseGitInitializationParams = {
12
+ setGit: SetGit
13
+ setFatalError: SetNullableString
14
+ setStatusMessage: SetString
15
+ gitOptions: GitClientOptions
16
+ }
17
+
18
+ export function useGitInitialization({ setGit, setFatalError, setStatusMessage, gitOptions }: UseGitInitializationParams) {
19
+ useEffect(() => {
20
+ let cancelled = false
21
+
22
+ async function init(): Promise<void> {
23
+ try {
24
+ const client = await GitClient.create(process.cwd(), gitOptions)
25
+ if (cancelled) return
26
+ setGit(client)
27
+ setFatalError(null)
28
+ setStatusMessage("Ready")
29
+ } catch (error) {
30
+ if (cancelled) return
31
+ const message = error instanceof Error ? error.message : String(error)
32
+ setFatalError(message)
33
+ setStatusMessage(`Error: ${message}`)
34
+ }
35
+ }
36
+
37
+ void init()
38
+ return () => {
39
+ cancelled = true
40
+ }
41
+ }, [gitOptions, setFatalError, setGit, setStatusMessage])
42
+ }
43
+
44
+ type UseGitSnapshotPollingParams = {
45
+ git: GitClient | null
46
+ refreshSnapshot: () => Promise<void>
47
+ setStatusMessage: SetString
48
+ }
49
+
50
+ export function useGitSnapshotPolling({ git, refreshSnapshot, setStatusMessage }: UseGitSnapshotPollingParams) {
51
+ useEffect(() => {
52
+ if (!git) return
53
+ let active = true
54
+
55
+ const sync = async () => {
56
+ if (!active) return
57
+ try {
58
+ await refreshSnapshot()
59
+ } catch (error) {
60
+ const message = error instanceof Error ? error.message : String(error)
61
+ setStatusMessage(`Error: ${message}`)
62
+ }
63
+ }
64
+
65
+ void sync()
66
+ const timer = setInterval(() => void sync(), 4000)
67
+ return () => {
68
+ active = false
69
+ clearInterval(timer)
70
+ }
71
+ }, [git, refreshSnapshot, setStatusMessage])
72
+ }
73
+
74
+ type UseSnapshotSelectionSyncParams = {
75
+ snapshot: RepoSnapshot | null
76
+ fileIndex: number
77
+ setFileIndex: SetNumber
78
+ setExcludedPaths: SetExcludedPaths
79
+ autoStageOnCommit: boolean
80
+ }
81
+
82
+ export function useSnapshotSelectionSync({
83
+ snapshot,
84
+ fileIndex,
85
+ setFileIndex,
86
+ setExcludedPaths,
87
+ autoStageOnCommit,
88
+ }: UseSnapshotSelectionSyncParams) {
89
+ const previousPathsRef = useRef<Set<string>>(new Set())
90
+
91
+ useEffect(() => {
92
+ if (!snapshot) return
93
+
94
+ const knownPaths = new Set(snapshot.files.map((file) => file.path))
95
+ const previousPaths = previousPathsRef.current
96
+ setExcludedPaths((current) => {
97
+ const next = new Set(Array.from(current).filter((path) => knownPaths.has(path)))
98
+
99
+ if (!autoStageOnCommit) {
100
+ for (const path of knownPaths) {
101
+ if (!previousPaths.has(path)) {
102
+ next.add(path)
103
+ }
104
+ }
105
+ }
106
+
107
+ if (next.size === current.size && Array.from(next).every((path) => current.has(path))) {
108
+ return current
109
+ }
110
+ return next
111
+ })
112
+
113
+ const nextFileIndex = Math.min(fileIndex, Math.max(snapshot.files.length - 1, 0))
114
+ if (nextFileIndex !== fileIndex) setFileIndex(nextFileIndex)
115
+ previousPathsRef.current = knownPaths
116
+ }, [autoStageOnCommit, fileIndex, setExcludedPaths, setFileIndex, snapshot])
117
+ }
118
+
119
+ type UseFileDiffLoaderParams = {
120
+ git: GitClient | null
121
+ selectedFile: ChangedFile | null
122
+ setDiffText: SetString
123
+ setDiffMessage: SetNullableString
124
+ }
125
+
126
+ export function useFileDiffLoader({ git, selectedFile, setDiffText, setDiffMessage }: UseFileDiffLoaderParams) {
127
+ const previousSelectedPathRef = useRef<string | null>(null)
128
+
129
+ useEffect(() => {
130
+ if (!git || !selectedFile) {
131
+ previousSelectedPathRef.current = null
132
+ setDiffText("")
133
+ setDiffMessage("No file selected")
134
+ return
135
+ }
136
+
137
+ const selectedPath = selectedFile.path
138
+ const pathChanged = previousSelectedPathRef.current !== selectedPath
139
+ previousSelectedPathRef.current = selectedPath
140
+
141
+ let cancelled = false
142
+ if (pathChanged) {
143
+ setDiffText("")
144
+ setDiffMessage(`Loading diff: ${selectedPath}`)
145
+ }
146
+
147
+ const loadDiff = async () => {
148
+ try {
149
+ const nextDiff = await git.diffForFile(selectedPath)
150
+ if (cancelled) return
151
+ setDiffText(nextDiff)
152
+ setDiffMessage(nextDiff.trim() ? null : `No diff output for ${selectedPath}`)
153
+ } catch (error) {
154
+ if (cancelled) return
155
+ const message = error instanceof Error ? error.message : String(error)
156
+ if (pathChanged) {
157
+ setDiffText("")
158
+ }
159
+ setDiffMessage(`Failed to load diff: ${message}`)
160
+ }
161
+ }
162
+
163
+ void loadDiff()
164
+ return () => {
165
+ cancelled = true
166
+ }
167
+ }, [git, selectedFile, setDiffMessage, setDiffText])
168
+ }