groove-dev 0.16.0 → 0.16.1

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.
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>GROOVE</title>
7
- <script type="module" crossorigin src="/assets/index-Dxg9hdf3.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-BQSznoq0.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-Gfb8Zxy9.css">
9
9
  </head>
10
10
  <body>
@@ -1,7 +1,7 @@
1
1
  // GROOVE GUI — File Tree (expandable directory browser)
2
2
  // FSL-1.1-Apache-2.0 — see LICENSE
3
3
 
4
- import React, { useState, useEffect, useCallback } from 'react';
4
+ import React, { useState, useEffect, useCallback, useRef } from 'react';
5
5
  import { useGrooveStore } from '../stores/groove';
6
6
 
7
7
  const FILE_COLORS = {
@@ -15,14 +15,14 @@ const FILE_COLORS = {
15
15
  rust: '#e06c75',
16
16
  go: '#33afbc',
17
17
  shell: '#4ae168',
18
- yaml: '#e06c75',
19
18
  text: '#abb2bf',
20
19
  };
21
20
 
22
- function TreeNode({ entry, depth, activeFile, expandedDirs, onToggleDir, onFileClick }) {
21
+ function TreeNode({ entry, depth, activeFile, expandedDirs, onToggleDir, onFileClick, onContextMenu, renamingPath, renameValue, onRenameChange, onRenameSubmit, onRenameCancel }) {
23
22
  const isDir = entry.type === 'dir';
24
23
  const isExpanded = expandedDirs.has(entry.path);
25
24
  const isActive = !isDir && entry.path === activeFile;
25
+ const isRenaming = renamingPath === entry.path;
26
26
  const treeCache = useGrooveStore((s) => s.editorTreeCache);
27
27
  const children = treeCache[entry.path] || [];
28
28
 
@@ -30,6 +30,7 @@ function TreeNode({ entry, depth, activeFile, expandedDirs, onToggleDir, onFileC
30
30
  <>
31
31
  <div
32
32
  onClick={() => isDir ? onToggleDir(entry.path, entry.hasChildren) : onFileClick(entry.path)}
33
+ onContextMenu={(e) => onContextMenu(e, entry)}
33
34
  style={{
34
35
  ...styles.row,
35
36
  paddingLeft: 12 + depth * 16,
@@ -42,14 +43,29 @@ function TreeNode({ entry, depth, activeFile, expandedDirs, onToggleDir, onFileC
42
43
  ) : (
43
44
  <span style={{ ...styles.fileDot, background: FILE_COLORS[entry.language] || FILE_COLORS.text }} />
44
45
  )}
45
- <span style={{
46
- ...styles.name,
47
- color: isDir ? 'var(--text-primary)' : (isActive ? 'var(--text-bright)' : 'var(--text-primary)'),
48
- fontWeight: isDir ? 600 : 400,
49
- }}>
50
- {entry.name}
51
- </span>
52
- {!isDir && entry.size > 0 && (
46
+ {isRenaming ? (
47
+ <input
48
+ autoFocus
49
+ value={renameValue}
50
+ onChange={(e) => onRenameChange(e.target.value)}
51
+ onKeyDown={(e) => {
52
+ if (e.key === 'Enter') onRenameSubmit();
53
+ if (e.key === 'Escape') onRenameCancel();
54
+ }}
55
+ onBlur={onRenameCancel}
56
+ onClick={(e) => e.stopPropagation()}
57
+ style={styles.renameInput}
58
+ />
59
+ ) : (
60
+ <span style={{
61
+ ...styles.name,
62
+ color: isDir ? 'var(--text-primary)' : (isActive ? 'var(--text-bright)' : 'var(--text-primary)'),
63
+ fontWeight: isDir ? 600 : 400,
64
+ }}>
65
+ {entry.name}
66
+ </span>
67
+ )}
68
+ {!isDir && !isRenaming && entry.size > 0 && (
53
69
  <span style={styles.size}>{formatSize(entry.size)}</span>
54
70
  )}
55
71
  </div>
@@ -62,6 +78,12 @@ function TreeNode({ entry, depth, activeFile, expandedDirs, onToggleDir, onFileC
62
78
  expandedDirs={expandedDirs}
63
79
  onToggleDir={onToggleDir}
64
80
  onFileClick={onFileClick}
81
+ onContextMenu={onContextMenu}
82
+ renamingPath={renamingPath}
83
+ renameValue={renameValue}
84
+ onRenameChange={onRenameChange}
85
+ onRenameSubmit={onRenameSubmit}
86
+ onRenameCancel={onRenameCancel}
65
87
  />
66
88
  ))}
67
89
  </>
@@ -74,15 +96,97 @@ function formatSize(bytes) {
74
96
  return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
75
97
  }
76
98
 
99
+ // --- Context Menu ---
100
+ function ContextMenu({ x, y, entry, onClose, onAction }) {
101
+ const ref = useRef(null);
102
+
103
+ useEffect(() => {
104
+ const handle = (e) => {
105
+ if (ref.current && !ref.current.contains(e.target)) onClose();
106
+ };
107
+ document.addEventListener('mousedown', handle);
108
+ return () => document.removeEventListener('mousedown', handle);
109
+ }, [onClose]);
110
+
111
+ const isDir = entry?.type === 'dir';
112
+
113
+ const items = [
114
+ ...(isDir ? [
115
+ { label: 'New File Here', action: 'newFileIn' },
116
+ { label: 'New Folder Here', action: 'newDirIn' },
117
+ { sep: true },
118
+ ] : []),
119
+ { label: 'Rename', action: 'rename' },
120
+ { label: 'Delete', action: 'delete', danger: true },
121
+ ];
122
+
123
+ return (
124
+ <div ref={ref} style={{ ...styles.contextMenu, left: x, top: y }}>
125
+ {items.map((item, i) =>
126
+ item.sep ? (
127
+ <div key={i} style={styles.contextSep} />
128
+ ) : (
129
+ <div
130
+ key={item.action}
131
+ onClick={() => { onAction(item.action, entry); onClose(); }}
132
+ style={{
133
+ ...styles.contextItem,
134
+ color: item.danger ? 'var(--red)' : 'var(--text-primary)',
135
+ }}
136
+ >
137
+ {item.label}
138
+ </div>
139
+ )
140
+ )}
141
+ </div>
142
+ );
143
+ }
144
+
145
+ // --- Inline New-Item Input ---
146
+ function InlineInput({ placeholder, onSubmit, onCancel, depth }) {
147
+ const [value, setValue] = useState('');
148
+ return (
149
+ <div style={{ ...styles.row, paddingLeft: 12 + (depth || 0) * 16 }}>
150
+ <input
151
+ autoFocus
152
+ placeholder={placeholder}
153
+ value={value}
154
+ onChange={(e) => setValue(e.target.value)}
155
+ onKeyDown={(e) => {
156
+ if (e.key === 'Enter' && value.trim()) onSubmit(value.trim());
157
+ if (e.key === 'Escape') onCancel();
158
+ }}
159
+ onBlur={onCancel}
160
+ style={styles.renameInput}
161
+ />
162
+ </div>
163
+ );
164
+ }
165
+
166
+ // --- Main FileTree ---
77
167
  export default function FileTree() {
78
168
  const treeCache = useGrooveStore((s) => s.editorTreeCache);
79
169
  const activeFile = useGrooveStore((s) => s.editorActiveFile);
80
170
  const fetchTreeDir = useGrooveStore((s) => s.fetchTreeDir);
81
171
  const openFile = useGrooveStore((s) => s.openFile);
172
+ const createFile = useGrooveStore((s) => s.createFile);
173
+ const createDir = useGrooveStore((s) => s.createDir);
174
+ const deleteFile = useGrooveStore((s) => s.deleteFile);
175
+ const renameFile = useGrooveStore((s) => s.renameFile);
82
176
 
83
177
  const [expandedDirs, setExpandedDirs] = useState(new Set(['']));
84
178
  const [filter, setFilter] = useState('');
85
179
 
180
+ // Context menu state
181
+ const [contextMenu, setContextMenu] = useState(null); // { x, y, entry }
182
+
183
+ // Inline creation state
184
+ const [creating, setCreating] = useState(null); // { type: 'file'|'dir', parentPath: '' }
185
+
186
+ // Rename state
187
+ const [renamingPath, setRenamingPath] = useState(null);
188
+ const [renameValue, setRenameValue] = useState('');
189
+
86
190
  // Fetch root on mount
87
191
  useEffect(() => {
88
192
  fetchTreeDir('');
@@ -103,15 +207,125 @@ export default function FileTree() {
103
207
  });
104
208
  }, [fetchTreeDir, treeCache]);
105
209
 
210
+ const onContextMenu = useCallback((e, entry) => {
211
+ e.preventDefault();
212
+ e.stopPropagation();
213
+ setContextMenu({ x: e.clientX, y: e.clientY, entry });
214
+ }, []);
215
+
216
+ const handleContextAction = useCallback(async (action, entry) => {
217
+ const parentDir = entry.path.includes('/') ? entry.path.split('/').slice(0, -1).join('/') : '';
218
+
219
+ switch (action) {
220
+ case 'newFileIn':
221
+ setCreating({ type: 'file', parentPath: entry.path });
222
+ // Ensure dir is expanded
223
+ setExpandedDirs((prev) => {
224
+ const next = new Set(prev);
225
+ next.add(entry.path);
226
+ if (!treeCache[entry.path]) fetchTreeDir(entry.path);
227
+ return next;
228
+ });
229
+ break;
230
+ case 'newDirIn':
231
+ setCreating({ type: 'dir', parentPath: entry.path });
232
+ setExpandedDirs((prev) => {
233
+ const next = new Set(prev);
234
+ next.add(entry.path);
235
+ if (!treeCache[entry.path]) fetchTreeDir(entry.path);
236
+ return next;
237
+ });
238
+ break;
239
+ case 'rename':
240
+ setRenamingPath(entry.path);
241
+ setRenameValue(entry.name);
242
+ break;
243
+ case 'delete':
244
+ if (confirm(`Delete "${entry.name}"?`)) {
245
+ await deleteFile(entry.path);
246
+ }
247
+ break;
248
+ }
249
+ }, [deleteFile, fetchTreeDir, treeCache]);
250
+
251
+ const handleCreate = useCallback(async (name) => {
252
+ if (!creating) return;
253
+ const fullPath = creating.parentPath ? `${creating.parentPath}/${name}` : name;
254
+ const ok = creating.type === 'file'
255
+ ? await createFile(fullPath)
256
+ : await createDir(fullPath);
257
+ setCreating(null);
258
+ if (ok && creating.type === 'file') openFile(fullPath);
259
+ }, [creating, createFile, createDir, openFile]);
260
+
261
+ const handleRenameSubmit = useCallback(async () => {
262
+ if (!renamingPath || !renameValue.trim()) { setRenamingPath(null); return; }
263
+ const parentDir = renamingPath.includes('/') ? renamingPath.split('/').slice(0, -1).join('/') : '';
264
+ const newPath = parentDir ? `${parentDir}/${renameValue.trim()}` : renameValue.trim();
265
+ if (newPath !== renamingPath) {
266
+ await renameFile(renamingPath, newPath);
267
+ }
268
+ setRenamingPath(null);
269
+ }, [renamingPath, renameValue, renameFile]);
270
+
271
+ // Handle root-level context menu (right-click on empty space)
272
+ const handleTreeContextMenu = useCallback((e) => {
273
+ // Only if click is on the tree background, not a node
274
+ if (e.target === e.currentTarget) {
275
+ e.preventDefault();
276
+ setContextMenu({ x: e.clientX, y: e.clientY, entry: { type: 'dir', path: '', name: 'root' } });
277
+ }
278
+ }, []);
279
+
106
280
  const rootEntries = treeCache[''] || [];
107
281
 
108
- // Client-side filter
282
+ // Client-side filter — search all cached entries recursively
109
283
  const filtered = filter
110
284
  ? rootEntries.filter((e) => e.name.toLowerCase().includes(filter.toLowerCase()))
111
285
  : rootEntries;
112
286
 
287
+ // Calculate inline input depth based on parent
288
+ const creatingDepth = creating
289
+ ? (creating.parentPath === '' ? 0 : creating.parentPath.split('/').length)
290
+ : 0;
291
+
113
292
  return (
114
293
  <div style={styles.container}>
294
+ {/* Toolbar */}
295
+ <div style={styles.toolbar}>
296
+ <span style={styles.toolbarTitle}>FILES</span>
297
+ <div style={styles.toolbarActions}>
298
+ <button
299
+ onClick={() => setCreating({ type: 'file', parentPath: '' })}
300
+ title="New File"
301
+ style={styles.toolbarBtn}
302
+ >
303
+ +f
304
+ </button>
305
+ <button
306
+ onClick={() => setCreating({ type: 'dir', parentPath: '' })}
307
+ title="New Folder"
308
+ style={styles.toolbarBtn}
309
+ >
310
+ +d
311
+ </button>
312
+ <button
313
+ onClick={() => setExpandedDirs(new Set(['']))}
314
+ title="Collapse All"
315
+ style={styles.toolbarBtn}
316
+ >
317
+ {'\u2191'}
318
+ </button>
319
+ <button
320
+ onClick={() => fetchTreeDir('')}
321
+ title="Refresh"
322
+ style={styles.toolbarBtn}
323
+ >
324
+ {'\u21BB'}
325
+ </button>
326
+ </div>
327
+ </div>
328
+
115
329
  {/* Search */}
116
330
  <div style={styles.searchWrap}>
117
331
  <input
@@ -124,24 +338,62 @@ export default function FileTree() {
124
338
  </div>
125
339
 
126
340
  {/* Tree */}
127
- <div style={styles.tree}>
128
- {filtered.length === 0 && (
341
+ <div style={styles.tree} onContextMenu={handleTreeContextMenu}>
342
+ {filtered.length === 0 && !creating && (
129
343
  <div style={styles.empty}>
130
344
  {rootEntries.length === 0 ? 'Loading...' : 'No matches'}
131
345
  </div>
132
346
  )}
133
- {filtered.map((entry) => (
134
- <TreeNode
135
- key={entry.path}
136
- entry={entry}
347
+
348
+ {/* Inline creation at root level */}
349
+ {creating && creating.parentPath === '' && (
350
+ <InlineInput
351
+ placeholder={creating.type === 'file' ? 'filename.ext' : 'folder-name'}
352
+ onSubmit={handleCreate}
353
+ onCancel={() => setCreating(null)}
137
354
  depth={0}
138
- activeFile={activeFile}
139
- expandedDirs={expandedDirs}
140
- onToggleDir={onToggleDir}
141
- onFileClick={openFile}
142
355
  />
356
+ )}
357
+
358
+ {filtered.map((entry) => (
359
+ <React.Fragment key={entry.path}>
360
+ <TreeNode
361
+ entry={entry}
362
+ depth={0}
363
+ activeFile={activeFile}
364
+ expandedDirs={expandedDirs}
365
+ onToggleDir={onToggleDir}
366
+ onFileClick={openFile}
367
+ onContextMenu={onContextMenu}
368
+ renamingPath={renamingPath}
369
+ renameValue={renameValue}
370
+ onRenameChange={setRenameValue}
371
+ onRenameSubmit={handleRenameSubmit}
372
+ onRenameCancel={() => setRenamingPath(null)}
373
+ />
374
+ {/* Inline creation inside this dir */}
375
+ {creating && creating.parentPath === entry.path && expandedDirs.has(entry.path) && (
376
+ <InlineInput
377
+ placeholder={creating.type === 'file' ? 'filename.ext' : 'folder-name'}
378
+ onSubmit={handleCreate}
379
+ onCancel={() => setCreating(null)}
380
+ depth={1}
381
+ />
382
+ )}
383
+ </React.Fragment>
143
384
  ))}
144
385
  </div>
386
+
387
+ {/* Context Menu */}
388
+ {contextMenu && (
389
+ <ContextMenu
390
+ x={contextMenu.x}
391
+ y={contextMenu.y}
392
+ entry={contextMenu.entry}
393
+ onClose={() => setContextMenu(null)}
394
+ onAction={handleContextAction}
395
+ />
396
+ )}
145
397
  </div>
146
398
  );
147
399
  }
@@ -150,9 +402,29 @@ const styles = {
150
402
  container: {
151
403
  display: 'flex', flexDirection: 'column',
152
404
  height: '100%', background: 'var(--bg-chrome)',
405
+ position: 'relative',
406
+ },
407
+ toolbar: {
408
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
409
+ padding: '6px 10px',
410
+ borderBottom: '1px solid var(--border)',
411
+ },
412
+ toolbarTitle: {
413
+ fontSize: 10, fontWeight: 700, letterSpacing: 1,
414
+ color: 'var(--text-dim)', textTransform: 'uppercase',
415
+ },
416
+ toolbarActions: {
417
+ display: 'flex', gap: 2,
418
+ },
419
+ toolbarBtn: {
420
+ background: 'none', border: 'none',
421
+ color: 'var(--text-dim)', fontSize: 11, fontWeight: 600,
422
+ cursor: 'pointer', fontFamily: 'var(--font)',
423
+ padding: '2px 5px', borderRadius: 3,
424
+ lineHeight: 1,
153
425
  },
154
426
  searchWrap: {
155
- padding: '8px 8px 6px',
427
+ padding: '6px 8px',
156
428
  borderBottom: '1px solid var(--border)',
157
429
  },
158
430
  searchInput: {
@@ -190,4 +462,31 @@ const styles = {
190
462
  padding: 16, textAlign: 'center',
191
463
  fontSize: 11, color: 'var(--text-dim)',
192
464
  },
465
+ renameInput: {
466
+ flex: 1, padding: '2px 6px',
467
+ background: 'var(--bg-base)', border: '1px solid var(--accent)',
468
+ borderRadius: 2, color: 'var(--text-bright)',
469
+ fontSize: 12, fontFamily: 'var(--font)',
470
+ outline: 'none', minWidth: 0,
471
+ },
472
+
473
+ // Context menu
474
+ contextMenu: {
475
+ position: 'fixed', zIndex: 300,
476
+ background: 'var(--bg-surface)',
477
+ border: '1px solid var(--border)',
478
+ borderRadius: 4, padding: '4px 0',
479
+ minWidth: 160,
480
+ boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
481
+ fontFamily: 'var(--font)',
482
+ },
483
+ contextItem: {
484
+ padding: '6px 14px', fontSize: 11,
485
+ cursor: 'pointer',
486
+ transition: 'background 0.08s',
487
+ },
488
+ contextSep: {
489
+ height: 1, background: 'var(--border)',
490
+ margin: '4px 0',
491
+ },
193
492
  };
@@ -0,0 +1,104 @@
1
+ // GROOVE GUI — Media Viewer (images + video)
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import React from 'react';
5
+
6
+ const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif']);
7
+ const VIDEO_EXTS = new Set(['mp4', 'webm', 'mov', 'avi', 'mkv', 'ogv']);
8
+
9
+ export function isMediaFile(path) {
10
+ const ext = path.split('.').pop()?.toLowerCase();
11
+ return IMAGE_EXTS.has(ext) || VIDEO_EXTS.has(ext);
12
+ }
13
+
14
+ export function isImageFile(path) {
15
+ const ext = path.split('.').pop()?.toLowerCase();
16
+ return IMAGE_EXTS.has(ext);
17
+ }
18
+
19
+ export default function MediaViewer({ path }) {
20
+ const ext = path.split('.').pop()?.toLowerCase();
21
+ const rawUrl = `/api/files/raw?path=${encodeURIComponent(path)}`;
22
+ const filename = path.split('/').pop();
23
+ const isImage = IMAGE_EXTS.has(ext);
24
+ const isVideo = VIDEO_EXTS.has(ext);
25
+
26
+ return (
27
+ <div style={styles.container}>
28
+ <div style={styles.header}>
29
+ <span style={styles.filename}>{filename}</span>
30
+ <span style={styles.badge}>{ext.toUpperCase()}</span>
31
+ </div>
32
+
33
+ <div style={styles.preview}>
34
+ {isImage && (
35
+ <img
36
+ src={rawUrl}
37
+ alt={filename}
38
+ style={styles.image}
39
+ draggable={false}
40
+ />
41
+ )}
42
+ {isVideo && (
43
+ <video
44
+ src={rawUrl}
45
+ controls
46
+ style={styles.video}
47
+ >
48
+ Your browser does not support this video format.
49
+ </video>
50
+ )}
51
+ </div>
52
+
53
+ <div style={styles.footer}>
54
+ <a href={rawUrl} target="_blank" rel="noopener noreferrer" style={styles.link}>
55
+ Open in new tab
56
+ </a>
57
+ </div>
58
+ </div>
59
+ );
60
+ }
61
+
62
+ const styles = {
63
+ container: {
64
+ flex: 1, display: 'flex', flexDirection: 'column',
65
+ background: 'var(--bg-base)', overflow: 'hidden',
66
+ },
67
+ header: {
68
+ padding: '10px 16px',
69
+ borderBottom: '1px solid var(--border)',
70
+ display: 'flex', alignItems: 'center', gap: 8,
71
+ },
72
+ filename: {
73
+ fontSize: 12, color: 'var(--text-bright)', fontWeight: 500,
74
+ },
75
+ badge: {
76
+ fontSize: 9, fontWeight: 700, letterSpacing: 0.5,
77
+ color: 'var(--text-dim)', background: 'var(--bg-active)',
78
+ padding: '2px 6px', borderRadius: 3,
79
+ },
80
+ preview: {
81
+ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
82
+ overflow: 'auto', padding: 24,
83
+ background: 'repeating-conic-gradient(var(--bg-surface) 0% 25%, var(--bg-base) 0% 50%) 50% / 20px 20px',
84
+ },
85
+ image: {
86
+ maxWidth: '100%', maxHeight: '100%',
87
+ objectFit: 'contain', borderRadius: 4,
88
+ boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
89
+ },
90
+ video: {
91
+ maxWidth: '100%', maxHeight: '100%',
92
+ borderRadius: 4, outline: 'none',
93
+ boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
94
+ },
95
+ footer: {
96
+ padding: '8px 16px',
97
+ borderTop: '1px solid var(--border)',
98
+ display: 'flex', alignItems: 'center',
99
+ },
100
+ link: {
101
+ fontSize: 11, color: 'var(--accent)',
102
+ textDecoration: 'none', fontFamily: 'var(--font)',
103
+ },
104
+ };