@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.
- package/app/app/api/file/import/route.ts +197 -0
- package/app/components/ActivityBar.tsx +3 -4
- package/app/components/FileTree.tsx +35 -9
- package/app/components/ImportModal.tsx +415 -0
- package/app/components/OnboardingView.tsx +9 -0
- package/app/components/Panel.tsx +4 -2
- package/app/components/SidebarLayout.tsx +83 -8
- package/app/components/TableOfContents.tsx +1 -0
- package/app/components/agents/AgentDetailContent.tsx +37 -28
- package/app/components/agents/AgentsMcpSection.tsx +16 -12
- package/app/components/agents/AgentsOverviewSection.tsx +48 -34
- package/app/components/agents/AgentsPrimitives.tsx +41 -20
- package/app/components/agents/AgentsSkillsSection.tsx +16 -7
- package/app/components/agents/SkillDetailPopover.tsx +11 -11
- package/app/components/ask/AskContent.tsx +11 -0
- package/app/components/panels/AgentsPanelAgentGroups.tsx +8 -6
- package/app/components/panels/AgentsPanelHubNav.tsx +3 -3
- package/app/components/panels/DiscoverPanel.tsx +88 -2
- package/app/hooks/useFileImport.ts +191 -0
- package/app/hooks/useFileUpload.ts +11 -0
- package/app/lib/agent/tools.ts +146 -0
- package/app/lib/core/file-convert.ts +97 -0
- package/app/lib/core/organize.ts +105 -0
- package/app/lib/i18n-en.ts +51 -0
- package/app/lib/i18n-zh.ts +51 -0
- package/package.json +1 -1
|
@@ -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">
|
package/app/components/Panel.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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',
|