@termaxjs/web-source-control 0.1.1

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,761 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ const COMMIT_DIFF_CHAR_LIMIT = 60_000;
3
+ const COMMIT_MESSAGE_MAX_OUTPUT_TOKENS = 1024;
4
+ const RECONCILE_DEBOUNCE_MS = 180;
5
+ const CONVENTIONAL_PREFIX = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]+\))?: .+/;
6
+ const COMMIT_MESSAGE_SYSTEM_PROMPT = "You write concise Conventional Commit subject lines in English. Return exactly one complete line, with no markdown, no quotes, no body, and no explanation.";
7
+ function normalizeError(error) {
8
+ if (typeof error === "string")
9
+ return error;
10
+ if (error && typeof error === "object" && "message" in error) {
11
+ const message = error.message;
12
+ if (typeof message === "string")
13
+ return message;
14
+ }
15
+ return "Unknown source control error";
16
+ }
17
+ function normalizeStatusCode(status) {
18
+ const code = status.trim().toUpperCase();
19
+ switch (code) {
20
+ case "?":
21
+ return "U";
22
+ case "A":
23
+ return "A";
24
+ case "M":
25
+ return "M";
26
+ case "D":
27
+ return "D";
28
+ case "R":
29
+ case "C":
30
+ return "R";
31
+ case "U":
32
+ return "U";
33
+ default:
34
+ return code || "M";
35
+ }
36
+ }
37
+ function statusCodeForMode(mode, file) {
38
+ if (mode === "-" && file.untracked)
39
+ return "U";
40
+ const primary = mode === "+" ? file.indexStatus : file.worktreeStatus;
41
+ const fallback = mode === "+" ? file.worktreeStatus : file.indexStatus;
42
+ return normalizeStatusCode(primary !== " " ? primary : fallback);
43
+ }
44
+ function makeEntry(path, mode, file) {
45
+ return {
46
+ key: `${mode}:${path}`,
47
+ path,
48
+ mode,
49
+ indexStatus: file.indexStatus,
50
+ worktreeStatus: file.worktreeStatus,
51
+ statusLabel: file.statusLabel,
52
+ statusCode: statusCodeForMode(mode, file),
53
+ originalPath: file.originalPath,
54
+ untracked: file.untracked,
55
+ };
56
+ }
57
+ function sameSelection(a, b) {
58
+ return !!a && !!b && a.path === b.path && a.mode === b.mode;
59
+ }
60
+ function stagedFilesSummary(entries) {
61
+ return entries
62
+ .map((entry) => {
63
+ const status = entry.originalPath
64
+ ? `R ${entry.originalPath} -> ${entry.path}`
65
+ : `${entry.statusCode} ${entry.path}`;
66
+ return `- ${status}`;
67
+ })
68
+ .join("\n");
69
+ }
70
+ function truncateDiff(diff) {
71
+ if (diff.length <= COMMIT_DIFF_CHAR_LIMIT) {
72
+ return { text: diff, truncated: false };
73
+ }
74
+ return { text: diff.slice(0, COMMIT_DIFF_CHAR_LIMIT), truncated: true };
75
+ }
76
+ function cleanCommitMessage(raw) {
77
+ let text = raw.trim();
78
+ const fence = text.match(/^```[a-zA-Z0-9_-]*\n([\s\S]*?)\n```\s*$/);
79
+ if (fence)
80
+ text = fence[1].trim();
81
+ const firstLine = text
82
+ .split(/\r?\n/)
83
+ .map((line) => line.trim())
84
+ .find(Boolean);
85
+ if (!firstLine)
86
+ return "";
87
+ return firstLine.replace(/^["'`]+|["'`]+$/g, "").trim();
88
+ }
89
+ function isValidCommitMessage(message) {
90
+ return CONVENTIONAL_PREFIX.test(message);
91
+ }
92
+ function buildCommitMessagePrompt(entries, diffText, truncated) {
93
+ return [
94
+ "Generate one complete commit message for the staged changes only.",
95
+ "Format: type(scope): subject",
96
+ "Allowed types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert.",
97
+ "Examples:",
98
+ "- feat(source-control): generate commit messages",
99
+ "- fix(git): handle staged diff errors",
100
+ "- chore: update project metadata",
101
+ "Use a short lowercase subject in imperative mood. Omit the scope if it would be vague.",
102
+ "Do not stop after the type or an opening parenthesis; the line must include a subject after ': '.",
103
+ truncated
104
+ ? "The diff below was truncated; infer from the visible staged changes only."
105
+ : "The full staged diff is included below.",
106
+ "",
107
+ "Staged files:",
108
+ stagedFilesSummary(entries),
109
+ "",
110
+ "Staged diff:",
111
+ diffText || "(No textual diff available.)",
112
+ ].join("\n");
113
+ }
114
+ function buildRepairCommitMessagePrompt(invalidMessage, entries) {
115
+ return [
116
+ "Repair this invalid Conventional Commit subject line.",
117
+ `Invalid line: ${invalidMessage || "(empty)"}`,
118
+ "Return exactly one complete valid line in this format: type(scope): subject",
119
+ "Allowed types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert.",
120
+ "If the scope is unclear, omit it and use: type: subject",
121
+ "",
122
+ "Staged files:",
123
+ stagedFilesSummary(entries),
124
+ ].join("\n");
125
+ }
126
+ function optimisticStage(status, paths) {
127
+ let changed = false;
128
+ const next = status.changedFiles.map((file) => {
129
+ if (!paths.has(file.path))
130
+ return file;
131
+ if (file.staged && !file.unstaged)
132
+ return file;
133
+ changed = true;
134
+ const wt = file.worktreeStatus !== " " ? file.worktreeStatus : file.indexStatus;
135
+ return {
136
+ ...file,
137
+ indexStatus: wt,
138
+ worktreeStatus: " ",
139
+ staged: true,
140
+ unstaged: false,
141
+ untracked: false,
142
+ };
143
+ });
144
+ if (!changed)
145
+ return status;
146
+ return { ...status, changedFiles: next };
147
+ }
148
+ function optimisticUnstage(status, paths) {
149
+ let changed = false;
150
+ const next = [];
151
+ for (const file of status.changedFiles) {
152
+ if (!paths.has(file.path)) {
153
+ next.push(file);
154
+ continue;
155
+ }
156
+ if (!file.staged && file.unstaged) {
157
+ next.push(file);
158
+ continue;
159
+ }
160
+ changed = true;
161
+ const idx = file.indexStatus !== " " ? file.indexStatus : file.worktreeStatus;
162
+ if (idx === "R" && file.originalPath) {
163
+ next.push({
164
+ path: file.originalPath,
165
+ originalPath: null,
166
+ indexStatus: " ",
167
+ worktreeStatus: "D",
168
+ staged: false,
169
+ unstaged: true,
170
+ untracked: false,
171
+ statusLabel: "Deleted",
172
+ });
173
+ next.push({
174
+ path: file.path,
175
+ originalPath: null,
176
+ indexStatus: " ",
177
+ worktreeStatus: "?",
178
+ staged: false,
179
+ unstaged: true,
180
+ untracked: true,
181
+ statusLabel: "Untracked",
182
+ });
183
+ continue;
184
+ }
185
+ next.push({
186
+ ...file,
187
+ originalPath: null,
188
+ indexStatus: " ",
189
+ worktreeStatus: idx === "A" ? "?" : idx,
190
+ staged: false,
191
+ unstaged: true,
192
+ untracked: idx === "A",
193
+ });
194
+ }
195
+ if (!changed)
196
+ return status;
197
+ return { ...status, changedFiles: next };
198
+ }
199
+ function optimisticDiscard(status, paths) {
200
+ let changed = false;
201
+ const next = [];
202
+ for (const file of status.changedFiles) {
203
+ if (!paths.has(file.path)) {
204
+ next.push(file);
205
+ continue;
206
+ }
207
+ if (file.staged) {
208
+ changed = true;
209
+ next.push({
210
+ ...file,
211
+ worktreeStatus: " ",
212
+ unstaged: false,
213
+ untracked: false,
214
+ });
215
+ }
216
+ else {
217
+ changed = true;
218
+ }
219
+ }
220
+ if (!changed)
221
+ return status;
222
+ return { ...status, changedFiles: next };
223
+ }
224
+ export function useSourceControlPanel(adapter, isOpen, summary, onOpenDiff) {
225
+ // AI commit generation state is calculated below
226
+ const [panelState, setPanelState] = useState("closed");
227
+ const [repo, setRepo] = useState(null);
228
+ const [status, setStatus] = useState(null);
229
+ const [selected, setSelected] = useState(null);
230
+ const [commitMessage, setCommitMessage] = useState("");
231
+ const [localActionBusy, setLocalActionBusy] = useState(null);
232
+ const [actionError, setActionError] = useState(null);
233
+ const [actionMessage, setActionMessage] = useState(null);
234
+ const [selectionTransition, setSelectionTransition] = useState("none");
235
+ const [pendingDiscard, setPendingDiscard] = useState(null);
236
+ const selectedRef = useRef(null);
237
+ const reconcileTimerRef = useRef(0);
238
+ useEffect(() => {
239
+ selectedRef.current = selected;
240
+ }, [selected]);
241
+ const stagedEntries = useMemo(() => (status?.changedFiles ?? [])
242
+ .filter((file) => file.staged)
243
+ .map((file) => makeEntry(file.path, "+", file)), [status]);
244
+ const unstagedEntries = useMemo(() => (status?.changedFiles ?? [])
245
+ .filter((file) => file.unstaged)
246
+ .map((file) => makeEntry(file.path, "-", file)), [status]);
247
+ const fileEntries = useMemo(() => {
248
+ const seen = new Set();
249
+ const out = [];
250
+ for (const file of status?.changedFiles ?? []) {
251
+ if (seen.has(file.path))
252
+ continue;
253
+ seen.add(file.path);
254
+ const checkState = file.staged && file.unstaged
255
+ ? "indeterminate"
256
+ : file.staged
257
+ ? "checked"
258
+ : "unchecked";
259
+ const statusCode = file.unstaged
260
+ ? statusCodeForMode("-", file)
261
+ : statusCodeForMode("+", file);
262
+ out.push({
263
+ key: file.path,
264
+ path: file.path,
265
+ originalPath: file.originalPath,
266
+ statusCode,
267
+ statusLabel: file.statusLabel,
268
+ checkState,
269
+ staged: file.staged,
270
+ unstaged: file.unstaged,
271
+ untracked: file.untracked,
272
+ });
273
+ }
274
+ return out;
275
+ }, [status]);
276
+ const headerCheckState = useMemo(() => {
277
+ if (fileEntries.length === 0)
278
+ return "unchecked";
279
+ const allChecked = fileEntries.every((e) => e.checkState === "checked");
280
+ if (allChecked)
281
+ return "checked";
282
+ const anyStaged = fileEntries.some((e) => e.staged);
283
+ return anyStaged ? "indeterminate" : "unchecked";
284
+ }, [fileEntries]);
285
+ const allClean = stagedEntries.length === 0 && unstagedEntries.length === 0;
286
+ const canPush = !!status?.upstream && status.behind === 0;
287
+ const anyActionBusy = localActionBusy !== null || summary.busyAction !== null;
288
+ const aiUnavailableReason = useMemo(() => {
289
+ if (stagedEntries.length === 0) {
290
+ return "Stage changes to generate a commit message";
291
+ }
292
+ return null;
293
+ }, [stagedEntries.length]);
294
+ const canGenerateCommitMessage = (adapter.canGenerateCommitMessage ?? false) && stagedEntries.length > 0 && !anyActionBusy && !!repo;
295
+ const generateCommitMessageHint = aiUnavailableReason
296
+ ? aiUnavailableReason
297
+ : (adapter.generateCommitMessageHint ?? "Generate commit message");
298
+ const pushHint = useMemo(() => {
299
+ if (!status)
300
+ return null;
301
+ if (!status.upstream) {
302
+ return "Configure or publish this branch in the terminal to enable push in this iteration.";
303
+ }
304
+ if (status.behind > 0) {
305
+ return "Pull remote changes before pushing local commits.";
306
+ }
307
+ if (status.ahead === 0) {
308
+ return `No local commits to push to ${status.upstream}.`;
309
+ }
310
+ return `Pushes to ${status.upstream}.`;
311
+ }, [status]);
312
+ const stagedEmptyText = "No staged changes";
313
+ const unstagedEmptyText = "No unstaged changes";
314
+ const cancelReconcile = useCallback(() => {
315
+ if (reconcileTimerRef.current) {
316
+ window.clearTimeout(reconcileTimerRef.current);
317
+ reconcileTimerRef.current = 0;
318
+ }
319
+ }, []);
320
+ const scheduleReconcile = useCallback(() => {
321
+ cancelReconcile();
322
+ reconcileTimerRef.current = window.setTimeout(() => {
323
+ reconcileTimerRef.current = 0;
324
+ void summary.refresh({ remote: "never" });
325
+ }, RECONCILE_DEBOUNCE_MS);
326
+ }, [cancelReconcile, summary]);
327
+ useEffect(() => () => cancelReconcile(), [cancelReconcile]);
328
+ const openSelection = useCallback((sel, repoRoot, file) => {
329
+ onOpenDiff?.({
330
+ path: sel.path,
331
+ repoRoot,
332
+ mode: sel.mode,
333
+ originalPath: file?.originalPath ?? null,
334
+ });
335
+ }, [onOpenDiff]);
336
+ const refresh = useCallback(async () => {
337
+ if (!isOpen) {
338
+ setPanelState("closed");
339
+ setSelectionTransition("none");
340
+ return;
341
+ }
342
+ if (summary.repo)
343
+ adapter.invalidateRepoDiffs?.(summary.repo.repoRoot);
344
+ await summary.refresh({ remote: "never" });
345
+ }, [isOpen, summary, adapter]);
346
+ useEffect(() => {
347
+ if (!isOpen) {
348
+ setPanelState("closed");
349
+ setSelectionTransition("none");
350
+ return;
351
+ }
352
+ if (summary.isLoading && !summary.hasRepo && !summary.status) {
353
+ setPanelState("loading");
354
+ return;
355
+ }
356
+ if (!summary.hasRepo) {
357
+ setRepo(null);
358
+ setStatus(null);
359
+ setSelected(null);
360
+ setPanelState("no-repo");
361
+ setSelectionTransition("none");
362
+ return;
363
+ }
364
+ if (summary.localError && !summary.status) {
365
+ setRepo(summary.repo);
366
+ setStatus(null);
367
+ setSelected(null);
368
+ setPanelState("error");
369
+ setSelectionTransition("none");
370
+ return;
371
+ }
372
+ if (!summary.repo || !summary.status) {
373
+ if (summary.isLoading) {
374
+ setPanelState("loading");
375
+ }
376
+ return;
377
+ }
378
+ setRepo(summary.repo);
379
+ setStatus(summary.status);
380
+ setPanelState("ready");
381
+ const current = selectedRef.current;
382
+ const exists = !!current &&
383
+ summary.status.changedFiles.some((file) => {
384
+ if (file.path !== current.path)
385
+ return false;
386
+ return current.mode === "+" ? file.staged : file.unstaged;
387
+ });
388
+ if (!exists && current) {
389
+ const samePathOtherMode = summary.status.changedFiles.find((file) => file.path === current.path &&
390
+ (current.mode === "+" ? file.unstaged : file.staged));
391
+ if (samePathOtherMode) {
392
+ const moved = {
393
+ path: samePathOtherMode.path,
394
+ mode: current.mode === "+" ? "-" : "+",
395
+ };
396
+ setSelected(moved);
397
+ setSelectionTransition("moved-group");
398
+ }
399
+ else {
400
+ setSelected(null);
401
+ setSelectionTransition("reset");
402
+ }
403
+ }
404
+ else {
405
+ setSelectionTransition("none");
406
+ }
407
+ }, [
408
+ isOpen,
409
+ summary.hasRepo,
410
+ summary.isLoading,
411
+ summary.localError,
412
+ summary.repo,
413
+ summary.status,
414
+ ]);
415
+ const selectEntry = useCallback(async (entry) => {
416
+ if (!repo)
417
+ return;
418
+ const nextSelection = {
419
+ path: entry.path,
420
+ mode: entry.mode,
421
+ };
422
+ if (sameSelection(selected, nextSelection)) {
423
+ setActionError(null);
424
+ setActionMessage(null);
425
+ setSelectionTransition("none");
426
+ return;
427
+ }
428
+ setSelected(nextSelection);
429
+ setActionError(null);
430
+ setActionMessage(null);
431
+ setSelectionTransition("none");
432
+ const file = status?.changedFiles.find((c) => c.path === entry.path);
433
+ openSelection(nextSelection, repo.repoRoot, file);
434
+ }, [openSelection, repo, selected, status]);
435
+ const runMutation = useCallback(async (busyKey, optimistic, ipc, affected) => {
436
+ if (!repo || summary.busyAction)
437
+ return;
438
+ setLocalActionBusy(busyKey);
439
+ setActionMessage(null);
440
+ setActionError(null);
441
+ if (optimistic)
442
+ summary.applyStatus(optimistic);
443
+ for (const path of affected) {
444
+ adapter.invalidateDiff?.(repo.repoRoot, path);
445
+ }
446
+ try {
447
+ await ipc();
448
+ scheduleReconcile();
449
+ }
450
+ catch (error) {
451
+ setActionError(normalizeError(error));
452
+ cancelReconcile();
453
+ await summary.refresh({ remote: "never" }).catch(() => { });
454
+ }
455
+ finally {
456
+ setLocalActionBusy(null);
457
+ }
458
+ }, [adapter, cancelReconcile, repo, scheduleReconcile, summary]);
459
+ const stageEntry = useCallback(async (entry) => {
460
+ if (!repo)
461
+ return;
462
+ await runMutation(`stage:${entry.path}`, (s) => optimisticStage(s, new Set([entry.path])), () => adapter.gitStage(repo.repoRoot, [entry.path]), [entry.path]);
463
+ }, [repo, runMutation, adapter]);
464
+ const unstageEntry = useCallback(async (entry) => {
465
+ if (!repo)
466
+ return;
467
+ await runMutation(`unstage:${entry.path}`, (s) => optimisticUnstage(s, new Set([entry.path])), () => adapter.gitUnstage(repo.repoRoot, [entry.path]), [entry.path]);
468
+ }, [repo, runMutation, adapter]);
469
+ const requestDiscardEntry = useCallback((entry) => {
470
+ if (!repo || summary.busyAction)
471
+ return;
472
+ setPendingDiscard({ scope: "single", entry });
473
+ }, [repo, summary.busyAction]);
474
+ const requestDiscardAll = useCallback(() => {
475
+ if (!repo || summary.busyAction || unstagedEntries.length === 0)
476
+ return;
477
+ setPendingDiscard({ scope: "all", entries: unstagedEntries });
478
+ }, [repo, summary.busyAction, unstagedEntries]);
479
+ const requestDiscardFile = useCallback((file) => {
480
+ if (!repo || summary.busyAction || unstagedEntries.length === 0)
481
+ return;
482
+ setPendingDiscard({ scope: "all", entries: unstagedEntries });
483
+ }, [repo, summary.busyAction, unstagedEntries]);
484
+ const cancelPendingDiscard = useCallback(() => {
485
+ setPendingDiscard(null);
486
+ }, []);
487
+ const confirmPendingDiscard = useCallback(async () => {
488
+ if (!repo || !pendingDiscard)
489
+ return;
490
+ const list = pendingDiscard.scope === "single"
491
+ ? [pendingDiscard.entry]
492
+ : pendingDiscard.entries;
493
+ setPendingDiscard(null);
494
+ const entries = list.map((entry) => ({
495
+ path: entry.path,
496
+ untracked: entry.untracked,
497
+ }));
498
+ const paths = new Set(list.map((entry) => entry.path));
499
+ await runMutation(pendingDiscard.scope === "single"
500
+ ? `discard:${list[0].path}`
501
+ : "discard:all", (s) => optimisticDiscard(s, paths), () => adapter.gitDiscard(repo.repoRoot, entries), [...paths]);
502
+ }, [pendingDiscard, repo, runMutation, adapter]);
503
+ const stageAllEntries = useCallback(async () => {
504
+ if (!repo || unstagedEntries.length === 0)
505
+ return;
506
+ const paths = new Set(unstagedEntries.map((entry) => entry.path));
507
+ await runMutation("stage:all", (s) => optimisticStage(s, paths), () => adapter.gitStage(repo.repoRoot, [...paths]), [...paths]);
508
+ }, [repo, runMutation, unstagedEntries, adapter]);
509
+ const unstageAllEntries = useCallback(async () => {
510
+ if (!repo || stagedEntries.length === 0)
511
+ return;
512
+ const paths = new Set(stagedEntries.map((entry) => entry.path));
513
+ await runMutation("unstage:all", (s) => optimisticUnstage(s, paths), () => adapter.gitUnstage(repo.repoRoot, [...paths]), [...paths]);
514
+ }, [repo, runMutation, stagedEntries, adapter]);
515
+ const selectFile = useCallback(async (entry) => {
516
+ if (!repo)
517
+ return;
518
+ const mode = entry.unstaged ? "-" : "+";
519
+ const nextSelection = { path: entry.path, mode };
520
+ if (sameSelection(selected, nextSelection)) {
521
+ setActionError(null);
522
+ setActionMessage(null);
523
+ setSelectionTransition("none");
524
+ return;
525
+ }
526
+ setSelected(nextSelection);
527
+ setActionError(null);
528
+ setActionMessage(null);
529
+ setSelectionTransition("none");
530
+ const file = status?.changedFiles.find((c) => c.path === entry.path);
531
+ openSelection(nextSelection, repo.repoRoot, file);
532
+ }, [openSelection, repo, selected, status]);
533
+ const toggleStageFile = useCallback(async (entry) => {
534
+ if (!repo)
535
+ return;
536
+ const paths = new Set([entry.path]);
537
+ if (entry.checkState === "checked") {
538
+ await runMutation(`unstage:${entry.path}`, (s) => optimisticUnstage(s, paths), () => adapter.gitUnstage(repo.repoRoot, [entry.path]), [entry.path]);
539
+ }
540
+ else {
541
+ await runMutation(`stage:${entry.path}`, (s) => optimisticStage(s, paths), () => adapter.gitStage(repo.repoRoot, [entry.path]), [entry.path]);
542
+ }
543
+ }, [repo, runMutation, adapter]);
544
+ const toggleAll = useCallback(async () => {
545
+ if (headerCheckState === "checked")
546
+ await unstageAllEntries();
547
+ else
548
+ await stageAllEntries();
549
+ }, [headerCheckState, stageAllEntries, unstageAllEntries]);
550
+ const stageHunk = useCallback(async (entry, patch, reverse) => {
551
+ if (!repo)
552
+ return;
553
+ await runMutation(reverse ? `unstage:${entry.path}` : `stage:${entry.path}`, (s) => s, () => adapter.gitApplyPatch(repo.repoRoot, patch, reverse, true), [entry.path]);
554
+ }, [repo, runMutation, adapter]);
555
+ const discardHunk = useCallback(async (entry, patch) => {
556
+ if (!repo)
557
+ return;
558
+ await runMutation(`discard:${entry.path}`, (s) => s, () => adapter.gitApplyPatch(repo.repoRoot, patch, true, false), [entry.path]);
559
+ }, [repo, runMutation, adapter]);
560
+ const generateCommitMessage = useCallback(async () => {
561
+ if (!repo || stagedEntries.length === 0)
562
+ return;
563
+ if (adapter.canGenerateCommitMessage === false) {
564
+ setActionError(adapter.generateCommitMessageHint ?? "AI commit generation not available");
565
+ return;
566
+ }
567
+ setLocalActionBusy("generate-message");
568
+ setActionMessage(null);
569
+ setActionError(null);
570
+ try {
571
+ const diffStr = await adapter.gitDiff(repo.repoRoot, null, true);
572
+ const diffText = diffStr.diffText;
573
+ if (adapter.generateCommitMessage) {
574
+ const message = await adapter.generateCommitMessage(diffText);
575
+ setCommitMessage(message);
576
+ }
577
+ else {
578
+ throw new Error("generateCommitMessage is not implemented in the adapter.");
579
+ }
580
+ }
581
+ catch (error) {
582
+ setActionError(normalizeError(error));
583
+ }
584
+ finally {
585
+ setLocalActionBusy(null);
586
+ }
587
+ }, [repo, stagedEntries, adapter]);
588
+ const commit = useCallback(async () => {
589
+ if (!repo || summary.busyAction)
590
+ return;
591
+ setLocalActionBusy("commit");
592
+ setActionMessage(null);
593
+ setActionError(null);
594
+ try {
595
+ const result = await adapter.gitCommit(repo.repoRoot, commitMessage);
596
+ setCommitMessage("");
597
+ setActionMessage(`Committed ${result.commitSha.slice(0, 7)} ${result.summary}`);
598
+ adapter.invalidateRepoDiffs?.(repo.repoRoot);
599
+ await summary.refresh({ remote: "never" });
600
+ }
601
+ catch (error) {
602
+ setActionError(normalizeError(error));
603
+ }
604
+ finally {
605
+ setLocalActionBusy(null);
606
+ }
607
+ }, [commitMessage, repo, summary, adapter.gitCommit, adapter.invalidateRepoDiffs]);
608
+ const push = useCallback(async () => {
609
+ if (!repo)
610
+ return;
611
+ setActionMessage(null);
612
+ setActionError(null);
613
+ const result = await summary.runRemoteAction("push");
614
+ if (result.ok) {
615
+ setActionMessage(status?.upstream ? `Pushed to ${status.upstream}` : "Push completed");
616
+ return;
617
+ }
618
+ if (result.error) {
619
+ setActionError(result.error);
620
+ }
621
+ }, [repo, status?.upstream, summary]);
622
+ const stashPush = useCallback(async (message) => {
623
+ if (!repo)
624
+ return;
625
+ setActionMessage(null);
626
+ setActionError(null);
627
+ setLocalActionBusy("stash");
628
+ try {
629
+ await adapter.gitStashPush(repo.repoRoot, message);
630
+ await summary.refresh();
631
+ setActionMessage("Stashed changes");
632
+ }
633
+ catch (error) {
634
+ setActionError(normalizeError(error));
635
+ }
636
+ finally {
637
+ setLocalActionBusy(null);
638
+ }
639
+ }, [repo, summary, adapter.gitStashPush]);
640
+ const stashPop = useCallback(async (stashId) => {
641
+ if (!repo)
642
+ return;
643
+ setActionMessage(null);
644
+ setActionError(null);
645
+ setLocalActionBusy("stash");
646
+ try {
647
+ await adapter.gitStashPop(repo.repoRoot, stashId);
648
+ await summary.refresh();
649
+ setActionMessage(`Popped stash ${stashId}`);
650
+ }
651
+ catch (error) {
652
+ setActionError(normalizeError(error));
653
+ }
654
+ finally {
655
+ setLocalActionBusy(null);
656
+ }
657
+ }, [repo, summary, adapter.gitStashPop]);
658
+ const stashApply = useCallback(async (stashId) => {
659
+ if (!repo)
660
+ return;
661
+ setActionMessage(null);
662
+ setActionError(null);
663
+ setLocalActionBusy("stash");
664
+ try {
665
+ await adapter.gitStashApply(repo.repoRoot, stashId);
666
+ await summary.refresh();
667
+ setActionMessage(`Applied stash ${stashId}`);
668
+ }
669
+ catch (error) {
670
+ setActionError(normalizeError(error));
671
+ }
672
+ finally {
673
+ setLocalActionBusy(null);
674
+ }
675
+ }, [repo, summary, adapter.gitStashApply]);
676
+ const stashDrop = useCallback(async (stashId) => {
677
+ if (!repo)
678
+ return;
679
+ setActionMessage(null);
680
+ setActionError(null);
681
+ setLocalActionBusy("stash");
682
+ try {
683
+ await adapter.gitStashDrop(repo.repoRoot, stashId);
684
+ await summary.refresh();
685
+ setActionMessage(`Dropped stash ${stashId}`);
686
+ }
687
+ catch (error) {
688
+ setActionError(normalizeError(error));
689
+ }
690
+ finally {
691
+ setLocalActionBusy(null);
692
+ }
693
+ }, [repo, summary, adapter.gitStashDrop]);
694
+ const pendingDiscardView = useMemo(() => {
695
+ if (!pendingDiscard)
696
+ return null;
697
+ if (pendingDiscard.scope === "single") {
698
+ return {
699
+ scope: "single",
700
+ count: 1,
701
+ label: pendingDiscard.entry.path,
702
+ };
703
+ }
704
+ return {
705
+ scope: "all",
706
+ count: pendingDiscard.entries.length,
707
+ label: `${pendingDiscard.entries.length} unstaged ${pendingDiscard.entries.length === 1 ? "file" : "files"}`,
708
+ };
709
+ }, [pendingDiscard]);
710
+ return {
711
+ panelState,
712
+ repo,
713
+ status,
714
+ stashes: summary.stashes,
715
+ selected,
716
+ commitMessage,
717
+ actionBusy: localActionBusy ?? summary.busyAction,
718
+ statusError: summary.localError,
719
+ actionError,
720
+ remoteError: summary.lastRemoteError,
721
+ actionMessage,
722
+ stagedEntries,
723
+ unstagedEntries,
724
+ fileEntries,
725
+ headerCheckState,
726
+ allClean,
727
+ canPush,
728
+ pushHint,
729
+ canGenerateCommitMessage,
730
+ generateCommitMessageHint,
731
+ selectionTransition,
732
+ stagedEmptyText,
733
+ unstagedEmptyText,
734
+ pendingDiscard: pendingDiscardView,
735
+ setCommitMessage,
736
+ refresh,
737
+ selectEntry,
738
+ selectFile,
739
+ stageEntry,
740
+ unstageEntry,
741
+ toggleStageFile,
742
+ toggleAll,
743
+ requestDiscardEntry,
744
+ requestDiscardFile,
745
+ requestDiscardAll,
746
+ stageHunk,
747
+ discardHunk,
748
+ confirmPendingDiscard,
749
+ cancelPendingDiscard,
750
+ stageAllEntries,
751
+ unstageAllEntries,
752
+ generateCommitMessage,
753
+ commit,
754
+ push,
755
+ stashPush,
756
+ stashPop,
757
+ stashApply,
758
+ stashDrop,
759
+ };
760
+ }
761
+ //# sourceMappingURL=useSourceControlPanel.js.map