@geminilight/mindos 0.6.30 → 0.6.31

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 (52) hide show
  1. package/README_zh.md +10 -4
  2. package/app/app/api/ask/route.ts +12 -7
  3. package/app/app/api/export/route.ts +105 -0
  4. package/app/app/globals.css +2 -2
  5. package/app/app/trash/page.tsx +7 -0
  6. package/app/app/view/[...path]/ViewPageClient.tsx +234 -2
  7. package/app/components/ExportModal.tsx +220 -0
  8. package/app/components/FileTree.tsx +22 -2
  9. package/app/components/HomeContent.tsx +91 -20
  10. package/app/components/MarkdownView.tsx +45 -10
  11. package/app/components/Sidebar.tsx +10 -1
  12. package/app/components/TrashPageClient.tsx +263 -0
  13. package/app/components/ask/ToolCallBlock.tsx +102 -18
  14. package/app/components/changes/ChangesContentPage.tsx +58 -14
  15. package/app/components/explore/ExploreContent.tsx +4 -7
  16. package/app/components/explore/UseCaseCard.tsx +18 -1
  17. package/app/components/explore/use-cases.generated.ts +76 -0
  18. package/app/components/explore/use-cases.yaml +185 -0
  19. package/app/components/panels/DiscoverPanel.tsx +1 -1
  20. package/app/components/renderers/workflow-yaml/StepEditor.tsx +98 -91
  21. package/app/components/renderers/workflow-yaml/WorkflowEditor.tsx +82 -72
  22. package/app/components/renderers/workflow-yaml/WorkflowRunner.tsx +163 -120
  23. package/app/components/renderers/workflow-yaml/WorkflowYamlRenderer.tsx +61 -61
  24. package/app/components/renderers/workflow-yaml/execution.ts +64 -12
  25. package/app/components/renderers/workflow-yaml/selectors.tsx +64 -12
  26. package/app/components/settings/AiTab.tsx +191 -174
  27. package/app/components/settings/AppearanceTab.tsx +168 -77
  28. package/app/components/settings/KnowledgeTab.tsx +131 -136
  29. package/app/components/settings/McpTab.tsx +11 -11
  30. package/app/components/settings/Primitives.tsx +60 -0
  31. package/app/components/settings/SettingsContent.tsx +15 -8
  32. package/app/components/settings/SyncTab.tsx +12 -12
  33. package/app/components/settings/UninstallTab.tsx +8 -18
  34. package/app/components/settings/UpdateTab.tsx +82 -82
  35. package/app/components/settings/types.ts +17 -8
  36. package/app/lib/acp/session.ts +12 -3
  37. package/app/lib/actions.ts +57 -3
  38. package/app/lib/agent/stream-consumer.ts +18 -0
  39. package/app/lib/agent/tools.ts +56 -9
  40. package/app/lib/core/export.ts +116 -0
  41. package/app/lib/core/trash.ts +241 -0
  42. package/app/lib/fs.ts +47 -0
  43. package/app/lib/hooks/usePinnedFiles.ts +90 -0
  44. package/app/lib/i18n/generated/explore-i18n.generated.ts +138 -0
  45. package/app/lib/i18n/index.ts +3 -0
  46. package/app/lib/i18n/modules/knowledge.ts +120 -6
  47. package/app/lib/i18n/modules/onboarding.ts +2 -134
  48. package/app/lib/i18n/modules/settings.ts +12 -0
  49. package/app/package.json +8 -2
  50. package/app/scripts/generate-explore.ts +145 -0
  51. package/package.json +1 -1
  52. 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
- - **插件扩展**:多种内置渲染器插件——TODO Board、CSV Views、Wiki Graph、Timeline、Agent Inspector 等。
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 兼容(OpenClaw, Claude Code, Cursor 等),零配置接入。
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
- - [ ] Agent 审计面板(Agent Inspector):将 Agent 操作日志渲染为可筛选的时间线,审查每次工具调用的详情
190
+ - [ ] ACP / A2A Phase 2:深度多 Agent 协作,支持任务委派、共享上下文、工作流链式执行
191
+ - [ ] 经验编译器:从 Agent 交互中自动提取纠正和偏好,沉淀为可复用的 Skills/SOP
192
+ - [ ] 知识库健康度仪表盘:可视化认知复利指标——已积累规则数、Agent 复用次数、知识新鲜度
187
193
 
188
194
  </details>
189
195
 
@@ -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 (err) {
527
- if (err instanceof TypeError) {
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
- controller.close();
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
- controller.close();
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
- controller.close();
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
+ }
@@ -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;
@@ -0,0 +1,7 @@
1
+ import { listTrashAction } from '@/lib/actions';
2
+ import TrashPageClient from '@/components/TrashPageClient';
3
+
4
+ export default async function TrashPage() {
5
+ const items = await listTrashAction();
6
+ return <TrashPageClient initialItems={items} />;
7
+ }
@@ -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
  }