@geminilight/mindos 0.5.68 → 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.
Files changed (33) hide show
  1. package/app/app/api/ask/route.ts +12 -4
  2. package/app/app/api/file/import/route.ts +197 -0
  3. package/app/app/api/mcp/install/route.ts +99 -12
  4. package/app/app/api/mcp/status/route.ts +1 -1
  5. package/app/components/ActivityBar.tsx +3 -4
  6. package/app/components/FileTree.tsx +35 -9
  7. package/app/components/ImportModal.tsx +415 -0
  8. package/app/components/OnboardingView.tsx +9 -0
  9. package/app/components/Panel.tsx +4 -2
  10. package/app/components/SidebarLayout.tsx +83 -8
  11. package/app/components/TableOfContents.tsx +1 -0
  12. package/app/components/agents/AgentDetailContent.tsx +37 -28
  13. package/app/components/agents/AgentsMcpSection.tsx +16 -12
  14. package/app/components/agents/AgentsOverviewSection.tsx +48 -34
  15. package/app/components/agents/AgentsPrimitives.tsx +41 -20
  16. package/app/components/agents/AgentsSkillsSection.tsx +16 -7
  17. package/app/components/agents/SkillDetailPopover.tsx +13 -11
  18. package/app/components/ask/AskContent.tsx +11 -0
  19. package/app/components/panels/AgentsPanelAgentGroups.tsx +8 -6
  20. package/app/components/panels/AgentsPanelHubNav.tsx +3 -3
  21. package/app/components/panels/DiscoverPanel.tsx +88 -2
  22. package/app/hooks/useFileImport.ts +191 -0
  23. package/app/hooks/useFileUpload.ts +11 -0
  24. package/app/lib/agent/context.ts +7 -2
  25. package/app/lib/agent/tools.ts +245 -6
  26. package/app/lib/core/backlinks.ts +12 -4
  27. package/app/lib/core/file-convert.ts +97 -0
  28. package/app/lib/core/organize.ts +105 -0
  29. package/app/lib/core/search.ts +17 -3
  30. package/app/lib/fs.ts +5 -3
  31. package/app/lib/i18n-en.ts +51 -0
  32. package/app/lib/i18n-zh.ts +51 -0
  33. package/package.json +1 -1
@@ -357,21 +357,29 @@ export async function POST(req: NextRequest) {
357
357
  contextStrategy,
358
358
  ),
359
359
 
360
- // Write-protection: block writes to protected files
360
+ // Write-protection: block writes to protected files gracefully
361
361
  beforeToolCall: async (context: BeforeToolCallContext): Promise<BeforeToolCallResult | undefined> => {
362
362
  const { toolCall, args } = context;
363
363
  // toolCall is an object with type "toolCall" and contains the tool name and ID
364
364
  const toolName = (toolCall as any).toolName ?? (toolCall as any).name;
365
365
  if (toolName && WRITE_TOOLS.has(toolName)) {
366
- const filePath = (args as any).path ?? (args as any).from_path;
367
- if (filePath) {
366
+ // Special handling for batch creations where we need to check multiple files
367
+ const pathsToCheck: string[] = [];
368
+ if (toolName === 'batch_create_files' && Array.isArray((args as any).files)) {
369
+ (args as any).files.forEach((f: any) => { if (f.path) pathsToCheck.push(f.path); });
370
+ } else {
371
+ const singlePath = (args as any).path ?? (args as any).from_path;
372
+ if (singlePath) pathsToCheck.push(singlePath);
373
+ }
374
+
375
+ for (const filePath of pathsToCheck) {
368
376
  try {
369
377
  assertNotProtected(filePath, 'modified by AI agent');
370
378
  } catch (e) {
371
379
  const errorMsg = e instanceof Error ? e.message : String(e);
372
380
  return {
373
381
  block: true,
374
- reason: `Write-protection error: ${errorMsg}`,
382
+ reason: `Write-protection error: ${errorMsg}. You CANNOT modify ${filePath} because it is system-protected. Please tell the user you don't have permission to do this.`,
375
383
  };
376
384
  }
377
385
  }
@@ -0,0 +1,197 @@
1
+ export const dynamic = 'force-dynamic';
2
+ export const runtime = 'nodejs';
3
+
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import { NextRequest, NextResponse } from 'next/server';
7
+ import { revalidatePath } from 'next/cache';
8
+ import { sanitizeFileName, convertToMarkdown } from '@/lib/core/file-convert';
9
+ import { resolveSafe } from '@/lib/core/security';
10
+ import { scaffoldIfNewSpace } from '@/lib/core/space-scaffold';
11
+ import { organizeAfterImport } from '@/lib/core/organize';
12
+ import { invalidateSearchIndex } from '@/lib/core/search';
13
+ import { effectiveSopRoot } from '@/lib/settings';
14
+ import { invalidateCache } from '@/lib/fs';
15
+
16
+ const MAX_FILES = 20;
17
+ const MAX_CONTENT_LENGTH = 5 * 1024 * 1024;
18
+
19
+ type ConflictMode = 'skip' | 'rename' | 'overwrite';
20
+
21
+ interface ImportRequest {
22
+ files: Array<{
23
+ name: string;
24
+ content: string;
25
+ encoding?: 'text' | 'base64';
26
+ }>;
27
+ targetSpace?: string;
28
+ organize?: boolean;
29
+ conflict?: ConflictMode;
30
+ }
31
+
32
+ function normalizeTargetSpace(raw: unknown): string {
33
+ if (raw === undefined || raw === null) return '';
34
+ if (typeof raw !== 'string') return '';
35
+ return raw.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '').trim();
36
+ }
37
+
38
+ function decodeFileContent(
39
+ encoding: 'text' | 'base64' | undefined,
40
+ content: string,
41
+ sanitizedName: string,
42
+ ): string {
43
+ if (encoding === 'base64') {
44
+ const buf = Buffer.from(content, 'base64');
45
+ if (sanitizedName.toLowerCase().endsWith('.pdf')) {
46
+ return buf.toString('latin1');
47
+ }
48
+ return buf.toString('utf-8');
49
+ }
50
+ return content;
51
+ }
52
+
53
+ function resolveUniquePath(
54
+ mindRoot: string,
55
+ relPath: string,
56
+ conflict: ConflictMode,
57
+ ): { relPath: string; resolved: string; skipped?: string } {
58
+ let rel = relPath.replace(/\\/g, '/');
59
+ let resolved = resolveSafe(mindRoot, rel);
60
+ if (!fs.existsSync(resolved)) {
61
+ return { relPath: rel, resolved };
62
+ }
63
+ if (conflict === 'skip') {
64
+ return { relPath: rel, resolved, skipped: 'file exists' };
65
+ }
66
+ if (conflict === 'overwrite') {
67
+ return { relPath: rel, resolved };
68
+ }
69
+ let n = 0;
70
+ while (fs.existsSync(resolved)) {
71
+ n += 1;
72
+ const dir = path.posix.dirname(rel);
73
+ const base = path.posix.basename(rel);
74
+ const ext = path.posix.extname(base);
75
+ const stem = ext ? base.slice(0, -ext.length) : base;
76
+ const newBase = `${stem}-${n}${ext}`;
77
+ rel = dir && dir !== '.' ? path.posix.join(dir, newBase) : newBase;
78
+ resolved = resolveSafe(mindRoot, rel);
79
+ }
80
+ return { relPath: rel, resolved };
81
+ }
82
+
83
+ export async function POST(req: NextRequest) {
84
+ let body: unknown;
85
+ try {
86
+ body = await req.json();
87
+ } catch {
88
+ return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
89
+ }
90
+
91
+ const mindRoot = effectiveSopRoot().trim();
92
+ if (!mindRoot) {
93
+ return NextResponse.json({ error: 'MIND_ROOT is not configured' }, { status: 400 });
94
+ }
95
+
96
+ if (!body || typeof body !== 'object') {
97
+ return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
98
+ }
99
+
100
+ const reqBody = body as ImportRequest;
101
+ if (!Array.isArray(reqBody.files)) {
102
+ return NextResponse.json({ error: 'files must be an array' }, { status: 400 });
103
+ }
104
+
105
+ if (reqBody.files.length > MAX_FILES) {
106
+ return NextResponse.json({ error: `At most ${MAX_FILES} files per request` }, { status: 400 });
107
+ }
108
+
109
+ const targetSpaceNorm = normalizeTargetSpace(reqBody.targetSpace);
110
+ const organize = reqBody.organize !== false;
111
+ const conflict: ConflictMode =
112
+ reqBody.conflict === 'skip' || reqBody.conflict === 'overwrite' || reqBody.conflict === 'rename'
113
+ ? reqBody.conflict
114
+ : 'rename';
115
+
116
+ const created: Array<{ original: string; path: string }> = [];
117
+ const skipped: Array<{ name: string; reason: string }> = [];
118
+ const errors: Array<{ name: string; error: string }> = [];
119
+ const createdPaths: string[] = [];
120
+ const updatedFiles: string[] = [];
121
+
122
+ for (const entry of reqBody.files) {
123
+ const originalName = typeof entry?.name === 'string' ? entry.name : '';
124
+ try {
125
+ if (typeof entry?.name !== 'string' || typeof entry?.content !== 'string') {
126
+ errors.push({ name: originalName || '(unknown)', error: 'name and content must be strings' });
127
+ continue;
128
+ }
129
+ if (!entry.name.trim()) {
130
+ errors.push({ name: '(empty)', error: 'name must not be empty' });
131
+ continue;
132
+ }
133
+ if (entry.content.length > MAX_CONTENT_LENGTH) {
134
+ errors.push({ name: entry.name, error: `content exceeds ${MAX_CONTENT_LENGTH} characters` });
135
+ continue;
136
+ }
137
+
138
+ const sanitized = sanitizeFileName(entry.name);
139
+ const encoding = entry.encoding === 'base64' ? 'base64' : 'text';
140
+ const raw = decodeFileContent(encoding, entry.content, sanitized);
141
+ const convertResult = convertToMarkdown(sanitized, raw);
142
+
143
+ let relPath = targetSpaceNorm
144
+ ? path.posix.join(targetSpaceNorm, convertResult.targetName)
145
+ : convertResult.targetName;
146
+
147
+ const { relPath: finalRel, resolved, skipped: skipReason } = resolveUniquePath(
148
+ mindRoot,
149
+ relPath,
150
+ conflict,
151
+ );
152
+
153
+ if (skipReason) {
154
+ skipped.push({ name: entry.name, reason: skipReason });
155
+ continue;
156
+ }
157
+
158
+ fs.mkdirSync(path.dirname(resolved), { recursive: true });
159
+ fs.writeFileSync(resolved, convertResult.content, 'utf-8');
160
+ scaffoldIfNewSpace(mindRoot, finalRel);
161
+
162
+ created.push({ original: entry.name, path: finalRel });
163
+ createdPaths.push(finalRel);
164
+ } catch (e) {
165
+ errors.push({ name: originalName || '(unknown)', error: (e as Error).message });
166
+ }
167
+ }
168
+
169
+ if (organize && createdPaths.length > 0) {
170
+ try {
171
+ const { readmeUpdated } = organizeAfterImport(mindRoot, createdPaths, targetSpaceNorm);
172
+ if (readmeUpdated && targetSpaceNorm) {
173
+ updatedFiles.push(path.posix.join(targetSpaceNorm, 'README.md'));
174
+ }
175
+ } catch {
176
+ /* organize is best-effort */
177
+ }
178
+ }
179
+
180
+ if (created.length > 0 || updatedFiles.length > 0) {
181
+ invalidateSearchIndex();
182
+ invalidateCache();
183
+ }
184
+
185
+ try {
186
+ revalidatePath('/');
187
+ } catch {
188
+ /* noop in test env */
189
+ }
190
+
191
+ return NextResponse.json({
192
+ created,
193
+ skipped,
194
+ errors,
195
+ updatedFiles,
196
+ });
197
+ }
@@ -11,6 +11,82 @@ function parseJsonc(text: string): Record<string, unknown> {
11
11
  return JSON.parse(stripped);
12
12
  }
13
13
 
14
+ /** Ensure nested object path exists and return the leaf container */
15
+ function ensureNestedPath(obj: Record<string, unknown>, dotPath: string): Record<string, unknown> {
16
+ const parts = dotPath.split('.').filter(Boolean);
17
+ let current = obj;
18
+ for (const part of parts) {
19
+ if (!current[part] || typeof current[part] !== 'object') {
20
+ current[part] = {};
21
+ }
22
+ current = current[part] as Record<string, unknown>;
23
+ }
24
+ return current;
25
+ }
26
+
27
+ /** Generate a TOML section string for an MCP entry */
28
+ function buildTomlEntry(sectionKey: string, serverName: string, entry: Record<string, unknown>): string {
29
+ const lines: string[] = [];
30
+ lines.push(`[${sectionKey}.${serverName}]`);
31
+ if (entry.type) lines.push(`type = "${entry.type}"`);
32
+ if (entry.command) lines.push(`command = "${entry.command}"`);
33
+ if (entry.url) lines.push(`url = "${entry.url}"`);
34
+ if (Array.isArray(entry.args)) {
35
+ lines.push(`args = [${entry.args.map(a => `"${a}"`).join(', ')}]`);
36
+ }
37
+ if (entry.env && typeof entry.env === 'object') {
38
+ lines.push('');
39
+ lines.push(`[${sectionKey}.${serverName}.env]`);
40
+ for (const [k, v] of Object.entries(entry.env)) {
41
+ lines.push(`${k} = "${v}"`);
42
+ }
43
+ }
44
+ if (entry.headers && typeof entry.headers === 'object') {
45
+ lines.push('');
46
+ lines.push(`[${sectionKey}.${serverName}.headers]`);
47
+ for (const [k, v] of Object.entries(entry.headers)) {
48
+ lines.push(`${k} = "${v}"`);
49
+ }
50
+ }
51
+ return lines.join('\n');
52
+ }
53
+
54
+ /** Replace or append a [section.server] block in a TOML file */
55
+ function mergeTomlEntry(existing: string, sectionKey: string, serverName: string, entry: Record<string, unknown>): string {
56
+ const sectionHeader = `[${sectionKey}.${serverName}]`;
57
+ const envHeader = `[${sectionKey}.${serverName}.env]`;
58
+ const headersHeader = `[${sectionKey}.${serverName}.headers]`;
59
+ const newBlock = buildTomlEntry(sectionKey, serverName, entry);
60
+
61
+ const lines = existing.split('\n');
62
+ const result: string[] = [];
63
+ let skipping = false;
64
+
65
+ for (const line of lines) {
66
+ const trimmed = line.trim();
67
+ if (trimmed === sectionHeader || trimmed === envHeader || trimmed === headersHeader) {
68
+ skipping = true;
69
+ continue;
70
+ }
71
+ if (skipping && trimmed.startsWith('[')) {
72
+ skipping = false;
73
+ }
74
+ if (!skipping) {
75
+ result.push(line);
76
+ }
77
+ }
78
+
79
+ // Remove trailing blank lines before appending
80
+ while (result.length > 0 && result[result.length - 1].trim() === '') {
81
+ result.pop();
82
+ }
83
+ result.push('');
84
+ result.push(newBlock);
85
+ result.push('');
86
+
87
+ return result.join('\n');
88
+ }
89
+
14
90
  interface AgentInstallItem {
15
91
  key: string;
16
92
  scope: 'project' | 'global';
@@ -96,20 +172,31 @@ export async function POST(req: NextRequest) {
96
172
  const absPath = expandHome(configPath);
97
173
 
98
174
  try {
99
- // Read existing config
100
- let config: Record<string, unknown> = {};
101
- if (fs.existsSync(absPath)) {
102
- config = parseJsonc(fs.readFileSync(absPath, 'utf-8'));
103
- }
104
-
105
- // Merge — only touch mcpServers.mindos
106
- if (!config[agent.key]) config[agent.key] = {};
107
- (config[agent.key] as Record<string, unknown>).mindos = entry;
108
-
109
- // Write
110
175
  const dir = path.dirname(absPath);
111
176
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
112
- fs.writeFileSync(absPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
177
+
178
+ if (agent.format === 'toml') {
179
+ // TOML format (e.g. Codex): merge into existing TOML or generate new
180
+ const existing = fs.existsSync(absPath) ? fs.readFileSync(absPath, 'utf-8') : '';
181
+ const merged = mergeTomlEntry(existing, agent.key, 'mindos', entry as Record<string, unknown>);
182
+ fs.writeFileSync(absPath, merged, 'utf-8');
183
+ } else {
184
+ // JSON format (default)
185
+ let config: Record<string, unknown> = {};
186
+ if (fs.existsSync(absPath)) {
187
+ config = parseJsonc(fs.readFileSync(absPath, 'utf-8'));
188
+ }
189
+
190
+ // For global scope with nested key (e.g. VS Code: mcp.servers),
191
+ // write to the nested path instead of the flat key
192
+ const useNestedKey = isGlobal && agent.globalNestedKey;
193
+ const container = useNestedKey
194
+ ? ensureNestedPath(config, agent.globalNestedKey!)
195
+ : (() => { if (!config[agent.key]) config[agent.key] = {}; return config[agent.key] as Record<string, unknown>; })();
196
+ container.mindos = entry;
197
+
198
+ fs.writeFileSync(absPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
199
+ }
113
200
 
114
201
  const result: typeof results[number] = { agent: key, status: 'ok', path: configPath, transport: effectiveTransport };
115
202
 
@@ -50,7 +50,7 @@ export async function GET(req: NextRequest) {
50
50
  transport: 'http',
51
51
  endpoint,
52
52
  port,
53
- toolCount: running ? 20 : 0,
53
+ toolCount: running ? 23 : 0,
54
54
  authConfigured,
55
55
  // Masked for display; full token only used server-side in snippet generation
56
56
  maskedToken: authConfigured ? maskToken(token) : undefined,
@@ -2,13 +2,13 @@
2
2
 
3
3
  import { useRef, useCallback, useState, useEffect } from 'react';
4
4
  import Link from 'next/link';
5
- import { FolderTree, Search, Settings, RefreshCw, Blocks, Bot, Compass, HelpCircle, ChevronLeft, ChevronRight, Radio } from 'lucide-react';
5
+ import { FolderTree, Search, Settings, RefreshCw, Bot, Compass, HelpCircle, ChevronLeft, ChevronRight, Radio } from 'lucide-react';
6
6
  import { useLocale } from '@/lib/LocaleContext';
7
7
  import { DOT_COLORS, getStatusLevel } from './SyncStatusBar';
8
8
  import type { SyncStatus } from './settings/SyncTab';
9
9
  import Logo from './Logo';
10
10
 
11
- export type PanelId = 'files' | 'search' | 'echo' | 'plugins' | 'agents' | 'discover';
11
+ export type PanelId = 'files' | 'search' | 'echo' | 'agents' | 'discover';
12
12
 
13
13
  export const RAIL_WIDTH_COLLAPSED = 48;
14
14
  export const RAIL_WIDTH_EXPANDED = 180;
@@ -181,9 +181,8 @@ export default function ActivityBar({
181
181
  {/* ── Middle: Core panel toggles ── */}
182
182
  <div className={`flex flex-col ${expanded ? 'px-1.5' : 'items-center'} gap-1 py-2`}>
183
183
  <RailButton icon={<FolderTree size={18} />} label={t.sidebar.files} active={activePanel === 'files'} expanded={expanded} onClick={() => toggle('files')} walkthroughId="files-panel" />
184
- <RailButton icon={<Radio size={18} />} label={t.sidebar.echo} active={activePanel === 'echo'} expanded={expanded} onClick={() => toggle('echo')} />
185
184
  <RailButton icon={<Search size={18} />} label={t.sidebar.searchTitle} shortcut="⌘K" active={activePanel === 'search'} expanded={expanded} onClick={() => toggle('search')} walkthroughId="search-button" />
186
- <RailButton icon={<Blocks size={18} />} label={t.sidebar.plugins} active={activePanel === 'plugins'} expanded={expanded} onClick={() => toggle('plugins')} />
185
+ <RailButton icon={<Radio size={18} />} label={t.sidebar.echo} active={activePanel === 'echo'} expanded={expanded} onClick={() => toggle('echo')} />
187
186
  <RailButton
188
187
  icon={<Bot size={18} />}
189
188
  label={t.sidebar.agents}
@@ -6,7 +6,7 @@ import { FileNode } from '@/lib/types';
6
6
  import { encodePath } from '@/lib/utils';
7
7
  import {
8
8
  ChevronDown, FileText, Table, Folder, FolderOpen, Plus, Loader2,
9
- Trash2, Pencil, Layers, ScrollText,
9
+ Trash2, Pencil, Layers, ScrollText, FolderInput,
10
10
  } from 'lucide-react';
11
11
  import { createFileAction, deleteFileAction, renameFileAction, renameSpaceAction, deleteSpaceAction, convertToSpaceAction, deleteFolderAction } from '@/lib/actions';
12
12
  import { useLocale } from '@/lib/LocaleContext';
@@ -23,6 +23,7 @@ interface FileTreeProps {
23
23
  onNavigate?: () => void;
24
24
  maxOpenDepth?: number | null;
25
25
  parentIsSpace?: boolean;
26
+ onImport?: (space: string) => void;
26
27
  }
27
28
 
28
29
  function getIcon(node: FileNode) {
@@ -96,8 +97,8 @@ const MENU_DIVIDER = "my-1 border-t border-border/50";
96
97
 
97
98
  // ─── SpaceContextMenu ─────────────────────────────────────────────────────────
98
99
 
99
- function SpaceContextMenu({ x, y, node, onClose, onRename }: {
100
- x: number; y: number; node: FileNode; onClose: () => void; onRename: () => void;
100
+ function SpaceContextMenu({ x, y, node, onClose, onRename, onImport }: {
101
+ x: number; y: number; node: FileNode; onClose: () => void; onRename: () => void; onImport?: (space: string) => void;
101
102
  }) {
102
103
  const router = useRouter();
103
104
  const { t } = useLocale();
@@ -108,6 +109,11 @@ function SpaceContextMenu({ x, y, node, onClose, onRename }: {
108
109
  <button className={MENU_ITEM} onClick={() => { router.push(`/view/${encodePath(`${node.path}/INSTRUCTION.md`)}`); onClose(); }}>
109
110
  <ScrollText size={14} className="shrink-0" /> {t.fileTree.editRules}
110
111
  </button>
112
+ {onImport && (
113
+ <button className={MENU_ITEM} onClick={() => { onImport(node.path); onClose(); }}>
114
+ <FolderInput size={14} className="shrink-0" /> {t.fileTree.importFile}
115
+ </button>
116
+ )}
111
117
  <button className={MENU_ITEM} onClick={() => { onRename(); onClose(); }}>
112
118
  <Pencil size={14} className="shrink-0" /> {t.fileTree.renameSpace}
113
119
  </button>
@@ -240,9 +246,9 @@ function NewFileInline({ dirPath, depth, onDone }: { dirPath: string; depth: num
240
246
 
241
247
  // ─── DirectoryNode ────────────────────────────────────────────────────────────
242
248
 
243
- function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth }: {
249
+ function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth, onImport }: {
244
250
  node: FileNode; depth: number; currentPath: string; onNavigate?: () => void;
245
- maxOpenDepth?: number | null;
251
+ maxOpenDepth?: number | null; onImport?: (space: string) => void;
246
252
  }) {
247
253
  const router = useRouter();
248
254
  const isActive = currentPath.startsWith(node.path + '/') || currentPath === node.path;
@@ -255,6 +261,8 @@ function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth }: {
255
261
  const renameRef = useRef<HTMLInputElement>(null);
256
262
  const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
257
263
  const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
264
+ const [plusPopover, setPlusPopover] = useState(false);
265
+ const plusRef = useRef<HTMLButtonElement>(null);
258
266
  const { t } = useLocale();
259
267
 
260
268
  const toggle = useCallback(() => setOpen(v => !v), []);
@@ -391,12 +399,12 @@ function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth }: {
391
399
  </button>
392
400
  <div className="absolute right-1 top-1/2 -translate-y-1/2 hidden group-hover/dir:flex items-center gap-0.5 z-10">
393
401
  <button
402
+ ref={plusRef}
394
403
  type="button"
395
404
  onClick={(e) => {
396
405
  e.preventDefault();
397
406
  e.stopPropagation();
398
- setOpen(true);
399
- setShowNewFile(true);
407
+ setPlusPopover(v => !v);
400
408
  }}
401
409
  className="p-0.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
402
410
  title={t.fileTree.newFileTitle}
@@ -444,6 +452,7 @@ function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth }: {
444
452
  onNavigate={onNavigate}
445
453
  maxOpenDepth={maxOpenDepth}
446
454
  parentIsSpace={isSpace}
455
+ onImport={onImport}
447
456
  />
448
457
  )}
449
458
  {showNewFile && (
@@ -462,6 +471,7 @@ function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth }: {
462
471
  node={node}
463
472
  onClose={() => setContextMenu(null)}
464
473
  onRename={() => startRename()}
474
+ onImport={onImport}
465
475
  />
466
476
  ) : (
467
477
  <FolderContextMenu
@@ -472,6 +482,22 @@ function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth }: {
472
482
  onRename={() => startRename()}
473
483
  />
474
484
  ))}
485
+
486
+ {plusPopover && plusRef.current && (() => {
487
+ const rect = plusRef.current!.getBoundingClientRect();
488
+ return (
489
+ <ContextMenuShell x={rect.left} y={rect.bottom + 4} onClose={() => setPlusPopover(false)} menuHeight={80}>
490
+ <button className={MENU_ITEM} onClick={() => { setPlusPopover(false); setOpen(true); setShowNewFile(true); }}>
491
+ <FileText size={14} className="shrink-0" /> {t.fileTree.newFile}
492
+ </button>
493
+ {onImport && (
494
+ <button className={MENU_ITEM} onClick={() => { setPlusPopover(false); onImport(node.path); }}>
495
+ <FolderInput size={14} className="shrink-0" /> {t.fileTree.importFile}
496
+ </button>
497
+ )}
498
+ </ContextMenuShell>
499
+ );
500
+ })()}
475
501
  </div>
476
502
  );
477
503
  }
@@ -589,7 +615,7 @@ function FileNodeItem({ node, depth, currentPath, onNavigate }: {
589
615
 
590
616
  // ─── FileTree (root) ──────────────────────────────────────────────────────────
591
617
 
592
- export default function FileTree({ nodes, depth = 0, onNavigate, maxOpenDepth, parentIsSpace }: FileTreeProps) {
618
+ export default function FileTree({ nodes, depth = 0, onNavigate, maxOpenDepth, parentIsSpace, onImport }: FileTreeProps) {
593
619
  const pathname = usePathname();
594
620
  const currentPath = getCurrentFilePath(pathname);
595
621
 
@@ -609,7 +635,7 @@ export default function FileTree({ nodes, depth = 0, onNavigate, maxOpenDepth, p
609
635
  <div className="flex flex-col gap-0.5">
610
636
  {visibleNodes.map((node) =>
611
637
  node.type === 'directory' ? (
612
- <DirectoryNode key={node.path} node={node} depth={depth} currentPath={currentPath} onNavigate={onNavigate} maxOpenDepth={maxOpenDepth} />
638
+ <DirectoryNode key={node.path} node={node} depth={depth} currentPath={currentPath} onNavigate={onNavigate} maxOpenDepth={maxOpenDepth} onImport={onImport} />
613
639
  ) : (
614
640
  <FileNodeItem key={node.path} node={node} depth={depth} currentPath={currentPath} onNavigate={onNavigate} />
615
641
  )