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.
- package/README.md +112 -0
- package/bin/stage +51 -0
- package/index.ts +22 -0
- package/package.json +46 -0
- package/src/ai-commit.ts +706 -0
- package/src/app.tsx +127 -0
- package/src/config-file.ts +40 -0
- package/src/config.ts +283 -0
- package/src/git-branch-name.ts +13 -0
- package/src/git-process.ts +25 -0
- package/src/git-status-parser.ts +103 -0
- package/src/git.ts +298 -0
- package/src/hooks/use-branch-dialog-controller.ts +188 -0
- package/src/hooks/use-commit-history-controller.ts +130 -0
- package/src/hooks/use-git-tui-controller.ts +310 -0
- package/src/hooks/use-git-tui-effects.ts +168 -0
- package/src/hooks/use-git-tui-keyboard.ts +293 -0
- package/src/ui/components/branch-dialog.tsx +107 -0
- package/src/ui/components/commit-dialog.tsx +68 -0
- package/src/ui/components/commit-history-dialog.tsx +87 -0
- package/src/ui/components/diff-workspace.tsx +108 -0
- package/src/ui/components/footer-bar.tsx +65 -0
- package/src/ui/components/shortcuts-dialog.tsx +53 -0
- package/src/ui/components/top-bar.tsx +36 -0
- package/src/ui/diff-style.ts +33 -0
- package/src/ui/theme.ts +151 -0
- package/src/ui/types.ts +21 -0
- package/src/ui/utils.ts +99 -0
|
@@ -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
|
+
}
|