@geminilight/mindos 0.5.56 → 0.5.58

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.
@@ -13,9 +13,13 @@ import {
13
13
  updateSection,
14
14
  deleteFile,
15
15
  renameFile,
16
+ renameSpace,
16
17
  moveFile,
17
18
  appendCsvRow,
19
+ getMindRoot,
20
+ invalidateCache,
18
21
  } from '@/lib/fs';
22
+ import { createSpaceFilesystem } from '@/lib/core/create-space';
19
23
 
20
24
  function err(msg: string, status = 400) {
21
25
  return NextResponse.json({ error: msg }, { status });
@@ -39,7 +43,14 @@ export async function GET(req: NextRequest) {
39
43
  }
40
44
 
41
45
  // Ops that change file tree structure (sidebar needs refresh)
42
- const TREE_CHANGING_OPS = new Set(['create_file', 'delete_file', 'rename_file', 'move_file']);
46
+ const TREE_CHANGING_OPS = new Set([
47
+ 'create_file',
48
+ 'delete_file',
49
+ 'rename_file',
50
+ 'move_file',
51
+ 'create_space',
52
+ 'rename_space',
53
+ ]);
43
54
 
44
55
  // POST /api/file body: { op, path, ...params }
45
56
  export async function POST(req: NextRequest) {
@@ -138,6 +149,37 @@ export async function POST(req: NextRequest) {
138
149
  break;
139
150
  }
140
151
 
152
+ case 'create_space': {
153
+ const name = params.name;
154
+ const description = typeof params.description === 'string' ? params.description : '';
155
+ const parent_path = typeof params.parent_path === 'string' ? params.parent_path : '';
156
+ if (typeof name !== 'string' || !name.trim()) {
157
+ return err('missing or empty name');
158
+ }
159
+ try {
160
+ const { path: spacePath } = createSpaceFilesystem(getMindRoot(), name, description, parent_path);
161
+ invalidateCache();
162
+ resp = NextResponse.json({ ok: true, path: spacePath });
163
+ } catch (e) {
164
+ const msg = (e as Error).message;
165
+ const code400 =
166
+ msg.includes('required') ||
167
+ msg.includes('must not contain') ||
168
+ msg.includes('Invalid parent') ||
169
+ msg.includes('already exists');
170
+ return err(msg, code400 ? 400 : 500);
171
+ }
172
+ break;
173
+ }
174
+
175
+ case 'rename_space': {
176
+ const { new_name } = params as { new_name: string };
177
+ if (typeof new_name !== 'string' || !new_name.trim()) return err('missing new_name');
178
+ const newPath = renameSpace(filePath, new_name.trim());
179
+ resp = NextResponse.json({ ok: true, newPath });
180
+ break;
181
+ }
182
+
141
183
  case 'append_csv': {
142
184
  const { row } = params as { row: string[] };
143
185
  if (!Array.isArray(row) || row.length === 0) return err('row must be non-empty array');
@@ -1,5 +1,5 @@
1
1
  import { notFound } from 'next/navigation';
2
- import { getFileContent, saveFileContent, isDirectory, getDirEntries, createFile, getFileTree } from '@/lib/fs';
2
+ import { getFileContent, saveFileContent, isDirectory, getDirEntries, createFile, getFileTree, getSpacePreview } from '@/lib/fs';
3
3
  import type { FileNode } from '@/lib/types';
4
4
  import ViewPageClient from './ViewPageClient';
5
5
  import DirView from '@/components/DirView';
@@ -24,10 +24,10 @@ export default async function ViewPage({ params }: PageProps) {
24
24
  const { path: segments } = await params;
25
25
  const filePath = segments.map(decodeURIComponent).join('/');
26
26
 
27
- // Directory: show folder listing
28
27
  if (isDirectory(filePath)) {
29
28
  const entries = getDirEntries(filePath);
30
- return <DirView dirPath={filePath} entries={entries} />;
29
+ const spacePreview = getSpacePreview(filePath);
30
+ return <DirView dirPath={filePath} entries={entries} spacePreview={spacePreview} />;
31
31
  }
32
32
 
33
33
  const extension = filePath.split('.').pop()?.toLowerCase() || '';
@@ -1,16 +1,20 @@
1
1
  'use client';
2
2
 
3
- import { useState, useSyncExternalStore, useMemo } from 'react';
3
+ import { useSyncExternalStore, useMemo } from 'react';
4
4
  import Link from 'next/link';
5
- import { FileText, Table, Folder, FolderOpen, LayoutGrid, List, FilePlus } from 'lucide-react';
5
+ import { FileText, Table, Folder, FolderOpen, LayoutGrid, List, FilePlus, ScrollText, BookOpen } from 'lucide-react';
6
6
  import Breadcrumb from '@/components/Breadcrumb';
7
7
  import { encodePath, relativeTime } from '@/lib/utils';
8
8
  import { FileNode } from '@/lib/types';
9
+ import type { SpacePreview } from '@/lib/core/types';
9
10
  import { useLocale } from '@/lib/LocaleContext';
10
11
 
12
+ const SYSTEM_FILES = new Set(['INSTRUCTION.md', 'README.md']);
13
+
11
14
  interface DirViewProps {
12
15
  dirPath: string;
13
16
  entries: FileNode[];
17
+ spacePreview?: SpacePreview | null;
14
18
  }
15
19
 
16
20
  function FileIcon({ node }: { node: FileNode }) {
@@ -54,15 +58,94 @@ function useDirViewPref() {
54
58
  return [view, setView] as const;
55
59
  }
56
60
 
57
- export default function DirView({ dirPath, entries }: DirViewProps) {
61
+ // ─── Space Preview Cards ──────────────────────────────────────────────────────
62
+
63
+ function SpacePreviewCard({ icon, title, lines, viewAllHref, viewAllLabel }: {
64
+ icon: React.ReactNode;
65
+ title: string;
66
+ lines: string[];
67
+ viewAllHref: string;
68
+ viewAllLabel: string;
69
+ }) {
70
+ if (lines.length === 0) return null;
71
+ return (
72
+ <div className="bg-muted/30 border border-border/40 rounded-lg px-4 py-3">
73
+ <div className="flex items-center gap-1.5 mb-2">
74
+ {icon}
75
+ <span className="text-sm font-medium text-muted-foreground">{title}</span>
76
+ </div>
77
+ <div className="space-y-1">
78
+ {lines.map((line, i) => (
79
+ <p key={i} className="text-sm text-muted-foreground/80 leading-relaxed">
80
+ · {line}
81
+ </p>
82
+ ))}
83
+ </div>
84
+ <div className="flex justify-end mt-2">
85
+ <Link
86
+ href={viewAllHref}
87
+ className="text-xs hover:underline transition-colors"
88
+ style={{ color: 'var(--amber)' }}
89
+ >
90
+ {viewAllLabel}
91
+ </Link>
92
+ </div>
93
+ </div>
94
+ );
95
+ }
96
+
97
+ function SpacePreviewSection({ preview, dirPath }: {
98
+ preview: SpacePreview;
99
+ dirPath: string;
100
+ }) {
101
+ const { t } = useLocale();
102
+ const hasRules = preview.instructionLines.length > 0;
103
+ const hasAbout = preview.readmeLines.length > 0;
104
+ if (!hasRules && !hasAbout) return null;
105
+
106
+ return (
107
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-6">
108
+ {hasRules && (
109
+ <SpacePreviewCard
110
+ icon={<ScrollText size={14} className="text-muted-foreground shrink-0" />}
111
+ title={t.fileTree.rules}
112
+ lines={preview.instructionLines}
113
+ viewAllHref={`/view/${encodePath(`${dirPath}/INSTRUCTION.md`)}`}
114
+ viewAllLabel={t.fileTree.viewAll}
115
+ />
116
+ )}
117
+ {hasAbout && (
118
+ <SpacePreviewCard
119
+ icon={<BookOpen size={14} className="text-muted-foreground shrink-0" />}
120
+ title={t.fileTree.about}
121
+ lines={preview.readmeLines}
122
+ viewAllHref={`/view/${encodePath(`${dirPath}/README.md`)}`}
123
+ viewAllLabel={t.fileTree.viewAll}
124
+ />
125
+ )}
126
+ </div>
127
+ );
128
+ }
129
+
130
+ // ─── DirView ──────────────────────────────────────────────────────────────────
131
+
132
+ export default function DirView({ dirPath, entries, spacePreview }: DirViewProps) {
58
133
  const [view, setView] = useDirViewPref();
59
134
  const { t } = useLocale();
60
135
  const formatTime = (mtime: number) => relativeTime(mtime, t.home.relativeTime);
136
+
137
+ const visibleEntries = useMemo(() => {
138
+ if (spacePreview) {
139
+ return entries.filter(e => e.type !== 'file' || !SYSTEM_FILES.has(e.name));
140
+ }
141
+ return entries.filter(e => e.type !== 'file' || e.name !== 'README.md');
142
+ }, [entries, spacePreview]);
143
+
61
144
  const fileCounts = useMemo(() => {
62
145
  const map = new Map<string, number>();
63
- for (const e of entries) map.set(e.path, countFiles(e));
146
+ for (const e of visibleEntries) map.set(e.path, countFiles(e));
64
147
  return map;
65
- }, [entries]);
148
+ }, [visibleEntries]);
66
149
 
67
150
  return (
68
151
  <div className="flex flex-col min-h-screen">
@@ -72,7 +155,6 @@ export default function DirView({ dirPath, entries }: DirViewProps) {
72
155
  <div className="min-w-0 flex-1">
73
156
  <Breadcrumb filePath={dirPath} />
74
157
  </div>
75
- {/* New file + View toggle */}
76
158
  <div className="flex items-center gap-2 shrink-0">
77
159
  <Link
78
160
  href={`/view/${encodePath(dirPath ? `${dirPath}/Untitled.md` : 'Untitled.md')}`}
@@ -104,11 +186,16 @@ export default function DirView({ dirPath, entries }: DirViewProps) {
104
186
  {/* Content */}
105
187
  <div className="flex-1 px-4 md:px-6 py-6">
106
188
  <div className="max-w-[860px] mx-auto">
107
- {entries.length === 0 ? (
189
+ {/* Space preview cards */}
190
+ {spacePreview && (
191
+ <SpacePreviewSection preview={spacePreview} dirPath={dirPath} />
192
+ )}
193
+
194
+ {visibleEntries.length === 0 ? (
108
195
  <p className="text-muted-foreground text-sm">{t.dirView.emptyFolder}</p>
109
196
  ) : view === 'grid' ? (
110
197
  <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-3">
111
- {entries.map(entry => (
198
+ {visibleEntries.map(entry => (
112
199
  <Link
113
200
  key={entry.path}
114
201
  href={`/view/${encodePath(entry.path)}`}
@@ -137,7 +224,7 @@ export default function DirView({ dirPath, entries }: DirViewProps) {
137
224
  </div>
138
225
  ) : (
139
226
  <div className="flex flex-col divide-y divide-border border border-border rounded-xl overflow-hidden">
140
- {entries.map(entry => (
227
+ {visibleEntries.map(entry => (
141
228
  <Link
142
229
  key={entry.path}
143
230
  href={`/view/${encodePath(entry.path)}`}
@@ -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
  ) : (