groove-dev 0.22.3 → 0.22.5

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.
@@ -5,12 +5,12 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" type="image/png" href="/favicon.png" />
7
7
  <title>Groove GUI</title>
8
- <script type="module" crossorigin src="/assets/index-BHDZqhzW.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-eDPQ15uG.js"></script>
9
9
  <link rel="modulepreload" crossorigin href="/assets/vendor-C0HXlhrU.js">
10
10
  <link rel="modulepreload" crossorigin href="/assets/reactflow-BQPfi37R.js">
11
11
  <link rel="modulepreload" crossorigin href="/assets/codemirror-BBL3i_JW.js">
12
12
  <link rel="modulepreload" crossorigin href="/assets/xterm--7_ns2zW.js">
13
- <link rel="stylesheet" crossorigin href="/assets/index-BDyGhxDd.css">
13
+ <link rel="stylesheet" crossorigin href="/assets/index-DCuYr-nF.css">
14
14
  </head>
15
15
  <body>
16
16
  <div id="root"></div>
@@ -26,7 +26,8 @@ const LANGS = {
26
26
 
27
27
  // Custom theme overrides to match our design tokens
28
28
  const grooveTheme = EditorView.theme({
29
- '&': { backgroundColor: '#24282f', color: '#bcc2cd', fontFamily: 'var(--font-mono)', fontSize: '13px' },
29
+ '&': { backgroundColor: '#24282f', color: '#bcc2cd', fontFamily: 'var(--font-mono)', fontSize: '13px', height: '100%' },
30
+ '.cm-scroller': { overflow: 'auto' },
30
31
  '.cm-content': { caretColor: '#33afbc' },
31
32
  '.cm-cursor': { borderLeftColor: '#33afbc' },
32
33
  '.cm-gutters': { backgroundColor: '#24282f', borderRight: '1px solid #2c313a', color: '#505862' },
@@ -103,5 +104,5 @@ export function CodeEditor({ content, language, onChange, onSave }) {
103
104
  view.dispatch({ effects: langCompartment.current.reconfigure(langExt()) });
104
105
  }, [language]);
105
106
 
106
- return <div ref={containerRef} className="w-full h-full" />;
107
+ return <div ref={containerRef} className="w-full h-full overflow-hidden" />;
107
108
  }
@@ -1,8 +1,12 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
- import { useState, useEffect } from 'react';
2
+ import { useState, useEffect, useRef } from 'react';
3
3
  import { useGrooveStore } from '../../stores/groove';
4
4
  import { cn } from '../../lib/cn';
5
- import { ChevronRight, ChevronDown, File, Folder, FolderOpen, Plus, FolderPlus, Search, RefreshCw } from 'lucide-react';
5
+ import { api } from '../../lib/api';
6
+ import {
7
+ ChevronRight, ChevronDown, File, Folder, FolderOpen,
8
+ Plus, FolderPlus, Search, RefreshCw, Trash2, Pencil, FilePlus,
9
+ } from 'lucide-react';
6
10
  import { ScrollArea } from '../ui/scroll-area';
7
11
 
8
12
  const FILE_COLORS = {
@@ -18,15 +22,100 @@ function getFileColor(name) {
18
22
  return FILE_COLORS[ext] || 'text-text-3';
19
23
  }
20
24
 
21
- function TreeNode({ entry, depth = 0, activePath, onFileClick, onDirToggle, expanded }) {
25
+ // ── Context Menu ─────────────────────────────────────────────
26
+
27
+ function ContextMenu({ x, y, items, onClose }) {
28
+ const ref = useRef(null);
29
+
30
+ useEffect(() => {
31
+ function handleClick(e) {
32
+ if (ref.current && !ref.current.contains(e.target)) onClose();
33
+ }
34
+ document.addEventListener('mousedown', handleClick);
35
+ return () => document.removeEventListener('mousedown', handleClick);
36
+ }, [onClose]);
37
+
38
+ return (
39
+ <div
40
+ ref={ref}
41
+ className="fixed z-50 min-w-[160px] py-1 bg-surface-2 border border-border rounded-lg shadow-xl"
42
+ style={{ left: x, top: y }}
43
+ >
44
+ {items.map((item, i) =>
45
+ item.separator ? (
46
+ <div key={i} className="h-px bg-border-subtle my-1" />
47
+ ) : (
48
+ <button
49
+ key={i}
50
+ onClick={() => { item.action(); onClose(); }}
51
+ className={cn(
52
+ 'w-full flex items-center gap-2.5 px-3 py-1.5 text-xs font-sans text-left cursor-pointer transition-colors',
53
+ item.danger
54
+ ? 'text-danger hover:bg-danger/10'
55
+ : 'text-text-1 hover:bg-surface-5',
56
+ )}
57
+ >
58
+ {item.icon && <item.icon size={12} className={item.danger ? 'text-danger' : 'text-text-3'} />}
59
+ {item.label}
60
+ </button>
61
+ )
62
+ )}
63
+ </div>
64
+ );
65
+ }
66
+
67
+ // ── Inline Input (for new file/folder/rename) ─────────────────
68
+
69
+ function InlineInput({ defaultValue = '', placeholder, onSubmit, onCancel, depth = 0 }) {
70
+ const [value, setValue] = useState(defaultValue);
71
+ const inputRef = useRef(null);
72
+
73
+ useEffect(() => {
74
+ inputRef.current?.focus();
75
+ if (defaultValue) inputRef.current?.select();
76
+ }, [defaultValue]);
77
+
78
+ function handleKeyDown(e) {
79
+ if (e.key === 'Enter') {
80
+ const name = value.trim();
81
+ if (name) onSubmit(name);
82
+ }
83
+ if (e.key === 'Escape') onCancel();
84
+ }
85
+
86
+ return (
87
+ <div className="flex items-center py-0.5" style={{ paddingLeft: depth * 16 + 8 }}>
88
+ <input
89
+ ref={inputRef}
90
+ value={value}
91
+ onChange={(e) => setValue(e.target.value)}
92
+ onKeyDown={handleKeyDown}
93
+ onBlur={onCancel}
94
+ placeholder={placeholder}
95
+ className="w-full h-5 px-1.5 text-xs bg-surface-0 border border-accent rounded text-text-0 font-sans focus:outline-none"
96
+ />
97
+ </div>
98
+ );
99
+ }
100
+
101
+ // ── Tree Node ────────────────────────────────────────────────
102
+
103
+ function TreeNode({ entry, depth = 0, activePath, onFileClick, onDirToggle, expanded, onContextMenu }) {
22
104
  const isDir = entry.type === 'dir';
23
105
  const isActive = activePath === entry.path;
24
106
  const isOpen = expanded.has(entry.path);
25
107
  const indent = depth * 16 + 8;
26
108
 
109
+ function handleContextMenu(e) {
110
+ e.preventDefault();
111
+ e.stopPropagation();
112
+ onContextMenu(e, entry);
113
+ }
114
+
27
115
  return (
28
116
  <button
29
117
  onClick={() => isDir ? onDirToggle(entry.path) : onFileClick(entry.path)}
118
+ onContextMenu={handleContextMenu}
30
119
  className={cn(
31
120
  'w-full flex items-center gap-1.5 py-1 text-xs font-sans cursor-pointer',
32
121
  'hover:bg-surface-5 transition-colors text-left select-none',
@@ -51,7 +140,7 @@ function TreeNode({ entry, depth = 0, activePath, onFileClick, onDirToggle, expa
51
140
  );
52
141
  }
53
142
 
54
- function TreeDir({ dirPath, depth, activePath, onFileClick, expanded, onDirToggle, treeCache, fetchTreeDir }) {
143
+ function TreeDir({ dirPath, depth, activePath, onFileClick, expanded, onDirToggle, treeCache, fetchTreeDir, onContextMenu, inlineInput }) {
55
144
  const entries = treeCache[dirPath] || [];
56
145
 
57
146
  useEffect(() => {
@@ -64,16 +153,36 @@ function TreeDir({ dirPath, depth, activePath, onFileClick, expanded, onDirToggl
64
153
 
65
154
  return (
66
155
  <>
156
+ {/* Inline input for new file/folder in this directory */}
157
+ {inlineInput?.parentPath === dirPath && (
158
+ <InlineInput
159
+ placeholder={inlineInput.type === 'file' ? 'filename.ext' : 'folder-name'}
160
+ onSubmit={inlineInput.onSubmit}
161
+ onCancel={inlineInput.onCancel}
162
+ depth={depth}
163
+ />
164
+ )}
67
165
  {entries.map((entry) => (
68
166
  <div key={entry.path}>
69
- <TreeNode
70
- entry={entry}
71
- depth={depth}
72
- activePath={activePath}
73
- onFileClick={onFileClick}
74
- onDirToggle={onDirToggle}
75
- expanded={expanded}
76
- />
167
+ {/* Rename input */}
168
+ {inlineInput?.renamePath === entry.path ? (
169
+ <InlineInput
170
+ defaultValue={entry.name}
171
+ onSubmit={inlineInput.onSubmit}
172
+ onCancel={inlineInput.onCancel}
173
+ depth={depth}
174
+ />
175
+ ) : (
176
+ <TreeNode
177
+ entry={entry}
178
+ depth={depth}
179
+ activePath={activePath}
180
+ onFileClick={onFileClick}
181
+ onDirToggle={onDirToggle}
182
+ expanded={expanded}
183
+ onContextMenu={onContextMenu}
184
+ />
185
+ )}
77
186
  {entry.type === 'dir' && (
78
187
  <TreeDir
79
188
  dirPath={entry.path}
@@ -84,6 +193,8 @@ function TreeDir({ dirPath, depth, activePath, onFileClick, expanded, onDirToggl
84
193
  onDirToggle={onDirToggle}
85
194
  treeCache={treeCache}
86
195
  fetchTreeDir={fetchTreeDir}
196
+ onContextMenu={onContextMenu}
197
+ inlineInput={inlineInput}
87
198
  />
88
199
  )}
89
200
  </div>
@@ -92,16 +203,20 @@ function TreeDir({ dirPath, depth, activePath, onFileClick, expanded, onDirToggl
92
203
  );
93
204
  }
94
205
 
206
+ // ── Main FileTree ────────────────────────────────────────────
207
+
95
208
  export function FileTree({ rootDir }) {
96
209
  const treeCache = useGrooveStore((s) => s.editorTreeCache);
97
210
  const activeFile = useGrooveStore((s) => s.editorActiveFile);
98
211
  const openFile = useGrooveStore((s) => s.openFile);
99
212
  const fetchTreeDir = useGrooveStore((s) => s.fetchTreeDir);
213
+ const addToast = useGrooveStore((s) => s.addToast);
100
214
 
101
215
  const [expanded, setExpanded] = useState(new Set(['']));
102
216
  const [filter, setFilter] = useState('');
217
+ const [contextMenu, setContextMenu] = useState(null);
218
+ const [inlineInput, setInlineInput] = useState(null);
103
219
 
104
- // Load root on mount
105
220
  useEffect(() => {
106
221
  fetchTreeDir('');
107
222
  }, [fetchTreeDir, rootDir]);
@@ -115,10 +230,123 @@ export function FileTree({ rootDir }) {
115
230
  });
116
231
  }
117
232
 
233
+ function handleContextMenu(e, entry) {
234
+ setContextMenu({ x: e.clientX, y: e.clientY, entry });
235
+ }
236
+
237
+ // Empty area context menu (right-click on blank space)
238
+ function handleRootContextMenu(e) {
239
+ e.preventDefault();
240
+ setContextMenu({ x: e.clientX, y: e.clientY, entry: { type: 'dir', path: '', name: 'root' } });
241
+ }
242
+
243
+ function parentDir(path) {
244
+ const parts = path.split('/');
245
+ parts.pop();
246
+ return parts.join('/');
247
+ }
248
+
249
+ async function handleNewFile(dirPath) {
250
+ setExpanded((prev) => new Set([...prev, dirPath]));
251
+ setInlineInput({
252
+ type: 'file',
253
+ parentPath: dirPath,
254
+ onSubmit: async (name) => {
255
+ const path = dirPath ? `${dirPath}/${name}` : name;
256
+ try {
257
+ await api.post('/files/create', { path, content: '' });
258
+ fetchTreeDir(dirPath);
259
+ openFile(path);
260
+ addToast('success', `Created ${name}`);
261
+ } catch (err) {
262
+ addToast('error', 'Create failed', err.message);
263
+ }
264
+ setInlineInput(null);
265
+ },
266
+ onCancel: () => setInlineInput(null),
267
+ });
268
+ }
269
+
270
+ async function handleNewFolder(dirPath) {
271
+ setExpanded((prev) => new Set([...prev, dirPath]));
272
+ setInlineInput({
273
+ type: 'folder',
274
+ parentPath: dirPath,
275
+ onSubmit: async (name) => {
276
+ const path = dirPath ? `${dirPath}/${name}` : name;
277
+ try {
278
+ await api.post('/files/mkdir', { path });
279
+ fetchTreeDir(dirPath);
280
+ setExpanded((prev) => new Set([...prev, path]));
281
+ addToast('success', `Created ${name}/`);
282
+ } catch (err) {
283
+ addToast('error', 'Create folder failed', err.message);
284
+ }
285
+ setInlineInput(null);
286
+ },
287
+ onCancel: () => setInlineInput(null),
288
+ });
289
+ }
290
+
291
+ async function handleRename(entry) {
292
+ setInlineInput({
293
+ type: 'rename',
294
+ renamePath: entry.path,
295
+ onSubmit: async (newName) => {
296
+ const dir = parentDir(entry.path);
297
+ const newPath = dir ? `${dir}/${newName}` : newName;
298
+ try {
299
+ await api.post('/files/rename', { oldPath: entry.path, newPath });
300
+ fetchTreeDir(dir);
301
+ addToast('success', `Renamed to ${newName}`);
302
+ } catch (err) {
303
+ addToast('error', 'Rename failed', err.message);
304
+ }
305
+ setInlineInput(null);
306
+ },
307
+ onCancel: () => setInlineInput(null),
308
+ });
309
+ }
310
+
311
+ async function handleDelete(entry) {
312
+ const label = entry.type === 'dir' ? `folder "${entry.name}" and all contents` : `"${entry.name}"`;
313
+ if (!window.confirm(`Delete ${label}?`)) return;
314
+ try {
315
+ await api.delete(`/files/delete?path=${encodeURIComponent(entry.path)}`);
316
+ fetchTreeDir(parentDir(entry.path));
317
+ addToast('success', `Deleted ${entry.name}`);
318
+ } catch (err) {
319
+ addToast('error', 'Delete failed', err.message);
320
+ }
321
+ }
322
+
323
+ function buildContextMenuItems(entry) {
324
+ const isDir = entry.type === 'dir';
325
+ const items = [];
326
+
327
+ if (isDir) {
328
+ items.push({ icon: FilePlus, label: 'New File', action: () => handleNewFile(entry.path) });
329
+ items.push({ icon: FolderPlus, label: 'New Folder', action: () => handleNewFolder(entry.path) });
330
+ }
331
+
332
+ if (entry.name !== 'root') {
333
+ if (items.length > 0) items.push({ separator: true });
334
+ items.push({ icon: Pencil, label: 'Rename', action: () => handleRename(entry) });
335
+ items.push({ icon: Trash2, label: 'Delete', danger: true, action: () => handleDelete(entry) });
336
+ } else {
337
+ // Root context — only new file/folder
338
+ items.length = 0;
339
+ items.push({ icon: FilePlus, label: 'New File', action: () => handleNewFile('') });
340
+ items.push({ icon: FolderPlus, label: 'New Folder', action: () => handleNewFolder('') });
341
+ }
342
+
343
+ return items;
344
+ }
345
+
118
346
  const rootEntries = treeCache[''] || [];
119
347
 
120
348
  return (
121
- <div className="flex flex-col h-full bg-surface-1">
349
+ <div className="flex flex-col h-full bg-surface-1" onContextMenu={handleRootContextMenu}>
122
350
  {/* Toolbar */}
123
351
  <div className="flex items-center gap-1 px-2 py-1.5 border-b border-border-subtle">
124
352
  <div className="flex-1 relative">
@@ -130,9 +358,24 @@ export function FileTree({ rootDir }) {
130
358
  className="w-full h-6 pl-6 pr-2 text-xs bg-surface-0 border border-border-subtle rounded text-text-1 placeholder:text-text-4 focus:outline-none focus:border-accent font-sans"
131
359
  />
132
360
  </div>
361
+ <button
362
+ onClick={() => handleNewFile('')}
363
+ className="p-1 text-text-4 hover:text-text-1 transition-colors cursor-pointer"
364
+ title="New file"
365
+ >
366
+ <FilePlus size={12} />
367
+ </button>
368
+ <button
369
+ onClick={() => handleNewFolder('')}
370
+ className="p-1 text-text-4 hover:text-text-1 transition-colors cursor-pointer"
371
+ title="New folder"
372
+ >
373
+ <FolderPlus size={12} />
374
+ </button>
133
375
  <button
134
376
  onClick={() => fetchTreeDir('')}
135
377
  className="p-1 text-text-4 hover:text-text-1 transition-colors cursor-pointer"
378
+ title="Refresh"
136
379
  >
137
380
  <RefreshCw size={12} />
138
381
  </button>
@@ -141,18 +384,37 @@ export function FileTree({ rootDir }) {
141
384
  {/* Tree */}
142
385
  <ScrollArea className="flex-1">
143
386
  <div className="py-1">
387
+ {/* Inline input at root level */}
388
+ {inlineInput?.parentPath === '' && (
389
+ <InlineInput
390
+ placeholder={inlineInput.type === 'file' ? 'filename.ext' : 'folder-name'}
391
+ onSubmit={inlineInput.onSubmit}
392
+ onCancel={inlineInput.onCancel}
393
+ depth={0}
394
+ />
395
+ )}
144
396
  {rootEntries
145
397
  .filter((e) => !filter || e.name.toLowerCase().includes(filter.toLowerCase()))
146
398
  .map((entry) => (
147
399
  <div key={entry.path}>
148
- <TreeNode
149
- entry={entry}
150
- depth={0}
151
- activePath={activeFile}
152
- onFileClick={openFile}
153
- onDirToggle={onDirToggle}
154
- expanded={expanded}
155
- />
400
+ {inlineInput?.renamePath === entry.path ? (
401
+ <InlineInput
402
+ defaultValue={entry.name}
403
+ onSubmit={inlineInput.onSubmit}
404
+ onCancel={inlineInput.onCancel}
405
+ depth={0}
406
+ />
407
+ ) : (
408
+ <TreeNode
409
+ entry={entry}
410
+ depth={0}
411
+ activePath={activeFile}
412
+ onFileClick={openFile}
413
+ onDirToggle={onDirToggle}
414
+ expanded={expanded}
415
+ onContextMenu={handleContextMenu}
416
+ />
417
+ )}
156
418
  {entry.type === 'dir' && (
157
419
  <TreeDir
158
420
  dirPath={entry.path}
@@ -163,12 +425,24 @@ export function FileTree({ rootDir }) {
163
425
  onDirToggle={onDirToggle}
164
426
  treeCache={treeCache}
165
427
  fetchTreeDir={fetchTreeDir}
428
+ onContextMenu={handleContextMenu}
429
+ inlineInput={inlineInput}
166
430
  />
167
431
  )}
168
432
  </div>
169
433
  ))}
170
434
  </div>
171
435
  </ScrollArea>
436
+
437
+ {/* Context menu */}
438
+ {contextMenu && (
439
+ <ContextMenu
440
+ x={contextMenu.x}
441
+ y={contextMenu.y}
442
+ items={buildContextMenuItems(contextMenu.entry)}
443
+ onClose={() => setContextMenu(null)}
444
+ />
445
+ )}
172
446
  </div>
173
447
  );
174
448
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groove-dev",
3
- "version": "0.22.3",
3
+ "version": "0.22.5",
4
4
  "description": "Open-source agent orchestration layer — the AI company OS. MCP integrations (Slack, Gmail, Stripe, 15+), agent scheduling (cron), business roles (CMO, CFO, EA). GUI dashboard, multi-agent coordination, zero cold-start, infinite sessions. Works with Claude Code, Codex, Gemini CLI, Ollama.",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
@@ -1,10 +1,33 @@
1
1
  // GROOVE CLI — start command
2
2
  // FSL-1.1-Apache-2.0 — see LICENSE
3
3
 
4
+ import { existsSync } from 'fs';
5
+ import { resolve } from 'path';
4
6
  import { Daemon } from '@groove-dev/daemon';
5
7
  import chalk from 'chalk';
8
+ import { runSetupWizard, saveKeysViaDaemon } from '../setup.js';
6
9
 
7
10
  export async function start(options) {
11
+ const grooveDir = resolve(process.cwd(), '.groove');
12
+ const isFirstRun = !existsSync(resolve(grooveDir, 'config.json'));
13
+
14
+ // ── First-run interactive wizard ────────────────────────────
15
+ let setupKeys = {};
16
+ if (isFirstRun) {
17
+ try {
18
+ const result = await runSetupWizard();
19
+ setupKeys = result.keys || {};
20
+ } catch (err) {
21
+ // If stdin is not interactive (piped), skip wizard
22
+ if (err.code === 'ERR_USE_AFTER_CLOSE') {
23
+ console.log(chalk.dim(' Non-interactive mode — skipping setup wizard.'));
24
+ } else {
25
+ throw err;
26
+ }
27
+ }
28
+ }
29
+
30
+ // ── Start daemon ────────────────────────────────────────────
8
31
  console.log(chalk.bold('GROOVE') + ' starting daemon...');
9
32
 
10
33
  try {
@@ -15,7 +38,6 @@ export async function start(options) {
15
38
 
16
39
  const shutdown = async () => {
17
40
  console.log('\nShutting down...');
18
- // Force exit after 3s if stop hangs
19
41
  const forceTimer = setTimeout(() => process.exit(1), 3000);
20
42
  forceTimer.unref();
21
43
  try { await daemon.stop(); } catch { /* ignore */ }
@@ -26,6 +48,12 @@ export async function start(options) {
26
48
  process.on('SIGTERM', shutdown);
27
49
 
28
50
  await daemon.start();
51
+
52
+ // Save API keys from wizard (after daemon is running)
53
+ if (Object.keys(setupKeys).length > 0) {
54
+ await saveKeysViaDaemon(setupKeys, daemon.port);
55
+ }
56
+
29
57
  console.log(chalk.green('Ready.'));
30
58
  } catch (err) {
31
59
  console.error(chalk.red('Failed to start:'), err.message);