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.
- package/CHANGELOG.md +665 -0
- package/LICENSE +191 -0
- package/README.md +853 -0
- package/hooks/claude-code/post-plan.mjs +391 -0
- package/hooks/claude-code/post-remember.mjs +239 -0
- package/hooks/claude-code/pre-compact.mjs +241 -0
- package/hooks/claude-code/pre-websearch.mjs +90 -0
- package/hooks/claude-code/prompt-submit.mjs +861 -0
- package/hooks/claude-code/session-start.mjs +494 -0
- package/hooks/claude-code/shared.mjs +368 -0
- package/hooks/claude-code/stop.mjs +646 -0
- package/hooks/claude-code/test-user-learning.mjs +531 -0
- package/mcp-server/package.json +38 -0
- package/mcp-server/run.mjs +10 -0
- package/mcp-server/src/config.ts +70 -0
- package/mcp-server/src/http.ts +93 -0
- package/mcp-server/src/index.ts +203 -0
- package/mcp-server/src/preload-http.ts +25 -0
- package/mcp-server/src/preload.ts +29 -0
- package/mcp-server/src/scripts/migrate-qdrant-to-sqlite.ts +506 -0
- package/mcp-server/src/services/categories.ts +429 -0
- package/mcp-server/src/services/discord.ts +702 -0
- package/mcp-server/src/services/file-checksums.ts +33 -0
- package/mcp-server/src/services/local-embeddings.ts +77 -0
- package/mcp-server/src/services/neural-interface.ts +344 -0
- package/mcp-server/src/services/sqlite.ts +823 -0
- package/mcp-server/src/tools/browser-advanced.ts +123 -0
- package/mcp-server/src/tools/browser-interact.ts +255 -0
- package/mcp-server/src/tools/browser-navigate.ts +77 -0
- package/mcp-server/src/tools/browser-observe.ts +1330 -0
- package/mcp-server/src/tools/browser.ts +107 -0
- package/mcp-server/src/tools/card-tools.ts +193 -0
- package/mcp-server/src/tools/card.ts +22 -0
- package/mcp-server/src/tools/category.ts +379 -0
- package/mcp-server/src/tools/discord-channel.ts +274 -0
- package/mcp-server/src/tools/discord-guild.ts +188 -0
- package/mcp-server/src/tools/discord-member.ts +158 -0
- package/mcp-server/src/tools/discord-message.ts +231 -0
- package/mcp-server/src/tools/discord-onboarding.ts +228 -0
- package/mcp-server/src/tools/discord-role.ts +209 -0
- package/mcp-server/src/tools/discord-thread.ts +110 -0
- package/mcp-server/src/tools/discord-webhook.ts +176 -0
- package/mcp-server/src/tools/discord.ts +40 -0
- package/mcp-server/src/tools/forget.ts +38 -0
- package/mcp-server/src/tools/git-tools.ts +141 -0
- package/mcp-server/src/tools/git.ts +13 -0
- package/mcp-server/src/tools/image-tools.ts +98 -0
- package/mcp-server/src/tools/image.ts +17 -0
- package/mcp-server/src/tools/leonardo-browser-tools.ts +281 -0
- package/mcp-server/src/tools/leonardo.ts +28 -0
- package/mcp-server/src/tools/loop.ts +348 -0
- package/mcp-server/src/tools/memories.ts +118 -0
- package/mcp-server/src/tools/recall.ts +323 -0
- package/mcp-server/src/tools/reflect.ts +145 -0
- package/mcp-server/src/tools/remember.ts +115 -0
- package/mcp-server/src/tools/response.ts +26 -0
- package/mcp-server/src/tools/restore.ts +34 -0
- package/mcp-server/src/tools/sync.ts +114 -0
- package/mcp-server/src/tools/tictactoe-tools.ts +98 -0
- package/mcp-server/src/tools/tictactoe.ts +13 -0
- package/mcp-server/src/tools/utils.ts +25 -0
- package/mcp-server/src/tools/whiteboard-tools.ts +260 -0
- package/mcp-server/src/tools/whiteboard.ts +22 -0
- package/mcp-server/src/tui.ts +940 -0
- package/mcp-server/src/types.ts +85 -0
- package/mcp-server/tsconfig.json +19 -0
- package/neural-interface/package.json +27 -0
- package/neural-interface/public/apple-touch-icon.png +0 -0
- package/neural-interface/public/category-logos/claude.svg +1 -0
- package/neural-interface/public/category-logos/gemini-color.svg +1 -0
- package/neural-interface/public/category-logos/openai.svg +1 -0
- package/neural-interface/public/category-logos/synabun.png +0 -0
- package/neural-interface/public/claude-chat.css +878 -0
- package/neural-interface/public/claude-chat.html +92 -0
- package/neural-interface/public/claude-chat.js +1168 -0
- package/neural-interface/public/favicon-16x16.png +0 -0
- package/neural-interface/public/favicon-32x32.png +0 -0
- package/neural-interface/public/gallery.svg +6 -0
- package/neural-interface/public/icon-192.png +0 -0
- package/neural-interface/public/icon-512.png +0 -0
- package/neural-interface/public/index-legacy.html +14677 -0
- package/neural-interface/public/index.html +212 -0
- package/neural-interface/public/index2d-legacy.html +8193 -0
- package/neural-interface/public/index2d.html +127 -0
- package/neural-interface/public/invite.html +351 -0
- package/neural-interface/public/leonardoai.svg +1 -0
- package/neural-interface/public/logo-synabun.png +0 -0
- package/neural-interface/public/logoHD.png +0 -0
- package/neural-interface/public/manifest.json +14 -0
- package/neural-interface/public/offline.html +299 -0
- package/neural-interface/public/onboarding.html +2335 -0
- package/neural-interface/public/openclaw-logo-text.png +0 -0
- package/neural-interface/public/pin-svgrepo-com.svg +25 -0
- package/neural-interface/public/sessionicon.svg +8 -0
- package/neural-interface/public/shared/ansi-parser.js +305 -0
- package/neural-interface/public/shared/api.js +1043 -0
- package/neural-interface/public/shared/colors.js +153 -0
- package/neural-interface/public/shared/constants.js +141 -0
- package/neural-interface/public/shared/html-shell.js +777 -0
- package/neural-interface/public/shared/html-term-renderer.js +1022 -0
- package/neural-interface/public/shared/i18n.js +147 -0
- package/neural-interface/public/shared/registry.js +82 -0
- package/neural-interface/public/shared/state.js +86 -0
- package/neural-interface/public/shared/storage.js +150 -0
- package/neural-interface/public/shared/styles.css +21212 -0
- package/neural-interface/public/shared/ui-automation-studio.js +3573 -0
- package/neural-interface/public/shared/ui-bookmarks.js +231 -0
- package/neural-interface/public/shared/ui-claude-panel.js +6333 -0
- package/neural-interface/public/shared/ui-command-runner.js +534 -0
- package/neural-interface/public/shared/ui-cost-widget.js +602 -0
- package/neural-interface/public/shared/ui-detail.js +1454 -0
- package/neural-interface/public/shared/ui-explorer.js +1356 -0
- package/neural-interface/public/shared/ui-file-explorer.js +2750 -0
- package/neural-interface/public/shared/ui-games.js +411 -0
- package/neural-interface/public/shared/ui-help.js +107 -0
- package/neural-interface/public/shared/ui-image-gallery.js +461 -0
- package/neural-interface/public/shared/ui-invite.js +519 -0
- package/neural-interface/public/shared/ui-keybinds.js +452 -0
- package/neural-interface/public/shared/ui-layouts.js +297 -0
- package/neural-interface/public/shared/ui-link.js +731 -0
- package/neural-interface/public/shared/ui-loading.js +255 -0
- package/neural-interface/public/shared/ui-menubar.js +499 -0
- package/neural-interface/public/shared/ui-multiselect.js +93 -0
- package/neural-interface/public/shared/ui-navbar.js +222 -0
- package/neural-interface/public/shared/ui-notifications.js +287 -0
- package/neural-interface/public/shared/ui-panels.js +309 -0
- package/neural-interface/public/shared/ui-resume.js +653 -0
- package/neural-interface/public/shared/ui-search.js +145 -0
- package/neural-interface/public/shared/ui-sessions.js +531 -0
- package/neural-interface/public/shared/ui-settings.js +5987 -0
- package/neural-interface/public/shared/ui-sidebar.js +1905 -0
- package/neural-interface/public/shared/ui-skills.js +2805 -0
- package/neural-interface/public/shared/ui-stats.js +38 -0
- package/neural-interface/public/shared/ui-sync.js +201 -0
- package/neural-interface/public/shared/ui-terminal.js +6385 -0
- package/neural-interface/public/shared/ui-tooltip.js +123 -0
- package/neural-interface/public/shared/ui-trash.js +439 -0
- package/neural-interface/public/shared/ui-tutorial-draw.js +875 -0
- package/neural-interface/public/shared/ui-tutorial-steps.js +2276 -0
- package/neural-interface/public/shared/ui-tutorial.js +1117 -0
- package/neural-interface/public/shared/ui-update.js +244 -0
- package/neural-interface/public/shared/ui-whiteboard.js +3262 -0
- package/neural-interface/public/shared/ui-workspaces.js +722 -0
- package/neural-interface/public/shared/utils.js +360 -0
- package/neural-interface/public/shared/vterm-buffer.js +1102 -0
- package/neural-interface/public/sw.js +52 -0
- package/neural-interface/public/synabun.png +0 -0
- package/neural-interface/public/toolbar-album.svg +8 -0
- package/neural-interface/public/toolbar-question.svg +6 -0
- package/neural-interface/public/toolbar-sessions.svg +6 -0
- package/neural-interface/public/variant/2d/background.js +113 -0
- package/neural-interface/public/variant/2d/context-menu.js +133 -0
- package/neural-interface/public/variant/2d/gfx.js +45 -0
- package/neural-interface/public/variant/2d/graph.js +1635 -0
- package/neural-interface/public/variant/2d/hulls.js +207 -0
- package/neural-interface/public/variant/2d/lasso.js +135 -0
- package/neural-interface/public/variant/2d/layout.js +594 -0
- package/neural-interface/public/variant/2d/main.js +521 -0
- package/neural-interface/public/variant/2d/minimap.js +312 -0
- package/neural-interface/public/variant/2d/settings-gfx.js +98 -0
- package/neural-interface/public/variant/3d/background.js +884 -0
- package/neural-interface/public/variant/3d/camera.js +661 -0
- package/neural-interface/public/variant/3d/gfx.js +116 -0
- package/neural-interface/public/variant/3d/graph.js +2399 -0
- package/neural-interface/public/variant/3d/main.js +500 -0
- package/neural-interface/public/variant/3d/settings-gfx.js +228 -0
- package/neural-interface/scripts/rebuild-pty.js +100 -0
- package/neural-interface/server.js +15000 -0
- package/neural-interface/templates/CLAUDE-template.md +211 -0
- package/package.json +53 -0
- package/postinstall.js +135 -0
- package/setup.js +260 -0
- package/skills/leonardo/SKILL.md +159 -0
- package/skills/leonardo/icon.svg +1 -0
- package/skills/leonardo/modules/browser-guide.md +378 -0
- package/skills/leonardo/modules/image-prompter.md +277 -0
- package/skills/leonardo/modules/model-advisor.md +100 -0
- package/skills/leonardo/modules/prompt-library.md +110 -0
- package/skills/leonardo/modules/style-guide.md +186 -0
- package/skills/leonardo/modules/video-prompter.md +311 -0
- package/skills/synabun/SKILL.md +185 -0
- package/skills/synabun/icon.png +0 -0
- package/skills/synabun/modules/audit.md +374 -0
- package/skills/synabun/modules/changelog.md +194 -0
- package/skills/synabun/modules/idea.md +144 -0
- package/skills/synabun/modules/memorize.md +188 -0
- 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(' · ');
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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(/^>\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 };
|