@geminilight/mindos 0.5.57 → 0.5.59

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 (96) hide show
  1. package/README.md +1 -1
  2. package/README_zh.md +1 -1
  3. package/app/app/api/ask/route.ts +4 -1
  4. package/app/app/api/extract-pdf/route.ts +4 -2
  5. package/app/app/api/file/route.ts +54 -2
  6. package/app/app/api/health/route.ts +3 -1
  7. package/app/app/api/mcp/install-skill/route.ts +4 -2
  8. package/app/app/api/mcp/restart/route.ts +1 -1
  9. package/app/app/api/restart/route.ts +1 -1
  10. package/app/app/api/skills/route.ts +1 -1
  11. package/app/app/api/sync/route.ts +2 -2
  12. package/app/app/api/update/route.ts +1 -1
  13. package/app/app/api/update-check/route.ts +11 -3
  14. package/app/app/globals.css +4 -0
  15. package/app/app/layout.tsx +6 -0
  16. package/app/app/login/page.tsx +12 -1
  17. package/app/app/register-sw.tsx +4 -0
  18. package/app/app/view/[...path]/page.tsx +3 -3
  19. package/app/components/DirView.tsx +96 -9
  20. package/app/components/FileTree.tsx +245 -27
  21. package/app/components/Sidebar.tsx +1 -0
  22. package/app/components/SidebarLayout.tsx +1 -1
  23. package/app/components/ask/AskContent.tsx +56 -14
  24. package/app/instrumentation.ts +2 -1
  25. package/app/lib/actions.ts +53 -25
  26. package/app/lib/core/create-space.ts +36 -0
  27. package/app/lib/core/fs-ops.ts +78 -1
  28. package/app/lib/core/index.ts +8 -0
  29. package/app/lib/core/list-spaces.ts +58 -0
  30. package/app/lib/core/types.ts +7 -0
  31. package/app/lib/fs.ts +78 -5
  32. package/app/lib/i18n-en.ts +10 -0
  33. package/app/lib/i18n-zh.ts +10 -0
  34. package/app/lib/project-root.ts +13 -0
  35. package/app/lib/template.ts +13 -6
  36. package/app/next-env.d.ts +1 -1
  37. package/bin/lib/mcp-spawn.js +38 -1
  38. package/mcp/README.md +5 -2
  39. package/mcp/src/index.ts +80 -0
  40. package/package.json +1 -1
  41. package/scripts/setup.js +1 -1
  42. package/skills/mindos/SKILL.md +4 -1
  43. package/skills/mindos/references/README.md +11 -0
  44. package/skills/mindos/references/post-task-hooks.md +53 -0
  45. package/skills/mindos/references/preference-capture.md +41 -0
  46. package/skills/mindos/references/sop-template.md +74 -0
  47. package/skills/mindos-zh/SKILL.md +4 -1
  48. package/skills/mindos-zh/references/README.md +11 -0
  49. package/skills/mindos-zh/references/post-task-hooks.md +53 -0
  50. package/skills/mindos-zh/references/preference-capture.md +41 -0
  51. package/skills/mindos-zh/references/sop-template.md +74 -0
  52. package/templates/empty/CONFIG.json +7 -5
  53. package/templates/empty/INSTRUCTION.md +5 -5
  54. package/templates/empty/README.md +1 -2
  55. package/templates/en/CONFIG.json +7 -5
  56. package/templates/en/INSTRUCTION.md +5 -5
  57. package/templates/en/README.md +1 -2
  58. package/templates/en//360/237/223/232 Resources/README.md" +4 -4
  59. package/templates/en//360/237/223/232 Resources//360/237/247/276 Books.csv" +1 -0
  60. package/templates/en//360/237/223/232 Resources//360/237/247/276 Learning Resources.csv" +1 -0
  61. package/templates/en//360/237/223/232 Resources//360/237/247/276 People to Follow.csv" +1 -0
  62. package/templates/en//360/237/223/232 Resources//360/237/247/276 Tools.csv" +1 -0
  63. package/templates/en//360/237/223/235 Notes/Ideas//360/237/247/252_example_product_idea.md" +9 -12
  64. package/templates/en//360/237/224/204 Workflows/Configurations//360/237/247/252_example_config_update_sop.md" +2 -3
  65. package/templates/en//360/237/224/204 Workflows/INSTRUCTION.md" +13 -5
  66. package/templates/en//360/237/232/200 Projects/Products//360/237/247/252_example_product_project_brief.md" +12 -14
  67. package/templates/template-generation-skill.md +4 -5
  68. package/templates/zh/CONFIG.json +7 -5
  69. package/templates/zh/INSTRUCTION.md +5 -5
  70. package/templates/zh/README.md +1 -2
  71. package/templates/zh//360/237/221/244 /347/224/273/345/203/217/README.md" +4 -4
  72. package/templates/zh//360/237/221/244 /347/224/273/345/203/217//342/232/231/357/270/{217 Preferences.md" → 217 /345/201/217/345/245/275.md" } +1 -1
  73. package/templates/zh//360/237/221/244 /347/224/273/345/203/217//360/237/216/{257 Focus.md" → 257 /350/201/232/347/204/246.md" } +1 -1
  74. package/templates/zh//360/237/221/244 /347/224/273/345/203/217//360/237/221/{244 Identity.md" → 244 /350/272/253/344/273/275.md" } +1 -1
  75. package/templates/zh//360/237/223/232 /350/265/204/346/272/220/README.md" +4 -4
  76. package/templates/zh//360/237/223/232 /350/265/204/346/272/220//360/237/247/276 /344/271/246/345/215/225.csv" +1 -0
  77. package/templates/zh//360/237/223/232 /350/265/204/346/272/220//360/237/247/276 /345/200/274/345/276/227/345/205/263/346/263/250/347/232/204/344/272/272.csv" +1 -0
  78. package/templates/zh//360/237/223/232 /350/265/204/346/272/220//360/237/247/276 /345/255/246/344/271/240/350/265/204/346/272/220.csv" +1 -0
  79. package/templates/zh//360/237/223/232 /350/265/204/346/272/220//360/237/247/276 /345/267/245/345/205/267/346/270/205/345/215/225.csv" +1 -0
  80. package/templates/zh//360/237/223/235 /347/254/224/350/256/260//346/203/263/346/263/225//360/237/247/252_example_/344/272/247/345/223/201/346/203/263/346/263/225.md" +8 -11
  81. package/templates/zh//360/237/224/204 /346/265/201/347/250/213/README.md" +1 -0
  82. package/templates/zh//360/237/224/204 /346/265/201/347/250/213//345/210/233/344/270/232/README.md" +3 -0
  83. package/templates/zh//360/237/224/204 /346/265/201/347/250/213//345/210/233/344/270/232//360/237/247/252_example_/346/257/217/345/221/250/345/210/233/345/247/213/344/272/272/350/277/220/350/220/245/350/212/202/345/245/217.md" +22 -0
  84. package/templates/zh//360/237/224/204 /346/265/201/347/250/213//351/205/215/347/275/256//360/237/247/252_example_/351/205/215/347/275/256/346/233/264/346/226/260/346/265/201/347/250/213.md" +1 -1
  85. package/templates/zh//360/237/232/200 /351/241/271/347/233/256//344/272/247/345/223/201//360/237/247/252_example_/344/272/247/345/223/201/351/241/271/347/233/256/347/256/200/346/212/245.md" +12 -14
  86. package/templates/empty/CONFIG.md +0 -73
  87. package/templates/en/CONFIG.md +0 -73
  88. package/templates/en//360/237/223/232 Resources//360/237/247/276 AI Influencers.csv" +0 -1
  89. package/templates/en//360/237/223/232 Resources//360/237/247/276 AI Products.csv" +0 -1
  90. package/templates/en//360/237/223/232 Resources//360/237/247/276 AI Scholars.csv" +0 -1
  91. package/templates/en//360/237/223/232 Resources//360/237/247/276 AI Tools.csv" +0 -1
  92. package/templates/zh/CONFIG.md +0 -66
  93. package/templates/zh//360/237/223/232 /350/265/204/346/272/220//360/237/247/276 AI Inferencers.csv" +0 -1
  94. package/templates/zh//360/237/223/232 /350/265/204/346/272/220//360/237/247/276 AI /344/272/247/345/223/201.csv" +0 -1
  95. package/templates/zh//360/237/223/232 /350/265/204/346/272/220//360/237/247/276 AI /345/255/246/350/200/205/346/270/205/345/215/225.csv" +0 -1
  96. package/templates/zh//360/237/223/232 /350/265/204/346/272/220//360/237/247/276 AI /345/267/245/345/205/267/346/270/205/345/215/225.csv" +0 -1
@@ -4,16 +4,21 @@ import { useState, useCallback, useRef, useTransition, useEffect } 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
- import { ChevronDown, FileText, Table, Folder, FolderOpen, Plus, Loader2, Trash2, Pencil } from 'lucide-react';
8
- import { createFileAction, deleteFileAction, renameFileAction } from '@/lib/actions';
7
+ import {
8
+ ChevronDown, FileText, Table, Folder, FolderOpen, Plus, Loader2,
9
+ Trash2, Pencil, Layers, ScrollText,
10
+ } from 'lucide-react';
11
+ import { createFileAction, deleteFileAction, renameFileAction, renameSpaceAction, deleteSpaceAction, convertToSpaceAction, deleteFolderAction } from '@/lib/actions';
9
12
  import { useLocale } from '@/lib/LocaleContext';
10
13
 
14
+ const SYSTEM_FILES = new Set(['INSTRUCTION.md', 'README.md']);
15
+
11
16
  interface FileTreeProps {
12
17
  nodes: FileNode[];
13
18
  depth?: number;
14
19
  onNavigate?: () => void;
15
- /** When set, directories with depth <= this value open, others close. null = no override (manual control). */
16
20
  maxOpenDepth?: number | null;
21
+ parentIsSpace?: boolean;
17
22
  }
18
23
 
19
24
  function getIcon(node: FileNode) {
@@ -29,12 +34,143 @@ function getCurrentFilePath(pathname: string): string {
29
34
  return encoded.split('/').map(decodeURIComponent).join('/');
30
35
  }
31
36
 
37
+ function countContentFiles(node: FileNode): number {
38
+ if (node.type === 'file') return SYSTEM_FILES.has(node.name) ? 0 : 1;
39
+ return (node.children ?? []).reduce((sum, c) => sum + countContentFiles(c), 0);
40
+ }
41
+
42
+ function filterVisibleNodes(nodes: FileNode[], parentIsSpace: boolean): FileNode[] {
43
+ return nodes.filter(node => {
44
+ if (node.type !== 'file') return true;
45
+ if (parentIsSpace && SYSTEM_FILES.has(node.name)) return false;
46
+ if (!parentIsSpace && node.name === 'README.md') return false;
47
+ return true;
48
+ });
49
+ }
50
+
51
+ // ─── Context Menu Shell ───────────────────────────────────────────────────────
52
+
53
+ function ContextMenuShell({ x, y, onClose, menuHeight, children }: {
54
+ x: number;
55
+ y: number;
56
+ onClose: () => void;
57
+ menuHeight?: number;
58
+ children: React.ReactNode;
59
+ }) {
60
+ const menuRef = useRef<HTMLDivElement>(null);
61
+
62
+ useEffect(() => {
63
+ const handler = (e: MouseEvent) => {
64
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose();
65
+ };
66
+ const keyHandler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
67
+ document.addEventListener('mousedown', handler);
68
+ document.addEventListener('keydown', keyHandler);
69
+ return () => {
70
+ document.removeEventListener('mousedown', handler);
71
+ document.removeEventListener('keydown', keyHandler);
72
+ };
73
+ }, [onClose]);
74
+
75
+ const adjustedY = Math.min(y, window.innerHeight - (menuHeight ?? 160));
76
+ const adjustedX = Math.min(x, window.innerWidth - 200);
77
+
78
+ return (
79
+ <div
80
+ ref={menuRef}
81
+ className="fixed z-50 min-w-[180px] bg-card border border-border rounded-lg shadow-lg py-1"
82
+ style={{ top: adjustedY, left: adjustedX }}
83
+ >
84
+ {children}
85
+ </div>
86
+ );
87
+ }
88
+
89
+ const MENU_ITEM = "w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors text-left";
90
+ const MENU_DANGER = "w-full flex items-center gap-2 px-3 py-2 text-sm text-error hover:bg-error/10 transition-colors text-left";
91
+ const MENU_DIVIDER = "my-1 border-t border-border/50";
92
+
93
+ // ─── SpaceContextMenu ─────────────────────────────────────────────────────────
94
+
95
+ function SpaceContextMenu({ x, y, node, onClose, onRename }: {
96
+ x: number; y: number; node: FileNode; onClose: () => void; onRename: () => void;
97
+ }) {
98
+ const router = useRouter();
99
+ const { t } = useLocale();
100
+ const [isPending, startTransition] = useTransition();
101
+
102
+ return (
103
+ <ContextMenuShell x={x} y={y} onClose={onClose}>
104
+ <button className={MENU_ITEM} onClick={() => { router.push(`/view/${encodePath(`${node.path}/INSTRUCTION.md`)}`); onClose(); }}>
105
+ <ScrollText size={14} className="shrink-0" /> {t.fileTree.editRules}
106
+ </button>
107
+ <button className={MENU_ITEM} onClick={() => { onRename(); onClose(); }}>
108
+ <Pencil size={14} className="shrink-0" /> {t.fileTree.renameSpace}
109
+ </button>
110
+ <div className={MENU_DIVIDER} />
111
+ <button className={MENU_DANGER} disabled={isPending} onClick={() => {
112
+ if (!confirm(t.fileTree.confirmDeleteSpace(node.name))) return;
113
+ startTransition(async () => {
114
+ const result = await deleteSpaceAction(node.path);
115
+ if (result.success) { router.push('/'); router.refresh(); }
116
+ onClose();
117
+ });
118
+ }}>
119
+ <Trash2 size={14} className="shrink-0" />
120
+ {isPending ? <Loader2 size={14} className="animate-spin" /> : t.fileTree.deleteSpace}
121
+ </button>
122
+ </ContextMenuShell>
123
+ );
124
+ }
125
+
126
+ // ─── FolderContextMenu ────────────────────────────────────────────────────────
127
+
128
+ function FolderContextMenu({ x, y, node, onClose, onRename }: {
129
+ x: number; y: number; node: FileNode; onClose: () => void; onRename: () => void;
130
+ }) {
131
+ const router = useRouter();
132
+ const { t } = useLocale();
133
+ const [isPending, startTransition] = useTransition();
134
+
135
+ return (
136
+ <ContextMenuShell x={x} y={y} onClose={onClose} menuHeight={140}>
137
+ <button className={MENU_ITEM} disabled={isPending} onClick={() => {
138
+ startTransition(async () => {
139
+ const result = await convertToSpaceAction(node.path);
140
+ if (result.success) router.refresh();
141
+ onClose();
142
+ });
143
+ }}>
144
+ <Layers size={14} className="shrink-0" style={{ color: 'var(--amber)' }} /> {t.fileTree.convertToSpace}
145
+ </button>
146
+ <button className={MENU_ITEM} onClick={() => { onRename(); onClose(); }}>
147
+ <Pencil size={14} className="shrink-0" /> {t.fileTree.rename}
148
+ </button>
149
+ <div className={MENU_DIVIDER} />
150
+ <button className={MENU_DANGER} disabled={isPending} onClick={() => {
151
+ if (!confirm(t.fileTree.confirmDeleteFolder(node.name))) return;
152
+ startTransition(async () => {
153
+ const result = await deleteFolderAction(node.path);
154
+ if (result.success) { router.push('/'); router.refresh(); }
155
+ onClose();
156
+ });
157
+ }}>
158
+ <Trash2 size={14} className="shrink-0" />
159
+ {isPending ? <Loader2 size={14} className="animate-spin" /> : t.fileTree.deleteFolder}
160
+ </button>
161
+ </ContextMenuShell>
162
+ );
163
+ }
164
+
165
+ // ─── NewFileInline ────────────────────────────────────────────────────────────
166
+
32
167
  function NewFileInline({ dirPath, depth, onDone }: { dirPath: string; depth: number; onDone: () => void }) {
33
168
  const [value, setValue] = useState('');
34
169
  const [isPending, startTransition] = useTransition();
35
170
  const [error, setError] = useState('');
36
171
  const router = useRouter();
37
172
  const { t } = useLocale();
173
+ const containerRef = useRef<HTMLDivElement>(null);
38
174
 
39
175
  const handleSubmit = useCallback(() => {
40
176
  const name = value.trim();
@@ -51,8 +187,18 @@ function NewFileInline({ dirPath, depth, onDone }: { dirPath: string; depth: num
51
187
  });
52
188
  }, [value, dirPath, onDone, router, t]);
53
189
 
190
+ useEffect(() => {
191
+ const handler = (e: MouseEvent) => {
192
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
193
+ onDone();
194
+ }
195
+ };
196
+ const timer = setTimeout(() => document.addEventListener('mousedown', handler), 0);
197
+ return () => { clearTimeout(timer); document.removeEventListener('mousedown', handler); };
198
+ }, [onDone]);
199
+
54
200
  return (
55
- <div className="px-2 pb-1" style={{ paddingLeft: `${depth * 12 + 20}px` }}>
201
+ <div ref={containerRef} className="px-2 pb-1" style={{ paddingLeft: `${depth * 12 + 20}px` }}>
56
202
  <div className="flex items-center gap-1">
57
203
  <input
58
204
  autoFocus
@@ -87,12 +233,15 @@ function NewFileInline({ dirPath, depth, onDone }: { dirPath: string; depth: num
87
233
  );
88
234
  }
89
235
 
236
+ // ─── DirectoryNode ────────────────────────────────────────────────────────────
237
+
90
238
  function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth }: {
91
239
  node: FileNode; depth: number; currentPath: string; onNavigate?: () => void;
92
240
  maxOpenDepth?: number | null;
93
241
  }) {
94
242
  const router = useRouter();
95
243
  const isActive = currentPath.startsWith(node.path + '/') || currentPath === node.path;
244
+ const isSpace = !!node.isSpace;
96
245
  const [open, setOpen] = useState(depth === 0 ? true : isActive);
97
246
  const [showNewFile, setShowNewFile] = useState(false);
98
247
  const [renaming, setRenaming] = useState(false);
@@ -100,18 +249,17 @@ function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth }: {
100
249
  const [isPending, startTransition] = useTransition();
101
250
  const renameRef = useRef<HTMLInputElement>(null);
102
251
  const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
252
+ const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
103
253
  const { t } = useLocale();
104
254
 
105
255
  const toggle = useCallback(() => setOpen(v => !v), []);
106
256
 
107
- // React to maxOpenDepth changes from parent
108
257
  const prevMaxOpenDepth = useRef<number | null | undefined>(undefined);
109
258
  useEffect(() => {
110
259
  if (maxOpenDepth === null || maxOpenDepth === undefined) {
111
260
  prevMaxOpenDepth.current = maxOpenDepth;
112
261
  return;
113
262
  }
114
- // Only react when value actually changes
115
263
  if (prevMaxOpenDepth.current !== maxOpenDepth) {
116
264
  setOpen(depth <= maxOpenDepth);
117
265
  prevMaxOpenDepth.current = maxOpenDepth;
@@ -140,7 +288,8 @@ function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth }: {
140
288
  const newName = renameValue.trim();
141
289
  if (!newName || newName === node.name) { setRenaming(false); return; }
142
290
  startTransition(async () => {
143
- const result = await renameFileAction(node.path, newName);
291
+ const action = isSpace ? renameSpaceAction : renameFileAction;
292
+ const result = await action(node.path, newName);
144
293
  if (result.success && result.newPath) {
145
294
  setRenaming(false);
146
295
  router.push(`/view/${encodePath(result.newPath)}`);
@@ -149,7 +298,7 @@ function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth }: {
149
298
  setRenaming(false);
150
299
  }
151
300
  });
152
- }, [renameValue, node.name, node.path, router]);
301
+ }, [renameValue, node.name, node.path, router, isSpace]);
153
302
 
154
303
  const handleSingleClick = useCallback(() => {
155
304
  if (renaming) return;
@@ -162,8 +311,17 @@ function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth }: {
162
311
  }, [renaming, router, node.path, onNavigate]);
163
312
 
164
313
  const handleDoubleClick = useCallback((e: React.MouseEvent) => {
314
+ if (isSpace) return;
165
315
  startRename(e);
166
- }, [startRename]);
316
+ }, [startRename, isSpace]);
317
+
318
+ const handleContextMenu = useCallback((e: React.MouseEvent) => {
319
+ e.preventDefault();
320
+ e.stopPropagation();
321
+ setContextMenu({ x: e.clientX, y: e.clientY });
322
+ }, []);
323
+
324
+ const contentCount = isSpace ? countContentFiles(node) : 0;
167
325
 
168
326
  if (renaming) {
169
327
  return (
@@ -185,9 +343,14 @@ function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth }: {
185
343
  );
186
344
  }
187
345
 
346
+ const showBorder = isSpace && depth === 0;
347
+
188
348
  return (
189
349
  <div>
190
- <div className="relative group/dir flex items-center">
350
+ <div
351
+ className="relative group/dir flex items-center"
352
+ onContextMenu={handleContextMenu}
353
+ >
191
354
  <button
192
355
  onClick={toggle}
193
356
  className="shrink-0 p-1 rounded hover:bg-muted text-zinc-500 transition-colors"
@@ -209,11 +372,16 @@ function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth }: {
209
372
  ${isActive ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'}
210
373
  `}
211
374
  >
212
- {open
213
- ? <FolderOpen size={14} className="text-yellow-400 shrink-0" />
214
- : <Folder size={14} className="text-yellow-400 shrink-0" />
375
+ {isSpace
376
+ ? <Layers size={14} className="shrink-0" style={{ color: 'var(--amber)' }} />
377
+ : open
378
+ ? <FolderOpen size={14} className="text-yellow-400 shrink-0" />
379
+ : <Folder size={14} className="text-yellow-400 shrink-0" />
215
380
  }
216
381
  <span className="truncate leading-5" suppressHydrationWarning>{node.name}</span>
382
+ {isSpace && !open && (
383
+ <span className="ml-auto text-xs text-muted-foreground shrink-0 tabular-nums pr-1">{contentCount}</span>
384
+ )}
217
385
  </button>
218
386
  <div className="absolute right-1 top-1/2 -translate-y-1/2 hidden group-hover/dir:flex items-center gap-0.5 z-10">
219
387
  <button
@@ -229,36 +397,81 @@ function DirectoryNode({ node, depth, currentPath, onNavigate, maxOpenDepth }: {
229
397
  >
230
398
  <Plus size={13} />
231
399
  </button>
232
- <button
233
- type="button"
234
- onClick={startRename}
235
- className="p-0.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
236
- title={t.fileTree.rename}
237
- >
238
- <Pencil size={12} />
239
- </button>
400
+ {isSpace ? (
401
+ <button
402
+ type="button"
403
+ onClick={(e) => {
404
+ e.preventDefault();
405
+ e.stopPropagation();
406
+ router.push(`/view/${encodePath(`${node.path}/INSTRUCTION.md`)}`);
407
+ onNavigate?.();
408
+ }}
409
+ className="p-0.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
410
+ title={t.fileTree.editRules}
411
+ >
412
+ <ScrollText size={12} />
413
+ </button>
414
+ ) : (
415
+ <button
416
+ type="button"
417
+ onClick={startRename}
418
+ className="p-0.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
419
+ title={t.fileTree.rename}
420
+ >
421
+ <Pencil size={12} />
422
+ </button>
423
+ )}
240
424
  </div>
241
425
  </div>
242
426
 
243
427
  <div
244
- className="overflow-hidden transition-all duration-200"
245
- style={{ maxHeight: open ? '9999px' : '0px' }}
428
+ className={`overflow-hidden transition-all duration-200 ${showBorder ? 'border-l-2 ml-[18px]' : ''}`}
429
+ style={{
430
+ maxHeight: open ? '9999px' : '0px',
431
+ ...(showBorder ? { borderColor: 'color-mix(in srgb, var(--amber) 30%, transparent)' } : {}),
432
+ }}
246
433
  >
247
434
  {node.children && (
248
- <FileTree nodes={node.children} depth={depth + 1} onNavigate={onNavigate} maxOpenDepth={maxOpenDepth} />
435
+ <FileTree
436
+ nodes={node.children}
437
+ depth={showBorder ? 1 : depth + 1}
438
+ onNavigate={onNavigate}
439
+ maxOpenDepth={maxOpenDepth}
440
+ parentIsSpace={isSpace}
441
+ />
249
442
  )}
250
443
  {showNewFile && (
251
444
  <NewFileInline
252
445
  dirPath={node.path}
253
- depth={depth}
446
+ depth={showBorder ? 0 : depth}
254
447
  onDone={() => setShowNewFile(false)}
255
448
  />
256
449
  )}
257
450
  </div>
451
+
452
+ {contextMenu && (isSpace ? (
453
+ <SpaceContextMenu
454
+ x={contextMenu.x}
455
+ y={contextMenu.y}
456
+ node={node}
457
+ onClose={() => setContextMenu(null)}
458
+ onRename={() => startRename()}
459
+ />
460
+ ) : (
461
+ <FolderContextMenu
462
+ x={contextMenu.x}
463
+ y={contextMenu.y}
464
+ node={node}
465
+ onClose={() => setContextMenu(null)}
466
+ onRename={() => startRename()}
467
+ />
468
+ ))}
258
469
  </div>
259
470
  );
260
471
  }
261
472
 
473
+ // ─── FileNodeItem ─────────────────────────────────────────────────────────────
474
+
262
475
  function FileNodeItem({ node, depth, currentPath, onNavigate }: {
263
476
  node: FileNode; depth: number; currentPath: string; onNavigate?: () => void;
264
477
  }) {
@@ -359,10 +572,15 @@ function FileNodeItem({ node, depth, currentPath, onNavigate }: {
359
572
  );
360
573
  }
361
574
 
362
- export default function FileTree({ nodes, depth = 0, onNavigate, maxOpenDepth }: FileTreeProps) {
575
+ // ─── FileTree (root) ──────────────────────────────────────────────────────────
576
+
577
+ export default function FileTree({ nodes, depth = 0, onNavigate, maxOpenDepth, parentIsSpace }: FileTreeProps) {
363
578
  const pathname = usePathname();
364
579
  const currentPath = getCurrentFilePath(pathname);
365
580
 
581
+ const isInsideDir = depth > 0;
582
+ const visibleNodes = isInsideDir ? filterVisibleNodes(nodes, !!parentIsSpace) : nodes;
583
+
366
584
  useEffect(() => {
367
585
  if (!currentPath || depth !== 0) return;
368
586
  const timer = setTimeout(() => {
@@ -374,7 +592,7 @@ export default function FileTree({ nodes, depth = 0, onNavigate, maxOpenDepth }:
374
592
 
375
593
  return (
376
594
  <div className="flex flex-col gap-0.5">
377
- {nodes.map((node) =>
595
+ {visibleNodes.map((node) =>
378
596
  node.type === 'directory' ? (
379
597
  <DirectoryNode key={node.path} node={node} depth={depth} currentPath={currentPath} onNavigate={onNavigate} maxOpenDepth={maxOpenDepth} />
380
598
  ) : (
@@ -90,6 +90,7 @@ export default function Sidebar({ fileTree, collapsed = false, onCollapse, onExp
90
90
 
91
91
  const sidebarContent = (
92
92
  <div className="flex flex-col h-full">
93
+ <div className="shrink-0 electron-mac-titlebar-pad" />
93
94
  <div className="flex items-center justify-between px-4 py-4 border-b border-border shrink-0">
94
95
  <Link href="/" className="flex items-center gap-2 hover:opacity-80 transition-opacity">
95
96
  <Logo id="desktop" />
@@ -376,7 +376,7 @@ export default function SidebarLayout({ fileTree, children }: SidebarLayoutProps
376
376
  #main-content {
377
377
  padding-left: ${lp.panelOpen && lp.panelMaximized ? '100vw' : `${lp.panelOpen ? lp.railWidth + lp.effectivePanelWidth : lp.railWidth}px`} !important;
378
378
  padding-right: calc(var(--right-panel-width) + var(--right-agent-detail-width)) !important;
379
- padding-top: 0 !important;
379
+ padding-top: 0;
380
380
  }
381
381
  }
382
382
  `}</style>
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useRef, useState, useCallback } from 'react';
3
+ import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react';
4
4
  import { Sparkles, Send, AtSign, Paperclip, StopCircle, RotateCcw, History, X, Maximize2, Minimize2, PanelRight, AppWindow } from 'lucide-react';
5
5
  import { useLocale } from '@/lib/LocaleContext';
6
6
  import type { Message } from '@/lib/types';
@@ -21,6 +21,27 @@ const PANEL_COMPOSER_MIN = 84;
21
21
  const PANEL_COMPOSER_MAX_ABS = 440;
22
22
  const PANEL_COMPOSER_MAX_VIEW = 0.48;
23
23
  const PANEL_COMPOSER_KEY_STEP = 24;
24
+ /** 输入框随内容增高,超过此行数后在框内滚动(与常见 IM 一致) */
25
+ const PANEL_TEXTAREA_MAX_VISIBLE_LINES = 8;
26
+
27
+ function syncPanelTextareaToContent(el: HTMLTextAreaElement, maxVisibleLines: number, availableHeight?: number): void {
28
+ const style = getComputedStyle(el);
29
+ const parsedLh = parseFloat(style.lineHeight);
30
+ const parsedFs = parseFloat(style.fontSize);
31
+ const fontSize = Number.isFinite(parsedFs) ? parsedFs : 14;
32
+ const lineHeight = Number.isFinite(parsedLh) ? parsedLh : fontSize * 1.375;
33
+ const pad =
34
+ (Number.isFinite(parseFloat(style.paddingTop)) ? parseFloat(style.paddingTop) : 0) +
35
+ (Number.isFinite(parseFloat(style.paddingBottom)) ? parseFloat(style.paddingBottom) : 0);
36
+ let maxH = lineHeight * maxVisibleLines + pad;
37
+ if (availableHeight && Number.isFinite(availableHeight) && availableHeight > 0) {
38
+ maxH = Math.min(maxH, availableHeight);
39
+ }
40
+ if (!Number.isFinite(maxH) || maxH <= 0) return;
41
+ el.style.height = '0px';
42
+ const next = Math.min(el.scrollHeight, maxH);
43
+ el.style.height = `${Number.isFinite(next) ? next : maxH}px`;
44
+ }
24
45
 
25
46
  function panelComposerMaxForViewport(): number {
26
47
  if (typeof window === 'undefined') return PANEL_COMPOSER_MAX_ABS;
@@ -67,10 +88,18 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
67
88
  const firstMessageFired = useRef(false);
68
89
  const { t } = useLocale();
69
90
 
70
- const [panelComposerHeight, setPanelComposerHeight] = useState(readStoredPanelComposerHeight);
91
+ const [panelComposerHeight, setPanelComposerHeight] = useState(PANEL_COMPOSER_DEFAULT);
71
92
  const panelComposerHRef = useRef(panelComposerHeight);
72
93
  panelComposerHRef.current = panelComposerHeight;
73
94
 
95
+ useEffect(() => {
96
+ const stored = readStoredPanelComposerHeight();
97
+ if (stored !== PANEL_COMPOSER_DEFAULT) {
98
+ setPanelComposerHeight(stored);
99
+ panelComposerHRef.current = stored;
100
+ }
101
+ }, []);
102
+
74
103
  const getPanelComposerHeight = useCallback(() => panelComposerHRef.current, []);
75
104
  const persistPanelComposerHeight = useCallback((h: number) => {
76
105
  try {
@@ -89,7 +118,11 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
89
118
  persist: persistPanelComposerHeight,
90
119
  });
91
120
 
92
- const [panelComposerViewportMax, setPanelComposerViewportMax] = useState(panelComposerMaxForViewport);
121
+ const [panelComposerViewportMax, setPanelComposerViewportMax] = useState(PANEL_COMPOSER_MAX_ABS);
122
+
123
+ useEffect(() => {
124
+ setPanelComposerViewportMax(panelComposerMaxForViewport());
125
+ }, []);
93
126
 
94
127
  const applyPanelComposerClampAndPersist = useCallback(() => {
95
128
  const maxH = panelComposerMaxForViewport();
@@ -195,11 +228,28 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
195
228
  return () => window.removeEventListener('resize', applyPanelComposerClampAndPersist);
196
229
  }, [isPanel, applyPanelComposerClampAndPersist]);
197
230
 
231
+ const formRef = useRef<HTMLFormElement>(null);
232
+
233
+ useLayoutEffect(() => {
234
+ if (!isPanel || !visible) return;
235
+ const el = inputRef.current;
236
+ if (!el || !(el instanceof HTMLTextAreaElement)) return;
237
+ const form = formRef.current;
238
+ const availableH = form ? form.clientHeight - 40 : undefined;
239
+ syncPanelTextareaToContent(el, PANEL_TEXTAREA_MAX_VISIBLE_LINES, availableH);
240
+ }, [input, isPanel, isLoading, visible, panelComposerHeight]);
241
+
242
+ const mentionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
198
243
  const handleInputChange = useCallback((val: string) => {
199
244
  setInput(val);
200
- mention.updateMentionFromInput(val);
245
+ if (mentionTimerRef.current) clearTimeout(mentionTimerRef.current);
246
+ mentionTimerRef.current = setTimeout(() => mention.updateMentionFromInput(val), 80);
201
247
  }, [mention]);
202
248
 
249
+ useEffect(() => {
250
+ return () => { if (mentionTimerRef.current) clearTimeout(mentionTimerRef.current); };
251
+ }, []);
252
+
203
253
  const selectMention = useCallback((filePath: string) => {
204
254
  const atIdx = input.lastIndexOf('@');
205
255
  setInput(input.slice(0, atIdx));
@@ -498,6 +548,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
498
548
  )}
499
549
 
500
550
  <form
551
+ ref={formRef}
501
552
  onSubmit={handleSubmit}
502
553
  className={cn(
503
554
  'flex',
@@ -551,7 +602,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
551
602
  placeholder={t.ask.placeholder}
552
603
  disabled={isLoading}
553
604
  rows={1}
554
- className="min-h-0 min-w-0 flex-1 resize-none overflow-y-auto bg-transparent py-2 text-sm leading-snug text-foreground placeholder:text-muted-foreground outline-none transition-[height] duration-75 disabled:opacity-50"
605
+ className="min-w-0 flex-1 resize-none overflow-y-auto bg-transparent py-2 text-sm leading-snug text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50"
555
606
  />
556
607
  ) : (
557
608
  <input
@@ -598,15 +649,6 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
598
649
  <kbd className="font-mono ml-0.5">↵</kbd> {t.ask.newlineHint}
599
650
  </span>
600
651
  ) : null}
601
- {isPanel ? (
602
- <span
603
- className="hidden sm:inline"
604
- suppressHydrationWarning
605
- title={`${t.ask.panelComposerResize} · ${t.ask.panelComposerResetHint} · ${t.ask.panelComposerKeyboard}`}
606
- >
607
- <kbd className="font-mono">↕</kbd> {t.ask.panelComposerFooter}
608
- </span>
609
- ) : null}
610
652
  <span suppressHydrationWarning>
611
653
  <kbd className="font-mono">@</kbd> {t.ask.attachFile}
612
654
  </span>
@@ -11,7 +11,8 @@ export async function register() {
11
11
  // createRequire() calls. The only way to load a runtime-computed path
12
12
  // is to hide the require call inside a Function constructor, which is
13
13
  // opaque to bundler static analysis.
14
- const syncModule = resolve(process.cwd(), '..', 'bin', 'lib', 'sync.js');
14
+ const projRoot = process.env.MINDOS_PROJECT_ROOT || resolve(process.cwd(), '..');
15
+ const syncModule = resolve(projRoot, 'bin', 'lib', 'sync.js');
15
16
  // eslint-disable-next-line @typescript-eslint/no-implied-eval
16
17
  const dynamicRequire = new Function('id', 'return require(id)') as (id: string) => any;
17
18
  const { startSyncDaemon } = dynamicRequire(syncModule);
@@ -1,6 +1,7 @@
1
1
  'use server';
2
2
 
3
- import { createFile, deleteFile, renameFile } from '@/lib/fs';
3
+ import { createFile, deleteFile, deleteDirectory, convertToSpace, renameFile, renameSpace, getMindRoot, invalidateCache } from '@/lib/fs';
4
+ import { createSpaceFilesystem } from '@/lib/core/create-space';
4
5
  import { revalidatePath } from 'next/cache';
5
6
 
6
7
  export async function createFileAction(dirPath: string, fileName: string): Promise<{ success: boolean; filePath?: string; error?: string }> {
@@ -39,6 +40,55 @@ export async function renameFileAction(oldPath: string, newName: string): Promis
39
40
  }
40
41
  }
41
42
 
43
+ export async function convertToSpaceAction(
44
+ dirPath: string,
45
+ ): Promise<{ success: boolean; error?: string }> {
46
+ try {
47
+ convertToSpace(dirPath);
48
+ revalidatePath('/', 'layout');
49
+ return { success: true };
50
+ } catch (err) {
51
+ return { success: false, error: err instanceof Error ? err.message : 'Failed to convert to space' };
52
+ }
53
+ }
54
+
55
+ export async function deleteFolderAction(
56
+ dirPath: string,
57
+ ): Promise<{ success: boolean; error?: string }> {
58
+ try {
59
+ deleteDirectory(dirPath);
60
+ revalidatePath('/', 'layout');
61
+ return { success: true };
62
+ } catch (err) {
63
+ return { success: false, error: err instanceof Error ? err.message : 'Failed to delete folder' };
64
+ }
65
+ }
66
+
67
+ export async function renameSpaceAction(
68
+ spacePath: string,
69
+ newName: string,
70
+ ): Promise<{ success: boolean; newPath?: string; error?: string }> {
71
+ try {
72
+ const newPath = renameSpace(spacePath, newName);
73
+ revalidatePath('/', 'layout');
74
+ return { success: true, newPath };
75
+ } catch (err) {
76
+ return { success: false, error: err instanceof Error ? err.message : 'Failed to rename space' };
77
+ }
78
+ }
79
+
80
+ export async function deleteSpaceAction(
81
+ spacePath: string,
82
+ ): Promise<{ success: boolean; error?: string }> {
83
+ try {
84
+ deleteDirectory(spacePath);
85
+ revalidatePath('/', 'layout');
86
+ return { success: true };
87
+ } catch (err) {
88
+ return { success: false, error: err instanceof Error ? err.message : 'Failed to delete space' };
89
+ }
90
+ }
91
+
42
92
  /**
43
93
  * Create a new Mind Space (top-level directory) with README.md + auto-scaffolded INSTRUCTION.md.
44
94
  * The description is written into README.md so it appears on the homepage Space card
@@ -50,34 +100,12 @@ export async function createSpaceAction(
50
100
  parentPath: string = ''
51
101
  ): Promise<{ success: boolean; path?: string; error?: string }> {
52
102
  try {
53
- const trimmed = name.trim();
54
- if (!trimmed) return { success: false, error: 'Space name is required' };
55
- if (trimmed.includes('/') || trimmed.includes('\\')) {
56
- return { success: false, error: 'Space name must not contain path separators' };
57
- }
58
-
59
- // Sanitize parentPath — reject traversal attempts
60
- const cleanParent = parentPath.replace(/\/+$/, '').trim();
61
- if (cleanParent.includes('..') || cleanParent.startsWith('/') || cleanParent.includes('\\')) {
62
- return { success: false, error: 'Invalid parent path' };
63
- }
64
-
65
- // Build full path: parentPath + name
66
- const prefix = cleanParent ? cleanParent + '/' : '';
67
- const fullPath = `${prefix}${trimmed}`;
68
-
69
- // Strip emoji for clean title in README content
70
- const cleanName = trimmed.replace(/^[\p{Emoji_Presentation}\p{Extended_Pictographic}\s]+/u, '') || trimmed;
71
- const desc = description.trim() || '(Describe the purpose and usage of this space.)';
72
- const readmeContent = `# ${cleanName}\n\n${desc}\n\n## 📁 Structure\n\n\`\`\`bash\n${fullPath}/\n├── INSTRUCTION.md\n├── README.md\n└── (your files here)\n\`\`\`\n\n## 💡 Usage\n\n(Add usage guidelines for this space.)\n`;
73
-
74
- // createFile triggers scaffoldIfNewSpace → auto-generates INSTRUCTION.md
75
- createFile(`${fullPath}/README.md`, readmeContent);
103
+ const { path: fullPath } = createSpaceFilesystem(getMindRoot(), name, description, parentPath);
104
+ invalidateCache();
76
105
  revalidatePath('/', 'layout');
77
106
  return { success: true, path: fullPath };
78
107
  } catch (err) {
79
108
  const msg = err instanceof Error ? err.message : 'Failed to create space';
80
- // Make "already exists" error more user-friendly
81
109
  if (msg.includes('already exists')) {
82
110
  return { success: false, error: 'A space with this name already exists' };
83
111
  }