@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.
- package/app/app/api/file/route.ts +43 -1
- package/app/app/view/[...path]/page.tsx +3 -3
- package/app/components/DirView.tsx +96 -9
- package/app/components/FileTree.tsx +245 -27
- package/app/components/ask/AskContent.tsx +297 -67
- package/app/hooks/useComposerVerticalResize.ts +74 -0
- package/app/lib/actions.ts +53 -25
- package/app/lib/core/create-space.ts +36 -0
- package/app/lib/core/fs-ops.ts +78 -1
- package/app/lib/core/index.ts +8 -0
- package/app/lib/core/types.ts +7 -0
- package/app/lib/fs.ts +78 -5
- package/app/lib/i18n-en.ts +15 -0
- package/app/lib/i18n-zh.ts +15 -0
- package/app/next-env.d.ts +1 -1
- package/app/next.config.ts +1 -0
- package/bin/cli.js +5 -1
- package/bin/lib/utils.js +6 -2
- package/mcp/README.md +4 -2
- package/mcp/package.json +1 -1
- package/mcp/src/index.ts +50 -0
- package/package.json +2 -1
- package/scripts/release.sh +7 -0
- package/scripts/verify-standalone.mjs +129 -0
- package/skills/mindos/SKILL.md +15 -1
- package/skills/mindos-zh/SKILL.md +15 -1
|
@@ -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([
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
146
|
+
for (const e of visibleEntries) map.set(e.path, countFiles(e));
|
|
64
147
|
return map;
|
|
65
|
-
}, [
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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 {
|
|
8
|
-
|
|
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
|
|
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
|
|
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
|
-
{
|
|
213
|
-
? <
|
|
214
|
-
:
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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=
|
|
245
|
-
style={{
|
|
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
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
) : (
|