@geminilight/mindos 0.5.69 → 0.5.70

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.
@@ -0,0 +1,415 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef, useState, useCallback } from 'react';
4
+ import {
5
+ X, FolderInput, Sparkles, FileText, AlertCircle,
6
+ AlertTriangle, Loader2, Check, ArrowLeft,
7
+ } from 'lucide-react';
8
+ import { useLocale } from '@/lib/LocaleContext';
9
+ import { useFileImport, type ImportIntent, type ConflictMode } from '@/hooks/useFileImport';
10
+ import { openAskModal } from '@/hooks/useAskModal';
11
+ import { ALLOWED_IMPORT_EXTENSIONS } from '@/lib/core/file-convert';
12
+ import type { LocalAttachment } from '@/lib/types';
13
+
14
+ interface ImportModalProps {
15
+ open: boolean;
16
+ onClose: () => void;
17
+ defaultSpace?: string;
18
+ initialFiles?: File[];
19
+ }
20
+
21
+ const ACCEPT = Array.from(ALLOWED_IMPORT_EXTENSIONS).join(',');
22
+
23
+ export default function ImportModal({ open, onClose, defaultSpace, initialFiles }: ImportModalProps) {
24
+ const { t } = useLocale();
25
+ const im = useFileImport();
26
+ const overlayRef = useRef<HTMLDivElement>(null);
27
+ const fileInputRef = useRef<HTMLInputElement>(null);
28
+ const [spaces, setSpaces] = useState<Array<{ name: string; path: string }>>([]);
29
+ const [closing, setClosing] = useState(false);
30
+ const [showSuccess, setShowSuccess] = useState(false);
31
+ const initializedRef = useRef(false);
32
+
33
+ useEffect(() => {
34
+ if (!open) {
35
+ initializedRef.current = false;
36
+ return;
37
+ }
38
+ if (initializedRef.current) return;
39
+ initializedRef.current = true;
40
+ im.reset();
41
+ if (defaultSpace) im.setTargetSpace(defaultSpace);
42
+ if (initialFiles && initialFiles.length > 0) {
43
+ im.addFiles(initialFiles);
44
+ }
45
+ fetch('/api/file?op=list_spaces')
46
+ .then(r => r.json())
47
+ .then(d => { if (d.spaces) setSpaces(d.spaces); })
48
+ .catch(() => {});
49
+ }, [open, defaultSpace, initialFiles, im]);
50
+
51
+ const handleClose = useCallback(() => {
52
+ if (im.files.length > 0 && im.step !== 'done') {
53
+ if (!confirm(t.fileImport.discardMessage(im.files.length))) return;
54
+ }
55
+ setClosing(true);
56
+ setTimeout(() => { setClosing(false); onClose(); im.reset(); }, 150);
57
+ }, [im, onClose, t]);
58
+
59
+ useEffect(() => {
60
+ if (!open) return;
61
+ const handler = (e: KeyboardEvent) => {
62
+ if (e.key === 'Escape') { e.stopPropagation(); handleClose(); }
63
+ };
64
+ window.addEventListener('keydown', handler, true);
65
+ return () => window.removeEventListener('keydown', handler, true);
66
+ }, [open, handleClose]);
67
+
68
+ const handleIntentSelect = useCallback((intent: ImportIntent) => {
69
+ im.setIntent(intent);
70
+ if (intent === 'archive') {
71
+ im.setStep('archive_config');
72
+ } else {
73
+ const attachments: LocalAttachment[] = im.validFiles.map(f => ({
74
+ name: f.name,
75
+ content: f.content!,
76
+ }));
77
+ setClosing(true);
78
+ setTimeout(() => {
79
+ setClosing(false);
80
+ onClose();
81
+ window.dispatchEvent(new CustomEvent('mindos:inject-ask-files', {
82
+ detail: { files: attachments },
83
+ }));
84
+ const prompt = attachments.length === 1
85
+ ? t.fileImport.digestPromptSingle(attachments[0].name)
86
+ : t.fileImport.digestPromptMulti(attachments.length);
87
+ openAskModal(prompt);
88
+ im.reset();
89
+ }, 150);
90
+ }
91
+ }, [im, onClose, t]);
92
+
93
+ const handleArchiveSubmit = useCallback(async () => {
94
+ await im.doArchive();
95
+ if (im.result && im.result.created.length > 0) {
96
+ setShowSuccess(true);
97
+ setTimeout(() => {
98
+ setClosing(true);
99
+ setTimeout(() => {
100
+ setClosing(false);
101
+ onClose();
102
+ im.reset();
103
+ setShowSuccess(false);
104
+ window.dispatchEvent(new Event('mindos:files-changed'));
105
+ }, 150);
106
+ }, 600);
107
+ }
108
+ }, [im, onClose]);
109
+
110
+ useEffect(() => {
111
+ if (im.step === 'done' && im.result) {
112
+ if (im.result.created.length > 0) {
113
+ setShowSuccess(true);
114
+ const timer = setTimeout(() => {
115
+ setClosing(true);
116
+ setTimeout(() => {
117
+ setClosing(false);
118
+ onClose();
119
+ im.reset();
120
+ setShowSuccess(false);
121
+ window.dispatchEvent(new Event('mindos:files-changed'));
122
+ }, 150);
123
+ }, 800);
124
+ return () => clearTimeout(timer);
125
+ }
126
+ }
127
+ }, [im.step, im.result, onClose, im]);
128
+
129
+ if (!open && !closing) return null;
130
+
131
+ const hasFiles = im.files.length > 0;
132
+ const isSelectStep = im.step === 'select';
133
+ const isArchiveConfig = im.step === 'archive_config';
134
+ const isImporting = im.step === 'importing';
135
+
136
+ return (
137
+ <>
138
+ <div
139
+ ref={overlayRef}
140
+ className={`fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 transition-opacity duration-200 ${closing ? 'opacity-0' : 'opacity-100'}`}
141
+ onClick={(e) => { if (e.target === overlayRef.current) handleClose(); }}
142
+ >
143
+ <div
144
+ className={`w-full max-w-lg bg-card rounded-xl shadow-xl border border-border transition-all duration-200 ${closing ? 'opacity-0 scale-[0.98]' : 'opacity-100 scale-100'}`}
145
+ role="dialog"
146
+ aria-modal="true"
147
+ aria-label={t.fileImport.title}
148
+ >
149
+ {/* Header */}
150
+ <div className="flex items-start justify-between px-5 pt-5 pb-2">
151
+ <div>
152
+ {isArchiveConfig && (
153
+ <button
154
+ onClick={() => im.setStep('select')}
155
+ className="text-xs text-muted-foreground hover:text-foreground transition-colors mb-1"
156
+ >
157
+ {t.fileImport.back}
158
+ </button>
159
+ )}
160
+ <h2 className="text-base font-semibold text-foreground">
161
+ {isArchiveConfig ? t.fileImport.archiveConfigTitle : t.fileImport.title}
162
+ </h2>
163
+ {isSelectStep && (
164
+ <p className="text-xs text-muted-foreground mt-0.5">{t.fileImport.subtitle}</p>
165
+ )}
166
+ </div>
167
+ <button
168
+ onClick={handleClose}
169
+ className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
170
+ aria-label="Close"
171
+ >
172
+ <X size={16} />
173
+ </button>
174
+ </div>
175
+
176
+ <div className="px-5 pb-5">
177
+ {/* DropZone */}
178
+ {isSelectStep && (
179
+ <div
180
+ className={`border-2 border-dashed rounded-lg transition-all duration-200 cursor-pointer ${
181
+ hasFiles
182
+ ? 'border-border py-3 px-4'
183
+ : 'border-[var(--amber)]/30 hover:border-[var(--amber)]/60 py-8 px-4'
184
+ }`}
185
+ role="button"
186
+ tabIndex={0}
187
+ aria-label={t.fileImport.dropzoneButton}
188
+ onClick={() => fileInputRef.current?.click()}
189
+ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInputRef.current?.click(); } }}
190
+ onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }}
191
+ onDrop={(e) => {
192
+ e.preventDefault();
193
+ e.stopPropagation();
194
+ if (e.dataTransfer.files.length > 0) im.addFiles(e.dataTransfer.files);
195
+ }}
196
+ >
197
+ {hasFiles ? (
198
+ <p className="text-xs text-muted-foreground text-center">
199
+ {t.fileImport.dropzoneCompact}{' '}
200
+ <span className="text-[var(--amber)] hover:underline">{t.fileImport.dropzoneCompactButton}</span>
201
+ </p>
202
+ ) : (
203
+ <div className="flex flex-col items-center gap-2 text-center">
204
+ <FolderInput size={28} className="text-[var(--amber)]/40" />
205
+ <p className="text-sm text-muted-foreground">
206
+ <span className="hidden md:inline">{t.fileImport.dropzoneText}{' '}</span>
207
+ <span className="md:hidden">{t.fileImport.dropzoneMobile}</span>
208
+ <span className="hidden md:inline text-[var(--amber)] hover:underline">{t.fileImport.dropzoneButton}</span>
209
+ </p>
210
+ <p className="text-2xs text-muted-foreground/60">{t.fileImport.dropOverlayFormats}</p>
211
+ </div>
212
+ )}
213
+ </div>
214
+ )}
215
+
216
+ <input
217
+ ref={fileInputRef}
218
+ type="file"
219
+ multiple
220
+ className="hidden"
221
+ accept={ACCEPT}
222
+ onChange={(e) => {
223
+ if (e.target.files) im.addFiles(e.target.files);
224
+ e.target.value = '';
225
+ }}
226
+ />
227
+
228
+ {/* File list */}
229
+ {hasFiles && (
230
+ <div className="mt-3">
231
+ <div className="flex items-center justify-between mb-1.5">
232
+ <span className="text-xs text-muted-foreground">{t.fileImport.fileCount(im.files.length)}</span>
233
+ {isSelectStep && (
234
+ <button
235
+ onClick={im.clearFiles}
236
+ className="text-2xs text-muted-foreground hover:text-foreground transition-colors"
237
+ >
238
+ {t.fileImport.clearAll}
239
+ </button>
240
+ )}
241
+ </div>
242
+ <div className="flex flex-col gap-1 max-h-[200px] overflow-y-auto">
243
+ {im.files.map((f, idx) => (
244
+ <div
245
+ key={`${f.name}-${idx}`}
246
+ className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm ${
247
+ f.error ? 'bg-error/5' : 'bg-muted/50'
248
+ }`}
249
+ >
250
+ {f.loading ? (
251
+ <Loader2 size={14} className="text-muted-foreground animate-spin shrink-0" />
252
+ ) : f.error ? (
253
+ <AlertCircle size={14} className="text-error shrink-0" />
254
+ ) : (
255
+ <FileText size={14} className="text-muted-foreground shrink-0" />
256
+ )}
257
+ <span className="truncate flex-1 text-foreground">{f.name}</span>
258
+ {f.error ? (
259
+ <span className="text-xs text-error shrink-0">
260
+ {f.error === 'unsupported' ? t.fileImport.unsupported
261
+ : f.error === 'tooLarge' ? t.fileImport.tooLarge('5MB')
262
+ : f.error}
263
+ </span>
264
+ ) : (
265
+ <span className="text-xs text-muted-foreground tabular-nums shrink-0">
266
+ {im.formatSize(f.size)}
267
+ </span>
268
+ )}
269
+ {isSelectStep && !isArchiveConfig && (
270
+ <button
271
+ onClick={(e) => { e.stopPropagation(); im.removeFile(idx); }}
272
+ className="p-0.5 text-muted-foreground hover:text-foreground transition-colors shrink-0"
273
+ aria-label={`${t.fileImport.remove} ${f.name}`}
274
+ >
275
+ <X size={14} />
276
+ </button>
277
+ )}
278
+ </div>
279
+ ))}
280
+ </div>
281
+
282
+ {/* Archive config: target path preview */}
283
+ {isArchiveConfig && (
284
+ <div className="flex flex-col gap-1 mt-2 max-h-[120px] overflow-y-auto">
285
+ {im.validFiles.map((f, idx) => {
286
+ const ext = f.name.split('.').pop()?.toLowerCase();
287
+ const targetExt = (ext === 'txt' || ext === 'html' || ext === 'htm' || ext === 'yaml' || ext === 'yml' || ext === 'xml')
288
+ ? 'md' : ext;
289
+ const stem = f.name.replace(/\.[^.]+$/, '');
290
+ const targetName = `${stem}.${targetExt}`;
291
+ const targetPath = im.targetSpace ? `${im.targetSpace}/${targetName}` : targetName;
292
+ return (
293
+ <div key={`preview-${idx}`} className="text-xs text-muted-foreground px-3">
294
+ {f.name} <span className="text-muted-foreground/50">{t.fileImport.arrowTo}</span> {targetPath}
295
+ </div>
296
+ );
297
+ })}
298
+ </div>
299
+ )}
300
+ </div>
301
+ )}
302
+
303
+ {/* Intent cards (Step 1) */}
304
+ {isSelectStep && hasFiles && im.allReady && (
305
+ <div className="grid grid-cols-2 gap-3 mt-4">
306
+ <button
307
+ onClick={() => handleIntentSelect('archive')}
308
+ className="flex flex-col items-center gap-2 p-4 border rounded-lg cursor-pointer transition-all duration-150 border-[var(--amber)]/30 bg-card hover:border-[var(--amber)]/60 hover:shadow-sm active:scale-[0.98] text-left"
309
+ disabled={im.validFiles.length === 0}
310
+ >
311
+ <FolderInput size={24} className="text-[var(--amber)]" />
312
+ <span className="text-sm font-medium text-foreground">{t.fileImport.archiveTitle}</span>
313
+ <span className="text-xs text-muted-foreground text-center">{t.fileImport.archiveDesc}</span>
314
+ </button>
315
+ <button
316
+ onClick={() => handleIntentSelect('digest')}
317
+ className="flex flex-col items-center gap-2 p-4 border border-border rounded-lg cursor-pointer transition-all duration-150 bg-card hover:border-[var(--amber)]/50 hover:shadow-sm active:scale-[0.98] text-left"
318
+ disabled={im.validFiles.length === 0}
319
+ >
320
+ <Sparkles size={24} className="text-[var(--amber)]" />
321
+ <span className="text-sm font-medium text-foreground">{t.fileImport.digestTitle}</span>
322
+ <span className="text-xs text-muted-foreground text-center">{t.fileImport.digestDesc}</span>
323
+ </button>
324
+ </div>
325
+ )}
326
+
327
+ {/* Archive config (Step 2a) */}
328
+ {isArchiveConfig && (
329
+ <div className="mt-4 space-y-4">
330
+ {/* Space selector */}
331
+ <div>
332
+ <label className="text-xs font-medium text-foreground mb-1 block">{t.fileImport.targetSpace}</label>
333
+ <select
334
+ value={im.targetSpace}
335
+ onChange={(e) => im.setTargetSpace(e.target.value)}
336
+ className="w-full bg-muted border border-border rounded-lg px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
337
+ >
338
+ <option value="">{t.fileImport.rootDir}</option>
339
+ {spaces.map(s => (
340
+ <option key={s.path} value={s.path}>{s.name}</option>
341
+ ))}
342
+ </select>
343
+ </div>
344
+
345
+ {/* Conflict strategy */}
346
+ <div>
347
+ <label className="text-xs font-medium text-foreground mb-1.5 block">{t.fileImport.conflictLabel}</label>
348
+ <div className="flex flex-col gap-1.5">
349
+ {([
350
+ { value: 'rename' as ConflictMode, label: t.fileImport.conflictRename },
351
+ { value: 'skip' as ConflictMode, label: t.fileImport.conflictSkip },
352
+ { value: 'overwrite' as ConflictMode, label: t.fileImport.conflictOverwrite },
353
+ ]).map(opt => (
354
+ <label
355
+ key={opt.value}
356
+ className={`flex items-center gap-2 py-1 text-sm cursor-pointer ${
357
+ opt.value === 'overwrite' ? 'text-error' : 'text-foreground'
358
+ }`}
359
+ >
360
+ <input
361
+ type="radio"
362
+ name="conflict"
363
+ value={opt.value}
364
+ checked={im.conflict === opt.value}
365
+ onChange={() => im.setConflict(opt.value)}
366
+ className="accent-[var(--amber)]"
367
+ />
368
+ {opt.label}
369
+ {opt.value === 'overwrite' && (
370
+ <AlertTriangle size={13} className="text-error shrink-0" />
371
+ )}
372
+ </label>
373
+ ))}
374
+ {im.conflict === 'overwrite' && (
375
+ <p className="text-2xs text-error/80 pl-6">{t.fileImport.overwriteWarn}</p>
376
+ )}
377
+ </div>
378
+ </div>
379
+
380
+ {/* Actions */}
381
+ <div className="flex items-center justify-end gap-3 pt-2">
382
+ <button
383
+ onClick={handleClose}
384
+ className="text-sm text-muted-foreground hover:text-foreground transition-colors px-3 py-2"
385
+ >
386
+ {t.fileImport.cancel}
387
+ </button>
388
+ <button
389
+ onClick={handleArchiveSubmit}
390
+ disabled={isImporting || im.validFiles.length === 0}
391
+ className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
392
+ showSuccess
393
+ ? 'bg-success text-success-foreground'
394
+ : 'bg-[var(--amber)] text-[var(--amber-foreground)] hover:opacity-90'
395
+ } disabled:opacity-50`}
396
+ >
397
+ {showSuccess ? (
398
+ <><Check size={14} /> {t.fileImport.importButton(im.validFiles.length)}</>
399
+ ) : isImporting ? (
400
+ <><Loader2 size={14} className="animate-spin" /> {t.fileImport.importing}</>
401
+ ) : !im.allReady ? (
402
+ t.fileImport.preparing
403
+ ) : (
404
+ t.fileImport.importButton(im.validFiles.length)
405
+ )}
406
+ </button>
407
+ </div>
408
+ </div>
409
+ )}
410
+ </div>
411
+ </div>
412
+ </div>
413
+ </>
414
+ );
415
+ }
@@ -133,6 +133,15 @@ export default function OnboardingView() {
133
133
  <p className="text-center text-xs leading-relaxed max-w-sm mx-auto font-display text-muted-foreground opacity-60">
134
134
  {ob.importHint}
135
135
  </p>
136
+ <p className="text-center mt-2">
137
+ <button
138
+ type="button"
139
+ onClick={() => window.dispatchEvent(new CustomEvent('mindos:open-import'))}
140
+ className="text-xs text-[var(--amber)] hover:underline transition-colors"
141
+ >
142
+ {t.fileImport.onboardingHint}
143
+ </button>
144
+ </p>
136
145
 
137
146
  {/* Sync hint card */}
138
147
  <div className="max-w-md mx-auto mt-6 flex items-center gap-3 px-4 py-3 rounded-lg border border-border bg-card text-left">
@@ -27,7 +27,6 @@ const DEFAULT_PANEL_WIDTH: Record<PanelId, number> = {
27
27
  files: 280,
28
28
  search: 280,
29
29
  echo: 280,
30
- plugins: 280,
31
30
  agents: 280,
32
31
  discover: 280,
33
32
  };
@@ -52,6 +51,8 @@ interface PanelProps {
52
51
  maximized?: boolean;
53
52
  /** Callback to toggle maximize */
54
53
  onMaximize?: () => void;
54
+ /** Callback to open import modal for a space */
55
+ onImport?: (space: string) => void;
55
56
  /** Lazy-loaded panel content for search/ask/plugins */
56
57
  children?: React.ReactNode;
57
58
  }
@@ -67,6 +68,7 @@ export default function Panel({
67
68
  onWidthCommit,
68
69
  maximized = false,
69
70
  onMaximize,
71
+ onImport,
70
72
  children,
71
73
  }: PanelProps) {
72
74
  const open = activePanel !== null;
@@ -135,7 +137,7 @@ export default function Panel({
135
137
  </div>
136
138
  </PanelHeader>
137
139
  <div className="flex-1 overflow-y-auto min-h-0 px-2 py-2">
138
- <FileTree nodes={fileTree} onNavigate={onNavigate} maxOpenDepth={maxOpenDepth} />
140
+ <FileTree nodes={fileTree} onNavigate={onNavigate} maxOpenDepth={maxOpenDepth} onImport={onImport} />
139
141
  </div>
140
142
  <SyncStatusBar collapsed={false} onOpenSyncSettings={onOpenSyncSettings} />
141
143
  </div>
@@ -1,15 +1,15 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useCallback } from 'react';
3
+ import { useState, useEffect, useCallback, useRef } from 'react';
4
4
  import { useRouter, usePathname } from 'next/navigation';
5
5
  import Link from 'next/link';
6
- import { Search, Settings, Menu, X } from 'lucide-react';
6
+ import { Search, Settings, Menu, X, FolderInput } from 'lucide-react';
7
7
  import ActivityBar, { type PanelId } from './ActivityBar';
8
8
  import Panel from './Panel';
9
9
  import FileTree from './FileTree';
10
10
  import Logo from './Logo';
11
11
  import SearchPanel from './panels/SearchPanel';
12
- import PluginsPanel from './panels/PluginsPanel';
12
+
13
13
  import AgentsPanel from './panels/AgentsPanel';
14
14
  import DiscoverPanel from './panels/DiscoverPanel';
15
15
  import EchoPanel from './panels/EchoPanel';
@@ -29,6 +29,9 @@ import ChangesBanner from './changes/ChangesBanner';
29
29
  import { MobileSyncDot, useSyncStatus } from './SyncStatusBar';
30
30
  import { FileNode } from '@/lib/types';
31
31
  import { useLocale } from '@/lib/LocaleContext';
32
+ import dynamic from 'next/dynamic';
33
+
34
+ const ImportModal = dynamic(() => import('./ImportModal'), { ssr: false });
32
35
  import { WalkthroughProvider } from './walkthrough';
33
36
  import McpProvider from '@/hooks/useMcpData';
34
37
  import '@/lib/renderers/index'; // client-side renderer registration source of truth
@@ -70,6 +73,25 @@ export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps
70
73
  return RIGHT_AGENT_DETAIL_DEFAULT_WIDTH;
71
74
  });
72
75
 
76
+ // ── Import modal state ──
77
+ const [importModalOpen, setImportModalOpen] = useState(false);
78
+ const [importDefaultSpace, setImportDefaultSpace] = useState<string | undefined>(undefined);
79
+ const [importInitialFiles, setImportInitialFiles] = useState<File[] | undefined>(undefined);
80
+ const [dragOverlay, setDragOverlay] = useState(false);
81
+ const dragCounterRef = useRef(0);
82
+
83
+ const handleOpenImport = useCallback((space?: string) => {
84
+ setImportDefaultSpace(space);
85
+ setImportInitialFiles(undefined);
86
+ setImportModalOpen(true);
87
+ }, []);
88
+
89
+ const handleCloseImport = useCallback(() => {
90
+ setImportModalOpen(false);
91
+ setImportDefaultSpace(undefined);
92
+ setImportInitialFiles(undefined);
93
+ }, []);
94
+
73
95
  // ── Mobile state ──
74
96
  const [mobileOpen, setMobileOpen] = useState(false);
75
97
  const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
@@ -99,6 +121,12 @@ export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps
99
121
  return () => window.removeEventListener('mindos:open-settings', handler);
100
122
  }, []);
101
123
 
124
+ useEffect(() => {
125
+ const handler = () => handleOpenImport();
126
+ window.addEventListener('mindos:open-import', handler);
127
+ return () => window.removeEventListener('mindos:open-import', handler);
128
+ }, [handleOpenImport]);
129
+
102
130
  // GuideCard first message handler
103
131
  const handleFirstMessage = useCallback(() => {
104
132
  const notifyGuide = () => window.dispatchEvent(new Event('guide-state-updated'));
@@ -206,6 +234,10 @@ export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps
206
234
  e.preventDefault();
207
235
  setSettingsOpen(v => !v);
208
236
  }
237
+ if ((e.metaKey || e.ctrlKey) && e.key === 'i') {
238
+ e.preventDefault();
239
+ setImportModalOpen(v => !v);
240
+ }
209
241
  };
210
242
  window.addEventListener('keydown', handler);
211
243
  return () => window.removeEventListener('keydown', handler);
@@ -283,6 +315,7 @@ export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps
283
315
  onWidthCommit={lp.handlePanelWidthCommit}
284
316
  maximized={lp.panelMaximized}
285
317
  onMaximize={lp.handlePanelMaximize}
318
+ onImport={handleOpenImport}
286
319
  >
287
320
  <div className={`flex flex-col h-full ${lp.activePanel === 'echo' ? '' : 'hidden'}`}>
288
321
  <EchoPanel active={lp.activePanel === 'echo'} maximized={lp.panelMaximized} onMaximize={lp.handlePanelMaximize} />
@@ -290,9 +323,6 @@ export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps
290
323
  <div className={`flex flex-col h-full ${lp.activePanel === 'search' ? '' : 'hidden'}`}>
291
324
  <SearchPanel active={lp.activePanel === 'search'} maximized={lp.panelMaximized} onMaximize={lp.handlePanelMaximize} />
292
325
  </div>
293
- <div className={`flex flex-col h-full ${lp.activePanel === 'plugins' ? '' : 'hidden'}`}>
294
- <PluginsPanel active={lp.activePanel === 'plugins'} maximized={lp.panelMaximized} onMaximize={lp.handlePanelMaximize} />
295
- </div>
296
326
  <div className={`flex flex-col h-full ${lp.activePanel === 'agents' ? '' : 'hidden'}`}>
297
327
  <AgentsPanel
298
328
  active={lp.activePanel === 'agents'}
@@ -389,20 +419,65 @@ export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps
389
419
  </button>
390
420
  </div>
391
421
  <div className="flex-1 overflow-y-auto min-h-0 px-2 py-2">
392
- <FileTree nodes={fileTree} onNavigate={() => setMobileOpen(false)} />
422
+ <FileTree nodes={fileTree} onNavigate={() => setMobileOpen(false)} onImport={handleOpenImport} />
393
423
  </div>
394
424
  </aside>
395
425
 
396
426
  <SearchModal open={mobileSearchOpen} onClose={() => setMobileSearchOpen(false)} />
397
427
  <AskModal open={mobileAskOpen} onClose={() => setMobileAskOpen(false)} currentFile={currentFile} />
398
428
 
399
- <main id="main-content" className="min-h-screen transition-all duration-200 pt-[52px] md:pt-0">
429
+ <main
430
+ id="main-content"
431
+ className="min-h-screen transition-all duration-200 pt-[52px] md:pt-0"
432
+ onDragEnter={(e) => {
433
+ if (!e.dataTransfer.types.includes('Files')) return;
434
+ e.preventDefault();
435
+ dragCounterRef.current++;
436
+ if (dragCounterRef.current === 1) setDragOverlay(true);
437
+ }}
438
+ onDragOver={(e) => {
439
+ if (!e.dataTransfer.types.includes('Files')) return;
440
+ e.preventDefault();
441
+ }}
442
+ onDragLeave={() => {
443
+ dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
444
+ if (dragCounterRef.current === 0) setDragOverlay(false);
445
+ }}
446
+ onDrop={(e) => {
447
+ e.preventDefault();
448
+ dragCounterRef.current = 0;
449
+ setDragOverlay(false);
450
+ if (e.dataTransfer.files.length > 0) {
451
+ setImportInitialFiles(Array.from(e.dataTransfer.files));
452
+ setImportDefaultSpace(undefined);
453
+ setImportModalOpen(true);
454
+ }
455
+ }}
456
+ >
400
457
  <div className="min-h-screen bg-background">
401
458
  <ChangesBanner />
402
459
  {children}
403
460
  </div>
461
+
462
+ {/* Global drag overlay */}
463
+ {dragOverlay && !importModalOpen && (
464
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm transition-opacity duration-200">
465
+ <div className="border-2 border-dashed border-[var(--amber)]/50 rounded-xl p-12 flex flex-col items-center gap-3">
466
+ <FolderInput size={48} className="text-[var(--amber)]/60" />
467
+ <p className="text-sm text-foreground font-medium">{t.fileImport.dropOverlay}</p>
468
+ <p className="text-xs text-muted-foreground">{t.fileImport.dropOverlayFormats}</p>
469
+ </div>
470
+ </div>
471
+ )}
404
472
  </main>
405
473
 
474
+ <ImportModal
475
+ open={importModalOpen}
476
+ onClose={handleCloseImport}
477
+ defaultSpace={importDefaultSpace}
478
+ initialFiles={importInitialFiles}
479
+ />
480
+
406
481
  <style>{`
407
482
  @media (min-width: 768px) {
408
483
  :root {
@@ -128,6 +128,7 @@ export default function TableOfContents({ content }: TableOfContentsProps) {
128
128
  href={`#${heading.id}`}
129
129
  onClick={(e) => handleClick(e, heading.id)}
130
130
  className="block text-xs py-1 rounded transition-colors duration-100 leading-snug"
131
+ suppressHydrationWarning
131
132
  style={{
132
133
  paddingLeft: `${8 + indent}px`,
133
134
  paddingRight: '8px',