@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.
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 +72 -72
  22. package/app/components/renderers/workflow-yaml/WorkflowRunner.tsx +175 -119
  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 +65 -13
  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
@@ -3,6 +3,7 @@
3
3
  import fs from 'fs';
4
4
  import path from 'path';
5
5
  import { createFile, deleteFile, deleteDirectory, convertToSpace, renameFile, renameSpace, getMindRoot, invalidateCache, collectAllFiles } from '@/lib/fs';
6
+ import { moveToTrash, restoreFromTrash, restoreAsCopy, permanentlyDelete, listTrash, emptyTrash, purgeExpired, type TrashMeta } from '@/lib/core/trash';
6
7
  import { createSpaceFilesystem, generateReadmeTemplate } from '@/lib/core/create-space';
7
8
  import { INSTRUCTION_TEMPLATE, cleanDirName } from '@/lib/core/space-scaffold';
8
9
  import { revalidatePath } from 'next/cache';
@@ -25,7 +26,8 @@ export async function createFileAction(dirPath: string, fileName: string): Promi
25
26
 
26
27
  export async function deleteFileAction(filePath: string): Promise<{ success: boolean; error?: string }> {
27
28
  try {
28
- deleteFile(filePath);
29
+ moveToTrash(getMindRoot(), filePath);
30
+ invalidateCache();
29
31
  revalidatePath('/', 'layout');
30
32
  return { success: true };
31
33
  } catch (err) {
@@ -59,7 +61,8 @@ export async function deleteFolderAction(
59
61
  dirPath: string,
60
62
  ): Promise<{ success: boolean; error?: string }> {
61
63
  try {
62
- deleteDirectory(dirPath);
64
+ moveToTrash(getMindRoot(), dirPath);
65
+ invalidateCache();
63
66
  revalidatePath('/', 'layout');
64
67
  return { success: true };
65
68
  } catch (err) {
@@ -84,7 +87,8 @@ export async function deleteSpaceAction(
84
87
  spacePath: string,
85
88
  ): Promise<{ success: boolean; error?: string }> {
86
89
  try {
87
- deleteDirectory(spacePath);
90
+ moveToTrash(getMindRoot(), spacePath);
91
+ invalidateCache();
88
92
  revalidatePath('/', 'layout');
89
93
  return { success: true };
90
94
  } catch (err) {
@@ -189,3 +193,53 @@ export async function cleanupExamplesAction(): Promise<{ success: boolean; delet
189
193
  return { success: false, deleted: 0, error: err instanceof Error ? err.message : 'Failed to cleanup' };
190
194
  }
191
195
  }
196
+
197
+ // ─── Trash Actions ────────────────────────────────────────────────────────────
198
+
199
+ export async function listTrashAction(): Promise<TrashMeta[]> {
200
+ // Purge expired items on each list call (lazy cleanup)
201
+ purgeExpired(getMindRoot());
202
+ return listTrash(getMindRoot());
203
+ }
204
+
205
+ export async function restoreFromTrashAction(
206
+ trashId: string,
207
+ mode: 'restore' | 'overwrite' | 'copy' = 'restore',
208
+ ): Promise<{ success: boolean; restoredPath?: string; error?: string; conflict?: boolean }> {
209
+ try {
210
+ const root = getMindRoot();
211
+ let result: { restoredPath: string };
212
+ if (mode === 'copy') {
213
+ result = restoreAsCopy(root, trashId);
214
+ } else {
215
+ result = restoreFromTrash(root, trashId, mode === 'overwrite');
216
+ }
217
+ invalidateCache();
218
+ revalidatePath('/', 'layout');
219
+ return { success: true, restoredPath: result.restoredPath };
220
+ } catch (err: unknown) {
221
+ const e = err as Error & { code?: string };
222
+ if (e.code === 'RESTORE_CONFLICT') {
223
+ return { success: false, error: e.message, conflict: true };
224
+ }
225
+ return { success: false, error: e.message ?? 'Failed to restore' };
226
+ }
227
+ }
228
+
229
+ export async function permanentlyDeleteAction(trashId: string): Promise<{ success: boolean; error?: string }> {
230
+ try {
231
+ permanentlyDelete(getMindRoot(), trashId);
232
+ return { success: true };
233
+ } catch (err) {
234
+ return { success: false, error: err instanceof Error ? err.message : 'Failed to delete' };
235
+ }
236
+ }
237
+
238
+ export async function emptyTrashAction(): Promise<{ success: boolean; count?: number; error?: string }> {
239
+ try {
240
+ const count = emptyTrash(getMindRoot());
241
+ return { success: true, count };
242
+ } catch (err) {
243
+ return { success: false, error: err instanceof Error ? err.message : 'Failed to empty trash' };
244
+ }
245
+ }
@@ -16,6 +16,20 @@
16
16
  */
17
17
  import type { Message, MessagePart, ToolCallPart, TextPart, ReasoningPart } from '@/lib/types';
18
18
 
19
+ /** Tools that modify files — trigger files-changed notification on completion */
20
+ const FILE_MUTATING_TOOLS = new Set([
21
+ 'write_file', 'create_file', 'batch_create_files',
22
+ 'update_section', 'insert_after_heading', 'delete_file',
23
+ 'rename_file', 'create_space',
24
+ ]);
25
+
26
+ /** Notify the app that files were changed by the AI agent */
27
+ function notifyFilesChanged() {
28
+ if (typeof window !== 'undefined') {
29
+ window.dispatchEvent(new Event('mindos:files-changed'));
30
+ }
31
+ }
32
+
19
33
  export async function consumeUIMessageStream(
20
34
  body: ReadableStream<Uint8Array>,
21
35
  onUpdate: (message: Message) => void,
@@ -158,6 +172,10 @@ export async function consumeUIMessageStream(
158
172
  tc.output = output ?? '';
159
173
  tc.state = (event.isError ? 'error' : 'done');
160
174
  changed = true;
175
+ // Notify when a file-modifying tool completes successfully
176
+ if (!event.isError && FILE_MUTATING_TOOLS.has(tc.toolName)) {
177
+ notifyFilesChanged();
178
+ }
161
179
  }
162
180
  break;
163
181
  }
@@ -11,6 +11,7 @@ import { readSkillContentByName, scanSkillDirs } from '@/lib/pi-integration/skil
11
11
  import { callMcporterTool, createMcporterAgentTools, listMcporterServers, listMcporterTools } from '@/lib/pi-integration/mcporter';
12
12
  import { a2aTools } from '@/lib/a2a/a2a-tools';
13
13
  import { acpTools } from '@/lib/acp/acp-tools';
14
+ import { buildLineDiff, collapseDiffContext } from '@/components/changes/line-diff';
14
15
 
15
16
  // Max chars per file to avoid token overflow (~100k chars ≈ ~25k tokens)
16
17
  const MAX_FILE_CHARS = 20_000;
@@ -39,6 +40,38 @@ function textResult(text: string): AgentToolResult<Record<string, never>> {
39
40
  return { content: [{ type: 'text', text }], details: {} as Record<string, never> };
40
41
  }
41
42
 
43
+ /** Build a compact diff summary for tool output. Max 30 diff lines to avoid bloating agent context. */
44
+ function buildDiffSummary(before: string, after: string): string {
45
+ if (before === after) return '';
46
+ // Skip diff for very large files — LCS is O(n*m), would block agent
47
+ const beforeLines = before.split('\n').length;
48
+ const afterLines = after.split('\n').length;
49
+ if (beforeLines > 2000 || afterLines > 2000) {
50
+ const added = Math.max(0, afterLines - beforeLines);
51
+ const removed = Math.max(0, beforeLines - afterLines);
52
+ return `(~+${added} ~−${removed}, ${afterLines} lines total)\n\n--- changes ---\n (diff skipped — file too large)`;
53
+ }
54
+ const raw = buildLineDiff(before, after);
55
+ const inserts = raw.filter(r => r.type === 'insert').length;
56
+ const deletes = raw.filter(r => r.type === 'delete').length;
57
+ const stats = `+${inserts} −${deletes}`;
58
+ const collapsed = collapseDiffContext(raw, 2);
59
+ const MAX_DIFF_LINES = 30;
60
+ const lines: string[] = [];
61
+ for (const row of collapsed) {
62
+ if (lines.length >= MAX_DIFF_LINES) { lines.push('... (diff truncated)'); break; }
63
+ if (row.type === 'gap') { lines.push(` ... ${row.count} lines unchanged ...`); continue; }
64
+ const prefix = row.type === 'insert' ? '+' : row.type === 'delete' ? '-' : ' ';
65
+ lines.push(`${prefix} ${row.text}`);
66
+ }
67
+ return `(${stats})\n\n--- changes ---\n${lines.join('\n')}`;
68
+ }
69
+
70
+ /** Safe read — returns empty string if file doesn't exist */
71
+ function safeReadContent(filePath: string): string {
72
+ try { return getFileContent(filePath); } catch { return ''; }
73
+ }
74
+
42
75
  /** Safe execute wrapper — catches all errors, returns error text (never throws) */
43
76
  function safeExecute<T>(
44
77
  fn: (toolCallId: string, params: T, signal?: AbortSignal) => Promise<AgentToolResult<any>>,
@@ -527,8 +560,10 @@ export const knowledgeBaseTools: AgentTool<any>[] = [
527
560
  description: 'Overwrite the entire content of an existing file. Use read_file first to see current content. Prefer update_section or insert_after_heading for partial edits.',
528
561
  parameters: WriteFileParams,
529
562
  execute: safeExecute(async (_id, params: Static<typeof WriteFileParams>) => {
563
+ const before = safeReadContent(params.path);
530
564
  saveFileContent(params.path, params.content);
531
- return textResult(`File written: ${params.path}`);
565
+ const diff = buildDiffSummary(before, params.content);
566
+ return textResult(`File written: ${params.path}${diff ? ' ' + diff : ''}`);
532
567
  }),
533
568
  },
534
569
 
@@ -538,8 +573,10 @@ export const knowledgeBaseTools: AgentTool<any>[] = [
538
573
  description: 'Create a new file. Only .md and .csv files are allowed. Parent directories are created automatically. Does NOT create Space scaffolding (INSTRUCTION.md/README.md). Use create_space to create a Space.',
539
574
  parameters: CreateFileParams,
540
575
  execute: safeExecute(async (_id, params: Static<typeof CreateFileParams>) => {
541
- createFile(params.path, params.content ?? '');
542
- return textResult(`File created: ${params.path}`);
576
+ const content = params.content ?? '';
577
+ createFile(params.path, content);
578
+ const lineCount = content.split('\n').length;
579
+ return textResult(`File created: ${params.path} (+${lineCount})\n\n--- changes ---\n${content.split('\n').slice(0, 30).map(l => '+ ' + l).join('\n')}${lineCount > 30 ? '\n... (truncated)' : ''}`);
543
580
  }),
544
581
  },
545
582
 
@@ -571,8 +608,11 @@ export const knowledgeBaseTools: AgentTool<any>[] = [
571
608
  description: 'Append text to the end of an existing file. A blank line separator is added automatically.',
572
609
  parameters: AppendParams,
573
610
  execute: safeExecute(async (_id, params: Static<typeof AppendParams>) => {
611
+ const before = safeReadContent(params.path);
574
612
  appendToFile(params.path, params.content);
575
- return textResult(`Content appended to: ${params.path}`);
613
+ const after = safeReadContent(params.path);
614
+ const diff = buildDiffSummary(before, after);
615
+ return textResult(`Content appended to: ${params.path}${diff ? ' ' + diff : ''}`);
576
616
  }),
577
617
  },
578
618
 
@@ -582,8 +622,11 @@ export const knowledgeBaseTools: AgentTool<any>[] = [
582
622
  description: 'Insert content right after a Markdown heading. Useful for adding items under a specific section. If heading matches fail, use edit_lines instead.',
583
623
  parameters: InsertHeadingParams,
584
624
  execute: safeExecute(async (_id, params: Static<typeof InsertHeadingParams>) => {
625
+ const before = safeReadContent(params.path);
585
626
  insertAfterHeading(params.path, params.heading, params.content);
586
- return textResult(`Content inserted after heading "${params.heading}" in ${params.path}`);
627
+ const after = safeReadContent(params.path);
628
+ const diff = buildDiffSummary(before, after);
629
+ return textResult(`Content inserted after heading "${params.heading}" in ${params.path}${diff ? ' ' + diff : ''}`);
587
630
  }),
588
631
  },
589
632
 
@@ -593,8 +636,11 @@ export const knowledgeBaseTools: AgentTool<any>[] = [
593
636
  description: 'Replace the content of a Markdown section identified by its heading. The section spans from the heading to the next heading of equal or higher level. If heading matches fail, use edit_lines instead.',
594
637
  parameters: UpdateSectionParams,
595
638
  execute: safeExecute(async (_id, params: Static<typeof UpdateSectionParams>) => {
639
+ const before = safeReadContent(params.path);
596
640
  updateSection(params.path, params.heading, params.content);
597
- return textResult(`Section "${params.heading}" updated in ${params.path}`);
641
+ const after = safeReadContent(params.path);
642
+ const diff = buildDiffSummary(before, after);
643
+ return textResult(`Section "${params.heading}" updated in ${params.path}${diff ? ' ' + diff : ''}`);
598
644
  }),
599
645
  },
600
646
 
@@ -607,12 +653,13 @@ export const knowledgeBaseTools: AgentTool<any>[] = [
607
653
  const { path: fp, start_line, end_line, content } = params;
608
654
  const start = Math.max(0, start_line - 1);
609
655
  const end = Math.max(0, end_line - 1);
610
-
656
+ const before = safeReadContent(fp);
611
657
  const mindRoot = getMindRoot();
612
- // Import the core function dynamically or it should be added to lib/fs.ts
613
658
  const { updateLines } = await import('@/lib/core');
614
659
  updateLines(mindRoot, fp, start, end, content.split('\n'));
615
- return textResult(`Lines ${start_line}-${end_line} replaced in ${fp}`);
660
+ const after = safeReadContent(fp);
661
+ const diff = buildDiffSummary(before, after);
662
+ return textResult(`Lines ${start_line}-${end_line} replaced in ${fp}${diff ? ' ' + diff : ''}`);
616
663
  }),
617
664
  },
618
665
 
@@ -0,0 +1,116 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { remark } from 'remark';
4
+ import remarkGfm from 'remark-gfm';
5
+ import remarkHtml from 'remark-html';
6
+
7
+ /** Convert wiki-links [[target]] → relative HTML links */
8
+ export function convertWikiLinks(content: string, _currentPath: string): string {
9
+ return content.replace(/\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g, (_match, target: string, display?: string) => {
10
+ const label = display ?? target;
11
+ const href = target.replace(/\s+/g, '%20');
12
+ return `<a href="${href}.html">${label}</a>`;
13
+ });
14
+ }
15
+
16
+ /** Convert markdown to a complete standalone HTML document */
17
+ export async function markdownToHTML(content: string, title: string, currentPath = ''): Promise<string> {
18
+ // Convert wiki-links first
19
+ const processed = convertWikiLinks(content, currentPath);
20
+
21
+ const result = await remark()
22
+ .use(remarkGfm)
23
+ .use(remarkHtml, { sanitize: false })
24
+ .process(processed);
25
+
26
+ const body = String(result);
27
+
28
+ return `<!DOCTYPE html>
29
+ <html lang="en">
30
+ <head>
31
+ <meta charset="UTF-8">
32
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
33
+ <title>${escapeHtml(title)}</title>
34
+ <style>
35
+ :root { color-scheme: light dark; }
36
+ body {
37
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
38
+ max-width: 720px;
39
+ margin: 2rem auto;
40
+ padding: 0 1.5rem;
41
+ line-height: 1.7;
42
+ color: #1a1a1a;
43
+ background: #fff;
44
+ }
45
+ @media (prefers-color-scheme: dark) {
46
+ body { color: #e0e0e0; background: #1a1a1a; }
47
+ a { color: #6eb5ff; }
48
+ code { background: #2a2a2a; }
49
+ pre { background: #2a2a2a; }
50
+ blockquote { border-color: #444; }
51
+ table, th, td { border-color: #444; }
52
+ }
53
+ h1 { font-size: 1.8rem; font-weight: 700; margin: 2rem 0 1rem; border-bottom: 1px solid #eee; padding-bottom: 0.3rem; }
54
+ h2 { font-size: 1.4rem; font-weight: 600; margin: 1.5rem 0 0.8rem; }
55
+ h3 { font-size: 1.15rem; font-weight: 600; margin: 1.2rem 0 0.5rem; }
56
+ a { color: #0066cc; text-decoration: none; }
57
+ a:hover { text-decoration: underline; }
58
+ code { font-family: 'SF Mono', Menlo, monospace; font-size: 0.875em; background: #f5f5f5; padding: 0.15em 0.4em; border-radius: 3px; }
59
+ pre { background: #f5f5f5; padding: 1rem; border-radius: 6px; overflow-x: auto; }
60
+ pre code { background: none; padding: 0; }
61
+ blockquote { margin: 1rem 0; padding: 0.5rem 1rem; border-left: 3px solid #ddd; color: #666; }
62
+ table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
63
+ th, td { border: 1px solid #ddd; padding: 0.5rem 0.75rem; text-align: left; }
64
+ th { background: #f8f8f8; font-weight: 600; }
65
+ img { max-width: 100%; height: auto; border-radius: 4px; }
66
+ hr { border: none; border-top: 1px solid #eee; margin: 2rem 0; }
67
+ ul, ol { padding-left: 1.5rem; }
68
+ li { margin: 0.25rem 0; }
69
+ .task-list-item { list-style: none; margin-left: -1.5rem; }
70
+ .task-list-item input { margin-right: 0.5rem; }
71
+ @media print {
72
+ body { max-width: 100%; margin: 0; padding: 1rem; }
73
+ a { color: inherit; text-decoration: underline; }
74
+ }
75
+ </style>
76
+ </head>
77
+ <body>
78
+ ${body}
79
+ <footer style="margin-top:3rem;padding-top:1rem;border-top:1px solid #eee;font-size:0.75rem;color:#999;">
80
+ Exported from MindOS
81
+ </footer>
82
+ </body>
83
+ </html>`;
84
+ }
85
+
86
+ function escapeHtml(str: string): string {
87
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
88
+ }
89
+
90
+ /** Collect all exportable files in a directory tree */
91
+ export function collectExportFiles(mindRoot: string, dirPath: string): { relativePath: string; content: string }[] {
92
+ const fullDir = path.join(mindRoot, dirPath);
93
+ if (!fs.existsSync(fullDir) || !fs.statSync(fullDir).isDirectory()) return [];
94
+
95
+ const results: { relativePath: string; content: string }[] = [];
96
+ const SKIP = new Set(['INSTRUCTION.md', '.DS_Store']);
97
+
98
+ function walk(dir: string, prefix: string) {
99
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
100
+ for (const entry of entries) {
101
+ if (entry.name.startsWith('.') || SKIP.has(entry.name)) continue;
102
+ const fullPath = path.join(dir, entry.name);
103
+ const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
104
+ if (entry.isDirectory()) {
105
+ walk(fullPath, relPath);
106
+ } else if (entry.name.endsWith('.md') || entry.name.endsWith('.csv')) {
107
+ try {
108
+ results.push({ relativePath: relPath, content: fs.readFileSync(fullPath, 'utf-8') });
109
+ } catch { /* skip unreadable */ }
110
+ }
111
+ }
112
+ }
113
+
114
+ walk(fullDir, '');
115
+ return results;
116
+ }
@@ -0,0 +1,241 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ // ─── Types ───────────────────────────────────────────────────────────────────
5
+
6
+ export interface TrashMeta {
7
+ id: string;
8
+ originalPath: string;
9
+ deletedAt: string;
10
+ expiresAt: string;
11
+ fileName: string;
12
+ isDirectory: boolean;
13
+ }
14
+
15
+ const TRASH_DIR = '.trash';
16
+ const META_DIR = '.trash-meta';
17
+ const EXPIRY_DAYS = 30;
18
+
19
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
20
+
21
+ function siblingDir(mindRoot: string, name: string): string {
22
+ return path.join(path.dirname(mindRoot), name);
23
+ }
24
+
25
+ function trashRoot(mindRoot: string): string {
26
+ return siblingDir(mindRoot, TRASH_DIR);
27
+ }
28
+
29
+ function metaRoot(mindRoot: string): string {
30
+ return siblingDir(mindRoot, META_DIR);
31
+ }
32
+
33
+ function ensureDirs(mindRoot: string): void {
34
+ fs.mkdirSync(trashRoot(mindRoot), { recursive: true });
35
+ fs.mkdirSync(metaRoot(mindRoot), { recursive: true });
36
+ }
37
+
38
+ /** Sanitize filename for use as trash ID — strip all unsafe characters */
39
+ function generateId(fileName: string): string {
40
+ const safe = fileName.replace(/[^a-zA-Z0-9._\-\u4e00-\u9fff]/g, '_');
41
+ return `${Date.now()}_${safe}`;
42
+ }
43
+
44
+ function writeMeta(mindRoot: string, meta: TrashMeta): void {
45
+ const metaPath = path.join(metaRoot(mindRoot), `${meta.id}.json`);
46
+ fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf-8');
47
+ }
48
+
49
+ function readMeta(mindRoot: string, id: string): TrashMeta | null {
50
+ const metaPath = path.join(metaRoot(mindRoot), `${id}.json`);
51
+ if (!fs.existsSync(metaPath)) return null;
52
+ try {
53
+ return JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ function deleteMeta(mindRoot: string, id: string): void {
60
+ const metaPath = path.join(metaRoot(mindRoot), `${id}.json`);
61
+ try { fs.unlinkSync(metaPath); } catch { /* already gone */ }
62
+ }
63
+
64
+ /**
65
+ * Move a file or directory safely, handling cross-filesystem (EXDEV) errors.
66
+ * Tries atomic rename first, falls back to copy+delete.
67
+ */
68
+ function safeMove(src: string, dest: string, isDir: boolean): void {
69
+ if (isDir) {
70
+ // Directories always use copy+delete (rename doesn't work for dirs across fs)
71
+ fs.cpSync(src, dest, { recursive: true });
72
+ fs.rmSync(src, { recursive: true, force: true });
73
+ } else {
74
+ try {
75
+ fs.renameSync(src, dest);
76
+ } catch (err: unknown) {
77
+ const e = err as NodeJS.ErrnoException;
78
+ if (e.code === 'EXDEV') {
79
+ // Cross-device: fallback to copy+delete
80
+ fs.copyFileSync(src, dest);
81
+ fs.unlinkSync(src);
82
+ } else {
83
+ throw err;
84
+ }
85
+ }
86
+ }
87
+ }
88
+
89
+ // ─── Core Operations ─────────────────────────────────────────────────────────
90
+
91
+ export function moveToTrash(mindRoot: string, filePath: string): TrashMeta {
92
+ ensureDirs(mindRoot);
93
+ const src = path.join(mindRoot, filePath);
94
+ if (!fs.existsSync(src)) throw new Error(`File not found: ${filePath}`);
95
+
96
+ const isDir = fs.statSync(src).isDirectory();
97
+ const fileName = path.basename(filePath);
98
+ const id = generateId(fileName);
99
+ const dest = path.join(trashRoot(mindRoot), id);
100
+
101
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
102
+ safeMove(src, dest, isDir);
103
+
104
+ const now = new Date();
105
+ const meta: TrashMeta = {
106
+ id,
107
+ originalPath: filePath,
108
+ deletedAt: now.toISOString(),
109
+ expiresAt: new Date(now.getTime() + EXPIRY_DAYS * 86400000).toISOString(),
110
+ fileName,
111
+ isDirectory: isDir,
112
+ };
113
+ writeMeta(mindRoot, meta);
114
+ return meta;
115
+ }
116
+
117
+ export function restoreFromTrash(mindRoot: string, trashId: string, overwrite = false): { restoredPath: string } {
118
+ const meta = readMeta(mindRoot, trashId);
119
+ if (!meta) throw new Error('Item not found in trash');
120
+
121
+ const trashPath = path.join(trashRoot(mindRoot), trashId);
122
+ if (!fs.existsSync(trashPath)) throw new Error('Trash file missing from disk');
123
+
124
+ const dest = path.join(mindRoot, meta.originalPath);
125
+
126
+ // Check for conflicts
127
+ if (fs.existsSync(dest) && !overwrite) {
128
+ throw Object.assign(new Error('Restore conflict: file exists at original location'), { code: 'RESTORE_CONFLICT' });
129
+ }
130
+
131
+ // Ensure parent directory exists
132
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
133
+
134
+ if (meta.isDirectory) {
135
+ if (overwrite && fs.existsSync(dest)) {
136
+ fs.rmSync(dest, { recursive: true, force: true });
137
+ }
138
+ } else {
139
+ if (overwrite && fs.existsSync(dest)) {
140
+ fs.unlinkSync(dest);
141
+ }
142
+ }
143
+
144
+ safeMove(trashPath, dest, meta.isDirectory);
145
+ deleteMeta(mindRoot, trashId);
146
+ return { restoredPath: meta.originalPath };
147
+ }
148
+
149
+ export function restoreAsCopy(mindRoot: string, trashId: string): { restoredPath: string } {
150
+ const meta = readMeta(mindRoot, trashId);
151
+ if (!meta) throw new Error('Item not found in trash');
152
+
153
+ const trashPath = path.join(trashRoot(mindRoot), trashId);
154
+ if (!fs.existsSync(trashPath)) throw new Error('Trash file missing from disk');
155
+
156
+ // Generate a unique copy name
157
+ const dir = path.dirname(meta.originalPath);
158
+ const ext = path.extname(meta.fileName);
159
+ const base = path.basename(meta.fileName, ext);
160
+ let copyPath = path.join(dir, `${base} (copy)${ext}`);
161
+ let counter = 2;
162
+ while (fs.existsSync(path.join(mindRoot, copyPath))) {
163
+ copyPath = path.join(dir, `${base} (copy ${counter})${ext}`);
164
+ counter++;
165
+ }
166
+
167
+ const dest = path.join(mindRoot, copyPath);
168
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
169
+ safeMove(trashPath, dest, meta.isDirectory);
170
+
171
+ deleteMeta(mindRoot, trashId);
172
+ return { restoredPath: copyPath };
173
+ }
174
+
175
+ export function permanentlyDelete(mindRoot: string, trashId: string): void {
176
+ const trashPath = path.join(trashRoot(mindRoot), trashId);
177
+ try {
178
+ if (fs.existsSync(trashPath)) {
179
+ const stat = fs.statSync(trashPath);
180
+ if (stat.isDirectory()) {
181
+ fs.rmSync(trashPath, { recursive: true, force: true });
182
+ } else {
183
+ fs.unlinkSync(trashPath);
184
+ }
185
+ }
186
+ } catch { /* file already gone */ }
187
+ deleteMeta(mindRoot, trashId);
188
+ }
189
+
190
+ export function listTrash(mindRoot: string): TrashMeta[] {
191
+ ensureDirs(mindRoot);
192
+ const metaDir = metaRoot(mindRoot);
193
+ let files: string[];
194
+ try {
195
+ files = fs.readdirSync(metaDir).filter(f => f.endsWith('.json'));
196
+ } catch {
197
+ return [];
198
+ }
199
+ const items: TrashMeta[] = [];
200
+
201
+ for (const file of files) {
202
+ try {
203
+ const meta = JSON.parse(fs.readFileSync(path.join(metaDir, file), 'utf-8')) as TrashMeta;
204
+ // Verify the trash file still exists on disk
205
+ const trashPath = path.join(trashRoot(mindRoot), meta.id);
206
+ if (fs.existsSync(trashPath)) {
207
+ items.push(meta);
208
+ } else {
209
+ // Clean up orphaned metadata
210
+ try { fs.unlinkSync(path.join(metaDir, file)); } catch { /* race ok */ }
211
+ }
212
+ } catch {
213
+ // Skip corrupt metadata files
214
+ }
215
+ }
216
+
217
+ // Sort by deletion time, newest first
218
+ items.sort((a, b) => new Date(b.deletedAt).getTime() - new Date(a.deletedAt).getTime());
219
+ return items;
220
+ }
221
+
222
+ export function emptyTrash(mindRoot: string): number {
223
+ const items = listTrash(mindRoot);
224
+ for (const item of items) {
225
+ permanentlyDelete(mindRoot, item.id);
226
+ }
227
+ return items.length;
228
+ }
229
+
230
+ export function purgeExpired(mindRoot: string): number {
231
+ const items = listTrash(mindRoot);
232
+ const now = Date.now();
233
+ let count = 0;
234
+ for (const item of items) {
235
+ if (new Date(item.expiresAt).getTime() <= now) {
236
+ permanentlyDelete(mindRoot, item.id);
237
+ count++;
238
+ }
239
+ }
240
+ return count;
241
+ }
package/app/lib/fs.ts CHANGED
@@ -601,6 +601,53 @@ export type { BacklinkEntry } from './core/types';
601
601
  export type { MindSpaceSummary } from './core';
602
602
  export type { ContentChangeEvent, ContentChangeInput, ContentChangeSummary, ContentChangeSource } from './core';
603
603
 
604
+ // ─── Public API: Trash (delegated to @mindos/core/trash) ────────────────────
605
+
606
+ import {
607
+ moveToTrash as coreMoveToTrash,
608
+ restoreFromTrash as coreRestoreFromTrash,
609
+ restoreAsCopy as coreRestoreAsCopy,
610
+ permanentlyDelete as corePermanentlyDelete,
611
+ listTrash as coreListTrash,
612
+ emptyTrash as coreEmptyTrash,
613
+ purgeExpired as corePurgeExpired,
614
+ } from './core/trash';
615
+ export type { TrashMeta } from './core/trash';
616
+
617
+ export function moveToTrashFile(filePath: string) {
618
+ const result = coreMoveToTrash(getMindRoot(), filePath);
619
+ invalidateCache();
620
+ return result;
621
+ }
622
+
623
+ export function restoreFromTrash(trashId: string, overwrite = false) {
624
+ const result = coreRestoreFromTrash(getMindRoot(), trashId, overwrite);
625
+ invalidateCache();
626
+ return result;
627
+ }
628
+
629
+ export function restoreAsCopy(trashId: string) {
630
+ const result = coreRestoreAsCopy(getMindRoot(), trashId);
631
+ invalidateCache();
632
+ return result;
633
+ }
634
+
635
+ export function permanentlyDeleteFromTrash(trashId: string) {
636
+ corePermanentlyDelete(getMindRoot(), trashId);
637
+ }
638
+
639
+ export function listTrash() {
640
+ return coreListTrash(getMindRoot());
641
+ }
642
+
643
+ export function emptyTrashAll() {
644
+ return coreEmptyTrash(getMindRoot());
645
+ }
646
+
647
+ export function purgeExpiredTrash() {
648
+ return corePurgeExpired(getMindRoot());
649
+ }
650
+
604
651
  export function findBacklinks(targetPath: string): BacklinkEntry[] {
605
652
  const { allFiles } = ensureCache();
606
653
  return coreFindBacklinks(getMindRoot(), targetPath, allFiles);