Improve Editor text file workflows

Generalize the Editor storage/session path from Markdown-only to exact-text .md and .txt documents, including open, save, Save As, and rename refresh behavior. Expose Open in Editor as a visible row action in the File Browser for Editor-owned text files, keep non-Editor surface opens in the overflow menu, and route Desktop text MIME handling through the Editor bridge. Tests: node --check webui/components/modals/file-browser/file-browser-store.js; python -m py_compile plugins/_office/helpers/document_store.py plugins/_editor/helpers/markdown_sessions.py plugins/_editor/api/editor_session.py plugins/_editor/api/ws_editor.py plugins/_desktop/helpers/desktop_session.py plugins/_desktop/api/desktop_session.py plugins/_office/api/office_session.py plugins/_office/api/ws_office.py; pytest tests/test_office_document_store.py tests/test_file_browser_navigation.py tests/test_office_canvas_setup.py -q

Alessandro committed Jun 23, 2026 at 14:42 UTC bd584da2f4b8c9afc6c3979ec62dd9f1d36bf267
21 files changed +967 -108
plugins/_desktop/AGENTS.md
+1
@@ -16,6 +16,7 @@
16 - Preserve session startup, cleanup, and route protection for desktop access.
17 - Keep desktop state injected into prompts accurate and bounded.
18 - Do not expose desktop routes without the expected auth protections.
19 +- Keep Markdown and plain text file open-with handling routed to the Editor surface through the desktop intent bridge; Desktop owns the Xfce launcher/MIME setup, while Editor owns `.md` and `.txt` editing.
20
21 ## Work Guidance
22
plugins/_desktop/api/desktop_session.py
+2 -2
@@ -74,10 +74,10 @@ class DesktopSession(ApiHandler):
74 return {"ok": False, "error": str(exc)}
75
76 ext = str(doc.get("extension") or "").lower()
77 - if ext == "md":
77 + if ext in document_store.EDITOR_TEXT_EXTENSIONS:
78 return {
79 "ok": False,
80 - "error": "Markdown documents use the Editor surface.",
80 + "error": "Text documents use the Editor surface.",
81 "requires_editor": True,
82 "document": _public_doc(doc),
83 }
plugins/_desktop/helpers/desktop_session.py
+103 -7
@@ -23,7 +23,7 @@ from plugins._desktop.helpers import desktop_state
23 from plugins._office.helpers import document_store, libreoffice
24
25
26 -OFFICIAL_EXTENSIONS = {"odt", "ods", "odp", "docx", "xlsx", "pptx", "txt"}
26 +OFFICIAL_EXTENSIONS = {"odt", "ods", "odp", "docx", "xlsx", "pptx"}
27 PLUGIN_NAME = "_desktop"
28 SYSTEM_SESSION_ID = "agent-zero-desktop"
29 SYSTEM_FILE_ID = "system-desktop"
@@ -78,6 +78,7 @@ DESKTOP_FOLDER_LINKS = (
78 URL_INTENT_MAX_ITEMS = 50
79 URL_INTENT_MAX_LENGTH = 8192
80 URL_HANDLER_DESKTOP_ID = "agent-zero-browser.desktop"
81 +EDITOR_HANDLER_DESKTOP_ID = "agent-zero-editor.desktop"
82 SHUTDOWN_HANDLER_DESKTOP_ID = "agent-zero-shutdown.desktop"
83 SHUTDOWN_PANEL_LAUNCHER_ID = SHUTDOWN_HANDLER_DESKTOP_ID
84 SHUTDOWN_CONFIRM_SECONDS = 8
@@ -1135,6 +1136,7 @@ class DesktopSessionManager:
1136 applications_dir.mkdir(parents=True, exist_ok=True)
1137
1138 browser_bridge = _write_url_bridge_script(session)
1139 + editor_bridge = _write_editor_bridge_script(session)
1140 shutdown_bridge = _write_shutdown_bridge_script(session)
1141 helpers_rc = config_dir / "xfce4" / "helpers.rc"
1142 helpers_rc.parent.mkdir(parents=True, exist_ok=True)
@@ -1153,8 +1155,12 @@ class DesktopSessionManager:
1155 config_dir / "xfce4" / "helpers" / "agent-zero-browser.desktop",
1156 browser_bridge,
1157 )
1156 - _write_mimeapps_defaults(config_dir / "mimeapps.list", URL_HANDLER_DESKTOP_ID)
1157 - _write_mimeapps_defaults(data_dir / "applications" / "mimeapps.list", URL_HANDLER_DESKTOP_ID)
1158 + _write_mimeapps_defaults(config_dir / "mimeapps.list", URL_HANDLER_DESKTOP_ID, EDITOR_HANDLER_DESKTOP_ID)
1159 + _write_mimeapps_defaults(
1160 + data_dir / "applications" / "mimeapps.list",
1161 + URL_HANDLER_DESKTOP_ID,
1162 + EDITOR_HANDLER_DESKTOP_ID,
1163 + )
1164 _write_desktop_launcher(
1165 applications_dir / URL_HANDLER_DESKTOP_ID,
1166 name="Agent Zero Browser",
@@ -1165,6 +1171,15 @@ class DesktopSessionManager:
1171 mime_types=_url_handler_mime_types(),
1172 no_display=True,
1173 )
1174 + _write_desktop_launcher(
1175 + applications_dir / EDITOR_HANDLER_DESKTOP_ID,
1176 + name="Agent Zero Editor",
1177 + exec_line=_desktop_exec(editor_bridge, "%F"),
1178 + icon="accessories-text-editor",
1179 + categories="Utility;TextEditor;",
1180 + try_exec=str(editor_bridge),
1181 + mime_types=_editor_text_handler_mime_types(),
1182 + )
1183 _write_desktop_launcher(
1184 applications_dir / SHUTDOWN_HANDLER_DESKTOP_ID,
1185 name="Shutdown Desktop",
@@ -1190,6 +1205,14 @@ class DesktopSessionManager:
1205 categories="Network;WebBrowser;",
1206 try_exec=str(browser_bridge),
1207 )
1208 + _write_desktop_launcher(
1209 + desktop_dir / "Editor.desktop",
1210 + name="Editor",
1211 + exec_line=_desktop_exec(editor_bridge),
1212 + icon="accessories-text-editor",
1213 + categories="Utility;TextEditor;",
1214 + try_exec=str(editor_bridge),
1215 + )
1216 self._trust_desktop_launchers(session, desktop_dir)
1217
1218 def _hide_xpra_desktop_entries(self, applications_dir: Path) -> None:
@@ -1830,6 +1853,10 @@ def _url_bridge_script_path(session: DesktopSession) -> Path:
1853 return _url_bridge_dir(session) / "open-url"
1854
1855
1856 +def _editor_bridge_script_path(session: DesktopSession) -> Path:
1857 + return _url_bridge_dir(session) / "open-editor"
1858 +
1859 +
1860 def _url_bridge_queue_path(session: DesktopSession) -> Path:
1861 return _url_bridge_dir(session) / "browser-url-intents.jsonl"
1862
@@ -1900,6 +1927,64 @@ if __name__ == "__main__":
1927 return script
1928
1929
1930 +def _write_editor_bridge_script(session: DesktopSession) -> Path:
1931 + bridge_dir = _url_bridge_dir(session)
1932 + bridge_dir.mkdir(parents=True, exist_ok=True)
1933 + script = _editor_bridge_script_path(session)
1934 + queue = _url_bridge_queue_path(session)
1935 + lock = _url_bridge_lock_path(session)
1936 + script.write_text(
1937 + f"""#!/usr/bin/env python3
1938 +import fcntl
1939 +import json
1940 +import os
1941 +import sys
1942 +import time
1943 +from urllib.parse import quote
1944 +
1945 +QUEUE_PATH = {str(queue)!r}
1946 +LOCK_PATH = {str(lock)!r}
1947 +MAX_URL_LENGTH = {URL_INTENT_MAX_LENGTH}
1948 +
1949 +
1950 +def editor_url(path):
1951 + path = str(path or "").strip()
1952 + if not path:
1953 + return "a0-editor://open"
1954 + return "a0-editor://open?path=" + quote(path[:MAX_URL_LENGTH], safe="/:")
1955 +
1956 +
1957 +def main():
1958 + urls = [editor_url(arg) for arg in sys.argv[1:] if str(arg or "").strip()]
1959 + if not urls:
1960 + urls = [editor_url("")]
1961 + os.makedirs(os.path.dirname(QUEUE_PATH), exist_ok=True)
1962 + with open(LOCK_PATH, "a+", encoding="utf-8") as lock_file:
1963 + fcntl.flock(lock_file, fcntl.LOCK_EX)
1964 + with open(QUEUE_PATH, "a", encoding="utf-8") as queue_file:
1965 + for url in urls:
1966 + queue_file.write(json.dumps({{
1967 + "url": url,
1968 + "created_at": time.time(),
1969 + "source": "desktop-editor",
1970 + }}, ensure_ascii=True) + "\\n")
1971 + queue_file.flush()
1972 + os.fsync(queue_file.fileno())
1973 + fcntl.flock(lock_file, fcntl.LOCK_UN)
1974 +
1975 +
1976 +if __name__ == "__main__":
1977 + main()
1978 +""",
1979 + encoding="utf-8",
1980 + )
1981 + try:
1982 + script.chmod(0o755)
1983 + except OSError:
1984 + pass
1985 + return script
1986 +
1987 +
1988 def _write_shutdown_bridge_script(session: DesktopSession) -> Path:
1989 bridge_dir = _url_bridge_dir(session)
1990 bridge_dir.mkdir(parents=True, exist_ok=True)
@@ -2102,14 +2187,25 @@ def _url_handler_mime_types() -> tuple[str, ...]:
2187 )
2188
2189
2105 -def _write_mimeapps_defaults(path: Path, desktop_id: str) -> None:
2106 - associations = ";".join([desktop_id, ""])
2190 +def _editor_text_handler_mime_types() -> tuple[str, ...]:
2191 + return (
2192 + "text/markdown",
2193 + "text/x-markdown",
2194 + "text/plain",
2195 + )
2196 +
2197 +
2198 +def _write_mimeapps_defaults(path: Path, url_desktop_id: str, editor_desktop_id: str) -> None:
2199 + url_associations = ";".join([url_desktop_id, ""])
2200 + editor_associations = ";".join([editor_desktop_id, ""])
2201 lines = [
2202 "[Default Applications]",
2109 - *(f"{mime_type}={desktop_id}" for mime_type in _url_handler_mime_types()),
2203 + *(f"{mime_type}={url_desktop_id}" for mime_type in _url_handler_mime_types()),
2204 + *(f"{mime_type}={editor_desktop_id}" for mime_type in _editor_text_handler_mime_types()),
2205 "",
2206 "[Added Associations]",
2112 - *(f"{mime_type}={associations}" for mime_type in _url_handler_mime_types()),
2207 + *(f"{mime_type}={url_associations}" for mime_type in _url_handler_mime_types()),
2208 + *(f"{mime_type}={editor_associations}" for mime_type in _editor_text_handler_mime_types()),
2209 "",
2210 ]
2211 path.parent.mkdir(parents=True, exist_ok=True)
plugins/_desktop/webui/desktop-store.js
+6 -1
@@ -2239,7 +2239,12 @@ const model = {
2239 async openDesktopUrlIntent(intent = {}) {
2240 const url = String(intent?.url || "").trim();
2241 const handled = await handleUrlIntent({ url, source: "desktop-url" });
2242 - this.setMessage(handled ? "Opened link in Browser" : "Browser is not available");
2242 + const isEditorIntent = url.startsWith("a0-editor:");
2243 + this.setMessage(
2244 + handled
2245 + ? (isEditorIntent ? "Opened text in Editor" : "Opened link in Browser")
2246 + : (isEditorIntent ? "Editor is not available" : "Browser is not available"),
2247 + );
2248 },
2249
2250 browserDestinationForDesktopUrl() {
plugins/_editor/AGENTS.md
+4 -2
@@ -2,12 +2,12 @@
2
3 ## Purpose
4
5 -- Own the native Markdown editor surface for canvas and floating modal workflows.
5 +- Own the native Markdown and plain text editor surface for canvas and floating modal workflows.
6
7 ## Ownership
8
9 - `api/` owns editor session and WebSocket handlers.
10 -- `helpers/` owns markdown session and open-file context helpers.
10 +- `helpers/` owns editor text session and open-file context helpers.
11 - `prompts/` owns agent-visible open-file context.
12 - `webui/` owns editor panel, preview, store, and main surface.
13 - `extensions/` owns editor hook contributions.
@@ -17,6 +17,8 @@
17 - Keep editor session state synchronized across API, WebSocket, and WebUI panel behavior.
18 - Do not expose unsaved content or local paths beyond intended chat/context surfaces.
19 - Keep the floating Editor modal on the shared surface modal chrome so the header remains draggable while existing Focus mode continues to work.
20 +- Keep Editor Open wired through the File Browser text picker so users can open one or more Markdown or plain text files with an obvious confirmation action.
21 +- Keep Save As distinct from Rename: Save As writes the current editor text to a chosen `.md` or `.txt` path and retargets the active session without removing the original file.
22
23 ## Work Guidance
24
plugins/_editor/api/editor_session.py
+26 -4
@@ -38,13 +38,13 @@ class EditorSession(ApiHandler):
38 return closed
39 if action == "create":
40 fmt = str(input.get("format") or "md").lower().lstrip(".")
41 - if fmt != "md":
42 - return {"ok": False, "error": "Editor can only create Markdown documents."}
41 + if fmt not in document_store.EDITOR_TEXT_EXTENSIONS:
42 + return {"ok": False, "error": "Editor can only create Markdown (.md) and text (.txt) documents."}
43 try:
44 doc = document_store.create_document(
45 kind="document",
46 title=str(input.get("title") or "Untitled"),
47 - fmt="md",
47 + fmt=fmt,
48 content=str(input.get("content") or ""),
49 path=str(input.get("path") or ""),
50 context_id=context_id,
@@ -72,6 +72,28 @@ class EditorSession(ApiHandler):
72 if not session_id:
73 return {"ok": False, "error": "session_id is required."}
74 return markdown_sessions.get_manager().save(session_id, text=input.get("text"))
75 + if action == "save_as":
76 + session_id = str(input.get("session_id") or "").strip()
77 + path = str(input.get("path") or "").strip()
78 + if not session_id:
79 + return {"ok": False, "error": "session_id is required."}
80 + if not path:
81 + return {"ok": False, "error": "path is required."}
82 + try:
83 + result = markdown_sessions.get_manager().save_as(session_id, path, text=input.get("text"))
84 + except Exception as exc:
85 + return {"ok": False, "error": str(exc)}
86 + document_store.close_session(session_id=str(input.get("store_session_id") or "").strip())
87 + store_session = document_store.create_session(
88 + result["document"]["file_id"],
89 + user_id=str(input.get("user_id") or "agent-zero-user"),
90 + permission="write",
91 + origin=self._origin(request),
92 + )
93 + return {
94 + **result,
95 + "store_session_id": store_session["session_id"],
96 + }
97 if action == "renamed":
98 return self._renamed(input, context_id)
99 if action == "refresh":
@@ -85,7 +107,7 @@ class EditorSession(ApiHandler):
107 request: Request,
108 context_id: str = "",
109 ) -> dict:
88 - if str(doc.get("extension") or "").lower() != "md":
110 + if str(doc.get("extension") or "").lower() not in document_store.EDITOR_TEXT_EXTENSIONS:
111 return {
112 "ok": False,
113 "error": f".{doc.get('extension', '')} documents use the Desktop surface.",
plugins/_editor/api/ws_editor.py
+8 -1
@@ -53,10 +53,17 @@ class WsEditor(WsHandler):
53 elif path:
54 doc = document_store.register_document(path, context_id=context_id)
55 else:
56 + fmt = str(data.get("format") or "md").lower().strip().lstrip(".")
57 + if fmt not in document_store.EDITOR_TEXT_EXTENSIONS:
58 + return WsResult.error(
59 + code="UNSUPPORTED_EDITOR_DOCUMENT",
60 + message=f"Editor can only create Markdown (.md) and text (.txt) documents, not .{fmt}.",
61 + correlation_id=data.get("correlationId"),
62 + )
63 doc = document_store.create_document(
64 kind="document",
65 title=str(data.get("title") or "Untitled"),
59 - fmt="md",
66 + fmt=fmt,
67 content=str(data.get("content") or ""),
68 context_id=context_id,
69 )
plugins/_editor/helpers/markdown_sessions.py
+34 -5
@@ -31,7 +31,7 @@ class MarkdownSession:
31
32
33 class MarkdownSessionManager:
34 - """Owns native Editor sessions for Markdown documents."""
34 + """Owns native Editor sessions for Markdown and plain text documents."""
35
36 def __init__(self) -> None:
37 self._sessions: dict[str, MarkdownSession] = {}
@@ -39,8 +39,8 @@ class MarkdownSessionManager:
39
40 def open(self, doc: dict[str, Any], sid: str = "", context_id: str = "", refresh: bool = False) -> dict[str, Any]:
41 ext = str(doc["extension"]).lower()
42 - if ext != "md":
43 - raise ValueError(f"Editor is only available for Markdown. Open .{ext} files in the Desktop.")
42 + if ext not in document_store.EDITOR_TEXT_EXTENSIONS:
43 + raise ValueError(f"Editor is only available for Markdown and text files. Open .{ext} files in the Desktop.")
44
45 normalized_context = str(context_id or "")
46 if refresh:
@@ -64,6 +64,7 @@ class MarkdownSessionManager:
64 _mark_session_external(session, doc)
65 session.path = doc["path"]
66 session.title = doc["basename"]
67 + session.extension = ext
68 session.updated_at = time.time()
69 self.activate(session.session_id)
70 return self._payload(session, doc)
@@ -103,11 +104,12 @@ class MarkdownSessionManager:
104 if conflict is not None:
105 return conflict
106
106 - updated = document_store.write_markdown(session.file_id, session.text)
107 + updated = document_store.write_text_document(session.file_id, session.text)
108 session.updated_at = time.time()
109 session.dirty = False
110 session.path = updated["path"]
111 session.title = updated["basename"]
112 + session.extension = str(updated.get("extension") or session.extension).lower()
113 _set_session_base(session, updated)
114 self._refresh_file_sessions(
115 updated,
@@ -121,6 +123,32 @@ class MarkdownSessionManager:
123 "version": document_store.item_version(updated),
124 }
125
126 + def save_as(self, session_id: str, path: str, text: str | None = None) -> dict[str, Any]:
127 + session = self._require(session_id)
128 + if text is not None:
129 + session.text = str(text)
130 +
131 + updated = document_store.save_text_document_as(
132 + session.file_id,
133 + path,
134 + session.text,
135 + context_id=session.context_id,
136 + )
137 + old_file_id = session.file_id
138 + session.file_id = updated["file_id"]
139 + session.updated_at = time.time()
140 + session.dirty = False
141 + session.path = updated["path"]
142 + session.title = updated["basename"]
143 + session.extension = str(updated.get("extension") or session.extension).lower()
144 + _set_session_base(session, updated)
145 + return {
146 + "ok": True,
147 + "previous_file_id": old_file_id,
148 + "document": _public_doc(updated),
149 + "version": document_store.item_version(updated),
150 + }
151 +
152 def activate(self, session_id: str) -> dict[str, Any]:
153 session = self._require(session_id)
154 now = time.time()
@@ -145,7 +173,7 @@ class MarkdownSessionManager:
173 doc = document_store.get_document(normalized)
174 except Exception:
175 return {"ok": False, "refreshed": 0, "sessions": []}
148 - if str(doc.get("extension") or "").lower() != "md":
176 + if str(doc.get("extension") or "").lower() not in document_store.EDITOR_TEXT_EXTENSIONS:
177 return {"ok": True, "refreshed": 0, "sessions": []}
178
179 refreshed = self._refresh_file_sessions(
@@ -342,6 +370,7 @@ class MarkdownSessionManager:
370 session.text = str(text)
371 session.path = doc["path"]
372 session.title = doc["basename"]
373 + session.extension = str(doc.get("extension") or session.extension).lower()
374 if dirty is not None and (can_replace_text or refresh_dirty or not session.dirty):
375 session.dirty = dirty
376 if can_replace_text or not session.dirty:
plugins/_editor/helpers/open_files_context.py
+1 -1
@@ -11,7 +11,7 @@ def build_context(context_id: str = "", max_items: int = 20) -> str:
11 return ""
12
13 lines = [
14 - "These Markdown files are open in the Editor for the active Agent Zero context. Content is omitted; use `text_editor` with action `read` before content-sensitive edits.",
14 + "These Markdown or plain text files are open in the Editor for the active Agent Zero context. Content is omitted; use `text_editor` with action `read` before content-sensitive edits.",
15 ]
16 for item in files:
17 lines.append(format_open_file_line(item))
plugins/_editor/webui/editor-panel.html
+27 -10
@@ -9,7 +9,7 @@
9 <template x-if="$store.editor">
10 <div class="editor-panel" x-create="$store.editor.onMount($el, xAttrs($el) || {})" x-destroy="$store.editor.cleanup()">
11 <div class="editor-shell" @keydown="$store.editor.handleEditorKeydown($event)">
12 - <div class="editor-tabs" x-show="$store.editor.visibleTabs().length > 0" style="display: none;" role="tablist" aria-label="Open Markdown files">
12 + <div class="editor-tabs" x-show="$store.editor.visibleTabs().length > 0" style="display: none;" role="tablist" aria-label="Open text files">
13 <template x-for="tab in $store.editor.visibleTabs()" :key="tab.tab_id">
14 <div
15 class="editor-tab-shell"
@@ -82,6 +82,8 @@
82 <button
83 type="button"
84 class="editor-icon-button editor-mode-toggle"
85 + x-show="$store.editor.isMarkdown()"
86 + style="display: none;"
87 :title="$store.editor.viewModeTitle()"
88 :aria-label="$store.editor.viewModeTitle()"
89 @click="$store.editor.toggleViewMode()"
@@ -89,30 +91,30 @@
91 <span class="material-symbols-outlined" aria-hidden="true" x-text="$store.editor.viewModeIcon()"></span>
92 </button>
93
92 - <div class="editor-tool-group editor-source-tools" x-show="$store.editor.isMarkdown() && $store.editor.isSourceMode()" style="display: none;">
94 + <div class="editor-tool-group editor-source-tools" x-show="$store.editor.isTextDocument() && $store.editor.isSourceMode()" style="display: none;">
95 <button type="button" class="editor-icon-button" title="Undo" aria-label="Undo" :disabled="!$store.editor.canUndo()" @click="$store.editor.undo()">
96 <span class="material-symbols-outlined">undo</span>
97 </button>
98 <button type="button" class="editor-icon-button" title="Redo" aria-label="Redo" :disabled="!$store.editor.canRedo()" @click="$store.editor.redo()">
99 <span class="material-symbols-outlined">redo</span>
100 </button>
99 - <button type="button" class="editor-icon-button" title="Bold" aria-label="Bold" @click="$store.editor.format('bold')">
101 + <button type="button" class="editor-icon-button" title="Bold" aria-label="Bold" x-show="$store.editor.isMarkdown()" @click="$store.editor.format('bold')">
102 <span class="material-symbols-outlined">format_bold</span>
103 </button>
102 - <button type="button" class="editor-icon-button" title="Italic" aria-label="Italic" @click="$store.editor.format('italic')">
104 + <button type="button" class="editor-icon-button" title="Italic" aria-label="Italic" x-show="$store.editor.isMarkdown()" @click="$store.editor.format('italic')">
105 <span class="material-symbols-outlined">format_italic</span>
106 </button>
105 - <button type="button" class="editor-icon-button" title="List" aria-label="List" @click="$store.editor.format('list')">
107 + <button type="button" class="editor-icon-button" title="List" aria-label="List" x-show="$store.editor.isMarkdown()" @click="$store.editor.format('list')">
108 <span class="material-symbols-outlined">format_list_bulleted</span>
109 </button>
108 - <button type="button" class="editor-icon-button" title="Numbered list" aria-label="Numbered list" @click="$store.editor.format('numbered')">
110 + <button type="button" class="editor-icon-button" title="Numbered list" aria-label="Numbered list" x-show="$store.editor.isMarkdown()" @click="$store.editor.format('numbered')">
111 <span class="material-symbols-outlined">format_list_numbered</span>
112 </button>
111 - <button type="button" class="editor-icon-button" title="Table" aria-label="Table" @click="$store.editor.format('table')">
113 + <button type="button" class="editor-icon-button" title="Table" aria-label="Table" x-show="$store.editor.isMarkdown()" @click="$store.editor.format('table')">
114 <span class="material-symbols-outlined">table</span>
115 </button>
116 </div>
115 - <div class="editor-tool-group editor-preview-tools" x-show="$store.editor.isPreviewMode()" style="display: none;">
117 + <div class="editor-tool-group editor-preview-tools" x-show="$store.editor.isMarkdown() && $store.editor.isPreviewMode()" style="display: none;">
118 <button type="button" class="editor-icon-button" title="Previous page" aria-label="Previous page" :disabled="$store.editor.previewEditing || $store.editor.activePageIndex <= 0" @click="$store.editor.previousPage()">
119 <span class="material-symbols-outlined">chevron_left</span>
120 </button>
@@ -147,6 +149,17 @@
149 <span class="material-symbols-outlined" :class="{ spinning: $store.editor.saving }" x-text="$store.editor.saving ? 'progress_activity' : 'save'"></span>
150 </button>
151
152 + <button
153 + type="button"
154 + class="editor-icon-button editor-save-as-button"
155 + title="Save As"
156 + aria-label="Save As"
157 + :disabled="$store.editor.saving"
158 + @click="$store.editor.saveAs()"
159 + >
160 + <span class="material-symbols-outlined">save_as</span>
161 + </button>
162 +
163 <div class="editor-file-actions" x-data="{ open: false }" @click.outside="open = false" @keydown.escape.window="open = false">
164 <button
165 type="button"
@@ -213,7 +226,7 @@
226 <textarea
227 class="editor-source-editor"
228 data-editor-source
216 - aria-label="Markdown source"
229 + aria-label="Text source"
230 x-show="$store.editor.aceUnavailable"
231 x-model="$store.editor.editorText"
232 @input="$store.editor.onSourceInput()"
@@ -224,7 +237,7 @@
237 </div>
238 </div>
239
227 - <div class="editor-preview-shell" x-show="$store.editor.session && $store.editor.isPreviewMode()" style="display: none;">
240 + <div class="editor-preview-shell" x-show="$store.editor.session && $store.editor.isMarkdown() && $store.editor.isPreviewMode()" style="display: none;">
241 <div class="editor-preview-title">
242 <h1 x-text="$store.editor.pageTitle()"></h1>
243 </div>
@@ -261,6 +274,10 @@
274 <span class="material-symbols-outlined" aria-hidden="true">article</span>
275 <span class="editor-button-label">Markdown</span>
276 </button>
277 + <button type="button" class="editor-icon-button editor-command-button" @click="$store.editor.runNewMenuAction('text')">
278 + <span class="material-symbols-outlined" aria-hidden="true">description</span>
279 + <span class="editor-button-label">Text</span>
280 + </button>
281 </div>
282 </div>
283 </div>
plugins/_editor/webui/editor-store.js
+182 -23
@@ -3,7 +3,9 @@ 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 {
@@ -24,6 +26,7 @@ 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 {
@@ -51,6 +54,33 @@ function parentPath(path = "") {
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 }
@@ -77,7 +107,7 @@ function placeCaretAtEnd(element) {
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 {
@@ -90,7 +120,7 @@ function normalizeMarkdown(doc = {}) {
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,
@@ -301,7 +331,7 @@ const model = {
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();
@@ -319,6 +349,7 @@ const model = {
349 },
350
351 async toggleViewMode() {
352 + if (!this.isMarkdown()) return;
353 await this.setViewMode(this.isPreviewMode() ? SOURCE_MODE : PREVIEW_MODE);
354 },
355
@@ -331,6 +362,7 @@ const model = {
362 },
363
364 pages() {
365 + if (!this.isMarkdown()) return [];
366 return buildMarkdownPages(this.editorText, this.tabTitle(this.session || {}));
367 },
368
@@ -351,6 +383,7 @@ const model = {
383 },
384
385 previewHtml() {
386 + if (!this.isMarkdown()) return "";
387 return renderEditorPreviewMarkdown(this.currentPage().markdown || "", this.editorText);
388 },
389
@@ -476,7 +509,7 @@ const model = {
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;
@@ -669,6 +702,7 @@ const model = {
702 },
703
704 openSearch() {
705 + if (!this.isMarkdown()) return;
706 if (!this.isPreviewMode()) {
707 this.setViewMode(PREVIEW_MODE);
708 }
@@ -832,13 +866,15 @@ const model = {
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",
@@ -866,7 +902,17 @@ const model = {
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 = {}) {
@@ -883,11 +929,11 @@ const model = {
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;
@@ -931,6 +977,11 @@ const model = {
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;
@@ -941,6 +992,7 @@ const model = {
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 }
@@ -994,7 +1046,7 @@ const model = {
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.";
@@ -1069,7 +1121,7 @@ const model = {
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)) {
@@ -1140,7 +1192,7 @@ const model = {
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",
@@ -1159,9 +1211,20 @@ const model = {
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);
@@ -1199,7 +1262,7 @@ const model = {
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;
@@ -1213,7 +1276,7 @@ const model = {
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,
@@ -1221,6 +1284,7 @@ const model = {
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);
@@ -1234,8 +1298,73 @@ const model = {
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();
@@ -1254,7 +1383,7 @@ const model = {
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,
@@ -1262,6 +1391,7 @@ const model = {
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);
@@ -1310,7 +1440,7 @@ const model = {
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 }
@@ -1330,7 +1460,7 @@ const model = {
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,
@@ -1355,7 +1485,11 @@ const model = {
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 },
@@ -1447,7 +1581,7 @@ const model = {
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;
@@ -1456,7 +1590,7 @@ const model = {
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", {
@@ -1525,7 +1659,7 @@ const model = {
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) {
@@ -1549,8 +1683,13 @@ const model = {
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() {
@@ -1560,7 +1699,8 @@ const model = {
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 = {}) {
@@ -1578,6 +1718,7 @@ const model = {
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
@@ -1585,6 +1726,7 @@ const model = {
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
@@ -1608,6 +1750,10 @@ const model = {
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
@@ -1678,6 +1824,19 @@ const model = {
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));
plugins/_office/AGENTS.md
+1
@@ -16,6 +16,7 @@
16 - Preserve document storage integrity and live session synchronization.
17 - Keep LibreOffice operations bounded to intended workspaces and artifact paths.
18 - Do not expose document contents or temporary files beyond intended UI/tool flows.
19 +- Editor text Save As storage helpers must preserve exact `.md` or `.txt` text and create a new registered document without mutating or deleting the source document.
20
21 ## Work Guidance
22
plugins/_office/api/office_session.py
+3 -3
@@ -63,10 +63,10 @@ class OfficeSession(ApiHandler):
63
64 async def _open_document(self, doc: dict, input: dict, request: Request) -> dict:
65 mode = "edit" if str(input.get("mode") or "edit").lower() == "edit" else "view"
66 - if str(doc.get("extension") or "").lower() == "md":
66 + if str(doc.get("extension") or "").lower() in document_store.EDITOR_TEXT_EXTENSIONS:
67 return {
68 "ok": False,
69 - "error": "Markdown documents use the Editor surface.",
69 + "error": "Text documents use the Editor surface.",
70 "document": _public_doc(doc),
71 }
72 if str(doc.get("extension") or "").lower() in desktop_session.OFFICIAL_EXTENSIONS:
@@ -116,7 +116,7 @@ class OfficeSession(ApiHandler):
116 return {"ok": False, "error": f".{doc.get('extension', '')} documents are not supported by LibreOffice."}
117
118 def _save(self, input: dict) -> dict:
119 - return {"ok": False, "error": "Markdown saves use /plugins/_editor/editor_session."}
119 + return {"ok": False, "error": "Text document saves use /plugins/_editor/editor_session."}
120
121 def _renamed(self, input: dict, context_id: str = "") -> dict:
122 file_id = str(input.get("file_id") or "").strip()
plugins/_office/api/ws_office.py
+3 -3
@@ -22,7 +22,7 @@ class WsOffice(WsHandler):
22 if event in {"office_input", "office_save", "office_close"}:
23 return {
24 "ok": False,
25 - "error": "Office WebSocket editing is not available for Markdown; use the Editor surface.",
25 + "error": "Office WebSocket editing is not available for text documents; use the Editor surface.",
26 }
27 except FileNotFoundError as exc:
28 return WsResult.error(code="OFFICE_SESSION_NOT_FOUND", message=str(exc), correlation_id=data.get("correlationId"))
@@ -59,10 +59,10 @@ class WsOffice(WsHandler):
59 context_id=context_id,
60 )
61 ext = str(doc.get("extension") or "").lower()
62 - if ext == "md":
62 + if ext in document_store.EDITOR_TEXT_EXTENSIONS:
63 return WsResult.error(
64 code="UNSUPPORTED_OFFICE_DOCUMENT",
65 - message="Markdown documents use the Editor surface.",
65 + message="Text documents use the Editor surface.",
66 correlation_id=data.get("correlationId"),
67 )
68 if ext in desktop_session.OFFICIAL_EXTENSIONS:
plugins/_office/helpers/document_store.py
+85 -7
@@ -23,8 +23,8 @@ from plugins._office.helpers import pptx_writer
23 PLUGIN_NAME = "_office"
24 OPEN_DOCUMENT_EXTENSIONS = {"odt", "ods", "odp"}
25 OOXML_EXTENSIONS = {"docx", "xlsx", "pptx"}
26 -DESKTOP_TEXT_EXTENSIONS = {"txt"}
27 -SUPPORTED_EXTENSIONS = {"md", *OPEN_DOCUMENT_EXTENSIONS, *OOXML_EXTENSIONS, *DESKTOP_TEXT_EXTENSIONS}
26 +EDITOR_TEXT_EXTENSIONS = {"md", "txt"}
27 +SUPPORTED_EXTENSIONS = {*EDITOR_TEXT_EXTENSIONS, *OPEN_DOCUMENT_EXTENSIONS, *OOXML_EXTENSIONS}
28 DEFAULT_TTL_SECONDS = 8 * 60 * 60
29 MAX_SAVE_BYTES = 512 * 1024 * 1024
30 ODF_OFFICE_NS = "urn:oasis:names:tc:opendocument:xmlns:office:1.0"
@@ -336,8 +336,8 @@ def rename_document(
336 ext = normalize_extension(resolved.suffix.lstrip("."))
337 data = None
338 if content is not None:
339 - if ext != "md":
340 - raise ValueError("Inline content can only be provided for Markdown documents.")
339 + if ext not in EDITOR_TEXT_EXTENSIONS:
340 + raise ValueError("Inline content can only be provided for Editor text documents.")
341 data = str(content or "").encode("utf-8")
342 if len(data) > MAX_SAVE_BYTES:
343 raise OverflowError("Document save exceeds maximum size")
@@ -495,13 +495,91 @@ def close_session(session_id: str = "", file_id: str = "") -> int:
495 def read_text_for_editor(doc: dict[str, Any]) -> str:
496 path = Path(doc["path"])
497 ext = str(doc["extension"]).lower()
498 - if ext == "md":
498 + if ext in EDITOR_TEXT_EXTENSIONS:
499 return path.read_text(encoding="utf-8", errors="replace")
500 raise ValueError(f"Text editing is not available for .{ext}.")
501
502
503 +def write_text_document(file_id: str, content: str) -> dict[str, Any]:
504 + doc = get_document(file_id)
505 + ext = str(doc.get("extension") or "").lower()
506 + if ext not in EDITOR_TEXT_EXTENSIONS:
507 + raise ValueError(f"Editor text saves are not available for .{ext}.")
508 + return replace_document_bytes(file_id, str(content or "").encode("utf-8"), actor=f"editor:{ext}")
509 +
510 +
511 def write_markdown(file_id: str, content: str) -> dict[str, Any]:
504 - return replace_document_bytes(file_id, str(content or "").encode("utf-8"), actor="editor:markdown")
512 + return write_text_document(file_id, content)
513 +
514 +
515 +def save_text_document_as(
516 + file_id: str,
517 + path: str | Path,
518 + content: str,
519 + context_id: str = "",
520 +) -> dict[str, Any]:
521 + target = normalize_path(path, context_id=context_id)
522 + ext = normalize_extension(target.suffix.lstrip("."))
523 + if ext not in EDITOR_TEXT_EXTENSIONS:
524 + raise ValueError("Editor Save As only supports Markdown (.md) and text (.txt) files.")
525 + if target.exists():
526 + raise FileExistsError(f"Target already exists: {display_path(target)}")
527 +
528 + data = str(content or "").encode("utf-8")
529 + if len(data) > MAX_SAVE_BYTES:
530 + raise OverflowError("Document save exceeds maximum size")
531 +
532 + with connect() as conn:
533 + source = get_document(file_id, conn=conn)
534 + source_ext = str(source.get("extension") or "").lower()
535 + if source_ext not in EDITOR_TEXT_EXTENSIONS:
536 + raise ValueError(f"Editor Save As is not available for .{source_ext}.")
537 + changed_at = now()
538 + target.parent.mkdir(parents=True, exist_ok=True)
539 + _write_atomic(target, data)
540 + digest = sha256_bytes(data)
541 + stat = target.stat()
542 + new_file_id = uuid.uuid4().hex
543 + conn.execute(
544 + """
545 + INSERT INTO documents
546 + (file_id, path, basename, extension, owner_id, size, version, sha256, last_modified, created_at, updated_at)
547 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
548 + """,
549 + (
550 + new_file_id,
551 + str(target),
552 + target.name,
553 + ext,
554 + str(source.get("owner_id") or "a0"),
555 + stat.st_size,
556 + 1,
557 + digest,
558 + now_iso(),
559 + changed_at,
560 + changed_at,
561 + ),
562 + )
563 + _record_version(conn, new_file_id, target, "1", data)
564 + conn.execute(
565 + "INSERT INTO events (file_id, event_type, payload, created_at) VALUES (?, ?, ?, ?)",
566 + (
567 + file_id,
568 + "saved_as",
569 + json.dumps({"from": display_path(source["path"]), "to": display_path(target)}),
570 + changed_at,
571 + ),
572 + )
573 + conn.execute(
574 + "INSERT INTO events (file_id, event_type, payload, created_at) VALUES (?, ?, ?, ?)",
575 + (
576 + new_file_id,
577 + "created_from_save_as",
578 + json.dumps({"from": display_path(source["path"])}),
579 + changed_at,
580 + ),
581 + )
582 + return get_document(new_file_id, conn=conn)
583
584
585 def replace_document_bytes(
@@ -623,7 +701,7 @@ def create_document(
701
702 def _unique_document_path(title: str, ext: str, context_id: str = "") -> Path:
703 base = safe_document_stem(title, ext, "Document")
626 - root = document_home(context_id) if ext == "md" else document_binary_home(context_id)
704 + root = document_home(context_id) if ext in EDITOR_TEXT_EXTENSIONS else document_binary_home(context_id)
705 candidate = root / f"{base}.{ext}"
706 index = 2
707 while candidate.exists():
tests/test_file_browser_navigation.py
+40 -1
@@ -81,7 +81,7 @@ def test_file_browser_compact_controls_and_narrow_layout_contract() -> None:
81
82 assert "container: file-browser / inline-size;" in html
83 assert "@container file-browser (max-width: 620px)" in html
84 - assert "grid-template-columns: 2.25rem minmax(0, 1fr) minmax(4.25rem, max-content) 5.25rem;" in html
84 + assert "grid-template-columns: 2.25rem minmax(0, 1fr) minmax(4.25rem, max-content) 8rem;" in html
85 assert ".file-cell-date,\n .file-date {\n display: none;" in html
86 assert ".file-cell-size,\n .file-size" not in html
87
@@ -89,6 +89,45 @@ def test_file_browser_compact_controls_and_narrow_layout_contract() -> None:
89 assert "New file and New folder controls icon-only" in dox
90
91
92 +def test_file_browser_editor_picker_modes_have_primary_footer_actions() -> None:
93 + html = read("webui", "components", "modals", "file-browser", "file-browser.html")
94 + store = read("webui", "components", "modals", "file-browser", "file-browser-store.js")
95 + dox = read("webui", "components", "modals", "file-browser", "AGENTS.md")
96 +
97 + assert "PICKER_MODE_TEXT_OPEN" in store
98 + assert "PICKER_MODE_SAVE_AS" in store
99 + assert "openTextPicker" in store
100 + assert "openSaveAsPicker" in store
101 + assert 'new Set(["md", "txt"])' in store
102 + assert "pickerSelectedFiles()" in store
103 + assert "validatePickerFilename" in store
104 + assert "handleFileNameClick(file = {})" in store
105 + assert "fileSurfaceTarget(file) === \"editor\"" in store
106 + assert "isEditorSurface(file = {})" in store
107 + assert "canOpenInActionMenu(file = {})" in store
108 +
109 + assert "file-browser-picker-actions" in html
110 + assert "file-editor-open-action" in html
111 + assert 'aria-label="Open in Editor"' in html
112 + assert "picker-filename-input" in html
113 + assert "Open Selected" in store
114 + assert "Save Here" in store
115 + assert "$store.fileBrowser.confirmPicker()" in html
116 + assert "$store.fileBrowser.pickerSelectionLabel()" in html
117 + assert "$store.fileBrowser.isPickerMode()" in html
118 + assert "$store.fileBrowser.isTextOpenPicker()" in html
119 + assert "picker-confirm-button" in html
120 +
121 + assert "picker modes for Editor Open and Save As" in dox
122 + assert "Markdown or plain text files" in dox
123 + assert "Open in Editor action visible outside the overflow menu" in dox
124 +
125 + editor_button_index = html.index("file-editor-open-action")
126 + dropdown_menu_index = html.index('class="dropdown-menu"')
127 + assert editor_button_index < dropdown_menu_index
128 + assert 'x-show="$store.fileBrowser.canOpenInActionMenu(file)"' in html
129 +
130 +
131 def test_file_browser_empty_api_path_uses_default_workdir_contract() -> None:
132 api_source = read("api", "get_work_dir_files.py")
133 api_dox = read("api", "get_work_dir_files.py.dox.md")
tests/test_office_canvas_setup.py
+35 -5
@@ -285,8 +285,8 @@ def test_desktop_plugin_owns_routes_runtime_surface_and_state_paths():
285
286 assert "virtual_desktop_routes.install_route_hooks()" in desktop_startup
287 assert 'action in {"open_document", "document"}' in desktop_api
288 - assert 'if ext == "md":' in desktop_api
289 - assert "Markdown documents use the Editor surface." in desktop_api
288 + assert "document_store.EDITOR_TEXT_EXTENSIONS" in desktop_api
289 + assert "Text documents use the Editor surface." in desktop_api
290 assert "return self._open_markdown(doc, input, request)" not in desktop_api
291 assert "markdown_sessions" not in desktop_api
292 assert '"status": desktop.get("status") or {}' in desktop_api
@@ -466,7 +466,7 @@ def test_office_artifacts_only_open_desktop_from_explicit_document_ui_requests()
466 assert 'action == "desktop"' not in office_api
467 assert 'action == "desktop_state"' not in office_api
468 assert 'action == "desktop_shutdown"' not in office_api
469 - assert "Markdown documents use the Editor surface." in office_api
469 + assert "Text documents use the Editor surface." in office_api
470 assert '"requires_editor": True' not in office_api
471
472
@@ -529,7 +529,7 @@ def test_editor_plugin_owns_markdown_sessions_and_active_context_extras():
529 assert "editor_open_files" in editor_extras
530 assert "desktop_state" in desktop_context
531 assert 'pop("office_canvas"' in office_context
532 - assert "Office WebSocket editing is not available for Markdown; use the Editor surface." in office_ws
532 + assert "Office WebSocket editing is not available for text documents; use the Editor surface." in office_ws
533 assert "from plugins._office.helpers import document_store, markdown_sessions" not in office_ws
534 assert not (PROJECT_ROOT / "plugins" / "_office" / "helpers" / "markdown_sessions.py").exists()
535 assert "syncTextEditorResultsIntoOpenEditor" in editor_result_sync
@@ -547,10 +547,13 @@ def test_editor_open_file_browser_prefers_context_home_before_workdir_fallback()
547 assert home_lookup < settings_fallback
548 assert "workdirPath = home.path;" in open_file_browser
549 assert "workdirPath = response?.settings?.workdir_path || workdirPath;" in open_file_browser
550 + assert "fileBrowserStore.openTextPicker" in open_file_browser
551 + assert "selectedFiles" in open_file_browser
552
553
554 def test_editor_toolbar_places_preview_toggle_left_and_save_on_right():
555 editor_panel = read("plugins", "_editor", "webui", "editor-panel.html")
556 + editor_store = read("plugins", "_editor", "webui", "editor-store.js")
557 toolbar_start = editor_panel.index('<div class="editor-toolbar"')
558 toolbar_end = editor_panel.index('<div class="editor-search-bar"', toolbar_start)
559 toolbar = editor_panel[toolbar_start:toolbar_end]
@@ -560,13 +563,20 @@ def test_editor_toolbar_places_preview_toggle_left_and_save_on_right():
563 preview_tools = toolbar.index("editor-preview-tools")
564 spacer = toolbar.index("editor-toolbar-spacer")
565 save_button = toolbar.index("editor-save-button")
566 + save_as_button = toolbar.index("editor-save-as-button")
567 file_actions = toolbar.index("editor-file-actions")
568 file_menu = toolbar.index("editor-file-menu")
569
570 assert mode_toggle < source_tools
571 assert mode_toggle < preview_tools
568 - assert spacer < save_button < file_actions < file_menu
572 + assert spacer < save_button < save_as_button < file_actions < file_menu
573 assert "@click=\"$store.editor.save()\"" in toolbar
574 + assert "@click=\"$store.editor.saveAs()\"" in toolbar
575 + assert "async saveAs()" in editor_store
576 + assert "fileBrowserStore.openSaveAsPicker" in editor_store
577 + assert 'callEditor("save_as"' in editor_store
578 + assert "isTextDocument()" in toolbar
579 + assert 'data-editor-new-action="text"' in editor_store
580
581 file_menu_markup = toolbar[file_menu:]
582 assert "<span>Save</span>" not in file_menu_markup
@@ -574,6 +584,26 @@ def test_editor_toolbar_places_preview_toggle_left_and_save_on_right():
584 assert "<span>Close File</span>" in file_menu_markup
585
586
587 +def test_desktop_text_open_with_routes_to_editor_surface():
588 + desktop_session = read("plugins", "_desktop", "helpers", "desktop_session.py")
589 + desktop_store = read("plugins", "_desktop", "webui", "desktop-store.js")
590 + editor_store = read("plugins", "_editor", "webui", "editor-store.js")
591 +
592 + assert 'EDITOR_HANDLER_DESKTOP_ID = "agent-zero-editor.desktop"' in desktop_session
593 + assert "def _write_editor_bridge_script" in desktop_session
594 + assert "a0-editor://open?path=" in desktop_session
595 + assert "_editor_text_handler_mime_types()" in desktop_session
596 + assert '"text/markdown"' in desktop_session
597 + assert '"text/x-markdown"' in desktop_session
598 + assert '"text/plain"' in desktop_session
599 + assert "applications_dir / EDITOR_HANDLER_DESKTOP_ID" in desktop_session
600 + assert 'desktop_dir / "Editor.desktop"' in desktop_session
601 + assert "Opened text in Editor" in desktop_store
602 + assert "registerUrlHandler" in editor_store
603 + assert "handleEditorUrlIntent" in editor_store
604 + assert 'openLatestSurface("editor"' in editor_store
605 +
606 +
607 def test_office_and_desktop_skills_are_rehomed_and_renamed():
608 office_skills = PROJECT_ROOT / "plugins" / "_office" / "skills"
609 desktop_skills = PROJECT_ROOT / "plugins" / "_desktop" / "skills"
tests/test_office_document_store.py
+44 -4
@@ -84,15 +84,15 @@ def test_document_store_create_defaults_to_markdown(office_state):
84 assert Path(doc["path"]).read_text(encoding="utf-8").startswith("# Research Note")
85
86
87 -def test_text_files_register_as_desktop_documents(office_state):
87 +def test_text_files_register_as_editor_documents(office_state):
88 path = office_state.workdir / "plain-note.txt"
89 - path.write_text("Plain text belongs on the Desktop surface.\n", encoding="utf-8")
89 + path.write_text("Plain text belongs in the Editor surface.\n", encoding="utf-8")
90
91 doc = document_store.register_document(path)
92
93 assert doc["extension"] == "txt"
94 - assert "txt" in document_store.DESKTOP_TEXT_EXTENSIONS
95 - assert "txt" in desktop_session.OFFICIAL_EXTENSIONS
94 + assert "txt" in document_store.EDITOR_TEXT_EXTENSIONS
95 + assert "txt" not in desktop_session.OFFICIAL_EXTENSIONS
96
97
98 def test_file_browser_can_register_runtime_root_markdown(office_state, monkeypatch):
@@ -521,6 +521,46 @@ def test_document_rename_saves_dirty_markdown_and_removes_original(office_state)
521 assert renamed.read_text(encoding="utf-8") == "# Clean Rename\n\nFresh text"
522
523
524 +def test_text_session_save_as_creates_new_file_without_mutating_original(office_state):
525 + manager = editor_markdown_sessions.MarkdownSessionManager()
526 + doc = document_store.create_document("document", "Original Note", "md", "# Original Note\n")
527 + original = Path(doc["path"])
528 + session = manager.open(doc, context_id="ctx-a")
529 + target = office_state.workdir / "notes" / "Saved Copy.txt"
530 +
531 + result = manager.save_as(
532 + session["session_id"],
533 + str(target),
534 + text="Saved Copy\n\nExact body\n",
535 + )
536 +
537 + saved_session = manager._sessions[session["session_id"]]
538 + assert result["ok"] is True
539 + assert result["document"]["file_id"] != doc["file_id"]
540 + assert result["previous_file_id"] == doc["file_id"]
541 + assert saved_session.file_id == result["document"]["file_id"]
542 + assert saved_session.path == str(target)
543 + assert saved_session.extension == "txt"
544 + assert saved_session.dirty is False
545 + assert original.read_text(encoding="utf-8") == "# Original Note"
546 + assert target.read_text(encoding="utf-8") == "Saved Copy\n\nExact body\n"
547 +
548 +
549 +def test_editor_session_opens_and_saves_txt_documents(office_state):
550 + manager = editor_markdown_sessions.MarkdownSessionManager()
551 + doc = document_store.create_document("document", "Plain Note", "txt", "First line")
552 + session = manager.open(doc, context_id="ctx-a")
553 +
554 + assert session["extension"] == "txt"
555 + assert session["text"] == "First line"
556 +
557 + result = manager.save(session["session_id"], text="Second line\n")
558 +
559 + assert result["ok"] is True
560 + assert result["document"]["extension"] == "txt"
561 + assert Path(result["document"]["path"]).read_text(encoding="utf-8") == "Second line\n"
562 +
563 +
564 def test_refresh_open_markdown_session_reloads_external_file_edits(office_state):
565 manager = editor_markdown_sessions.MarkdownSessionManager()
566 doc = document_store.create_document("document", "External Refresh", "md", "First")
webui/components/modals/file-browser/AGENTS.md
+2
@@ -17,6 +17,8 @@
17 - The floating file-browser modal must use the shared surface modal chrome so it remains draggable/resizable and exposes Focus mode.
18 - Preserve remembered-directory behavior: explicit paths win, then remembered path, then `$WORK_DIR`.
19 - Empty mounted startup states must self-heal to the `$WORK_DIR` default instead of rendering a blank path and empty list.
20 +- Preserve picker modes for Editor Open and Save As: Editor Open selects one or more Markdown or plain text files with a pinned primary action, and Save As selects the current folder plus a `.md` or `.txt` file name.
21 +- Keep the row-level Open in Editor action visible outside the overflow menu for Editor-owned `.md` and `.txt` files.
22 - Keep the file list readable in narrow canvas/modal containers by hiding the Modified date column before sacrificing the Name or Size columns.
23 - Keep New file and New folder controls icon-only across canvas and modal modes while preserving accessible labels.
24 - Keep narrow mobile controls compact: Up shares the path row, and New file/New folder share the search row.
webui/components/modals/file-browser/file-browser-store.js
+213 -15
@@ -10,8 +10,11 @@ import {
10 const FILE_BROWSER_MODAL_PATH = "modals/file-browser/file-browser.html";
11 const FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY = "fileBrowser.lastDirectory";
12 const DEFAULT_REMEMBER_LAST_DIRECTORY = true;
13 -const MARKDOWN_EXTENSIONS = new Set(["md", "markdown", "mdown"]);
14 -const DESKTOP_EXTENSIONS = new Set(["odt", "ods", "odp", "docx", "xlsx", "pptx", "txt"]);
13 +const PICKER_MODE_NONE = "";
14 +const PICKER_MODE_TEXT_OPEN = "text-open";
15 +const PICKER_MODE_SAVE_AS = "save-as";
16 +const EDITOR_TEXT_EXTENSIONS = new Set(["md", "txt"]);
17 +const DESKTOP_EXTENSIONS = new Set(["odt", "ods", "odp", "docx", "xlsx", "pptx"]);
18 const BROWSER_EXTENSIONS = new Set([
19 "html",
20 "htm",
@@ -32,7 +35,7 @@ const SURFACE_ACTIONS = {
35 editor: {
36 label: "Open in Editor",
37 icon: "article",
35 - title: "Open Markdown in Editor",
38 + title: "Open text in Editor",
39 },
40 desktop: {
41 label: "Open in Desktop",
@@ -87,6 +90,12 @@ const model = {
90 openDropdownPath: null, // Track which dropdown is currently open
91 searchQuery: "",
92 isBulkBusy: false,
93 + pickerMode: PICKER_MODE_NONE,
94 + pickerConfirmLabel: "",
95 + pickerFilename: "",
96 + pickerDefaultExtension: "md",
97 + pickerFilenameError: "",
98 + pickerOnConfirm: null,
99
100 // --- Lifecycle -----------------------------------------------------------
101 init() {
@@ -118,9 +127,9 @@ const model = {
127 },
128
129 // --- Public API (called from button/link) --------------------------------
121 - async open(path = "") {
130 + async open(path = "", options = {}) {
131 if (this.isLoading) return; // Prevent double-open
123 - this.resetOpenState();
132 + this.resetOpenState(options);
133
134 try {
135 // Open modal FIRST (immediate UI feedback)
@@ -164,6 +173,24 @@ const model = {
173 window.closeModal(FILE_BROWSER_MODAL_PATH);
174 },
175
176 + async openTextPicker(path = "", onConfirm = null) {
177 + return await this.open(path, {
178 + pickerMode: PICKER_MODE_TEXT_OPEN,
179 + confirmLabel: "Open Selected",
180 + onConfirm,
181 + });
182 + },
183 +
184 + async openSaveAsPicker(path = "", options = {}) {
185 + return await this.open(path, {
186 + pickerMode: PICKER_MODE_SAVE_AS,
187 + confirmLabel: "Save Here",
188 + filename: options.filename || "Untitled.md",
189 + defaultExtension: options.defaultExtension || "",
190 + onConfirm: options.onConfirm,
191 + });
192 + },
193 +
194 destroy() {
195 this._floatingCleanup?.();
196 this._floatingCleanup = null;
@@ -184,6 +211,7 @@ const model = {
211 this.pathInput = "";
212 this.pathError = "";
213 this.isPathSubmitting = false;
214 + this.resetPickerState();
215 this.resetRenameState();
216 },
217
@@ -223,7 +251,7 @@ const model = {
251 },
252
253 // --- Helpers -------------------------------------------------------------
226 - resetOpenState() {
254 + resetOpenState(options = {}) {
255 this.cancelMountedDefaultLoad();
256 this.isLoading = true;
257 this.error = null;
@@ -232,6 +260,32 @@ const model = {
260 this.isBulkBusy = false;
261 this.pathError = "";
262 this.isPathSubmitting = false;
263 + this.configurePicker(options);
264 + },
265 +
266 + configurePicker(options = {}) {
267 + const mode = String(options?.pickerMode || PICKER_MODE_NONE).trim();
268 + this.pickerMode = [PICKER_MODE_TEXT_OPEN, PICKER_MODE_SAVE_AS].includes(mode)
269 + ? mode
270 + : PICKER_MODE_NONE;
271 + this.pickerConfirmLabel = String(options?.confirmLabel || "").trim()
272 + || (this.pickerMode === PICKER_MODE_SAVE_AS ? "Save Here" : "Open Selected");
273 + this.pickerFilename = String(options?.filename || "").trim();
274 + this.pickerDefaultExtension = this.normalizedEditorTextExtension(
275 + options?.defaultExtension || this.fileExtension({ name: this.pickerFilename }) || "md",
276 + );
277 + this.pickerFilenameError = "";
278 + this.pickerOnConfirm = typeof options?.onConfirm === "function" ? options.onConfirm : null;
279 + if (this.pickerMode) this.clearSelection();
280 + },
281 +
282 + resetPickerState() {
283 + this.pickerMode = PICKER_MODE_NONE;
284 + this.pickerConfirmLabel = "";
285 + this.pickerFilename = "";
286 + this.pickerDefaultExtension = "md";
287 + this.pickerFilenameError = "";
288 + this.pickerOnConfirm = null;
289 },
290
291 async loadOpeningPath(path = "") {
@@ -328,9 +382,9 @@ const model = {
382
383 get filteredEntries() {
384 const query = this.searchQuery.trim().toLowerCase();
331 - if (!query) return this.browser.entries;
332 -
385 return this.browser.entries.filter((file) => {
386 + if (!this.pickerAllowsEntry(file)) return false;
387 + if (!query) return true;
388 const searchable = [
389 file.name,
390 file.path,
@@ -354,7 +408,11 @@ const model = {
408 },
409
410 get selectedFiles() {
357 - return this.browser.entries.filter((file) => file.selected);
411 + return this.browser.entries.filter((file) => file.selected && this.isSelectableEntry(file));
412 + },
413 +
414 + get selectableEntries() {
415 + return this.filteredEntries.filter((file) => this.isSelectableEntry(file));
416 },
417
418 get selectedCount() {
@@ -367,18 +425,18 @@ const model = {
425
426 get allVisibleSelected() {
427 return (
370 - this.filteredEntries.length > 0 &&
371 - this.filteredEntries.every((file) => file.selected)
428 + this.selectableEntries.length > 0 &&
429 + this.selectableEntries.every((file) => file.selected)
430 );
431 },
432
433 get someVisibleSelected() {
376 - return this.filteredEntries.some((file) => file.selected);
434 + return this.selectableEntries.some((file) => file.selected);
435 },
436
437 toggleSelectAllVisible() {
438 const shouldSelect = !this.allVisibleSelected;
381 - this.filteredEntries.forEach((file) => {
439 + this.selectableEntries.forEach((file) => {
440 file.selected = shouldSelect;
441 });
442 },
@@ -389,6 +447,24 @@ const model = {
447 });
448 },
449
450 + isPickerMode() {
451 + return this.pickerMode !== PICKER_MODE_NONE;
452 + },
453 +
454 + isTextOpenPicker() {
455 + return this.pickerMode === PICKER_MODE_TEXT_OPEN;
456 + },
457 +
458 + isSaveAsPicker() {
459 + return this.pickerMode === PICKER_MODE_SAVE_AS;
460 + },
461 +
462 + isSelectableEntry(file = {}) {
463 + if (this.isSaveAsPicker()) return false;
464 + if (this.isTextOpenPicker()) return !file?.is_dir && this.fileSurfaceTarget(file) === "editor";
465 + return true;
466 + },
467 +
468 normalizeOpeningPath(path) {
469 return String(path || "").trim();
470 },
@@ -483,16 +559,138 @@ const model = {
559 fileSurfaceTarget(file = {}) {
560 if (!file || file.is_dir) return "";
561 const ext = this.fileExtension(file);
486 - if (MARKDOWN_EXTENSIONS.has(ext)) return "editor";
562 + if (EDITOR_TEXT_EXTENSIONS.has(ext)) return "editor";
563 if (BROWSER_EXTENSIONS.has(ext)) return "browser";
564 if (DESKTOP_EXTENSIONS.has(ext)) return "desktop";
565 return "";
566 },
567
568 + pickerAllowsEntry(file = {}) {
569 + if (!this.isTextOpenPicker()) return true;
570 + return Boolean(file?.is_dir || this.fileSurfaceTarget(file) === "editor");
571 + },
572 +
573 + pickerSelectedFiles() {
574 + if (!this.isTextOpenPicker()) return [];
575 + return this.selectedFiles.filter((file) => !file.is_dir && this.fileSurfaceTarget(file) === "editor");
576 + },
577 +
578 + pickerSelectionLabel() {
579 + if (!this.isTextOpenPicker()) return "";
580 + const count = this.pickerSelectedFiles().length;
581 + if (!count) return "No text files selected";
582 + return `${count} text ${count === 1 ? "file" : "files"} selected`;
583 + },
584 +
585 + normalizedEditorTextExtension(value = "") {
586 + const ext = String(value || "").toLowerCase().trim().replace(/^\./, "");
587 + return EDITOR_TEXT_EXTENSIONS.has(ext) ? ext : "md";
588 + },
589 +
590 + pickerFilenameValue() {
591 + const raw = String(this.pickerFilename || "").trim();
592 + if (!raw) return "";
593 + const ext = this.fileExtension({ name: raw });
594 + return ext ? raw : `${raw}.${this.pickerDefaultExtension || "md"}`;
595 + },
596 +
597 + validatePickerFilename(updateError = true) {
598 + if (!this.isSaveAsPicker()) return true;
599 + const raw = String(this.pickerFilename || "").trim();
600 + const filename = this.pickerFilenameValue();
601 + let error = "";
602 + if (!raw) {
603 + error = "File name is required.";
604 + } else if (raw === "." || raw === "..") {
605 + error = "File name cannot be '.' or '..'.";
606 + } else if (raw.includes("/") || raw.includes("\\")) {
607 + error = "File name cannot include path separators.";
608 + } else if (!EDITOR_TEXT_EXTENSIONS.has(this.fileExtension({ name: filename }))) {
609 + error = "Use a .md or .txt file name.";
610 + } else if ((this.browser.entries || []).some((entry) => entry?.name === filename)) {
611 + error = `An item named "${filename}" already exists.`;
612 + }
613 + if (updateError) this.pickerFilenameError = error;
614 + return !error;
615 + },
616 +
617 + onPickerFilenameInput() {
618 + if (this.pickerFilenameError) this.validatePickerFilename(true);
619 + },
620 +
621 + canConfirmPicker() {
622 + if (this.isTextOpenPicker()) return this.pickerSelectedFiles().length > 0;
623 + if (this.isSaveAsPicker()) return Boolean(this.pickerFilenameValue()) && !this.pickerFilenameError;
624 + return false;
625 + },
626 +
627 + pickerTargetPath() {
628 + if (!this.isSaveAsPicker()) return "";
629 + return this.buildChildPath(this.pickerFilenameValue());
630 + },
631 +
632 + togglePickerFile(file = {}) {
633 + if (!this.isTextOpenPicker() || file?.is_dir || this.fileSurfaceTarget(file) !== "editor") return;
634 + file.selected = !file.selected;
635 + },
636 +
637 + async confirmPicker() {
638 + if (!this.isPickerMode() || this.isBulkBusy) return;
639 + if (this.isSaveAsPicker() && !this.validatePickerFilename(true)) return;
640 + const payload = this.isSaveAsPicker()
641 + ? {
642 + mode: this.pickerMode,
643 + directory: this.browser.currentPath,
644 + filename: this.pickerFilenameValue(),
645 + path: this.pickerTargetPath(),
646 + }
647 + : {
648 + mode: this.pickerMode,
649 + directory: this.browser.currentPath,
650 + selectedFiles: this.pickerSelectedFiles(),
651 + };
652 + try {
653 + this.isBulkBusy = true;
654 + const result = await this.pickerOnConfirm?.(payload);
655 + if (result === false) return;
656 + this.disposeScopedTooltips();
657 + window.closeModal(FILE_BROWSER_MODAL_PATH);
658 + } catch (error) {
659 + const message = error?.message || "File selection failed";
660 + if (this.isSaveAsPicker()) this.pickerFilenameError = message;
661 + window.toastFrontendError?.(message, "File Browser");
662 + } finally {
663 + this.isBulkBusy = false;
664 + }
665 + },
666 +
667 + cancelPicker() {
668 + this.disposeScopedTooltips();
669 + window.closeModal(FILE_BROWSER_MODAL_PATH);
670 + },
671 +
672 + handleFileNameClick(file = {}) {
673 + if (file?.is_dir) {
674 + return this.navigateToFolder(file.path);
675 + }
676 + if (this.isTextOpenPicker()) {
677 + this.togglePickerFile(file);
678 + }
679 + },
680 +
681 canOpenInSurface(file = {}) {
682 return Boolean(this.fileSurfaceTarget(file));
683 },
684
685 + isEditorSurface(file = {}) {
686 + return this.fileSurfaceTarget(file) === "editor";
687 + },
688 +
689 + canOpenInActionMenu(file = {}) {
690 + const target = this.fileSurfaceTarget(file);
691 + return Boolean(target && target !== "editor");
692 + },
693 +
694 surfaceAction(file = {}) {
695 const target = this.fileSurfaceTarget(file);
696 return target ? SURFACE_ACTIONS[target] : null;
@@ -1117,7 +1315,7 @@ const model = {
1315 if (!this.storeHasPath(editorStore, path)) {
1316 const session = await editorStore.openPath(path, { source: "file-browser" });
1317 if (!session || session.ok === false) {
1120 - throw new Error(editorStore.error || "Markdown could not be opened.");
1318 + throw new Error(editorStore.error || "Text document could not be opened.");
1319 }
1320 }
1321 }
webui/components/modals/file-browser/file-browser.html
+147 -14
@@ -103,6 +103,7 @@
103 <button
104 type="button"
105 class="btn btn-ok btn-new-item"
106 + x-show="!$store.fileBrowser.isPickerMode()"
107 @click="$store.fileBrowser.openNewFile()"
108 aria-label="New file"
109 title="New file"
@@ -112,6 +113,7 @@
113 <button
114 type="button"
115 class="btn btn-ok btn-new-item"
116 + x-show="!$store.fileBrowser.isTextOpenPicker()"
117 @click="$store.fileBrowser.openNewFolderModal()"
118 aria-label="New folder"
119 title="New folder"
@@ -142,7 +144,7 @@
144 </div>
145 </div>
146
145 - <div x-show="$store.fileBrowser.selectedCount > 0" class="mass-action-toolbar file-mass-toolbar">
147 + <div x-show="$store.fileBrowser.selectedCount > 0 && !$store.fileBrowser.isPickerMode()" class="mass-action-toolbar file-mass-toolbar">
148 <div class="selection-info" x-text="$store.fileBrowser.selectedCountLabel"></div>
149
150 <div class="mass-actions">
@@ -201,6 +203,7 @@
203 :checked="$store.fileBrowser.allVisibleSelected"
204 :indeterminate="$store.fileBrowser.someVisibleSelected && !$store.fileBrowser.allVisibleSelected"
205 @change="$store.fileBrowser.toggleSelectAllVisible()"
206 + :disabled="$store.fileBrowser.selectableEntries.length === 0"
207 title="Select visible items"
208 aria-label="Select visible items"
209 />
@@ -219,17 +222,29 @@
222 <input
223 type="checkbox"
224 x-model="file.selected"
225 + :disabled="!$store.fileBrowser.isSelectableEntry(file)"
226 :aria-label="`Select ${file.name}`"
227 />
228 </label>
225 - <div class="file-name" @click="file.is_dir && $store.fileBrowser.navigateToFolder(file.path)">
229 + <div class="file-name" @click="$store.fileBrowser.handleFileNameClick(file)">
230 <img :src="'/public/' + (file.type === 'unknown' ? 'file' : ($store.fileBrowser.isArchive(file.name) ? 'archive' : file.type)) + '.svg'" class="file-icon" :alt="file.type" />
231 <span x-text="file.name"></span>
232 </div>
233 <div class="file-size" x-text="$store.fileBrowser.formatFileSize(file.size)"></div>
234 <div class="file-date" x-text="$store.fileBrowser.formatDate(file.modified)"></div>
231 - <div class="file-actions">
232 - <!-- Single-item actions (Edit/Rename/...) are grouped under a dropdown -->
235 + <div class="file-actions" x-show="!$store.fileBrowser.isPickerMode()">
236 + <button
237 + type="button"
238 + class="btn-icon-action file-editor-open-action"
239 + x-show="$store.fileBrowser.isEditorSurface(file)"
240 + @click.stop="$store.fileBrowser.openInSurface(file)"
241 + :title="$store.fileBrowser.surfaceActionTitle(file)"
242 + aria-label="Open in Editor"
243 + >
244 + <span class="material-symbols-outlined" aria-hidden="true" x-text="$store.fileBrowser.surfaceActionIcon(file)"></span>
245 + </button>
246 +
247 + <!-- Secondary single-item actions (Rename/...) are grouped under a dropdown -->
248 <div
249 class="dropdown file-actions-dropdown"
250 @click.outside="$store.fileBrowser.closeDropdown()"
@@ -257,7 +272,7 @@
272 <button
273 type="button"
274 class="dropdown-item"
260 - x-show="$store.fileBrowser.canOpenInSurface(file)"
275 + x-show="$store.fileBrowser.canOpenInActionMenu(file)"
276 @click="$store.fileBrowser.openInSurface(file)"
277 :title="$store.fileBrowser.surfaceActionTitle(file)"
278 >
@@ -329,12 +344,45 @@
344 <!-- Modal Footer (outside template x-if so it exists immediately) -->
345 <template x-if="$store.fileBrowser">
346 <div class="modal-footer file-browser-footer" data-modal-footer :class="{ 'is-surface': xAttrs($el)?.mode === 'canvas' }">
332 - <label class="btn btn-upload">
347 + <div class="file-browser-picker-actions" x-show="$store.fileBrowser.isPickerMode()" style="display: none;">
348 + <template x-if="$store.fileBrowser.isSaveAsPicker()">
349 + <div class="picker-filename-field">
350 + <span class="material-symbols-outlined" aria-hidden="true">article</span>
351 + <input
352 + type="text"
353 + class="picker-filename-input"
354 + x-model="$store.fileBrowser.pickerFilename"
355 + @input="$store.fileBrowser.onPickerFilenameInput()"
356 + @keydown.enter.prevent="$store.fileBrowser.confirmPicker()"
357 + placeholder="File name"
358 + aria-label="File name"
359 + spellcheck="false"
360 + />
361 + </div>
362 + </template>
363 + <span
364 + class="picker-selection-label"
365 + x-show="$store.fileBrowser.isTextOpenPicker()"
366 + x-text="$store.fileBrowser.pickerSelectionLabel()"
367 + ></span>
368 + <template x-if="$store.fileBrowser.pickerFilenameError">
369 + <span class="picker-inline-error" x-text="$store.fileBrowser.pickerFilenameError"></span>
370 + </template>
371 + <button
372 + type="button"
373 + class="btn btn-ok picker-confirm-button"
374 + :disabled="!$store.fileBrowser.canConfirmPicker() || $store.fileBrowser.isBulkBusy"
375 + @click="$store.fileBrowser.confirmPicker()"
376 + x-text="$store.fileBrowser.pickerConfirmLabel"
377 + ></button>
378 + <button type="button" class="btn btn-cancel" :disabled="$store.fileBrowser.isBulkBusy" @click="$store.fileBrowser.cancelPicker()">Cancel</button>
379 + </div>
380 + <label class="btn btn-upload" x-show="!$store.fileBrowser.isPickerMode()">
381 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5"/></svg>
382 Upload Files
383 <input type="file" multiple accept="*" @change="$store.fileBrowser.handleFileUpload" style="display:none;" />
384 </label>
337 - <button class="btn btn-cancel" x-show="xAttrs($el)?.mode !== 'canvas'" @click="$store.fileBrowser.handleClose()">Close Browser</button>
385 + <button class="btn btn-cancel" x-show="xAttrs($el)?.mode !== 'canvas' && !$store.fileBrowser.isPickerMode()" @click="$store.fileBrowser.handleClose()">Close Browser</button>
386 </div>
387 </template>
388 </div>
@@ -510,7 +558,7 @@
558 border-radius: 4px;
559 overflow: hidden;
560 display: grid;
513 - grid-template-columns: 2.5rem minmax(0, 1.5fr) minmax(5.5rem, 0.7fr) minmax(9rem, 1fr) 7.5rem;
561 + grid-template-columns: 2.5rem minmax(0, 1.5fr) minmax(5.5rem, 0.7fr) minmax(9rem, 1fr) 8.75rem;
562 background: var(--secondary-bg);
563 padding: 8px 0;
564 font-weight: bold;
@@ -534,7 +582,7 @@
582 /* File Item Styles */
583 .file-item {
584 display: grid;
537 - grid-template-columns: 2.5rem minmax(0, 1.5fr) minmax(5.5rem, 0.7fr) minmax(9rem, 1fr) 7.5rem;
585 + grid-template-columns: 2.5rem minmax(0, 1.5fr) minmax(5.5rem, 0.7fr) minmax(9rem, 1fr) 8.75rem;
586 align-items: center;
587 padding: 8px 0;
588 font-size: 0.875rem;
@@ -860,6 +908,71 @@
908 background: color-mix(in srgb, var(--color-panel) 90%, var(--color-background) 10%);
909 }
910
911 + .file-browser-picker-actions {
912 + display: flex;
913 + flex: 1 1 auto;
914 + align-items: center;
915 + justify-content: flex-end;
916 + gap: var(--spacing-sm);
917 + min-width: 0;
918 + }
919 +
920 + .picker-filename-field {
921 + display: flex;
922 + align-items: center;
923 + flex: 1 1 16rem;
924 + max-width: 24rem;
925 + min-width: 10rem;
926 + height: 2.35rem;
927 + border: 1px solid var(--color-border);
928 + border-radius: 7px;
929 + background: var(--color-input);
930 + color: var(--color-text);
931 + }
932 +
933 + .picker-filename-field .material-symbols-outlined {
934 + flex: 0 0 auto;
935 + padding-left: 0.65rem;
936 + color: var(--color-primary);
937 + font-size: 1.1rem;
938 + opacity: 0.78;
939 + }
940 +
941 + .picker-filename-input {
942 + flex: 1 1 auto;
943 + width: 100%;
944 + min-width: 0;
945 + height: 100%;
946 + border: 0;
947 + outline: 0;
948 + background: transparent;
949 + color: var(--color-text);
950 + padding: 0 0.7rem;
951 + font: inherit;
952 + }
953 +
954 + .picker-selection-label,
955 + .picker-inline-error {
956 + overflow: hidden;
957 + text-overflow: ellipsis;
958 + white-space: nowrap;
959 + font-size: 0.82rem;
960 + font-weight: 700;
961 + letter-spacing: 0;
962 + }
963 +
964 + .picker-selection-label {
965 + color: var(--color-text-secondary);
966 + }
967 +
968 + .picker-inline-error {
969 + color: var(--color-error);
970 + }
971 +
972 + .picker-confirm-button {
973 + min-width: 7.6rem;
974 + }
975 +
976 /* File Actions */
977 .file-actions {
978 display: flex;
@@ -867,6 +980,10 @@
980 justify-content: flex-end;
981 padding-right: 0.5rem;
982 }
983 + .file-editor-open-action {
984 + border-color: color-mix(in srgb, var(--color-primary) 52%, var(--color-border));
985 + background: color-mix(in srgb, var(--color-primary) 10%, transparent);
986 + }
987 .file-actions .dropdown-menu {
988 top: auto !important;
989 bottom: 100% !important;
@@ -929,15 +1046,19 @@
1046 height: 2.5rem;
1047 }
1048 .file-header {
932 - grid-template-columns: 2.25rem minmax(0, 1fr) minmax(5rem, 0.52fr) 7.5rem;
1049 + grid-template-columns: 2.25rem minmax(0, 1fr) minmax(5rem, 0.52fr) 8.75rem;
1050 }
1051 .file-item {
935 - grid-template-columns: 2.25rem minmax(0, 1fr) minmax(5rem, 0.5fr) 7.5rem;
1052 + grid-template-columns: 2.25rem minmax(0, 1fr) minmax(5rem, 0.5fr) 8.75rem;
1053 }
1054 .file-cell-date,
1055 .file-date {
1056 display: none;
1057 }
1058 + .file-actions {
1059 + gap: 0.2rem;
1060 + padding-right: 0.2rem;
1061 + }
1062 }
1063 @media (max-width: 540px) {
1064 .path-navigator .back-button {
@@ -949,7 +1070,7 @@
1070 }
1071 .file-header,
1072 .file-item {
952 - grid-template-columns: 2.25rem minmax(0, 1fr) minmax(4.25rem, max-content) 5.25rem;
1073 + grid-template-columns: 2.25rem minmax(0, 1fr) minmax(4.25rem, max-content) 8rem;
1074 }
1075 .file-cell-date,
1076 .file-date {
@@ -959,14 +1080,26 @@
1080 @container file-browser (max-width: 620px) {
1081 .file-header,
1082 .file-item {
962 - grid-template-columns: 2.25rem minmax(0, 1fr) minmax(4.25rem, max-content) 5.25rem;
1083 + grid-template-columns: 2.25rem minmax(0, 1fr) minmax(4.25rem, max-content) 8rem;
1084 }
1085 .file-cell-date,
1086 .file-date {
1087 display: none;
1088 }
1089 .file-actions {
969 - padding-right: 0.35rem;
1090 + gap: 0.2rem;
1091 + padding-right: 0.2rem;
1092 + }
1093 + .file-browser-picker-actions {
1094 + flex-wrap: wrap;
1095 + justify-content: stretch;
1096 + }
1097 + .picker-filename-field {
1098 + flex-basis: 100%;
1099 + max-width: none;
1100 + }
1101 + .picker-confirm-button {
1102 + flex: 1 1 auto;
1103 }
1104 }
1105 </style>