groove-dev 0.27.87 → 0.27.88
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/CLAUDE.md +3 -2
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/api.js +115 -7
- package/node_modules/@groove-dev/daemon/src/conversations.js +29 -3
- package/node_modules/@groove-dev/daemon/src/providers/codex.js +28 -10
- package/node_modules/@groove-dev/daemon/src/registry.js +30 -0
- package/node_modules/@groove-dev/daemon/src/validate.js +23 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-BSqk8cbI.css +1 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-B_igwWvq.js +8642 -0
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/components/agents/agent-file-tree.jsx +254 -0
- package/node_modules/@groove-dev/gui/src/components/agents/code-review.jsx +177 -0
- package/node_modules/@groove-dev/gui/src/components/agents/diff-viewer.jsx +148 -0
- package/node_modules/@groove-dev/gui/src/components/agents/workspace-mode.jsx +377 -0
- package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +117 -40
- package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +10 -13
- package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +26 -1
- package/node_modules/@groove-dev/gui/src/components/chat/conversation-list.jsx +14 -14
- package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +5 -0
- package/node_modules/@groove-dev/gui/src/stores/groove.js +132 -1
- package/node_modules/@groove-dev/gui/src/views/agents.jsx +22 -3
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +115 -7
- package/packages/daemon/src/conversations.js +29 -3
- package/packages/daemon/src/providers/codex.js +28 -10
- package/packages/daemon/src/registry.js +30 -0
- package/packages/daemon/src/validate.js +23 -0
- package/packages/gui/dist/assets/index-BSqk8cbI.css +1 -0
- package/packages/gui/dist/assets/index-B_igwWvq.js +8642 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/components/agents/agent-file-tree.jsx +254 -0
- package/packages/gui/src/components/agents/code-review.jsx +177 -0
- package/packages/gui/src/components/agents/diff-viewer.jsx +148 -0
- package/packages/gui/src/components/agents/workspace-mode.jsx +377 -0
- package/packages/gui/src/components/chat/chat-input.jsx +117 -40
- package/packages/gui/src/components/chat/chat-messages.jsx +10 -13
- package/packages/gui/src/components/chat/chat-view.jsx +26 -1
- package/packages/gui/src/components/chat/conversation-list.jsx +14 -14
- package/packages/gui/src/components/chat/model-picker.jsx +5 -0
- package/packages/gui/src/stores/groove.js +132 -1
- package/packages/gui/src/views/agents.jsx +22 -3
- package/test/doomsday-clock/index.html +55 -0
- package/test/doomsday-clock/script.js +66 -0
- package/test/doomsday-clock/style.css +315 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-BCQY8ojz.css +0 -1
- package/node_modules/@groove-dev/gui/dist/assets/index-C5e7KVGN.js +0 -8637
- package/packages/gui/dist/assets/index-BCQY8ojz.css +0 -1
- package/packages/gui/dist/assets/index-C5e7KVGN.js +0 -8637
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
7
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
|
8
8
|
<title>Groove GUI</title>
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-B_igwWvq.js"></script>
|
|
10
10
|
<link rel="modulepreload" crossorigin href="/assets/vendor-C0HXlhrU.js">
|
|
11
11
|
<link rel="modulepreload" crossorigin href="/assets/reactflow-BQPfi37R.js">
|
|
12
12
|
<link rel="modulepreload" crossorigin href="/assets/codemirror-BBL3i_JW.js">
|
|
13
13
|
<link rel="modulepreload" crossorigin href="/assets/xterm--7_ns2zW.js">
|
|
14
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
14
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BSqk8cbI.css">
|
|
15
15
|
</head>
|
|
16
16
|
<body>
|
|
17
17
|
<div id="root"></div>
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
3
|
+
import { useGrooveStore } from '../../stores/groove';
|
|
4
|
+
import { cn } from '../../lib/cn';
|
|
5
|
+
import { api } from '../../lib/api';
|
|
6
|
+
import { ChevronRight, ChevronDown, File, Folder, FolderOpen, Clock } from 'lucide-react';
|
|
7
|
+
import { ScrollArea } from '../ui/scroll-area';
|
|
8
|
+
|
|
9
|
+
const FILE_COLORS = {
|
|
10
|
+
js: 'text-warning', jsx: 'text-warning', ts: 'text-info', tsx: 'text-info',
|
|
11
|
+
css: 'text-info', html: 'text-orange', json: 'text-warning',
|
|
12
|
+
md: 'text-text-2', py: 'text-success', rs: 'text-orange',
|
|
13
|
+
go: 'text-accent', sh: 'text-success', yaml: 'text-danger', yml: 'text-danger',
|
|
14
|
+
sql: 'text-purple', xml: 'text-orange', svg: 'text-warning',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function getFileColor(name) {
|
|
18
|
+
const ext = name.split('.').pop()?.toLowerCase();
|
|
19
|
+
return FILE_COLORS[ext] || 'text-text-3';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function matchesScope(filePath, scopePatterns) {
|
|
23
|
+
if (!scopePatterns || scopePatterns.length === 0) return true;
|
|
24
|
+
for (const pattern of scopePatterns) {
|
|
25
|
+
const escaped = pattern
|
|
26
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
27
|
+
.replace(/\*\*/g, '<<GLOBSTAR>>')
|
|
28
|
+
.replace(/\*/g, '[^/]*')
|
|
29
|
+
.replace(/<<GLOBSTAR>>/g, '.*');
|
|
30
|
+
if (new RegExp(`^${escaped}$`).test(filePath) || new RegExp(`^${escaped}`).test(filePath)) {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function TreeEntry({ entry, depth, onOpen, expandedDirs, onToggleDir }) {
|
|
38
|
+
const isDir = entry.type === 'directory';
|
|
39
|
+
const isExpanded = expandedDirs.has(entry.path);
|
|
40
|
+
const fileColor = isDir ? 'text-accent' : getFileColor(entry.name);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<>
|
|
44
|
+
<button
|
|
45
|
+
onClick={() => isDir ? onToggleDir(entry.path) : onOpen(entry.path)}
|
|
46
|
+
className={cn(
|
|
47
|
+
'w-full flex items-center gap-1.5 py-1 text-xs font-sans cursor-pointer',
|
|
48
|
+
'hover:bg-surface-4/50 transition-colors text-left',
|
|
49
|
+
)}
|
|
50
|
+
style={{ paddingLeft: depth * 14 + 8 }}
|
|
51
|
+
>
|
|
52
|
+
{isDir ? (
|
|
53
|
+
<>
|
|
54
|
+
{isExpanded ? <ChevronDown size={12} className="text-text-4 flex-shrink-0" /> : <ChevronRight size={12} className="text-text-4 flex-shrink-0" />}
|
|
55
|
+
{isExpanded ? <FolderOpen size={13} className={cn(fileColor, 'flex-shrink-0')} /> : <Folder size={13} className={cn(fileColor, 'flex-shrink-0')} />}
|
|
56
|
+
</>
|
|
57
|
+
) : (
|
|
58
|
+
<>
|
|
59
|
+
<span className="w-3 flex-shrink-0" />
|
|
60
|
+
<File size={13} className={cn(fileColor, 'flex-shrink-0')} />
|
|
61
|
+
</>
|
|
62
|
+
)}
|
|
63
|
+
<span className="truncate text-text-1">{entry.name}</span>
|
|
64
|
+
</button>
|
|
65
|
+
{isDir && isExpanded && entry.children?.map((child) => (
|
|
66
|
+
<TreeEntry
|
|
67
|
+
key={child.path}
|
|
68
|
+
entry={child}
|
|
69
|
+
depth={depth + 1}
|
|
70
|
+
onOpen={onOpen}
|
|
71
|
+
expandedDirs={expandedDirs}
|
|
72
|
+
onToggleDir={onToggleDir}
|
|
73
|
+
/>
|
|
74
|
+
))}
|
|
75
|
+
</>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function AgentFileTree({ agentId }) {
|
|
80
|
+
const agents = useGrooveStore((s) => s.agents);
|
|
81
|
+
const activityLog = useGrooveStore((s) => s.activityLog);
|
|
82
|
+
const openFile = useGrooveStore((s) => s.openFile);
|
|
83
|
+
const editorActiveFile = useGrooveStore((s) => s.editorActiveFile);
|
|
84
|
+
|
|
85
|
+
const agent = agents.find((a) => a.id === agentId);
|
|
86
|
+
const scope = agent?.scope || [];
|
|
87
|
+
const log = activityLog[agentId] || [];
|
|
88
|
+
|
|
89
|
+
const [treeData, setTreeData] = useState([]);
|
|
90
|
+
const [expandedDirs, setExpandedDirs] = useState(new Set());
|
|
91
|
+
const [loading, setLoading] = useState(true);
|
|
92
|
+
const fetchedRef = useRef(new Set());
|
|
93
|
+
|
|
94
|
+
const recentFiles = (() => {
|
|
95
|
+
const seen = new Set();
|
|
96
|
+
const files = [];
|
|
97
|
+
for (let i = log.length - 1; i >= 0; i--) {
|
|
98
|
+
const t = (log[i].text || '').toLowerCase();
|
|
99
|
+
if (!(t.includes('writ') || t.includes('edit') || t.includes('creat') || t.includes('read'))) continue;
|
|
100
|
+
const match = log[i].text.match(/(?:Write|Edit|Create|Read|wrote|editing|writing|reading)\S*\s+(\S+)/i);
|
|
101
|
+
if (!match) continue;
|
|
102
|
+
const path = match[1];
|
|
103
|
+
if (seen.has(path) || path.startsWith('.') || path.includes('node_modules')) continue;
|
|
104
|
+
seen.add(path);
|
|
105
|
+
files.push(path);
|
|
106
|
+
if (files.length >= 10) break;
|
|
107
|
+
}
|
|
108
|
+
return files;
|
|
109
|
+
})();
|
|
110
|
+
|
|
111
|
+
const fetchDir = useCallback(async (dirPath) => {
|
|
112
|
+
if (fetchedRef.current.has(dirPath)) return;
|
|
113
|
+
fetchedRef.current.add(dirPath);
|
|
114
|
+
try {
|
|
115
|
+
const data = await api.get(`/files/tree?path=${encodeURIComponent(dirPath)}`);
|
|
116
|
+
return data.entries || [];
|
|
117
|
+
} catch {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
}, []);
|
|
121
|
+
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
let cancelled = false;
|
|
124
|
+
async function loadTree() {
|
|
125
|
+
setLoading(true);
|
|
126
|
+
fetchedRef.current = new Set();
|
|
127
|
+
|
|
128
|
+
if (scope.length === 0) {
|
|
129
|
+
const entries = await fetchDir('');
|
|
130
|
+
if (!cancelled) setTreeData(entries);
|
|
131
|
+
setLoading(false);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const dirs = new Set();
|
|
136
|
+
for (const pattern of scope) {
|
|
137
|
+
const parts = pattern.split('/');
|
|
138
|
+
let dir = '';
|
|
139
|
+
for (let i = 0; i < parts.length; i++) {
|
|
140
|
+
if (parts[i].includes('*')) break;
|
|
141
|
+
dir = dir ? `${dir}/${parts[i]}` : parts[i];
|
|
142
|
+
}
|
|
143
|
+
if (dir) dirs.add(dir);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (dirs.size === 0) {
|
|
147
|
+
const entries = await fetchDir('');
|
|
148
|
+
if (!cancelled) setTreeData(entries);
|
|
149
|
+
setLoading(false);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const results = [];
|
|
154
|
+
for (const dir of dirs) {
|
|
155
|
+
const entries = await fetchDir(dir);
|
|
156
|
+
if (entries.length > 0) {
|
|
157
|
+
results.push({ name: dir.split('/').pop(), path: dir, type: 'directory', children: entries });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (!cancelled) setTreeData(results);
|
|
161
|
+
setLoading(false);
|
|
162
|
+
}
|
|
163
|
+
loadTree();
|
|
164
|
+
return () => { cancelled = true; };
|
|
165
|
+
}, [agentId, scope.join(','), fetchDir]);
|
|
166
|
+
|
|
167
|
+
async function handleToggleDir(path) {
|
|
168
|
+
const next = new Set(expandedDirs);
|
|
169
|
+
if (next.has(path)) {
|
|
170
|
+
next.delete(path);
|
|
171
|
+
} else {
|
|
172
|
+
next.add(path);
|
|
173
|
+
const entries = await fetchDir(path);
|
|
174
|
+
setTreeData((prev) => updateTreeChildren(prev, path, entries));
|
|
175
|
+
}
|
|
176
|
+
setExpandedDirs(next);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function handleOpen(path) {
|
|
180
|
+
openFile(path);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<ScrollArea className="h-full">
|
|
185
|
+
<div className="py-2">
|
|
186
|
+
{recentFiles.length > 0 && (
|
|
187
|
+
<div className="mb-3">
|
|
188
|
+
<div className="flex items-center gap-1.5 px-3 py-1.5 text-2xs font-semibold text-text-3 uppercase tracking-wider">
|
|
189
|
+
<Clock size={10} />
|
|
190
|
+
Recently Touched
|
|
191
|
+
</div>
|
|
192
|
+
{recentFiles.map((path) => {
|
|
193
|
+
const name = path.split('/').pop();
|
|
194
|
+
return (
|
|
195
|
+
<button
|
|
196
|
+
key={path}
|
|
197
|
+
onClick={() => openFile(path)}
|
|
198
|
+
className={cn(
|
|
199
|
+
'w-full flex items-center gap-1.5 px-3 py-1 text-xs font-sans cursor-pointer',
|
|
200
|
+
'hover:bg-surface-4/50 transition-colors text-left',
|
|
201
|
+
editorActiveFile === path && 'bg-accent/8 text-accent',
|
|
202
|
+
)}
|
|
203
|
+
>
|
|
204
|
+
<File size={12} className={cn(getFileColor(name), 'flex-shrink-0')} />
|
|
205
|
+
<span className="truncate text-text-1">{name}</span>
|
|
206
|
+
</button>
|
|
207
|
+
);
|
|
208
|
+
})}
|
|
209
|
+
<div className="h-px bg-border-subtle mx-3 mt-2" />
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
|
|
213
|
+
{loading ? (
|
|
214
|
+
<div className="flex items-center justify-center py-8 text-text-4 text-xs font-sans">
|
|
215
|
+
Loading...
|
|
216
|
+
</div>
|
|
217
|
+
) : treeData.length === 0 ? (
|
|
218
|
+
<div className="flex items-center justify-center py-8 text-text-4 text-xs font-sans">
|
|
219
|
+
No files in scope
|
|
220
|
+
</div>
|
|
221
|
+
) : (
|
|
222
|
+
<div className="px-1">
|
|
223
|
+
<div className="flex items-center gap-1.5 px-2 py-1.5 text-2xs font-semibold text-text-3 uppercase tracking-wider">
|
|
224
|
+
<Folder size={10} />
|
|
225
|
+
Scope
|
|
226
|
+
</div>
|
|
227
|
+
{treeData.map((entry) => (
|
|
228
|
+
<TreeEntry
|
|
229
|
+
key={entry.path}
|
|
230
|
+
entry={entry}
|
|
231
|
+
depth={0}
|
|
232
|
+
onOpen={handleOpen}
|
|
233
|
+
expandedDirs={expandedDirs}
|
|
234
|
+
onToggleDir={handleToggleDir}
|
|
235
|
+
/>
|
|
236
|
+
))}
|
|
237
|
+
</div>
|
|
238
|
+
)}
|
|
239
|
+
</div>
|
|
240
|
+
</ScrollArea>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function updateTreeChildren(tree, targetPath, children) {
|
|
245
|
+
return tree.map((entry) => {
|
|
246
|
+
if (entry.path === targetPath) {
|
|
247
|
+
return { ...entry, children };
|
|
248
|
+
}
|
|
249
|
+
if (entry.children) {
|
|
250
|
+
return { ...entry, children: updateTreeChildren(entry.children, targetPath, children) };
|
|
251
|
+
}
|
|
252
|
+
return entry;
|
|
253
|
+
});
|
|
254
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { useGrooveStore } from '../../stores/groove';
|
|
4
|
+
import { cn } from '../../lib/cn';
|
|
5
|
+
import { ScrollArea } from '../ui/scroll-area';
|
|
6
|
+
import { Button } from '../ui/button';
|
|
7
|
+
import { DiffViewer } from './diff-viewer';
|
|
8
|
+
import { Check, X, MessageSquare, ChevronLeft, CheckCircle2, XCircle, Send } from 'lucide-react';
|
|
9
|
+
|
|
10
|
+
export function CodeReview({ agentId }) {
|
|
11
|
+
const reviewFiles = useGrooveStore((s) => s.workspaceReviewFiles);
|
|
12
|
+
const approveFile = useGrooveStore((s) => s.approveFile);
|
|
13
|
+
const rejectFile = useGrooveStore((s) => s.rejectFile);
|
|
14
|
+
const commentFile = useGrooveStore((s) => s.commentFile);
|
|
15
|
+
const instructAgent = useGrooveStore((s) => s.instructAgent);
|
|
16
|
+
const toggleReviewMode = useGrooveStore((s) => s.toggleReviewMode);
|
|
17
|
+
const openFile = useGrooveStore((s) => s.openFile);
|
|
18
|
+
|
|
19
|
+
const [selectedFile, setSelectedFile] = useState(null);
|
|
20
|
+
const [commentingPath, setCommentingPath] = useState(null);
|
|
21
|
+
const [commentText, setCommentText] = useState('');
|
|
22
|
+
|
|
23
|
+
const approved = reviewFiles.filter((f) => f.status === 'approved').length;
|
|
24
|
+
const rejected = reviewFiles.filter((f) => f.status === 'rejected').length;
|
|
25
|
+
|
|
26
|
+
function handleComment(path) {
|
|
27
|
+
if (!commentText.trim()) return;
|
|
28
|
+
commentFile(path, commentText.trim());
|
|
29
|
+
setCommentText('');
|
|
30
|
+
setCommentingPath(null);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function handleApproveAll() {
|
|
34
|
+
for (const f of reviewFiles) {
|
|
35
|
+
approveFile(f.path);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function handleRequestChanges() {
|
|
40
|
+
const comments = reviewFiles
|
|
41
|
+
.filter((f) => f.comment || f.status === 'rejected')
|
|
42
|
+
.map((f) => {
|
|
43
|
+
const status = f.status === 'rejected' ? '[REJECTED]' : '[COMMENT]';
|
|
44
|
+
return `${status} ${f.path}: ${f.comment || 'Changes needed'}`;
|
|
45
|
+
});
|
|
46
|
+
if (comments.length > 0) {
|
|
47
|
+
await instructAgent(agentId, `Code review feedback:\n${comments.join('\n')}`);
|
|
48
|
+
}
|
|
49
|
+
toggleReviewMode();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (selectedFile) {
|
|
53
|
+
return (
|
|
54
|
+
<div className="flex flex-col h-full">
|
|
55
|
+
<div className="flex items-center gap-2 px-4 py-2 bg-surface-1 border-b border-border flex-shrink-0">
|
|
56
|
+
<button
|
|
57
|
+
onClick={() => setSelectedFile(null)}
|
|
58
|
+
className="p-1 rounded hover:bg-surface-4 text-text-3 hover:text-text-1 cursor-pointer"
|
|
59
|
+
>
|
|
60
|
+
<ChevronLeft size={14} />
|
|
61
|
+
</button>
|
|
62
|
+
<span className="text-xs font-mono text-text-1 truncate">{selectedFile}</span>
|
|
63
|
+
</div>
|
|
64
|
+
<div className="flex-1 min-h-0">
|
|
65
|
+
<DiffViewer filePath={selectedFile} />
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="flex flex-col h-full">
|
|
73
|
+
<div className="flex items-center gap-3 px-4 py-3 bg-surface-1 border-b border-border flex-shrink-0">
|
|
74
|
+
<span className="text-sm font-semibold text-text-0 font-sans flex-1">Review Changes</span>
|
|
75
|
+
<span className="text-xs text-text-3 font-sans">
|
|
76
|
+
{reviewFiles.length} file{reviewFiles.length !== 1 ? 's' : ''} changed
|
|
77
|
+
</span>
|
|
78
|
+
{approved > 0 && <span className="text-xs text-success font-sans">{approved} approved</span>}
|
|
79
|
+
{rejected > 0 && <span className="text-xs text-danger font-sans">{rejected} rejected</span>}
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<ScrollArea className="flex-1">
|
|
83
|
+
<div className="p-2 space-y-1">
|
|
84
|
+
{reviewFiles.length === 0 && (
|
|
85
|
+
<div className="flex items-center justify-center py-12 text-text-4 text-xs font-sans">
|
|
86
|
+
No modified files found
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
89
|
+
{reviewFiles.map((file) => (
|
|
90
|
+
<div key={file.path} className="rounded-md border border-border-subtle bg-surface-2">
|
|
91
|
+
<div className="flex items-center gap-2 px-3 py-2">
|
|
92
|
+
<button
|
|
93
|
+
onClick={() => { openFile(file.path); setSelectedFile(file.path); }}
|
|
94
|
+
className="flex-1 min-w-0 text-xs font-mono text-text-1 hover:text-accent truncate text-left cursor-pointer"
|
|
95
|
+
>
|
|
96
|
+
{file.path}
|
|
97
|
+
</button>
|
|
98
|
+
<div className="flex items-center gap-1 flex-shrink-0">
|
|
99
|
+
<button
|
|
100
|
+
onClick={() => approveFile(file.path)}
|
|
101
|
+
className={cn(
|
|
102
|
+
'p-1 rounded cursor-pointer transition-colors',
|
|
103
|
+
file.status === 'approved'
|
|
104
|
+
? 'bg-success/15 text-success'
|
|
105
|
+
: 'text-text-4 hover:text-success hover:bg-success/10',
|
|
106
|
+
)}
|
|
107
|
+
title="Approve"
|
|
108
|
+
>
|
|
109
|
+
<Check size={14} />
|
|
110
|
+
</button>
|
|
111
|
+
<button
|
|
112
|
+
onClick={() => rejectFile(file.path)}
|
|
113
|
+
className={cn(
|
|
114
|
+
'p-1 rounded cursor-pointer transition-colors',
|
|
115
|
+
file.status === 'rejected'
|
|
116
|
+
? 'bg-danger/15 text-danger'
|
|
117
|
+
: 'text-text-4 hover:text-danger hover:bg-danger/10',
|
|
118
|
+
)}
|
|
119
|
+
title="Reject"
|
|
120
|
+
>
|
|
121
|
+
<X size={14} />
|
|
122
|
+
</button>
|
|
123
|
+
<button
|
|
124
|
+
onClick={() => setCommentingPath(commentingPath === file.path ? null : file.path)}
|
|
125
|
+
className={cn(
|
|
126
|
+
'p-1 rounded cursor-pointer transition-colors',
|
|
127
|
+
file.comment
|
|
128
|
+
? 'bg-accent/15 text-accent'
|
|
129
|
+
: 'text-text-4 hover:text-accent hover:bg-accent/10',
|
|
130
|
+
)}
|
|
131
|
+
title="Comment"
|
|
132
|
+
>
|
|
133
|
+
<MessageSquare size={14} />
|
|
134
|
+
</button>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
{file.comment && commentingPath !== file.path && (
|
|
138
|
+
<div className="px-3 pb-2 text-2xs text-text-2 font-sans italic">
|
|
139
|
+
{file.comment}
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
142
|
+
{commentingPath === file.path && (
|
|
143
|
+
<div className="flex items-center gap-1.5 px-3 pb-2">
|
|
144
|
+
<input
|
|
145
|
+
value={commentText}
|
|
146
|
+
onChange={(e) => setCommentText(e.target.value)}
|
|
147
|
+
onKeyDown={(e) => { if (e.key === 'Enter') handleComment(file.path); if (e.key === 'Escape') setCommentingPath(null); }}
|
|
148
|
+
placeholder="Add review comment..."
|
|
149
|
+
className="flex-1 h-7 px-2 text-xs bg-surface-0 border border-border-subtle rounded text-text-0 font-sans focus:outline-none focus:border-accent"
|
|
150
|
+
autoFocus
|
|
151
|
+
/>
|
|
152
|
+
<button
|
|
153
|
+
onClick={() => handleComment(file.path)}
|
|
154
|
+
className="p-1 text-accent hover:text-accent/80 cursor-pointer"
|
|
155
|
+
>
|
|
156
|
+
<Send size={12} />
|
|
157
|
+
</button>
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
))}
|
|
162
|
+
</div>
|
|
163
|
+
</ScrollArea>
|
|
164
|
+
|
|
165
|
+
<div className="flex items-center gap-2 px-4 py-3 border-t border-border bg-surface-1 flex-shrink-0">
|
|
166
|
+
<Button variant="ghost" size="sm" onClick={handleApproveAll} className="gap-1.5">
|
|
167
|
+
<CheckCircle2 size={13} />
|
|
168
|
+
Approve All
|
|
169
|
+
</Button>
|
|
170
|
+
<Button variant="ghost" size="sm" onClick={handleRequestChanges} className="gap-1.5 text-warning">
|
|
171
|
+
<XCircle size={13} />
|
|
172
|
+
Request Changes
|
|
173
|
+
</Button>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
2
|
+
import { useMemo } from 'react';
|
|
3
|
+
import { useGrooveStore } from '../../stores/groove';
|
|
4
|
+
import { cn } from '../../lib/cn';
|
|
5
|
+
import { ScrollArea } from '../ui/scroll-area';
|
|
6
|
+
|
|
7
|
+
function computeDiff(original, modified) {
|
|
8
|
+
const origLines = (original || '').split('\n');
|
|
9
|
+
const modLines = (modified || '').split('\n');
|
|
10
|
+
const result = [];
|
|
11
|
+
|
|
12
|
+
const maxLen = Math.max(origLines.length, modLines.length);
|
|
13
|
+
let oi = 0, mi = 0;
|
|
14
|
+
|
|
15
|
+
while (oi < origLines.length || mi < modLines.length) {
|
|
16
|
+
if (oi >= origLines.length) {
|
|
17
|
+
result.push({ type: 'add', lineNum: mi + 1, text: modLines[mi] });
|
|
18
|
+
mi++;
|
|
19
|
+
} else if (mi >= modLines.length) {
|
|
20
|
+
result.push({ type: 'del', lineNum: oi + 1, text: origLines[oi] });
|
|
21
|
+
oi++;
|
|
22
|
+
} else if (origLines[oi] === modLines[mi]) {
|
|
23
|
+
result.push({ type: 'same', lineNum: mi + 1, origLineNum: oi + 1, text: modLines[mi] });
|
|
24
|
+
oi++;
|
|
25
|
+
mi++;
|
|
26
|
+
} else {
|
|
27
|
+
let foundOrig = -1;
|
|
28
|
+
let foundMod = -1;
|
|
29
|
+
const lookAhead = Math.min(10, maxLen);
|
|
30
|
+
|
|
31
|
+
for (let k = 1; k <= lookAhead; k++) {
|
|
32
|
+
if (mi + k < modLines.length && origLines[oi] === modLines[mi + k]) {
|
|
33
|
+
foundMod = mi + k;
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
for (let k = 1; k <= lookAhead; k++) {
|
|
38
|
+
if (oi + k < origLines.length && origLines[oi + k] === modLines[mi]) {
|
|
39
|
+
foundOrig = oi + k;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (foundMod >= 0 && (foundOrig < 0 || foundMod - mi <= foundOrig - oi)) {
|
|
45
|
+
while (mi < foundMod) {
|
|
46
|
+
result.push({ type: 'add', lineNum: mi + 1, text: modLines[mi] });
|
|
47
|
+
mi++;
|
|
48
|
+
}
|
|
49
|
+
} else if (foundOrig >= 0) {
|
|
50
|
+
while (oi < foundOrig) {
|
|
51
|
+
result.push({ type: 'del', lineNum: oi + 1, text: origLines[oi] });
|
|
52
|
+
oi++;
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
result.push({ type: 'del', lineNum: oi + 1, text: origLines[oi] });
|
|
56
|
+
result.push({ type: 'add', lineNum: mi + 1, text: modLines[mi] });
|
|
57
|
+
oi++;
|
|
58
|
+
mi++;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function DiffViewer({ filePath }) {
|
|
67
|
+
const file = useGrooveStore((s) => s.editorFiles[filePath]);
|
|
68
|
+
const snapshot = useGrooveStore((s) => s.workspaceSnapshots[filePath]);
|
|
69
|
+
|
|
70
|
+
const original = snapshot || file?.originalContent || '';
|
|
71
|
+
const modified = file?.content || '';
|
|
72
|
+
|
|
73
|
+
const diffLines = useMemo(() => computeDiff(original, modified), [original, modified]);
|
|
74
|
+
|
|
75
|
+
const stats = useMemo(() => {
|
|
76
|
+
let adds = 0, dels = 0;
|
|
77
|
+
for (const line of diffLines) {
|
|
78
|
+
if (line.type === 'add') adds++;
|
|
79
|
+
if (line.type === 'del') dels++;
|
|
80
|
+
}
|
|
81
|
+
return { adds, dels };
|
|
82
|
+
}, [diffLines]);
|
|
83
|
+
|
|
84
|
+
if (!file) {
|
|
85
|
+
return (
|
|
86
|
+
<div className="flex items-center justify-center h-full text-text-4 text-xs font-sans">
|
|
87
|
+
No file loaded
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (original === modified) {
|
|
93
|
+
return (
|
|
94
|
+
<div className="flex items-center justify-center h-full text-text-4 text-xs font-sans">
|
|
95
|
+
No changes detected
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div className="flex flex-col h-full">
|
|
102
|
+
<div className="flex items-center gap-3 px-4 py-2 bg-surface-1 border-b border-border-subtle text-xs font-sans flex-shrink-0">
|
|
103
|
+
<span className="text-text-2">{filePath.split('/').pop()}</span>
|
|
104
|
+
<span className="text-success">+{stats.adds}</span>
|
|
105
|
+
<span className="text-danger">-{stats.dels}</span>
|
|
106
|
+
</div>
|
|
107
|
+
<ScrollArea className="flex-1">
|
|
108
|
+
<div className="font-mono text-xs leading-5">
|
|
109
|
+
{diffLines.map((line, i) => (
|
|
110
|
+
<div
|
|
111
|
+
key={i}
|
|
112
|
+
className={cn(
|
|
113
|
+
'flex',
|
|
114
|
+
line.type === 'add' && 'bg-success/8',
|
|
115
|
+
line.type === 'del' && 'bg-danger/8',
|
|
116
|
+
)}
|
|
117
|
+
>
|
|
118
|
+
<span className={cn(
|
|
119
|
+
'w-12 flex-shrink-0 text-right pr-3 select-none',
|
|
120
|
+
line.type === 'add' ? 'text-success/60' : line.type === 'del' ? 'text-danger/60' : 'text-text-4',
|
|
121
|
+
)}>
|
|
122
|
+
{line.type === 'add' ? '' : (line.origLineNum || line.lineNum)}
|
|
123
|
+
</span>
|
|
124
|
+
<span className={cn(
|
|
125
|
+
'w-12 flex-shrink-0 text-right pr-3 select-none',
|
|
126
|
+
line.type === 'add' ? 'text-success/60' : line.type === 'del' ? 'text-danger/60' : 'text-text-4',
|
|
127
|
+
)}>
|
|
128
|
+
{line.type === 'del' ? '' : line.lineNum}
|
|
129
|
+
</span>
|
|
130
|
+
<span className={cn(
|
|
131
|
+
'w-5 flex-shrink-0 text-center select-none font-bold',
|
|
132
|
+
line.type === 'add' ? 'text-success' : line.type === 'del' ? 'text-danger' : 'text-text-4',
|
|
133
|
+
)}>
|
|
134
|
+
{line.type === 'add' ? '+' : line.type === 'del' ? '-' : ' '}
|
|
135
|
+
</span>
|
|
136
|
+
<span className={cn(
|
|
137
|
+
'flex-1 whitespace-pre px-2',
|
|
138
|
+
line.type === 'add' ? 'text-success/90' : line.type === 'del' ? 'text-danger/90' : 'text-text-1',
|
|
139
|
+
)}>
|
|
140
|
+
{line.text}
|
|
141
|
+
</span>
|
|
142
|
+
</div>
|
|
143
|
+
))}
|
|
144
|
+
</div>
|
|
145
|
+
</ScrollArea>
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
}
|