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/src/git.ts ADDED
@@ -0,0 +1,298 @@
1
+ import { runGitRaw, type GitCommandResult } from "./git-process"
2
+ import { normalizeBranchName } from "./git-branch-name"
3
+ import { parseBranchLine, parseChangedFiles } from "./git-status-parser"
4
+ export type ChangedFile = {
5
+ path: string
6
+ indexStatus: string
7
+ worktreeStatus: string
8
+ staged: boolean
9
+ unstaged: boolean
10
+ untracked: boolean
11
+ statusLabel: string
12
+ }
13
+
14
+ export type RepoSnapshot = {
15
+ root: string
16
+ branch: string
17
+ upstream: string | null
18
+ ahead: number
19
+ behind: number
20
+ branches: string[]
21
+ files: ChangedFile[]
22
+ }
23
+
24
+ export type CommitHistoryEntry = {
25
+ hash: string
26
+ shortHash: string
27
+ subject: string
28
+ relativeDate: string
29
+ author: string
30
+ }
31
+ export type GitClientOptions = {
32
+ hideWhitespaceChanges: boolean
33
+ historyLimit: number
34
+ autoStageOnCommit: boolean
35
+ }
36
+ const DEFAULT_GIT_CLIENT_OPTIONS: GitClientOptions = {
37
+ hideWhitespaceChanges: true,
38
+ historyLimit: 200,
39
+ autoStageOnCommit: true,
40
+ }
41
+
42
+ export class GitClient {
43
+ private constructor(
44
+ private readonly root: string,
45
+ private readonly options: GitClientOptions,
46
+ ) {}
47
+
48
+ static async create(cwd: string, options?: Partial<GitClientOptions>): Promise<GitClient> {
49
+ const rootResult = await runGitRaw(cwd, ["rev-parse", "--show-toplevel"])
50
+ if (rootResult.code !== 0) {
51
+ throw new Error(rootResult.stderr || "Current directory is not a git repository.")
52
+ }
53
+ const root = rootResult.stdout.trim()
54
+ if (!root) {
55
+ throw new Error("Failed to resolve git repository root.")
56
+ }
57
+ return new GitClient(root, {
58
+ hideWhitespaceChanges: options?.hideWhitespaceChanges ?? DEFAULT_GIT_CLIENT_OPTIONS.hideWhitespaceChanges,
59
+ historyLimit: options?.historyLimit ?? DEFAULT_GIT_CLIENT_OPTIONS.historyLimit,
60
+ autoStageOnCommit: options?.autoStageOnCommit ?? DEFAULT_GIT_CLIENT_OPTIONS.autoStageOnCommit,
61
+ })
62
+ }
63
+ async snapshot(): Promise<RepoSnapshot> {
64
+ const [statusResult, branchesResult] = await Promise.all([
65
+ this.runGit(["status", "--porcelain=v1", "--branch", "--untracked-files=all"]),
66
+ this.runGit(["for-each-ref", "--format=%(refname:short)", "refs/heads"]),
67
+ ])
68
+
69
+ const statusLines = statusResult.stdout.split("\n").filter(Boolean)
70
+ const branchInfo = parseBranchLine(statusLines[0] ?? "")
71
+ const files = parseChangedFiles(statusLines.slice(1))
72
+ const branches = branchesResult.stdout
73
+ .split("\n")
74
+ .map((line) => line.trim())
75
+ .filter(Boolean)
76
+
77
+ return {
78
+ root: this.root,
79
+ branch: branchInfo.branch,
80
+ upstream: branchInfo.upstream,
81
+ ahead: branchInfo.ahead,
82
+ behind: branchInfo.behind,
83
+ branches,
84
+ files,
85
+ }
86
+ }
87
+
88
+ async diffForFile(path: string): Promise<string> {
89
+ const whitespaceArgs = this.options.hideWhitespaceChanges ? ["-w"] : []
90
+ const [unstaged, staged] = await Promise.all([
91
+ this.runGit(["diff", ...whitespaceArgs, "--", path], true),
92
+ this.runGit(["diff", "--cached", ...whitespaceArgs, "--", path], true),
93
+ ])
94
+
95
+ const sections: string[] = []
96
+ if (staged.stdout.trim()) {
97
+ sections.push(`# Staged\n${staged.stdout.trimEnd()}`)
98
+ }
99
+ if (unstaged.stdout.trim()) {
100
+ sections.push(`# Unstaged\n${unstaged.stdout.trimEnd()}`)
101
+ }
102
+
103
+ if (sections.length > 0) {
104
+ return sections.join("\n\n")
105
+ }
106
+
107
+ const untracked = await this.runGit(["diff", "--no-index", ...whitespaceArgs, "--", "/dev/null", path], true)
108
+ if (untracked.stdout.trim()) {
109
+ return `# Untracked\n${untracked.stdout.trimEnd()}`
110
+ }
111
+
112
+ return ""
113
+ }
114
+
115
+ async fetch(): Promise<void> {
116
+ await this.runGit(["fetch", "--prune"])
117
+ }
118
+
119
+ async pull(): Promise<void> {
120
+ await this.runGit(["pull", "--ff-only"])
121
+ }
122
+
123
+ async push(): Promise<void> {
124
+ const hasHeadCommit = await this.runGit(["rev-parse", "--verify", "HEAD"], true)
125
+ if (hasHeadCommit.code !== 0) {
126
+ throw new Error("No commits yet. Create a commit before pushing.")
127
+ }
128
+
129
+ const branchResult = await this.runGit(["rev-parse", "--abbrev-ref", "HEAD"])
130
+ const branch = branchResult.stdout.trim()
131
+ if (!branch || branch === "HEAD") {
132
+ throw new Error("Cannot push from detached HEAD.")
133
+ }
134
+
135
+ const upstreamResult = await this.runGit(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], true)
136
+ if (upstreamResult.code === 0) {
137
+ await this.runGit(["push"])
138
+ return
139
+ }
140
+
141
+ const remoteResult = await this.runGit(["remote"], true)
142
+ const remotes = remoteResult.stdout
143
+ .split("\n")
144
+ .map((line) => line.trim())
145
+ .filter(Boolean)
146
+
147
+ if (!remotes.includes("origin")) {
148
+ throw new Error("No upstream configured and remote 'origin' was not found.")
149
+ }
150
+
151
+ await this.runGit(["push", "--set-upstream", "origin", branch])
152
+ }
153
+
154
+ async listCommits(limit = this.options.historyLimit): Promise<CommitHistoryEntry[]> {
155
+ const result = await this.runGit([
156
+ "log",
157
+ `--max-count=${Math.max(limit, 1)}`,
158
+ "--date=relative",
159
+ "--pretty=format:%H%x1f%h%x1f%s%x1f%ar%x1f%an",
160
+ ], true)
161
+
162
+ if (result.code !== 0) {
163
+ const details = result.stderr || result.stdout
164
+ throw new Error(details || "Failed to load commit history.")
165
+ }
166
+ if (!result.stdout.trim()) return []
167
+
168
+ return result.stdout
169
+ .split("\n")
170
+ .map((line) => line.split("\u001f"))
171
+ .map(([hash, shortHash, subject, relativeDate, author]) => ({
172
+ hash: hash ?? "",
173
+ shortHash: shortHash ?? "",
174
+ subject: subject ?? "(no subject)",
175
+ relativeDate: relativeDate ?? "",
176
+ author: author ?? "",
177
+ }))
178
+ .filter((entry) => entry.hash.length > 0)
179
+ }
180
+
181
+ async checkout(branch: string): Promise<void> {
182
+ if (!branch.trim()) {
183
+ throw new Error("Branch name is required.")
184
+ }
185
+ await this.runGit(["checkout", branch])
186
+ }
187
+
188
+ async checkoutLeavingChanges(branch: string): Promise<void> {
189
+ await this.runWithStashedChanges(() => this.checkout(branch))
190
+ }
191
+
192
+ async checkoutCommit(commitHash: string): Promise<void> {
193
+ const hash = commitHash.trim()
194
+ if (!hash) throw new Error("Commit hash is required.")
195
+ await this.runGit(["checkout", hash])
196
+ }
197
+
198
+ async createAndCheckoutBranch(branchName: string): Promise<void> {
199
+ const name = normalizeBranchName(branchName)
200
+ if (!name) {
201
+ throw new Error("Branch name is required.")
202
+ }
203
+
204
+ const validation = await this.runGit(["check-ref-format", "--branch", name], true)
205
+ if (validation.code !== 0) {
206
+ throw new Error(`Invalid branch name: ${name}`)
207
+ }
208
+
209
+ await this.runGit(["checkout", "-b", name])
210
+ }
211
+
212
+ async createAndCheckoutBranchLeavingChanges(branchName: string): Promise<void> {
213
+ await this.runWithStashedChanges(() => this.createAndCheckoutBranch(branchName))
214
+ }
215
+
216
+ async revertCommit(commitHash: string): Promise<void> {
217
+ const hash = commitHash.trim()
218
+ if (!hash) throw new Error("Commit hash is required.")
219
+ await this.runGit(["revert", "--no-edit", hash])
220
+ }
221
+
222
+ async commit(summary: string, description: string, excludedPaths: string[] = [], includedPaths: string[] = []): Promise<void> {
223
+ const title = summary.trim()
224
+ if (!title) {
225
+ throw new Error("Commit summary is required.")
226
+ }
227
+
228
+ const selectedPaths = includedPaths.map((path) => path.trim()).filter(Boolean)
229
+ if (selectedPaths.length === 0) {
230
+ throw new Error("No files selected for commit.")
231
+ }
232
+
233
+ if (this.options.autoStageOnCommit) {
234
+ await this.runGit(["add", "-A"])
235
+ } else {
236
+ await this.runGit(["add", "-A", "--", ...selectedPaths])
237
+ }
238
+
239
+ const excluded = excludedPaths.map((path) => path.trim()).filter(Boolean)
240
+ if (excluded.length > 0) {
241
+ const stagedExcluded = await this.runGit(["diff", "--name-only", "--cached", "--", ...excluded], true)
242
+ const stagedExcludedPaths = stagedExcluded.stdout
243
+ .split("\n")
244
+ .map((line) => line.trim())
245
+ .filter(Boolean)
246
+ if (stagedExcludedPaths.length > 0) {
247
+ await this.runGit(["reset", "--", ...stagedExcludedPaths])
248
+ }
249
+ }
250
+
251
+ const hasStagedChanges = await this.runGit(["diff", "--cached", "--quiet"], true)
252
+ if (hasStagedChanges.code === 0) {
253
+ throw new Error("No files selected for commit.")
254
+ }
255
+
256
+ const args = ["commit", "-m", title]
257
+ if (description.trim()) {
258
+ args.push("-m", description.trim())
259
+ }
260
+ await this.runGit(args)
261
+ }
262
+
263
+ private async runGit(args: string[], allowFailure = false): Promise<GitCommandResult> {
264
+ const result = await runGitRaw(this.root, args)
265
+ if (result.code !== 0 && !allowFailure) {
266
+ const details = result.stderr || result.stdout
267
+ throw new Error(details || `git ${args.join(" ")} failed with code ${result.code}.`)
268
+ }
269
+ return result
270
+ }
271
+
272
+ private async runWithStashedChanges(task: () => Promise<void>): Promise<void> {
273
+ const marker = `stage-tui-leave-${Date.now()}`
274
+ const stashResult = await this.runGit(["stash", "push", "-u", "-m", marker], true)
275
+ if (stashResult.code !== 0) {
276
+ const details = stashResult.stderr || stashResult.stdout
277
+ throw new Error(details || "Failed to stash working changes.")
278
+ }
279
+
280
+ const output = `${stashResult.stdout}\n${stashResult.stderr}`.toLowerCase()
281
+ const stashed = output.includes("saved working directory") || output.includes("saved index state")
282
+ if (!stashed) {
283
+ await task()
284
+ return
285
+ }
286
+
287
+ try {
288
+ await task()
289
+ } catch (error) {
290
+ const restoreResult = await this.runGit(["stash", "pop"], true)
291
+ if (restoreResult.code !== 0) {
292
+ const message = error instanceof Error ? error.message : String(error)
293
+ throw new Error(`${message} Also failed to restore stashed changes automatically.`)
294
+ }
295
+ throw error
296
+ }
297
+ }
298
+ }
@@ -0,0 +1,188 @@
1
+ import type { InputRenderable, SelectOption } from "@opentui/core"
2
+ import { useCallback, useMemo, useState, type Dispatch, type RefObject, type SetStateAction } from "react"
3
+
4
+ import type { GitClient, RepoSnapshot } from "../git"
5
+ import type { FocusTarget } from "../ui/types"
6
+
7
+ type BranchDialogMode = "select" | "create" | "confirm"
8
+ type BranchChangeStrategy = "bring" | "leave"
9
+ type PendingBranchAction =
10
+ | { kind: "checkout"; branch: string }
11
+ | { kind: "create"; branchName: string }
12
+
13
+ type RunTask = (label: string, task: () => Promise<void>) => Promise<boolean>
14
+
15
+ type UseBranchDialogControllerParams = {
16
+ git: GitClient | null
17
+ snapshot: RepoSnapshot | null
18
+ refreshSnapshot: () => Promise<void>
19
+ runTask: RunTask
20
+ setFocus: Dispatch<SetStateAction<FocusTarget>>
21
+ branchNameRef: RefObject<InputRenderable | null>
22
+ }
23
+
24
+ const CREATE_BRANCH_VALUE = "__create_branch__"
25
+ const BRANCH_STRATEGY_OPTIONS: SelectOption[] = [
26
+ { name: "bring working changes", description: "Carry local changes onto the new branch", value: "bring" },
27
+ { name: "leave changes on current branch", description: "Stash changes and switch cleanly", value: "leave" },
28
+ ]
29
+
30
+ export function useBranchDialogController({
31
+ git,
32
+ snapshot,
33
+ refreshSnapshot,
34
+ runTask,
35
+ setFocus,
36
+ branchNameRef,
37
+ }: UseBranchDialogControllerParams) {
38
+ const [branchDialogOpen, setBranchDialogOpen] = useState(false)
39
+ const [branchDialogMode, setBranchDialogMode] = useState<BranchDialogMode>("select")
40
+ const [branchIndex, setBranchIndex] = useState(0)
41
+ const [branchStrategyIndex, setBranchStrategyIndex] = useState(0)
42
+ const [pendingBranchAction, setPendingBranchAction] = useState<PendingBranchAction | null>(null)
43
+ const [newBranchName, setNewBranchName] = useState("")
44
+
45
+ const branchOptions = useMemo<SelectOption[]>(
46
+ () => [
47
+ ...(snapshot?.branches ?? []).map((branch) => ({
48
+ name: branch,
49
+ description: branch === snapshot?.branch ? "Current branch" : "Checkout branch",
50
+ value: branch,
51
+ })),
52
+ { name: "+ create new branch...", description: "Create and checkout", value: CREATE_BRANCH_VALUE },
53
+ ],
54
+ [snapshot],
55
+ )
56
+
57
+ const branchOptionsKey = useMemo(
58
+ () => branchOptions.map((option) => String(option.value)).join("|"),
59
+ [branchOptions],
60
+ )
61
+
62
+ const openBranchDialog = useCallback(() => {
63
+ setBranchDialogOpen(true)
64
+ setBranchDialogMode("select")
65
+ setPendingBranchAction(null)
66
+ setNewBranchName("")
67
+ setBranchStrategyIndex(0)
68
+ setFocus("branch-dialog-list")
69
+
70
+ const currentBranchIndex = snapshot?.branches.findIndex((branch) => branch === snapshot.branch) ?? -1
71
+ setBranchIndex(currentBranchIndex >= 0 ? currentBranchIndex : 0)
72
+ }, [setFocus, snapshot])
73
+
74
+ const closeBranchDialog = useCallback(() => {
75
+ setBranchDialogOpen(false)
76
+ setBranchDialogMode("select")
77
+ setPendingBranchAction(null)
78
+ setBranchStrategyIndex(0)
79
+ setNewBranchName("")
80
+ setFocus("files")
81
+ }, [setFocus])
82
+
83
+ const showBranchCreateInput = useCallback(() => {
84
+ setBranchDialogMode("create")
85
+ setPendingBranchAction(null)
86
+ setBranchStrategyIndex(0)
87
+ setNewBranchName("")
88
+ setFocus("branch-create")
89
+ }, [setFocus])
90
+
91
+ const showBranchDialogList = useCallback(() => {
92
+ setBranchDialogMode("select")
93
+ setPendingBranchAction(null)
94
+ setBranchStrategyIndex(0)
95
+ setFocus("branch-dialog-list")
96
+ setNewBranchName("")
97
+ }, [setFocus])
98
+
99
+ const performBranchTransition = useCallback(
100
+ async (action: PendingBranchAction, strategy: BranchChangeStrategy): Promise<void> => {
101
+ if (!git) return
102
+ const labelTarget = action.kind === "checkout" ? action.branch : action.branchName
103
+ const label = `${action.kind === "checkout" ? "CHECKOUT" : "CREATE BRANCH"} ${labelTarget} (${strategy})`
104
+ const succeeded = await runTask(label, async () => {
105
+ if (action.kind === "checkout") {
106
+ if (strategy === "leave") {
107
+ await git.checkoutLeavingChanges(action.branch)
108
+ } else {
109
+ await git.checkout(action.branch)
110
+ }
111
+ } else if (strategy === "leave") {
112
+ await git.createAndCheckoutBranchLeavingChanges(action.branchName)
113
+ } else {
114
+ await git.createAndCheckoutBranch(action.branchName)
115
+ }
116
+ await refreshSnapshot()
117
+ })
118
+
119
+ if (succeeded) {
120
+ closeBranchDialog()
121
+ }
122
+ },
123
+ [closeBranchDialog, git, refreshSnapshot, runTask],
124
+ )
125
+
126
+ const requestBranchTransition = useCallback(
127
+ async (action: PendingBranchAction): Promise<void> => {
128
+ if ((snapshot?.files.length ?? 0) > 0) {
129
+ setPendingBranchAction(action)
130
+ setBranchStrategyIndex(0)
131
+ setBranchDialogMode("confirm")
132
+ setFocus("branch-dialog-list")
133
+ return
134
+ }
135
+ await performBranchTransition(action, "bring")
136
+ },
137
+ [performBranchTransition, setFocus, snapshot],
138
+ )
139
+
140
+ const submitBranchSelection = useCallback(async (): Promise<void> => {
141
+ if (!snapshot) return
142
+ const selected = branchOptions[branchIndex]
143
+ const optionValue = typeof selected?.value === "string" ? selected.value : selected?.name
144
+ if (!optionValue) return
145
+
146
+ if (optionValue === CREATE_BRANCH_VALUE) {
147
+ showBranchCreateInput()
148
+ return
149
+ }
150
+ if (optionValue === snapshot.branch) {
151
+ closeBranchDialog()
152
+ return
153
+ }
154
+ await requestBranchTransition({ kind: "checkout", branch: optionValue })
155
+ }, [branchIndex, branchOptions, closeBranchDialog, requestBranchTransition, showBranchCreateInput, snapshot])
156
+
157
+ const createBranchAndCheckout = useCallback(async (): Promise<void> => {
158
+ const branchName = branchNameRef.current?.value ?? newBranchName
159
+ await requestBranchTransition({ kind: "create", branchName })
160
+ }, [branchNameRef, newBranchName, requestBranchTransition])
161
+
162
+ const submitBranchStrategy = useCallback(async (): Promise<void> => {
163
+ if (!pendingBranchAction) return
164
+ const selectedOption = BRANCH_STRATEGY_OPTIONS[branchStrategyIndex]
165
+ const selectedValue = selectedOption?.value === "leave" ? "leave" : "bring"
166
+ await performBranchTransition(pendingBranchAction, selectedValue)
167
+ }, [branchStrategyIndex, pendingBranchAction, performBranchTransition])
168
+
169
+ return {
170
+ branchDialogOpen,
171
+ branchDialogMode,
172
+ branchOptions,
173
+ branchOptionsKey,
174
+ branchIndex,
175
+ branchStrategyOptions: BRANCH_STRATEGY_OPTIONS,
176
+ branchStrategyIndex,
177
+ newBranchName,
178
+ openBranchDialog,
179
+ closeBranchDialog,
180
+ showBranchDialogList,
181
+ submitBranchSelection,
182
+ submitBranchStrategy,
183
+ createBranchAndCheckout,
184
+ onBranchDialogChange: setBranchIndex,
185
+ onBranchStrategyChange: setBranchStrategyIndex,
186
+ onBranchNameInput: setNewBranchName,
187
+ }
188
+ }
@@ -0,0 +1,130 @@
1
+ import type { SelectOption } from "@opentui/core"
2
+ import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from "react"
3
+
4
+ import type { CommitHistoryEntry, GitClient } from "../git"
5
+ import type { FocusTarget } from "../ui/types"
6
+
7
+ type CommitHistoryMode = "list" | "action"
8
+ type RunTask = (label: string, task: () => Promise<void>) => Promise<boolean>
9
+
10
+ type UseCommitHistoryControllerParams = {
11
+ git: GitClient | null
12
+ refreshSnapshot: () => Promise<void>
13
+ runTask: RunTask
14
+ setFocus: Dispatch<SetStateAction<FocusTarget>>
15
+ }
16
+
17
+ const ACTION_OPTIONS: SelectOption[] = [
18
+ { name: "revert commit", description: "Create a new commit that reverts this one", value: "revert" },
19
+ { name: "checkout commit", description: "Move HEAD to this commit (detached)", value: "checkout" },
20
+ ]
21
+
22
+ export function useCommitHistoryController({
23
+ git,
24
+ refreshSnapshot,
25
+ runTask,
26
+ setFocus,
27
+ }: UseCommitHistoryControllerParams) {
28
+ const [historyDialogOpen, setHistoryDialogOpen] = useState(false)
29
+ const [historyMode, setHistoryMode] = useState<CommitHistoryMode>("list")
30
+ const [commitOptions, setCommitOptions] = useState<SelectOption[]>([])
31
+ const [selectedCommit, setSelectedCommit] = useState<CommitHistoryEntry | null>(null)
32
+ const [commitIndex, setCommitIndex] = useState(0)
33
+ const [actionIndex, setActionIndex] = useState(0)
34
+
35
+ const selectedCommitTitle = useMemo(() => {
36
+ if (!selectedCommit) return ""
37
+ return `${selectedCommit.shortHash} ${selectedCommit.subject}`
38
+ }, [selectedCommit])
39
+
40
+ const openHistoryDialog = useCallback(async (): Promise<void> => {
41
+ if (!git) return
42
+
43
+ const succeeded = await runTask("LOAD HISTORY", async () => {
44
+ const commits = await git.listCommits()
45
+ if (commits.length === 0) {
46
+ throw new Error("No commits found in this repository.")
47
+ }
48
+
49
+ const options = commits.map((commit) => ({
50
+ name: `${commit.shortHash} ${commit.subject}`,
51
+ description: `${commit.relativeDate} by ${commit.author}`,
52
+ value: commit,
53
+ }))
54
+
55
+ setCommitOptions(options)
56
+ setCommitIndex(0)
57
+ setSelectedCommit(commits[0] ?? null)
58
+ setActionIndex(0)
59
+ setHistoryMode("list")
60
+ setHistoryDialogOpen(true)
61
+ setFocus("history-commits")
62
+ })
63
+
64
+ if (!succeeded) {
65
+ setHistoryDialogOpen(false)
66
+ }
67
+ }, [git, runTask, setFocus])
68
+
69
+ const closeHistoryDialog = useCallback(() => {
70
+ setHistoryDialogOpen(false)
71
+ setHistoryMode("list")
72
+ setSelectedCommit(null)
73
+ setActionIndex(0)
74
+ setFocus("files")
75
+ }, [setFocus])
76
+
77
+ const backToCommitList = useCallback(() => {
78
+ setHistoryMode("list")
79
+ setFocus("history-commits")
80
+ }, [setFocus])
81
+
82
+ const submitHistoryCommitSelection = useCallback(async (): Promise<void> => {
83
+ const selectedOption = commitOptions[commitIndex]
84
+ const commit = selectedOption?.value as CommitHistoryEntry | undefined
85
+ if (!commit) return
86
+
87
+ setSelectedCommit(commit)
88
+ setActionIndex(0)
89
+ setHistoryMode("action")
90
+ setFocus("history-actions")
91
+ }, [commitIndex, commitOptions, setFocus])
92
+
93
+ const submitHistoryAction = useCallback(async (): Promise<void> => {
94
+ if (!git || !selectedCommit) return
95
+
96
+ const selectedAction = ACTION_OPTIONS[actionIndex]
97
+ const action = selectedAction?.value === "checkout" ? "checkout" : "revert"
98
+ const label = `${action.toUpperCase()} ${selectedCommit.shortHash}`
99
+
100
+ const succeeded = await runTask(label, async () => {
101
+ if (action === "revert") {
102
+ await git.revertCommit(selectedCommit.hash)
103
+ } else {
104
+ await git.checkoutCommit(selectedCommit.hash)
105
+ }
106
+ await refreshSnapshot()
107
+ })
108
+
109
+ if (succeeded) {
110
+ closeHistoryDialog()
111
+ }
112
+ }, [actionIndex, closeHistoryDialog, git, refreshSnapshot, runTask, selectedCommit])
113
+
114
+ return {
115
+ historyDialogOpen,
116
+ historyMode,
117
+ commitOptions,
118
+ commitIndex,
119
+ actionOptions: ACTION_OPTIONS,
120
+ actionIndex,
121
+ selectedCommitTitle,
122
+ openHistoryDialog,
123
+ closeHistoryDialog,
124
+ backToCommitList,
125
+ submitHistoryCommitSelection,
126
+ submitHistoryAction,
127
+ onCommitIndexChange: setCommitIndex,
128
+ onActionIndexChange: setActionIndex,
129
+ }
130
+ }