synabun 1.3.0

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 (187) hide show
  1. package/CHANGELOG.md +665 -0
  2. package/LICENSE +191 -0
  3. package/README.md +853 -0
  4. package/hooks/claude-code/post-plan.mjs +391 -0
  5. package/hooks/claude-code/post-remember.mjs +239 -0
  6. package/hooks/claude-code/pre-compact.mjs +241 -0
  7. package/hooks/claude-code/pre-websearch.mjs +90 -0
  8. package/hooks/claude-code/prompt-submit.mjs +861 -0
  9. package/hooks/claude-code/session-start.mjs +494 -0
  10. package/hooks/claude-code/shared.mjs +368 -0
  11. package/hooks/claude-code/stop.mjs +646 -0
  12. package/hooks/claude-code/test-user-learning.mjs +531 -0
  13. package/mcp-server/package.json +38 -0
  14. package/mcp-server/run.mjs +10 -0
  15. package/mcp-server/src/config.ts +70 -0
  16. package/mcp-server/src/http.ts +93 -0
  17. package/mcp-server/src/index.ts +203 -0
  18. package/mcp-server/src/preload-http.ts +25 -0
  19. package/mcp-server/src/preload.ts +29 -0
  20. package/mcp-server/src/scripts/migrate-qdrant-to-sqlite.ts +506 -0
  21. package/mcp-server/src/services/categories.ts +429 -0
  22. package/mcp-server/src/services/discord.ts +702 -0
  23. package/mcp-server/src/services/file-checksums.ts +33 -0
  24. package/mcp-server/src/services/local-embeddings.ts +77 -0
  25. package/mcp-server/src/services/neural-interface.ts +344 -0
  26. package/mcp-server/src/services/sqlite.ts +823 -0
  27. package/mcp-server/src/tools/browser-advanced.ts +123 -0
  28. package/mcp-server/src/tools/browser-interact.ts +255 -0
  29. package/mcp-server/src/tools/browser-navigate.ts +77 -0
  30. package/mcp-server/src/tools/browser-observe.ts +1330 -0
  31. package/mcp-server/src/tools/browser.ts +107 -0
  32. package/mcp-server/src/tools/card-tools.ts +193 -0
  33. package/mcp-server/src/tools/card.ts +22 -0
  34. package/mcp-server/src/tools/category.ts +379 -0
  35. package/mcp-server/src/tools/discord-channel.ts +274 -0
  36. package/mcp-server/src/tools/discord-guild.ts +188 -0
  37. package/mcp-server/src/tools/discord-member.ts +158 -0
  38. package/mcp-server/src/tools/discord-message.ts +231 -0
  39. package/mcp-server/src/tools/discord-onboarding.ts +228 -0
  40. package/mcp-server/src/tools/discord-role.ts +209 -0
  41. package/mcp-server/src/tools/discord-thread.ts +110 -0
  42. package/mcp-server/src/tools/discord-webhook.ts +176 -0
  43. package/mcp-server/src/tools/discord.ts +40 -0
  44. package/mcp-server/src/tools/forget.ts +38 -0
  45. package/mcp-server/src/tools/git-tools.ts +141 -0
  46. package/mcp-server/src/tools/git.ts +13 -0
  47. package/mcp-server/src/tools/image-tools.ts +98 -0
  48. package/mcp-server/src/tools/image.ts +17 -0
  49. package/mcp-server/src/tools/leonardo-browser-tools.ts +281 -0
  50. package/mcp-server/src/tools/leonardo.ts +28 -0
  51. package/mcp-server/src/tools/loop.ts +348 -0
  52. package/mcp-server/src/tools/memories.ts +118 -0
  53. package/mcp-server/src/tools/recall.ts +323 -0
  54. package/mcp-server/src/tools/reflect.ts +145 -0
  55. package/mcp-server/src/tools/remember.ts +115 -0
  56. package/mcp-server/src/tools/response.ts +26 -0
  57. package/mcp-server/src/tools/restore.ts +34 -0
  58. package/mcp-server/src/tools/sync.ts +114 -0
  59. package/mcp-server/src/tools/tictactoe-tools.ts +98 -0
  60. package/mcp-server/src/tools/tictactoe.ts +13 -0
  61. package/mcp-server/src/tools/utils.ts +25 -0
  62. package/mcp-server/src/tools/whiteboard-tools.ts +260 -0
  63. package/mcp-server/src/tools/whiteboard.ts +22 -0
  64. package/mcp-server/src/tui.ts +940 -0
  65. package/mcp-server/src/types.ts +85 -0
  66. package/mcp-server/tsconfig.json +19 -0
  67. package/neural-interface/package.json +27 -0
  68. package/neural-interface/public/apple-touch-icon.png +0 -0
  69. package/neural-interface/public/category-logos/claude.svg +1 -0
  70. package/neural-interface/public/category-logos/gemini-color.svg +1 -0
  71. package/neural-interface/public/category-logos/openai.svg +1 -0
  72. package/neural-interface/public/category-logos/synabun.png +0 -0
  73. package/neural-interface/public/claude-chat.css +878 -0
  74. package/neural-interface/public/claude-chat.html +92 -0
  75. package/neural-interface/public/claude-chat.js +1168 -0
  76. package/neural-interface/public/favicon-16x16.png +0 -0
  77. package/neural-interface/public/favicon-32x32.png +0 -0
  78. package/neural-interface/public/gallery.svg +6 -0
  79. package/neural-interface/public/icon-192.png +0 -0
  80. package/neural-interface/public/icon-512.png +0 -0
  81. package/neural-interface/public/index-legacy.html +14677 -0
  82. package/neural-interface/public/index.html +212 -0
  83. package/neural-interface/public/index2d-legacy.html +8193 -0
  84. package/neural-interface/public/index2d.html +127 -0
  85. package/neural-interface/public/invite.html +351 -0
  86. package/neural-interface/public/leonardoai.svg +1 -0
  87. package/neural-interface/public/logo-synabun.png +0 -0
  88. package/neural-interface/public/logoHD.png +0 -0
  89. package/neural-interface/public/manifest.json +14 -0
  90. package/neural-interface/public/offline.html +299 -0
  91. package/neural-interface/public/onboarding.html +2335 -0
  92. package/neural-interface/public/openclaw-logo-text.png +0 -0
  93. package/neural-interface/public/pin-svgrepo-com.svg +25 -0
  94. package/neural-interface/public/sessionicon.svg +8 -0
  95. package/neural-interface/public/shared/ansi-parser.js +305 -0
  96. package/neural-interface/public/shared/api.js +1043 -0
  97. package/neural-interface/public/shared/colors.js +153 -0
  98. package/neural-interface/public/shared/constants.js +141 -0
  99. package/neural-interface/public/shared/html-shell.js +777 -0
  100. package/neural-interface/public/shared/html-term-renderer.js +1022 -0
  101. package/neural-interface/public/shared/i18n.js +147 -0
  102. package/neural-interface/public/shared/registry.js +82 -0
  103. package/neural-interface/public/shared/state.js +86 -0
  104. package/neural-interface/public/shared/storage.js +150 -0
  105. package/neural-interface/public/shared/styles.css +21212 -0
  106. package/neural-interface/public/shared/ui-automation-studio.js +3573 -0
  107. package/neural-interface/public/shared/ui-bookmarks.js +231 -0
  108. package/neural-interface/public/shared/ui-claude-panel.js +6333 -0
  109. package/neural-interface/public/shared/ui-command-runner.js +534 -0
  110. package/neural-interface/public/shared/ui-cost-widget.js +602 -0
  111. package/neural-interface/public/shared/ui-detail.js +1454 -0
  112. package/neural-interface/public/shared/ui-explorer.js +1356 -0
  113. package/neural-interface/public/shared/ui-file-explorer.js +2750 -0
  114. package/neural-interface/public/shared/ui-games.js +411 -0
  115. package/neural-interface/public/shared/ui-help.js +107 -0
  116. package/neural-interface/public/shared/ui-image-gallery.js +461 -0
  117. package/neural-interface/public/shared/ui-invite.js +519 -0
  118. package/neural-interface/public/shared/ui-keybinds.js +452 -0
  119. package/neural-interface/public/shared/ui-layouts.js +297 -0
  120. package/neural-interface/public/shared/ui-link.js +731 -0
  121. package/neural-interface/public/shared/ui-loading.js +255 -0
  122. package/neural-interface/public/shared/ui-menubar.js +499 -0
  123. package/neural-interface/public/shared/ui-multiselect.js +93 -0
  124. package/neural-interface/public/shared/ui-navbar.js +222 -0
  125. package/neural-interface/public/shared/ui-notifications.js +287 -0
  126. package/neural-interface/public/shared/ui-panels.js +309 -0
  127. package/neural-interface/public/shared/ui-resume.js +653 -0
  128. package/neural-interface/public/shared/ui-search.js +145 -0
  129. package/neural-interface/public/shared/ui-sessions.js +531 -0
  130. package/neural-interface/public/shared/ui-settings.js +5987 -0
  131. package/neural-interface/public/shared/ui-sidebar.js +1905 -0
  132. package/neural-interface/public/shared/ui-skills.js +2805 -0
  133. package/neural-interface/public/shared/ui-stats.js +38 -0
  134. package/neural-interface/public/shared/ui-sync.js +201 -0
  135. package/neural-interface/public/shared/ui-terminal.js +6385 -0
  136. package/neural-interface/public/shared/ui-tooltip.js +123 -0
  137. package/neural-interface/public/shared/ui-trash.js +439 -0
  138. package/neural-interface/public/shared/ui-tutorial-draw.js +875 -0
  139. package/neural-interface/public/shared/ui-tutorial-steps.js +2276 -0
  140. package/neural-interface/public/shared/ui-tutorial.js +1117 -0
  141. package/neural-interface/public/shared/ui-update.js +244 -0
  142. package/neural-interface/public/shared/ui-whiteboard.js +3262 -0
  143. package/neural-interface/public/shared/ui-workspaces.js +722 -0
  144. package/neural-interface/public/shared/utils.js +360 -0
  145. package/neural-interface/public/shared/vterm-buffer.js +1102 -0
  146. package/neural-interface/public/sw.js +52 -0
  147. package/neural-interface/public/synabun.png +0 -0
  148. package/neural-interface/public/toolbar-album.svg +8 -0
  149. package/neural-interface/public/toolbar-question.svg +6 -0
  150. package/neural-interface/public/toolbar-sessions.svg +6 -0
  151. package/neural-interface/public/variant/2d/background.js +113 -0
  152. package/neural-interface/public/variant/2d/context-menu.js +133 -0
  153. package/neural-interface/public/variant/2d/gfx.js +45 -0
  154. package/neural-interface/public/variant/2d/graph.js +1635 -0
  155. package/neural-interface/public/variant/2d/hulls.js +207 -0
  156. package/neural-interface/public/variant/2d/lasso.js +135 -0
  157. package/neural-interface/public/variant/2d/layout.js +594 -0
  158. package/neural-interface/public/variant/2d/main.js +521 -0
  159. package/neural-interface/public/variant/2d/minimap.js +312 -0
  160. package/neural-interface/public/variant/2d/settings-gfx.js +98 -0
  161. package/neural-interface/public/variant/3d/background.js +884 -0
  162. package/neural-interface/public/variant/3d/camera.js +661 -0
  163. package/neural-interface/public/variant/3d/gfx.js +116 -0
  164. package/neural-interface/public/variant/3d/graph.js +2399 -0
  165. package/neural-interface/public/variant/3d/main.js +500 -0
  166. package/neural-interface/public/variant/3d/settings-gfx.js +228 -0
  167. package/neural-interface/scripts/rebuild-pty.js +100 -0
  168. package/neural-interface/server.js +15000 -0
  169. package/neural-interface/templates/CLAUDE-template.md +211 -0
  170. package/package.json +53 -0
  171. package/postinstall.js +135 -0
  172. package/setup.js +260 -0
  173. package/skills/leonardo/SKILL.md +159 -0
  174. package/skills/leonardo/icon.svg +1 -0
  175. package/skills/leonardo/modules/browser-guide.md +378 -0
  176. package/skills/leonardo/modules/image-prompter.md +277 -0
  177. package/skills/leonardo/modules/model-advisor.md +100 -0
  178. package/skills/leonardo/modules/prompt-library.md +110 -0
  179. package/skills/leonardo/modules/style-guide.md +186 -0
  180. package/skills/leonardo/modules/video-prompter.md +311 -0
  181. package/skills/synabun/SKILL.md +185 -0
  182. package/skills/synabun/icon.png +0 -0
  183. package/skills/synabun/modules/audit.md +374 -0
  184. package/skills/synabun/modules/changelog.md +194 -0
  185. package/skills/synabun/modules/idea.md +144 -0
  186. package/skills/synabun/modules/memorize.md +188 -0
  187. package/skills/synabun/synabunicon.png +0 -0
@@ -0,0 +1,2750 @@
1
+ // ═══════════════════════════════════════════
2
+ // SynaBun Neural Interface — File Explorer
3
+ // ═══════════════════════════════════════════
4
+ //
5
+ // Docked sidebar that shows project files in a tree view.
6
+ // Sits adjacent to the Memory Explorer (to its right).
7
+
8
+ import { KEYS } from './constants.js';
9
+ import { registerAction } from './ui-keybinds.js';
10
+ import { storage } from './storage.js';
11
+ import { sendToPanel } from './ui-claude-panel.js';
12
+ import { on, emit } from './state.js';
13
+
14
+ const $ = (id) => document.getElementById(id);
15
+
16
+ // ─── Local state ────────────────────────
17
+ let _visible = false;
18
+ let _width = 260;
19
+ let _collapsed = new Set(); // set of dir paths that are collapsed
20
+ let _filterText = '';
21
+ let _sortMode = 'name'; // 'name' | 'size' | 'date'
22
+ let _cache = new Map(); // path → items array
23
+ let _rootLoaded = false;
24
+ let _projects = []; // [{ path, label }] from API
25
+ let _selectedProject = null; // { path, label } of current project
26
+ let _editorOpen = false;
27
+ let _editorFilePath = null;
28
+ let _editorOriginal = '';
29
+ let _editorDirty = false;
30
+ let _editorPreviewMode = false; // false = raw, true = rendered MD
31
+ let _editorIsMarkdown = false;
32
+ let _findOpen = false;
33
+ let _findMatches = []; // [{ start, end }]
34
+ let _findIndex = -1;
35
+ let _undoStack = []; // [{ value, selStart, selEnd }]
36
+ let _redoStack = [];
37
+ let _undoBurstOpen = false; // true while user is in a typing burst
38
+ let _folderColors = {}; // { "/path/to/folder": "#hex" }
39
+ let _customIcons = null; // { extensions: {}, filenames: {} } from server
40
+ let _planEditMode = false; // true when editing a plan file from Claude panel
41
+ let _planEditFilePath = null; // path of the plan file being edited
42
+ let _changelogEditMode = false; // true when editing a changelog draft from Claude panel
43
+ let _changelogEditFilePath = null; // path of the changelog draft being edited
44
+
45
+ const MIN_WIDTH = 240;
46
+ const MAX_WIDTH = 500;
47
+
48
+ // ─── SVG icons ────────────────────────
49
+ const FOLDER_SVG = '<svg viewBox="0 0 24 24"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
50
+ const FILE_SVG = '<svg viewBox="0 0 24 24"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z"/><polyline points="13 2 13 7 18 7"/></svg>';
51
+
52
+ // ─── File type icons ────────────────────────
53
+ // Each shape is stroke-based (fill:none via CSS). Color set via `color` on .fe-icon span.
54
+ const FI = {
55
+ code: '<svg viewBox="0 0 24 24"><path d="M9 3H7C5.3 3 4 4.3 4 6v4c0 1.1-.9 2-2 2 1.1 0 2 .9 2 2v4c0 1.7 1.3 3 3 3h2"/><path d="M15 3h2c1.7 0 3 1.3 3 3v4c0 1.1.9 2 2 2-1.1 0-2 .9-2 2v4c0 1.7-1.3 3-3 3h-2"/></svg>',
56
+ markup: '<svg viewBox="0 0 24 24"><polyline points="8 6 2 12 8 18"/><polyline points="16 6 22 12 16 18"/><line x1="14" y1="4" x2="10" y2="20"/></svg>',
57
+ style: '<svg viewBox="0 0 24 24"><line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/></svg>',
58
+ image: '<svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>',
59
+ terminal: '<svg viewBox="0 0 24 24"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="6 9 10 12 6 15"/><line x1="12" y1="15" x2="18" y2="15"/></svg>',
60
+ doc: '<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="12" y2="17"/></svg>',
61
+ gear: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M12 1v4m0 14v4M1 12h4m14 0h4M4.2 4.2l2.8 2.8m10 10l2.8 2.8M19.8 4.2l-2.8 2.8M7 17l-2.8 2.8"/></svg>',
62
+ lock: '<svg viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>',
63
+ git: '<svg viewBox="0 0 24 24"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/></svg>',
64
+ db: '<svg viewBox="0 0 24 24"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>',
65
+ file: FILE_SVG,
66
+ };
67
+
68
+ // Extension → { i: icon key, c: color hex }
69
+ const FI_MAP = {
70
+ js: { i:'code', c:'#e8d44d' }, mjs: { i:'code', c:'#e8d44d' }, cjs: { i:'code', c:'#e8d44d' },
71
+ ts: { i:'code', c:'#3178c6' }, mts: { i:'code', c:'#3178c6' }, cts: { i:'code', c:'#3178c6' },
72
+ jsx: { i:'markup', c:'#61dafb' }, tsx: { i:'markup', c:'#61dafb' },
73
+ html: { i:'markup', c:'#e44d26' }, htm: { i:'markup', c:'#e44d26' },
74
+ xml: { i:'markup', c:'#e44d26' }, vue: { i:'markup', c:'#42b883' }, svelte: { i:'markup', c:'#ff3e00' },
75
+ css: { i:'style', c:'#2196f3' }, scss: { i:'style', c:'#cd6799' }, sass: { i:'style', c:'#cd6799' }, less: { i:'style', c:'#6a9ccd' },
76
+ json: { i:'code', c:'#a8b065' }, jsonc: { i:'code', c:'#a8b065' }, json5: { i:'code', c:'#a8b065' },
77
+ py: { i:'code', c:'#3776ab' }, pyw: { i:'code', c:'#3776ab' },
78
+ rb: { i:'code', c:'#cc342d' }, rs: { i:'code', c:'#dea584' }, go: { i:'code', c:'#00add8' },
79
+ java: { i:'code', c:'#e76f00' }, kt: { i:'code', c:'#7f52ff' }, scala: { i:'code', c:'#dc322f' },
80
+ php: { i:'code', c:'#777bb3' }, c: { i:'code', c:'#8a8a8a' }, h: { i:'code', c:'#8a8a8a' },
81
+ cpp: { i:'code', c:'#00599c' }, cc: { i:'code', c:'#00599c' }, hpp: { i:'code', c:'#00599c' },
82
+ cs: { i:'code', c:'#178600' }, swift: { i:'code', c:'#f05138' },
83
+ lua: { i:'code', c:'#4e6faf' }, zig: { i:'code', c:'#f7a41d' }, r: { i:'code', c:'#276dc3' },
84
+ wasm: { i:'code', c:'#654ff0' },
85
+ sql: { i:'db', c:'#e38c00' }, csv: { i:'db', c:'#4eaa25' }, tsv: { i:'db', c:'#4eaa25' },
86
+ md: { i:'doc', c:'#519aba' }, mdx: { i:'doc', c:'#519aba' }, txt: { i:'doc', c:'#9aa7b0' },
87
+ yaml: { i:'gear', c:'#cb171e' }, yml: { i:'gear', c:'#cb171e' },
88
+ toml: { i:'gear', c:'#9aa7b0' }, ini: { i:'gear', c:'#9aa7b0' },
89
+ sh: { i:'terminal', c:'#4eaa25' }, bash: { i:'terminal', c:'#4eaa25' }, zsh: { i:'terminal', c:'#4eaa25' },
90
+ bat: { i:'terminal', c:'#c1f12e' }, cmd: { i:'terminal', c:'#c1f12e' }, ps1: { i:'terminal', c:'#4e73b5' },
91
+ png: { i:'image', c:'#a074c4' }, jpg: { i:'image', c:'#a074c4' }, jpeg: { i:'image', c:'#a074c4' },
92
+ gif: { i:'image', c:'#a074c4' }, webp: { i:'image', c:'#a074c4' }, ico: { i:'image', c:'#a074c4' },
93
+ bmp: { i:'image', c:'#a074c4' }, svg: { i:'image', c:'#ffb13b' },
94
+ lock: { i:'lock', c:'#9aa7b0' }, log: { i:'file', c:'#9aa7b0' }, map: { i:'file', c:'#9aa7b0' },
95
+ };
96
+
97
+ function getFileIcon(filename) {
98
+ const name = filename.toLowerCase();
99
+
100
+ // Custom filename override
101
+ if (_customIcons?.filenames?.[name]) {
102
+ return { img: `/custom-icons/${_customIcons.filenames[name].path}`, color: null };
103
+ }
104
+
105
+ // Special filenames (check custom ext overrides inline)
106
+ if (name === 'dockerfile' || name.startsWith('dockerfile.')) return { svg: FI.terminal, color: '#2496ed' };
107
+ if (name === 'makefile' || name === 'gnumakefile') return { svg: FI.terminal, color: '#6d8086' };
108
+ if (name.startsWith('.git')) return { svg: FI.git, color: '#f05032' };
109
+ if (name === '.env' || name.startsWith('.env.')) return { svg: FI.gear, color: '#ecd53f' };
110
+ if (/^(license|licence|notice)$/i.test(name)) return { svg: FI.doc, color: '#9aa7b0' };
111
+ if (/^(readme|contributing|changelog|security)/i.test(name)) return { svg: FI.doc, color: '#519aba' };
112
+
113
+ const dot = name.lastIndexOf('.');
114
+ const ext = dot >= 0 ? name.slice(dot + 1) : '';
115
+
116
+ // Custom extension override
117
+ if (ext && _customIcons?.extensions?.[ext]) {
118
+ return { img: `/custom-icons/${_customIcons.extensions[ext].path}`, color: null };
119
+ }
120
+
121
+ const info = FI_MAP[ext];
122
+ if (info) return { svg: FI[info.i], color: info.c };
123
+ return { svg: FI.file, color: null };
124
+ }
125
+
126
+ // Git status → badge text + class
127
+ const GIT_BADGE = {
128
+ modified: ['M', 'fe-git-modified'],
129
+ staged: ['S', 'fe-git-staged'],
130
+ added: ['A', 'fe-git-added'],
131
+ deleted: ['D', 'fe-git-deleted'],
132
+ untracked:['?', 'fe-git-untracked'],
133
+ conflict: ['!', 'fe-git-conflict'],
134
+ renamed: ['R', 'fe-git-renamed'],
135
+ mixed: ['M', 'fe-git-mixed'],
136
+ };
137
+
138
+
139
+ // ═══════════════════════════════════════════
140
+ // CSS VARIABLE — drives layout shift
141
+ // ═══════════════════════════════════════════
142
+
143
+ function applyFileExplorerWidth() {
144
+ const w = _visible ? `${_width}px` : '0px';
145
+ document.documentElement.style.setProperty('--file-explorer-width', w);
146
+ }
147
+
148
+ function calcDefaultWidth() {
149
+ const barLeft = document.querySelector('#title-bar .bar-left');
150
+ if (!barLeft) return _width;
151
+ const seps = barLeft.querySelectorAll('.bar-sep');
152
+ if (seps.length < 2) return _width;
153
+ const titleBar = document.getElementById('title-bar');
154
+ const tbPad = parseFloat(getComputedStyle(titleBar).paddingLeft) || 18;
155
+ const barLeftRect = barLeft.getBoundingClientRect();
156
+ const sepRect = seps[1].getBoundingClientRect();
157
+ const w = Math.round(sepRect.right - barLeftRect.left + tbPad);
158
+ return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, w));
159
+ }
160
+
161
+
162
+ // ═══════════════════════════════════════════
163
+ // PERSISTENCE
164
+ // ═══════════════════════════════════════════
165
+
166
+ function saveVisible() { try { storage.setItem(KEYS.FILE_EXPLORER_VISIBLE, String(_visible)); } catch {} }
167
+ function saveWidth() { try { storage.setItem(KEYS.FILE_EXPLORER_WIDTH, String(_width)); } catch {} }
168
+ function saveCollapsed() { try { storage.setItem(KEYS.FILE_EXPLORER_COLLAPSED, JSON.stringify([..._collapsed])); } catch {} }
169
+ function saveSort() { try { storage.setItem(KEYS.FILE_EXPLORER_SORT, _sortMode); } catch {} }
170
+ function saveFolderColors() { try { storage.setItem(KEYS.FILE_EXPLORER_FOLDER_COLORS, JSON.stringify(_folderColors)); } catch {} }
171
+
172
+ function loadPersistedState() {
173
+ const vis = storage.getItem(KEYS.FILE_EXPLORER_VISIBLE);
174
+ if (vis === 'true') _visible = true;
175
+
176
+ const w = parseInt(storage.getItem(KEYS.FILE_EXPLORER_WIDTH), 10);
177
+ if (w >= MIN_WIDTH && w <= MAX_WIDTH) _width = w;
178
+
179
+ try {
180
+ const col = JSON.parse(storage.getItem(KEYS.FILE_EXPLORER_COLLAPSED) || '[]');
181
+ if (Array.isArray(col)) _collapsed = new Set(col);
182
+ } catch {}
183
+
184
+ const sort = storage.getItem(KEYS.FILE_EXPLORER_SORT);
185
+ if (sort === 'name' || sort === 'size' || sort === 'date') _sortMode = sort;
186
+
187
+ try {
188
+ const fc = JSON.parse(storage.getItem(KEYS.FILE_EXPLORER_FOLDER_COLORS) || '{}');
189
+ if (fc && typeof fc === 'object') _folderColors = fc;
190
+ } catch {}
191
+ }
192
+
193
+
194
+ // ═══════════════════════════════════════════
195
+ // PROJECT SELECTOR
196
+ // ═══════════════════════════════════════════
197
+
198
+ async function fetchProjects() {
199
+ try {
200
+ const res = await fetch('/api/projects');
201
+ const data = await res.json();
202
+ if (data.ok && Array.isArray(data.projects)) {
203
+ _projects = data.projects;
204
+ }
205
+ } catch {}
206
+ return _projects;
207
+ }
208
+
209
+ function selectProject(proj) {
210
+ _selectedProject = proj;
211
+ try { storage.setItem(KEYS.FILE_EXPLORER_PROJECT, proj.path); } catch {}
212
+
213
+ // Update label
214
+ const label = $('fe-project-label');
215
+ if (label) label.textContent = proj.label;
216
+
217
+ // Close dropdown
218
+ closeProjectDropdown();
219
+
220
+ // Reset tree state
221
+ _cache.clear();
222
+ _collapsed.clear();
223
+ saveCollapsed();
224
+ _rootLoaded = false;
225
+ loadDirectory(_selectedProject.path).then(() => {
226
+ _rootLoaded = true;
227
+ buildFileTree();
228
+ });
229
+ }
230
+
231
+ function updateBranchLabel(branch) {
232
+ const btn = $('fe-branch-btn');
233
+ const nameEl = $('fe-branch-btn-name');
234
+ if (!btn) return;
235
+
236
+ if (branch) {
237
+ btn.style.display = '';
238
+ if (nameEl) nameEl.textContent = branch;
239
+ refreshGitStatus();
240
+ } else {
241
+ btn.style.display = 'none';
242
+ closeGitPopover();
243
+ }
244
+ }
245
+
246
+
247
+ // ═══════════════════════════════════════════
248
+ // GIT POPOVER
249
+ // ═══════════════════════════════════════════
250
+
251
+ let _gitChanges = [];
252
+ let _gitPopoverOpen = false;
253
+
254
+ function positionGitPopover() {
255
+ const btn = $('fe-branch-btn');
256
+ const pop = $('fe-git-popover');
257
+ if (!btn || !pop) return;
258
+
259
+ const r = btn.getBoundingClientRect();
260
+ const arrow = pop.querySelector('.fe-git-popover-arrow');
261
+ const popHeight = pop.offsetHeight || 200;
262
+ const navbarH = 44;
263
+
264
+ // Position to the right of the branch button, vertically centered on button
265
+ let left = r.right + 8;
266
+ let top = r.top + (r.height / 2) - 18; // align arrow with button center
267
+
268
+ // If it would overflow the right edge, flip to the left
269
+ let flipped = false;
270
+ const popWidth = pop.offsetWidth || 248;
271
+ if (left + popWidth > window.innerWidth) {
272
+ left = r.left - popWidth - 8;
273
+ flipped = true;
274
+ }
275
+
276
+ // Arrow positioning
277
+ if (arrow) {
278
+ arrow.style.left = flipped ? '' : '-5px';
279
+ arrow.style.right = flipped ? '-5px' : '';
280
+ arrow.style.borderLeft = flipped ? 'none' : '1px solid rgba(255,255,255,0.1)';
281
+ arrow.style.borderBottom = flipped ? 'none' : '1px solid rgba(255,255,255,0.1)';
282
+ arrow.style.borderRight = flipped ? '1px solid rgba(255,255,255,0.1)' : 'none';
283
+ arrow.style.borderTop = flipped ? '1px solid rgba(255,255,255,0.1)' : 'none';
284
+ }
285
+
286
+ // Clamp to viewport — respect navbar at top
287
+ if (top + popHeight > window.innerHeight - 8) top = window.innerHeight - popHeight - 8;
288
+ if (top < navbarH + 4) top = navbarH + 4;
289
+
290
+ pop.style.left = left + 'px';
291
+ pop.style.top = top + 'px';
292
+ }
293
+
294
+ function openGitPopover() {
295
+ const pop = $('fe-git-popover');
296
+ const btn = $('fe-branch-btn');
297
+ if (!pop) return;
298
+
299
+ resetCommitOutput();
300
+ _gitPopoverOpen = true;
301
+ btn?.classList.add('active');
302
+ positionGitPopover();
303
+
304
+ // Reset arrow for default (right-side) positioning
305
+ const arrow = pop.querySelector('.fe-git-popover-arrow');
306
+ if (arrow) {
307
+ arrow.style.left = '-5px';
308
+ arrow.style.right = '';
309
+ arrow.style.borderLeft = '1px solid rgba(255,255,255,0.1)';
310
+ arrow.style.borderBottom = '1px solid rgba(255,255,255,0.1)';
311
+ arrow.style.borderRight = 'none';
312
+ arrow.style.borderTop = 'none';
313
+ }
314
+
315
+ requestAnimationFrame(() => pop.classList.add('visible'));
316
+ loadGitBranches();
317
+ refreshGitStatus();
318
+ }
319
+
320
+ function closeGitPopover() {
321
+ const pop = $('fe-git-popover');
322
+ const btn = $('fe-branch-btn');
323
+ if (!pop) return;
324
+ resetCommitOutput();
325
+ _gitPopoverOpen = false;
326
+ pop.classList.remove('visible');
327
+ btn?.classList.remove('active');
328
+ }
329
+
330
+ function toggleGitPopover() {
331
+ if (_gitPopoverOpen) closeGitPopover();
332
+ else openGitPopover();
333
+ }
334
+
335
+ async function refreshGitStatus() {
336
+ if (!_selectedProject) return;
337
+ try {
338
+ const res = await fetch('/api/git/status?path=' + encodeURIComponent(_selectedProject.path));
339
+ const data = await res.json();
340
+ if (!data.ok || !data.isGit) return;
341
+
342
+ _gitChanges = data.changes || [];
343
+
344
+ // Update button badge
345
+ const badge = $('fe-branch-btn-badge');
346
+ if (badge) badge.textContent = _gitChanges.length > 0 ? _gitChanges.length : '';
347
+
348
+ // Update branch name on button
349
+ const nameEl = $('fe-branch-btn-name');
350
+ if (nameEl && data.branch) nameEl.textContent = data.branch;
351
+
352
+ // Update summary in popover
353
+ const summary = $('fe-git-summary');
354
+ if (summary) {
355
+ if (_gitChanges.length === 0) {
356
+ summary.innerHTML = 'Clean working tree';
357
+ } else {
358
+ const counts = { modified: 0, added: 0, deleted: 0, untracked: 0, other: 0 };
359
+ for (const c of _gitChanges) {
360
+ if (c.status === 'modified' || c.status === 'mixed') counts.modified++;
361
+ else if (c.status === 'added' || c.status === 'staged') counts.added++;
362
+ else if (c.status === 'deleted') counts.deleted++;
363
+ else if (c.status === 'untracked') counts.untracked++;
364
+ else counts.other++;
365
+ }
366
+ const parts = [];
367
+ if (counts.modified) parts.push('<span class="gs-m">' + counts.modified + ' modified</span>');
368
+ if (counts.added) parts.push('<span class="gs-a">' + counts.added + ' added</span>');
369
+ if (counts.deleted) parts.push('<span class="gs-d">' + counts.deleted + ' deleted</span>');
370
+ if (counts.untracked) parts.push('<span class="gs-u">' + counts.untracked + ' untracked</span>');
371
+ if (counts.other) parts.push(counts.other + ' other');
372
+ summary.innerHTML = parts.join(' &middot; ');
373
+ }
374
+ }
375
+ } catch {}
376
+ }
377
+
378
+ async function loadGitBranches() {
379
+ if (!_selectedProject) return;
380
+ const sel = $('fe-git-branch-select');
381
+ if (!sel) return;
382
+
383
+ try {
384
+ const res = await fetch('/api/terminal/branches?path=' + encodeURIComponent(_selectedProject.path));
385
+ const data = await res.json();
386
+ sel.innerHTML = '';
387
+ for (const b of (data.branches || [])) {
388
+ const opt = document.createElement('option');
389
+ opt.value = b;
390
+ opt.textContent = b;
391
+ if (b === data.current) opt.selected = true;
392
+ sel.appendChild(opt);
393
+ }
394
+ } catch {}
395
+ }
396
+
397
+ async function switchBranch(branch) {
398
+ if (!_selectedProject || !branch) return;
399
+ try {
400
+ const res = await fetch('/api/terminal/checkout', {
401
+ method: 'POST',
402
+ headers: { 'Content-Type': 'application/json' },
403
+ body: JSON.stringify({ path: _selectedProject.path, branch })
404
+ });
405
+ const data = await res.json();
406
+ if (data.ok) {
407
+ showToast('Switched to ' + (data.branch || branch));
408
+ closeGitPopover();
409
+ _cache.clear();
410
+ _rootLoaded = false;
411
+ loadDirectory(_selectedProject.path).then(() => {
412
+ _rootLoaded = true;
413
+ buildFileTree();
414
+ });
415
+ } else {
416
+ showToast(data.error || 'Checkout failed');
417
+ refreshGitStatus();
418
+ loadGitBranches();
419
+ }
420
+ } catch {
421
+ showToast('Checkout failed');
422
+ }
423
+ }
424
+
425
+ async function doGitCommit() {
426
+ const input = $('fe-git-commit-input');
427
+ const btn = $('fe-git-commit-btn');
428
+ if (!input || !_selectedProject) return;
429
+
430
+ const message = input.value.trim();
431
+ if (!message) { input.focus(); return; }
432
+
433
+ btn?.classList.add('loading');
434
+ try {
435
+ const res = await fetch('/api/git/commit', {
436
+ method: 'POST',
437
+ headers: { 'Content-Type': 'application/json' },
438
+ body: JSON.stringify({ path: _selectedProject.path, message })
439
+ });
440
+ const data = await res.json();
441
+ if (data.ok) {
442
+ input.value = '';
443
+ showCommitOutput(data.output, true);
444
+ _cache.clear();
445
+ _rootLoaded = false;
446
+ loadDirectory(_selectedProject.path).then(() => {
447
+ _rootLoaded = true;
448
+ buildFileTree();
449
+ });
450
+ refreshGitStatus();
451
+ } else {
452
+ showCommitOutput(data.error || 'Commit failed', false);
453
+ }
454
+ } catch {
455
+ showCommitOutput('Commit failed', false);
456
+ } finally {
457
+ btn?.classList.remove('loading');
458
+ }
459
+ }
460
+
461
+ function showCommitOutput(text, isSuccess) {
462
+ const controls = $('fe-git-controls');
463
+ const output = $('fe-git-output');
464
+ const pop = $('fe-git-popover');
465
+ if (!controls || !output || !pop) return;
466
+
467
+ controls.style.display = 'none';
468
+
469
+ const icon = isSuccess
470
+ ? '<svg viewBox="0 0 24 24" style="width:11px;height:11px;stroke:#66bb6a;fill:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:round;vertical-align:-1px"><polyline points="20 6 9 17 4 12"/></svg>'
471
+ : '<svg viewBox="0 0 24 24" style="width:11px;height:11px;stroke:#ef5350;fill:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:round;vertical-align:-1px"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
472
+
473
+ output.innerHTML =
474
+ `<div class="fe-git-output-header">${icon} ${isSuccess ? 'Committed' : 'Failed'}</div>` +
475
+ `<pre class="fe-git-output-pre${isSuccess ? '' : ' error'}">${escHtml(text)}</pre>`;
476
+ output.style.display = 'block';
477
+ pop.classList.add('fe-git-output-mode');
478
+ positionGitPopover();
479
+ }
480
+
481
+ function resetCommitOutput() {
482
+ const controls = $('fe-git-controls');
483
+ const output = $('fe-git-output');
484
+ const pop = $('fe-git-popover');
485
+ if (controls) controls.style.display = '';
486
+ if (output) { output.innerHTML = ''; output.style.display = 'none'; }
487
+ if (pop) pop.classList.remove('fe-git-output-mode');
488
+ }
489
+
490
+ async function generateCommitMessage() {
491
+ const input = $('fe-git-commit-input');
492
+ const genBtn = $('fe-git-gen-btn');
493
+ if (!input || !_selectedProject) return;
494
+
495
+ genBtn?.classList.add('loading');
496
+ try {
497
+ const res = await fetch('/api/git/generate-message', {
498
+ method: 'POST',
499
+ headers: { 'Content-Type': 'application/json' },
500
+ body: JSON.stringify({ path: _selectedProject.path })
501
+ });
502
+ const data = await res.json();
503
+ if (data.ok && data.message) {
504
+ input.value = data.message;
505
+ input.focus();
506
+ } else {
507
+ showToast(data.summary || 'No changes');
508
+ }
509
+ } catch {
510
+ showToast('Failed to generate message');
511
+ } finally {
512
+ genBtn?.classList.remove('loading');
513
+ }
514
+ }
515
+
516
+ function createGitPopover() {
517
+ // Build the popover in document.body to escape the file-explorer-panel's
518
+ // transform-based stacking context (which breaks position:fixed)
519
+ const pop = document.createElement('div');
520
+ pop.className = 'fe-git-popover';
521
+ pop.id = 'fe-git-popover';
522
+ pop.innerHTML = `
523
+ <div class="fe-git-popover-arrow"></div>
524
+ <div class="fe-git-controls" id="fe-git-controls">
525
+ <div class="fe-git-pop-section">
526
+ <label class="fe-git-pop-label">Branch</label>
527
+ <select class="fe-git-branch-select" id="fe-git-branch-select"></select>
528
+ </div>
529
+ <div class="fe-git-pop-sep"></div>
530
+ <div class="fe-git-pop-section">
531
+ <label class="fe-git-pop-label">Commit</label>
532
+ <div class="fe-git-commit-area">
533
+ <button class="fe-git-gen-btn" id="fe-git-gen-btn" data-tooltip="Generate message">
534
+ <svg viewBox="0 0 24 24"><path d="M12 2L9.5 8.5 3 10l5 4-1.5 7L12 17.5 17.5 21 16 14l5-4-6.5-1.5z"/></svg>
535
+ </button>
536
+ <input type="text" class="fe-git-commit-input" id="fe-git-commit-input" placeholder="Message..." autocomplete="off" spellcheck="false" maxlength="200">
537
+ <button class="fe-git-commit-btn" id="fe-git-commit-btn">
538
+ <svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
539
+ </button>
540
+ </div>
541
+ </div>
542
+ <div class="fe-git-pop-sep"></div>
543
+ <div class="fe-git-pop-section">
544
+ <div class="fe-git-summary" id="fe-git-summary"></div>
545
+ </div>
546
+ </div>
547
+ <div class="fe-git-output" id="fe-git-output"></div>`;
548
+ document.body.appendChild(pop);
549
+ return pop;
550
+ }
551
+
552
+ function initGitPanel() {
553
+ const branchBtn = $('fe-branch-btn');
554
+ const popover = createGitPopover();
555
+ const sel = $('fe-git-branch-select');
556
+ const commitInput = $('fe-git-commit-input');
557
+ const commitBtn = $('fe-git-commit-btn');
558
+
559
+ if (branchBtn) {
560
+ branchBtn.addEventListener('click', (e) => {
561
+ e.stopPropagation();
562
+ toggleGitPopover();
563
+ });
564
+ }
565
+
566
+ // Close on outside click
567
+ document.addEventListener('click', (e) => {
568
+ if (!_gitPopoverOpen) return;
569
+ if (popover && !popover.contains(e.target) && branchBtn && !branchBtn.contains(e.target)) {
570
+ closeGitPopover();
571
+ }
572
+ });
573
+
574
+ // Reposition on scroll/resize
575
+ window.addEventListener('resize', () => { if (_gitPopoverOpen) positionGitPopover(); });
576
+
577
+ if (sel) {
578
+ sel.addEventListener('change', () => switchBranch(sel.value));
579
+ }
580
+
581
+ const genBtn = $('fe-git-gen-btn');
582
+ if (genBtn) {
583
+ genBtn.addEventListener('click', generateCommitMessage);
584
+ }
585
+
586
+ if (commitBtn) {
587
+ commitBtn.addEventListener('click', doGitCommit);
588
+ }
589
+
590
+ if (commitInput) {
591
+ commitInput.addEventListener('keydown', (e) => {
592
+ if (e.key === 'Enter') { e.preventDefault(); doGitCommit(); }
593
+ e.stopPropagation();
594
+ });
595
+ commitInput.addEventListener('click', (e) => e.stopPropagation());
596
+ }
597
+ }
598
+
599
+ function closeProjectDropdown() {
600
+ const wrap = $('fe-project-selector');
601
+ if (wrap) wrap.classList.remove('open');
602
+ }
603
+
604
+ function toggleProjectDropdown() {
605
+ const wrap = $('fe-project-selector');
606
+ if (!wrap) return;
607
+ wrap.classList.toggle('open');
608
+ }
609
+
610
+ function buildProjectDropdown() {
611
+ const dd = $('fe-project-dropdown');
612
+ if (!dd) return;
613
+ dd.innerHTML = '';
614
+
615
+ const folderSvg = '<svg viewBox="0 0 24 24"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
616
+ const plusSvg = '<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>';
617
+
618
+ for (const p of _projects) {
619
+ const opt = document.createElement('button');
620
+ opt.className = 'fe-project-option' + (_selectedProject && _selectedProject.path === p.path ? ' active' : '');
621
+ opt.innerHTML = folderSvg + '<span class="fe-opt-label">' + escHtml(p.label) + '</span>';
622
+ opt.addEventListener('click', (e) => { e.stopPropagation(); selectProject(p); });
623
+ dd.appendChild(opt);
624
+ }
625
+
626
+ // Separator + Add Project
627
+ const sep = document.createElement('div');
628
+ sep.className = 'fe-project-sep';
629
+ dd.appendChild(sep);
630
+
631
+ const addBtn = document.createElement('button');
632
+ addBtn.className = 'fe-project-option fe-add-project';
633
+ addBtn.innerHTML = plusSvg + '<span class="fe-opt-label">Add project...</span>';
634
+ addBtn.addEventListener('click', (e) => { e.stopPropagation(); addProjectFlow(); });
635
+ dd.appendChild(addBtn);
636
+ }
637
+
638
+ function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
639
+
640
+ async function addProjectFlow() {
641
+ closeProjectDropdown();
642
+ try {
643
+ const res = await fetch('/api/browse-folder', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ description: 'Select project folder' }) });
644
+ const data = await res.json();
645
+ if (!data.path) return; // User cancelled
646
+
647
+ // Register as a project
648
+ const label = data.path.split(/[\\/]/).pop() || data.path;
649
+ await fetch('/api/claude-code/integrations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: 'project', projectPath: data.path, label }) });
650
+
651
+ // Refresh projects list and select the new one
652
+ await fetchProjects();
653
+ const newProj = _projects.find(p => p.path.replace(/\\/g, '/') === data.path.replace(/\\/g, '/'));
654
+ if (newProj) {
655
+ _selectedProject = newProj;
656
+ try { storage.setItem(KEYS.FILE_EXPLORER_PROJECT, newProj.path); } catch {}
657
+ }
658
+ initProjectSelector();
659
+ if (newProj) selectProject(newProj);
660
+ else { _cache.clear(); buildFileTree(); }
661
+ } catch (err) {
662
+ showToast('Failed to add project');
663
+ }
664
+ }
665
+
666
+ function initProjectSelector() {
667
+ const btn = $('fe-project-btn');
668
+ const wrap = $('fe-project-selector');
669
+ if (!btn || !wrap || _projects.length === 0) return;
670
+
671
+ // Restore persisted selection
672
+ const saved = storage.getItem(KEYS.FILE_EXPLORER_PROJECT);
673
+ const match = _projects.find(p => p.path === saved);
674
+ _selectedProject = match || _projects[0];
675
+
676
+ // Update label
677
+ const label = $('fe-project-label');
678
+ if (label) label.textContent = _selectedProject.label;
679
+
680
+ // Build dropdown options
681
+ buildProjectDropdown();
682
+
683
+ // Wire toggle (safe to reassign)
684
+ btn.onclick = (e) => { e.stopPropagation(); toggleProjectDropdown(); };
685
+
686
+ // Close on outside click
687
+ if (!wrap._outsideHandler) {
688
+ wrap._outsideHandler = (e) => {
689
+ if (!wrap.contains(e.target)) closeProjectDropdown();
690
+ };
691
+ document.addEventListener('click', wrap._outsideHandler);
692
+ }
693
+ }
694
+
695
+
696
+ // ═══════════════════════════════════════════
697
+ // API
698
+ // ═══════════════════════════════════════════
699
+
700
+ async function loadDirectory(dirPath) {
701
+ const params = new URLSearchParams();
702
+ // Use selected project root when no specific path given
703
+ const target = dirPath || (_selectedProject ? _selectedProject.path : null);
704
+ if (target) params.set('path', target);
705
+ const res = await fetch(`/api/project-files?${params}`);
706
+ const data = await res.json();
707
+ if (data.items) {
708
+ const key = data.path || '';
709
+ _cache.set(key, data);
710
+ // Update branch label when loading the project root
711
+ if (target && _selectedProject && target.replace(/\\/g, '/') === _selectedProject.path.replace(/\\/g, '/')) {
712
+ updateBranchLabel(data.branch || null);
713
+ }
714
+ }
715
+ return data;
716
+ }
717
+
718
+ async function searchFiles(term) {
719
+ const params = new URLSearchParams({ search: term });
720
+ // Scope search to selected project
721
+ if (_selectedProject) params.set('path', _selectedProject.path);
722
+ const res = await fetch(`/api/project-files?${params}`);
723
+ return res.json();
724
+ }
725
+
726
+
727
+ // ═══════════════════════════════════════════
728
+ // TREE RENDERING
729
+ // ═══════════════════════════════════════════
730
+
731
+ function sortItems(items) {
732
+ const sorted = [...items];
733
+ // Always dirs first
734
+ sorted.sort((a, b) => {
735
+ if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
736
+ if (_sortMode === 'size' && a.type === 'file' && b.type === 'file') {
737
+ return (b.size || 0) - (a.size || 0);
738
+ }
739
+ if (_sortMode === 'date' && a.mtime && b.mtime) {
740
+ return b.mtime - a.mtime;
741
+ }
742
+ return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
743
+ });
744
+ return sorted;
745
+ }
746
+
747
+ function createRow(item, depth) {
748
+ const node = document.createElement('div');
749
+ node.className = 'fe-node';
750
+ node.dataset.path = item.fullPath || item.name;
751
+ node.dataset.type = item.type;
752
+ node.dataset.expanded = 'false';
753
+
754
+ const row = document.createElement('div');
755
+ row.className = 'fe-row';
756
+ row.style.paddingLeft = `${depth * 16 + 8}px`;
757
+
758
+ if (item.type === 'dir') {
759
+ const chevron = document.createElement('span');
760
+ chevron.className = 'fe-chevron';
761
+ chevron.textContent = '\u25B6'; // ▶
762
+ row.appendChild(chevron);
763
+ } else {
764
+ // Spacer for alignment
765
+ const spacer = document.createElement('span');
766
+ spacer.style.width = '12px';
767
+ spacer.style.flexShrink = '0';
768
+ row.appendChild(spacer);
769
+ }
770
+
771
+ const icon = document.createElement('span');
772
+ if (item.type === 'dir') {
773
+ icon.className = 'fe-icon fe-icon--dir';
774
+ icon.innerHTML = FOLDER_SVG;
775
+ if (_folderColors[item.fullPath]) {
776
+ const svg = icon.querySelector('svg');
777
+ if (svg) { svg.style.fill = _folderColors[item.fullPath]; svg.style.stroke = _folderColors[item.fullPath]; }
778
+ }
779
+ } else {
780
+ const fi = getFileIcon(item.name);
781
+ icon.className = 'fe-icon fe-icon--file';
782
+ if (fi.img) {
783
+ icon.innerHTML = `<img src="${fi.img}" class="fe-icon-img">`;
784
+ } else {
785
+ icon.innerHTML = fi.svg;
786
+ if (fi.color) icon.style.color = fi.color;
787
+ }
788
+ }
789
+ row.appendChild(icon);
790
+
791
+ const name = document.createElement('span');
792
+ name.className = 'fe-name';
793
+ name.textContent = item.name;
794
+ row.appendChild(name);
795
+
796
+ // Git badge
797
+ if (item.git && GIT_BADGE[item.git]) {
798
+ const [text, cls] = GIT_BADGE[item.git];
799
+ const badge = document.createElement('span');
800
+ badge.className = `fe-git-badge ${cls}`;
801
+ badge.textContent = text;
802
+ row.appendChild(badge);
803
+ }
804
+
805
+ node.appendChild(row);
806
+
807
+ // Children container for dirs
808
+ if (item.type === 'dir') {
809
+ const children = document.createElement('div');
810
+ children.className = 'fe-children';
811
+ node.appendChild(children);
812
+ }
813
+
814
+ // Event handlers
815
+ if (item.type === 'dir') {
816
+ row.addEventListener('click', () => {
817
+ const isExpanded = node.dataset.expanded === 'true';
818
+ if (isExpanded) {
819
+ collapseDir(node, item.fullPath);
820
+ } else {
821
+ expandDir(node, item.fullPath, depth + 1);
822
+ }
823
+ });
824
+ } else {
825
+ // Left-click opens context menu for files too
826
+ row.addEventListener('click', (e) => {
827
+ e.preventDefault();
828
+ e.stopPropagation();
829
+ showContextMenu(e, item, node, row, icon, depth);
830
+ });
831
+ }
832
+
833
+ // Right-click context menu for both files and folders
834
+ row.addEventListener('contextmenu', (e) => {
835
+ e.preventDefault();
836
+ e.stopPropagation();
837
+ showContextMenu(e, item, node, row, icon, depth);
838
+ });
839
+
840
+ return node;
841
+ }
842
+
843
+ // ─── Delete undo/redo stacks ─────────────
844
+ let _deleteUndoStack = []; // [{ path, content, type }] — deleted items
845
+ let _deleteRedoStack = []; // [{ path, content, type }] — undone deletes
846
+
847
+ function updateDeleteButtons() {
848
+ const undoBtn = $('fe-undo-btn');
849
+ const redoBtn = $('fe-redo-btn');
850
+ if (undoBtn) undoBtn.classList.toggle('fe-disabled', _deleteUndoStack.length === 0);
851
+ if (redoBtn) redoBtn.classList.toggle('fe-disabled', _deleteRedoStack.length === 0);
852
+ }
853
+
854
+ async function deleteFileOrDir(filePath, itemName, type) {
855
+ // Read content before deleting (for undo) — only for files
856
+ let content = null;
857
+ if (type === 'file') {
858
+ try {
859
+ const r = await fetch(`/api/file-content?path=${encodeURIComponent(filePath)}`);
860
+ const d = await r.json();
861
+ if (d.ok) content = d.content;
862
+ } catch {}
863
+ }
864
+
865
+ const res = await fetch(`/api/file-content?path=${encodeURIComponent(filePath)}`, { method: 'DELETE' });
866
+ const data = await res.json();
867
+ if (!data.ok) {
868
+ showToast(data.error || 'Delete failed');
869
+ return false;
870
+ }
871
+
872
+ _deleteUndoStack.push({ path: filePath, content, type });
873
+ _deleteRedoStack = [];
874
+ updateDeleteButtons();
875
+ showToast(`Deleted ${itemName}`);
876
+
877
+ // Refresh tree
878
+ _cache.clear();
879
+ await loadDirectory();
880
+ buildFileTree();
881
+ return true;
882
+ }
883
+
884
+ async function undoDelete() {
885
+ if (_deleteUndoStack.length === 0) return;
886
+ const entry = _deleteUndoStack.pop();
887
+
888
+ if (entry.type === 'file' && entry.content !== null) {
889
+ // Recreate file via POST — need to ensure parent dir exists
890
+ try {
891
+ const res = await fetch('/api/file-content', {
892
+ method: 'POST',
893
+ headers: { 'Content-Type': 'application/json' },
894
+ body: JSON.stringify({ path: entry.path, content: entry.content, create: true }),
895
+ });
896
+ const data = await res.json();
897
+ if (!data.ok) { showToast('Undo failed'); _deleteUndoStack.push(entry); updateDeleteButtons(); return; }
898
+ } catch { showToast('Undo failed'); _deleteUndoStack.push(entry); updateDeleteButtons(); return; }
899
+ } else if (entry.type === 'dir') {
900
+ // Recreate empty dir
901
+ try {
902
+ const res = await fetch('/api/file-mkdir', {
903
+ method: 'POST',
904
+ headers: { 'Content-Type': 'application/json' },
905
+ body: JSON.stringify({ path: entry.path }),
906
+ });
907
+ const data = await res.json();
908
+ if (!data.ok) { showToast('Undo failed'); _deleteUndoStack.push(entry); updateDeleteButtons(); return; }
909
+ } catch { showToast('Undo failed'); _deleteUndoStack.push(entry); updateDeleteButtons(); return; }
910
+ } else {
911
+ showToast('Cannot undo — file content was not saved');
912
+ _deleteUndoStack.push(entry);
913
+ updateDeleteButtons();
914
+ return;
915
+ }
916
+
917
+ _deleteRedoStack.push(entry);
918
+ updateDeleteButtons();
919
+ showToast(`Restored ${entry.path.split('/').pop()}`);
920
+ _cache.clear();
921
+ await loadDirectory();
922
+ buildFileTree();
923
+ }
924
+
925
+ async function redoDelete() {
926
+ if (_deleteRedoStack.length === 0) return;
927
+ const entry = _deleteRedoStack.pop();
928
+
929
+ const res = await fetch(`/api/file-content?path=${encodeURIComponent(entry.path)}`, { method: 'DELETE' });
930
+ const data = await res.json();
931
+ if (!data.ok) { showToast('Redo failed'); _deleteRedoStack.push(entry); updateDeleteButtons(); return; }
932
+
933
+ _deleteUndoStack.push(entry);
934
+ updateDeleteButtons();
935
+ showToast(`Deleted ${entry.path.split('/').pop()}`);
936
+ _cache.clear();
937
+ await loadDirectory();
938
+ buildFileTree();
939
+ }
940
+
941
+ // ─── Confirmation modal ──────────────────
942
+ function showDeleteConfirm(itemName, itemType, onConfirm) {
943
+ // Remove any existing modal
944
+ const existing = document.getElementById('fe-delete-modal');
945
+ if (existing) existing.remove();
946
+
947
+ const overlay = document.createElement('div');
948
+ overlay.id = 'fe-delete-modal';
949
+ overlay.className = 'fe-modal-overlay';
950
+
951
+ const modal = document.createElement('div');
952
+ modal.className = 'fe-modal';
953
+
954
+ const typeLabel = itemType === 'dir' ? 'folder' : 'file';
955
+ modal.innerHTML = `
956
+ <div class="fe-modal-title">Delete ${typeLabel}</div>
957
+ <div class="fe-modal-body">Are you sure you want to delete <strong>${itemName}</strong>?${itemType === 'dir' ? '<br><span style="color:rgba(255,255,255,0.35);font-size:11px">Only empty folders can be deleted.</span>' : ''}</div>
958
+ <div class="fe-modal-actions">
959
+ <button class="fe-modal-btn fe-modal-cancel">Cancel</button>
960
+ <button class="fe-modal-btn fe-modal-confirm">Delete</button>
961
+ </div>
962
+ `;
963
+
964
+ overlay.appendChild(modal);
965
+ document.body.appendChild(overlay);
966
+
967
+ // Animate in
968
+ requestAnimationFrame(() => overlay.classList.add('fe-modal-overlay--in'));
969
+
970
+ const dismiss = () => {
971
+ overlay.classList.remove('fe-modal-overlay--in');
972
+ setTimeout(() => overlay.remove(), 150);
973
+ };
974
+
975
+ modal.querySelector('.fe-modal-cancel').addEventListener('click', dismiss);
976
+ modal.querySelector('.fe-modal-confirm').addEventListener('click', () => {
977
+ dismiss();
978
+ onConfirm();
979
+ });
980
+ overlay.addEventListener('click', (ev) => { if (ev.target === overlay) dismiss(); });
981
+ // Esc to cancel
982
+ const onKey = (ev) => { if (ev.key === 'Escape') { dismiss(); document.removeEventListener('keydown', onKey); } };
983
+ document.addEventListener('keydown', onKey);
984
+ }
985
+
986
+ // ─── Context menu ────────────────────────
987
+ let _activeCtxMenu = null;
988
+ let _activeColorPalette = null;
989
+
990
+ const FOLDER_COLORS = [
991
+ { hex: '#ffb74d', label: 'Amber' },
992
+ { hex: '#64b5f6', label: 'Blue' },
993
+ { hex: '#81c784', label: 'Green' },
994
+ { hex: '#e57373', label: 'Red' },
995
+ { hex: '#ba68c8', label: 'Purple' },
996
+ { hex: '#4dd0e1', label: 'Cyan' },
997
+ { hex: '#fff176', label: 'Yellow' },
998
+ { hex: '#a1887f', label: 'Brown' },
999
+ { hex: '#90a4ae', label: 'Grey' },
1000
+ ];
1001
+
1002
+ function dismissCtxMenu() {
1003
+ if (_activeCtxMenu) {
1004
+ if (_activeCtxMenu._row) _activeCtxMenu._row.classList.remove('fe-row--ctx-active');
1005
+ _activeCtxMenu.classList.remove('fe-ctx-flyout--in');
1006
+ _activeCtxMenu.classList.add('fe-ctx-flyout--out');
1007
+ const el = _activeCtxMenu;
1008
+ setTimeout(() => el.remove(), 150);
1009
+ _activeCtxMenu = null;
1010
+ }
1011
+ }
1012
+
1013
+ function dismissColorPalette() {
1014
+ if (_activeColorPalette) { _activeColorPalette.remove(); _activeColorPalette = null; }
1015
+ }
1016
+
1017
+ function setFolderColor(folderPath, color, iconEl, dotEl) {
1018
+ if (color) {
1019
+ _folderColors[folderPath] = color;
1020
+ } else {
1021
+ delete _folderColors[folderPath];
1022
+ }
1023
+ saveFolderColors();
1024
+
1025
+ // Update icon in DOM
1026
+ if (iconEl) {
1027
+ const svg = iconEl.querySelector('svg');
1028
+ if (svg) {
1029
+ svg.style.fill = color || '';
1030
+ svg.style.stroke = color || '';
1031
+ }
1032
+ }
1033
+ // Update color dot
1034
+ if (dotEl) {
1035
+ dotEl.style.color = color || '#ffb74d';
1036
+ }
1037
+ }
1038
+
1039
+ function showColorPalette(e, folderPath, iconEl, dotEl) {
1040
+ dismissColorPalette();
1041
+ dismissCtxMenu();
1042
+
1043
+ const palette = document.createElement('div');
1044
+ palette.className = 'fe-color-palette';
1045
+
1046
+ const currentColor = _folderColors[folderPath] || '#ffb74d';
1047
+
1048
+ for (const c of FOLDER_COLORS) {
1049
+ const swatch = document.createElement('button');
1050
+ swatch.className = 'fe-color-swatch' + (c.hex === currentColor ? ' fe-color-swatch--active' : '');
1051
+ swatch.style.background = c.hex;
1052
+ swatch.title = c.label;
1053
+ swatch.addEventListener('click', (ev) => {
1054
+ ev.stopPropagation();
1055
+ setFolderColor(folderPath, c.hex === '#ffb74d' ? null : c.hex, iconEl, dotEl);
1056
+ dismissColorPalette();
1057
+ });
1058
+ palette.appendChild(swatch);
1059
+ }
1060
+
1061
+ // Reset button
1062
+ if (_folderColors[folderPath]) {
1063
+ const resetBtn = document.createElement('button');
1064
+ resetBtn.className = 'fe-ctx-item fe-color-reset';
1065
+ resetBtn.textContent = 'Reset';
1066
+ resetBtn.addEventListener('click', (ev) => {
1067
+ ev.stopPropagation();
1068
+ setFolderColor(folderPath, null, iconEl, dotEl);
1069
+ dismissColorPalette();
1070
+ });
1071
+ palette.appendChild(resetBtn);
1072
+ }
1073
+
1074
+ const panel = $('file-explorer-panel');
1075
+ if (panel) panel.appendChild(palette);
1076
+
1077
+ // Position near the click
1078
+ const panelRect = panel.getBoundingClientRect();
1079
+ palette.style.top = `${e.clientY - panelRect.top + 4}px`;
1080
+ palette.style.left = `${e.clientX - panelRect.left}px`;
1081
+
1082
+ // Clamp
1083
+ requestAnimationFrame(() => {
1084
+ const palRect = palette.getBoundingClientRect();
1085
+ if (palRect.right > panelRect.right - 8) {
1086
+ palette.style.left = `${panelRect.width - palRect.width - 8}px`;
1087
+ }
1088
+ if (palRect.bottom > panelRect.bottom - 8) {
1089
+ palette.style.top = `${e.clientY - panelRect.top - palRect.height - 4}px`;
1090
+ }
1091
+ });
1092
+
1093
+ _activeColorPalette = palette;
1094
+
1095
+ const onOutside = (ev) => {
1096
+ if (!palette.contains(ev.target)) {
1097
+ dismissColorPalette();
1098
+ document.removeEventListener('mousedown', onOutside, true);
1099
+ }
1100
+ };
1101
+ setTimeout(() => document.addEventListener('mousedown', onOutside, true), 0);
1102
+ }
1103
+
1104
+ function _ctxItem(iconSvg, label, onClick) {
1105
+ const btn = document.createElement('button');
1106
+ btn.className = 'fe-ctx-item';
1107
+ btn.innerHTML = iconSvg + `<span>${label}</span>`;
1108
+ btn.addEventListener('click', onClick);
1109
+ return btn;
1110
+ }
1111
+
1112
+ function _ctxSeparator() {
1113
+ const sep = document.createElement('div');
1114
+ sep.className = 'fe-ctx-separator';
1115
+ return sep;
1116
+ }
1117
+
1118
+ function showContextMenu(e, item, nodeEl, rowEl, iconEl, depth) {
1119
+ dismissCtxMenu();
1120
+ dismissColorPalette();
1121
+
1122
+ const panel = $('file-explorer-panel');
1123
+ if (!panel) return;
1124
+
1125
+ // ── Flyout container (positioned outside panel, to its right)
1126
+ const flyout = document.createElement('div');
1127
+ flyout.className = 'fe-ctx-flyout';
1128
+
1129
+ // ── Arrow element
1130
+ const arrow = document.createElement('div');
1131
+ arrow.className = 'fe-ctx-arrow';
1132
+ flyout.appendChild(arrow);
1133
+
1134
+ // ── Menu body
1135
+ const menu = document.createElement('div');
1136
+ menu.className = 'fe-ctx-menu';
1137
+
1138
+ const copyIcon = '<svg viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
1139
+ const nameIcon = '<svg viewBox="0 0 24 24"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>';
1140
+ const aiIcon = '<svg viewBox="0 0 24 24"><path d="M12 3l1.5 4.5L18 9l-4.5 1.5L12 15l-1.5-4.5L6 9l4.5-1.5L12 3z"/><path d="M18 14l.9 2.1L21 17l-2.1.9L18 20l-.9-2.1L15 17l2.1-.9L18 14z"/></svg>';
1141
+ const editIcon = '<svg viewBox="0 0 24 24"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg>';
1142
+ const paletteIcon = '<svg viewBox="0 0 24 24"><circle cx="13.5" cy="6.5" r="2.5"/><circle cx="17.5" cy="10.5" r="2.5"/><circle cx="8.5" cy="7.5" r="2.5"/><circle cx="6.5" cy="12.5" r="2.5"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.93 0 1.5-.67 1.5-1.5 0-.39-.14-.74-.39-1.04-.24-.28-.37-.59-.37-.96 0-.83.67-1.5 1.5-1.5H16c3.31 0 6-2.69 6-6 0-5.5-4.5-10-10-10z"/></svg>';
1143
+ const collapseIcon = '<svg viewBox="0 0 24 24"><path d="M4 14h6v6"/><path d="M20 10h-6V4"/><path d="M14 10l7-7"/><path d="M3 21l7-7"/></svg>';
1144
+ const expandIcon = '<svg viewBox="0 0 24 24"><path d="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="M21 3l-7 7"/><path d="M3 21l7-7"/></svg>';
1145
+
1146
+ // ── Copy Path (both)
1147
+ menu.appendChild(_ctxItem(copyIcon, 'Copy Path', () => {
1148
+ navigator.clipboard.writeText(item.fullPath).then(() => showToast('Path copied'));
1149
+ dismissCtxMenu();
1150
+ }));
1151
+
1152
+ // ── Copy Name (both)
1153
+ menu.appendChild(_ctxItem(nameIcon, 'Copy Name', () => {
1154
+ navigator.clipboard.writeText(item.name).then(() => showToast('Name copied'));
1155
+ dismissCtxMenu();
1156
+ }));
1157
+
1158
+ // ── Send to AI (both)
1159
+ menu.appendChild(_ctxItem(aiIcon, 'Send to AI', () => {
1160
+ sendToPanel(item.fullPath);
1161
+ dismissCtxMenu();
1162
+ }));
1163
+
1164
+ if (item.type === 'file') {
1165
+ menu.appendChild(_ctxSeparator());
1166
+
1167
+ // ── Edit File
1168
+ menu.appendChild(_ctxItem(editIcon, 'Edit File', () => {
1169
+ dismissCtxMenu();
1170
+ openFileEditor(item.fullPath);
1171
+ }));
1172
+ }
1173
+
1174
+ if (item.type === 'dir') {
1175
+ menu.appendChild(_ctxSeparator());
1176
+
1177
+ // ── Change Color
1178
+ menu.appendChild(_ctxItem(paletteIcon, 'Change Color', (ev) => {
1179
+ dismissCtxMenu();
1180
+ showColorPalette(ev, item.fullPath, iconEl, null);
1181
+ }));
1182
+
1183
+ menu.appendChild(_ctxSeparator());
1184
+
1185
+ // ── Expand / Collapse
1186
+ const isExpanded = nodeEl.dataset.expanded === 'true';
1187
+ if (isExpanded) {
1188
+ menu.appendChild(_ctxItem(collapseIcon, 'Collapse', () => {
1189
+ collapseDir(nodeEl, item.fullPath);
1190
+ dismissCtxMenu();
1191
+ }));
1192
+
1193
+ // ── Collapse All Children
1194
+ menu.appendChild(_ctxItem(collapseIcon, 'Collapse Children', () => {
1195
+ nodeEl.querySelectorAll('.fe-node[data-type="dir"][data-expanded="true"]').forEach(n => {
1196
+ n.dataset.expanded = 'false';
1197
+ _collapsed.add(n.dataset.path);
1198
+ });
1199
+ saveCollapsed();
1200
+ dismissCtxMenu();
1201
+ }));
1202
+ } else {
1203
+ menu.appendChild(_ctxItem(expandIcon, 'Expand', () => {
1204
+ expandDir(nodeEl, item.fullPath, depth + 1);
1205
+ dismissCtxMenu();
1206
+ }));
1207
+ }
1208
+ }
1209
+
1210
+ // ── Delete (both files and dirs)
1211
+ menu.appendChild(_ctxSeparator());
1212
+ const deleteIcon = '<svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
1213
+ const deleteBtn = _ctxItem(deleteIcon, 'Delete', () => {
1214
+ dismissCtxMenu();
1215
+ showDeleteConfirm(item.name, item.type, () => {
1216
+ deleteFileOrDir(item.fullPath, item.name, item.type);
1217
+ });
1218
+ });
1219
+ deleteBtn.classList.add('fe-ctx-item--danger');
1220
+ menu.appendChild(deleteBtn);
1221
+
1222
+ flyout.appendChild(menu);
1223
+
1224
+ // ── Position: flyout docks to the right edge of the panel, arrow points at the row
1225
+ document.body.appendChild(flyout);
1226
+
1227
+ const panelRect = panel.getBoundingClientRect();
1228
+ const rowRect = rowEl.getBoundingClientRect();
1229
+ const rowCenterY = rowRect.top + rowRect.height / 2;
1230
+
1231
+ // Flyout left edge sits at the panel's right edge + small gap for arrow
1232
+ flyout.style.left = `${panelRect.right + 6}px`;
1233
+ flyout.style.opacity = '0';
1234
+
1235
+ requestAnimationFrame(() => {
1236
+ const flyRect = flyout.getBoundingClientRect();
1237
+ // Vertically center on the row, but clamp to viewport
1238
+ let top = rowCenterY - flyRect.height / 2;
1239
+ if (top < 8) top = 8;
1240
+ if (top + flyRect.height > window.innerHeight - 8) top = window.innerHeight - flyRect.height - 8;
1241
+ flyout.style.top = `${top}px`;
1242
+
1243
+ // Arrow points at the row center
1244
+ const arrowY = rowCenterY - top;
1245
+ arrow.style.top = `${Math.max(12, Math.min(flyRect.height - 12, arrowY))}px`;
1246
+
1247
+ // Animate in
1248
+ flyout.style.opacity = '';
1249
+ flyout.classList.add('fe-ctx-flyout--in');
1250
+ });
1251
+
1252
+ // Highlight the source row
1253
+ rowEl.classList.add('fe-row--ctx-active');
1254
+
1255
+ _activeCtxMenu = flyout;
1256
+ _activeCtxMenu._row = rowEl;
1257
+
1258
+ // Close on outside click
1259
+ const onOutside = (ev) => {
1260
+ if (!flyout.contains(ev.target)) {
1261
+ dismissCtxMenu();
1262
+ document.removeEventListener('mousedown', onOutside, true);
1263
+ }
1264
+ };
1265
+ setTimeout(() => document.addEventListener('mousedown', onOutside, true), 0);
1266
+ }
1267
+
1268
+ function showToast(msg) {
1269
+ let toast = document.getElementById('fe-toast');
1270
+ if (!toast) {
1271
+ toast = document.createElement('div');
1272
+ toast.id = 'fe-toast';
1273
+ toast.style.cssText = 'position:fixed;bottom:60px;left:50%;transform:translateX(-50%);background:rgba(30,30,34,0.95);color:#e0e0e0;padding:6px 16px;border-radius:6px;font-size:12px;font-family:var(--ff-mono);z-index:2147483647;pointer-events:none;opacity:0;transition:opacity 0.2s;border:1px solid rgba(255,255,255,0.1);';
1274
+ document.body.appendChild(toast);
1275
+ }
1276
+ toast.textContent = msg;
1277
+ toast.style.opacity = '1';
1278
+ clearTimeout(toast._timer);
1279
+ toast._timer = setTimeout(() => { toast.style.opacity = '0'; }, 3500);
1280
+ }
1281
+
1282
+ // ═══════════════════════════════════════════
1283
+ // INLINE FILE EDITOR
1284
+ // ═══════════════════════════════════════════
1285
+
1286
+ // File extension → language label
1287
+ const EXT_LANG = {
1288
+ js: 'JavaScript', mjs: 'JavaScript', cjs: 'JavaScript',
1289
+ ts: 'TypeScript', tsx: 'TypeScript',
1290
+ json: 'JSON', html: 'HTML', css: 'CSS',
1291
+ md: 'Markdown', py: 'Python', sh: 'Shell', bash: 'Shell',
1292
+ yml: 'YAML', yaml: 'YAML', toml: 'TOML',
1293
+ sql: 'SQL', rs: 'Rust', go: 'Go', rb: 'Ruby',
1294
+ env: 'ENV', gitignore: 'Git', dockerignore: 'Docker',
1295
+ };
1296
+
1297
+ const COMMENT_PREFIX = {
1298
+ JavaScript: '//', TypeScript: '//', JSON: '//', CSS: '//',
1299
+ HTML: '<!--', Python: '#', Shell: '#', YAML: '#', TOML: '#',
1300
+ SQL: '--', Rust: '//', Go: '//', Ruby: '#', ENV: '#', Git: '#', Docker: '#',
1301
+ };
1302
+
1303
+ // ─── Syntax Highlighting ────────────────────────
1304
+
1305
+ const SH_KEYWORDS = {
1306
+ JavaScript: 'async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|export|extends|finally|for|from|function|if|import|in|instanceof|let|new|of|return|static|super|switch|this|throw|try|typeof|var|void|while|with|yield|true|false|null|undefined',
1307
+ TypeScript: 'abstract|as|async|await|break|case|catch|class|const|continue|debugger|declare|default|delete|do|else|enum|export|extends|finally|for|from|function|if|implements|import|in|instanceof|interface|keyof|let|new|of|return|static|super|switch|this|throw|try|type|typeof|var|void|while|with|yield|true|false|null|undefined',
1308
+ Python: 'and|as|assert|async|await|break|class|continue|def|del|elif|else|except|finally|for|from|global|if|import|in|is|lambda|nonlocal|not|or|pass|raise|return|try|while|with|yield|True|False|None',
1309
+ JSON: 'true|false|null',
1310
+ Shell: 'if|then|else|elif|fi|for|while|do|done|case|esac|in|function|return|exit|local|export|source|set|unset|shift|read|echo|printf|test|eval',
1311
+ SQL: 'SELECT|FROM|WHERE|AND|OR|NOT|INSERT|INTO|VALUES|UPDATE|SET|DELETE|CREATE|TABLE|DROP|ALTER|ADD|INDEX|JOIN|LEFT|RIGHT|INNER|OUTER|ON|AS|ORDER|BY|GROUP|HAVING|LIMIT|OFFSET|UNION|ALL|DISTINCT|COUNT|SUM|AVG|MIN|MAX|IS|NULL|LIKE|BETWEEN|IN|EXISTS|PRIMARY|KEY|FOREIGN|REFERENCES|DEFAULT|CASCADE|VIEW|BEGIN|END|IF|ELSE|THEN|WHILE|DECLARE|COMMIT|ROLLBACK',
1312
+ Rust: 'as|async|await|break|const|continue|crate|dyn|else|enum|extern|false|fn|for|if|impl|in|let|loop|match|mod|move|mut|pub|ref|return|self|Self|static|struct|super|trait|true|type|unsafe|use|where|while|yield',
1313
+ Go: 'break|case|chan|const|continue|default|defer|else|fallthrough|for|func|go|goto|if|import|interface|map|package|range|return|select|struct|switch|type|var|true|false|nil',
1314
+ Ruby: 'alias|and|begin|break|case|class|def|do|else|elsif|end|ensure|false|for|if|in|module|next|nil|not|or|redo|rescue|retry|return|self|super|then|true|undef|unless|until|when|while|yield',
1315
+ TOML: 'true|false',
1316
+ YAML: 'true|false|null|yes|no|on|off',
1317
+ };
1318
+
1319
+ const SH_MAX_SIZE = 80000;
1320
+
1321
+ function _shEsc(s) {
1322
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1323
+ }
1324
+
1325
+ function _shBuildRules(lang) {
1326
+ const rules = [];
1327
+ const cStyle = ['JavaScript', 'TypeScript', 'CSS', 'Go', 'Rust'];
1328
+ const hashStyle = ['Python', 'Shell', 'YAML', 'TOML', 'Ruby', 'ENV', 'Git', 'Docker'];
1329
+
1330
+ // Comments (highest priority)
1331
+ if (cStyle.includes(lang)) rules.push({ p: '\\/\\*[\\s\\S]*?\\*\\/', c: 'sh-cm' });
1332
+ if (lang === 'HTML') rules.push({ p: '<!--[\\s\\S]*?-->', c: 'sh-cm' });
1333
+ if (cStyle.includes(lang) && lang !== 'CSS') rules.push({ p: '\\/\\/[^\\n]*', c: 'sh-cm' });
1334
+ if (hashStyle.includes(lang)) rules.push({ p: '#[^\\n]*', c: 'sh-cm' });
1335
+ if (lang === 'SQL') rules.push({ p: '--[^\\n]*', c: 'sh-cm' });
1336
+
1337
+ // JSON property keys (before generic strings so they match first)
1338
+ if (lang === 'JSON') {
1339
+ rules.push({ p: '"(?:[^"\\\\\\n]|\\\\.)*"(?=\\s*:)', c: 'sh-prop' });
1340
+ }
1341
+
1342
+ // Strings
1343
+ rules.push({ p: '"(?:[^"\\\\\\n]|\\\\.)*"', c: 'sh-str' });
1344
+ rules.push({ p: "'(?:[^'\\\\\\n]|\\\\.)*'", c: 'sh-str' });
1345
+ if (['JavaScript', 'TypeScript'].includes(lang)) {
1346
+ rules.push({ p: '`(?:[^`\\\\]|\\\\.)*`', c: 'sh-str' });
1347
+ }
1348
+ if (lang === 'Python') {
1349
+ // Triple-quoted strings
1350
+ rules.push({ p: '"""[\\s\\S]*?"""|\'\'\'[\\s\\S]*?\'\'\'', c: 'sh-str' });
1351
+ }
1352
+
1353
+ // Numbers
1354
+ rules.push({ p: '\\b(?:0x[0-9a-fA-F]+|0b[01]+|\\d+(?:\\.\\d+)?(?:e[+-]?\\d+)?)\\b', c: 'sh-num' });
1355
+
1356
+ // CSS-specific: at-rules, selectors, properties
1357
+ if (lang === 'CSS') {
1358
+ rules.push({ p: '@[a-zA-Z][a-zA-Z-]*', c: 'sh-kw' });
1359
+ rules.push({ p: '!important', c: 'sh-kw' });
1360
+ rules.push({ p: '[.#][a-zA-Z_-][a-zA-Z0-9_-]*', c: 'sh-fn' });
1361
+ rules.push({ p: ':[a-zA-Z][a-zA-Z-]*', c: 'sh-attr' });
1362
+ }
1363
+
1364
+ // HTML-specific: tags and attributes
1365
+ if (lang === 'HTML') {
1366
+ rules.push({ p: '<\\/?[a-zA-Z][a-zA-Z0-9-]*', c: 'sh-tag' });
1367
+ rules.push({ p: '\\/?>', c: 'sh-op' });
1368
+ rules.push({ p: '\\b[a-zA-Z][a-zA-Z-]*(?=\\s*=)', c: 'sh-attr' });
1369
+ }
1370
+
1371
+ // Keywords
1372
+ const kw = SH_KEYWORDS[lang];
1373
+ if (kw) {
1374
+ const flags = lang === 'SQL' ? 'i' : '';
1375
+ rules.push({ p: '\\b(?:' + kw + ')\\b', c: 'sh-kw', flags });
1376
+ }
1377
+
1378
+ // Function calls (identifier followed by paren)
1379
+ if (!['JSON', 'CSS', 'HTML', 'YAML', 'TOML', 'ENV', 'Markdown'].includes(lang)) {
1380
+ rules.push({ p: '\\b[a-zA-Z_$][a-zA-Z0-9_$]*(?=\\s*\\()', c: 'sh-fn' });
1381
+ }
1382
+
1383
+ // YAML keys (word at line start followed by colon)
1384
+ if (lang === 'YAML' || lang === 'TOML') {
1385
+ rules.push({ p: '^[ \\t]*[a-zA-Z_][a-zA-Z0-9_.\\-]*(?=\\s*[:=])', c: 'sh-prop' });
1386
+ }
1387
+
1388
+ // Shell variables
1389
+ if (lang === 'Shell' || lang === 'ENV') {
1390
+ rules.push({ p: '\\$[a-zA-Z_][a-zA-Z0-9_]*|\\$\\{[^}]+\\}', c: 'sh-prop' });
1391
+ }
1392
+
1393
+ // Brackets
1394
+ rules.push({ p: '[{}\\[\\]()]', c: 'sh-br' });
1395
+
1396
+ return rules;
1397
+ }
1398
+
1399
+ // Cache compiled regexes per language
1400
+ const _shCache = {};
1401
+
1402
+ function _shGetRegex(lang) {
1403
+ if (_shCache[lang]) return _shCache[lang];
1404
+ const rules = _shBuildRules(lang);
1405
+ if (rules.length === 0) return null;
1406
+ // Combine all rule patterns into one alternation
1407
+ const combined = rules.map(r => '(' + r.p + ')').join('|');
1408
+ const flags = 'gm' + (rules.some(r => r.flags?.includes('i')) ? 'i' : '');
1409
+ _shCache[lang] = { regex: new RegExp(combined, flags), rules };
1410
+ return _shCache[lang];
1411
+ }
1412
+
1413
+ function highlightCode(text, lang) {
1414
+ if (!text) return '';
1415
+ if (text.length > SH_MAX_SIZE || !lang || lang === 'Plain Text' || lang === 'Markdown') {
1416
+ return _shEsc(text);
1417
+ }
1418
+
1419
+ const compiled = _shGetRegex(lang);
1420
+ if (!compiled) return _shEsc(text);
1421
+
1422
+ const { regex, rules } = compiled;
1423
+ regex.lastIndex = 0;
1424
+ let result = '';
1425
+ let lastIndex = 0;
1426
+ let match;
1427
+
1428
+ while ((match = regex.exec(text)) !== null) {
1429
+ // Unmatched text before this token
1430
+ if (match.index > lastIndex) {
1431
+ result += _shEsc(text.slice(lastIndex, match.index));
1432
+ }
1433
+ // Find which capturing group matched
1434
+ for (let i = 0; i < rules.length; i++) {
1435
+ if (match[i + 1] !== undefined) {
1436
+ result += '<span class="' + rules[i].c + '">' + _shEsc(match[0]) + '</span>';
1437
+ break;
1438
+ }
1439
+ }
1440
+ lastIndex = match.index + match[0].length;
1441
+ // Guard against zero-length matches
1442
+ if (match[0].length === 0) { regex.lastIndex++; lastIndex++; }
1443
+ }
1444
+ // Remaining text
1445
+ if (lastIndex < text.length) result += _shEsc(text.slice(lastIndex));
1446
+
1447
+ return result;
1448
+ }
1449
+
1450
+ let _shLang = '';
1451
+ let _shLines = null; // cached line split for large files
1452
+ let _shViewStart = 0; // first buffered line
1453
+ let _shViewEnd = 0; // last buffered line
1454
+ const SH_LINE_H = 20;
1455
+ const SH_BUFFER = 80; // lines of buffer above/below viewport
1456
+ const SH_REBUFFER = 30; // re-render when within this many lines of buffer edge
1457
+
1458
+ function updateHighlight() {
1459
+ const textarea = $('fe-editor-textarea');
1460
+ const code = $('fe-editor-highlight-code');
1461
+ if (!textarea || !code) return;
1462
+
1463
+ const text = textarea.value;
1464
+ if (!text || !_shLang || _shLang === 'Plain Text' || _shLang === 'Markdown') {
1465
+ code.innerHTML = _shEsc(text || '') + '\n';
1466
+ _shLines = null;
1467
+ return;
1468
+ }
1469
+
1470
+ if (text.length <= SH_MAX_SIZE) {
1471
+ // Small file: highlight everything
1472
+ code.innerHTML = highlightCode(text, _shLang) + '\n';
1473
+ _shLines = null;
1474
+ return;
1475
+ }
1476
+
1477
+ // Large file: viewport-based highlighting
1478
+ _shLines = text.split('\n');
1479
+ _shRenderViewport(textarea, code, text);
1480
+ }
1481
+
1482
+ function _shRenderViewport(textarea, code, text) {
1483
+ if (!_shLines) return;
1484
+
1485
+ const scrollTop = textarea.scrollTop;
1486
+ const viewH = textarea.clientHeight;
1487
+ const firstVisLine = Math.max(0, Math.floor(scrollTop / SH_LINE_H));
1488
+ const visLineCount = Math.ceil(viewH / SH_LINE_H);
1489
+
1490
+ const startLine = Math.max(0, firstVisLine - SH_BUFFER);
1491
+ const endLine = Math.min(_shLines.length, firstVisLine + visLineCount + SH_BUFFER);
1492
+
1493
+ _shViewStart = startLine;
1494
+ _shViewEnd = endLine;
1495
+
1496
+ // Character offsets for the visible chunk
1497
+ let charStart = 0;
1498
+ for (let i = 0; i < startLine; i++) charStart += _shLines[i].length + 1;
1499
+ let charEnd = charStart;
1500
+ for (let i = startLine; i < endLine; i++) charEnd += _shLines[i].length + 1;
1501
+ if (charEnd > text.length) charEnd = text.length;
1502
+
1503
+ const chunk = text.slice(charStart, charEnd);
1504
+ const highlighted = highlightCode(chunk, _shLang);
1505
+
1506
+ // Use real newlines as spacers — guarantees identical line height to textarea
1507
+ code.innerHTML =
1508
+ '\n'.repeat(startLine) +
1509
+ highlighted +
1510
+ '\n'.repeat(Math.max(0, _shLines.length - endLine)) +
1511
+ '\n';
1512
+ }
1513
+
1514
+ function syncHighlightScroll() {
1515
+ const textarea = $('fe-editor-textarea');
1516
+ const code = $('fe-editor-highlight-code');
1517
+ if (!textarea || !code) return;
1518
+ code.style.transform = `translate(${-textarea.scrollLeft}px, ${-textarea.scrollTop}px)`;
1519
+
1520
+ // Large file: re-render viewport if scrolled near buffer edge
1521
+ if (_shLines) {
1522
+ const firstVisLine = Math.floor(textarea.scrollTop / SH_LINE_H);
1523
+ const lastVisLine = firstVisLine + Math.ceil(textarea.clientHeight / SH_LINE_H);
1524
+ if (firstVisLine < _shViewStart + SH_REBUFFER || lastVisLine > _shViewEnd - SH_REBUFFER) {
1525
+ _shRenderViewport(textarea, code, textarea.value);
1526
+ }
1527
+ }
1528
+ }
1529
+
1530
+ function detectLang(filename) {
1531
+ const parts = filename.split('.');
1532
+ if (parts.length < 2) return 'Plain Text';
1533
+ const ext = parts.pop().toLowerCase();
1534
+ // Check the full dotfile name first (e.g. .env, .gitignore)
1535
+ const dotName = filename.startsWith('.') ? filename.slice(1).toLowerCase() : null;
1536
+ return EXT_LANG[dotName] || EXT_LANG[ext] || ext.toUpperCase();
1537
+ }
1538
+
1539
+ function formatSize(bytes) {
1540
+ if (bytes < 1024) return `${bytes} B`;
1541
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
1542
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1543
+ }
1544
+
1545
+ function updateGutter() {
1546
+ const gutter = $('fe-editor-gutter');
1547
+ const textarea = $('fe-editor-textarea');
1548
+ if (!gutter || !textarea) return;
1549
+ const lines = textarea.value.split('\n').length;
1550
+ const frags = [];
1551
+ for (let i = 1; i <= lines; i++) frags.push(`<div>${i}</div>`);
1552
+ gutter.innerHTML = frags.join('');
1553
+ }
1554
+
1555
+ function updateCursorPos() {
1556
+ const textarea = $('fe-editor-textarea');
1557
+ const el = $('fe-editor-cursor');
1558
+ if (!textarea || !el) return;
1559
+ const val = textarea.value;
1560
+ const pos = textarea.selectionStart;
1561
+ const before = val.substring(0, pos);
1562
+ const ln = before.split('\n').length;
1563
+ const col = pos - before.lastIndexOf('\n');
1564
+ el.textContent = `Ln ${ln}, Col ${col}`;
1565
+ }
1566
+
1567
+ function syncGutterScroll() {
1568
+ const gutter = $('fe-editor-gutter');
1569
+ const textarea = $('fe-editor-textarea');
1570
+ if (gutter && textarea) gutter.scrollTop = textarea.scrollTop;
1571
+ }
1572
+
1573
+ function updateLineHighlight() {
1574
+ const textarea = $('fe-editor-textarea');
1575
+ const hl = $('fe-editor-line-highlight');
1576
+ if (!textarea || !hl) return;
1577
+ const val = textarea.value;
1578
+ const pos = textarea.selectionStart;
1579
+ const lineNum = val.substring(0, pos).split('\n').length - 1;
1580
+ const lineH = 20;
1581
+ hl.style.top = `${10 + lineNum * lineH - textarea.scrollTop}px`;
1582
+ }
1583
+
1584
+ function updateScrollmap() {
1585
+ const textarea = $('fe-editor-textarea');
1586
+ const thumb = $('fe-editor-scrollmap-thumb');
1587
+ const map = $('fe-editor-scrollmap');
1588
+ if (!textarea || !thumb || !map) return;
1589
+ const sh = textarea.scrollHeight;
1590
+ const ch = textarea.clientHeight;
1591
+ if (sh <= ch) { thumb.style.display = 'none'; return; }
1592
+ thumb.style.display = '';
1593
+ const mapH = map.clientHeight;
1594
+ const ratio = ch / sh;
1595
+ const thumbH = Math.max(12, mapH * ratio);
1596
+ const scrollRatio = textarea.scrollTop / (sh - ch);
1597
+ thumb.style.height = `${thumbH}px`;
1598
+ thumb.style.top = `${scrollRatio * (mapH - thumbH)}px`;
1599
+ }
1600
+
1601
+ // ─── Undo/Redo stack ───────────────────────
1602
+ function editorPushUndo(ta) {
1603
+ _undoStack.push({ value: ta.value, selStart: ta.selectionStart, selEnd: ta.selectionEnd });
1604
+ if (_undoStack.length > 200) _undoStack.shift();
1605
+ _redoStack = [];
1606
+ }
1607
+
1608
+ function editorInsertText(ta, text) {
1609
+ editorPushUndo(ta);
1610
+ const start = ta.selectionStart;
1611
+ const end = ta.selectionEnd;
1612
+ ta.value = ta.value.substring(0, start) + text + ta.value.substring(end);
1613
+ const newPos = start + text.length;
1614
+ ta.selectionStart = ta.selectionEnd = newPos;
1615
+ ta.dispatchEvent(new Event('input'));
1616
+ }
1617
+
1618
+ function editorReplaceRange(ta, from, to, text) {
1619
+ editorPushUndo(ta);
1620
+ ta.value = ta.value.substring(0, from) + text + ta.value.substring(to);
1621
+ ta.dispatchEvent(new Event('input'));
1622
+ }
1623
+
1624
+ function editorUndo(ta) {
1625
+ if (_undoStack.length === 0) return;
1626
+ _redoStack.push({ value: ta.value, selStart: ta.selectionStart, selEnd: ta.selectionEnd });
1627
+ const state = _undoStack.pop();
1628
+ ta.value = state.value;
1629
+ ta.selectionStart = state.selStart;
1630
+ ta.selectionEnd = state.selEnd;
1631
+ updateEditorDirtyState();
1632
+ updateGutter();
1633
+ updateHighlight();
1634
+ }
1635
+
1636
+ function editorRedo(ta) {
1637
+ if (_redoStack.length === 0) return;
1638
+ _undoStack.push({ value: ta.value, selStart: ta.selectionStart, selEnd: ta.selectionEnd });
1639
+ const state = _redoStack.pop();
1640
+ ta.value = state.value;
1641
+ ta.selectionStart = state.selStart;
1642
+ ta.selectionEnd = state.selEnd;
1643
+ updateEditorDirtyState();
1644
+ updateGutter();
1645
+ updateHighlight();
1646
+ }
1647
+
1648
+ async function openFileEditor(filePath) {
1649
+ try {
1650
+ const res = await fetch(`/api/file-content?path=${encodeURIComponent(filePath)}`);
1651
+ const data = await res.json();
1652
+ if (!res.ok) {
1653
+ showToast(data.error || 'Cannot open file');
1654
+ return;
1655
+ }
1656
+
1657
+ _editorOpen = true;
1658
+ _editorFilePath = data.path;
1659
+ // Normalize line endings — textarea always returns \n, server may send \r\n on Windows
1660
+ _editorOriginal = data.content.replace(/\r\n/g, '\n');
1661
+ _editorDirty = false;
1662
+ _undoStack = [];
1663
+ _redoStack = [];
1664
+ _undoBurstOpen = false;
1665
+
1666
+ // Slide editor panel open
1667
+ dismissCtxMenu();
1668
+ const editorPanel = $('fe-editor-panel');
1669
+ if (editorPanel) editorPanel.classList.add('open');
1670
+ document.body.classList.add('fe-editor-open');
1671
+ if (document.querySelector('.claude-panel.open')) editorPanel?.classList.add('panel-adjacent');
1672
+
1673
+ const textarea = $('fe-editor-textarea');
1674
+ if (textarea) {
1675
+ textarea.value = _editorOriginal;
1676
+ textarea.scrollTop = 0;
1677
+ textarea.setSelectionRange(0, 0);
1678
+ // Push initial state so first Ctrl+Z reverts to original
1679
+ _undoStack.push({ value: _editorOriginal, selStart: 0, selEnd: 0 });
1680
+ }
1681
+
1682
+ // File path in header (show relative path from project root)
1683
+ const fpEl = $('fe-editor-filepath');
1684
+ if (fpEl) {
1685
+ const rel = _selectedProject ? data.path.replace(_selectedProject.path.replace(/\\/g, '/'), '') : data.path;
1686
+ fpEl.textContent = rel.startsWith('/') ? rel.slice(1) : rel;
1687
+ }
1688
+
1689
+ // Language detection + markdown check + syntax highlighting
1690
+ const lang = detectLang(data.name);
1691
+ _shLang = lang;
1692
+ const langEl = $('fe-editor-lang');
1693
+ if (langEl) langEl.textContent = lang;
1694
+ _editorIsMarkdown = lang === 'Markdown';
1695
+ _editorPreviewMode = false;
1696
+ updateViewToggle();
1697
+ // Reset toggle buttons
1698
+ const toggleBtns = document.querySelectorAll('.fe-editor-toggle-btn');
1699
+ toggleBtns.forEach(b => b.classList.toggle('active', b.dataset.mode === 'raw'));
1700
+ setPreviewMode(false);
1701
+
1702
+ // Close find bar + go-to-line on new file
1703
+ closeFind();
1704
+ closeGoToLine();
1705
+
1706
+ // File size
1707
+ const sizeEl = $('fe-editor-size');
1708
+ if (sizeEl) sizeEl.textContent = formatSize(data.size || 0);
1709
+
1710
+ // Plan/changelog edit banner
1711
+ const edPanel = $('fe-editor-panel');
1712
+ edPanel?.querySelector('.fe-plan-banner')?.remove();
1713
+ if (_planEditMode) {
1714
+ const banner = document.createElement('div');
1715
+ banner.className = 'fe-plan-banner';
1716
+ banner.textContent = 'Editing plan — save to send to Claude';
1717
+ edPanel?.prepend(banner);
1718
+ } else if (_changelogEditMode) {
1719
+ const banner = document.createElement('div');
1720
+ banner.className = 'fe-plan-banner';
1721
+ banner.textContent = 'Editing changelog — save to send to Claude';
1722
+ edPanel?.prepend(banner);
1723
+ }
1724
+
1725
+ updateGutter();
1726
+ updateHighlight();
1727
+ updateCursorPos();
1728
+ updateEditorDirtyState();
1729
+ // Defer so the textarea has rendered its scroll dimensions
1730
+ requestAnimationFrame(() => { updateLineHighlight(); updateScrollmap(); syncHighlightScroll(); });
1731
+ } catch (err) {
1732
+ showToast('Failed to open file');
1733
+ }
1734
+ }
1735
+
1736
+ function closeFileEditor() {
1737
+ if (_editorDirty) {
1738
+ if (!confirm('Discard unsaved changes?')) return false;
1739
+ }
1740
+
1741
+ // Emit cancel events before clearing flags so Claude panel can re-enable buttons
1742
+ if (_planEditMode) emit('plan-edit-cancelled');
1743
+ if (_changelogEditMode) emit('changelog-edit-cancelled');
1744
+
1745
+ _editorOpen = false;
1746
+ _editorFilePath = null;
1747
+ _editorOriginal = '';
1748
+ _editorDirty = false;
1749
+ _editorIsMarkdown = false;
1750
+ _editorPreviewMode = false;
1751
+ _planEditMode = false;
1752
+ _planEditFilePath = null;
1753
+ _changelogEditMode = false;
1754
+ _changelogEditFilePath = null;
1755
+ $('fe-editor-panel')?.querySelector('.fe-plan-banner')?.remove();
1756
+ _shLang = '';
1757
+ _shLines = null;
1758
+ _shViewStart = 0;
1759
+ _shViewEnd = 0;
1760
+ const hlCode = $('fe-editor-highlight-code');
1761
+ if (hlCode) hlCode.innerHTML = '';
1762
+
1763
+ // Close find bar and preview
1764
+ closeFind();
1765
+ setPreviewMode(false);
1766
+ updateViewToggle();
1767
+
1768
+ // Slide editor panel closed
1769
+ const editorPanel = $('fe-editor-panel');
1770
+ if (editorPanel) editorPanel.classList.remove('open');
1771
+ document.body.classList.remove('fe-editor-open');
1772
+
1773
+ // Restore footer
1774
+ const data = _cache.values().next().value;
1775
+ updateCount(data?.items?.length || 0);
1776
+
1777
+ return true;
1778
+ }
1779
+
1780
+ async function saveFileEditor() {
1781
+ if (!_editorFilePath) return;
1782
+ const textarea = $('fe-editor-textarea');
1783
+ if (!textarea) return;
1784
+
1785
+ try {
1786
+ const res = await fetch('/api/file-content', {
1787
+ method: 'POST',
1788
+ headers: { 'Content-Type': 'application/json' },
1789
+ body: JSON.stringify({ path: _editorFilePath, content: textarea.value })
1790
+ });
1791
+ const data = await res.json();
1792
+ if (!res.ok) {
1793
+ showToast(data.error || 'Save failed');
1794
+ return;
1795
+ }
1796
+ _editorOriginal = textarea.value;
1797
+ updateEditorDirtyState();
1798
+
1799
+ // Plan edit: emit saved content to Claude panel, then close editor
1800
+ if (_planEditMode && _editorFilePath === _planEditFilePath) {
1801
+ const savedContent = textarea.value;
1802
+ const savedPath = _editorFilePath;
1803
+ _planEditMode = false;
1804
+ _planEditFilePath = null;
1805
+ showToast('Plan saved — sending to Claude');
1806
+ emit('plan-saved', { filePath: savedPath, content: savedContent });
1807
+ closeFileEditor();
1808
+ return;
1809
+ }
1810
+
1811
+ // Changelog edit: emit saved content to Claude panel, then close editor
1812
+ if (_changelogEditMode && _editorFilePath === _changelogEditFilePath) {
1813
+ const savedContent = textarea.value;
1814
+ _changelogEditMode = false;
1815
+ _changelogEditFilePath = null;
1816
+ showToast('Changelog saved — sending to Claude');
1817
+ emit('changelog-saved', { content: savedContent });
1818
+ closeFileEditor();
1819
+ return;
1820
+ }
1821
+
1822
+ showToast('Saved');
1823
+ } catch {
1824
+ showToast('Save failed');
1825
+ }
1826
+ }
1827
+
1828
+ function discardFileEditor() {
1829
+ if (!confirm('Discard changes?')) return;
1830
+ const textarea = $('fe-editor-textarea');
1831
+ if (textarea) textarea.value = _editorOriginal;
1832
+ updateEditorDirtyState();
1833
+ }
1834
+
1835
+ function updateEditorDirtyState() {
1836
+ const textarea = $('fe-editor-textarea');
1837
+ _editorDirty = textarea ? textarea.value !== _editorOriginal : false;
1838
+
1839
+ const dot = $('fe-editor-dot');
1840
+ if (dot) dot.classList.toggle('dirty', _editorDirty);
1841
+
1842
+ const saveBtn = $('fe-editor-save');
1843
+ if (saveBtn) {
1844
+ saveBtn.disabled = !_editorDirty;
1845
+ saveBtn.classList.toggle('dirty', _editorDirty);
1846
+ }
1847
+
1848
+ const discardBtn = $('fe-editor-discard');
1849
+ if (discardBtn) discardBtn.disabled = !_editorDirty;
1850
+ }
1851
+
1852
+ // ─── Markdown rendering (same as Skills Studio) ────────────
1853
+ function renderMarkdown(md) {
1854
+ let text = md.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
1855
+ text = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1856
+ text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
1857
+ return `<pre><code class="lang-${lang}">${code.trimEnd()}</code></pre>`;
1858
+ });
1859
+ text = text.replace(/^######\s+(.+)$/gm, '<h6>$1</h6>');
1860
+ text = text.replace(/^#####\s+(.+)$/gm, '<h5>$1</h5>');
1861
+ text = text.replace(/^####\s+(.+)$/gm, '<h4>$1</h4>');
1862
+ text = text.replace(/^###\s+(.+)$/gm, '<h3>$1</h3>');
1863
+ text = text.replace(/^##\s+(.+)$/gm, '<h2>$1</h2>');
1864
+ text = text.replace(/^#\s+(.+)$/gm, '<h1>$1</h1>');
1865
+ text = text.replace(/^---+$/gm, '<hr>');
1866
+ text = text.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
1867
+ text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
1868
+ text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
1869
+ text = text.replace(/~~(.+?)~~/g, '<del>$1</del>');
1870
+ text = text.replace(/`([^`]+)`/g, '<code>$1</code>');
1871
+ text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
1872
+ text = text.replace(/^&gt;\s?(.*)$/gm, '<blockquote>$1</blockquote>');
1873
+ text = text.replace(/<\/blockquote>\n<blockquote>/g, '\n');
1874
+ text = text.replace(/^(\|.+\|)\n(\|[-| :]+\|)\n((?:\|.+\|\n?)+)/gm, (_, header, _sep, body) => {
1875
+ const ths = header.split('|').filter(c => c.trim()).map(c => `<th>${c.trim()}</th>`).join('');
1876
+ const rows = body.trim().split('\n').map(row => {
1877
+ const tds = row.split('|').filter(c => c.trim()).map(c => `<td>${c.trim()}</td>`).join('');
1878
+ return `<tr>${tds}</tr>`;
1879
+ }).join('');
1880
+ return `<table><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>`;
1881
+ });
1882
+ text = text.replace(/^[-*]\s+(.+)$/gm, '<li>$1</li>');
1883
+ text = text.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>');
1884
+ text = text.replace(/^\d+\.\s+(.+)$/gm, '<oli>$1</oli>');
1885
+ text = text.replace(/((?:<oli>.*<\/oli>\n?)+)/g, (match) => {
1886
+ return '<ol>' + match.replace(/<\/?oli>/g, (t) => t.replace('oli', 'li')) + '</ol>';
1887
+ });
1888
+ text = text.replace(/^(?!<[a-z/])((?!<).+)$/gm, '<p>$1</p>');
1889
+ text = text.replace(/<p>\s*<\/p>/g, '');
1890
+ return text;
1891
+ }
1892
+
1893
+ function setPreviewMode(preview) {
1894
+ _editorPreviewMode = preview;
1895
+ const textarea = $('fe-editor-textarea');
1896
+ const gutter = $('fe-editor-gutter');
1897
+ const previewEl = $('fe-editor-preview');
1898
+ const highlight = $('fe-editor-highlight');
1899
+ const lineHighlight = $('fe-editor-line-highlight');
1900
+
1901
+ if (preview) {
1902
+ if (textarea) textarea.style.display = 'none';
1903
+ if (gutter) gutter.style.display = 'none';
1904
+ if (highlight) highlight.style.display = 'none';
1905
+ if (lineHighlight) lineHighlight.style.display = 'none';
1906
+ if (previewEl) {
1907
+ previewEl.innerHTML = renderMarkdown(textarea?.value || '');
1908
+ previewEl.classList.add('visible');
1909
+ }
1910
+ } else {
1911
+ if (textarea) textarea.style.display = '';
1912
+ if (gutter) gutter.style.display = '';
1913
+ if (highlight) highlight.style.display = '';
1914
+ if (lineHighlight) lineHighlight.style.display = '';
1915
+ if (previewEl) previewEl.classList.remove('visible');
1916
+ }
1917
+ }
1918
+
1919
+ function updateViewToggle() {
1920
+ const toggle = $('fe-editor-view-toggle');
1921
+ const sep = $('fe-editor-sep-preview');
1922
+ if (toggle) toggle.style.display = _editorIsMarkdown ? 'flex' : 'none';
1923
+ if (sep) sep.style.display = _editorIsMarkdown ? '' : 'none';
1924
+ }
1925
+
1926
+ // ─── Find ─────────────────────────────────
1927
+ function openFind() {
1928
+ _findOpen = true;
1929
+ const bar = $('fe-editor-find');
1930
+ const input = $('fe-editor-find-input');
1931
+ if (bar) bar.classList.add('open');
1932
+ if (input) { input.value = ''; input.focus(); }
1933
+ _findMatches = [];
1934
+ _findIndex = -1;
1935
+ updateFindCount();
1936
+ }
1937
+
1938
+ function closeFind() {
1939
+ _findOpen = false;
1940
+ const bar = $('fe-editor-find');
1941
+ if (bar) bar.classList.remove('open');
1942
+ _findMatches = [];
1943
+ _findIndex = -1;
1944
+ updateFindCount();
1945
+ }
1946
+
1947
+ function doFind(query) {
1948
+ _findMatches = [];
1949
+ _findIndex = -1;
1950
+ if (!query) { updateFindCount(); return; }
1951
+
1952
+ const textarea = $('fe-editor-textarea');
1953
+ if (!textarea) return;
1954
+ const text = textarea.value;
1955
+ const lower = text.toLowerCase();
1956
+ const q = query.toLowerCase();
1957
+ let pos = 0;
1958
+ while (true) {
1959
+ const idx = lower.indexOf(q, pos);
1960
+ if (idx === -1) break;
1961
+ _findMatches.push({ start: idx, end: idx + q.length });
1962
+ pos = idx + 1;
1963
+ }
1964
+ if (_findMatches.length > 0) {
1965
+ _findIndex = 0;
1966
+ selectFindMatch();
1967
+ }
1968
+ updateFindCount();
1969
+ }
1970
+
1971
+ function findNext() {
1972
+ if (_findMatches.length === 0) return;
1973
+ _findIndex = (_findIndex + 1) % _findMatches.length;
1974
+ selectFindMatch(true);
1975
+ updateFindCount();
1976
+ }
1977
+
1978
+ function findPrev() {
1979
+ if (_findMatches.length === 0) return;
1980
+ _findIndex = (_findIndex - 1 + _findMatches.length) % _findMatches.length;
1981
+ selectFindMatch(true);
1982
+ updateFindCount();
1983
+ }
1984
+
1985
+ function selectFindMatch(focusTextarea) {
1986
+ const textarea = $('fe-editor-textarea');
1987
+ if (!textarea || _findIndex < 0 || _findIndex >= _findMatches.length) return;
1988
+ const m = _findMatches[_findIndex];
1989
+ if (focusTextarea) {
1990
+ textarea.focus();
1991
+ textarea.setSelectionRange(m.start, m.end);
1992
+ }
1993
+ // Scroll match into view
1994
+ const text = textarea.value.substring(0, m.start);
1995
+ const lineNum = text.split('\n').length;
1996
+ const lineHeight = 20;
1997
+ const targetScroll = (lineNum - 5) * lineHeight; // a few lines above
1998
+ textarea.scrollTop = Math.max(0, targetScroll);
1999
+ }
2000
+
2001
+ function updateFindCount() {
2002
+ const el = $('fe-editor-find-count');
2003
+ if (!el) return;
2004
+ if (_findMatches.length === 0) {
2005
+ const input = $('fe-editor-find-input');
2006
+ el.textContent = input?.value ? 'No results' : '';
2007
+ } else {
2008
+ el.textContent = `${_findIndex + 1} of ${_findMatches.length}`;
2009
+ }
2010
+ }
2011
+
2012
+ function doReplace() {
2013
+ const textarea = $('fe-editor-textarea');
2014
+ const replaceInput = $('fe-editor-replace-input');
2015
+ if (!textarea || !replaceInput || _findMatches.length === 0 || _findIndex < 0) return;
2016
+ const m = _findMatches[_findIndex];
2017
+ const replacement = replaceInput.value;
2018
+ const savedIndex = _findIndex;
2019
+ editorReplaceRange(textarea, m.start, m.end, replacement);
2020
+ textarea.selectionStart = textarea.selectionEnd = m.start + replacement.length;
2021
+ updateEditorDirtyState();
2022
+ updateGutter();
2023
+ doFind($('fe-editor-find-input')?.value || '');
2024
+ // Stay at same index (next match slid into this position)
2025
+ if (_findMatches.length > 0) {
2026
+ _findIndex = Math.min(savedIndex, _findMatches.length - 1);
2027
+ selectFindMatch(true);
2028
+ updateFindCount();
2029
+ }
2030
+ }
2031
+
2032
+ function doReplaceAll() {
2033
+ const textarea = $('fe-editor-textarea');
2034
+ const findInput = $('fe-editor-find-input');
2035
+ const replaceInput = $('fe-editor-replace-input');
2036
+ if (!textarea || !findInput?.value || !replaceInput) return;
2037
+ const query = findInput.value;
2038
+ const replacement = replaceInput.value;
2039
+ const text = textarea.value;
2040
+ const lower = text.toLowerCase();
2041
+ const q = query.toLowerCase();
2042
+ const parts = [];
2043
+ let pos = 0;
2044
+ while (true) {
2045
+ const idx = lower.indexOf(q, pos);
2046
+ if (idx === -1) break;
2047
+ parts.push(text.substring(pos, idx), replacement);
2048
+ pos = idx + q.length;
2049
+ }
2050
+ parts.push(text.substring(pos));
2051
+ editorPushUndo(textarea);
2052
+ textarea.value = parts.join('');
2053
+ updateEditorDirtyState();
2054
+ updateGutter();
2055
+ updateHighlight();
2056
+ doFind(query);
2057
+ }
2058
+
2059
+ // ─── Go to Line ───────────────────────────
2060
+ function openGoToLine() {
2061
+ const bar = $('fe-editor-goto');
2062
+ const input = $('fe-editor-goto-input');
2063
+ if (bar) bar.classList.add('open');
2064
+ if (input) { input.value = ''; input.focus(); }
2065
+ }
2066
+
2067
+ function closeGoToLine() {
2068
+ const bar = $('fe-editor-goto');
2069
+ if (bar) bar.classList.remove('open');
2070
+ }
2071
+
2072
+ function goToLine(lineNum) {
2073
+ const textarea = $('fe-editor-textarea');
2074
+ if (!textarea || !lineNum || lineNum < 1) return;
2075
+ const lines = textarea.value.split('\n');
2076
+ const target = Math.min(lineNum, lines.length);
2077
+ let pos = 0;
2078
+ for (let i = 0; i < target - 1; i++) pos += lines[i].length + 1;
2079
+ textarea.focus();
2080
+ textarea.setSelectionRange(pos, pos);
2081
+ const lineHeight = 20;
2082
+ textarea.scrollTop = Math.max(0, (target - 5) * lineHeight);
2083
+ updateCursorPos();
2084
+ updateLineHighlight();
2085
+ syncGutterScroll();
2086
+ closeGoToLine();
2087
+ }
2088
+
2089
+
2090
+ async function buildFileTree() {
2091
+ const tree = $('fe-tree');
2092
+ if (!tree) return;
2093
+ tree.innerHTML = '';
2094
+
2095
+ const data = _cache.values().next().value || await loadDirectory();
2096
+ if (!data || !data.items) return;
2097
+
2098
+ const items = sortItems(data.items);
2099
+ const rootPath = data.path || '';
2100
+
2101
+ for (const item of items) {
2102
+ // Apply client-side filter
2103
+ if (_filterText && !item.name.toLowerCase().includes(_filterText.toLowerCase())) continue;
2104
+ item.fullPath = rootPath + '/' + item.name;
2105
+ const node = createRow(item, 0);
2106
+
2107
+ // Restore expanded state
2108
+ if (item.type === 'dir' && !_collapsed.has(item.fullPath)) {
2109
+ // Only auto-expand if previously expanded (not collapsed)
2110
+ // By default dirs start collapsed (not in _collapsed means not explicitly toggled)
2111
+ }
2112
+
2113
+ tree.appendChild(node);
2114
+ }
2115
+
2116
+ updateCount(items.length);
2117
+ }
2118
+
2119
+ async function expandDir(nodeEl, dirPath, depth) {
2120
+ nodeEl.dataset.expanded = 'true';
2121
+ _collapsed.delete(dirPath);
2122
+ saveCollapsed();
2123
+
2124
+ const childrenEl = nodeEl.querySelector('.fe-children');
2125
+ if (!childrenEl) return;
2126
+
2127
+ // If already loaded, just show
2128
+ if (childrenEl.children.length > 0) return;
2129
+
2130
+ // Loading indicator
2131
+ childrenEl.innerHTML = '<div style="padding:4px 0 4px ' + (depth * 16 + 24) + 'px;font-size:11px;color:rgba(255,255,255,0.25);font-family:var(--ff-mono);">Loading...</div>';
2132
+
2133
+ const data = await loadDirectory(dirPath);
2134
+ childrenEl.innerHTML = '';
2135
+
2136
+ if (!data || !data.items) return;
2137
+
2138
+ const items = sortItems(data.items);
2139
+ for (const item of items) {
2140
+ if (_filterText && !item.name.toLowerCase().includes(_filterText.toLowerCase())) continue;
2141
+ item.fullPath = dirPath + '/' + item.name;
2142
+ const child = createRow(item, depth);
2143
+ childrenEl.appendChild(child);
2144
+ }
2145
+ }
2146
+
2147
+ function collapseDir(nodeEl, dirPath) {
2148
+ nodeEl.dataset.expanded = 'false';
2149
+ if (dirPath) {
2150
+ _collapsed.add(dirPath);
2151
+ saveCollapsed();
2152
+ }
2153
+ }
2154
+
2155
+ function collapseAll() {
2156
+ const tree = $('fe-tree');
2157
+ if (!tree) return;
2158
+ tree.querySelectorAll('.fe-node[data-type="dir"][data-expanded="true"]').forEach(n => {
2159
+ n.dataset.expanded = 'false';
2160
+ _collapsed.add(n.dataset.path);
2161
+ });
2162
+ saveCollapsed();
2163
+ }
2164
+
2165
+ async function expandAll() {
2166
+ const tree = $('fe-tree');
2167
+ if (!tree) return;
2168
+ const dirs = tree.querySelectorAll('.fe-node[data-type="dir"][data-expanded="false"]');
2169
+ for (const n of dirs) {
2170
+ const path = n.dataset.path;
2171
+ // Determine depth by counting ancestor fe-children
2172
+ let depth = 0;
2173
+ let parent = n.parentElement;
2174
+ while (parent && parent !== tree) {
2175
+ if (parent.classList.contains('fe-children')) depth++;
2176
+ parent = parent.parentElement;
2177
+ }
2178
+ await expandDir(n, path, depth + 1);
2179
+ }
2180
+ }
2181
+
2182
+ function updateCount(count) {
2183
+ const el = $('fe-count');
2184
+ if (el) el.textContent = `${count} items`;
2185
+ }
2186
+
2187
+
2188
+ // ═══════════════════════════════════════════
2189
+ // SEARCH (debounced server-side for >2 chars)
2190
+ // ═══════════════════════════════════════════
2191
+
2192
+ let _searchTimer = null;
2193
+
2194
+ function onFilterInput(e) {
2195
+ _filterText = e.target.value.trim();
2196
+
2197
+ clearTimeout(_searchTimer);
2198
+
2199
+ if (_filterText.length > 2) {
2200
+ _searchTimer = setTimeout(async () => {
2201
+ const tree = $('fe-tree');
2202
+ if (!tree) return;
2203
+ tree.innerHTML = '<div style="padding:12px;font-size:11px;color:rgba(255,255,255,0.3);font-family:var(--ff-mono);text-align:center;">Searching...</div>';
2204
+
2205
+ const data = await searchFiles(_filterText);
2206
+ tree.innerHTML = '';
2207
+
2208
+ if (!data || !data.items || data.items.length === 0) {
2209
+ tree.innerHTML = '<div style="padding:12px;font-size:11px;color:rgba(255,255,255,0.25);font-family:var(--ff-mono);text-align:center;">No results</div>';
2210
+ updateCount(0);
2211
+ return;
2212
+ }
2213
+
2214
+ for (const item of data.items) {
2215
+ item.fullPath = item.path;
2216
+ const node = createRow(item, 0);
2217
+ // Show relative path for search results
2218
+ const nameEl = node.querySelector('.fe-name');
2219
+ if (nameEl) nameEl.textContent = item.path;
2220
+ tree.appendChild(node);
2221
+ }
2222
+ updateCount(data.items.length);
2223
+ }, 300);
2224
+ } else {
2225
+ // Client-side filter on loaded tree
2226
+ buildFileTree();
2227
+ }
2228
+ }
2229
+
2230
+
2231
+ // ═══════════════════════════════════════════
2232
+ // RESIZE HANDLE
2233
+ // ═══════════════════════════════════════════
2234
+
2235
+ function initResizeHandle() {
2236
+ const handle = $('fe-resize-handle');
2237
+ const panel = $('file-explorer-panel');
2238
+ if (!handle || !panel) return;
2239
+
2240
+ let dragging = false;
2241
+
2242
+ handle.addEventListener('mousedown', (e) => {
2243
+ e.preventDefault();
2244
+ dragging = true;
2245
+ handle.classList.add('active');
2246
+ document.body.style.cursor = 'col-resize';
2247
+ document.body.style.userSelect = 'none';
2248
+ });
2249
+
2250
+ document.addEventListener('mousemove', (e) => {
2251
+ if (!dragging) return;
2252
+ // The panel left edge starts at --explorer-width
2253
+ const explorerWidth = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--explorer-width')) || 0;
2254
+ const newWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, e.clientX - explorerWidth));
2255
+ _width = newWidth;
2256
+ panel.style.width = `${_width}px`;
2257
+ applyFileExplorerWidth();
2258
+ });
2259
+
2260
+ document.addEventListener('mouseup', () => {
2261
+ if (!dragging) return;
2262
+ dragging = false;
2263
+ handle.classList.remove('active');
2264
+ document.body.style.cursor = '';
2265
+ document.body.style.userSelect = '';
2266
+ saveWidth();
2267
+ window.dispatchEvent(new Event('resize'));
2268
+ });
2269
+ }
2270
+
2271
+
2272
+ // ═══════════════════════════════════════════
2273
+ // TOGGLE
2274
+ // ═══════════════════════════════════════════
2275
+
2276
+ export function toggleFileExplorer() {
2277
+ const panel = $('file-explorer-panel');
2278
+ if (!panel) return;
2279
+
2280
+ // If closing while editor is open + dirty, prompt first
2281
+ if (_visible && _editorOpen) {
2282
+ if (!closeFileEditor()) return; // User cancelled discard
2283
+ }
2284
+
2285
+ _visible = !_visible;
2286
+ panel.classList.toggle('open', _visible);
2287
+ saveVisible();
2288
+
2289
+ // Toggle unified title bar style (seamless with sidebar, slides under title bar)
2290
+ const titleBar = document.getElementById('title-bar');
2291
+ if (titleBar) titleBar.classList.toggle('file-explorer-active', _visible);
2292
+ document.body.classList.toggle('file-explorer-open', _visible);
2293
+
2294
+ applyFileExplorerWidth();
2295
+
2296
+ // Resize after transition
2297
+ setTimeout(() => {
2298
+ window.dispatchEvent(new Event('resize'));
2299
+ }, 320);
2300
+
2301
+ // Sync menu checkmark
2302
+ const menuEl = $('menu-toggle-file-explorer');
2303
+ if (menuEl) menuEl.classList.toggle('active', _visible);
2304
+ const tbBtn = $('topright-file-explorer-btn');
2305
+ if (tbBtn) tbBtn.classList.toggle('active', _visible);
2306
+
2307
+ // Build tree on first show
2308
+ if (_visible && !_rootLoaded) {
2309
+ _rootLoaded = true;
2310
+ fetchProjects().then(() => {
2311
+ initProjectSelector();
2312
+ return loadDirectory();
2313
+ }).then(() => buildFileTree());
2314
+ }
2315
+ }
2316
+
2317
+
2318
+ // ═══════════════════════════════════════════
2319
+ // INIT
2320
+ // ═══════════════════════════════════════════
2321
+
2322
+ async function loadCustomIcons() {
2323
+ try {
2324
+ const r = await fetch('/api/file-icons').then(r => r.json());
2325
+ if (r.ok) _customIcons = r.custom;
2326
+ } catch {}
2327
+ }
2328
+
2329
+ export function initFileExplorer() {
2330
+ loadPersistedState();
2331
+ loadCustomIcons(); // non-blocking — icons load in background
2332
+
2333
+ // Re-render tree when custom icons change
2334
+ on('sync:icons:changed', () => {
2335
+ loadCustomIcons().then(() => { if (_rootLoaded) buildFileTree(); });
2336
+ });
2337
+
2338
+ // ── Plan editor — open plan file from Claude panel for direct editing ──
2339
+ on('open-plan-editor', ({ filePath }) => {
2340
+ _planEditMode = true;
2341
+ _planEditFilePath = filePath;
2342
+ openFileEditor(filePath);
2343
+ });
2344
+
2345
+ // ── Changelog editor — open changelog draft from Claude panel for direct editing ──
2346
+ on('open-changelog-editor', ({ filePath }) => {
2347
+ _changelogEditMode = true;
2348
+ _changelogEditFilePath = filePath;
2349
+ openFileEditor(filePath);
2350
+ });
2351
+
2352
+ const panel = $('file-explorer-panel');
2353
+ if (!panel) return;
2354
+
2355
+ // Set width — use navbar-aligned default if no persisted value
2356
+ if (!storage.getItem(KEYS.FILE_EXPLORER_WIDTH)) {
2357
+ _width = calcDefaultWidth();
2358
+ }
2359
+ panel.style.width = `${_width}px`;
2360
+
2361
+ // Wire filter
2362
+ const filterInput = $('fe-filter-input');
2363
+ const filterClear = $('fe-filter-clear');
2364
+ if (filterInput) filterInput.addEventListener('input', onFilterInput);
2365
+ if (filterClear) filterClear.addEventListener('click', () => {
2366
+ if (filterInput) filterInput.value = '';
2367
+ _filterText = '';
2368
+ buildFileTree();
2369
+ });
2370
+
2371
+ // Wire toolbar
2372
+ const collapseBtn = $('fe-collapse-all');
2373
+ const expandBtn = $('fe-expand-all');
2374
+ const sortBtn = $('fe-sort-btn');
2375
+ const refreshBtn = $('fe-refresh');
2376
+
2377
+ if (collapseBtn) collapseBtn.addEventListener('click', collapseAll);
2378
+ if (expandBtn) expandBtn.addEventListener('click', expandAll);
2379
+
2380
+ if (sortBtn) {
2381
+ const sortModes = ['name', 'size', 'date'];
2382
+ const sortLabels = { name: 'Sort: name', size: 'Sort: size', date: 'Sort: date' };
2383
+ sortBtn.addEventListener('click', () => {
2384
+ const idx = sortModes.indexOf(_sortMode);
2385
+ _sortMode = sortModes[(idx + 1) % sortModes.length];
2386
+ sortBtn.dataset.tooltip = sortLabels[_sortMode];
2387
+ saveSort();
2388
+ buildFileTree();
2389
+ });
2390
+ sortBtn.dataset.tooltip = `Sort: ${_sortMode}`;
2391
+ }
2392
+
2393
+ const undoBtn = $('fe-undo-btn');
2394
+ const redoBtn = $('fe-redo-btn');
2395
+ if (undoBtn) undoBtn.addEventListener('click', undoDelete);
2396
+ if (redoBtn) redoBtn.addEventListener('click', redoDelete);
2397
+
2398
+ if (refreshBtn) refreshBtn.addEventListener('click', () => {
2399
+ _cache.clear();
2400
+ _rootLoaded = false;
2401
+ fetchProjects().then(() => {
2402
+ initProjectSelector();
2403
+ return loadDirectory();
2404
+ }).then(() => {
2405
+ _rootLoaded = true;
2406
+ buildFileTree();
2407
+ });
2408
+ });
2409
+
2410
+ // Editor buttons + features
2411
+ const editorBack = $('fe-editor-back');
2412
+ const editorSave = $('fe-editor-save');
2413
+ const editorDiscard = $('fe-editor-discard');
2414
+ const editorClose = $('fe-editor-close');
2415
+ const editorTextarea = $('fe-editor-textarea');
2416
+ const wordWrapBtn = $('fe-editor-word-wrap');
2417
+
2418
+ if (editorBack) editorBack.addEventListener('click', closeFileEditor);
2419
+ if (editorSave) editorSave.addEventListener('click', saveFileEditor);
2420
+ if (editorDiscard) editorDiscard.addEventListener('click', discardFileEditor);
2421
+ if (editorClose) editorClose.addEventListener('click', closeFileEditor);
2422
+
2423
+ // Word wrap toggle (enabled by default)
2424
+ if (wordWrapBtn) wordWrapBtn.classList.add('active');
2425
+ if (wordWrapBtn && editorTextarea) {
2426
+ wordWrapBtn.addEventListener('click', () => {
2427
+ const wrapped = editorTextarea.style.whiteSpace === 'pre-wrap';
2428
+ const newWrap = wrapped ? 'pre' : 'pre-wrap';
2429
+ const newOverflow = wrapped ? 'normal' : 'break-word';
2430
+ editorTextarea.style.whiteSpace = newWrap;
2431
+ editorTextarea.style.overflowWrap = newOverflow;
2432
+ const hl = $('fe-editor-highlight');
2433
+ if (hl) { hl.style.whiteSpace = newWrap; hl.style.overflowWrap = newOverflow; }
2434
+ wordWrapBtn.classList.toggle('active', !wrapped);
2435
+ });
2436
+ }
2437
+
2438
+ // Keybinds help popover
2439
+ const helpBtn = $('fe-editor-help-btn');
2440
+ const keybindsPanel = $('fe-editor-keybinds');
2441
+ if (helpBtn && keybindsPanel) {
2442
+ helpBtn.addEventListener('click', (e) => {
2443
+ e.stopPropagation();
2444
+ keybindsPanel.classList.toggle('open');
2445
+ });
2446
+ document.addEventListener('click', () => keybindsPanel.classList.remove('open'));
2447
+ keybindsPanel.addEventListener('click', (e) => e.stopPropagation());
2448
+ }
2449
+
2450
+ // Raw/Preview toggle for markdown files
2451
+ document.querySelectorAll('.fe-editor-toggle-btn').forEach(btn => {
2452
+ btn.addEventListener('click', () => {
2453
+ const mode = btn.dataset.mode;
2454
+ _editorPreviewMode = mode === 'preview';
2455
+ document.querySelectorAll('.fe-editor-toggle-btn').forEach(b => b.classList.toggle('active', b === btn));
2456
+ setPreviewMode(_editorPreviewMode);
2457
+ });
2458
+ });
2459
+
2460
+ // Find & Replace bar
2461
+ const findBtn = $('fe-editor-find-btn');
2462
+ const findInput = $('fe-editor-find-input');
2463
+ const findPrevBtn = $('fe-editor-find-prev');
2464
+ const findNextBtn = $('fe-editor-find-next');
2465
+ const findCloseBtn = $('fe-editor-find-close');
2466
+ const replaceBtn = $('fe-editor-replace');
2467
+ const replaceAllBtn = $('fe-editor-replace-all');
2468
+ const replaceInput = $('fe-editor-replace-input');
2469
+
2470
+ if (findBtn) findBtn.addEventListener('click', () => {
2471
+ if (_findOpen) closeFind(); else openFind();
2472
+ });
2473
+ if (findCloseBtn) findCloseBtn.addEventListener('click', closeFind);
2474
+ if (findNextBtn) findNextBtn.addEventListener('click', findNext);
2475
+ if (findPrevBtn) findPrevBtn.addEventListener('click', findPrev);
2476
+ if (replaceBtn) replaceBtn.addEventListener('click', doReplace);
2477
+ if (replaceAllBtn) replaceAllBtn.addEventListener('click', doReplaceAll);
2478
+ if (findInput) {
2479
+ findInput.addEventListener('input', () => doFind(findInput.value));
2480
+ findInput.addEventListener('keydown', (e) => {
2481
+ if (e.key === 'Enter') { e.shiftKey ? findPrev() : findNext(); }
2482
+ if (e.key === 'Escape') closeFind();
2483
+ });
2484
+ }
2485
+ if (replaceInput) {
2486
+ replaceInput.addEventListener('keydown', (e) => {
2487
+ if (e.key === 'Enter') doReplace();
2488
+ if (e.key === 'Escape') closeFind();
2489
+ });
2490
+ }
2491
+
2492
+ // Go to Line
2493
+ const gotoInput = $('fe-editor-goto-input');
2494
+ if (gotoInput) {
2495
+ gotoInput.addEventListener('keydown', (e) => {
2496
+ if (e.key === 'Enter') { goToLine(parseInt(gotoInput.value, 10)); }
2497
+ if (e.key === 'Escape') closeGoToLine();
2498
+ });
2499
+ }
2500
+
2501
+ // Auto-close bracket/quote pairs
2502
+ const PAIRS = { '(': ')', '[': ']', '{': '}', '"': '"', "'": "'", '`': '`' };
2503
+
2504
+ if (editorTextarea) {
2505
+ editorTextarea.addEventListener('input', () => {
2506
+ updateEditorDirtyState();
2507
+ updateGutter();
2508
+ updateHighlight();
2509
+ updateLineHighlight();
2510
+ });
2511
+ // Scroll sync: gutter follows textarea, update scrollmap + line highlight + syntax
2512
+ editorTextarea.addEventListener('scroll', () => {
2513
+ syncGutterScroll();
2514
+ syncHighlightScroll();
2515
+ updateScrollmap();
2516
+ updateLineHighlight();
2517
+ });
2518
+ // Cursor position + line highlight tracking
2519
+ editorTextarea.addEventListener('click', () => { updateCursorPos(); updateLineHighlight(); });
2520
+ editorTextarea.addEventListener('keyup', () => { updateCursorPos(); updateLineHighlight(); });
2521
+ editorTextarea.addEventListener('select', () => { updateCursorPos(); updateLineHighlight(); });
2522
+
2523
+ let _undoBurstTimer = null;
2524
+
2525
+ editorTextarea.addEventListener('keydown', (e) => {
2526
+ const ta = e.target;
2527
+ const mod = e.ctrlKey || e.metaKey;
2528
+
2529
+ // Typing snapshot — capture pre-edit state on first key of each burst
2530
+ const isTypingKey = e.key.length === 1 && !mod;
2531
+ const isDeleteKey = (e.key === 'Backspace' || e.key === 'Delete') && !mod;
2532
+ if ((isTypingKey || isDeleteKey) && !PAIRS[e.key]) {
2533
+ if (!_undoBurstOpen) {
2534
+ _undoBurstOpen = true;
2535
+ const top = _undoStack.length ? _undoStack[_undoStack.length - 1] : null;
2536
+ if (!top || top.value !== ta.value) {
2537
+ _undoStack.push({ value: ta.value, selStart: ta.selectionStart, selEnd: ta.selectionEnd });
2538
+ if (_undoStack.length > 200) _undoStack.shift();
2539
+ _redoStack = [];
2540
+ }
2541
+ }
2542
+ clearTimeout(_undoBurstTimer);
2543
+ _undoBurstTimer = setTimeout(() => { _undoBurstOpen = false; }, 400);
2544
+ }
2545
+
2546
+ // Ctrl+Z → undo
2547
+ if (e.key === 'z' && mod && !e.shiftKey) {
2548
+ e.preventDefault();
2549
+ editorUndo(ta);
2550
+ return;
2551
+ }
2552
+
2553
+ // Ctrl+Y or Ctrl+Shift+Z → redo
2554
+ if ((e.key === 'y' && mod) || (e.key === 'z' && mod && e.shiftKey)) {
2555
+ e.preventDefault();
2556
+ editorRedo(ta);
2557
+ return;
2558
+ }
2559
+
2560
+ // Tab → insert 2 spaces
2561
+ if (e.key === 'Tab' && !e.shiftKey) {
2562
+ e.preventDefault();
2563
+ editorInsertText(ta, ' ');
2564
+ return;
2565
+ }
2566
+
2567
+ // Shift+Tab → unindent selected lines
2568
+ if (e.key === 'Tab' && e.shiftKey) {
2569
+ e.preventDefault();
2570
+ const start = ta.selectionStart;
2571
+ const end = ta.selectionEnd;
2572
+ const val = ta.value;
2573
+ const lineStart = val.lastIndexOf('\n', start - 1) + 1;
2574
+ const lineEnd = val.indexOf('\n', end);
2575
+ const blockEnd = lineEnd === -1 ? val.length : lineEnd;
2576
+ const block = val.substring(lineStart, blockEnd);
2577
+ const unindented = block.split('\n').map(l => l.startsWith(' ') ? l.slice(2) : l.replace(/^\t/, '')).join('\n');
2578
+ editorReplaceRange(ta, lineStart, blockEnd, unindented);
2579
+ ta.setSelectionRange(lineStart, lineStart + unindented.length);
2580
+ updateEditorDirtyState();
2581
+ updateGutter();
2582
+ return;
2583
+ }
2584
+
2585
+ // Enter → auto-indent
2586
+ if (e.key === 'Enter' && !mod) {
2587
+ e.preventDefault();
2588
+ const pos = ta.selectionStart;
2589
+ const val = ta.value;
2590
+ const lineStart = val.lastIndexOf('\n', pos - 1) + 1;
2591
+ const line = val.substring(lineStart, pos);
2592
+ const indent = line.match(/^(\s*)/)[1];
2593
+ editorInsertText(ta, '\n' + indent);
2594
+ return;
2595
+ }
2596
+
2597
+ // Ctrl+D → duplicate line
2598
+ if (e.key === 'd' && mod) {
2599
+ e.preventDefault();
2600
+ const pos = ta.selectionStart;
2601
+ const val = ta.value;
2602
+ const lineStart = val.lastIndexOf('\n', pos - 1) + 1;
2603
+ let lineEnd = val.indexOf('\n', pos);
2604
+ if (lineEnd === -1) lineEnd = val.length;
2605
+ const line = val.substring(lineStart, lineEnd);
2606
+ editorPushUndo(ta);
2607
+ ta.value = val.substring(0, lineEnd) + '\n' + line + val.substring(lineEnd);
2608
+ const newPos = lineEnd + 1 + line.length;
2609
+ ta.selectionStart = ta.selectionEnd = newPos;
2610
+ updateEditorDirtyState();
2611
+ updateGutter();
2612
+ return;
2613
+ }
2614
+
2615
+ // Ctrl+/ → toggle line comment
2616
+ if (e.key === '/' && mod) {
2617
+ e.preventDefault();
2618
+ const lang = $('fe-editor-lang')?.textContent || '';
2619
+ const prefix = COMMENT_PREFIX[lang] || '//';
2620
+ const start = ta.selectionStart;
2621
+ const end = ta.selectionEnd;
2622
+ const val = ta.value;
2623
+ const lineStart = val.lastIndexOf('\n', start - 1) + 1;
2624
+ let lineEnd = val.indexOf('\n', end);
2625
+ if (lineEnd === -1) lineEnd = val.length;
2626
+ const block = val.substring(lineStart, lineEnd);
2627
+ const lines = block.split('\n');
2628
+ const allCommented = lines.every(l => l.trimStart().startsWith(prefix));
2629
+ const toggled = lines.map(l => {
2630
+ if (allCommented) {
2631
+ const idx = l.indexOf(prefix);
2632
+ return l.substring(0, idx) + l.substring(idx + prefix.length + (l[idx + prefix.length] === ' ' ? 1 : 0));
2633
+ }
2634
+ return prefix + ' ' + l;
2635
+ }).join('\n');
2636
+ editorReplaceRange(ta, lineStart, lineEnd, toggled);
2637
+ ta.setSelectionRange(lineStart, lineStart + toggled.length);
2638
+ updateEditorDirtyState();
2639
+ updateGutter();
2640
+ return;
2641
+ }
2642
+
2643
+ // Ctrl+S → save
2644
+ if (e.key === 's' && mod) {
2645
+ e.preventDefault();
2646
+ saveFileEditor();
2647
+ return;
2648
+ }
2649
+
2650
+ // Ctrl+F → find
2651
+ if (e.key === 'f' && mod) {
2652
+ e.preventDefault();
2653
+ openFind();
2654
+ return;
2655
+ }
2656
+
2657
+ // Ctrl+G → go to line
2658
+ if (e.key === 'g' && mod) {
2659
+ e.preventDefault();
2660
+ openGoToLine();
2661
+ return;
2662
+ }
2663
+
2664
+ // Ctrl+H → focus replace (open find first if needed)
2665
+ if (e.key === 'h' && mod) {
2666
+ e.preventDefault();
2667
+ if (!_findOpen) openFind();
2668
+ const ri = $('fe-editor-replace-input');
2669
+ if (ri) ri.focus();
2670
+ return;
2671
+ }
2672
+
2673
+ // Auto-close brackets and quotes
2674
+ if (PAIRS[e.key]) {
2675
+ const pos = ta.selectionStart;
2676
+ const end = ta.selectionEnd;
2677
+ editorPushUndo(ta);
2678
+ if (pos !== end) {
2679
+ const selected = ta.value.substring(pos, end);
2680
+ ta.value = ta.value.substring(0, pos) + e.key + selected + PAIRS[e.key] + ta.value.substring(end);
2681
+ ta.setSelectionRange(pos + 1, pos + 1 + selected.length);
2682
+ } else {
2683
+ ta.value = ta.value.substring(0, pos) + e.key + PAIRS[e.key] + ta.value.substring(pos);
2684
+ ta.setSelectionRange(pos + 1, pos + 1);
2685
+ }
2686
+ e.preventDefault();
2687
+ updateEditorDirtyState();
2688
+ return;
2689
+ }
2690
+
2691
+ // Backspace → delete matching pair if cursor is between them
2692
+ if (e.key === 'Backspace' && !mod) {
2693
+ const pos = ta.selectionStart;
2694
+ if (pos > 0 && pos === ta.selectionEnd) {
2695
+ const before = ta.value[pos - 1];
2696
+ const after = ta.value[pos];
2697
+ if (PAIRS[before] === after) {
2698
+ e.preventDefault();
2699
+ editorPushUndo(ta);
2700
+ ta.value = ta.value.substring(0, pos - 1) + ta.value.substring(pos + 1);
2701
+ ta.selectionStart = ta.selectionEnd = pos - 1;
2702
+ updateEditorDirtyState();
2703
+ updateGutter();
2704
+ return;
2705
+ }
2706
+ }
2707
+ }
2708
+ });
2709
+ }
2710
+
2711
+ // Git panel
2712
+ initGitPanel();
2713
+
2714
+ // Resize handle
2715
+ initResizeHandle();
2716
+
2717
+ // Keybind
2718
+ registerAction('toggle-file-explorer', toggleFileExplorer);
2719
+
2720
+ // Menu item
2721
+ const menuEl = $('menu-toggle-file-explorer');
2722
+ if (menuEl) {
2723
+ menuEl.addEventListener('click', toggleFileExplorer);
2724
+ }
2725
+
2726
+ // Toolbar button
2727
+ const toolbarBtn = $('topright-file-explorer-btn');
2728
+ if (toolbarBtn) {
2729
+ toolbarBtn.addEventListener('click', toggleFileExplorer);
2730
+ }
2731
+
2732
+ // Restore visibility
2733
+ if (_visible) {
2734
+ panel.classList.add('open');
2735
+ const titleBar = document.getElementById('title-bar');
2736
+ if (titleBar) titleBar.classList.add('file-explorer-active');
2737
+ document.body.classList.add('file-explorer-open');
2738
+ applyFileExplorerWidth();
2739
+ if (menuEl) menuEl.classList.add('active');
2740
+ if (toolbarBtn) toolbarBtn.classList.add('active');
2741
+ _rootLoaded = true;
2742
+ fetchProjects().then(() => {
2743
+ initProjectSelector();
2744
+ return loadDirectory();
2745
+ }).then(() => buildFileTree());
2746
+ }
2747
+ }
2748
+
2749
+ // Exports for Settings → Icons tab preview
2750
+ export { FI, FI_MAP, getFileIcon };