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
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
|
+
}
|