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 {
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 {
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
}
107
selection.addRange(range);
108
}
109
80
-function normalizeMarkdown(doc = {}) {
110
+function normalizeTextDocument(doc = {}) {
111
const path = doc.path || "";
112
const extension = String(doc.extension || extensionOf(path)).toLowerCase();
113
return {
120
}
121
122
function normalizeSession(payload = {}) {
93
- const document = normalizeMarkdown(payload.document || payload);
123
+ const document = normalizeTextDocument(payload.document || payload);
124
return {
125
...payload,
126
document,
331
},
332
333
async setViewMode(mode) {
304
- const next = mode === PREVIEW_MODE ? PREVIEW_MODE : SOURCE_MODE;
334
+ const next = mode === PREVIEW_MODE && this.isMarkdown() ? PREVIEW_MODE : SOURCE_MODE;
335
if (this.viewMode === next) return;
336
this.applyPreviewEdit({ silent: true });
337
this.syncEditorText();
349
},
350
351
async toggleViewMode() {
352
+ if (!this.isMarkdown()) return;
353
await this.setViewMode(this.isPreviewMode() ? SOURCE_MODE : PREVIEW_MODE);
354
},
355
362
},
363
364
pages() {
365
+ if (!this.isMarkdown()) return [];
366
return buildMarkdownPages(this.editorText, this.tabTitle(this.session || {}));
367
},
368
383
},
384
385
previewHtml() {
386
+ if (!this.isMarkdown()) return "";
387
return renderEditorPreviewMarkdown(this.currentPage().markdown || "", this.editorText);
388
},
389
509
},
510
511
schedulePreviewEnhance() {
479
- if (!this.isPreviewMode()) return;
512
+ if (!this.isMarkdown() || !this.isPreviewMode()) return;
513
if (this._previewEnhanceTimer) globalThis.clearTimeout(this._previewEnhanceTimer);
514
this._previewEnhanceTimer = globalThis.setTimeout(() => {
515
this._previewEnhanceTimer = null;
702
},
703
704
openSearch() {
705
+ if (!this.isMarkdown()) return;
706
if (!this.isPreviewMode()) {
707
this.setViewMode(PREVIEW_MODE);
708
}
866
867
handleEditorKeydown(event) {
868
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f") {
869
+ if (!this.isMarkdown()) return;
870
event.preventDefault();
871
this.openSearch();
872
}
873
},
874
875
async create(kind = "document", format = "") {
841
- const fmt = "md";
876
+ const requested = String(format || "md").toLowerCase().replace(/^\./, "");
877
+ const fmt = EDITOR_TEXT_EXTENSIONS.has(requested) ? requested : "md";
878
const title = this.defaultTitle(kind, fmt);
879
return await this.openSession({
880
action: "create",
902
// The file browser can still open with the static fallback.
903
}
904
}
869
- await fileBrowserStore.open(workdirPath);
905
+ await fileBrowserStore.openTextPicker(workdirPath, async ({ selectedFiles = [] } = {}) => {
906
+ const files = selectedFiles.filter((file) => file?.path);
907
+ if (!files.length) return false;
908
+ for (const file of files) {
909
+ const session = await this.openPath(file.path, { source: "file-browser", refresh: true });
910
+ if (!session || session.ok === false) {
911
+ throw new Error(this.error || `Could not open ${file.name || file.path}`);
912
+ }
913
+ }
914
+ return true;
915
+ });
916
},
917
918
async openPath(path, options = {}) {
929
try {
930
const response = await callEditor(payload.action || "open", payload);
931
if (response?.ok === false) {
886
- this.error = response.error || "Markdown could not be opened.";
932
+ this.error = response.error || "Text document could not be opened.";
933
return null;
934
}
935
if (response?.requires_desktop) {
890
- const document = normalizeMarkdown(response.document || response);
936
+ const document = normalizeTextDocument(response.document || response);
937
this.setMessage(`${documentLabel(document)} uses the Desktop surface.`);
938
await this.refresh();
939
return response;
977
this.activeTabId = tab?.tab_id || "";
978
this.editorText = String(tab?.text || "");
979
this.dirty = Boolean(tab?.dirty);
980
+ if (!this.isMarkdown(tab) && this.isPreviewMode()) {
981
+ this.viewMode = SOURCE_MODE;
982
+ this.searchOpen = false;
983
+ this.searchQuery = "";
984
+ }
985
if (this.previewEditing) this.cancelPreviewEdit();
986
if (!options.preservePage) {
987
this.activePageIndex = 0;
992
this.searchIndex = -1;
993
this.resetHistory(this.editorText);
994
this.setSourceEditorText(this.editorText);
995
+ this.updateSourceEditorMode();
996
if (tab?.session_id) {
997
requestEditor("editor_activate", { session_id: tab.session_id }, 2500).catch(() => {});
998
}
1046
if (!pending) return "";
1047
const dirtyCount = Number(pending.dirtyCount || 0);
1048
if (pending.kind === "all") {
997
- if (dirtyCount === 0) return "All open Markdown files will be closed.";
1049
+ if (dirtyCount === 0) return "All open text files will be closed.";
1050
return `${dirtyCount} open ${dirtyCount === 1 ? "file has" : "files have"} unsaved changes.`;
1051
}
1052
if (dirtyCount > 0) return "This file has unsaved changes.";
1121
file_id: tab.file_id || "",
1122
});
1123
} catch (error) {
1072
- console.warn("Markdown close skipped", error);
1124
+ console.warn("Editor close skipped", error);
1125
}
1126
this.tabs = this.tabs.filter((item) => item.tab_id !== tabId);
1127
if (this.pendingClose?.tabId === tabId || this.pendingClose?.tabIds?.includes(tabId)) {
1192
const darkMode = globalThis.localStorage?.getItem("darkMode");
1193
const theme = darkMode !== "false" ? "ace/theme/github_dark" : "ace/theme/github";
1194
editor.setTheme(theme);
1143
- editor.session.setMode("ace/mode/markdown");
1195
+ editor.session.setMode(this.sourceEditorMode());
1196
editor.session.setUseWrapMode(true);
1197
editor.setOptions({
1198
fontSize: "13px",
1211
editor.session.on("change", this._sourceEditorChangeHandler);
1212
this.sourceEditor = editor;
1213
this.aceUnavailable = false;
1214
+ this.updateSourceEditorMode();
1215
this.queueRender({ focus: Boolean(this.session), end: false });
1216
},
1217
1218
+ sourceEditorMode(tab = this.session) {
1219
+ return this.isMarkdown(tab) ? "ace/mode/markdown" : "ace/mode/text";
1220
+ },
1221
+
1222
+ updateSourceEditorMode(tab = this.session) {
1223
+ try {
1224
+ this.sourceEditor?.session?.setMode(this.sourceEditorMode(tab));
1225
+ } catch {}
1226
+ },
1227
+
1228
destroySourceEditor() {
1229
if (this.sourceEditor?.session && this._sourceEditorChangeHandler) {
1230
this.sourceEditor.session.off?.("change", this._sourceEditorChangeHandler);
1262
},
1263
1264
async save() {
1202
- if (!this.session || this.saving || !this.isMarkdown()) return;
1265
+ if (!this.session || this.saving || !this.isTextDocument()) return;
1266
this.applyPreviewEdit({ silent: true });
1267
this.syncEditorText();
1268
this.saving = true;
1276
response = await callEditor("save", payload);
1277
}
1278
if (response?.ok === false) throw new Error(response.error || "Save failed.");
1216
- const document = normalizeMarkdown(response.document || this.session.document || {});
1279
+ const document = normalizeTextDocument(response.document || this.session.document || {});
1280
const updated = {
1281
...this.session,
1282
text: this.editorText,
1284
document,
1285
path: document.path || this.session.path,
1286
file_id: document.file_id || this.session.file_id,
1287
+ extension: document.extension || this.session.extension,
1288
version: document.version || response.version || this.session.version,
1289
};
1290
this.replaceActiveSession(updated);
1298
}
1299
},
1300
1301
+ async saveAs() {
1302
+ if (!this.session || this.saving || !this.isTextDocument()) return;
1303
+ this.applyPreviewEdit({ silent: true });
1304
+ this.syncEditorText();
1305
+
1306
+ let startPath = parentPath(this.session.path || this.session.document?.path || "");
1307
+ if (!startPath || startPath === "/") {
1308
+ try {
1309
+ const home = await callEditor("home");
1310
+ startPath = home?.path || startPath || "/a0/usr/workdir";
1311
+ } catch {
1312
+ startPath = "/a0/usr/workdir";
1313
+ }
1314
+ }
1315
+
1316
+ await fileBrowserStore.openSaveAsPicker(startPath, {
1317
+ filename: textDocumentFilename(this.session.path || this.session.title || "Untitled.md"),
1318
+ defaultExtension: textDocumentDefaultExtension(this.session.path || this.session.title || "Untitled.md"),
1319
+ onConfirm: async ({ path } = {}) => {
1320
+ if (!path) return false;
1321
+ await this.saveAsPath(path);
1322
+ return true;
1323
+ },
1324
+ });
1325
+ },
1326
+
1327
+ async saveAsPath(path) {
1328
+ if (!this.session || this.saving || !this.isTextDocument()) return null;
1329
+ this.saving = true;
1330
+ this.error = "";
1331
+ try {
1332
+ const payload = {
1333
+ session_id: this.session.session_id,
1334
+ store_session_id: this.session.store_session_id || "",
1335
+ path,
1336
+ text: this.editorText,
1337
+ };
1338
+ const response = await callEditor("save_as", payload);
1339
+ if (response?.ok === false) throw new Error(response.error || "Save As failed.");
1340
+ const document = normalizeTextDocument(response.document || this.session.document || {});
1341
+ const updated = {
1342
+ ...this.session,
1343
+ text: this.editorText,
1344
+ dirty: false,
1345
+ document,
1346
+ title: document.title || document.basename || basename(document.path),
1347
+ path: document.path || path,
1348
+ file_id: document.file_id || this.session.file_id,
1349
+ extension: document.extension || this.session.extension,
1350
+ store_session_id: response.store_session_id || this.session.store_session_id,
1351
+ version: document.version || response.version || this.session.version,
1352
+ };
1353
+ this.replaceActiveSession(updated);
1354
+ this.dirty = false;
1355
+ this.setMessage("Saved As");
1356
+ await this.refresh();
1357
+ return updated;
1358
+ } catch (error) {
1359
+ this.error = error instanceof Error ? error.message : String(error);
1360
+ throw error;
1361
+ } finally {
1362
+ this.saving = false;
1363
+ }
1364
+ },
1365
+
1366
async saveTab(tab) {
1238
- if (!tab || this.saving || !this.isMarkdown(tab)) return false;
1367
+ if (!tab || this.saving || !this.isTextDocument(tab)) return false;
1368
if (this.isActiveTab(tab)) {
1369
this.applyPreviewEdit({ silent: true });
1370
this.syncEditorText();
1383
response = await callEditor("save", payload);
1384
}
1385
if (response?.ok === false) throw new Error(response.error || "Save failed.");
1257
- const document = normalizeMarkdown(response.document || tab.document || {});
1386
+ const document = normalizeTextDocument(response.document || tab.document || {});
1387
const updated = {
1388
...tab,
1389
text: payload.text,
1391
document,
1392
path: document.path || tab.path,
1393
file_id: document.file_id || tab.file_id,
1394
+ extension: document.extension || tab.extension,
1395
version: document.version || response.version || tab.version,
1396
};
1397
this.replaceSession(tab, updated);
1440
file_id: session.file_id || "",
1441
path: renamedPath,
1442
};
1313
- if (this.isMarkdown(session)) {
1443
+ if (this.isTextDocument(session)) {
1444
this.syncEditorText();
1445
payload.text = this.session?.tab_id === session.tab_id ? this.editorText : session.text || "";
1446
}
1460
});
1461
if (response?.ok === false) throw new Error(response.error || "Rename failed.");
1462
1333
- const document = normalizeMarkdown(response.document || session.document || {});
1463
+ const document = normalizeTextDocument(response.document || session.document || {});
1464
const updated = {
1465
...session,
1466
document,
1485
1486
replaceSession(previous, next) {
1487
const wasActive = this.activeTabId === (previous?.tab_id || next.tab_id);
1358
- if (wasActive) this.session = next;
1488
+ if (wasActive) {
1489
+ this.session = next;
1490
+ if (!this.isMarkdown(next) && this.isPreviewMode()) this.viewMode = SOURCE_MODE;
1491
+ this.updateSourceEditorMode(next);
1492
+ }
1493
const index = this.tabs.findIndex((tab) => tab.tab_id === (previous?.tab_id || next.tab_id));
1494
if (index >= 0) this.tabs.splice(index, 1, next);
1495
},
1581
},
1582
1583
scheduleInputPush() {
1450
- if (!this.session?.session_id || !this.isMarkdown()) return;
1584
+ if (!this.session?.session_id || !this.isTextDocument()) return;
1585
if (this._inputTimer) globalThis.clearTimeout(this._inputTimer);
1586
this._inputTimer = globalThis.setTimeout(() => {
1587
this._inputTimer = null;
1590
},
1591
1592
flushInput() {
1459
- if (!this.session?.session_id || !this.isMarkdown()) return;
1593
+ if (!this.session?.session_id || !this.isTextDocument()) return;
1594
if (this.previewEditing) return;
1595
this.syncEditorText();
1596
requestEditor("editor_input", {
1659
},
1660
1661
focusEditor(options = {}) {
1528
- if (!this.session || !this.isMarkdown()) return false;
1662
+ if (!this.session || !this.isTextDocument()) return false;
1663
if (this.sourceEditor && this.isSourceMode()) {
1664
this.sourceEditor.focus();
1665
if (options.end !== false) {
1683
return ext === "md";
1684
},
1685
1686
+ isTextDocument(tab = this.session) {
1687
+ const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase();
1688
+ return EDITOR_TEXT_EXTENSIONS.has(ext);
1689
+ },
1690
+
1691
hasActiveFile(tab = this.session) {
1553
- return Boolean(tab && this.isMarkdown(tab));
1692
+ return Boolean(tab && this.isTextDocument(tab));
1693
},
1694
1695
visibleTabs() {
1699
defaultTitle(kind, fmt) {
1700
const date = new Date().toISOString().slice(0, 10);
1701
if (fmt === "md") return `Markdown ${date}`;
1563
- return `Markdown ${date}`;
1702
+ if (fmt === "txt") return `Text ${date}`;
1703
+ return `Text ${date}`;
1704
},
1705
1706
tabTitle(tab = {}) {
1718
tab = tab || {};
1719
const ext = String(tab.extension || tab.document?.extension || "").toLowerCase();
1720
if (ext === "md") return "article";
1721
+ if (ext === "txt") return "description";
1722
return "draft";
1723
},
1724
1726
const normalized = String(action || "").trim().toLowerCase();
1727
if (normalized === "open") return await this.openFileBrowser();
1728
if (normalized === "markdown") return await this.create("document", "md");
1729
+ if (normalized === "text") return await this.create("document", "txt");
1730
return null;
1731
},
1732
1750
<span class="material-symbols-outlined" aria-hidden="true">article</span>
1751
<span>Markdown</span>
1752
</button>
1753
+ <button type="button" class="editor-new-menu-item" role="menuitem" data-editor-new-action="text">
1754
+ <span class="material-symbols-outlined" aria-hidden="true">description</span>
1755
+ <span>Text</span>
1756
+ </button>
1757
</div>
1758
`;
1759
1824
inner.classList.remove("editor-modal", "is-focus-mode");
1825
};
1826
},
1827
+
1828
+ async handleEditorUrlIntent(intent = {}) {
1829
+ const editor = editorIntent(intent?.url || "");
1830
+ if (!editor) return false;
1831
+ await openLatestSurface("editor", {
1832
+ path: editor.path,
1833
+ refresh: true,
1834
+ source: intent?.source || "desktop-open",
1835
+ });
1836
+ return true;
1837
+ },
1838
};
1839
1840
export const store = createStore("editor", model);
1841
+
1842
+registerUrlHandler((intent) => model.handleEditorUrlIntent(intent));