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,200 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef } from 'react';
|
|
4
|
+
import { cn } from '@/lib/utils';
|
|
5
|
+
|
|
6
|
+
export interface ContextMenuItem {
|
|
7
|
+
label: string;
|
|
8
|
+
labelNode?: React.ReactNode;
|
|
9
|
+
onClick?: () => void;
|
|
10
|
+
danger?: boolean;
|
|
11
|
+
children?: ContextMenuItem[];
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function ContextMenu({
|
|
16
|
+
children,
|
|
17
|
+
items,
|
|
18
|
+
containerClassName = 'w-full',
|
|
19
|
+
}: {
|
|
20
|
+
children: React.ReactNode,
|
|
21
|
+
items: ContextMenuItem[],
|
|
22
|
+
containerClassName?: string
|
|
23
|
+
}) {
|
|
24
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
25
|
+
const [position, setPosition] = useState({ x: 0, y: 0 });
|
|
26
|
+
const menuRef = useRef<HTMLUListElement>(null);
|
|
27
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
28
|
+
|
|
29
|
+
const handleContextMenu = (e: React.MouseEvent) => {
|
|
30
|
+
e.preventDefault();
|
|
31
|
+
e.stopPropagation();
|
|
32
|
+
|
|
33
|
+
// Calculate position relative to viewport
|
|
34
|
+
const x = e.clientX;
|
|
35
|
+
const y = e.clientY;
|
|
36
|
+
|
|
37
|
+
setPosition({ x, y });
|
|
38
|
+
setIsOpen(true);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Close menu when clicking outside
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!isOpen) return;
|
|
44
|
+
|
|
45
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
46
|
+
if (menuRef.current && !menuRef.current.contains(e.target as Node) &&
|
|
47
|
+
containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
48
|
+
setIsOpen(false);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
53
|
+
if (e.key === 'Escape') {
|
|
54
|
+
setIsOpen(false);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Use setTimeout to avoid immediate closure
|
|
59
|
+
setTimeout(() => {
|
|
60
|
+
document.addEventListener('click', handleClickOutside);
|
|
61
|
+
document.addEventListener('contextmenu', handleClickOutside);
|
|
62
|
+
document.addEventListener('keydown', handleEscape);
|
|
63
|
+
}, 0);
|
|
64
|
+
|
|
65
|
+
return () => {
|
|
66
|
+
document.removeEventListener('click', handleClickOutside);
|
|
67
|
+
document.removeEventListener('contextmenu', handleClickOutside);
|
|
68
|
+
document.removeEventListener('keydown', handleEscape);
|
|
69
|
+
};
|
|
70
|
+
}, [isOpen]);
|
|
71
|
+
|
|
72
|
+
// Calculate menu width based on content and adjust position if menu would go off-screen
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (!isOpen || !menuRef.current) return;
|
|
75
|
+
|
|
76
|
+
const menu = menuRef.current;
|
|
77
|
+
|
|
78
|
+
// Use requestAnimationFrame to ensure styles are applied
|
|
79
|
+
requestAnimationFrame(() => {
|
|
80
|
+
// Calculate optimal width based on content
|
|
81
|
+
// Create a temporary element to measure text width
|
|
82
|
+
const tempElement = document.createElement('div');
|
|
83
|
+
tempElement.style.position = 'absolute';
|
|
84
|
+
tempElement.style.visibility = 'hidden';
|
|
85
|
+
tempElement.style.whiteSpace = 'nowrap';
|
|
86
|
+
tempElement.style.pointerEvents = 'none';
|
|
87
|
+
|
|
88
|
+
// Copy font styles from menu
|
|
89
|
+
const menuStyles = window.getComputedStyle(menu);
|
|
90
|
+
tempElement.style.fontSize = menuStyles.fontSize;
|
|
91
|
+
tempElement.style.fontFamily = menuStyles.fontFamily;
|
|
92
|
+
tempElement.style.fontWeight = menuStyles.fontWeight;
|
|
93
|
+
tempElement.style.padding = '0.5rem 0.75rem'; // Match menu item padding
|
|
94
|
+
tempElement.style.boxSizing = 'border-box';
|
|
95
|
+
|
|
96
|
+
document.body.appendChild(tempElement);
|
|
97
|
+
|
|
98
|
+
let maxWidth = 0;
|
|
99
|
+
items.forEach(item => {
|
|
100
|
+
tempElement.textContent = item.label;
|
|
101
|
+
const width = tempElement.getBoundingClientRect().width;
|
|
102
|
+
if (width > maxWidth) {
|
|
103
|
+
maxWidth = width;
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
document.body.removeChild(tempElement);
|
|
108
|
+
|
|
109
|
+
// Set menu width (add padding: 1rem on each side = 2rem total = 32px)
|
|
110
|
+
const menuWidth = Math.max(maxWidth + 32, 280); // Minimum 280px, or content width + padding
|
|
111
|
+
menu.style.width = `${menuWidth}px`;
|
|
112
|
+
menu.style.minWidth = `${menuWidth}px`;
|
|
113
|
+
|
|
114
|
+
// Now adjust position if menu would go off-screen
|
|
115
|
+
const rect = menu.getBoundingClientRect();
|
|
116
|
+
const viewportWidth = window.innerWidth;
|
|
117
|
+
const viewportHeight = window.innerHeight;
|
|
118
|
+
|
|
119
|
+
let adjustedX = position.x;
|
|
120
|
+
let adjustedY = position.y;
|
|
121
|
+
|
|
122
|
+
// Adjust horizontal position if menu goes off right edge
|
|
123
|
+
if (rect.right > viewportWidth) {
|
|
124
|
+
adjustedX = viewportWidth - rect.width - 10;
|
|
125
|
+
}
|
|
126
|
+
// Adjust horizontal position if menu goes off left edge
|
|
127
|
+
if (adjustedX < 10) {
|
|
128
|
+
adjustedX = 10;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Adjust vertical position if menu goes off bottom edge
|
|
132
|
+
if (rect.bottom > viewportHeight) {
|
|
133
|
+
adjustedY = viewportHeight - rect.height - 10;
|
|
134
|
+
}
|
|
135
|
+
// Adjust vertical position if menu goes off top edge
|
|
136
|
+
if (adjustedY < 10) {
|
|
137
|
+
adjustedY = 10;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (adjustedX !== position.x || adjustedY !== position.y) {
|
|
141
|
+
menu.style.left = `${adjustedX}px`;
|
|
142
|
+
menu.style.top = `${adjustedY}px`;
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}, [isOpen, position, items]);
|
|
146
|
+
|
|
147
|
+
const renderMenuItem = (item: ContextMenuItem, key: string) => {
|
|
148
|
+
const hasChildren = !!item.children?.length;
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<li key={key} className={cn(hasChildren && "relative group/submenu")}>
|
|
152
|
+
<a
|
|
153
|
+
onClick={(e) => {
|
|
154
|
+
e.stopPropagation();
|
|
155
|
+
if (item.disabled || hasChildren) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
item.onClick?.();
|
|
159
|
+
setIsOpen(false);
|
|
160
|
+
}}
|
|
161
|
+
className={cn(
|
|
162
|
+
"whitespace-nowrap",
|
|
163
|
+
item.danger && "text-error",
|
|
164
|
+
item.disabled && "opacity-40 pointer-events-none",
|
|
165
|
+
hasChildren && "flex items-center justify-between gap-3"
|
|
166
|
+
)}
|
|
167
|
+
>
|
|
168
|
+
<span>{item.labelNode ?? item.label}</span>
|
|
169
|
+
{hasChildren && <i className="iconoir-nav-arrow-right text-[12px]" aria-hidden="true" />}
|
|
170
|
+
</a>
|
|
171
|
+
{hasChildren && (
|
|
172
|
+
<>
|
|
173
|
+
<span className="hidden group-hover/submenu:block absolute left-full top-0 h-full w-3" />
|
|
174
|
+
<ul className="hidden group-hover/submenu:block absolute left-[calc(100%-1px)] top-0 menu !m-0 p-2 shadow-lg bg-base-100 rounded-box border border-base-200 min-w-[220px] z-[10000] [&:before]:hidden">
|
|
175
|
+
{item.children!.map((child, idx) => renderMenuItem(child, `${key}-${idx}`))}
|
|
176
|
+
</ul>
|
|
177
|
+
</>
|
|
178
|
+
)}
|
|
179
|
+
</li>
|
|
180
|
+
);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<div ref={containerRef} className={containerClassName} onContextMenu={handleContextMenu}>
|
|
185
|
+
{children}
|
|
186
|
+
{isOpen && (
|
|
187
|
+
<ul
|
|
188
|
+
ref={menuRef}
|
|
189
|
+
className="fixed z-[9999] menu p-2 shadow-lg bg-base-100 rounded-box border border-base-200"
|
|
190
|
+
style={{
|
|
191
|
+
left: `${position.x}px`,
|
|
192
|
+
top: `${position.y}px`,
|
|
193
|
+
}}
|
|
194
|
+
>
|
|
195
|
+
{items.map((item, idx) => renderMenuItem(item, `menu-${idx}`))}
|
|
196
|
+
</ul>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import { cn } from '@/lib/utils';
|
|
5
|
+
import { useEscapeDismiss } from '@/hooks/use-escape-dismiss';
|
|
6
|
+
|
|
7
|
+
interface FSItem {
|
|
8
|
+
name: string;
|
|
9
|
+
path: string;
|
|
10
|
+
isRepo: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface FSResponse {
|
|
14
|
+
path: string;
|
|
15
|
+
folders: FSItem[];
|
|
16
|
+
parent: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface FileSystemBrowserProps {
|
|
20
|
+
open: boolean;
|
|
21
|
+
onOpenChange: (open: boolean) => void;
|
|
22
|
+
onSelect: (path: string) => void;
|
|
23
|
+
initialPath?: string;
|
|
24
|
+
title?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function FileSystemBrowser({ open, onOpenChange, onSelect, initialPath, title = 'Select Repository' }: FileSystemBrowserProps) {
|
|
28
|
+
const [currentPath, setCurrentPath] = useState<string>('');
|
|
29
|
+
const [data, setData] = useState<FSResponse | null>(null);
|
|
30
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
31
|
+
const [hasInitialized, setHasInitialized] = useState(false);
|
|
32
|
+
|
|
33
|
+
const loadPath = async (path?: string) => {
|
|
34
|
+
setIsLoading(true);
|
|
35
|
+
try {
|
|
36
|
+
const url = path ? `/api/fs?path=${encodeURIComponent(path)}` : '/api/fs';
|
|
37
|
+
const res = await fetch(url);
|
|
38
|
+
const json = await res.json();
|
|
39
|
+
if (res.ok) {
|
|
40
|
+
setData(json);
|
|
41
|
+
setCurrentPath(json.path);
|
|
42
|
+
} else {
|
|
43
|
+
console.error(json.error);
|
|
44
|
+
}
|
|
45
|
+
} catch (e) {
|
|
46
|
+
console.error(e);
|
|
47
|
+
} finally {
|
|
48
|
+
setIsLoading(false);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (open && !hasInitialized) {
|
|
54
|
+
loadPath(initialPath);
|
|
55
|
+
setHasInitialized(true);
|
|
56
|
+
}
|
|
57
|
+
if (!open) {
|
|
58
|
+
// Reset when dialog closes so it starts fresh next time
|
|
59
|
+
setHasInitialized(false);
|
|
60
|
+
}
|
|
61
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
62
|
+
}, [open, initialPath]);
|
|
63
|
+
|
|
64
|
+
useEscapeDismiss(open, () => onOpenChange(false));
|
|
65
|
+
|
|
66
|
+
const handleNavigate = (path: string) => {
|
|
67
|
+
loadPath(path);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!open) return null;
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<dialog className="modal modal-open">
|
|
74
|
+
<div className="modal-box w-11/12 max-w-2xl h-[80vh] flex flex-col p-0 overflow-hidden bg-base-100">
|
|
75
|
+
<div className="p-4 border-b border-base-300 flex justify-between items-center bg-base-200/50">
|
|
76
|
+
<div className="overflow-hidden">
|
|
77
|
+
<h3 className="font-bold text-lg">{title}</h3>
|
|
78
|
+
<div className="text-xs opacity-70 font-mono truncate pt-1" title={currentPath}>
|
|
79
|
+
{currentPath || 'Loading...'}
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
<button className="btn btn-sm btn-circle btn-ghost" onClick={() => onOpenChange(false)}>
|
|
83
|
+
<i className="iconoir-xmark text-[16px]" aria-hidden="true" />
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div className="flex-1 overflow-hidden relative bg-base-100">
|
|
88
|
+
{isLoading && (
|
|
89
|
+
<div className="absolute inset-0 bg-base-100/50 flex items-center justify-center z-10">
|
|
90
|
+
<span className="loading loading-spinner loading-lg text-primary"></span>
|
|
91
|
+
</div>
|
|
92
|
+
)}
|
|
93
|
+
|
|
94
|
+
<div className="h-full overflow-y-auto">
|
|
95
|
+
<div className="divide-y divide-base-200">
|
|
96
|
+
{data?.parent && (
|
|
97
|
+
<div
|
|
98
|
+
className="flex items-center gap-3 px-4 py-3 hover:bg-base-200 cursor-pointer opacity-70 transition-colors"
|
|
99
|
+
onClick={() => handleNavigate(data.parent)}
|
|
100
|
+
>
|
|
101
|
+
<i className="iconoir-u-turn-arrow-left text-[20px]" aria-hidden="true" />
|
|
102
|
+
<span className="text-sm">..</span>
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
|
|
106
|
+
{data?.folders.map((item) => (
|
|
107
|
+
<div
|
|
108
|
+
key={item.path}
|
|
109
|
+
className={cn(
|
|
110
|
+
"flex items-center justify-between px-4 py-3 hover:bg-base-200 cursor-pointer group transition-colors",
|
|
111
|
+
item.name.startsWith('.') && "opacity-60"
|
|
112
|
+
)}
|
|
113
|
+
onClick={() => handleNavigate(item.path)}
|
|
114
|
+
>
|
|
115
|
+
<div className="flex items-center gap-3 truncate">
|
|
116
|
+
{item.isRepo ? <i className="iconoir-bookmark text-[20px]" aria-hidden="true" /> : <i className="iconoir-folder text-[20px]" aria-hidden="true" />}
|
|
117
|
+
<span className={cn("text-sm font-mono", item.isRepo && "font-medium")}>{item.name}</span>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
{item.isRepo && (
|
|
121
|
+
<button
|
|
122
|
+
className="btn btn-xs btn-outline"
|
|
123
|
+
onClick={(e) => { e.stopPropagation(); onSelect(item.path); onOpenChange(false); }}
|
|
124
|
+
>
|
|
125
|
+
Select
|
|
126
|
+
</button>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
))}
|
|
130
|
+
|
|
131
|
+
{data?.folders.length === 0 && (
|
|
132
|
+
<div className="p-8 text-center opacity-70 text-sm">
|
|
133
|
+
No folders found
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
<div className="p-4 border-t border-base-300 flex items-center justify-between bg-base-200/30">
|
|
141
|
+
<div className="text-xs opacity-70">
|
|
142
|
+
Click folder to navigate.
|
|
143
|
+
</div>
|
|
144
|
+
<button className="btn btn-primary btn-sm" onClick={() => { onSelect(currentPath); onOpenChange(false); }}>
|
|
145
|
+
Select Current Folder
|
|
146
|
+
</button>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
<form method="dialog" className="modal-backdrop">
|
|
150
|
+
<button onClick={() => onOpenChange(false)}>close</button>
|
|
151
|
+
</form>
|
|
152
|
+
</dialog>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useGitDiff } from '@/hooks/use-git';
|
|
4
|
+
import { useState, useEffect } from 'react';
|
|
5
|
+
import { useTheme } from 'next-themes';
|
|
6
|
+
import { getChangedLineCountFromDiff, isFileBinary, isImageFile } from '@/lib/utils';
|
|
7
|
+
import { GroupedDiffViewer } from './grouped-diff-viewer';
|
|
8
|
+
import { ImageDiffView } from './image-diff-view';
|
|
9
|
+
|
|
10
|
+
export function DiffView({ repoPath, filePath }: { repoPath: string, filePath: string }) {
|
|
11
|
+
const { data, isLoading } = useGitDiff(repoPath, filePath);
|
|
12
|
+
|
|
13
|
+
// Storage key for split view preference
|
|
14
|
+
const storageKey = 'git-web:diff-view-split';
|
|
15
|
+
|
|
16
|
+
const [splitView, setSplitView] = useState(() => {
|
|
17
|
+
if (typeof window === 'undefined') return true;
|
|
18
|
+
try {
|
|
19
|
+
const stored = localStorage.getItem(storageKey);
|
|
20
|
+
return stored !== null ? JSON.parse(stored) : true;
|
|
21
|
+
} catch (e) {
|
|
22
|
+
console.error('Failed to load split view preference:', e);
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const [renderAnyway, setRenderAnyway] = useState(false);
|
|
28
|
+
|
|
29
|
+
const { resolvedTheme } = useTheme();
|
|
30
|
+
|
|
31
|
+
// Reset renderAnyway when filePath changes
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
setRenderAnyway(false);
|
|
34
|
+
}, [filePath]);
|
|
35
|
+
|
|
36
|
+
// Save split view preference when it changes
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
try {
|
|
39
|
+
localStorage.setItem(storageKey, JSON.stringify(splitView));
|
|
40
|
+
} catch (e) {
|
|
41
|
+
console.error('Failed to save split view preference:', e);
|
|
42
|
+
}
|
|
43
|
+
}, [splitView]);
|
|
44
|
+
|
|
45
|
+
if (isLoading) {
|
|
46
|
+
return <div className="flex items-center justify-center p-8 h-full"><span className="loading loading-spinner text-base-content/50"></span></div>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!data) {
|
|
50
|
+
return (
|
|
51
|
+
<div className="flex items-center justify-center h-full opacity-50">
|
|
52
|
+
No diff available
|
|
53
|
+
</div>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const isImage = isImageFile(filePath);
|
|
58
|
+
if (isImage) {
|
|
59
|
+
return (
|
|
60
|
+
<div className="flex flex-col h-full bg-base-100">
|
|
61
|
+
<div className="flex items-center justify-between px-4 h-[57px] border-b border-base-300 shrink-0 bg-base-100">
|
|
62
|
+
<span className="text-sm font-mono truncate max-w-[70%]" title={filePath}>{filePath}</span>
|
|
63
|
+
</div>
|
|
64
|
+
<div className="flex-1 overflow-auto">
|
|
65
|
+
<ImageDiffView filePath={filePath} imageDiff={data.imageDiff} />
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Check if file is binary (first by extension, then by content if unknown)
|
|
72
|
+
const isBinary = isFileBinary(filePath, data.left, data.right);
|
|
73
|
+
|
|
74
|
+
if (isBinary) {
|
|
75
|
+
return (
|
|
76
|
+
<div className="flex flex-col h-full bg-base-100">
|
|
77
|
+
<div className="flex items-center justify-between px-4 h-[57px] border-b border-base-300 shrink-0 bg-base-100">
|
|
78
|
+
<span className="text-sm font-mono truncate max-w-[70%]" title={filePath}>{filePath}</span>
|
|
79
|
+
</div>
|
|
80
|
+
<div className="flex-1 flex items-center justify-center opacity-50">
|
|
81
|
+
Binary file - diff not available
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Large file protection
|
|
88
|
+
const MAX_DIFF_SIZE = 100 * 1024; // 100KB
|
|
89
|
+
const MAX_DIFF_LINES = 3000;
|
|
90
|
+
|
|
91
|
+
const diffContent = data.diff || '';
|
|
92
|
+
const contentSize = diffContent.length;
|
|
93
|
+
const lineCount = getChangedLineCountFromDiff(diffContent);
|
|
94
|
+
|
|
95
|
+
const isLargeDiff = (contentSize > MAX_DIFF_SIZE || lineCount > MAX_DIFF_LINES);
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div className="flex flex-col h-full bg-base-100">
|
|
99
|
+
<div className="flex items-center justify-between px-4 h-[57px] border-b border-base-300 shrink-0 bg-base-100">
|
|
100
|
+
<span className="text-sm font-mono truncate max-w-[70%]" title={filePath}>{filePath}</span>
|
|
101
|
+
<div className="flex items-center gap-2">
|
|
102
|
+
<label htmlFor="split-view" className="text-[10px] uppercase tracking-wider font-bold cursor-pointer opacity-70">Split View</label>
|
|
103
|
+
<input
|
|
104
|
+
type="checkbox"
|
|
105
|
+
id="split-view"
|
|
106
|
+
checked={splitView}
|
|
107
|
+
onChange={(e) => setSplitView(e.target.checked)}
|
|
108
|
+
className="toggle toggle-sm toggle-primary"
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
<div className="flex-1 overflow-auto diff-viewer-wrapper">
|
|
113
|
+
{isLargeDiff && !renderAnyway ? (
|
|
114
|
+
<div className="flex flex-col items-center justify-center h-full gap-4 text-center p-4">
|
|
115
|
+
<i className="iconoir-warning-triangle text-[40px] text-warning" aria-hidden="true" />
|
|
116
|
+
<div className="space-y-2">
|
|
117
|
+
<h3 className="font-bold text-lg">Large Diff Detected</h3>
|
|
118
|
+
<p className="opacity-70">
|
|
119
|
+
This diff is large ({Math.round(contentSize / 1024)}KB, ~{lineCount} changed lines) and may freeze your browser if rendered.
|
|
120
|
+
</p>
|
|
121
|
+
</div>
|
|
122
|
+
<button className="btn btn-outline" onClick={() => setRenderAnyway(true)}>
|
|
123
|
+
Show Diff Anyway
|
|
124
|
+
</button>
|
|
125
|
+
</div>
|
|
126
|
+
) : (
|
|
127
|
+
<GroupedDiffViewer
|
|
128
|
+
oldValue={data.left || ''}
|
|
129
|
+
newValue={data.right || ''}
|
|
130
|
+
splitView={splitView}
|
|
131
|
+
useDarkTheme={resolvedTheme === 'dark'}
|
|
132
|
+
/>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|