main
js 1,843 lines 60.8 KB
Raw
1 import { createStore } from "/js/AlpineStore.js";
2 import { callJsonApi } from "/js/api.js";
3 import { getNamespacedClient } from "/js/websocket.js";
4 import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
5 import {
6 openLatest as openLatestSurface,
7 placeSurfaceModalHeaderAction,
8 registerUrlHandler,
9 setupFloatingSurfaceModalChrome,
10 } from "/js/surfaces.js";
11 import {
12 buildMarkdownPages,
13 isExternalHref,
14 isMarkdownPath,
15 renderEditorPreviewMarkdown,
16 resolveDocumentRelativePath,
17 slugifyHeading,
18 splitHref,
19 } from "/plugins/_editor/webui/editor-preview.js";
20
21 const editorSocket = getNamespacedClient("/ws");
22 editorSocket.addHandlers(["ws_webui"]);
23
24 const SAVE_MESSAGE_MS = 1800;
25 const INPUT_PUSH_DELAY_MS = 650;
26 const MAX_HISTORY = 80;
27 const SOURCE_MODE = "source";
28 const PREVIEW_MODE = "preview";
29 const EDITOR_TEXT_EXTENSIONS = new Set(["md", "txt"]);
30
31 function currentContextId() {
32 try {
33 return globalThis.getContext?.() || "";
34 } catch {
35 return "";
36 }
37 }
38
39 function basename(path = "") {
40 const value = String(path || "").split("?")[0].split("#")[0];
41 return value.split("/").filter(Boolean).pop() || "Untitled";
42 }
43
44 function extensionOf(path = "") {
45 const name = basename(path).toLowerCase();
46 const index = name.lastIndexOf(".");
47 return index >= 0 ? name.slice(index + 1) : "";
48 }
49
50 function parentPath(path = "") {
51 const normalized = String(path || "").split("?")[0].split("#")[0].replace(/\/+$/, "");
52 const index = normalized.lastIndexOf("/");
53 if (index <= 0) return "/";
54 return normalized.slice(0, index);
55 }
56
57 function textDocumentFilename(path = "", fallback = "Untitled.md") {
58 const name = basename(path || fallback || "Untitled.md");
59 const ext = extensionOf(name);
60 if (EDITOR_TEXT_EXTENSIONS.has(ext)) return name;
61 return `${name.replace(/\.+$/, "") || "Untitled"}.md`;
62 }
63
64 function textDocumentDefaultExtension(path = "") {
65 const ext = extensionOf(path);
66 return EDITOR_TEXT_EXTENSIONS.has(ext) ? ext : "md";
67 }
68
69 function editorIntent(url = "") {
70 const raw = String(url || "").trim();
71 if (!raw) return null;
72 try {
73 const parsed = new URL(raw);
74 if (parsed.protocol !== "a0-editor:") return null;
75 if (parsed.hostname === "open") {
76 return { path: parsed.searchParams.get("path") || "" };
77 }
78 return null;
79 } catch {
80 return null;
81 }
82 }
83
84 function uniqueTabId(session = {}) {
85 return String(session.file_id || session.session_id || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`);
86 }
87
88 function editorContainsFocus(element) {
89 const active = document.activeElement;
90 return Boolean(element && active && (element === active || element.contains(active)));
91 }
92
93 function placeCaretAtEnd(element) {
94 if (!element) return;
95 if (element.tagName === "TEXTAREA" || element.tagName === "INPUT") {
96 const length = element.value?.length || 0;
97 element.selectionStart = length;
98 element.selectionEnd = length;
99 return;
100 }
101 const selection = globalThis.getSelection?.();
102 const range = document.createRange?.();
103 if (!selection || !range) return;
104 range.selectNodeContents(element);
105 range.collapse(false);
106 selection.removeAllRanges();
107 selection.addRange(range);
108 }
109
110 function normalizeTextDocument(doc = {}) {
111 const path = doc.path || "";
112 const extension = String(doc.extension || extensionOf(path)).toLowerCase();
113 return {
114 ...doc,
115 extension,
116 title: doc.title || doc.basename || basename(path),
117 basename: doc.basename || basename(path),
118 path,
119 };
120 }
121
122 function normalizeSession(payload = {}) {
123 const document = normalizeTextDocument(payload.document || payload);
124 return {
125 ...payload,
126 document,
127 extension: String(payload.extension || document.extension || "").toLowerCase(),
128 file_id: payload.file_id || document.file_id || "",
129 path: document.path || payload.path || "",
130 title: payload.title || document.title || document.basename || basename(document.path),
131 tab_id: uniqueTabId(payload),
132 text: String(payload.text || ""),
133 dirty: Boolean(payload.dirty),
134 active: Boolean(payload.active),
135 };
136 }
137
138 function documentLabel(document = {}) {
139 return document.title || document.basename || basename(document.path);
140 }
141
142 function escapeRegExp(value = "") {
143 return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
144 }
145
146 function textNodesUnder(root, skipSelector = "") {
147 const nodes = [];
148 if (!root) return nodes;
149 const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
150 acceptNode(node) {
151 if (!node.nodeValue) return NodeFilter.FILTER_REJECT;
152 if (skipSelector && node.parentElement?.closest(skipSelector)) return NodeFilter.FILTER_REJECT;
153 return NodeFilter.FILTER_ACCEPT;
154 },
155 });
156 while (walker.nextNode()) nodes.push(walker.currentNode);
157 return nodes;
158 }
159
160 function aceModeForLanguage(language = "") {
161 const value = String(language || "").toLowerCase();
162 const aliases = {
163 bash: "sh",
164 shell: "sh",
165 zsh: "sh",
166 py: "python",
167 js: "javascript",
168 jsx: "javascript",
169 ts: "typescript",
170 md: "markdown",
171 yml: "yaml",
172 };
173 return aliases[value] || value || "text";
174 }
175
176 function taskLineIndexes(markdown = "") {
177 const indexes = [];
178 String(markdown || "").split("\n").forEach((line, index) => {
179 if (/^\s*(?:[-*+]|\d+[.)])\s+\[[ xX]\](?:\s+|$)/.test(line)) indexes.push(index);
180 });
181 return indexes;
182 }
183
184 async function callEditor(action, payload = {}) {
185 const explicitContextId = String(payload.ctxid || payload.context_id || "").trim();
186 return await callJsonApi("/plugins/_editor/editor_session", {
187 action,
188 ...payload,
189 ctxid: explicitContextId || currentContextId(),
190 });
191 }
192
193 async function requestEditor(eventType, payload = {}, timeoutMs = 5000) {
194 const explicitContextId = String(payload.ctxid || payload.context_id || "").trim();
195 const response = await editorSocket.request(eventType, {
196 ...payload,
197 ctxid: explicitContextId || currentContextId(),
198 }, { timeoutMs });
199 const results = Array.isArray(response?.results) ? response.results : [];
200 const first = results.find((item) => item?.ok === true && isEditorSocketData(item?.data))
201 || results.find((item) => item?.ok === true);
202 if (!first) {
203 const error = results.find((item) => item?.error)?.error;
204 throw new Error(error?.error || error?.code || `${eventType} failed`);
205 }
206 if (first.data?.editor_error) {
207 const error = first.data.editor_error;
208 throw new Error(error.error || error.code || `${eventType} failed`);
209 }
210 return first.data || {};
211 }
212
213 function isEditorSocketData(data) {
214 if (!data || typeof data !== "object") return false;
215 return (
216 Object.prototype.hasOwnProperty.call(data, "editor_error")
217 || Object.prototype.hasOwnProperty.call(data, "ok")
218 || Object.prototype.hasOwnProperty.call(data, "session_id")
219 || Object.prototype.hasOwnProperty.call(data, "document")
220 );
221 }
222
223 const model = {
224 status: null,
225 tabs: [],
226 activeTabId: "",
227 session: null,
228 loading: false,
229 saving: false,
230 dirty: false,
231 error: "",
232 message: "",
233 pendingClose: null,
234 viewMode: SOURCE_MODE,
235 searchOpen: false,
236 searchQuery: "",
237 searchMatches: [],
238 searchIndex: -1,
239 activePageIndex: 0,
240 previewEditing: false,
241 previewEditDirty: false,
242 previewEditText: "",
243 previewEditPageIndex: -1,
244 aceUnavailable: false,
245 editorText: "",
246 sourceEditor: null,
247 _root: null,
248 _mode: "modal",
249 _initialized: false,
250 _saveMessageTimer: null,
251 _inputTimer: null,
252 _history: [],
253 _historyIndex: -1,
254 _historyPushedAt: 0,
255 _pendingFocus: false,
256 _pendingFocusEnd: true,
257 _focusAttempts: 0,
258 _headerCleanup: null,
259 _settingSourceEditorValue: false,
260 _sourceEditorChangeHandler: null,
261 _previewEnhanceTimer: null,
262 _staticHighlightPromise: null,
263 _pendingPreviewFragment: "",
264
265 async init() {
266 if (this._initialized) return;
267 this._initialized = true;
268 await this.refresh();
269 },
270
271 async onMount(element = null, options = {}) {
272 await this.init();
273 if (element && element !== this._root) {
274 if (this.sourceEditor && !element.contains?.(this.sourceEditor.container)) {
275 this.destroySourceEditor();
276 }
277 this._root = element;
278 }
279 this._mode = options?.mode === "canvas" ? "canvas" : "modal";
280 if (this._mode === "modal") {
281 this.setupMarkdownModal(element);
282 }
283 this.scheduleSourceEditorInit();
284 },
285
286 async onOpen(payload = {}) {
287 await this.init();
288 await this.refresh();
289 if (payload?.path || payload?.file_id) {
290 const contextId = String(payload.ctxid || payload.context_id || "").trim();
291 await this.openSession({
292 path: payload.path || "",
293 file_id: payload.file_id || "",
294 ctxid: contextId,
295 context_id: contextId,
296 refresh: payload.refresh === true,
297 source: payload.source || "",
298 });
299 return;
300 }
301 },
302
303 beforeHostHidden() {
304 this.flushInput();
305 },
306
307 cleanup() {
308 this.flushInput();
309 this.destroySourceEditor();
310 if (this._previewEnhanceTimer) globalThis.clearTimeout(this._previewEnhanceTimer);
311 this._previewEnhanceTimer = null;
312 this._headerCleanup?.();
313 this._headerCleanup = null;
314 if (this._mode === "modal") this._root = null;
315 },
316
317 beginSurfaceHandoff() {
318 this.flushInput();
319 },
320
321 async refresh() {
322 try {
323 const status = await callEditor("status");
324 this.status = status || {};
325 this.error = "";
326 } catch (error) {
327 this.error = error instanceof Error ? error.message : String(error);
328 }
329 },
330
331 isSourceMode() {
332 return this.viewMode === SOURCE_MODE;
333 },
334
335 isPreviewMode() {
336 return this.viewMode === PREVIEW_MODE;
337 },
338
339 async setViewMode(mode) {
340 const next = mode === PREVIEW_MODE && this.isTextDocument() ? PREVIEW_MODE : SOURCE_MODE;
341 if (this.viewMode === next) return;
342 this.applyPreviewEdit({ silent: true });
343 this.syncEditorText();
344 this.viewMode = next;
345 this.cancelPendingClose();
346 if (next === SOURCE_MODE) {
347 this.setSourceEditorText(this.editorText);
348 this.scheduleSourceEditorInit();
349 this.refreshSourceEditorLayout();
350 this.queueRender({ focus: Boolean(this.session), end: false });
351 return;
352 }
353 this.clampActivePage();
354 this.schedulePreviewEnhance();
355 },
356
357 async toggleViewMode() {
358 if (!this.isTextDocument()) return;
359 await this.setViewMode(this.isPreviewMode() ? SOURCE_MODE : PREVIEW_MODE);
360 },
361
362 viewModeIcon() {
363 return this.isPreviewMode() ? "code" : "article";
364 },
365
366 viewModeTitle() {
367 return this.isPreviewMode() ? "Source edit" : "Preview";
368 },
369
370 pages() {
371 if (!this.isTextDocument()) return [];
372 return buildMarkdownPages(this.editorText, this.tabTitle(this.session || {}));
373 },
374
375 currentPage() {
376 const pages = this.pages();
377 const index = Math.max(0, Math.min(this.activePageIndex, pages.length - 1));
378 return pages[index] || pages[0] || { title: this.tabTitle(this.session || {}), markdown: "" };
379 },
380
381 pageTitle() {
382 return this.currentPage().title || this.tabTitle(this.session || {});
383 },
384
385 previewHtml() {
386 if (!this.isTextDocument()) return "";
387 return renderEditorPreviewMarkdown(this.currentPage().markdown || "", this.editorText);
388 },
389
390 startPreviewEdit() {
391 if (!this.session || !this.isTextDocument() || !this.isPreviewMode()) return;
392 const page = this.currentPage();
393 this.previewEditing = true;
394 this.previewEditDirty = false;
395 this.previewEditPageIndex = this.activePageIndex;
396 this.previewEditText = page.markdown || "";
397 globalThis.requestAnimationFrame?.(() => {
398 const editor = this._root?.querySelector?.("[data-editor-preview-source]");
399 editor?.focus?.({ preventScroll: true });
400 });
401 },
402
403 onPreviewEditInput() {
404 if (this.previewEditing) this.previewEditDirty = true;
405 },
406
407 cancelPreviewEdit() {
408 this.previewEditing = false;
409 this.previewEditDirty = false;
410 this.previewEditText = "";
411 this.previewEditPageIndex = -1;
412 this.schedulePreviewEnhance();
413 },
414
415 applyPreviewEdit(options = {}) {
416 if (!this.previewEditing) return false;
417 if (!this.previewEditDirty && options.force !== true) {
418 this.cancelPreviewEdit();
419 return false;
420 }
421 const pages = this.pages();
422 const index = Math.max(0, Math.min(
423 this.previewEditPageIndex >= 0 ? this.previewEditPageIndex : this.activePageIndex,
424 pages.length - 1,
425 ));
426 const page = pages[index];
427 if (!page) {
428 this.cancelPreviewEdit();
429 return false;
430 }
431
432 let replacement = String(this.previewEditText || "");
433 this.previewEditing = false;
434 this.previewEditDirty = false;
435 this.previewEditText = "";
436 this.previewEditPageIndex = -1;
437
438 return this.replacePageMarkdown(page, replacement, {
439 message: "Document updated",
440 silent: options.silent,
441 });
442 },
443
444 replacePageMarkdown(page = null, markdown = "", options = {}) {
445 if (!page) return false;
446 const source = String(this.editorText || "");
447 const start = Math.max(0, Number(page.start || 0));
448 const end = Math.max(start, Number(page.end ?? source.length));
449 const before = source.slice(0, start);
450 const after = source.slice(end);
451 let replacement = String(markdown || "");
452 if (replacement && after && !replacement.endsWith("\n")) replacement += "\n";
453 const next = before + replacement + after;
454 if (next === source) {
455 this.schedulePreviewEnhance();
456 return false;
457 }
458
459 this.editorText = next;
460 this.setSourceEditorText(next);
461 if (this.session) {
462 this.session.text = next;
463 this.session.dirty = true;
464 }
465 this.dirty = true;
466 this.pushHistory(next);
467 this.scheduleInputPush();
468 this.clampActivePage();
469 this.schedulePreviewEnhance();
470 if (!options.silent && options.message) this.setMessage(options.message);
471 return true;
472 },
473
474 togglePreviewTask(taskIndex, checked) {
475 if (!this.session || !this.isTextDocument() || !this.isPreviewMode() || this.previewEditing) return false;
476 const page = this.currentPage();
477 const lines = String(page.markdown || "").split("\n");
478 const indexes = taskLineIndexes(page.markdown || "");
479 const lineIndex = indexes[Number(taskIndex)];
480 if (lineIndex == null || !lines[lineIndex]) return false;
481 const nextLine = lines[lineIndex].replace(
482 /^(\s*(?:[-*+]|\d+[.)])\s+\[)[ xX](\](?:\s+|$))/,
483 `$1${checked ? "x" : " "}$2`,
484 );
485 if (nextLine === lines[lineIndex]) return false;
486 lines[lineIndex] = nextLine;
487 return this.replacePageMarkdown(page, lines.join("\n"));
488 },
489
490 clampActivePage() {
491 const pages = this.pages();
492 this.activePageIndex = Math.max(0, Math.min(this.activePageIndex, Math.max(0, pages.length - 1)));
493 },
494
495 schedulePreviewEnhance() {
496 if (!this.isTextDocument() || !this.isPreviewMode()) return;
497 if (this._previewEnhanceTimer) globalThis.clearTimeout(this._previewEnhanceTimer);
498 this._previewEnhanceTimer = globalThis.setTimeout(() => {
499 this._previewEnhanceTimer = null;
500 this.enhancePreview();
501 }, 0);
502 },
503
504 enhancePreview() {
505 const root = this._root?.querySelector?.("[data-editor-preview]");
506 if (!root) return;
507 this.addHeadingIds(root);
508 this.enhanceTables(root);
509 this.enhanceTaskLists(root);
510 this.enhanceImages(root);
511 this.enhanceLinks(root);
512 this.enhanceCodeBlocks(root);
513 this.renderMath(root);
514 this.applySearchHighlights(root);
515 this.scrollPendingFragment(root);
516 },
517
518 addHeadingIds(root) {
519 const used = new Map();
520 root.querySelectorAll("h1,h2,h3,h4,h5,h6").forEach((heading) => {
521 if (!heading.id) heading.id = slugifyHeading(heading.textContent || "", used);
522 });
523 },
524
525 enhanceTables(root) {
526 root.querySelectorAll("table").forEach((table) => {
527 if (table.parentElement?.classList.contains("editor-table-wrap")) return;
528 const wrapper = document.createElement("div");
529 wrapper.className = "editor-table-wrap";
530 table.parentNode?.insertBefore(wrapper, table);
531 wrapper.appendChild(table);
532 });
533 },
534
535 enhanceTaskLists(root) {
536 root.querySelectorAll('input[type="checkbox"]').forEach((checkbox, index) => {
537 if (checkbox.dataset.editorTaskEnhanced === "true") return;
538 checkbox.dataset.editorTaskEnhanced = "true";
539 checkbox.dataset.editorTaskIndex = String(index);
540 checkbox.disabled = false;
541 checkbox.removeAttribute("disabled");
542 checkbox.addEventListener("change", (event) => {
543 const target = event.currentTarget;
544 this.togglePreviewTask(Number(target?.dataset?.editorTaskIndex || 0), Boolean(target?.checked));
545 });
546 });
547 },
548
549 enhanceImages(root) {
550 const docPath = this.session?.path || this.session?.document?.path || "";
551 root.querySelectorAll("img[src]").forEach((image) => {
552 const src = image.getAttribute("src") || "";
553 if (!src || isExternalHref(src) || src.startsWith("data:") || src.startsWith("/api/image_get")) return;
554 const resolved = resolveDocumentRelativePath(docPath, src);
555 image.setAttribute("src", `/api/image_get?path=${encodeURIComponent(resolved)}`);
556 image.setAttribute("loading", "lazy");
557 });
558 },
559
560 enhanceLinks(root) {
561 const docPath = this.session?.path || this.session?.document?.path || "";
562 root.querySelectorAll("a[href]").forEach((anchor) => {
563 const href = anchor.getAttribute("href") || "";
564 if (!href || isExternalHref(href)) return;
565 const { path, fragment } = splitHref(href);
566 if (!path && fragment) {
567 anchor.dataset.editorFragment = fragment;
568 return;
569 }
570 if (!isMarkdownPath(path)) return;
571 anchor.dataset.editorMarkdownPath = resolveDocumentRelativePath(docPath, path);
572 anchor.dataset.editorFragment = fragment;
573 });
574 },
575
576 async enhanceCodeBlocks(root) {
577 root.querySelectorAll("pre > code").forEach((code) => {
578 const pre = code.parentElement;
579 if (!pre || pre.parentElement?.classList.contains("editor-code-block")) return;
580 const wrapper = document.createElement("div");
581 wrapper.className = "editor-code-block";
582 const header = document.createElement("div");
583 header.className = "editor-code-header";
584 const language = this.codeLanguage(code);
585 const label = document.createElement("span");
586 label.className = "editor-code-language";
587 label.textContent = language || "text";
588 const button = document.createElement("button");
589 button.type = "button";
590 button.className = "editor-code-copy";
591 button.textContent = "Copy";
592 button.addEventListener("click", async () => {
593 await navigator.clipboard?.writeText(code.textContent || "");
594 button.textContent = "Copied";
595 globalThis.setTimeout(() => { button.textContent = "Copy"; }, 1200);
596 });
597 header.append(label, button);
598 pre.parentNode?.insertBefore(wrapper, pre);
599 wrapper.append(header, pre);
600 this.highlightCodeBlock(code, language);
601 });
602 },
603
604 codeLanguage(code) {
605 for (const className of code.classList || []) {
606 if (className.startsWith("language-")) return className.slice("language-".length);
607 if (className.startsWith("lang-")) return className.slice("lang-".length);
608 }
609 return "";
610 },
611
612 async highlightCodeBlock(code, language) {
613 if (!language || !globalThis.ace?.require) return;
614 const source = code.textContent || "";
615 try {
616 const highlighter = await this.loadAceStaticHighlighter();
617 const darkMode = globalThis.localStorage?.getItem("darkMode");
618 const theme = darkMode !== "false" ? "ace/theme/github_dark" : "ace/theme/github";
619 const mode = `ace/mode/${aceModeForLanguage(language)}`;
620 highlighter.render(source, mode, theme, 1, true, (result) => {
621 code.innerHTML = result.html;
622 code.classList.add("is-highlighted");
623 });
624 } catch {
625 // Fenced code still renders as preformatted text if highlighting is unavailable.
626 }
627 },
628
629 loadAceStaticHighlighter() {
630 if (this._staticHighlightPromise) return this._staticHighlightPromise;
631 this._staticHighlightPromise = new Promise((resolve, reject) => {
632 let existing = null;
633 try {
634 existing = globalThis.ace?.require?.("ace/ext/static_highlight");
635 } catch {
636 existing = null;
637 }
638 if (existing?.render) {
639 resolve(existing);
640 return;
641 }
642 const script = document.createElement("script");
643 script.src = "/vendor/ace-min/ext-static_highlight.js";
644 script.onload = () => {
645 const loaded = globalThis.ace?.require?.("ace/ext/static_highlight");
646 loaded?.render ? resolve(loaded) : reject(new Error("ACE highlighter unavailable"));
647 };
648 script.onerror = () => reject(new Error("ACE highlighter failed to load"));
649 document.head.appendChild(script);
650 });
651 return this._staticHighlightPromise;
652 },
653
654 renderMath(root) {
655 if (!globalThis.katex?.render) return;
656 for (const node of textNodesUnder(root, "code,pre,.katex,.editor-code-block")) {
657 this.replaceMathInTextNode(node);
658 }
659 },
660
661 replaceMathInTextNode(node) {
662 const text = node.nodeValue || "";
663 const pattern = /(\$\$[^$]+\$\$|\$[^$\n]+\$)/g;
664 if (!pattern.test(text)) return;
665 pattern.lastIndex = 0;
666 const fragment = document.createDocumentFragment();
667 let lastIndex = 0;
668 let match;
669 while ((match = pattern.exec(text))) {
670 if (match.index > lastIndex) fragment.append(document.createTextNode(text.slice(lastIndex, match.index)));
671 const raw = match[0];
672 const displayMode = raw.startsWith("$$");
673 const expression = raw.slice(displayMode ? 2 : 1, displayMode ? -2 : -1);
674 const span = document.createElement(displayMode ? "div" : "span");
675 span.className = displayMode ? "editor-math-display" : "editor-math-inline";
676 try {
677 globalThis.katex.render(expression, span, { throwOnError: false, displayMode });
678 } catch {
679 span.textContent = raw;
680 }
681 fragment.append(span);
682 lastIndex = match.index + raw.length;
683 }
684 if (lastIndex < text.length) fragment.append(document.createTextNode(text.slice(lastIndex)));
685 node.parentNode?.replaceChild(fragment, node);
686 },
687
688 openSearch() {
689 if (!this.isTextDocument()) return;
690 if (!this.isPreviewMode()) {
691 this.setViewMode(PREVIEW_MODE);
692 }
693 this.searchOpen = true;
694 this.runSearch();
695 globalThis.requestAnimationFrame?.(() => {
696 this._root?.querySelector?.("[data-editor-search]")?.focus?.();
697 });
698 },
699
700 closeSearch() {
701 this.searchOpen = false;
702 this.searchQuery = "";
703 this.searchMatches = [];
704 this.searchIndex = -1;
705 this.schedulePreviewEnhance();
706 },
707
708 searchCountLabel() {
709 if (!this.searchQuery) return "";
710 if (!this.searchMatches.length) return "0 of 0";
711 return `${this.searchIndex + 1} of ${this.searchMatches.length}`;
712 },
713
714 runSearch() {
715 const query = String(this.searchQuery || "");
716 if (!query) {
717 this.searchMatches = [];
718 this.searchIndex = -1;
719 this.schedulePreviewEnhance();
720 return;
721 }
722 const lower = query.toLowerCase();
723 const matches = [];
724 for (const page of this.pages()) {
725 const text = this.renderedTextForPage(page);
726 let index = 0;
727 let occurrence = 0;
728 while ((index = text.toLowerCase().indexOf(lower, index)) >= 0) {
729 matches.push({ pageIndex: page.index, occurrence, offset: index });
730 occurrence += 1;
731 index += Math.max(1, lower.length);
732 }
733 }
734 this.searchMatches = matches;
735 this.searchIndex = matches.length ? 0 : -1;
736 this.goToCurrentSearchMatch();
737 },
738
739 nextSearchMatch() {
740 if (!this.searchMatches.length) return;
741 this.searchIndex = (this.searchIndex + 1) % this.searchMatches.length;
742 this.goToCurrentSearchMatch();
743 },
744
745 previousSearchMatch() {
746 if (!this.searchMatches.length) return;
747 this.searchIndex = (this.searchIndex - 1 + this.searchMatches.length) % this.searchMatches.length;
748 this.goToCurrentSearchMatch();
749 },
750
751 goToCurrentSearchMatch() {
752 const match = this.searchMatches[this.searchIndex];
753 if (!match) {
754 this.schedulePreviewEnhance();
755 return;
756 }
757 this.activePageIndex = match.pageIndex;
758 this.schedulePreviewEnhance();
759 },
760
761 renderedTextForPage(page) {
762 const html = renderEditorPreviewMarkdown(page.markdown || "", this.editorText);
763 const doc = new DOMParser().parseFromString(html, "text/html");
764 return doc.body.textContent || "";
765 },
766
767 applySearchHighlights(root) {
768 root.querySelectorAll("mark.editor-search-mark").forEach((mark) => {
769 mark.replaceWith(document.createTextNode(mark.textContent || ""));
770 });
771 const query = String(this.searchQuery || "");
772 if (!query || !this.searchMatches.length) return;
773 const regex = new RegExp(escapeRegExp(query), "gi");
774 const current = this.searchMatches[this.searchIndex];
775 let occurrence = 0;
776 for (const node of textNodesUnder(root, "script,style")) {
777 const text = node.nodeValue || "";
778 if (!regex.test(text)) continue;
779 regex.lastIndex = 0;
780 const fragment = document.createDocumentFragment();
781 let lastIndex = 0;
782 let match;
783 while ((match = regex.exec(text))) {
784 if (match.index > lastIndex) fragment.append(document.createTextNode(text.slice(lastIndex, match.index)));
785 const mark = document.createElement("mark");
786 mark.className = "editor-search-mark";
787 if (current?.pageIndex === this.activePageIndex && current.occurrence === occurrence) {
788 mark.classList.add("is-current");
789 }
790 mark.textContent = match[0];
791 fragment.append(mark);
792 occurrence += 1;
793 lastIndex = match.index + match[0].length;
794 }
795 if (lastIndex < text.length) fragment.append(document.createTextNode(text.slice(lastIndex)));
796 node.parentNode?.replaceChild(fragment, node);
797 }
798 root.querySelector("mark.editor-search-mark.is-current")?.scrollIntoView?.({ block: "center" });
799 },
800
801 async handlePreviewClick(event) {
802 const anchor = event.target?.closest?.("a[href]");
803 if (!anchor) return;
804 const markdownPath = anchor.dataset.editorMarkdownPath || "";
805 const fragment = anchor.dataset.editorFragment || "";
806 if (!markdownPath && fragment) {
807 event.preventDefault();
808 this.navigateToFragment(fragment);
809 return;
810 }
811 if (!markdownPath) return;
812 event.preventDefault();
813 this._pendingPreviewFragment = fragment;
814 const opened = await this.openSession({ path: markdownPath, refresh: true, source: "editor-preview-link" });
815 if (!opened) return;
816 if (this.isPreviewMode() && fragment) {
817 this.navigateToFragment(fragment);
818 }
819 },
820
821 navigateToFragment(fragment = "") {
822 const target = String(fragment || "").replace(/^#/, "");
823 if (!target) return;
824 const pages = this.pages();
825 const normalized = target.toLowerCase();
826 for (const page of pages) {
827 const doc = new DOMParser().parseFromString(renderEditorPreviewMarkdown(page.markdown || "", this.editorText), "text/html");
828 const used = new Map();
829 const headings = [...doc.body.querySelectorAll("h1,h2,h3,h4,h5,h6")];
830 if (headings.some((heading) => (heading.id || slugifyHeading(heading.textContent || "", used)) === normalized)) {
831 this.activePageIndex = page.index;
832 this._pendingPreviewFragment = target;
833 this.schedulePreviewEnhance();
834 return;
835 }
836 }
837 this._pendingPreviewFragment = target;
838 this.schedulePreviewEnhance();
839 },
840
841 scrollPendingFragment(root) {
842 const fragment = this._pendingPreviewFragment;
843 if (!fragment) return;
844 const target = root.querySelector(`#${CSS.escape(fragment)}`);
845 if (target) {
846 target.scrollIntoView({ block: "start" });
847 this._pendingPreviewFragment = "";
848 }
849 },
850
851 handleEditorKeydown(event) {
852 if (!(event.metaKey || event.ctrlKey) || !this.isTextDocument()) return;
853 const key = event.key.toLowerCase();
854 const historyAction = key === "y" || (key === "z" && event.shiftKey) ? "redo" : key === "z" ? "undo" : "";
855 const nativeEditing = event.target?.matches?.("input, textarea, [contenteditable='true']")
856 && !event.target.closest?.("[data-editor-ace], [data-editor-source]");
857 if (nativeEditing) return;
858 if (historyAction && !this.previewEditing) {
859 event.preventDefault();
860 event.stopPropagation();
861 this[historyAction]();
862 return;
863 }
864 if (key === "f") {
865 event.preventDefault();
866 this.openSearch();
867 }
868 },
869
870 async create(kind = "document", format = "") {
871 const requested = String(format || "md").toLowerCase().replace(/^\./, "");
872 const fmt = EDITOR_TEXT_EXTENSIONS.has(requested) ? requested : "md";
873 const title = this.defaultTitle(kind, fmt);
874 return await this.openSession({
875 action: "create",
876 kind: "document",
877 format: fmt,
878 title,
879 });
880 },
881
882 async openFileBrowser() {
883 let workdirPath = "/a0/usr/workdir";
884 try {
885 const home = await callEditor("home");
886 if (home?.path) {
887 workdirPath = home.path;
888 } else {
889 const response = await callJsonApi("settings_get", null);
890 workdirPath = response?.settings?.workdir_path || workdirPath;
891 }
892 } catch {
893 try {
894 const response = await callJsonApi("settings_get", null);
895 workdirPath = response?.settings?.workdir_path || workdirPath;
896 } catch {
897 // The file browser can still open with the static fallback.
898 }
899 }
900 await fileBrowserStore.openTextPicker(workdirPath, async ({ selectedFiles = [] } = {}) => {
901 const files = selectedFiles.filter((file) => file?.path);
902 if (!files.length) return false;
903 for (const file of files) {
904 const session = await this.openPath(fileBrowserStore.normalizePath(file.path), { source: "file-browser", refresh: true });
905 if (!session || session.ok === false) {
906 throw new Error(this.error || `Could not open ${file.name || file.path}`);
907 }
908 }
909 return true;
910 });
911 },
912
913 async openPath(path, options = {}) {
914 return await this.openSession({
915 path: String(path || ""),
916 source: options?.source || "",
917 refresh: options?.refresh === true,
918 });
919 },
920
921 async openSession(payload = {}) {
922 this.loading = true;
923 this.error = "";
924 try {
925 const response = await callEditor(payload.action || "open", payload);
926 if (response?.ok === false) {
927 this.error = response.error || "Text document could not be opened.";
928 return null;
929 }
930 if (response?.requires_desktop) {
931 const document = normalizeTextDocument(response.document || response);
932 this.setMessage(`${documentLabel(document)} uses the Desktop surface.`);
933 await this.refresh();
934 return response;
935 }
936 const session = normalizeSession(response);
937 this.installSession(session);
938 await this.refresh();
939 return session;
940 } catch (error) {
941 this.error = error instanceof Error ? error.message : String(error);
942 return null;
943 } finally {
944 this.loading = false;
945 }
946 },
947
948 installSession(session) {
949 const existingIndex = this.tabs.findIndex((tab) => (
950 (session.file_id && tab.file_id === session.file_id)
951 || (session.path && tab.path === session.path)
952 ));
953 if (existingIndex >= 0) {
954 const tabId = this.tabs[existingIndex].tab_id;
955 const wasActive = this.activeTabId === tabId || this.session?.tab_id === tabId;
956 const merged = { ...this.tabs[existingIndex], ...session, tab_id: tabId };
957 this.tabs.splice(existingIndex, 1, merged);
958 this.activeTabId = tabId;
959 if (wasActive) {
960 this.hydrateActiveSession(merged, { preservePage: true, focus: false });
961 return;
962 }
963 } else {
964 this.tabs.push(session);
965 this.activeTabId = session.tab_id;
966 }
967 this.selectTab(this.activeTabId);
968 },
969
970 hydrateActiveSession(tab, options = {}) {
971 this.session = tab || null;
972 this.activeTabId = tab?.tab_id || "";
973 this.editorText = String(tab?.text || "");
974 this.dirty = Boolean(tab?.dirty);
975 if (this.previewEditing) this.cancelPreviewEdit();
976 if (!options.preservePage) {
977 this.activePageIndex = 0;
978 } else {
979 this.clampActivePage();
980 }
981 this.searchMatches = [];
982 this.searchIndex = -1;
983 this.resetHistory(this.editorText);
984 this.setSourceEditorText(this.editorText);
985 this.updateSourceEditorMode();
986 if (tab?.session_id) {
987 requestEditor("editor_activate", { session_id: tab.session_id }, 2500).catch(() => {});
988 }
989 if (this.searchOpen && this.searchQuery) this.runSearch();
990 else if (this.isSourceMode()) this.scheduleSourceEditorInit();
991 else this.schedulePreviewEnhance();
992 this.refreshSourceEditorLayout();
993 this.queueRender({ focus: this.isSourceMode() && Boolean(tab) && options.focus !== false, end: false });
994 },
995
996 selectTab(tabId, options = {}) {
997 this.applyPreviewEdit({ silent: true });
998 this.syncEditorText();
999 const tab = this.tabs.find((item) => item.tab_id === tabId) || this.tabs[0] || null;
1000 this.previewEditing = false;
1001 this.previewEditDirty = false;
1002 this.previewEditText = "";
1003 this.previewEditPageIndex = -1;
1004 this.hydrateActiveSession(tab, { preservePage: false, focus: options.focus !== false });
1005 },
1006
1007 ensureActiveTab() {
1008 if (this.session && this.tabs.some((tab) => tab.tab_id === this.session.tab_id)) return;
1009 if (this.tabs.length) this.selectTab(this.tabs[0].tab_id, { focus: false });
1010 },
1011
1012 isActiveTab(tab) {
1013 return Boolean(tab && tab.tab_id === this.activeTabId);
1014 },
1015
1016 isTabDirty(tab) {
1017 return Boolean(tab?.dirty || (this.isActiveTab(tab) && (this.dirty || this.previewEditDirty)));
1018 },
1019
1020 hasPendingClose() {
1021 return Boolean(this.pendingClose);
1022 },
1023
1024 pendingCloseTitle() {
1025 const pending = this.pendingClose;
1026 if (!pending) return "";
1027 if (pending.kind === "all") {
1028 return `Close ${pending.totalCount || 0} open files?`;
1029 }
1030 const tab = this.tabs.find((item) => item.tab_id === pending.tabId);
1031 return `Close ${this.tabTitle(tab || {})}?`;
1032 },
1033
1034 pendingCloseMessage() {
1035 const pending = this.pendingClose;
1036 if (!pending) return "";
1037 const dirtyCount = Number(pending.dirtyCount || 0);
1038 if (pending.kind === "all") {
1039 if (dirtyCount === 0) return "All open text files will be closed.";
1040 return `${dirtyCount} open ${dirtyCount === 1 ? "file has" : "files have"} unsaved changes.`;
1041 }
1042 if (dirtyCount > 0) return "This file has unsaved changes.";
1043 return "This file will be closed.";
1044 },
1045
1046 pendingCloseHasDirty() {
1047 return Number(this.pendingClose?.dirtyCount || 0) > 0;
1048 },
1049
1050 pendingCloseDiscardLabel() {
1051 return this.pendingCloseHasDirty() ? "Discard" : "Close";
1052 },
1053
1054 beginCloseConfirmation(kind, tabIds = []) {
1055 const ids = tabIds.filter(Boolean);
1056 const tabs = ids.map((id) => this.tabs.find((tab) => tab.tab_id === id)).filter(Boolean);
1057 const dirtyCount = tabs.filter((tab) => this.isTabDirty(tab)).length;
1058 this.pendingClose = {
1059 kind,
1060 tabId: kind === "single" ? ids[0] || "" : "",
1061 tabIds: ids,
1062 totalCount: tabs.length,
1063 dirtyCount,
1064 };
1065 if (kind === "single" && ids[0] && this.activeTabId !== ids[0]) {
1066 this.selectTab(ids[0], { focus: false });
1067 }
1068 },
1069
1070 cancelPendingClose() {
1071 this.pendingClose = null;
1072 },
1073
1074 async confirmPendingClose(options = {}) {
1075 const pending = this.pendingClose;
1076 if (!pending || this.loading) return;
1077 this.pendingClose = null;
1078 const save = options.save === true;
1079 if (pending.kind === "all") {
1080 await this.closeAllFiles({ confirm: false, save, tabIds: pending.tabIds || [] });
1081 return;
1082 }
1083 await this.closeTab(pending.tabId, { confirm: false, save });
1084 },
1085
1086 async closeTab(tabId, options = {}) {
1087 const tab = this.tabs.find((item) => item.tab_id === tabId);
1088 if (!tab) return;
1089 if (this.isTabDirty(tab) && options.confirm !== false) {
1090 this.beginCloseConfirmation("single", [tab.tab_id]);
1091 return;
1092 }
1093 await this.closeTabNow(tab, { save: options.save === true });
1094 },
1095
1096 async closeTabNow(tab, options = {}) {
1097 if (!tab || this.loading) return false;
1098 const tabId = tab.tab_id;
1099 if (options.save === true && this.isTabDirty(tab)) {
1100 const saved = await this.saveTab(tab);
1101 if (!saved) return false;
1102 }
1103 if (this.activeTabId === tabId && this.previewEditing) this.cancelPreviewEdit();
1104 try {
1105 if (tab.session_id) {
1106 await requestEditor("editor_close", { session_id: tab.session_id }, 2500).catch(() => null);
1107 }
1108 await callEditor("close", {
1109 session_id: tab.session_id || "",
1110 store_session_id: tab.store_session_id || "",
1111 file_id: tab.file_id || "",
1112 });
1113 } catch (error) {
1114 console.warn("Editor close skipped", error);
1115 }
1116 this.tabs = this.tabs.filter((item) => item.tab_id !== tabId);
1117 if (this.pendingClose?.tabId === tabId || this.pendingClose?.tabIds?.includes(tabId)) {
1118 this.pendingClose = null;
1119 }
1120 if (this.activeTabId === tabId) {
1121 this.session = null;
1122 this.activeTabId = "";
1123 this.editorText = "";
1124 this.dirty = false;
1125 this.ensureActiveTab();
1126 }
1127 this.ensureActiveTab();
1128 await this.refresh();
1129 return true;
1130 },
1131
1132 async closeActiveFile() {
1133 if (!this.session || this.loading) return;
1134 await this.closeTab(this.session.tab_id);
1135 },
1136
1137 async closeAllFiles(options = {}) {
1138 if (this.loading) return;
1139 const requestedIds = Array.isArray(options.tabIds) && options.tabIds.length
1140 ? options.tabIds
1141 : this.visibleTabs().map((tab) => tab.tab_id);
1142 const tabs = requestedIds.map((id) => this.tabs.find((tab) => tab.tab_id === id)).filter(Boolean);
1143 if (!tabs.length) return;
1144
1145 const dirtyTabs = tabs.filter((tab) => this.isTabDirty(tab));
1146 if (dirtyTabs.length && options.confirm !== false) {
1147 this.beginCloseConfirmation("all", tabs.map((tab) => tab.tab_id));
1148 return;
1149 }
1150
1151 this.pendingClose = null;
1152 for (const tab of [...tabs]) {
1153 const current = this.tabs.find((item) => item.tab_id === tab.tab_id);
1154 if (!current) continue;
1155 const closed = await this.closeTabNow(current, {
1156 save: options.save === true && this.isTabDirty(current),
1157 });
1158 if (!closed) break;
1159 }
1160 },
1161
1162 scheduleSourceEditorInit() {
1163 if (!this.isSourceMode()) return;
1164 globalThis.requestAnimationFrame?.(() => {
1165 globalThis.requestAnimationFrame?.(() => this.initSourceEditor());
1166 });
1167 },
1168
1169 initSourceEditor() {
1170 if (!this.isSourceMode() || !this._root) return;
1171 const container = this._root.querySelector?.("[data-editor-ace]");
1172 if (this.sourceEditor && !this._root.contains?.(this.sourceEditor.container)) {
1173 this.destroySourceEditor();
1174 }
1175 if (!container || this.sourceEditor) return;
1176 if (!globalThis.ace?.edit) {
1177 this.aceUnavailable = true;
1178 return;
1179 }
1180
1181 const editor = globalThis.ace.edit(container);
1182 const darkMode = globalThis.localStorage?.getItem("darkMode");
1183 const theme = darkMode !== "false" ? "ace/theme/github_dark" : "ace/theme/github";
1184 editor.setTheme(theme);
1185 editor.session.setMode(this.sourceEditorMode());
1186 editor.session.setUseWrapMode(true);
1187 editor.setOptions({
1188 fontSize: "13px",
1189 showGutter: false,
1190 showPrintMargin: false,
1191 useWorker: false,
1192 });
1193 editor.renderer.setShowGutter(false);
1194 editor.renderer.setScrollMargin(14, 14, 0, 0);
1195 editor.setValue(this.editorText || "", -1);
1196 this._sourceEditorChangeHandler = () => {
1197 if (this._settingSourceEditorValue) return;
1198 this.editorText = editor.getValue();
1199 this.onSourceInput();
1200 };
1201 editor.session.on("change", this._sourceEditorChangeHandler);
1202 this.sourceEditor = editor;
1203 this.aceUnavailable = false;
1204 this.updateSourceEditorMode();
1205 this.queueRender({ focus: Boolean(this.session), end: false });
1206 },
1207
1208 sourceEditorMode(tab = this.session) {
1209 return this.isMarkdown(tab) ? "ace/mode/markdown" : "ace/mode/text";
1210 },
1211
1212 updateSourceEditorMode(tab = this.session) {
1213 try {
1214 this.sourceEditor?.session?.setMode(this.sourceEditorMode(tab));
1215 } catch {}
1216 },
1217
1218 destroySourceEditor() {
1219 if (this.sourceEditor?.session && this._sourceEditorChangeHandler) {
1220 this.sourceEditor.session.off?.("change", this._sourceEditorChangeHandler);
1221 }
1222 const container = this.sourceEditor?.container;
1223 this.sourceEditor?.destroy?.();
1224 if (container) container.textContent = "";
1225 this.sourceEditor = null;
1226 this._sourceEditorChangeHandler = null;
1227 },
1228
1229 setSourceEditorText(text = "") {
1230 if (!this.sourceEditor) return;
1231 const value = String(text || "");
1232 if (this.sourceEditor.getValue() === value) return;
1233 this._settingSourceEditorValue = true;
1234 this.sourceEditor.setValue(value, -1);
1235 this._settingSourceEditorValue = false;
1236 this.refreshSourceEditorLayout();
1237 },
1238
1239 refreshSourceEditorLayout() {
1240 const editor = this.sourceEditor;
1241 if (!editor) return;
1242 const refresh = () => {
1243 editor.resize?.(true);
1244 editor.renderer?.updateFull?.();
1245 editor.renderer?.updateText?.();
1246 };
1247 if (globalThis.requestAnimationFrame) {
1248 globalThis.requestAnimationFrame(() => globalThis.requestAnimationFrame(refresh));
1249 } else {
1250 globalThis.setTimeout(refresh, 0);
1251 }
1252 },
1253
1254 async save() {
1255 if (!this.session || this.saving || !this.isTextDocument()) return;
1256 this.applyPreviewEdit({ silent: true });
1257 this.syncEditorText();
1258 this.saving = true;
1259 this.error = "";
1260 try {
1261 let response;
1262 const payload = { session_id: this.session.session_id, text: this.editorText };
1263 try {
1264 response = await requestEditor("editor_save", payload, 10000);
1265 } catch (_socketError) {
1266 response = await callEditor("save", payload);
1267 }
1268 if (response?.ok === false) throw new Error(response.error || "Save failed.");
1269 const document = normalizeTextDocument(response.document || this.session.document || {});
1270 const updated = {
1271 ...this.session,
1272 text: this.editorText,
1273 dirty: false,
1274 document,
1275 path: document.path || this.session.path,
1276 file_id: document.file_id || this.session.file_id,
1277 extension: document.extension || this.session.extension,
1278 version: document.version || response.version || this.session.version,
1279 };
1280 this.replaceActiveSession(updated);
1281 this.dirty = false;
1282 this.setMessage("Saved");
1283 await this.refresh();
1284 } catch (error) {
1285 this.error = error instanceof Error ? error.message : String(error);
1286 } finally {
1287 this.saving = false;
1288 }
1289 },
1290
1291 async downloadActiveFile() {
1292 if (!this.session || this.saving || !this.isTextDocument()) return;
1293 if (this.dirty) await this.save();
1294 if (this.dirty) return;
1295 const path = this.session.path || this.session.document?.path;
1296 if (path) fileBrowserStore.downloadFile({ path, name: this.tabTitle() });
1297 },
1298
1299 async saveAs() {
1300 if (!this.session || this.saving || !this.isTextDocument()) return;
1301 this.applyPreviewEdit({ silent: true });
1302 this.syncEditorText();
1303
1304 let startPath = parentPath(this.session.path || this.session.document?.path || "");
1305 if (!startPath || startPath === "/") {
1306 try {
1307 const home = await callEditor("home");
1308 startPath = home?.path || startPath || "/a0/usr/workdir";
1309 } catch {
1310 startPath = "/a0/usr/workdir";
1311 }
1312 }
1313
1314 await fileBrowserStore.openSaveAsPicker(startPath, {
1315 filename: textDocumentFilename(this.session.path || this.session.title || "Untitled.md"),
1316 defaultExtension: textDocumentDefaultExtension(this.session.path || this.session.title || "Untitled.md"),
1317 onConfirm: async ({ path } = {}) => {
1318 if (!path) return false;
1319 await this.saveAsPath(path);
1320 return true;
1321 },
1322 });
1323 },
1324
1325 async saveAsPath(path) {
1326 if (!this.session || this.saving || !this.isTextDocument()) return null;
1327 this.saving = true;
1328 this.error = "";
1329 try {
1330 const payload = {
1331 session_id: this.session.session_id,
1332 store_session_id: this.session.store_session_id || "",
1333 path,
1334 text: this.editorText,
1335 };
1336 const response = await callEditor("save_as", payload);
1337 if (response?.ok === false) throw new Error(response.error || "Save As failed.");
1338 const document = normalizeTextDocument(response.document || this.session.document || {});
1339 const updated = {
1340 ...this.session,
1341 text: this.editorText,
1342 dirty: false,
1343 document,
1344 title: document.title || document.basename || basename(document.path),
1345 path: document.path || path,
1346 file_id: document.file_id || this.session.file_id,
1347 extension: document.extension || this.session.extension,
1348 store_session_id: response.store_session_id || this.session.store_session_id,
1349 version: document.version || response.version || this.session.version,
1350 };
1351 this.replaceActiveSession(updated);
1352 this.dirty = false;
1353 this.setMessage("Saved As");
1354 await this.refresh();
1355 return updated;
1356 } catch (error) {
1357 this.error = error instanceof Error ? error.message : String(error);
1358 throw error;
1359 } finally {
1360 this.saving = false;
1361 }
1362 },
1363
1364 async saveTab(tab) {
1365 if (!tab || this.saving || !this.isTextDocument(tab)) return false;
1366 if (this.isActiveTab(tab)) {
1367 this.applyPreviewEdit({ silent: true });
1368 this.syncEditorText();
1369 }
1370 this.saving = true;
1371 this.error = "";
1372 try {
1373 let response;
1374 const payload = {
1375 session_id: tab.session_id,
1376 text: this.isActiveTab(tab) ? this.editorText : String(tab.text || ""),
1377 };
1378 try {
1379 response = await requestEditor("editor_save", payload, 10000);
1380 } catch (_socketError) {
1381 response = await callEditor("save", payload);
1382 }
1383 if (response?.ok === false) throw new Error(response.error || "Save failed.");
1384 const document = normalizeTextDocument(response.document || tab.document || {});
1385 const updated = {
1386 ...tab,
1387 text: payload.text,
1388 dirty: false,
1389 document,
1390 path: document.path || tab.path,
1391 file_id: document.file_id || tab.file_id,
1392 extension: document.extension || tab.extension,
1393 version: document.version || response.version || tab.version,
1394 };
1395 this.replaceSession(tab, updated);
1396 if (this.isActiveTab(updated)) {
1397 this.dirty = false;
1398 }
1399 this.setMessage("Saved");
1400 await this.refresh();
1401 return true;
1402 } catch (error) {
1403 this.error = error instanceof Error ? error.message : String(error);
1404 return false;
1405 } finally {
1406 this.saving = false;
1407 }
1408 },
1409
1410 async renameActiveFile() {
1411 if (!this.session || this.saving) return;
1412 this.applyPreviewEdit({ silent: true });
1413 const session = this.session;
1414 const path = session.path || session.document?.path || "";
1415 if (!path) {
1416 this.error = "This document does not have a file path to rename.";
1417 return;
1418 }
1419 const name = basename(path || session.title || "");
1420 const extension = extensionOf(name);
1421 await fileBrowserStore.openRenameModal(
1422 {
1423 name,
1424 path,
1425 is_dir: false,
1426 size: session.document?.size || 0,
1427 modified: session.document?.last_modified || "",
1428 type: "document",
1429 },
1430 {
1431 currentPath: parentPath(path),
1432 validateName: (newName) => {
1433 if (!extension) return true;
1434 return extensionOf(newName) === extension || `Keep the .${extension} extension for this open document.`;
1435 },
1436 performRename: async ({ path: renamedPath }) => {
1437 const payload = {
1438 file_id: session.file_id || "",
1439 path: renamedPath,
1440 };
1441 if (this.isTextDocument(session)) {
1442 this.syncEditorText();
1443 payload.text = this.session?.tab_id === session.tab_id ? this.editorText : session.text || "";
1444 }
1445 return await callEditor("renamed", payload);
1446 },
1447 onRenamed: async ({ path: renamedPath, response }) => {
1448 await this.handleActiveFileRenamed(session, renamedPath, response);
1449 },
1450 },
1451 );
1452 },
1453
1454 async handleActiveFileRenamed(session, renamedPath, renameResponse = null) {
1455 const response = renameResponse || await callEditor("renamed", {
1456 file_id: session.file_id || "",
1457 path: renamedPath,
1458 });
1459 if (response?.ok === false) throw new Error(response.error || "Rename failed.");
1460
1461 const document = normalizeTextDocument(response.document || session.document || {});
1462 const updated = {
1463 ...session,
1464 document,
1465 title: document.title || document.basename || basename(document.path),
1466 path: document.path || renamedPath,
1467 extension: document.extension || session.extension,
1468 file_id: document.file_id || session.file_id,
1469 version: document.version || response.version || session.version,
1470 text: this.session?.tab_id === session.tab_id ? this.editorText : session.text,
1471 dirty: false,
1472 };
1473 this.replaceSession(session, updated);
1474 this.dirty = false;
1475 this.setMessage("Renamed");
1476 await this.refresh();
1477 },
1478
1479 replaceActiveSession(next) {
1480 if (!this.session) return;
1481 this.replaceSession(this.session, next);
1482 },
1483
1484 replaceSession(previous, next) {
1485 const wasActive = this.activeTabId === (previous?.tab_id || next.tab_id);
1486 if (wasActive) {
1487 this.session = next;
1488 this.updateSourceEditorMode(next);
1489 }
1490 const index = this.tabs.findIndex((tab) => tab.tab_id === (previous?.tab_id || next.tab_id));
1491 if (index >= 0) this.tabs.splice(index, 1, next);
1492 },
1493
1494 setMessage(value) {
1495 this.message = value;
1496 if (this._saveMessageTimer) globalThis.clearTimeout(this._saveMessageTimer);
1497 this._saveMessageTimer = globalThis.setTimeout(() => {
1498 this.message = "";
1499 this._saveMessageTimer = null;
1500 }, SAVE_MESSAGE_MS);
1501 },
1502
1503 resetHistory(text) {
1504 this._history = [String(text || "")];
1505 this._historyIndex = 0;
1506 this._historyPushedAt = 0;
1507 },
1508
1509 pushHistory(text, coalesce = false) {
1510 const value = String(text || "");
1511 if (this._history[this._historyIndex] === value) return;
1512 const now = Date.now();
1513 if (
1514 coalesce
1515 && this._historyPushedAt
1516 && now - this._historyPushedAt <= INPUT_PUSH_DELAY_MS
1517 && this._historyIndex === this._history.length - 1
1518 && this._historyIndex > 0
1519 ) {
1520 this._history[this._historyIndex] = value;
1521 this._historyPushedAt = now;
1522 return;
1523 }
1524 this._history = this._history.slice(0, this._historyIndex + 1);
1525 this._history.push(value);
1526 if (this._history.length > MAX_HISTORY) this._history.shift();
1527 this._historyIndex = this._history.length - 1;
1528 this._historyPushedAt = coalesce ? now : 0;
1529 },
1530
1531 undo() {
1532 if (this._historyIndex <= 0) return;
1533 this._historyPushedAt = 0;
1534 this._historyIndex -= 1;
1535 this.applyEditorText(this._history[this._historyIndex], true);
1536 },
1537
1538 redo() {
1539 if (this._historyIndex >= this._history.length - 1) return;
1540 this._historyPushedAt = 0;
1541 this._historyIndex += 1;
1542 this.applyEditorText(this._history[this._historyIndex], true);
1543 },
1544
1545 canUndo() {
1546 return this._historyIndex > 0;
1547 },
1548
1549 canRedo() {
1550 return this._historyIndex < this._history.length - 1;
1551 },
1552
1553 applyEditorText(text, markDirty = false) {
1554 this.editorText = String(text || "");
1555 this.setSourceEditorText(this.editorText);
1556 if (this.session) {
1557 this.session.text = this.editorText;
1558 this.session.dirty = markDirty || this.session.dirty;
1559 }
1560 if (markDirty) this.markDirty();
1561 this.queueRender({ focus: true });
1562 },
1563
1564 markDirty() {
1565 this.dirty = true;
1566 if (this.session) this.session.dirty = true;
1567 },
1568
1569 onSourceInput() {
1570 this.markDirty();
1571 this.pushHistory(this.editorText, true);
1572 this.scheduleInputPush();
1573 },
1574
1575 syncEditorText() {
1576 if (!this.session) return;
1577 if (this.previewEditing) return;
1578 if (this.sourceEditor && this.isSourceMode()) {
1579 this.editorText = this.sourceEditor.getValue();
1580 }
1581 this.session.text = this.editorText;
1582 },
1583
1584 scheduleInputPush() {
1585 if (!this.session?.session_id || !this.isTextDocument()) return;
1586 if (this._inputTimer) globalThis.clearTimeout(this._inputTimer);
1587 this._inputTimer = globalThis.setTimeout(() => {
1588 this._inputTimer = null;
1589 this.flushInput();
1590 }, INPUT_PUSH_DELAY_MS);
1591 },
1592
1593 flushInput() {
1594 if (!this.session?.session_id || !this.isTextDocument()) return;
1595 if (this.previewEditing) return;
1596 this.syncEditorText();
1597 requestEditor("editor_input", {
1598 session_id: this.session.session_id,
1599 text: this.editorText,
1600 }, 3000).catch(() => {});
1601 },
1602
1603 format(command) {
1604 if (!this.session || !this.isTextDocument()) return;
1605 if (this.sourceEditor && this.isSourceMode()) {
1606 const selected = this.sourceEditor.getSelectedText();
1607 const replacement = this.formatReplacement(command, selected);
1608 if (replacement === selected) return;
1609 this.sourceEditor.session.replace(this.sourceEditor.getSelectionRange(), replacement);
1610 this.editorText = this.sourceEditor.getValue();
1611 this.onSourceInput();
1612 this.sourceEditor.focus();
1613 return;
1614 }
1615 const textarea = this._root?.querySelector?.("[data-editor-source]");
1616 if (!textarea) return;
1617 const start = textarea.selectionStart || 0;
1618 const end = textarea.selectionEnd || start;
1619 const selected = this.editorText.slice(start, end);
1620 const replacement = this.formatReplacement(command, selected);
1621 if (replacement === selected) return;
1622 this.editorText = `${this.editorText.slice(0, start)}${replacement}${this.editorText.slice(end)}`;
1623 this.onSourceInput();
1624 globalThis.requestAnimationFrame?.(() => {
1625 textarea.focus();
1626 textarea.selectionStart = start;
1627 textarea.selectionEnd = start + replacement.length;
1628 });
1629 },
1630
1631 formatReplacement(command, selected = "") {
1632 if (command === "bold") return `**${selected || "text"}**`;
1633 if (command === "italic") return `*${selected || "text"}*`;
1634 if (command === "list") return (selected || "item").split("\n").map((line) => `- ${line.replace(/^[-*]\s+/, "")}`).join("\n");
1635 if (command === "numbered") return (selected || "item").split("\n").map((line, index) => `${index + 1}. ${line.replace(/^\d+\.\s+/, "")}`).join("\n");
1636 if (command === "table") return "| Column | Value |\n| --- | --- |\n| | |";
1637 return selected;
1638 },
1639
1640 queueRender(options = {}) {
1641 if (options.focus) {
1642 this._pendingFocus = true;
1643 this._pendingFocusEnd = options.end !== false;
1644 this._focusAttempts = 0;
1645 }
1646 const render = () => {
1647 if (this._pendingFocus && this.focusEditor({ end: this._pendingFocusEnd })) {
1648 this._pendingFocus = false;
1649 this._focusAttempts = 0;
1650 } else if (this._pendingFocus && this._focusAttempts < 6) {
1651 this._focusAttempts += 1;
1652 globalThis.setTimeout(render, 45);
1653 }
1654 };
1655 if (globalThis.requestAnimationFrame) {
1656 globalThis.requestAnimationFrame(render);
1657 } else {
1658 globalThis.setTimeout(render, 0);
1659 }
1660 },
1661
1662 focusEditor(options = {}) {
1663 if (!this.session || !this.isTextDocument()) return false;
1664 if (this.sourceEditor && this.isSourceMode()) {
1665 this.sourceEditor.focus();
1666 if (options.end !== false) {
1667 const session = this.sourceEditor.session;
1668 const row = Math.max(0, session.getLength() - 1);
1669 const column = session.getLine(row).length;
1670 this.sourceEditor.moveCursorTo(row, column);
1671 }
1672 return true;
1673 }
1674 const source = this._root?.querySelector?.("[data-editor-source]");
1675 if (!source) return false;
1676 source.focus?.({ preventScroll: true });
1677 if (!editorContainsFocus(source)) return false;
1678 if (options.end !== false) placeCaretAtEnd(source);
1679 return true;
1680 },
1681
1682 isMarkdown(tab = this.session) {
1683 const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase();
1684 return ext === "md";
1685 },
1686
1687 isTextDocument(tab = this.session) {
1688 const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase();
1689 return EDITOR_TEXT_EXTENSIONS.has(ext);
1690 },
1691
1692 hasActiveFile(tab = this.session) {
1693 return Boolean(tab && this.isTextDocument(tab));
1694 },
1695
1696 visibleTabs() {
1697 return this.tabs.filter((tab) => this.hasActiveFile(tab));
1698 },
1699
1700 defaultTitle(kind, fmt) {
1701 const date = new Date().toISOString().slice(0, 10);
1702 if (fmt === "md") return `Markdown ${date}`;
1703 if (fmt === "txt") return `Text ${date}`;
1704 return `Text ${date}`;
1705 },
1706
1707 tabTitle(tab = {}) {
1708 tab = tab || {};
1709 return tab.title || tab.document?.basename || basename(tab.path);
1710 },
1711
1712 tabLabel(tab = {}) {
1713 tab = tab || {};
1714 const title = this.tabTitle(tab);
1715 return tab.dirty ? `${title} unsaved` : title;
1716 },
1717
1718 tabIcon(tab = {}) {
1719 tab = tab || {};
1720 const ext = String(tab.extension || tab.document?.extension || "").toLowerCase();
1721 if (ext === "md") return "article";
1722 if (ext === "txt") return "description";
1723 return "draft";
1724 },
1725
1726 async runNewMenuAction(action = "") {
1727 const normalized = String(action || "").trim().toLowerCase();
1728 if (normalized === "open") return await this.openFileBrowser();
1729 if (normalized === "markdown") return await this.create("document", "md");
1730 if (normalized === "text") return await this.create("document", "txt");
1731 return null;
1732 },
1733
1734 installHeaderNewMenu(header = null) {
1735 if (!header || header.querySelector(".editor-header-actions")) return () => {};
1736
1737 const root = document.createElement("div");
1738 root.className = "editor-header-actions surface-modal-new-action";
1739 root.innerHTML = `
1740 <button type="button" class="editor-header-new-button surface-modal-new-button" aria-haspopup="menu" aria-expanded="false">
1741 <x-icon aria-hidden="true" name="add"></x-icon>
1742 <span>New</span>
1743 <x-icon class="editor-new-chevron" aria-hidden="true" name="expand_more"></x-icon>
1744 </button>
1745 <div class="editor-new-menu" role="menu" hidden>
1746 <button type="button" class="editor-new-menu-item" role="menuitem" data-editor-new-action="open">
1747 <x-icon aria-hidden="true" name="folder_open"></x-icon>
1748 <span>Open</span>
1749 </button>
1750 <button type="button" class="editor-new-menu-item" role="menuitem" data-editor-new-action="markdown">
1751 <x-icon aria-hidden="true" name="article"></x-icon>
1752 <span>Markdown</span>
1753 </button>
1754 <button type="button" class="editor-new-menu-item" role="menuitem" data-editor-new-action="text">
1755 <x-icon aria-hidden="true" name="description"></x-icon>
1756 <span>Text</span>
1757 </button>
1758 </div>
1759 `;
1760
1761 const button = root.querySelector(".editor-header-new-button");
1762 const menu = root.querySelector(".editor-new-menu");
1763 const setOpen = (open) => {
1764 root.classList.toggle("is-open", open);
1765 button?.setAttribute("aria-expanded", open.toString());
1766 if (menu) menu.hidden = !open;
1767 };
1768 const onButtonClick = (event) => {
1769 event.preventDefault();
1770 event.stopPropagation();
1771 setOpen(!root.classList.contains("is-open"));
1772 };
1773 const onMarkdownClick = (event) => {
1774 if (!root.contains(event.target)) setOpen(false);
1775 };
1776 const onMarkdownKeydown = (event) => {
1777 if (event.key === "Escape") setOpen(false);
1778 };
1779
1780 button?.addEventListener("click", onButtonClick);
1781 for (const item of root.querySelectorAll("[data-editor-new-action]")) {
1782 item.addEventListener("click", async (event) => {
1783 event.preventDefault();
1784 event.stopPropagation();
1785 const action = event.currentTarget?.dataset?.editorNewAction || "";
1786 setOpen(false);
1787 await this.runNewMenuAction(action);
1788 });
1789 }
1790 document.addEventListener("click", onMarkdownClick);
1791 document.addEventListener("keydown", onMarkdownKeydown);
1792
1793 placeSurfaceModalHeaderAction(header, root, "new");
1794
1795 setOpen(false);
1796 return () => {
1797 button?.removeEventListener("click", onButtonClick);
1798 document.removeEventListener("click", onMarkdownClick);
1799 document.removeEventListener("keydown", onMarkdownKeydown);
1800 root.remove();
1801 };
1802 },
1803
1804 setupMarkdownModal(element = null) {
1805 const root = element || document.querySelector(".editor-panel");
1806 const inner = root?.closest?.(".modal-inner");
1807 const header = inner?.querySelector?.(".modal-header");
1808 if (!inner || !header || inner.dataset.editorModalReady === "1") return;
1809 inner.dataset.editorModalReady = "1";
1810 inner.classList.add("editor-modal");
1811 const floatingCleanup = setupFloatingSurfaceModalChrome({
1812 root,
1813 modalClass: "editor-modal",
1814 focusButtonClass: "editor-modal-focus-button",
1815 minWidth: 640,
1816 minHeight: 460,
1817 onBoundsChange: () => this.refreshSourceEditorLayout(),
1818 onFocusChange: () => this.refreshSourceEditorLayout(),
1819 });
1820 const menuCleanup = this.installHeaderNewMenu(header);
1821 this._headerCleanup = () => {
1822 menuCleanup?.();
1823 floatingCleanup?.();
1824 delete inner.dataset.editorModalReady;
1825 inner.classList.remove("editor-modal", "is-focus-mode");
1826 };
1827 },
1828
1829 async handleEditorUrlIntent(intent = {}) {
1830 const editor = editorIntent(intent?.url || "");
1831 if (!editor) return false;
1832 await openLatestSurface("editor", {
1833 path: editor.path,
1834 refresh: true,
1835 source: intent?.source || "desktop-open",
1836 });
1837 return true;
1838 },
1839 };
1840
1841 export const store = createStore("editor", model);
1842
1843 registerUrlHandler((intent) => model.handleEditorUrlIntent(intent));