@geminilight/mindos 0.6.30 → 0.6.32
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/README_zh.md +10 -4
- package/app/app/api/ask/route.ts +12 -7
- package/app/app/api/export/route.ts +105 -0
- package/app/app/globals.css +2 -2
- package/app/app/trash/page.tsx +7 -0
- package/app/app/view/[...path]/ViewPageClient.tsx +234 -2
- package/app/components/ExportModal.tsx +220 -0
- package/app/components/FileTree.tsx +22 -2
- package/app/components/HomeContent.tsx +91 -20
- package/app/components/MarkdownView.tsx +45 -10
- package/app/components/Sidebar.tsx +10 -1
- package/app/components/TrashPageClient.tsx +263 -0
- package/app/components/ask/ToolCallBlock.tsx +102 -18
- package/app/components/changes/ChangesContentPage.tsx +58 -14
- package/app/components/explore/ExploreContent.tsx +4 -7
- package/app/components/explore/UseCaseCard.tsx +18 -1
- package/app/components/explore/use-cases.generated.ts +76 -0
- package/app/components/explore/use-cases.yaml +185 -0
- package/app/components/panels/DiscoverPanel.tsx +1 -1
- package/app/components/renderers/workflow-yaml/StepEditor.tsx +98 -91
- package/app/components/renderers/workflow-yaml/WorkflowEditor.tsx +72 -72
- package/app/components/renderers/workflow-yaml/WorkflowRunner.tsx +175 -119
- package/app/components/renderers/workflow-yaml/WorkflowYamlRenderer.tsx +61 -61
- package/app/components/renderers/workflow-yaml/execution.ts +64 -12
- package/app/components/renderers/workflow-yaml/selectors.tsx +65 -13
- package/app/components/settings/AiTab.tsx +191 -174
- package/app/components/settings/AppearanceTab.tsx +168 -77
- package/app/components/settings/KnowledgeTab.tsx +131 -136
- package/app/components/settings/McpTab.tsx +11 -11
- package/app/components/settings/Primitives.tsx +60 -0
- package/app/components/settings/SettingsContent.tsx +15 -8
- package/app/components/settings/SyncTab.tsx +12 -12
- package/app/components/settings/UninstallTab.tsx +8 -18
- package/app/components/settings/UpdateTab.tsx +82 -82
- package/app/components/settings/types.ts +17 -8
- package/app/lib/acp/session.ts +12 -3
- package/app/lib/actions.ts +57 -3
- package/app/lib/agent/stream-consumer.ts +18 -0
- package/app/lib/agent/tools.ts +56 -9
- package/app/lib/core/export.ts +116 -0
- package/app/lib/core/trash.ts +241 -0
- package/app/lib/fs.ts +47 -0
- package/app/lib/hooks/usePinnedFiles.ts +90 -0
- package/app/lib/i18n/generated/explore-i18n.generated.ts +138 -0
- package/app/lib/i18n/index.ts +3 -0
- package/app/lib/i18n/modules/knowledge.ts +120 -6
- package/app/lib/i18n/modules/onboarding.ts +2 -134
- package/app/lib/i18n/modules/settings.ts +12 -0
- package/app/package.json +8 -2
- package/app/scripts/generate-explore.ts +145 -0
- package/package.json +1 -1
- package/app/components/explore/use-cases.ts +0 -58
package/README_zh.md
CHANGED
|
@@ -162,11 +162,15 @@ npx skills add https://github.com/GeminiLight/MindOS --skill mindos-zh -g -y #
|
|
|
162
162
|
|
|
163
163
|
- **GUI 工作台**:浏览、编辑、搜索笔记,统一搜索 + AI 入口(`⌘K` / `⌘/`),专为人机共创设计。
|
|
164
164
|
- **内置 Agent 助手**:在上下文中与知识库对话,编辑无缝沉淀为可管理知识。
|
|
165
|
-
-
|
|
165
|
+
- **一键导入**:拖拽文件即可导入,Inline AI Organize 自动分析、分类、写入知识库,支持进度追踪和撤销。
|
|
166
|
+
- **新手引导**:首次使用的分步引导体验,帮助新用户快速搭建知识库并连接第一个 Agent。
|
|
167
|
+
- **插件扩展**:多种内置渲染器插件——TODO Board、CSV Views、Wiki Graph、Timeline、Workflow Editor、Agent Inspector 等。
|
|
166
168
|
|
|
167
169
|
**Agent 侧**
|
|
168
170
|
|
|
169
|
-
- **MCP Server + Skills**:stdio + HTTP 双传输,全阵容 Agent 兼容(
|
|
171
|
+
- **MCP Server + Skills**:stdio + HTTP 双传输,全阵容 Agent 兼容(Claude Code, Cursor, Gemini CLI 等),零配置接入。
|
|
172
|
+
- **ACP / A2A 协议**:Agent 间通信协议,支持 Agent 发现、任务委派与编排。Phase 1 已上线:Agent Card 发现 + JSON-RPC 消息通信。
|
|
173
|
+
- **Workflow 编排**:基于 YAML 的可视化工作流编辑器 + 步骤执行引擎,定义、编辑、运行多步 Agent 工作流。
|
|
170
174
|
- **结构化模板**:预置 Profile、Workflows、Configurations 等目录骨架,快速冷启动个人 Context。
|
|
171
175
|
- **笔记即指令**:日常笔记天然就是 Agent 可直接执行的高质量指令——无需额外格式转换,写下即可调度。
|
|
172
176
|
|
|
@@ -175,15 +179,17 @@ npx skills add https://github.com/GeminiLight/MindOS --skill mindos-zh -g -y #
|
|
|
175
179
|
- **安全防线**:Bearer Token 认证、路径沙箱、INSTRUCTION.md 写保护、原子写入。
|
|
176
180
|
- **知识图谱**:动态解析并可视化文件间的引用与依赖关系。
|
|
177
181
|
- **反向链接视图**:展示所有引用当前文件的反向链接,理解笔记在知识网络中的位置。
|
|
182
|
+
- **Agent 审计面板**:将 Agent 操作日志渲染为可筛选的时间线,审查每次工具调用的详情。
|
|
178
183
|
- **Git 时光机**:Git 自动同步(commit/push/pull),记录人类与 Agent 的每次编辑历史,一键回滚,跨设备同步。
|
|
179
184
|
- **桌面客户端**:原生 macOS/Windows/Linux 应用,系统托盘、开机自启、本地进程管理。
|
|
180
185
|
|
|
181
186
|
<details>
|
|
182
187
|
<summary><strong>即将到来</strong></summary>
|
|
183
188
|
|
|
184
|
-
- [ ] ACP(Agent Communication Protocol):连接外部 Agent(如 Claude Code、Cursor),让知识库成为多 Agent 协作的中枢
|
|
185
189
|
- [ ] RAG 深度集成:基于知识库内容的检索增强生成,让 AI 回答更精准、更有上下文
|
|
186
|
-
- [ ]
|
|
190
|
+
- [ ] ACP / A2A Phase 2:深度多 Agent 协作,支持任务委派、共享上下文、工作流链式执行
|
|
191
|
+
- [ ] 经验编译器:从 Agent 交互中自动提取纠正和偏好,沉淀为可复用的 Skills/SOP
|
|
192
|
+
- [ ] 知识库健康度仪表盘:可视化认知复利指标——已积累规则数、Agent 复用次数、知识新鲜度
|
|
187
193
|
|
|
188
194
|
</details>
|
|
189
195
|
|
package/app/app/api/ask/route.ts
CHANGED
|
@@ -520,15 +520,20 @@ export async function POST(req: NextRequest) {
|
|
|
520
520
|
const requestStartTime = Date.now();
|
|
521
521
|
const stream = new ReadableStream({
|
|
522
522
|
start(controller) {
|
|
523
|
+
let streamClosed = false;
|
|
523
524
|
function send(event: MindOSSSEvent) {
|
|
525
|
+
if (streamClosed) return;
|
|
524
526
|
try {
|
|
525
527
|
controller.enqueue(encoder.encode(`data:${JSON.stringify(event)}\n\n`));
|
|
526
|
-
} catch
|
|
527
|
-
|
|
528
|
-
console.error('[ask] SSE send failed (serialization):', (err as Error).message, 'event type:', (event as { type?: string }).type);
|
|
529
|
-
}
|
|
528
|
+
} catch {
|
|
529
|
+
streamClosed = true;
|
|
530
530
|
}
|
|
531
531
|
}
|
|
532
|
+
function safeClose() {
|
|
533
|
+
if (streamClosed) return;
|
|
534
|
+
streamClosed = true;
|
|
535
|
+
try { controller.close(); } catch { /* already closed */ }
|
|
536
|
+
}
|
|
532
537
|
|
|
533
538
|
let hasContent = false;
|
|
534
539
|
let lastModelError = '';
|
|
@@ -700,7 +705,7 @@ export async function POST(req: NextRequest) {
|
|
|
700
705
|
await closeSession(acpSessionId).catch(() => {});
|
|
701
706
|
}
|
|
702
707
|
}
|
|
703
|
-
|
|
708
|
+
safeClose();
|
|
704
709
|
} else {
|
|
705
710
|
// Route to MindOS agent (existing logic)
|
|
706
711
|
await session.prompt(lastUserContent, lastUserImages ? { images: lastUserImages } : undefined);
|
|
@@ -710,7 +715,7 @@ export async function POST(req: NextRequest) {
|
|
|
710
715
|
} else {
|
|
711
716
|
send({ type: 'done' });
|
|
712
717
|
}
|
|
713
|
-
|
|
718
|
+
safeClose();
|
|
714
719
|
}
|
|
715
720
|
};
|
|
716
721
|
|
|
@@ -718,7 +723,7 @@ export async function POST(req: NextRequest) {
|
|
|
718
723
|
metrics.recordRequest(Date.now() - requestStartTime);
|
|
719
724
|
metrics.recordError();
|
|
720
725
|
send({ type: 'error', message: err instanceof Error ? err.message : String(err) });
|
|
721
|
-
|
|
726
|
+
safeClose();
|
|
722
727
|
});
|
|
723
728
|
},
|
|
724
729
|
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import archiver from 'archiver';
|
|
4
|
+
import { Readable, PassThrough } from 'stream';
|
|
5
|
+
import { getMindRoot } from '@/lib/fs';
|
|
6
|
+
import { readFile } from '@/lib/core/fs-ops';
|
|
7
|
+
import { markdownToHTML, collectExportFiles } from '@/lib/core/export';
|
|
8
|
+
|
|
9
|
+
export async function GET(req: NextRequest) {
|
|
10
|
+
const { searchParams } = req.nextUrl;
|
|
11
|
+
const filePath = searchParams.get('path');
|
|
12
|
+
const format = searchParams.get('format') ?? 'md';
|
|
13
|
+
|
|
14
|
+
if (!filePath) {
|
|
15
|
+
return NextResponse.json({ error: 'Missing path parameter' }, { status: 400 });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Path traversal defense-in-depth
|
|
19
|
+
if (filePath.includes('..') || filePath.startsWith('/')) {
|
|
20
|
+
return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const mindRoot = getMindRoot();
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
// ── Single file export ──
|
|
27
|
+
if (format === 'md') {
|
|
28
|
+
const content = readFile(mindRoot, filePath);
|
|
29
|
+
const fileName = path.basename(filePath);
|
|
30
|
+
return new NextResponse(content, {
|
|
31
|
+
headers: {
|
|
32
|
+
'Content-Type': 'text/markdown; charset=utf-8',
|
|
33
|
+
'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (format === 'html') {
|
|
39
|
+
const content = readFile(mindRoot, filePath);
|
|
40
|
+
const title = path.basename(filePath, '.md');
|
|
41
|
+
const html = await markdownToHTML(content, title, filePath);
|
|
42
|
+
const fileName = path.basename(filePath, '.md') + '.html';
|
|
43
|
+
return new NextResponse(html, {
|
|
44
|
+
headers: {
|
|
45
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
46
|
+
'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Directory/Space ZIP export ──
|
|
52
|
+
if (format === 'zip' || format === 'zip-html') {
|
|
53
|
+
const files = collectExportFiles(mindRoot, filePath);
|
|
54
|
+
if (files.length === 0) {
|
|
55
|
+
return NextResponse.json({ error: 'No exportable files found' }, { status: 404 });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const spaceName = path.basename(filePath);
|
|
59
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
60
|
+
const zipName = `${spaceName}-${date}.zip`;
|
|
61
|
+
|
|
62
|
+
// Create archive
|
|
63
|
+
const archive = archiver('zip', { zlib: { level: 6 } });
|
|
64
|
+
const passThrough = new PassThrough();
|
|
65
|
+
archive.pipe(passThrough);
|
|
66
|
+
|
|
67
|
+
if (format === 'zip-html') {
|
|
68
|
+
// Convert each MD file to HTML
|
|
69
|
+
for (const file of files) {
|
|
70
|
+
if (file.relativePath.endsWith('.md')) {
|
|
71
|
+
const title = path.basename(file.relativePath, '.md');
|
|
72
|
+
const html = await markdownToHTML(file.content, title, file.relativePath);
|
|
73
|
+
const htmlPath = file.relativePath.replace(/\.md$/, '.html');
|
|
74
|
+
archive.append(html, { name: htmlPath });
|
|
75
|
+
} else {
|
|
76
|
+
archive.append(file.content, { name: file.relativePath });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
for (const file of files) {
|
|
81
|
+
archive.append(file.content, { name: file.relativePath });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Pipe archive errors to the passthrough stream
|
|
86
|
+
archive.on('error', (err) => passThrough.destroy(err));
|
|
87
|
+
void archive.finalize();
|
|
88
|
+
|
|
89
|
+
// Convert Node stream to Web ReadableStream
|
|
90
|
+
const readable = Readable.toWeb(passThrough) as ReadableStream;
|
|
91
|
+
|
|
92
|
+
return new NextResponse(readable, {
|
|
93
|
+
headers: {
|
|
94
|
+
'Content-Type': 'application/zip',
|
|
95
|
+
'Content-Disposition': `attachment; filename="${encodeURIComponent(zipName)}"`,
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return NextResponse.json({ error: `Unsupported format: ${format}` }, { status: 400 });
|
|
101
|
+
} catch (err) {
|
|
102
|
+
const message = err instanceof Error ? err.message : 'Export failed';
|
|
103
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
104
|
+
}
|
|
105
|
+
}
|
package/app/app/globals.css
CHANGED
|
@@ -166,10 +166,10 @@ body {
|
|
|
166
166
|
font-family: var(--prose-font-override, 'Lora', Georgia, serif);
|
|
167
167
|
color: var(--prose-body);
|
|
168
168
|
line-height: 1.85;
|
|
169
|
-
font-size: 0.95rem;
|
|
169
|
+
font-size: var(--prose-font-size-override, 0.95rem);
|
|
170
170
|
}
|
|
171
171
|
@media (min-width: 640px) {
|
|
172
|
-
.prose { font-size: 1rem; }
|
|
172
|
+
.prose { font-size: var(--prose-font-size-override, 1rem); }
|
|
173
173
|
}
|
|
174
174
|
.prose h1, .prose h2, .prose h3, .prose h4 {
|
|
175
175
|
font-family: 'IBM Plex Sans', sans-serif;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useTransition, useCallback, useEffect, useRef, useSyncExternalStore, useMemo, Suspense } from 'react';
|
|
4
4
|
import { useRouter } from 'next/navigation';
|
|
5
|
-
import { Edit3, Save, X, Loader2, LayoutTemplate, ArrowLeft, Share2, FileText, Code } from 'lucide-react';
|
|
5
|
+
import { Edit3, Save, X, Loader2, LayoutTemplate, ArrowLeft, Share2, FileText, Code, MoreHorizontal, Copy, Pencil, Trash2, Star, Download } from 'lucide-react';
|
|
6
6
|
import { lazy } from 'react';
|
|
7
7
|
import MarkdownView from '@/components/MarkdownView';
|
|
8
8
|
import JsonView from '@/components/JsonView';
|
|
@@ -17,6 +17,11 @@ import { resolveRenderer, isRendererEnabled } from '@/lib/renderers/registry';
|
|
|
17
17
|
import { encodePath } from '@/lib/utils';
|
|
18
18
|
import { useLocale } from '@/lib/LocaleContext';
|
|
19
19
|
import DirPicker from '@/components/DirPicker';
|
|
20
|
+
import { renameFileAction, deleteFileAction } from '@/lib/actions';
|
|
21
|
+
import { ConfirmDialog } from '@/components/agents/AgentsPrimitives';
|
|
22
|
+
import { buildLineDiff } from '@/components/changes/line-diff';
|
|
23
|
+
import { usePinnedFiles } from '@/lib/hooks/usePinnedFiles';
|
|
24
|
+
import ExportModal from '@/components/ExportModal';
|
|
20
25
|
|
|
21
26
|
interface ViewPageClientProps {
|
|
22
27
|
filePath: string;
|
|
@@ -42,6 +47,9 @@ export default function ViewPageClient({
|
|
|
42
47
|
createDraftAction,
|
|
43
48
|
}: ViewPageClientProps) {
|
|
44
49
|
const { t } = useLocale();
|
|
50
|
+
const { isPinned, togglePin } = usePinnedFiles();
|
|
51
|
+
const pinned = isPinned(filePath);
|
|
52
|
+
const [exportOpen, setExportOpen] = useState(false);
|
|
45
53
|
const hydrated = useSyncExternalStore(
|
|
46
54
|
() => () => {},
|
|
47
55
|
() => true,
|
|
@@ -55,18 +63,85 @@ export default function ViewPageClient({
|
|
|
55
63
|
const [editing, setEditing] = useState(initialEditing || content === '');
|
|
56
64
|
const [editContent, setEditContent] = useState(content);
|
|
57
65
|
const [savedContent, setSavedContent] = useState(content);
|
|
66
|
+
|
|
67
|
+
// Sync savedContent when server re-renders with new content (e.g. after router.refresh)
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!editing) {
|
|
70
|
+
setSavedContent(content);
|
|
71
|
+
}
|
|
72
|
+
}, [content, editing]);
|
|
58
73
|
const [isPending, startTransition] = useTransition();
|
|
59
74
|
const [saveError, setSaveError] = useState<string | null>(null);
|
|
60
75
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
|
61
76
|
const [mdViewMode, setMdViewMode] = useState<MdViewMode>('wysiwyg');
|
|
62
77
|
const [findOpen, setFindOpen] = useState(false);
|
|
63
78
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
79
|
+
const [moreOpen, setMoreOpen] = useState(false);
|
|
80
|
+
const moreRef = useRef<HTMLButtonElement>(null);
|
|
81
|
+
const moreMenuRef = useRef<HTMLDivElement>(null);
|
|
82
|
+
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
83
|
+
const [renaming, setRenaming] = useState(false);
|
|
84
|
+
const [renameValue, setRenameValue] = useState('');
|
|
85
|
+
const [, startRenameTransition] = useTransition();
|
|
64
86
|
|
|
65
87
|
const inferredName = filePath.split('/').pop() || 'Untitled.md';
|
|
66
88
|
const [showSaveAs, setShowSaveAs] = useState(isDraft);
|
|
67
89
|
const [saveDir, setSaveDir] = useState('');
|
|
68
90
|
const [saveName, setSaveName] = useState(inferredName);
|
|
69
91
|
|
|
92
|
+
// Close more menu on outside click
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (!moreOpen) return;
|
|
95
|
+
const handler = (e: MouseEvent) => {
|
|
96
|
+
if (
|
|
97
|
+
moreRef.current && !moreRef.current.contains(e.target as Node) &&
|
|
98
|
+
moreMenuRef.current && !moreMenuRef.current.contains(e.target as Node)
|
|
99
|
+
) setMoreOpen(false);
|
|
100
|
+
};
|
|
101
|
+
const keyHandler = (e: KeyboardEvent) => { if (e.key === 'Escape') setMoreOpen(false); };
|
|
102
|
+
document.addEventListener('mousedown', handler);
|
|
103
|
+
document.addEventListener('keydown', keyHandler);
|
|
104
|
+
return () => { document.removeEventListener('mousedown', handler); document.removeEventListener('keydown', keyHandler); };
|
|
105
|
+
}, [moreOpen]);
|
|
106
|
+
|
|
107
|
+
const handleCopyPath = useCallback(() => {
|
|
108
|
+
navigator.clipboard.writeText(filePath).catch(() => {});
|
|
109
|
+
setMoreOpen(false);
|
|
110
|
+
}, [filePath]);
|
|
111
|
+
|
|
112
|
+
const handleStartRename = useCallback(() => {
|
|
113
|
+
setMoreOpen(false);
|
|
114
|
+
const name = filePath.split('/').pop() ?? '';
|
|
115
|
+
setRenameValue(name);
|
|
116
|
+
setRenaming(true);
|
|
117
|
+
}, [filePath]);
|
|
118
|
+
|
|
119
|
+
const handleCommitRename = useCallback(() => {
|
|
120
|
+
const newName = renameValue.trim();
|
|
121
|
+
if (!newName || newName === filePath.split('/').pop()) { setRenaming(false); return; }
|
|
122
|
+
startRenameTransition(async () => {
|
|
123
|
+
const result = await renameFileAction(filePath, newName);
|
|
124
|
+
setRenaming(false);
|
|
125
|
+
if (result.success && result.newPath) {
|
|
126
|
+
router.push(`/view/${encodePath(result.newPath)}`);
|
|
127
|
+
router.refresh();
|
|
128
|
+
window.dispatchEvent(new Event('mindos:files-changed'));
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}, [renameValue, filePath, router]);
|
|
132
|
+
|
|
133
|
+
const handleConfirmDelete = useCallback(() => {
|
|
134
|
+
setShowDeleteConfirm(false);
|
|
135
|
+
startTransition(async () => {
|
|
136
|
+
const result = await deleteFileAction(filePath);
|
|
137
|
+
if (result.success) {
|
|
138
|
+
router.push('/');
|
|
139
|
+
router.refresh();
|
|
140
|
+
window.dispatchEvent(new Event('mindos:files-changed'));
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}, [filePath, router]);
|
|
144
|
+
|
|
70
145
|
// Keep first paint deterministic between server and client to avoid hydration mismatch.
|
|
71
146
|
const effectiveUseRaw = hydrated ? useRaw : false;
|
|
72
147
|
|
|
@@ -202,6 +277,65 @@ export default function ViewPageClient({
|
|
|
202
277
|
return () => window.removeEventListener('keydown', handler);
|
|
203
278
|
}, [editing, handleSave, handleEdit, handleCancel]);
|
|
204
279
|
|
|
280
|
+
// Auto-refresh when AI agent modifies files + compute changed lines for highlight
|
|
281
|
+
const [fileUpdated, setFileUpdated] = useState(false);
|
|
282
|
+
const [changedLines, setChangedLines] = useState<number[]>([]);
|
|
283
|
+
const prevContentRef = useRef(content);
|
|
284
|
+
const aiTriggeredRef = useRef(false);
|
|
285
|
+
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
286
|
+
const updatedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
287
|
+
|
|
288
|
+
// When content prop changes after an AI-triggered refresh, compute diff highlights
|
|
289
|
+
useEffect(() => {
|
|
290
|
+
if (!editing && aiTriggeredRef.current && content !== prevContentRef.current && prevContentRef.current !== '') {
|
|
291
|
+
aiTriggeredRef.current = false;
|
|
292
|
+
const diff = buildLineDiff(prevContentRef.current, content);
|
|
293
|
+
const lines: number[] = [];
|
|
294
|
+
let lineNum = 1;
|
|
295
|
+
for (const row of diff) {
|
|
296
|
+
if (row.type === 'insert') {
|
|
297
|
+
lines.push(lineNum);
|
|
298
|
+
lineNum++;
|
|
299
|
+
} else if (row.type === 'equal') {
|
|
300
|
+
lineNum++;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (lines.length > 0) {
|
|
304
|
+
setChangedLines(lines);
|
|
305
|
+
// Clear previous timer if any
|
|
306
|
+
if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
|
|
307
|
+
highlightTimerRef.current = setTimeout(() => setChangedLines([]), 6000);
|
|
308
|
+
// Auto-scroll to change banner
|
|
309
|
+
setTimeout(() => {
|
|
310
|
+
const el = document.querySelector('[data-highlight-line]');
|
|
311
|
+
if (el) el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
312
|
+
}, 100);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
prevContentRef.current = content;
|
|
316
|
+
}, [content, editing]);
|
|
317
|
+
|
|
318
|
+
useEffect(() => {
|
|
319
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
320
|
+
const handler = () => {
|
|
321
|
+
if (editing) return;
|
|
322
|
+
// Debounce rapid file changes (AI may write multiple files in sequence)
|
|
323
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
324
|
+
debounceTimer = setTimeout(() => {
|
|
325
|
+
aiTriggeredRef.current = true;
|
|
326
|
+
router.refresh();
|
|
327
|
+
setFileUpdated(true);
|
|
328
|
+
if (updatedTimerRef.current) clearTimeout(updatedTimerRef.current);
|
|
329
|
+
updatedTimerRef.current = setTimeout(() => setFileUpdated(false), 3000);
|
|
330
|
+
}, 300);
|
|
331
|
+
};
|
|
332
|
+
window.addEventListener('mindos:files-changed', handler);
|
|
333
|
+
return () => {
|
|
334
|
+
window.removeEventListener('mindos:files-changed', handler);
|
|
335
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
336
|
+
};
|
|
337
|
+
}, [editing, router]);
|
|
338
|
+
|
|
205
339
|
return (
|
|
206
340
|
<div className="flex flex-col min-h-screen">
|
|
207
341
|
{/* Top bar */}
|
|
@@ -219,6 +353,12 @@ export default function ViewPageClient({
|
|
|
219
353
|
</div>
|
|
220
354
|
|
|
221
355
|
<div className="flex items-center gap-1.5 md:gap-2 shrink-0">
|
|
356
|
+
{fileUpdated && !editing && (
|
|
357
|
+
<span className="text-xs flex items-center gap-1.5 text-[var(--amber)] animate-in fade-in-0 duration-200">
|
|
358
|
+
<span className="w-1.5 h-1.5 rounded-full bg-[var(--amber)]" />
|
|
359
|
+
<span className="hidden sm:inline">updated</span>
|
|
360
|
+
</span>
|
|
361
|
+
)}
|
|
222
362
|
{saveSuccess && (
|
|
223
363
|
<span className="text-xs flex items-center gap-1.5 font-display" style={{ color: 'var(--success)' }}>
|
|
224
364
|
<span className="w-1.5 h-1.5 rounded-full" style={{ background: 'var(--success)' }} />
|
|
@@ -297,6 +437,54 @@ export default function ViewPageClient({
|
|
|
297
437
|
</button>
|
|
298
438
|
</>
|
|
299
439
|
)}
|
|
440
|
+
|
|
441
|
+
{/* More menu (rename, copy path, delete) */}
|
|
442
|
+
{!isDraft && (
|
|
443
|
+
<div className="relative">
|
|
444
|
+
<button
|
|
445
|
+
type="button"
|
|
446
|
+
onClick={() => togglePin(filePath)}
|
|
447
|
+
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
448
|
+
title={pinned ? t.fileTree.removeFromFavorites : t.fileTree.pinToFavorites}
|
|
449
|
+
>
|
|
450
|
+
<Star size={16} className={pinned ? 'fill-[var(--amber)] text-[var(--amber)]' : ''} />
|
|
451
|
+
</button>
|
|
452
|
+
<button
|
|
453
|
+
type="button"
|
|
454
|
+
onClick={() => setExportOpen(true)}
|
|
455
|
+
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
456
|
+
title={t.fileTree.export}
|
|
457
|
+
>
|
|
458
|
+
<Download size={16} />
|
|
459
|
+
</button>
|
|
460
|
+
<button
|
|
461
|
+
ref={moreRef}
|
|
462
|
+
type="button"
|
|
463
|
+
onClick={() => setMoreOpen(v => !v)}
|
|
464
|
+
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
465
|
+
title="More"
|
|
466
|
+
>
|
|
467
|
+
<MoreHorizontal size={16} />
|
|
468
|
+
</button>
|
|
469
|
+
{moreOpen && (
|
|
470
|
+
<div
|
|
471
|
+
ref={moreMenuRef}
|
|
472
|
+
className="absolute right-0 top-full mt-1 z-50 min-w-[160px] rounded-lg border border-border bg-card shadow-lg py-1"
|
|
473
|
+
>
|
|
474
|
+
<button className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors text-left" onClick={handleCopyPath}>
|
|
475
|
+
<Copy size={14} className="shrink-0" /> Copy Path
|
|
476
|
+
</button>
|
|
477
|
+
<button className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors text-left" onClick={handleStartRename}>
|
|
478
|
+
<Pencil size={14} className="shrink-0" /> Rename
|
|
479
|
+
</button>
|
|
480
|
+
<div className="my-1 border-t border-border/50" />
|
|
481
|
+
<button className="w-full flex items-center gap-2 px-3 py-2 text-sm text-error hover:bg-error/10 transition-colors text-left" onClick={() => { setMoreOpen(false); setShowDeleteConfirm(true); }}>
|
|
482
|
+
<Trash2 size={14} className="shrink-0" /> Delete
|
|
483
|
+
</button>
|
|
484
|
+
</div>
|
|
485
|
+
)}
|
|
486
|
+
</div>
|
|
487
|
+
)}
|
|
300
488
|
</div>
|
|
301
489
|
</div>
|
|
302
490
|
</div>
|
|
@@ -375,7 +563,7 @@ export default function ViewPageClient({
|
|
|
375
563
|
<JsonView content={savedContent} />
|
|
376
564
|
) : (
|
|
377
565
|
<>
|
|
378
|
-
<MarkdownView content={savedContent} />
|
|
566
|
+
<MarkdownView content={savedContent} highlightLines={changedLines} onDismissHighlight={() => setChangedLines([])} />
|
|
379
567
|
<TableOfContents content={savedContent} />
|
|
380
568
|
</>
|
|
381
569
|
)}
|
|
@@ -383,6 +571,50 @@ export default function ViewPageClient({
|
|
|
383
571
|
</div>
|
|
384
572
|
)}
|
|
385
573
|
</div>
|
|
574
|
+
|
|
575
|
+
{/* Inline rename dialog */}
|
|
576
|
+
{renaming && (
|
|
577
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/60 backdrop-blur-sm">
|
|
578
|
+
<div className="bg-card border border-border rounded-lg shadow-lg p-4 w-80">
|
|
579
|
+
<h3 className="text-sm font-medium mb-2">Rename</h3>
|
|
580
|
+
<input
|
|
581
|
+
autoFocus
|
|
582
|
+
value={renameValue}
|
|
583
|
+
onChange={e => setRenameValue(e.target.value)}
|
|
584
|
+
onKeyDown={e => {
|
|
585
|
+
if (e.key === 'Enter') handleCommitRename();
|
|
586
|
+
if (e.key === 'Escape') setRenaming(false);
|
|
587
|
+
}}
|
|
588
|
+
className="w-full bg-muted border border-border rounded-md px-3 py-1.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
589
|
+
/>
|
|
590
|
+
<div className="flex justify-end gap-2 mt-3">
|
|
591
|
+
<button onClick={() => setRenaming(false)} className="px-3 py-1.5 rounded-md text-xs bg-muted text-muted-foreground hover:bg-accent transition-colors">Cancel</button>
|
|
592
|
+
<button onClick={handleCommitRename} className="px-3 py-1.5 rounded-md text-xs bg-[var(--amber)] text-[var(--amber-foreground)] transition-colors">Rename</button>
|
|
593
|
+
</div>
|
|
594
|
+
</div>
|
|
595
|
+
</div>
|
|
596
|
+
)}
|
|
597
|
+
|
|
598
|
+
{/* Delete confirm */}
|
|
599
|
+
<ConfirmDialog
|
|
600
|
+
open={showDeleteConfirm}
|
|
601
|
+
title="Delete"
|
|
602
|
+
message={`Delete "${filePath.split('/').pop()}"? This cannot be undone.`}
|
|
603
|
+
confirmLabel="Delete"
|
|
604
|
+
cancelLabel="Cancel"
|
|
605
|
+
variant="destructive"
|
|
606
|
+
onCancel={() => setShowDeleteConfirm(false)}
|
|
607
|
+
onConfirm={handleConfirmDelete}
|
|
608
|
+
/>
|
|
609
|
+
|
|
610
|
+
{/* Export modal */}
|
|
611
|
+
<ExportModal
|
|
612
|
+
open={exportOpen}
|
|
613
|
+
onClose={() => setExportOpen(false)}
|
|
614
|
+
filePath={filePath}
|
|
615
|
+
isDirectory={false}
|
|
616
|
+
fileName={filePath.split('/').pop() ?? filePath}
|
|
617
|
+
/>
|
|
386
618
|
</div>
|
|
387
619
|
);
|
|
388
620
|
}
|