trident-git 0.2.0

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.
Files changed (64) hide show
  1. package/README.md +198 -0
  2. package/bin/trident-git.mjs +153 -0
  3. package/eslint.config.mjs +18 -0
  4. package/next.config.ts +30 -0
  5. package/package.json +60 -0
  6. package/postcss.config.mjs +7 -0
  7. package/public/favicon.png +0 -0
  8. package/public/file.svg +1 -0
  9. package/public/globe.svg +1 -0
  10. package/public/next.svg +1 -0
  11. package/public/vercel.svg +1 -0
  12. package/public/window.svg +1 -0
  13. package/src/app/api/credentials/route.ts +113 -0
  14. package/src/app/api/custom-scripts/route.ts +203 -0
  15. package/src/app/api/fs/route.ts +75 -0
  16. package/src/app/api/git/action/route.ts +383 -0
  17. package/src/app/api/git/branches/route.ts +20 -0
  18. package/src/app/api/git/diff/route.ts +104 -0
  19. package/src/app/api/git/log/route.ts +28 -0
  20. package/src/app/api/git/status/route.ts +28 -0
  21. package/src/app/api/repos/route.ts +84 -0
  22. package/src/app/api/settings/route.ts +37 -0
  23. package/src/app/credentials/page.tsx +408 -0
  24. package/src/app/globals.css +109 -0
  25. package/src/app/icon.png +0 -0
  26. package/src/app/layout.tsx +38 -0
  27. package/src/app/page.tsx +10 -0
  28. package/src/app/providers.tsx +21 -0
  29. package/src/app/workspace/changes/page.tsx +27 -0
  30. package/src/app/workspace/custom-scripts/page.tsx +247 -0
  31. package/src/app/workspace/history/page.tsx +27 -0
  32. package/src/app/workspace/layout.tsx +26 -0
  33. package/src/app/workspace/page.tsx +27 -0
  34. package/src/app/workspace/settings/page.tsx +233 -0
  35. package/src/app/workspace/stashes/page.tsx +395 -0
  36. package/src/components/command-palette.tsx +178 -0
  37. package/src/components/context-menu.tsx +200 -0
  38. package/src/components/fs-browser.tsx +154 -0
  39. package/src/components/git/diff-view.tsx +137 -0
  40. package/src/components/git/git-graph.tsx +489 -0
  41. package/src/components/git/grouped-diff-viewer.tsx +332 -0
  42. package/src/components/git/history-view.tsx +4862 -0
  43. package/src/components/git/image-diff-view.tsx +342 -0
  44. package/src/components/git/status-view.tsx +597 -0
  45. package/src/components/home-settings-modal.tsx +192 -0
  46. package/src/components/layout/sidebar.tsx +256 -0
  47. package/src/components/repo-list.tsx +206 -0
  48. package/src/components/theme-toggle.tsx +37 -0
  49. package/src/components/toaster.tsx +36 -0
  50. package/src/components/workspace-repo-open-tracker.tsx +39 -0
  51. package/src/hooks/use-credentials.ts +123 -0
  52. package/src/hooks/use-escape-dismiss.ts +72 -0
  53. package/src/hooks/use-git.ts +448 -0
  54. package/src/hooks/use-toast.ts +280 -0
  55. package/src/hooks/use-workspace-title.ts +23 -0
  56. package/src/lib/api-utils.ts +24 -0
  57. package/src/lib/branch-colors.ts +98 -0
  58. package/src/lib/credentials.ts +404 -0
  59. package/src/lib/git.ts +1510 -0
  60. package/src/lib/graph-utils.ts +253 -0
  61. package/src/lib/store.ts +145 -0
  62. package/src/lib/types.ts +95 -0
  63. package/src/lib/utils.ts +266 -0
  64. package/tsconfig.json +34 -0
@@ -0,0 +1,597 @@
1
+ 'use client';
2
+
3
+ import { useGitStatus, useGitAction } from '@/hooks/use-git';
4
+ import { useCallback, useMemo, useState, useEffect, useRef } from 'react';
5
+ import { cn } from '@/lib/utils';
6
+ import { DiffView } from './diff-view';
7
+ import { useEscapeDismiss } from '@/hooks/use-escape-dismiss';
8
+
9
+ const EMPTY_FILES: Array<{ path: string; index: string; working_dir: string }> = [];
10
+
11
+ function buildCommitMessage(subject: string, body: string): string {
12
+ const trimmedSubject = subject.trim();
13
+ const normalizedBody = body.replace(/\r\n/g, '\n');
14
+ return normalizedBody.trim() ? `${trimmedSubject}\n\n${normalizedBody}` : trimmedSubject;
15
+ }
16
+
17
+ interface StatusFileTreeNode {
18
+ name: string;
19
+ path: string;
20
+ filePath?: string;
21
+ children: Map<string, StatusFileTreeNode>;
22
+ }
23
+
24
+ function buildStatusFileTree(paths: string[]): StatusFileTreeNode {
25
+ const root: StatusFileTreeNode = {
26
+ name: '',
27
+ path: '',
28
+ children: new Map(),
29
+ };
30
+
31
+ for (const filePath of paths) {
32
+ const parts = filePath.split('/').filter(Boolean);
33
+ let current = root;
34
+ let currentPath = '';
35
+
36
+ for (let i = 0; i < parts.length; i++) {
37
+ const part = parts[i];
38
+ currentPath = currentPath ? `${currentPath}/${part}` : part;
39
+
40
+ if (!current.children.has(part)) {
41
+ current.children.set(part, {
42
+ name: part,
43
+ path: currentPath,
44
+ children: new Map(),
45
+ });
46
+ }
47
+
48
+ current = current.children.get(part)!;
49
+
50
+ if (i === parts.length - 1) {
51
+ current.filePath = filePath;
52
+ }
53
+ }
54
+ }
55
+
56
+ return root;
57
+ }
58
+
59
+ function collectFolderPaths(node: StatusFileTreeNode): string[] {
60
+ const paths: string[] = [];
61
+ const children = Array.from(node.children.values());
62
+
63
+ children.forEach((child) => {
64
+ if (child.children.size > 0) {
65
+ paths.push(child.path);
66
+ paths.push(...collectFolderPaths(child));
67
+ }
68
+ });
69
+
70
+ return paths;
71
+ }
72
+
73
+ function getParentPaths(filePath: string): string[] {
74
+ const parts = filePath.split('/').filter(Boolean);
75
+ const parentPaths: string[] = [];
76
+
77
+ for (let i = 1; i < parts.length; i++) {
78
+ parentPaths.push(parts.slice(0, i).join('/'));
79
+ }
80
+
81
+ return parentPaths;
82
+ }
83
+
84
+ function StatusFileTreeItem({
85
+ node,
86
+ selectedFile,
87
+ expandedFolders,
88
+ onToggleFolder,
89
+ onSelectFile,
90
+ onActionFile,
91
+ actionType,
92
+ actionPending,
93
+ depth = 0,
94
+ }: {
95
+ node: StatusFileTreeNode;
96
+ selectedFile: string | null;
97
+ expandedFolders: Set<string>;
98
+ onToggleFolder: (path: string) => void;
99
+ onSelectFile: (path: string) => void;
100
+ onActionFile: (path: string) => Promise<void>;
101
+ actionType: 'stage' | 'unstage';
102
+ actionPending: boolean;
103
+ depth?: number;
104
+ }) {
105
+ const children = Array.from(node.children.values()).sort((a, b) => {
106
+ const aIsFolder = a.children.size > 0;
107
+ const bIsFolder = b.children.size > 0;
108
+
109
+ if (aIsFolder && !bIsFolder) return -1;
110
+ if (!aIsFolder && bIsFolder) return 1;
111
+ return a.name.localeCompare(b.name);
112
+ });
113
+
114
+ return (
115
+ <>
116
+ {children.map((child) => {
117
+ const isFolder = child.children.size > 0;
118
+
119
+ if (isFolder) {
120
+ const isExpanded = expandedFolders.has(child.path);
121
+
122
+ return (
123
+ <div key={child.path}>
124
+ <div
125
+ className="flex items-center gap-1 px-2 py-1.5 text-xs rounded cursor-pointer hover:bg-base-300 transition-colors opacity-80"
126
+ style={{ paddingLeft: `${depth * 12 + 8}px` }}
127
+ onClick={() => onToggleFolder(child.path)}
128
+ title={child.path}
129
+ >
130
+ <span className="text-[10px] opacity-70">{isExpanded ? '▼' : '▶'}</span>
131
+ <i className="iconoir-folder text-[14px] opacity-70" aria-hidden="true" />
132
+ <span className="truncate flex-1">{child.name}</span>
133
+ </div>
134
+ {isExpanded && (
135
+ <StatusFileTreeItem
136
+ node={child}
137
+ selectedFile={selectedFile}
138
+ expandedFolders={expandedFolders}
139
+ onToggleFolder={onToggleFolder}
140
+ onSelectFile={onSelectFile}
141
+ onActionFile={onActionFile}
142
+ actionType={actionType}
143
+ actionPending={actionPending}
144
+ depth={depth + 1}
145
+ />
146
+ )}
147
+ </div>
148
+ );
149
+ }
150
+
151
+ const filePath = child.filePath;
152
+ if (!filePath) return null;
153
+
154
+ return (
155
+ <div
156
+ key={filePath}
157
+ className={cn(
158
+ 'flex items-center justify-between gap-2 px-2 py-1.5 rounded-md cursor-pointer group hover:bg-base-300 transition-colors text-sm',
159
+ selectedFile === filePath && 'bg-base-300 font-medium text-primary'
160
+ )}
161
+ style={{ paddingLeft: `${depth * 12 + 8}px` }}
162
+ onClick={() => onSelectFile(filePath)}
163
+ title={filePath}
164
+ >
165
+ <div className="flex items-center gap-2 min-w-0 flex-1">
166
+ <i className="iconoir-page text-[14px] opacity-70 shrink-0" aria-hidden="true" />
167
+ <span className="truncate flex-1 font-mono text-xs">{child.name}</span>
168
+ </div>
169
+ <button
170
+ className={cn(
171
+ 'btn btn-ghost btn-xs btn-square opacity-0 group-hover:opacity-100 transition-opacity',
172
+ actionType === 'stage'
173
+ ? 'text-success hover:bg-success/10'
174
+ : 'text-error hover:bg-error/10'
175
+ )}
176
+ onClick={(e) => {
177
+ e.stopPropagation();
178
+ void onActionFile(filePath);
179
+ }}
180
+ disabled={actionPending}
181
+ >
182
+ <i
183
+ className={cn(
184
+ 'text-[14px]',
185
+ actionType === 'stage' ? 'iconoir-plus-circle' : 'iconoir-minus-circle'
186
+ )}
187
+ aria-hidden="true"
188
+ />
189
+ </button>
190
+ </div>
191
+ );
192
+ })}
193
+ </>
194
+ );
195
+ }
196
+
197
+ export function StatusView({ repoPath }: { repoPath: string }) {
198
+ const { data: status, isLoading, isError, error, refetch } = useGitStatus(repoPath);
199
+ const action = useGitAction();
200
+ const [subject, setSubject] = useState('');
201
+ const [body, setBody] = useState('');
202
+ const [selectedFile, setSelectedFile] = useState<string | null>(null);
203
+ const [stashDialogOpen, setStashDialogOpen] = useState(false);
204
+ const [stashMessage, setStashMessage] = useState('');
205
+ const [discardDialogOpen, setDiscardDialogOpen] = useState(false);
206
+ const [collapsedChangeFolders, setCollapsedChangeFolders] = useState<Set<string>>(new Set());
207
+ const [collapsedStagedFolders, setCollapsedStagedFolders] = useState<Set<string>>(new Set());
208
+
209
+ // Resize logic for commit box
210
+ const [commitBoxHeight, setCommitBoxHeight] = useState(250);
211
+ const [isResizing, setIsResizing] = useState(false);
212
+ const resizeRef = useRef<{ startY: number; startHeight: number } | null>(null);
213
+
214
+ useEffect(() => {
215
+ const handleMouseMove = (e: MouseEvent) => {
216
+ if (!isResizing || !resizeRef.current) return;
217
+ const delta = resizeRef.current.startY - e.clientY;
218
+ const newHeight = Math.max(150, Math.min(800, resizeRef.current.startHeight + delta));
219
+ setCommitBoxHeight(newHeight);
220
+ };
221
+
222
+ const handleMouseUp = () => {
223
+ setIsResizing(false);
224
+ resizeRef.current = null;
225
+ document.body.style.cursor = 'default';
226
+ document.body.style.userSelect = 'auto';
227
+ };
228
+
229
+ if (isResizing) {
230
+ window.addEventListener('mousemove', handleMouseMove);
231
+ window.addEventListener('mouseup', handleMouseUp);
232
+ document.body.style.cursor = 'ns-resize';
233
+ document.body.style.userSelect = 'none';
234
+ }
235
+
236
+ return () => {
237
+ window.removeEventListener('mousemove', handleMouseMove);
238
+ window.removeEventListener('mouseup', handleMouseUp);
239
+ document.body.style.cursor = 'default';
240
+ document.body.style.userSelect = 'auto';
241
+ };
242
+ }, [isResizing]);
243
+
244
+ const handleResizeStart = (e: React.MouseEvent) => {
245
+ e.preventDefault();
246
+ setIsResizing(true);
247
+ resizeRef.current = { startY: e.clientY, startHeight: commitBoxHeight };
248
+ };
249
+
250
+ const files = status?.files ?? EMPTY_FILES;
251
+ useEscapeDismiss(stashDialogOpen, () => setStashDialogOpen(false));
252
+ useEscapeDismiss(discardDialogOpen, () => setDiscardDialogOpen(false));
253
+
254
+ // Group files
255
+ const { staged, changes } = useMemo(() => {
256
+ const stagedFiles: string[] = [];
257
+ const changedFiles: string[] = [];
258
+
259
+ files.forEach((file) => {
260
+ if (file.index !== ' ' && file.index !== '?') {
261
+ stagedFiles.push(file.path);
262
+ }
263
+ if (file.working_dir !== ' ' || file.index === '?') {
264
+ changedFiles.push(file.path);
265
+ }
266
+ });
267
+
268
+ return {
269
+ staged: stagedFiles,
270
+ changes: changedFiles,
271
+ };
272
+ }, [files]);
273
+
274
+ const changesTree = useMemo(() => buildStatusFileTree(changes), [changes]);
275
+ const stagedTree = useMemo(() => buildStatusFileTree(staged), [staged]);
276
+ const allChangeFolderPaths = useMemo(() => collectFolderPaths(changesTree), [changesTree]);
277
+ const allStagedFolderPaths = useMemo(() => collectFolderPaths(stagedTree), [stagedTree]);
278
+
279
+ const expandedChangeFolders = useMemo(() => {
280
+ const expanded = new Set<string>();
281
+
282
+ allChangeFolderPaths.forEach((path) => {
283
+ if (!collapsedChangeFolders.has(path)) {
284
+ expanded.add(path);
285
+ }
286
+ });
287
+
288
+ if (selectedFile) {
289
+ getParentPaths(selectedFile).forEach((path) => expanded.add(path));
290
+ }
291
+
292
+ return expanded;
293
+ }, [allChangeFolderPaths, collapsedChangeFolders, selectedFile]);
294
+
295
+ const expandedStagedFolders = useMemo(() => {
296
+ const expanded = new Set<string>();
297
+
298
+ allStagedFolderPaths.forEach((path) => {
299
+ if (!collapsedStagedFolders.has(path)) {
300
+ expanded.add(path);
301
+ }
302
+ });
303
+
304
+ if (selectedFile) {
305
+ getParentPaths(selectedFile).forEach((path) => expanded.add(path));
306
+ }
307
+
308
+ return expanded;
309
+ }, [allStagedFolderPaths, collapsedStagedFolders, selectedFile]);
310
+
311
+ const handleToggleChangeFolder = useCallback((path: string) => {
312
+ setCollapsedChangeFolders((prev) => {
313
+ const next = new Set(prev);
314
+ if (next.has(path)) {
315
+ next.delete(path);
316
+ } else {
317
+ next.add(path);
318
+ }
319
+ return next;
320
+ });
321
+ }, []);
322
+
323
+ const handleToggleStagedFolder = useCallback((path: string) => {
324
+ setCollapsedStagedFolders((prev) => {
325
+ const next = new Set(prev);
326
+ if (next.has(path)) {
327
+ next.delete(path);
328
+ } else {
329
+ next.add(path);
330
+ }
331
+ return next;
332
+ });
333
+ }, []);
334
+
335
+ const handleStage = async (file: string) => {
336
+ await action.mutateAsync({ repoPath, action: 'stage', data: { files: [file] } });
337
+ };
338
+
339
+ const handleUnstage = async (file: string) => {
340
+ await action.mutateAsync({ repoPath, action: 'unstage', data: { files: [file] } });
341
+ };
342
+
343
+ const handleStageAll = async () => {
344
+ await action.mutateAsync({ repoPath, action: 'stage', data: { files: ['.'] } });
345
+ }
346
+
347
+ const handleUnstageAll = async () => {
348
+ await action.mutateAsync({ repoPath, action: 'unstage', data: { files: staged } });
349
+ }
350
+
351
+ const handleStash = async () => {
352
+ await action.mutateAsync({ repoPath, action: 'stash', data: { message: stashMessage || undefined } });
353
+ setStashDialogOpen(false);
354
+ setStashMessage('');
355
+ setSelectedFile(null);
356
+ }
357
+
358
+ const handleDiscard = async () => {
359
+ await action.mutateAsync({ repoPath, action: 'discard', data: { includeUntracked: true } });
360
+ setDiscardDialogOpen(false);
361
+ setSelectedFile(null);
362
+ }
363
+
364
+ const handleCommit = async () => {
365
+ const trimmedSubject = subject.trim();
366
+ if (!trimmedSubject) return;
367
+ await action.mutateAsync({
368
+ repoPath,
369
+ action: 'commit',
370
+ data: { message: buildCommitMessage(trimmedSubject, body) },
371
+ });
372
+ setSubject('');
373
+ setBody('');
374
+ setSelectedFile(null);
375
+ };
376
+
377
+ const handleCommitShortcut = (e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
378
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
379
+ e.preventDefault();
380
+ if (staged.length > 0 && subject.trim() && !action.isPending) {
381
+ handleCommit();
382
+ }
383
+ }
384
+ };
385
+
386
+ if (isLoading) {
387
+ return <div className="flex items-center justify-center h-64"><span className="loading loading-spinner text-base-content/50"></span></div>;
388
+ }
389
+
390
+ if (isError) {
391
+ return (
392
+ <div className="flex items-center justify-center h-64 flex-col gap-4">
393
+ <p className="text-error font-bold">Error Loading Status</p>
394
+ <p className="text-sm opacity-70">{(error as Error)?.message || 'An unknown error occurred'}</p>
395
+ <button onClick={() => refetch()} className="btn btn-outline btn-sm">
396
+ <i className="iconoir-refresh-circle text-[16px] mr-1" aria-hidden="true" />
397
+ Try Again
398
+ </button>
399
+ </div>
400
+ );
401
+ }
402
+
403
+ if (!status) return <div className="flex items-center justify-center h-64 opacity-70">No status data available</div>;
404
+
405
+ return (
406
+ <div className="flex h-full overflow-hidden">
407
+ {/* Left Panel: File List */}
408
+ <div className="w-64 border-r border-base-300 flex flex-col bg-base-200/30">
409
+ <div className="h-[57px] px-4 border-b border-base-300 flex items-center justify-between bg-base-100">
410
+ <h1 className="font-bold text-lg">Changes</h1>
411
+ <button className="btn btn-ghost btn-sm btn-square" onClick={() => refetch()} disabled={action.isPending} title="Refresh">
412
+ {action.isPending ? <span className="loading loading-spinner loading-xs"></span> : <i className="iconoir-refresh-circle text-[16px]" aria-hidden="true" />}
413
+ </button>
414
+ </div>
415
+
416
+ <div className="flex-1 overflow-y-auto">
417
+ {/* Unstaged Changes */}
418
+ <div className="p-2">
419
+ <div className="flex items-center justify-between px-2 py-2 mb-1">
420
+ <h3 className="text-xs font-bold uppercase tracking-wider opacity-70">Changes ({changes.length})</h3>
421
+ <div className="flex items-center gap-0.5">
422
+ {changes.length === 0 && staged.length > 0 ? (
423
+ <button className="btn btn-ghost btn-xs btn-square" onClick={handleUnstageAll} title="Unstage All">
424
+ <i className="iconoir-arrow-up text-[16px]" aria-hidden="true" />
425
+ </button>
426
+ ) : (
427
+ <button className="btn btn-ghost btn-xs btn-square" onClick={handleStageAll} disabled={changes.length === 0} title="Stage All">
428
+ <i className="iconoir-arrow-down text-[16px]" aria-hidden="true" />
429
+ </button>
430
+ )}
431
+ <button className="btn btn-ghost btn-xs btn-square" onClick={() => setStashDialogOpen(true)} disabled={changes.length === 0 && staged.length === 0} title="Stash">
432
+ <i className="iconoir-download-square text-[16px]" aria-hidden="true" />
433
+ </button>
434
+ <button className="btn btn-ghost btn-xs btn-square text-error hover:bg-error/10" onClick={() => setDiscardDialogOpen(true)} disabled={changes.length === 0} title="Discard All">
435
+ <i className="iconoir-trash text-[16px]" aria-hidden="true" />
436
+ </button>
437
+ </div>
438
+ </div>
439
+ <div className="space-y-0.5">
440
+ {changes.length === 0 && <p className="px-2 py-2 text-xs opacity-50 italic">No changes</p>}
441
+ {changes.length > 0 && (
442
+ <StatusFileTreeItem
443
+ node={changesTree}
444
+ selectedFile={selectedFile}
445
+ expandedFolders={expandedChangeFolders}
446
+ onToggleFolder={handleToggleChangeFolder}
447
+ onSelectFile={setSelectedFile}
448
+ onActionFile={handleStage}
449
+ actionType="stage"
450
+ actionPending={action.isPending}
451
+ />
452
+ )}
453
+ </div>
454
+ </div>
455
+
456
+ <div className="h-px bg-base-300 mx-4 my-2" />
457
+
458
+ {/* Staged Changes */}
459
+ <div className="p-2">
460
+ <div className="flex items-center justify-between px-2 py-2 mb-1">
461
+ <h3 className="text-xs font-bold uppercase tracking-wider opacity-70">Staged ({staged.length})</h3>
462
+ </div>
463
+ <div className="space-y-0.5">
464
+ {staged.length === 0 && <p className="px-2 py-2 text-xs opacity-50 italic">No staged changes</p>}
465
+ {staged.length > 0 && (
466
+ <StatusFileTreeItem
467
+ node={stagedTree}
468
+ selectedFile={selectedFile}
469
+ expandedFolders={expandedStagedFolders}
470
+ onToggleFolder={handleToggleStagedFolder}
471
+ onSelectFile={setSelectedFile}
472
+ onActionFile={handleUnstage}
473
+ actionType="unstage"
474
+ actionPending={action.isPending}
475
+ />
476
+ )}
477
+ </div>
478
+ </div>
479
+ </div>
480
+ </div>
481
+
482
+ {/* Right Panel: Diff View & Commit Box */}
483
+ <div className="flex-1 flex flex-col bg-base-100 overflow-hidden">
484
+ {/* Diff View Area */}
485
+ <div className="flex-1 overflow-hidden flex flex-col min-h-0">
486
+ {selectedFile ? (
487
+ <div className="h-full flex flex-col">
488
+ <DiffView repoPath={repoPath} filePath={selectedFile} />
489
+ </div>
490
+ ) : (
491
+ <div className="flex-1 flex flex-col items-center justify-center opacity-50">
492
+ <div className="p-8 rounded-full bg-base-200 mb-4 text-4xl">
493
+ <i className="iconoir-refresh-circle text-[32px]" aria-hidden="true" />
494
+ </div>
495
+ <p className="text-sm font-bold">Select a file to view changes</p>
496
+ </div>
497
+ )}
498
+ </div>
499
+
500
+ {/* Resize Handle */}
501
+ <div
502
+ className="h-1.5 cursor-ns-resize flex items-center justify-center hover:bg-base-200 transition-colors group shrink-0 border-t border-base-300"
503
+ onMouseDown={handleResizeStart}
504
+ >
505
+ <div className="w-8 h-1 rounded-full bg-base-300 group-hover:bg-base-400 transition-colors" />
506
+ </div>
507
+
508
+ {/* Commit Box */}
509
+ <div
510
+ className="flex flex-col border-t border-base-300 bg-base-100 shrink-0"
511
+ style={{ height: commitBoxHeight }}
512
+ >
513
+ <div className="flex-1 p-4 overflow-y-auto">
514
+ <input
515
+ type="text"
516
+ placeholder="Commit subject..."
517
+ value={subject}
518
+ onChange={e => setSubject(e.target.value)}
519
+ onKeyDown={handleCommitShortcut}
520
+ className="input input-bordered w-full text-sm mb-2 font-sans"
521
+ />
522
+ <textarea
523
+ placeholder="Commit message body (optional)..."
524
+ value={body}
525
+ onChange={e => setBody(e.target.value)}
526
+ onKeyDown={handleCommitShortcut}
527
+ className="textarea textarea-bordered w-full text-sm resize-none mb-3 font-sans flex-1"
528
+ style={{ minHeight: '80px', height: 'calc(100% - 90px)' }}
529
+ />
530
+ <button className="btn btn-primary w-full btn-sm" onClick={handleCommit} disabled={staged.length === 0 || !subject.trim() || action.isPending}>
531
+ {action.isPending ? <span className="loading loading-spinner loading-xs mr-2"></span> : <span className="mr-2">✅</span>}
532
+ Commit Changes
533
+ </button>
534
+ </div>
535
+ </div>
536
+ </div>
537
+
538
+ {/* Stash Dialog */}
539
+ {stashDialogOpen && (
540
+ <dialog className="modal modal-open">
541
+ <div className="modal-box">
542
+ <h3 className="font-bold text-lg">Stash Changes</h3>
543
+ <p className="py-4 opacity-70">Save your local modifications to a new stash entry.</p>
544
+ <div className="py-2">
545
+ <input
546
+ type="text"
547
+ placeholder="Stash message (optional)"
548
+ value={stashMessage}
549
+ onChange={(e) => setStashMessage(e.target.value)}
550
+ autoFocus
551
+ onKeyDown={(e) => {
552
+ if (e.key === 'Enter') {
553
+ e.preventDefault();
554
+ handleStash();
555
+ }
556
+ }}
557
+ className="input input-bordered w-full"
558
+ />
559
+ </div>
560
+ <div className="modal-action">
561
+ <button className="btn" onClick={() => setStashDialogOpen(false)}>Cancel</button>
562
+ <button className="btn btn-primary" onClick={handleStash} disabled={action.isPending}>
563
+ {action.isPending && <span className="loading loading-spinner loading-xs"></span>}
564
+ Stash
565
+ </button>
566
+ </div>
567
+ </div>
568
+ <form method="dialog" className="modal-backdrop">
569
+ <button onClick={() => setStashDialogOpen(false)}>close</button>
570
+ </form>
571
+ </dialog>
572
+ )}
573
+
574
+ {/* Discard Dialog */}
575
+ {discardDialogOpen && (
576
+ <dialog className="modal modal-open">
577
+ <div className="modal-box">
578
+ <h3 className="font-bold text-lg">Discard Changes</h3>
579
+ <p className="py-4">
580
+ Are you sure you want to discard all unstaged changes and new files? This action cannot be undone.
581
+ </p>
582
+ <div className="modal-action">
583
+ <button className="btn" onClick={() => setDiscardDialogOpen(false)}>Cancel</button>
584
+ <button className="btn btn-error" onClick={handleDiscard} disabled={action.isPending}>
585
+ {action.isPending && <span className="loading loading-spinner loading-xs"></span>}
586
+ Discard
587
+ </button>
588
+ </div>
589
+ </div>
590
+ <form method="dialog" className="modal-backdrop">
591
+ <button onClick={() => setDiscardDialogOpen(false)}>close</button>
592
+ </form>
593
+ </dialog>
594
+ )}
595
+ </div>
596
+ );
597
+ }