@geminilight/mindos 0.5.69 → 0.6.0

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 (58) hide show
  1. package/app/app/api/ask/route.ts +122 -92
  2. package/app/app/api/file/import/route.ts +197 -0
  3. package/app/app/api/mcp/agents/route.ts +53 -2
  4. package/app/app/api/mcp/status/route.ts +1 -1
  5. package/app/app/api/skills/route.ts +10 -114
  6. package/app/components/ActivityBar.tsx +5 -7
  7. package/app/components/CreateSpaceModal.tsx +31 -6
  8. package/app/components/FileTree.tsx +68 -11
  9. package/app/components/GuideCard.tsx +197 -131
  10. package/app/components/HomeContent.tsx +85 -18
  11. package/app/components/ImportModal.tsx +415 -0
  12. package/app/components/OnboardingView.tsx +9 -0
  13. package/app/components/Panel.tsx +4 -2
  14. package/app/components/SidebarLayout.tsx +96 -8
  15. package/app/components/SpaceInitToast.tsx +173 -0
  16. package/app/components/TableOfContents.tsx +1 -0
  17. package/app/components/agents/AgentDetailContent.tsx +69 -45
  18. package/app/components/agents/AgentsContentPage.tsx +2 -1
  19. package/app/components/agents/AgentsMcpSection.tsx +16 -12
  20. package/app/components/agents/AgentsOverviewSection.tsx +37 -36
  21. package/app/components/agents/AgentsPrimitives.tsx +41 -20
  22. package/app/components/agents/AgentsSkillsSection.tsx +16 -7
  23. package/app/components/agents/SkillDetailPopover.tsx +11 -11
  24. package/app/components/agents/agents-content-model.ts +16 -8
  25. package/app/components/ask/AskContent.tsx +148 -50
  26. package/app/components/ask/MentionPopover.tsx +16 -8
  27. package/app/components/ask/SlashCommandPopover.tsx +62 -0
  28. package/app/components/panels/AgentsPanelAgentGroups.tsx +8 -6
  29. package/app/components/panels/AgentsPanelHubNav.tsx +3 -3
  30. package/app/components/panels/DiscoverPanel.tsx +88 -2
  31. package/app/components/settings/KnowledgeTab.tsx +61 -0
  32. package/app/components/walkthrough/steps.ts +11 -6
  33. package/app/hooks/useFileImport.ts +191 -0
  34. package/app/hooks/useFileUpload.ts +11 -0
  35. package/app/hooks/useMention.ts +14 -6
  36. package/app/hooks/useSlashCommand.ts +114 -0
  37. package/app/lib/actions.ts +79 -2
  38. package/app/lib/agent/index.ts +1 -1
  39. package/app/lib/agent/prompt.ts +2 -0
  40. package/app/lib/agent/tools.ts +252 -0
  41. package/app/lib/core/create-space.ts +11 -4
  42. package/app/lib/core/file-convert.ts +97 -0
  43. package/app/lib/core/index.ts +1 -1
  44. package/app/lib/core/organize.ts +105 -0
  45. package/app/lib/i18n-en.ts +102 -46
  46. package/app/lib/i18n-zh.ts +101 -45
  47. package/app/lib/mcp-agents.ts +8 -0
  48. package/app/lib/pdf-extract.ts +33 -0
  49. package/app/lib/pi-integration/extensions.ts +45 -0
  50. package/app/lib/pi-integration/mcporter.ts +219 -0
  51. package/app/lib/pi-integration/session-store.ts +62 -0
  52. package/app/lib/pi-integration/skills.ts +116 -0
  53. package/app/lib/settings.ts +1 -1
  54. package/app/next-env.d.ts +1 -1
  55. package/app/next.config.ts +1 -1
  56. package/app/package.json +2 -0
  57. package/mcp/src/index.ts +29 -0
  58. package/package.json +1 -1
@@ -4,6 +4,7 @@ import fs from 'fs';
4
4
  import path from 'path';
5
5
  import os from 'os';
6
6
  import { readSettings, writeSettings } from '@/lib/settings';
7
+ import { parseSkillMd, readSkillContentByName, scanSkillDirs } from '@/lib/pi-integration/skills';
7
8
 
8
9
  const PROJECT_ROOT = process.env.MINDOS_PROJECT_ROOT || path.resolve(process.cwd(), '..');
9
10
 
@@ -12,113 +13,15 @@ function getMindRoot(): string {
12
13
  return s.mindRoot || process.env.MIND_ROOT || path.join(os.homedir(), 'MindOS', 'mind');
13
14
  }
14
15
 
15
- interface SkillInfo {
16
- name: string;
17
- description: string;
18
- path: string;
19
- source: 'builtin' | 'user';
20
- enabled: boolean;
21
- editable: boolean;
22
- }
23
-
24
- function parseSkillMd(content: string): { name: string; description: string } {
25
- const match = content.match(/^---\n([\s\S]*?)\n---/);
26
- if (!match) return { name: '', description: '' };
27
- const yaml = match[1];
28
- const nameMatch = yaml.match(/^name:\s*(.+)/m);
29
- const descMatch = yaml.match(/^description:\s*>?\s*\n?([\s\S]*?)(?=\n\w|\n---)/m);
30
- const name = nameMatch ? nameMatch[1].trim() : '';
31
- let description = '';
32
- if (descMatch) {
33
- description = descMatch[1].trim().split('\n').map(l => l.trim()).join(' ').slice(0, 200);
34
- } else {
35
- const simpleDesc = yaml.match(/^description:\s*(.+)/m);
36
- if (simpleDesc) description = simpleDesc[1].trim().slice(0, 200);
37
- }
38
- return { name, description };
39
- }
40
-
41
- function scanSkillDirs(disabledSkills: string[]): SkillInfo[] {
42
- const skills: SkillInfo[] = [];
43
- const seen = new Set<string>();
44
-
45
- // 1. app/data/skills/ — builtin
46
- const builtinDir = path.join(PROJECT_ROOT, 'app', 'data', 'skills');
47
- if (fs.existsSync(builtinDir)) {
48
- for (const entry of fs.readdirSync(builtinDir, { withFileTypes: true })) {
49
- if (!entry.isDirectory()) continue;
50
- const skillFile = path.join(builtinDir, entry.name, 'SKILL.md');
51
- if (!fs.existsSync(skillFile)) continue;
52
- const content = fs.readFileSync(skillFile, 'utf-8');
53
- const { name, description } = parseSkillMd(content);
54
- const skillName = name || entry.name;
55
- seen.add(skillName);
56
- skills.push({
57
- name: skillName,
58
- description,
59
- path: `app/data/skills/${entry.name}/SKILL.md`,
60
- source: 'builtin',
61
- enabled: !disabledSkills.includes(skillName),
62
- editable: false,
63
- });
64
- }
65
- }
66
-
67
- // 2. skills/ — project root builtin
68
- const skillsDir = path.join(PROJECT_ROOT, 'skills');
69
- if (fs.existsSync(skillsDir)) {
70
- for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
71
- if (!entry.isDirectory()) continue;
72
- const skillFile = path.join(skillsDir, entry.name, 'SKILL.md');
73
- if (!fs.existsSync(skillFile)) continue;
74
- const content = fs.readFileSync(skillFile, 'utf-8');
75
- const { name, description } = parseSkillMd(content);
76
- const skillName = name || entry.name;
77
- if (seen.has(skillName)) continue; // already listed from app/data/skills/
78
- seen.add(skillName);
79
- skills.push({
80
- name: skillName,
81
- description,
82
- path: `skills/${entry.name}/SKILL.md`,
83
- source: 'builtin',
84
- enabled: !disabledSkills.includes(skillName),
85
- editable: false,
86
- });
87
- }
88
- }
89
-
90
- // 3. {mindRoot}/.skills/ — user custom
91
- const mindRoot = getMindRoot();
92
- const userSkillsDir = path.join(mindRoot, '.skills');
93
- if (fs.existsSync(userSkillsDir)) {
94
- for (const entry of fs.readdirSync(userSkillsDir, { withFileTypes: true })) {
95
- if (!entry.isDirectory()) continue;
96
- const skillFile = path.join(userSkillsDir, entry.name, 'SKILL.md');
97
- if (!fs.existsSync(skillFile)) continue;
98
- const content = fs.readFileSync(skillFile, 'utf-8');
99
- const { name, description } = parseSkillMd(content);
100
- const skillName = name || entry.name;
101
- if (seen.has(skillName)) continue;
102
- seen.add(skillName);
103
- skills.push({
104
- name: skillName,
105
- description,
106
- path: `{mindRoot}/.skills/${entry.name}/SKILL.md`,
107
- source: 'user',
108
- enabled: !disabledSkills.includes(skillName),
109
- editable: true,
110
- });
111
- }
112
- }
113
-
114
- return skills;
115
- }
116
-
117
16
  export async function GET() {
118
17
  try {
119
18
  const settings = readSettings();
120
19
  const disabledSkills = settings.disabledSkills ?? [];
121
- const skills = scanSkillDirs(disabledSkills);
20
+ const skills = scanSkillDirs({
21
+ projectRoot: PROJECT_ROOT,
22
+ mindRoot: getMindRoot(),
23
+ disabledSkills,
24
+ });
122
25
  return NextResponse.json({ skills });
123
26
  } catch (err) {
124
27
  return NextResponse.json({ error: String(err) }, { status: 500 });
@@ -205,18 +108,11 @@ export async function POST(req: NextRequest) {
205
108
 
206
109
  case 'read': {
207
110
  if (!name) return NextResponse.json({ error: 'name required' }, { status: 400 });
208
- const dirs = [
209
- path.join(PROJECT_ROOT, 'app', 'data', 'skills', name),
210
- path.join(PROJECT_ROOT, 'skills', name),
211
- path.join(userSkillsDir, name),
212
- ];
213
- for (const dir of dirs) {
214
- const file = path.join(dir, 'SKILL.md');
215
- if (fs.existsSync(file)) {
216
- return NextResponse.json({ content: fs.readFileSync(file, 'utf-8') });
217
- }
111
+ const content = readSkillContentByName(name, { projectRoot: PROJECT_ROOT, mindRoot });
112
+ if (!content) {
113
+ return NextResponse.json({ error: 'Skill not found' }, { status: 404 });
218
114
  }
219
- return NextResponse.json({ error: 'Skill not found' }, { status: 404 });
115
+ return NextResponse.json({ content });
220
116
  }
221
117
 
222
118
  case 'read-native': {
@@ -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;
@@ -162,7 +162,6 @@ export default function ActivityBar({
162
162
  role="toolbar"
163
163
  aria-label="Navigation"
164
164
  aria-orientation="vertical"
165
- data-walkthrough="activity-bar"
166
165
  >
167
166
  {/* Content wrapper — overflow-hidden prevents text flash during width transitions */}
168
167
  <div className="flex flex-col h-full w-full overflow-hidden">
@@ -181,15 +180,15 @@ export default function ActivityBar({
181
180
  {/* ── Middle: Core panel toggles ── */}
182
181
  <div className={`flex flex-col ${expanded ? 'px-1.5' : 'items-center'} gap-1 py-2`}>
183
182
  <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
- <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')} />
183
+ <RailButton icon={<Search size={18} />} label={t.sidebar.searchTitle} shortcut="⌘K" active={activePanel === 'search'} expanded={expanded} onClick={() => toggle('search')} />
184
+ <RailButton icon={<Radio size={18} />} label={t.sidebar.echo} active={activePanel === 'echo'} expanded={expanded} onClick={() => toggle('echo')} walkthroughId="echo-panel" />
187
185
  <RailButton
188
186
  icon={<Bot size={18} />}
189
187
  label={t.sidebar.agents}
190
188
  active={activePanel === 'agents'}
191
189
  expanded={expanded}
192
190
  onClick={() => onAgentsClick ? debounced(onAgentsClick) : toggle('agents')}
191
+ walkthroughId="agents-panel"
193
192
  />
194
193
  <RailButton icon={<Compass size={18} />} label={t.sidebar.discover} active={activePanel === 'discover'} expanded={expanded} onClick={() => toggle('discover')} />
195
194
  </div>
@@ -212,7 +211,6 @@ export default function ActivityBar({
212
211
  shortcut="⌘,"
213
212
  expanded={expanded}
214
213
  onClick={() => debounced(onSettingsClick)}
215
- walkthroughId="settings-button"
216
214
  badge={hasUpdate ? (
217
215
  <span className={`absolute ${expanded ? 'left-[26px] top-1.5' : 'top-1.5 right-1.5'} w-2 h-2 rounded-full bg-error`} />
218
216
  ) : undefined}
@@ -93,17 +93,42 @@ export default function CreateSpaceModal({ t, dirPaths }: { t: ReturnType<typeof
93
93
  if (useAi && aiAvailable) {
94
94
  const isZh = document.documentElement.lang === 'zh';
95
95
  const prompt = isZh
96
- ? `初始化新建的心智空间「${trimmed}」,路径为「${createdPath}/」。${description ? `描述:「${description}」。` : ''}请根据空间名称生成有意义的内容:\n1. README.md — 空间用途、结构概览、使用指南\n2. INSTRUCTION.md AI Agent 在此空间的行为规则\n\n使用可用工具直接写入文件,内容简洁实用。`
97
- : `Initialize this new Mind Space "${trimmed}" at "${createdPath}/". ${description ? `Description: "${description}". ` : ''}Generate meaningful content for:\n1. README.md — purpose, structure overview, usage guidelines\n2. INSTRUCTION.md — rules for AI agents operating in this space\n\nWrite directly to the files using available tools. Keep content concise and actionable.`;
98
- // Fire and forget — don't block navigation
99
- apiFetch('/api/ask', {
96
+ ? `初始化新建的心智空间「${trimmed}」,路径为「${createdPath}/」。${description ? `描述:「${description}」。` : ''}两个文件均已存在模板,用 write_file 覆盖:\n1. 「${createdPath}/README.md」— 写入空间用途、结构概览、使用指南\n2. 「${createdPath}/INSTRUCTION.md」— 写入 AI Agent 在此空间中的行为规则和操作约定\n\n内容简洁实用,直接使用工具写入。`
97
+ : `Initialize the new Mind Space "${trimmed}" at "${createdPath}/". ${description ? `Description: "${description}". ` : ''}Both files already exist with templates — use write_file to overwrite:\n1. "${createdPath}/README.md"write purpose, structure overview, usage guidelines\n2. "${createdPath}/INSTRUCTION.md"write rules for AI agents operating in this space\n\nKeep content concise and actionable. Write files directly using tools.`;
98
+
99
+ window.dispatchEvent(new CustomEvent('mindos:ai-init', {
100
+ detail: { spaceName: trimmed, spacePath: createdPath, description, state: 'working' },
101
+ }));
102
+
103
+ // /api/ask returns SSE — use raw fetch and consume the stream
104
+ // so the server-side agent runs to completion.
105
+ fetch('/api/ask', {
100
106
  method: 'POST',
101
107
  headers: { 'Content-Type': 'application/json' },
102
108
  body: JSON.stringify({
103
109
  messages: [{ role: 'user', content: prompt }],
104
- targetDir: createdPath,
110
+ currentFile: createdPath + '/INSTRUCTION.md',
105
111
  }),
106
- }).catch(() => { /* AI init is best-effort */ });
112
+ }).then(async (res) => {
113
+ if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
114
+ const reader = res.body.getReader();
115
+ try {
116
+ while (true) {
117
+ const { done } = await reader.read();
118
+ if (done) break;
119
+ }
120
+ } finally {
121
+ reader.releaseLock();
122
+ }
123
+ window.dispatchEvent(new CustomEvent('mindos:ai-init', {
124
+ detail: { spacePath: createdPath, state: 'done' },
125
+ }));
126
+ window.dispatchEvent(new Event('mindos:files-changed'));
127
+ }).catch(() => {
128
+ window.dispatchEvent(new CustomEvent('mindos:ai-init', {
129
+ detail: { spacePath: createdPath, state: 'error' },
130
+ }));
131
+ });
107
132
  }
108
133
 
109
134
  close();
@@ -1,12 +1,12 @@
1
1
  'use client';
2
2
 
3
- import { useState, useCallback, useRef, useTransition, useEffect } from 'react';
3
+ import { useState, useCallback, useRef, useTransition, useEffect, useSyncExternalStore } from 'react';
4
4
  import { useRouter, usePathname } from 'next/navigation';
5
5
  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';
@@ -17,12 +17,40 @@ function notifyFilesChanged() {
17
17
 
18
18
  const SYSTEM_FILES = new Set(['INSTRUCTION.md', 'README.md']);
19
19
 
20
+ const HIDDEN_FILES_KEY = 'show-hidden-files';
21
+
22
+ function subscribeHiddenFiles(cb: () => void) {
23
+ const handler = (e: StorageEvent) => { if (e.key === HIDDEN_FILES_KEY) cb(); };
24
+ const custom = () => cb();
25
+ window.addEventListener('storage', handler);
26
+ window.addEventListener('mindos:hidden-files-changed', custom);
27
+ return () => {
28
+ window.removeEventListener('storage', handler);
29
+ window.removeEventListener('mindos:hidden-files-changed', custom);
30
+ };
31
+ }
32
+
33
+ function getShowHiddenFiles() {
34
+ if (typeof window === 'undefined') return false;
35
+ return localStorage.getItem(HIDDEN_FILES_KEY) === 'true';
36
+ }
37
+
38
+ export function setShowHiddenFiles(value: boolean) {
39
+ localStorage.setItem(HIDDEN_FILES_KEY, String(value));
40
+ window.dispatchEvent(new Event('mindos:hidden-files-changed'));
41
+ }
42
+
43
+ function useShowHiddenFiles() {
44
+ return useSyncExternalStore(subscribeHiddenFiles, getShowHiddenFiles, () => false);
45
+ }
46
+
20
47
  interface FileTreeProps {
21
48
  nodes: FileNode[];
22
49
  depth?: number;
23
50
  onNavigate?: () => void;
24
51
  maxOpenDepth?: number | null;
25
52
  parentIsSpace?: boolean;
53
+ onImport?: (space: string) => void;
26
54
  }
27
55
 
28
56
  function getIcon(node: FileNode) {
@@ -96,8 +124,8 @@ const MENU_DIVIDER = "my-1 border-t border-border/50";
96
124
 
97
125
  // ─── SpaceContextMenu ─────────────────────────────────────────────────────────
98
126
 
99
- function SpaceContextMenu({ x, y, node, onClose, onRename }: {
100
- x: number; y: number; node: FileNode; onClose: () => void; onRename: () => void;
127
+ function SpaceContextMenu({ x, y, node, onClose, onRename, onImport }: {
128
+ x: number; y: number; node: FileNode; onClose: () => void; onRename: () => void; onImport?: (space: string) => void;
101
129
  }) {
102
130
  const router = useRouter();
103
131
  const { t } = useLocale();
@@ -108,6 +136,11 @@ function SpaceContextMenu({ x, y, node, onClose, onRename }: {
108
136
  <button className={MENU_ITEM} onClick={() => { router.push(`/view/${encodePath(`${node.path}/INSTRUCTION.md`)}`); onClose(); }}>
109
137
  <ScrollText size={14} className="shrink-0" /> {t.fileTree.editRules}
110
138
  </button>
139
+ {onImport && (
140
+ <button className={MENU_ITEM} onClick={() => { onImport(node.path); onClose(); }}>
141
+ <FolderInput size={14} className="shrink-0" /> {t.fileTree.importFile}
142
+ </button>
143
+ )}
111
144
  <button className={MENU_ITEM} onClick={() => { onRename(); onClose(); }}>
112
145
  <Pencil size={14} className="shrink-0" /> {t.fileTree.renameSpace}
113
146
  </button>
@@ -240,9 +273,9 @@ function NewFileInline({ dirPath, depth, onDone }: { dirPath: string; depth: num
240
273
 
241
274
  // ─── DirectoryNode ────────────────────────────────────────────────────────────
242
275
 
243
- function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth }: {
276
+ function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth, onImport }: {
244
277
  node: FileNode; depth: number; currentPath: string; onNavigate?: () => void;
245
- maxOpenDepth?: number | null;
278
+ maxOpenDepth?: number | null; onImport?: (space: string) => void;
246
279
  }) {
247
280
  const router = useRouter();
248
281
  const isActive = currentPath.startsWith(node.path + '/') || currentPath === node.path;
@@ -255,6 +288,8 @@ function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth }: {
255
288
  const renameRef = useRef<HTMLInputElement>(null);
256
289
  const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
257
290
  const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
291
+ const [plusPopover, setPlusPopover] = useState(false);
292
+ const plusRef = useRef<HTMLButtonElement>(null);
258
293
  const { t } = useLocale();
259
294
 
260
295
  const toggle = useCallback(() => setOpen(v => !v), []);
@@ -391,12 +426,12 @@ function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth }: {
391
426
  </button>
392
427
  <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
428
  <button
429
+ ref={plusRef}
394
430
  type="button"
395
431
  onClick={(e) => {
396
432
  e.preventDefault();
397
433
  e.stopPropagation();
398
- setOpen(true);
399
- setShowNewFile(true);
434
+ setPlusPopover(v => !v);
400
435
  }}
401
436
  className="p-0.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
402
437
  title={t.fileTree.newFileTitle}
@@ -444,6 +479,7 @@ function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth }: {
444
479
  onNavigate={onNavigate}
445
480
  maxOpenDepth={maxOpenDepth}
446
481
  parentIsSpace={isSpace}
482
+ onImport={onImport}
447
483
  />
448
484
  )}
449
485
  {showNewFile && (
@@ -462,6 +498,7 @@ function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth }: {
462
498
  node={node}
463
499
  onClose={() => setContextMenu(null)}
464
500
  onRename={() => startRename()}
501
+ onImport={onImport}
465
502
  />
466
503
  ) : (
467
504
  <FolderContextMenu
@@ -472,6 +509,22 @@ function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth }: {
472
509
  onRename={() => startRename()}
473
510
  />
474
511
  ))}
512
+
513
+ {plusPopover && plusRef.current && (() => {
514
+ const rect = plusRef.current!.getBoundingClientRect();
515
+ return (
516
+ <ContextMenuShell x={rect.left} y={rect.bottom + 4} onClose={() => setPlusPopover(false)} menuHeight={80}>
517
+ <button className={MENU_ITEM} onClick={() => { setPlusPopover(false); setOpen(true); setShowNewFile(true); }}>
518
+ <FileText size={14} className="shrink-0" /> {t.fileTree.newFile}
519
+ </button>
520
+ {onImport && (
521
+ <button className={MENU_ITEM} onClick={() => { setPlusPopover(false); onImport(node.path); }}>
522
+ <FolderInput size={14} className="shrink-0" /> {t.fileTree.importFile}
523
+ </button>
524
+ )}
525
+ </ContextMenuShell>
526
+ );
527
+ })()}
475
528
  </div>
476
529
  );
477
530
  }
@@ -589,12 +642,16 @@ function FileNodeItem({ node, depth, currentPath, onNavigate }: {
589
642
 
590
643
  // ─── FileTree (root) ──────────────────────────────────────────────────────────
591
644
 
592
- export default function FileTree({ nodes, depth = 0, onNavigate, maxOpenDepth, parentIsSpace }: FileTreeProps) {
645
+ export default function FileTree({ nodes, depth = 0, onNavigate, maxOpenDepth, parentIsSpace, onImport }: FileTreeProps) {
593
646
  const pathname = usePathname();
594
647
  const currentPath = getCurrentFilePath(pathname);
648
+ const showHidden = useShowHiddenFiles();
595
649
 
596
650
  const isInsideDir = depth > 0;
597
- const visibleNodes = isInsideDir ? filterVisibleNodes(nodes, !!parentIsSpace) : nodes;
651
+ let visibleNodes = isInsideDir ? filterVisibleNodes(nodes, !!parentIsSpace) : nodes;
652
+ if (!isInsideDir && !showHidden) {
653
+ visibleNodes = visibleNodes.filter(n => !n.name.startsWith('.'));
654
+ }
598
655
 
599
656
  useEffect(() => {
600
657
  if (!currentPath || depth !== 0) return;
@@ -609,7 +666,7 @@ export default function FileTree({ nodes, depth = 0, onNavigate, maxOpenDepth, p
609
666
  <div className="flex flex-col gap-0.5">
610
667
  {visibleNodes.map((node) =>
611
668
  node.type === 'directory' ? (
612
- <DirectoryNode key={node.path} node={node} depth={depth} currentPath={currentPath} onNavigate={onNavigate} maxOpenDepth={maxOpenDepth} />
669
+ <DirectoryNode key={node.path} node={node} depth={depth} currentPath={currentPath} onNavigate={onNavigate} maxOpenDepth={maxOpenDepth} onImport={onImport} />
613
670
  ) : (
614
671
  <FileNodeItem key={node.path} node={node} depth={depth} currentPath={currentPath} onNavigate={onNavigate} />
615
672
  )