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.
- package/README.md +198 -0
- package/bin/trident-git.mjs +153 -0
- package/eslint.config.mjs +18 -0
- package/next.config.ts +30 -0
- package/package.json +60 -0
- package/postcss.config.mjs +7 -0
- package/public/favicon.png +0 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/src/app/api/credentials/route.ts +113 -0
- package/src/app/api/custom-scripts/route.ts +203 -0
- package/src/app/api/fs/route.ts +75 -0
- package/src/app/api/git/action/route.ts +383 -0
- package/src/app/api/git/branches/route.ts +20 -0
- package/src/app/api/git/diff/route.ts +104 -0
- package/src/app/api/git/log/route.ts +28 -0
- package/src/app/api/git/status/route.ts +28 -0
- package/src/app/api/repos/route.ts +84 -0
- package/src/app/api/settings/route.ts +37 -0
- package/src/app/credentials/page.tsx +408 -0
- package/src/app/globals.css +109 -0
- package/src/app/icon.png +0 -0
- package/src/app/layout.tsx +38 -0
- package/src/app/page.tsx +10 -0
- package/src/app/providers.tsx +21 -0
- package/src/app/workspace/changes/page.tsx +27 -0
- package/src/app/workspace/custom-scripts/page.tsx +247 -0
- package/src/app/workspace/history/page.tsx +27 -0
- package/src/app/workspace/layout.tsx +26 -0
- package/src/app/workspace/page.tsx +27 -0
- package/src/app/workspace/settings/page.tsx +233 -0
- package/src/app/workspace/stashes/page.tsx +395 -0
- package/src/components/command-palette.tsx +178 -0
- package/src/components/context-menu.tsx +200 -0
- package/src/components/fs-browser.tsx +154 -0
- package/src/components/git/diff-view.tsx +137 -0
- package/src/components/git/git-graph.tsx +489 -0
- package/src/components/git/grouped-diff-viewer.tsx +332 -0
- package/src/components/git/history-view.tsx +4862 -0
- package/src/components/git/image-diff-view.tsx +342 -0
- package/src/components/git/status-view.tsx +597 -0
- package/src/components/home-settings-modal.tsx +192 -0
- package/src/components/layout/sidebar.tsx +256 -0
- package/src/components/repo-list.tsx +206 -0
- package/src/components/theme-toggle.tsx +37 -0
- package/src/components/toaster.tsx +36 -0
- package/src/components/workspace-repo-open-tracker.tsx +39 -0
- package/src/hooks/use-credentials.ts +123 -0
- package/src/hooks/use-escape-dismiss.ts +72 -0
- package/src/hooks/use-git.ts +448 -0
- package/src/hooks/use-toast.ts +280 -0
- package/src/hooks/use-workspace-title.ts +23 -0
- package/src/lib/api-utils.ts +24 -0
- package/src/lib/branch-colors.ts +98 -0
- package/src/lib/credentials.ts +404 -0
- package/src/lib/git.ts +1510 -0
- package/src/lib/graph-utils.ts +253 -0
- package/src/lib/store.ts +145 -0
- package/src/lib/types.ts +95 -0
- package/src/lib/utils.ts +266 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useSearchParams } from 'next/navigation';
|
|
4
|
+
import { Suspense, useState, useEffect } from 'react';
|
|
5
|
+
import { useWorkspaceTitle } from '@/hooks/use-workspace-title';
|
|
6
|
+
import { useGitStashes, useGitAction, useStashFiles, useStashFileDiff } from '@/hooks/use-git';
|
|
7
|
+
import { cn, getChangedLineCountFromDiff, isFileBinary, isImageFile } from '@/lib/utils';
|
|
8
|
+
import { useTheme } from 'next-themes';
|
|
9
|
+
import { GroupedDiffViewer } from '@/components/git/grouped-diff-viewer';
|
|
10
|
+
import { ImageDiffView } from '@/components/git/image-diff-view';
|
|
11
|
+
|
|
12
|
+
function StashDiffView({ repoPath, stashIndex, filePath }: { repoPath: string; stashIndex: number; filePath: string }) {
|
|
13
|
+
const { data, isLoading } = useStashFileDiff(repoPath, stashIndex, filePath);
|
|
14
|
+
const { resolvedTheme } = useTheme();
|
|
15
|
+
const storageKey = 'git-web:diff-view-split';
|
|
16
|
+
|
|
17
|
+
const [splitView, setSplitView] = useState(() => {
|
|
18
|
+
if (typeof window === 'undefined') return true;
|
|
19
|
+
try {
|
|
20
|
+
const stored = localStorage.getItem(storageKey);
|
|
21
|
+
return stored !== null ? JSON.parse(stored) : true;
|
|
22
|
+
} catch {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const [renderAnyway, setRenderAnyway] = useState(false);
|
|
28
|
+
|
|
29
|
+
// Reset renderAnyway when file or stash changes
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
setRenderAnyway(false);
|
|
32
|
+
}, [filePath, stashIndex]);
|
|
33
|
+
|
|
34
|
+
if (isLoading) {
|
|
35
|
+
return <div className="flex items-center justify-center p-8 h-full"><span className="loading loading-spinner text-base-content/50"></span></div>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!data) {
|
|
39
|
+
return (
|
|
40
|
+
<div className="flex items-center justify-center h-full opacity-50">
|
|
41
|
+
No diff available
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (isImageFile(filePath)) {
|
|
47
|
+
return (
|
|
48
|
+
<div className="flex flex-col h-full bg-base-100">
|
|
49
|
+
<div className="flex items-center justify-between px-4 h-[57px] border-b border-base-300 shrink-0 bg-base-100">
|
|
50
|
+
<span className="text-sm font-mono truncate max-w-[70%]" title={filePath}>{filePath}</span>
|
|
51
|
+
</div>
|
|
52
|
+
<div className="flex-1 overflow-auto">
|
|
53
|
+
<ImageDiffView filePath={filePath} imageDiff={data.imageDiff} />
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const isBinary = isFileBinary(filePath, data.left, data.right);
|
|
60
|
+
|
|
61
|
+
if (isBinary) {
|
|
62
|
+
return (
|
|
63
|
+
<div className="flex flex-col h-full bg-base-100">
|
|
64
|
+
<div className="flex items-center justify-between px-4 h-[57px] border-b border-base-300 shrink-0 bg-base-100">
|
|
65
|
+
<span className="text-sm font-mono truncate max-w-[70%]" title={filePath}>{filePath}</span>
|
|
66
|
+
</div>
|
|
67
|
+
<div className="flex-1 flex items-center justify-center opacity-50">
|
|
68
|
+
Binary file - diff not available
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Large file protection
|
|
75
|
+
const MAX_DIFF_SIZE = 100 * 1024; // 100KB
|
|
76
|
+
const MAX_DIFF_LINES = 3000;
|
|
77
|
+
|
|
78
|
+
const diffContent = data.diff || '';
|
|
79
|
+
const contentSize = diffContent.length;
|
|
80
|
+
const lineCount = getChangedLineCountFromDiff(diffContent);
|
|
81
|
+
|
|
82
|
+
const isLargeDiff = (contentSize > MAX_DIFF_SIZE || lineCount > MAX_DIFF_LINES);
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="flex flex-col h-full bg-base-100">
|
|
86
|
+
<div className="flex items-center justify-between px-4 h-[57px] border-b border-base-300 shrink-0 bg-base-100">
|
|
87
|
+
<span className="text-sm font-mono truncate max-w-[70%]" title={filePath}>{filePath}</span>
|
|
88
|
+
<div className="flex items-center gap-2">
|
|
89
|
+
<label htmlFor="split-view-stash" className="text-[10px] uppercase tracking-wider font-bold cursor-pointer opacity-70">Split View</label>
|
|
90
|
+
<input
|
|
91
|
+
type="checkbox"
|
|
92
|
+
id="split-view-stash"
|
|
93
|
+
checked={splitView}
|
|
94
|
+
onChange={(e) => {
|
|
95
|
+
setSplitView(e.target.checked);
|
|
96
|
+
try {
|
|
97
|
+
localStorage.setItem(storageKey, JSON.stringify(e.target.checked));
|
|
98
|
+
} catch { }
|
|
99
|
+
}}
|
|
100
|
+
className="toggle toggle-xs toggle-primary"
|
|
101
|
+
/>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
<div className="flex-1 overflow-auto diff-viewer-wrapper">
|
|
105
|
+
{isLargeDiff && !renderAnyway ? (
|
|
106
|
+
<div className="flex flex-col items-center justify-center h-full gap-4 text-center p-4">
|
|
107
|
+
<i className="iconoir-warning-triangle text-[32px] text-warning" aria-hidden="true" />
|
|
108
|
+
<div className="space-y-2">
|
|
109
|
+
<h3 className="font-bold text-lg">Large Diff Detected</h3>
|
|
110
|
+
<p className="opacity-70">
|
|
111
|
+
This diff is large ({Math.round(contentSize / 1024)}KB, ~{lineCount} changed lines) and may freeze your browser if rendered.
|
|
112
|
+
</p>
|
|
113
|
+
</div>
|
|
114
|
+
<button className="btn btn-outline" onClick={() => setRenderAnyway(true)}>
|
|
115
|
+
Show Diff Anyway
|
|
116
|
+
</button>
|
|
117
|
+
</div>
|
|
118
|
+
) : (
|
|
119
|
+
<GroupedDiffViewer
|
|
120
|
+
oldValue={data.left || ''}
|
|
121
|
+
newValue={data.right || ''}
|
|
122
|
+
splitView={splitView}
|
|
123
|
+
useDarkTheme={resolvedTheme === 'dark'}
|
|
124
|
+
/>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function StashesContent() {
|
|
132
|
+
const searchParams = useSearchParams();
|
|
133
|
+
const repoPath = searchParams.get('path');
|
|
134
|
+
const { data: stashes, isLoading, isError, error, refetch } = useGitStashes(repoPath);
|
|
135
|
+
const action = useGitAction();
|
|
136
|
+
const [selectedStashIndex, setSelectedStashIndex] = useState<number | null>(null);
|
|
137
|
+
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
|
138
|
+
const [expandedStashes, setExpandedStashes] = useState<Set<number>>(new Set());
|
|
139
|
+
|
|
140
|
+
const { data: stashFiles, isLoading: filesLoading } = useStashFiles(repoPath, selectedStashIndex);
|
|
141
|
+
|
|
142
|
+
useWorkspaceTitle(repoPath, 'Stashes');
|
|
143
|
+
|
|
144
|
+
if (!repoPath) {
|
|
145
|
+
return <div className="p-8">No repository path specified.</div>;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (isLoading) {
|
|
149
|
+
return (
|
|
150
|
+
<div className="flex items-center justify-center h-64">
|
|
151
|
+
<span className="loading loading-spinner text-base-content/50"></span>
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (isError) {
|
|
157
|
+
return (
|
|
158
|
+
<div className="flex items-center justify-center h-64 flex-col gap-4">
|
|
159
|
+
<p className="text-error font-medium">Error Loading Stashes</p>
|
|
160
|
+
<p className="text-sm opacity-70">{(error as Error)?.message || 'An unknown error occurred'}</p>
|
|
161
|
+
<button onClick={() => refetch()} className="btn btn-outline btn-sm">
|
|
162
|
+
<i className="iconoir-refresh-circle text-[16px] mr-1" aria-hidden="true" />
|
|
163
|
+
Try Again
|
|
164
|
+
</button>
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const handleApply = async (index: number) => {
|
|
170
|
+
await action.mutateAsync({ repoPath, action: 'stash-apply', data: { index } });
|
|
171
|
+
refetch();
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const handlePop = async (index: number) => {
|
|
175
|
+
await action.mutateAsync({ repoPath, action: 'stash-pop', data: { index } });
|
|
176
|
+
setSelectedStashIndex(null);
|
|
177
|
+
setSelectedFile(null);
|
|
178
|
+
refetch();
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const handleDrop = async (index: number) => {
|
|
182
|
+
await action.mutateAsync({ repoPath, action: 'stash-drop', data: { index } });
|
|
183
|
+
if (selectedStashIndex === index) {
|
|
184
|
+
setSelectedStashIndex(null);
|
|
185
|
+
setSelectedFile(null);
|
|
186
|
+
}
|
|
187
|
+
refetch();
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const toggleStashExpanded = (index: number) => {
|
|
191
|
+
const newExpanded = new Set(expandedStashes);
|
|
192
|
+
if (newExpanded.has(index)) {
|
|
193
|
+
newExpanded.delete(index);
|
|
194
|
+
} else {
|
|
195
|
+
newExpanded.add(index);
|
|
196
|
+
}
|
|
197
|
+
setExpandedStashes(newExpanded);
|
|
198
|
+
setSelectedStashIndex(index);
|
|
199
|
+
setSelectedFile(null);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const formatDate = (dateString: string) => {
|
|
203
|
+
try {
|
|
204
|
+
const date = new Date(dateString);
|
|
205
|
+
const now = new Date();
|
|
206
|
+
const diffMs = now.getTime() - date.getTime();
|
|
207
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
208
|
+
const diffHours = Math.floor(diffMs / 3600000);
|
|
209
|
+
const diffDays = Math.floor(diffMs / 86400000);
|
|
210
|
+
|
|
211
|
+
if (diffMins < 1) return 'just now';
|
|
212
|
+
if (diffMins < 60) return `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`;
|
|
213
|
+
if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
|
|
214
|
+
if (diffDays < 30) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
|
|
215
|
+
return date.toLocaleDateString();
|
|
216
|
+
} catch {
|
|
217
|
+
return dateString;
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const getStatusColor = (status: string) => {
|
|
222
|
+
switch (status) {
|
|
223
|
+
case 'A': return 'text-success';
|
|
224
|
+
case 'D': return 'text-error';
|
|
225
|
+
case 'M': return 'text-warning';
|
|
226
|
+
default: return 'opacity-50';
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const getStatusLabel = (status: string) => {
|
|
231
|
+
switch (status) {
|
|
232
|
+
case 'A': return 'Added';
|
|
233
|
+
case 'D': return 'Deleted';
|
|
234
|
+
case 'M': return 'Modified';
|
|
235
|
+
default: return status;
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<div className="flex h-full overflow-hidden">
|
|
241
|
+
{/* Left Panel: Stash List */}
|
|
242
|
+
<div className="w-64 border-r border-base-300 flex flex-col bg-base-200/30">
|
|
243
|
+
<div className="h-[57px] px-4 border-b border-base-300 flex items-center justify-between bg-base-100">
|
|
244
|
+
<h1 className="font-bold text-lg">Stashes</h1>
|
|
245
|
+
<button className="btn btn-ghost btn-sm btn-square" onClick={() => refetch()} disabled={action.isPending} title="Refresh">
|
|
246
|
+
{action.isPending ? <span className="loading loading-spinner loading-xs"></span> : <i className="iconoir-refresh-circle text-[16px]" aria-hidden="true" />}
|
|
247
|
+
</button>
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
<div className="flex-1 overflow-y-auto overflow-x-hidden">
|
|
251
|
+
{!stashes || stashes.length === 0 ? (
|
|
252
|
+
<div className="flex-1 flex flex-col items-center justify-center opacity-50 h-64">
|
|
253
|
+
<div className="p-8 rounded-full bg-base-200 mb-4 text-4xl">
|
|
254
|
+
📦
|
|
255
|
+
</div>
|
|
256
|
+
<p className="text-sm font-bold">No stashes</p>
|
|
257
|
+
<p className="text-xs mt-1">Stash changes from the Changes page</p>
|
|
258
|
+
</div>
|
|
259
|
+
) : (
|
|
260
|
+
<div className="p-2">
|
|
261
|
+
{stashes.map((stash) => {
|
|
262
|
+
const isExpanded = expandedStashes.has(stash.index);
|
|
263
|
+
const isSelected = selectedStashIndex === stash.index;
|
|
264
|
+
|
|
265
|
+
return (
|
|
266
|
+
<div key={stash.hash} className="mb-1">
|
|
267
|
+
<div
|
|
268
|
+
className={cn(
|
|
269
|
+
"p-2 rounded-md cursor-pointer transition-colors",
|
|
270
|
+
isSelected ? "bg-base-200" : "hover:bg-base-200/50"
|
|
271
|
+
)}
|
|
272
|
+
onClick={() => toggleStashExpanded(stash.index)}
|
|
273
|
+
>
|
|
274
|
+
<div className="flex flex-col gap-1">
|
|
275
|
+
{/* Row 1: ID and Buttons */}
|
|
276
|
+
<div className="flex items-center justify-between">
|
|
277
|
+
<div className="flex items-center gap-2">
|
|
278
|
+
<div className="opacity-50 text-[10px]">
|
|
279
|
+
{isExpanded ? '▼' : '▶'}
|
|
280
|
+
</div>
|
|
281
|
+
<span className="text-[10px] font-mono bg-base-300 px-1 py-0.5 rounded opacity-70">
|
|
282
|
+
stash@{'{' + stash.index + '}'}
|
|
283
|
+
</span>
|
|
284
|
+
</div>
|
|
285
|
+
<div className="flex items-center gap-1 shrink-0">
|
|
286
|
+
<div className="tooltip tooltip-left before:whitespace-normal before:max-w-[120px]" data-tip="Apply stash (keep)">
|
|
287
|
+
<button
|
|
288
|
+
className="btn btn-ghost btn-xs btn-square"
|
|
289
|
+
onClick={(e) => { e.stopPropagation(); handleApply(stash.index); }}
|
|
290
|
+
disabled={action.isPending}
|
|
291
|
+
>
|
|
292
|
+
<i className="iconoir-play text-[16px]" aria-hidden="true" />
|
|
293
|
+
</button>
|
|
294
|
+
</div>
|
|
295
|
+
<div className="tooltip tooltip-left before:whitespace-normal before:max-w-[120px]" data-tip="Pop stash (apply and delete)">
|
|
296
|
+
<button
|
|
297
|
+
className="btn btn-ghost btn-xs btn-square"
|
|
298
|
+
onClick={(e) => { e.stopPropagation(); handlePop(stash.index); }}
|
|
299
|
+
disabled={action.isPending}
|
|
300
|
+
>
|
|
301
|
+
<i className="iconoir-u-turn-arrow-left text-[16px]" aria-hidden="true" />
|
|
302
|
+
</button>
|
|
303
|
+
</div>
|
|
304
|
+
<div className="tooltip tooltip-left before:whitespace-normal before:max-w-[120px]" data-tip="Delete stash">
|
|
305
|
+
<button
|
|
306
|
+
className="btn btn-ghost btn-xs btn-square text-error hover:bg-error/10"
|
|
307
|
+
onClick={(e) => { e.stopPropagation(); handleDrop(stash.index); }}
|
|
308
|
+
disabled={action.isPending}
|
|
309
|
+
>
|
|
310
|
+
<i className="iconoir-trash text-[16px]" aria-hidden="true" />
|
|
311
|
+
</button>
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
{/* Row 2: Message */}
|
|
317
|
+
<div className="tooltip tooltip-bottom before:whitespace-normal before:max-w-[200px] w-full block text-left" data-tip={stash.message}>
|
|
318
|
+
<p className="text-xs font-bold truncate opacity-90 w-full">
|
|
319
|
+
{stash.message}
|
|
320
|
+
</p>
|
|
321
|
+
</div>
|
|
322
|
+
|
|
323
|
+
{/* Row 3: Metadata */}
|
|
324
|
+
<div className="text-[10px] opacity-50">
|
|
325
|
+
{formatDate(stash.date)}
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
|
|
330
|
+
{/* Files list when expanded */}
|
|
331
|
+
{isExpanded && isSelected && (
|
|
332
|
+
<div className="ml-6 mt-1 space-y-0.5">
|
|
333
|
+
{filesLoading ? (
|
|
334
|
+
<div className="flex items-center gap-2 px-2 py-1 text-xs opacity-50">
|
|
335
|
+
<span className="loading loading-spinner loading-xs"></span>
|
|
336
|
+
Loading files...
|
|
337
|
+
</div>
|
|
338
|
+
) : stashFiles && stashFiles.length > 0 ? (
|
|
339
|
+
stashFiles.map((file) => (
|
|
340
|
+
<div
|
|
341
|
+
key={file.path}
|
|
342
|
+
className={cn(
|
|
343
|
+
"flex items-center gap-2 px-2 py-1 rounded cursor-pointer transition-colors text-xs",
|
|
344
|
+
selectedFile === file.path ? "bg-primary/10 text-primary font-bold" : "hover:bg-base-200/50"
|
|
345
|
+
)}
|
|
346
|
+
onClick={(e) => { e.stopPropagation(); setSelectedFile(file.path); }}
|
|
347
|
+
>
|
|
348
|
+
<i className="iconoir-page text-[14px] opacity-50" aria-hidden="true" />
|
|
349
|
+
<span className="font-mono truncate flex-1" title={file.path}>{file.path}</span>
|
|
350
|
+
<span className={cn("text-[10px] uppercase font-bold", getStatusColor(file.status))} title={getStatusLabel(file.status)}>
|
|
351
|
+
{file.status}
|
|
352
|
+
</span>
|
|
353
|
+
</div>
|
|
354
|
+
))
|
|
355
|
+
) : (
|
|
356
|
+
<div className="px-2 py-1 text-xs opacity-50 italic">
|
|
357
|
+
No files in stash
|
|
358
|
+
</div>
|
|
359
|
+
)}
|
|
360
|
+
</div>
|
|
361
|
+
)}
|
|
362
|
+
</div>
|
|
363
|
+
);
|
|
364
|
+
})}
|
|
365
|
+
</div>
|
|
366
|
+
)}
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
|
|
370
|
+
{/* Right Panel: Diff View */}
|
|
371
|
+
<div className="flex-1 flex flex-col bg-base-100 overflow-hidden">
|
|
372
|
+
{selectedStashIndex !== null && selectedFile ? (
|
|
373
|
+
<StashDiffView repoPath={repoPath} stashIndex={selectedStashIndex} filePath={selectedFile} />
|
|
374
|
+
) : (
|
|
375
|
+
<div className="flex-1 flex flex-col items-center justify-center opacity-50">
|
|
376
|
+
<div className="p-8 rounded-full bg-base-200 mb-4 text-4xl">
|
|
377
|
+
📦
|
|
378
|
+
</div>
|
|
379
|
+
<p className="text-sm font-bold">
|
|
380
|
+
{selectedStashIndex !== null ? 'Select a file to view changes' : 'Select a stash to view files'}
|
|
381
|
+
</p>
|
|
382
|
+
</div>
|
|
383
|
+
)}
|
|
384
|
+
</div>
|
|
385
|
+
</div>
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export default function WorkspaceStashesPage() {
|
|
390
|
+
return (
|
|
391
|
+
<Suspense fallback={<div className="flex items-center justify-center h-full"><span className="loading loading-spinner"></span></div>}>
|
|
392
|
+
<StashesContent />
|
|
393
|
+
</Suspense>
|
|
394
|
+
);
|
|
395
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import { Repository } from '@/lib/types';
|
|
6
|
+
import { useRepositories } from '@/hooks/use-git';
|
|
7
|
+
import { cn, getRepositoryDisplayName } from '@/lib/utils';
|
|
8
|
+
import { useEscapeDismiss } from '@/hooks/use-escape-dismiss';
|
|
9
|
+
|
|
10
|
+
function sortByLastOpenedDesc(a: Repository, b: Repository) {
|
|
11
|
+
return new Date(b.lastOpenedAt || 0).getTime() - new Date(a.lastOpenedAt || 0).getTime();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function CommandPalette() {
|
|
15
|
+
const router = useRouter();
|
|
16
|
+
const { data: repositories } = useRepositories();
|
|
17
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
18
|
+
const [query, setQuery] = useState('');
|
|
19
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
20
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
21
|
+
|
|
22
|
+
const recentRepositories = useMemo(
|
|
23
|
+
() =>
|
|
24
|
+
(repositories || [])
|
|
25
|
+
.filter((repo) => Boolean(repo.lastOpenedAt))
|
|
26
|
+
.sort(sortByLastOpenedDesc)
|
|
27
|
+
.slice(0, 5),
|
|
28
|
+
[repositories]
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const filteredRepositories = useMemo(() => {
|
|
32
|
+
const keyword = query.trim().toLowerCase();
|
|
33
|
+
if (!keyword) return recentRepositories;
|
|
34
|
+
|
|
35
|
+
return recentRepositories.filter((repo) => {
|
|
36
|
+
const name = getRepositoryDisplayName(repo).toLowerCase();
|
|
37
|
+
const repoPath = repo.path.toLowerCase();
|
|
38
|
+
return name.includes(keyword) || repoPath.includes(keyword);
|
|
39
|
+
});
|
|
40
|
+
}, [query, recentRepositories]);
|
|
41
|
+
|
|
42
|
+
const closePalette = useCallback(() => {
|
|
43
|
+
setIsOpen(false);
|
|
44
|
+
setQuery('');
|
|
45
|
+
setSelectedIndex(0);
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
const activeIndex =
|
|
49
|
+
filteredRepositories.length === 0
|
|
50
|
+
? -1
|
|
51
|
+
: Math.min(selectedIndex, filteredRepositories.length - 1);
|
|
52
|
+
|
|
53
|
+
const openRepository = useCallback(
|
|
54
|
+
(repoPath: string) => {
|
|
55
|
+
closePalette();
|
|
56
|
+
router.push(`/workspace?path=${encodeURIComponent(repoPath)}`);
|
|
57
|
+
},
|
|
58
|
+
[closePalette, router]
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
const onGlobalKeyDown = (event: KeyboardEvent) => {
|
|
63
|
+
const isCommandOpenShortcut = (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k';
|
|
64
|
+
if (isCommandOpenShortcut) {
|
|
65
|
+
event.preventDefault();
|
|
66
|
+
setIsOpen(true);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
window.addEventListener('keydown', onGlobalKeyDown);
|
|
71
|
+
return () => window.removeEventListener('keydown', onGlobalKeyDown);
|
|
72
|
+
}, []);
|
|
73
|
+
|
|
74
|
+
useEscapeDismiss(isOpen, closePalette);
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!isOpen) return;
|
|
78
|
+
requestAnimationFrame(() => inputRef.current?.focus());
|
|
79
|
+
}, [isOpen]);
|
|
80
|
+
|
|
81
|
+
if (!isOpen) return null;
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div
|
|
85
|
+
className="fixed inset-0 z-50 bg-black/40 px-4 py-16"
|
|
86
|
+
onMouseDown={closePalette}
|
|
87
|
+
role="dialog"
|
|
88
|
+
aria-modal="true"
|
|
89
|
+
aria-label="Command palette"
|
|
90
|
+
>
|
|
91
|
+
<div
|
|
92
|
+
className="mx-auto w-full max-w-2xl overflow-hidden rounded-xl border border-base-300 bg-base-100 shadow-2xl"
|
|
93
|
+
onMouseDown={(event) => event.stopPropagation()}
|
|
94
|
+
>
|
|
95
|
+
<div className="border-b border-base-300 p-3">
|
|
96
|
+
<input
|
|
97
|
+
ref={inputRef}
|
|
98
|
+
type="text"
|
|
99
|
+
value={query}
|
|
100
|
+
onChange={(event) => {
|
|
101
|
+
setQuery(event.target.value);
|
|
102
|
+
setSelectedIndex(0);
|
|
103
|
+
}}
|
|
104
|
+
onKeyDown={(event) => {
|
|
105
|
+
if (event.key === 'ArrowDown') {
|
|
106
|
+
event.preventDefault();
|
|
107
|
+
if (filteredRepositories.length > 0) {
|
|
108
|
+
setSelectedIndex((current) => (current + 1) % filteredRepositories.length);
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (event.key === 'ArrowUp') {
|
|
114
|
+
event.preventDefault();
|
|
115
|
+
if (filteredRepositories.length > 0) {
|
|
116
|
+
setSelectedIndex((current) =>
|
|
117
|
+
current <= 0 ? filteredRepositories.length - 1 : current - 1
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (event.key === 'Enter') {
|
|
124
|
+
event.preventDefault();
|
|
125
|
+
const selectedRepo = activeIndex >= 0 ? filteredRepositories[activeIndex] : null;
|
|
126
|
+
if (selectedRepo) {
|
|
127
|
+
openRepository(selectedRepo.path);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}}
|
|
131
|
+
placeholder="Type a command..."
|
|
132
|
+
className="input input-ghost w-full text-base focus:outline-none"
|
|
133
|
+
/>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<div className="max-h-80 overflow-y-auto py-2">
|
|
137
|
+
<div className="px-4 pb-2 text-xs font-semibold uppercase tracking-wider opacity-60">
|
|
138
|
+
Recent repositories
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
{recentRepositories.length === 0 && (
|
|
142
|
+
<div className="px-4 py-6 text-sm opacity-60">No recently opened repositories yet.</div>
|
|
143
|
+
)}
|
|
144
|
+
|
|
145
|
+
{recentRepositories.length > 0 && filteredRepositories.length === 0 && (
|
|
146
|
+
<div className="px-4 py-6 text-sm opacity-60">No matching repositories.</div>
|
|
147
|
+
)}
|
|
148
|
+
|
|
149
|
+
{filteredRepositories.map((repo, index) => {
|
|
150
|
+
const repoDisplayName = getRepositoryDisplayName(repo);
|
|
151
|
+
return (
|
|
152
|
+
<button
|
|
153
|
+
key={repo.path}
|
|
154
|
+
type="button"
|
|
155
|
+
className={cn(
|
|
156
|
+
'flex w-full items-center justify-between gap-4 px-4 py-3 text-left',
|
|
157
|
+
activeIndex === index ? 'bg-base-200' : 'hover:bg-base-200/70'
|
|
158
|
+
)}
|
|
159
|
+
onMouseEnter={() => setSelectedIndex(index)}
|
|
160
|
+
onClick={() => openRepository(repo.path)}
|
|
161
|
+
>
|
|
162
|
+
<div className="min-w-0">
|
|
163
|
+
<div className="truncate text-sm font-medium">{repoDisplayName}</div>
|
|
164
|
+
<div className="truncate text-xs opacity-65">{repo.path}</div>
|
|
165
|
+
</div>
|
|
166
|
+
<span className="text-xs opacity-60">Open</span>
|
|
167
|
+
</button>
|
|
168
|
+
);
|
|
169
|
+
})}
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<div className="border-t border-base-300 px-4 py-2 text-xs opacity-60">
|
|
173
|
+
Use ↑ ↓ to navigate, Enter to open, Esc to close.
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|