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
This commit is contained in:
Alessandro 2026-06-23 14:42:49 +02:00
parent 6ccd48e81f
commit bd584da2f4
21 changed files with 967 additions and 108 deletions

View file

@ -16,6 +16,7 @@
- Preserve session startup, cleanup, and route protection for desktop access.
- Keep desktop state injected into prompts accurate and bounded.
- Do not expose desktop routes without the expected auth protections.
- 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.
## Work Guidance

View file

@ -74,10 +74,10 @@ class DesktopSession(ApiHandler):
return {"ok": False, "error": str(exc)}
ext = str(doc.get("extension") or "").lower()
if ext == "md":
if ext in document_store.EDITOR_TEXT_EXTENSIONS:
return {
"ok": False,
"error": "Markdown documents use the Editor surface.",
"error": "Text documents use the Editor surface.",
"requires_editor": True,
"document": _public_doc(doc),
}

View file

@ -23,7 +23,7 @@ from plugins._desktop.helpers import desktop_state
from plugins._office.helpers import document_store, libreoffice
OFFICIAL_EXTENSIONS = {"odt", "ods", "odp", "docx", "xlsx", "pptx", "txt"}
OFFICIAL_EXTENSIONS = {"odt", "ods", "odp", "docx", "xlsx", "pptx"}
PLUGIN_NAME = "_desktop"
SYSTEM_SESSION_ID = "agent-zero-desktop"
SYSTEM_FILE_ID = "system-desktop"
@ -78,6 +78,7 @@ DESKTOP_FOLDER_LINKS = (
URL_INTENT_MAX_ITEMS = 50
URL_INTENT_MAX_LENGTH = 8192
URL_HANDLER_DESKTOP_ID = "agent-zero-browser.desktop"
EDITOR_HANDLER_DESKTOP_ID = "agent-zero-editor.desktop"
SHUTDOWN_HANDLER_DESKTOP_ID = "agent-zero-shutdown.desktop"
SHUTDOWN_PANEL_LAUNCHER_ID = SHUTDOWN_HANDLER_DESKTOP_ID
SHUTDOWN_CONFIRM_SECONDS = 8
@ -1135,6 +1136,7 @@ class DesktopSessionManager:
applications_dir.mkdir(parents=True, exist_ok=True)
browser_bridge = _write_url_bridge_script(session)
editor_bridge = _write_editor_bridge_script(session)
shutdown_bridge = _write_shutdown_bridge_script(session)
helpers_rc = config_dir / "xfce4" / "helpers.rc"
helpers_rc.parent.mkdir(parents=True, exist_ok=True)
@ -1153,8 +1155,12 @@ class DesktopSessionManager:
config_dir / "xfce4" / "helpers" / "agent-zero-browser.desktop",
browser_bridge,
)
_write_mimeapps_defaults(config_dir / "mimeapps.list", URL_HANDLER_DESKTOP_ID)
_write_mimeapps_defaults(data_dir / "applications" / "mimeapps.list", URL_HANDLER_DESKTOP_ID)
_write_mimeapps_defaults(config_dir / "mimeapps.list", URL_HANDLER_DESKTOP_ID, EDITOR_HANDLER_DESKTOP_ID)
_write_mimeapps_defaults(
data_dir / "applications" / "mimeapps.list",
URL_HANDLER_DESKTOP_ID,
EDITOR_HANDLER_DESKTOP_ID,
)
_write_desktop_launcher(
applications_dir / URL_HANDLER_DESKTOP_ID,
name="Agent Zero Browser",
@ -1165,6 +1171,15 @@ class DesktopSessionManager:
mime_types=_url_handler_mime_types(),
no_display=True,
)
_write_desktop_launcher(
applications_dir / EDITOR_HANDLER_DESKTOP_ID,
name="Agent Zero Editor",
exec_line=_desktop_exec(editor_bridge, "%F"),
icon="accessories-text-editor",
categories="Utility;TextEditor;",
try_exec=str(editor_bridge),
mime_types=_editor_text_handler_mime_types(),
)
_write_desktop_launcher(
applications_dir / SHUTDOWN_HANDLER_DESKTOP_ID,
name="Shutdown Desktop",
@ -1190,6 +1205,14 @@ class DesktopSessionManager:
categories="Network;WebBrowser;",
try_exec=str(browser_bridge),
)
_write_desktop_launcher(
desktop_dir / "Editor.desktop",
name="Editor",
exec_line=_desktop_exec(editor_bridge),
icon="accessories-text-editor",
categories="Utility;TextEditor;",
try_exec=str(editor_bridge),
)
self._trust_desktop_launchers(session, desktop_dir)
def _hide_xpra_desktop_entries(self, applications_dir: Path) -> None:
@ -1830,6 +1853,10 @@ def _url_bridge_script_path(session: DesktopSession) -> Path:
return _url_bridge_dir(session) / "open-url"
def _editor_bridge_script_path(session: DesktopSession) -> Path:
return _url_bridge_dir(session) / "open-editor"
def _url_bridge_queue_path(session: DesktopSession) -> Path:
return _url_bridge_dir(session) / "browser-url-intents.jsonl"
@ -1900,6 +1927,64 @@ if __name__ == "__main__":
return script
def _write_editor_bridge_script(session: DesktopSession) -> Path:
bridge_dir = _url_bridge_dir(session)
bridge_dir.mkdir(parents=True, exist_ok=True)
script = _editor_bridge_script_path(session)
queue = _url_bridge_queue_path(session)
lock = _url_bridge_lock_path(session)
script.write_text(
f"""#!/usr/bin/env python3
import fcntl
import json
import os
import sys
import time
from urllib.parse import quote
QUEUE_PATH = {str(queue)!r}
LOCK_PATH = {str(lock)!r}
MAX_URL_LENGTH = {URL_INTENT_MAX_LENGTH}
def editor_url(path):
path = str(path or "").strip()
if not path:
return "a0-editor://open"
return "a0-editor://open?path=" + quote(path[:MAX_URL_LENGTH], safe="/:")
def main():
urls = [editor_url(arg) for arg in sys.argv[1:] if str(arg or "").strip()]
if not urls:
urls = [editor_url("")]
os.makedirs(os.path.dirname(QUEUE_PATH), exist_ok=True)
with open(LOCK_PATH, "a+", encoding="utf-8") as lock_file:
fcntl.flock(lock_file, fcntl.LOCK_EX)
with open(QUEUE_PATH, "a", encoding="utf-8") as queue_file:
for url in urls:
queue_file.write(json.dumps({{
"url": url,
"created_at": time.time(),
"source": "desktop-editor",
}}, ensure_ascii=True) + "\\n")
queue_file.flush()
os.fsync(queue_file.fileno())
fcntl.flock(lock_file, fcntl.LOCK_UN)
if __name__ == "__main__":
main()
""",
encoding="utf-8",
)
try:
script.chmod(0o755)
except OSError:
pass
return script
def _write_shutdown_bridge_script(session: DesktopSession) -> Path:
bridge_dir = _url_bridge_dir(session)
bridge_dir.mkdir(parents=True, exist_ok=True)
@ -2102,14 +2187,25 @@ def _url_handler_mime_types() -> tuple[str, ...]:
)
def _write_mimeapps_defaults(path: Path, desktop_id: str) -> None:
associations = ";".join([desktop_id, ""])
def _editor_text_handler_mime_types() -> tuple[str, ...]:
return (
"text/markdown",
"text/x-markdown",
"text/plain",
)
def _write_mimeapps_defaults(path: Path, url_desktop_id: str, editor_desktop_id: str) -> None:
url_associations = ";".join([url_desktop_id, ""])
editor_associations = ";".join([editor_desktop_id, ""])
lines = [
"[Default Applications]",
*(f"{mime_type}={desktop_id}" for mime_type in _url_handler_mime_types()),
*(f"{mime_type}={url_desktop_id}" for mime_type in _url_handler_mime_types()),
*(f"{mime_type}={editor_desktop_id}" for mime_type in _editor_text_handler_mime_types()),
"",
"[Added Associations]",
*(f"{mime_type}={associations}" for mime_type in _url_handler_mime_types()),
*(f"{mime_type}={url_associations}" for mime_type in _url_handler_mime_types()),
*(f"{mime_type}={editor_associations}" for mime_type in _editor_text_handler_mime_types()),
"",
]
path.parent.mkdir(parents=True, exist_ok=True)

View file

@ -2239,7 +2239,12 @@ const model = {
async openDesktopUrlIntent(intent = {}) {
const url = String(intent?.url || "").trim();
const handled = await handleUrlIntent({ url, source: "desktop-url" });
this.setMessage(handled ? "Opened link in Browser" : "Browser is not available");
const isEditorIntent = url.startsWith("a0-editor:");
this.setMessage(
handled
? (isEditorIntent ? "Opened text in Editor" : "Opened link in Browser")
: (isEditorIntent ? "Editor is not available" : "Browser is not available"),
);
},
browserDestinationForDesktopUrl() {

View file

@ -2,12 +2,12 @@
## Purpose
- Own the native Markdown editor surface for canvas and floating modal workflows.
- Own the native Markdown and plain text editor surface for canvas and floating modal workflows.
## Ownership
- `api/` owns editor session and WebSocket handlers.
- `helpers/` owns markdown session and open-file context helpers.
- `helpers/` owns editor text session and open-file context helpers.
- `prompts/` owns agent-visible open-file context.
- `webui/` owns editor panel, preview, store, and main surface.
- `extensions/` owns editor hook contributions.
@ -17,6 +17,8 @@
- Keep editor session state synchronized across API, WebSocket, and WebUI panel behavior.
- Do not expose unsaved content or local paths beyond intended chat/context surfaces.
- Keep the floating Editor modal on the shared surface modal chrome so the header remains draggable while existing Focus mode continues to work.
- 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.
- 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.
## Work Guidance

View file

@ -38,13 +38,13 @@ class EditorSession(ApiHandler):
return closed
if action == "create":
fmt = str(input.get("format") or "md").lower().lstrip(".")
if fmt != "md":
return {"ok": False, "error": "Editor can only create Markdown documents."}
if fmt not in document_store.EDITOR_TEXT_EXTENSIONS:
return {"ok": False, "error": "Editor can only create Markdown (.md) and text (.txt) documents."}
try:
doc = document_store.create_document(
kind="document",
title=str(input.get("title") or "Untitled"),
fmt="md",
fmt=fmt,
content=str(input.get("content") or ""),
path=str(input.get("path") or ""),
context_id=context_id,
@ -72,6 +72,28 @@ class EditorSession(ApiHandler):
if not session_id:
return {"ok": False, "error": "session_id is required."}
return markdown_sessions.get_manager().save(session_id, text=input.get("text"))
if action == "save_as":
session_id = str(input.get("session_id") or "").strip()
path = str(input.get("path") or "").strip()
if not session_id:
return {"ok": False, "error": "session_id is required."}
if not path:
return {"ok": False, "error": "path is required."}
try:
result = markdown_sessions.get_manager().save_as(session_id, path, text=input.get("text"))
except Exception as exc:
return {"ok": False, "error": str(exc)}
document_store.close_session(session_id=str(input.get("store_session_id") or "").strip())
store_session = document_store.create_session(
result["document"]["file_id"],
user_id=str(input.get("user_id") or "agent-zero-user"),
permission="write",
origin=self._origin(request),
)
return {
**result,
"store_session_id": store_session["session_id"],
}
if action == "renamed":
return self._renamed(input, context_id)
if action == "refresh":
@ -85,7 +107,7 @@ class EditorSession(ApiHandler):
request: Request,
context_id: str = "",
) -> dict:
if str(doc.get("extension") or "").lower() != "md":
if str(doc.get("extension") or "").lower() not in document_store.EDITOR_TEXT_EXTENSIONS:
return {
"ok": False,
"error": f".{doc.get('extension', '')} documents use the Desktop surface.",

View file

@ -53,10 +53,17 @@ class WsEditor(WsHandler):
elif path:
doc = document_store.register_document(path, context_id=context_id)
else:
fmt = str(data.get("format") or "md").lower().strip().lstrip(".")
if fmt not in document_store.EDITOR_TEXT_EXTENSIONS:
return WsResult.error(
code="UNSUPPORTED_EDITOR_DOCUMENT",
message=f"Editor can only create Markdown (.md) and text (.txt) documents, not .{fmt}.",
correlation_id=data.get("correlationId"),
)
doc = document_store.create_document(
kind="document",
title=str(data.get("title") or "Untitled"),
fmt="md",
fmt=fmt,
content=str(data.get("content") or ""),
context_id=context_id,
)

View file

@ -31,7 +31,7 @@ class MarkdownSession:
class MarkdownSessionManager:
"""Owns native Editor sessions for Markdown documents."""
"""Owns native Editor sessions for Markdown and plain text documents."""
def __init__(self) -> None:
self._sessions: dict[str, MarkdownSession] = {}
@ -39,8 +39,8 @@ class MarkdownSessionManager:
def open(self, doc: dict[str, Any], sid: str = "", context_id: str = "", refresh: bool = False) -> dict[str, Any]:
ext = str(doc["extension"]).lower()
if ext != "md":
raise ValueError(f"Editor is only available for Markdown. Open .{ext} files in the Desktop.")
if ext not in document_store.EDITOR_TEXT_EXTENSIONS:
raise ValueError(f"Editor is only available for Markdown and text files. Open .{ext} files in the Desktop.")
normalized_context = str(context_id or "")
if refresh:
@ -64,6 +64,7 @@ class MarkdownSessionManager:
_mark_session_external(session, doc)
session.path = doc["path"]
session.title = doc["basename"]
session.extension = ext
session.updated_at = time.time()
self.activate(session.session_id)
return self._payload(session, doc)
@ -103,11 +104,12 @@ class MarkdownSessionManager:
if conflict is not None:
return conflict
updated = document_store.write_markdown(session.file_id, session.text)
updated = document_store.write_text_document(session.file_id, session.text)
session.updated_at = time.time()
session.dirty = False
session.path = updated["path"]
session.title = updated["basename"]
session.extension = str(updated.get("extension") or session.extension).lower()
_set_session_base(session, updated)
self._refresh_file_sessions(
updated,
@ -121,6 +123,32 @@ class MarkdownSessionManager:
"version": document_store.item_version(updated),
}
def save_as(self, session_id: str, path: str, text: str | None = None) -> dict[str, Any]:
session = self._require(session_id)
if text is not None:
session.text = str(text)
updated = document_store.save_text_document_as(
session.file_id,
path,
session.text,
context_id=session.context_id,
)
old_file_id = session.file_id
session.file_id = updated["file_id"]
session.updated_at = time.time()
session.dirty = False
session.path = updated["path"]
session.title = updated["basename"]
session.extension = str(updated.get("extension") or session.extension).lower()
_set_session_base(session, updated)
return {
"ok": True,
"previous_file_id": old_file_id,
"document": _public_doc(updated),
"version": document_store.item_version(updated),
}
def activate(self, session_id: str) -> dict[str, Any]:
session = self._require(session_id)
now = time.time()
@ -145,7 +173,7 @@ class MarkdownSessionManager:
doc = document_store.get_document(normalized)
except Exception:
return {"ok": False, "refreshed": 0, "sessions": []}
if str(doc.get("extension") or "").lower() != "md":
if str(doc.get("extension") or "").lower() not in document_store.EDITOR_TEXT_EXTENSIONS:
return {"ok": True, "refreshed": 0, "sessions": []}
refreshed = self._refresh_file_sessions(
@ -342,6 +370,7 @@ class MarkdownSessionManager:
session.text = str(text)
session.path = doc["path"]
session.title = doc["basename"]
session.extension = str(doc.get("extension") or session.extension).lower()
if dirty is not None and (can_replace_text or refresh_dirty or not session.dirty):
session.dirty = dirty
if can_replace_text or not session.dirty:

View file

@ -11,7 +11,7 @@ def build_context(context_id: str = "", max_items: int = 20) -> str:
return ""
lines = [
"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.",
"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.",
]
for item in files:
lines.append(format_open_file_line(item))

View file

@ -9,7 +9,7 @@
<template x-if="$store.editor">
<div class="editor-panel" x-create="$store.editor.onMount($el, xAttrs($el) || {})" x-destroy="$store.editor.cleanup()">
<div class="editor-shell" @keydown="$store.editor.handleEditorKeydown($event)">
<div class="editor-tabs" x-show="$store.editor.visibleTabs().length > 0" style="display: none;" role="tablist" aria-label="Open Markdown files">
<div class="editor-tabs" x-show="$store.editor.visibleTabs().length > 0" style="display: none;" role="tablist" aria-label="Open text files">
<template x-for="tab in $store.editor.visibleTabs()" :key="tab.tab_id">
<div
class="editor-tab-shell"
@ -82,6 +82,8 @@
<button
type="button"
class="editor-icon-button editor-mode-toggle"
x-show="$store.editor.isMarkdown()"
style="display: none;"
:title="$store.editor.viewModeTitle()"
:aria-label="$store.editor.viewModeTitle()"
@click="$store.editor.toggleViewMode()"
@ -89,30 +91,30 @@
<span class="material-symbols-outlined" aria-hidden="true" x-text="$store.editor.viewModeIcon()"></span>
</button>
<div class="editor-tool-group editor-source-tools" x-show="$store.editor.isMarkdown() && $store.editor.isSourceMode()" style="display: none;">
<div class="editor-tool-group editor-source-tools" x-show="$store.editor.isTextDocument() && $store.editor.isSourceMode()" style="display: none;">
<button type="button" class="editor-icon-button" title="Undo" aria-label="Undo" :disabled="!$store.editor.canUndo()" @click="$store.editor.undo()">
<span class="material-symbols-outlined">undo</span>
</button>
<button type="button" class="editor-icon-button" title="Redo" aria-label="Redo" :disabled="!$store.editor.canRedo()" @click="$store.editor.redo()">
<span class="material-symbols-outlined">redo</span>
</button>
<button type="button" class="editor-icon-button" title="Bold" aria-label="Bold" @click="$store.editor.format('bold')">
<button type="button" class="editor-icon-button" title="Bold" aria-label="Bold" x-show="$store.editor.isMarkdown()" @click="$store.editor.format('bold')">
<span class="material-symbols-outlined">format_bold</span>
</button>
<button type="button" class="editor-icon-button" title="Italic" aria-label="Italic" @click="$store.editor.format('italic')">
<button type="button" class="editor-icon-button" title="Italic" aria-label="Italic" x-show="$store.editor.isMarkdown()" @click="$store.editor.format('italic')">
<span class="material-symbols-outlined">format_italic</span>
</button>
<button type="button" class="editor-icon-button" title="List" aria-label="List" @click="$store.editor.format('list')">
<button type="button" class="editor-icon-button" title="List" aria-label="List" x-show="$store.editor.isMarkdown()" @click="$store.editor.format('list')">
<span class="material-symbols-outlined">format_list_bulleted</span>
</button>
<button type="button" class="editor-icon-button" title="Numbered list" aria-label="Numbered list" @click="$store.editor.format('numbered')">
<button type="button" class="editor-icon-button" title="Numbered list" aria-label="Numbered list" x-show="$store.editor.isMarkdown()" @click="$store.editor.format('numbered')">
<span class="material-symbols-outlined">format_list_numbered</span>
</button>
<button type="button" class="editor-icon-button" title="Table" aria-label="Table" @click="$store.editor.format('table')">
<button type="button" class="editor-icon-button" title="Table" aria-label="Table" x-show="$store.editor.isMarkdown()" @click="$store.editor.format('table')">
<span class="material-symbols-outlined">table</span>
</button>
</div>
<div class="editor-tool-group editor-preview-tools" x-show="$store.editor.isPreviewMode()" style="display: none;">
<div class="editor-tool-group editor-preview-tools" x-show="$store.editor.isMarkdown() && $store.editor.isPreviewMode()" style="display: none;">
<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()">
<span class="material-symbols-outlined">chevron_left</span>
</button>
@ -147,6 +149,17 @@
<span class="material-symbols-outlined" :class="{ spinning: $store.editor.saving }" x-text="$store.editor.saving ? 'progress_activity' : 'save'"></span>
</button>
<button
type="button"
class="editor-icon-button editor-save-as-button"
title="Save As"
aria-label="Save As"
:disabled="$store.editor.saving"
@click="$store.editor.saveAs()"
>
<span class="material-symbols-outlined">save_as</span>
</button>
<div class="editor-file-actions" x-data="{ open: false }" @click.outside="open = false" @keydown.escape.window="open = false">
<button
type="button"
@ -213,7 +226,7 @@
<textarea
class="editor-source-editor"
data-editor-source
aria-label="Markdown source"
aria-label="Text source"
x-show="$store.editor.aceUnavailable"
x-model="$store.editor.editorText"
@input="$store.editor.onSourceInput()"
@ -224,7 +237,7 @@
</div>
</div>
<div class="editor-preview-shell" x-show="$store.editor.session && $store.editor.isPreviewMode()" style="display: none;">
<div class="editor-preview-shell" x-show="$store.editor.session && $store.editor.isMarkdown() && $store.editor.isPreviewMode()" style="display: none;">
<div class="editor-preview-title">
<h1 x-text="$store.editor.pageTitle()"></h1>
</div>
@ -261,6 +274,10 @@
<span class="material-symbols-outlined" aria-hidden="true">article</span>
<span class="editor-button-label">Markdown</span>
</button>
<button type="button" class="editor-icon-button editor-command-button" @click="$store.editor.runNewMenuAction('text')">
<span class="material-symbols-outlined" aria-hidden="true">description</span>
<span class="editor-button-label">Text</span>
</button>
</div>
</div>
</div>

View file

@ -3,7 +3,9 @@ import { callJsonApi } from "/js/api.js";
import { getNamespacedClient } from "/js/websocket.js";
import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
import {
openLatest as openLatestSurface,
placeSurfaceModalHeaderAction,
registerUrlHandler,
setupFloatingSurfaceModalChrome,
} from "/js/surfaces.js";
import {
@ -24,6 +26,7 @@ const INPUT_PUSH_DELAY_MS = 650;
const MAX_HISTORY = 80;
const SOURCE_MODE = "source";
const PREVIEW_MODE = "preview";
const EDITOR_TEXT_EXTENSIONS = new Set(["md", "txt"]);
function currentContextId() {
try {
@ -51,6 +54,33 @@ function parentPath(path = "") {
return normalized.slice(0, index);
}
function textDocumentFilename(path = "", fallback = "Untitled.md") {
const name = basename(path || fallback || "Untitled.md");
const ext = extensionOf(name);
if (EDITOR_TEXT_EXTENSIONS.has(ext)) return name;
return `${name.replace(/\.+$/, "") || "Untitled"}.md`;
}
function textDocumentDefaultExtension(path = "") {
const ext = extensionOf(path);
return EDITOR_TEXT_EXTENSIONS.has(ext) ? ext : "md";
}
function editorIntent(url = "") {
const raw = String(url || "").trim();
if (!raw) return null;
try {
const parsed = new URL(raw);
if (parsed.protocol !== "a0-editor:") return null;
if (parsed.hostname === "open") {
return { path: parsed.searchParams.get("path") || "" };
}
return null;
} catch {
return null;
}
}
function uniqueTabId(session = {}) {
return String(session.file_id || session.session_id || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`);
}
@ -77,7 +107,7 @@ function placeCaretAtEnd(element) {
selection.addRange(range);
}
function normalizeMarkdown(doc = {}) {
function normalizeTextDocument(doc = {}) {
const path = doc.path || "";
const extension = String(doc.extension || extensionOf(path)).toLowerCase();
return {
@ -90,7 +120,7 @@ function normalizeMarkdown(doc = {}) {
}
function normalizeSession(payload = {}) {
const document = normalizeMarkdown(payload.document || payload);
const document = normalizeTextDocument(payload.document || payload);
return {
...payload,
document,
@ -301,7 +331,7 @@ const model = {
},
async setViewMode(mode) {
const next = mode === PREVIEW_MODE ? PREVIEW_MODE : SOURCE_MODE;
const next = mode === PREVIEW_MODE && this.isMarkdown() ? PREVIEW_MODE : SOURCE_MODE;
if (this.viewMode === next) return;
this.applyPreviewEdit({ silent: true });
this.syncEditorText();
@ -319,6 +349,7 @@ const model = {
},
async toggleViewMode() {
if (!this.isMarkdown()) return;
await this.setViewMode(this.isPreviewMode() ? SOURCE_MODE : PREVIEW_MODE);
},
@ -331,6 +362,7 @@ const model = {
},
pages() {
if (!this.isMarkdown()) return [];
return buildMarkdownPages(this.editorText, this.tabTitle(this.session || {}));
},
@ -351,6 +383,7 @@ const model = {
},
previewHtml() {
if (!this.isMarkdown()) return "";
return renderEditorPreviewMarkdown(this.currentPage().markdown || "", this.editorText);
},
@ -476,7 +509,7 @@ const model = {
},
schedulePreviewEnhance() {
if (!this.isPreviewMode()) return;
if (!this.isMarkdown() || !this.isPreviewMode()) return;
if (this._previewEnhanceTimer) globalThis.clearTimeout(this._previewEnhanceTimer);
this._previewEnhanceTimer = globalThis.setTimeout(() => {
this._previewEnhanceTimer = null;
@ -669,6 +702,7 @@ const model = {
},
openSearch() {
if (!this.isMarkdown()) return;
if (!this.isPreviewMode()) {
this.setViewMode(PREVIEW_MODE);
}
@ -832,13 +866,15 @@ const model = {
handleEditorKeydown(event) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f") {
if (!this.isMarkdown()) return;
event.preventDefault();
this.openSearch();
}
},
async create(kind = "document", format = "") {
const fmt = "md";
const requested = String(format || "md").toLowerCase().replace(/^\./, "");
const fmt = EDITOR_TEXT_EXTENSIONS.has(requested) ? requested : "md";
const title = this.defaultTitle(kind, fmt);
return await this.openSession({
action: "create",
@ -866,7 +902,17 @@ const model = {
// The file browser can still open with the static fallback.
}
}
await fileBrowserStore.open(workdirPath);
await fileBrowserStore.openTextPicker(workdirPath, async ({ selectedFiles = [] } = {}) => {
const files = selectedFiles.filter((file) => file?.path);
if (!files.length) return false;
for (const file of files) {
const session = await this.openPath(file.path, { source: "file-browser", refresh: true });
if (!session || session.ok === false) {
throw new Error(this.error || `Could not open ${file.name || file.path}`);
}
}
return true;
});
},
async openPath(path, options = {}) {
@ -883,11 +929,11 @@ const model = {
try {
const response = await callEditor(payload.action || "open", payload);
if (response?.ok === false) {
this.error = response.error || "Markdown could not be opened.";
this.error = response.error || "Text document could not be opened.";
return null;
}
if (response?.requires_desktop) {
const document = normalizeMarkdown(response.document || response);
const document = normalizeTextDocument(response.document || response);
this.setMessage(`${documentLabel(document)} uses the Desktop surface.`);
await this.refresh();
return response;
@ -931,6 +977,11 @@ const model = {
this.activeTabId = tab?.tab_id || "";
this.editorText = String(tab?.text || "");
this.dirty = Boolean(tab?.dirty);
if (!this.isMarkdown(tab) && this.isPreviewMode()) {
this.viewMode = SOURCE_MODE;
this.searchOpen = false;
this.searchQuery = "";
}
if (this.previewEditing) this.cancelPreviewEdit();
if (!options.preservePage) {
this.activePageIndex = 0;
@ -941,6 +992,7 @@ const model = {
this.searchIndex = -1;
this.resetHistory(this.editorText);
this.setSourceEditorText(this.editorText);
this.updateSourceEditorMode();
if (tab?.session_id) {
requestEditor("editor_activate", { session_id: tab.session_id }, 2500).catch(() => {});
}
@ -994,7 +1046,7 @@ const model = {
if (!pending) return "";
const dirtyCount = Number(pending.dirtyCount || 0);
if (pending.kind === "all") {
if (dirtyCount === 0) return "All open Markdown files will be closed.";
if (dirtyCount === 0) return "All open text files will be closed.";
return `${dirtyCount} open ${dirtyCount === 1 ? "file has" : "files have"} unsaved changes.`;
}
if (dirtyCount > 0) return "This file has unsaved changes.";
@ -1069,7 +1121,7 @@ const model = {
file_id: tab.file_id || "",
});
} catch (error) {
console.warn("Markdown close skipped", error);
console.warn("Editor close skipped", error);
}
this.tabs = this.tabs.filter((item) => item.tab_id !== tabId);
if (this.pendingClose?.tabId === tabId || this.pendingClose?.tabIds?.includes(tabId)) {
@ -1140,7 +1192,7 @@ const model = {
const darkMode = globalThis.localStorage?.getItem("darkMode");
const theme = darkMode !== "false" ? "ace/theme/github_dark" : "ace/theme/github";
editor.setTheme(theme);
editor.session.setMode("ace/mode/markdown");
editor.session.setMode(this.sourceEditorMode());
editor.session.setUseWrapMode(true);
editor.setOptions({
fontSize: "13px",
@ -1159,9 +1211,20 @@ const model = {
editor.session.on("change", this._sourceEditorChangeHandler);
this.sourceEditor = editor;
this.aceUnavailable = false;
this.updateSourceEditorMode();
this.queueRender({ focus: Boolean(this.session), end: false });
},
sourceEditorMode(tab = this.session) {
return this.isMarkdown(tab) ? "ace/mode/markdown" : "ace/mode/text";
},
updateSourceEditorMode(tab = this.session) {
try {
this.sourceEditor?.session?.setMode(this.sourceEditorMode(tab));
} catch {}
},
destroySourceEditor() {
if (this.sourceEditor?.session && this._sourceEditorChangeHandler) {
this.sourceEditor.session.off?.("change", this._sourceEditorChangeHandler);
@ -1199,7 +1262,7 @@ const model = {
},
async save() {
if (!this.session || this.saving || !this.isMarkdown()) return;
if (!this.session || this.saving || !this.isTextDocument()) return;
this.applyPreviewEdit({ silent: true });
this.syncEditorText();
this.saving = true;
@ -1213,7 +1276,7 @@ const model = {
response = await callEditor("save", payload);
}
if (response?.ok === false) throw new Error(response.error || "Save failed.");
const document = normalizeMarkdown(response.document || this.session.document || {});
const document = normalizeTextDocument(response.document || this.session.document || {});
const updated = {
...this.session,
text: this.editorText,
@ -1221,6 +1284,7 @@ const model = {
document,
path: document.path || this.session.path,
file_id: document.file_id || this.session.file_id,
extension: document.extension || this.session.extension,
version: document.version || response.version || this.session.version,
};
this.replaceActiveSession(updated);
@ -1234,8 +1298,73 @@ const model = {
}
},
async saveAs() {
if (!this.session || this.saving || !this.isTextDocument()) return;
this.applyPreviewEdit({ silent: true });
this.syncEditorText();
let startPath = parentPath(this.session.path || this.session.document?.path || "");
if (!startPath || startPath === "/") {
try {
const home = await callEditor("home");
startPath = home?.path || startPath || "/a0/usr/workdir";
} catch {
startPath = "/a0/usr/workdir";
}
}
await fileBrowserStore.openSaveAsPicker(startPath, {
filename: textDocumentFilename(this.session.path || this.session.title || "Untitled.md"),
defaultExtension: textDocumentDefaultExtension(this.session.path || this.session.title || "Untitled.md"),
onConfirm: async ({ path } = {}) => {
if (!path) return false;
await this.saveAsPath(path);
return true;
},
});
},
async saveAsPath(path) {
if (!this.session || this.saving || !this.isTextDocument()) return null;
this.saving = true;
this.error = "";
try {
const payload = {
session_id: this.session.session_id,
store_session_id: this.session.store_session_id || "",
path,
text: this.editorText,
};
const response = await callEditor("save_as", payload);
if (response?.ok === false) throw new Error(response.error || "Save As failed.");
const document = normalizeTextDocument(response.document || this.session.document || {});
const updated = {
...this.session,
text: this.editorText,
dirty: false,
document,
title: document.title || document.basename || basename(document.path),
path: document.path || path,
file_id: document.file_id || this.session.file_id,
extension: document.extension || this.session.extension,
store_session_id: response.store_session_id || this.session.store_session_id,
version: document.version || response.version || this.session.version,
};
this.replaceActiveSession(updated);
this.dirty = false;
this.setMessage("Saved As");
await this.refresh();
return updated;
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
throw error;
} finally {
this.saving = false;
}
},
async saveTab(tab) {
if (!tab || this.saving || !this.isMarkdown(tab)) return false;
if (!tab || this.saving || !this.isTextDocument(tab)) return false;
if (this.isActiveTab(tab)) {
this.applyPreviewEdit({ silent: true });
this.syncEditorText();
@ -1254,7 +1383,7 @@ const model = {
response = await callEditor("save", payload);
}
if (response?.ok === false) throw new Error(response.error || "Save failed.");
const document = normalizeMarkdown(response.document || tab.document || {});
const document = normalizeTextDocument(response.document || tab.document || {});
const updated = {
...tab,
text: payload.text,
@ -1262,6 +1391,7 @@ const model = {
document,
path: document.path || tab.path,
file_id: document.file_id || tab.file_id,
extension: document.extension || tab.extension,
version: document.version || response.version || tab.version,
};
this.replaceSession(tab, updated);
@ -1310,7 +1440,7 @@ const model = {
file_id: session.file_id || "",
path: renamedPath,
};
if (this.isMarkdown(session)) {
if (this.isTextDocument(session)) {
this.syncEditorText();
payload.text = this.session?.tab_id === session.tab_id ? this.editorText : session.text || "";
}
@ -1330,7 +1460,7 @@ const model = {
});
if (response?.ok === false) throw new Error(response.error || "Rename failed.");
const document = normalizeMarkdown(response.document || session.document || {});
const document = normalizeTextDocument(response.document || session.document || {});
const updated = {
...session,
document,
@ -1355,7 +1485,11 @@ const model = {
replaceSession(previous, next) {
const wasActive = this.activeTabId === (previous?.tab_id || next.tab_id);
if (wasActive) this.session = next;
if (wasActive) {
this.session = next;
if (!this.isMarkdown(next) && this.isPreviewMode()) this.viewMode = SOURCE_MODE;
this.updateSourceEditorMode(next);
}
const index = this.tabs.findIndex((tab) => tab.tab_id === (previous?.tab_id || next.tab_id));
if (index >= 0) this.tabs.splice(index, 1, next);
},
@ -1447,7 +1581,7 @@ const model = {
},
scheduleInputPush() {
if (!this.session?.session_id || !this.isMarkdown()) return;
if (!this.session?.session_id || !this.isTextDocument()) return;
if (this._inputTimer) globalThis.clearTimeout(this._inputTimer);
this._inputTimer = globalThis.setTimeout(() => {
this._inputTimer = null;
@ -1456,7 +1590,7 @@ const model = {
},
flushInput() {
if (!this.session?.session_id || !this.isMarkdown()) return;
if (!this.session?.session_id || !this.isTextDocument()) return;
if (this.previewEditing) return;
this.syncEditorText();
requestEditor("editor_input", {
@ -1525,7 +1659,7 @@ const model = {
},
focusEditor(options = {}) {
if (!this.session || !this.isMarkdown()) return false;
if (!this.session || !this.isTextDocument()) return false;
if (this.sourceEditor && this.isSourceMode()) {
this.sourceEditor.focus();
if (options.end !== false) {
@ -1549,8 +1683,13 @@ const model = {
return ext === "md";
},
isTextDocument(tab = this.session) {
const ext = String(tab?.extension || tab?.document?.extension || "").toLowerCase();
return EDITOR_TEXT_EXTENSIONS.has(ext);
},
hasActiveFile(tab = this.session) {
return Boolean(tab && this.isMarkdown(tab));
return Boolean(tab && this.isTextDocument(tab));
},
visibleTabs() {
@ -1560,7 +1699,8 @@ const model = {
defaultTitle(kind, fmt) {
const date = new Date().toISOString().slice(0, 10);
if (fmt === "md") return `Markdown ${date}`;
return `Markdown ${date}`;
if (fmt === "txt") return `Text ${date}`;
return `Text ${date}`;
},
tabTitle(tab = {}) {
@ -1578,6 +1718,7 @@ const model = {
tab = tab || {};
const ext = String(tab.extension || tab.document?.extension || "").toLowerCase();
if (ext === "md") return "article";
if (ext === "txt") return "description";
return "draft";
},
@ -1585,6 +1726,7 @@ const model = {
const normalized = String(action || "").trim().toLowerCase();
if (normalized === "open") return await this.openFileBrowser();
if (normalized === "markdown") return await this.create("document", "md");
if (normalized === "text") return await this.create("document", "txt");
return null;
},
@ -1608,6 +1750,10 @@ const model = {
<span class="material-symbols-outlined" aria-hidden="true">article</span>
<span>Markdown</span>
</button>
<button type="button" class="editor-new-menu-item" role="menuitem" data-editor-new-action="text">
<span class="material-symbols-outlined" aria-hidden="true">description</span>
<span>Text</span>
</button>
</div>
`;
@ -1678,6 +1824,19 @@ const model = {
inner.classList.remove("editor-modal", "is-focus-mode");
};
},
async handleEditorUrlIntent(intent = {}) {
const editor = editorIntent(intent?.url || "");
if (!editor) return false;
await openLatestSurface("editor", {
path: editor.path,
refresh: true,
source: intent?.source || "desktop-open",
});
return true;
},
};
export const store = createStore("editor", model);
registerUrlHandler((intent) => model.handleEditorUrlIntent(intent));

View file

@ -16,6 +16,7 @@
- Preserve document storage integrity and live session synchronization.
- Keep LibreOffice operations bounded to intended workspaces and artifact paths.
- Do not expose document contents or temporary files beyond intended UI/tool flows.
- 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.
## Work Guidance

View file

@ -63,10 +63,10 @@ class OfficeSession(ApiHandler):
async def _open_document(self, doc: dict, input: dict, request: Request) -> dict:
mode = "edit" if str(input.get("mode") or "edit").lower() == "edit" else "view"
if str(doc.get("extension") or "").lower() == "md":
if str(doc.get("extension") or "").lower() in document_store.EDITOR_TEXT_EXTENSIONS:
return {
"ok": False,
"error": "Markdown documents use the Editor surface.",
"error": "Text documents use the Editor surface.",
"document": _public_doc(doc),
}
if str(doc.get("extension") or "").lower() in desktop_session.OFFICIAL_EXTENSIONS:
@ -116,7 +116,7 @@ class OfficeSession(ApiHandler):
return {"ok": False, "error": f".{doc.get('extension', '')} documents are not supported by LibreOffice."}
def _save(self, input: dict) -> dict:
return {"ok": False, "error": "Markdown saves use /plugins/_editor/editor_session."}
return {"ok": False, "error": "Text document saves use /plugins/_editor/editor_session."}
def _renamed(self, input: dict, context_id: str = "") -> dict:
file_id = str(input.get("file_id") or "").strip()

View file

@ -22,7 +22,7 @@ class WsOffice(WsHandler):
if event in {"office_input", "office_save", "office_close"}:
return {
"ok": False,
"error": "Office WebSocket editing is not available for Markdown; use the Editor surface.",
"error": "Office WebSocket editing is not available for text documents; use the Editor surface.",
}
except FileNotFoundError as exc:
return WsResult.error(code="OFFICE_SESSION_NOT_FOUND", message=str(exc), correlation_id=data.get("correlationId"))
@ -59,10 +59,10 @@ class WsOffice(WsHandler):
context_id=context_id,
)
ext = str(doc.get("extension") or "").lower()
if ext == "md":
if ext in document_store.EDITOR_TEXT_EXTENSIONS:
return WsResult.error(
code="UNSUPPORTED_OFFICE_DOCUMENT",
message="Markdown documents use the Editor surface.",
message="Text documents use the Editor surface.",
correlation_id=data.get("correlationId"),
)
if ext in desktop_session.OFFICIAL_EXTENSIONS:

View file

@ -23,8 +23,8 @@ from plugins._office.helpers import pptx_writer
PLUGIN_NAME = "_office"
OPEN_DOCUMENT_EXTENSIONS = {"odt", "ods", "odp"}
OOXML_EXTENSIONS = {"docx", "xlsx", "pptx"}
DESKTOP_TEXT_EXTENSIONS = {"txt"}
SUPPORTED_EXTENSIONS = {"md", *OPEN_DOCUMENT_EXTENSIONS, *OOXML_EXTENSIONS, *DESKTOP_TEXT_EXTENSIONS}
EDITOR_TEXT_EXTENSIONS = {"md", "txt"}
SUPPORTED_EXTENSIONS = {*EDITOR_TEXT_EXTENSIONS, *OPEN_DOCUMENT_EXTENSIONS, *OOXML_EXTENSIONS}
DEFAULT_TTL_SECONDS = 8 * 60 * 60
MAX_SAVE_BYTES = 512 * 1024 * 1024
ODF_OFFICE_NS = "urn:oasis:names:tc:opendocument:xmlns:office:1.0"
@ -336,8 +336,8 @@ def rename_document(
ext = normalize_extension(resolved.suffix.lstrip("."))
data = None
if content is not None:
if ext != "md":
raise ValueError("Inline content can only be provided for Markdown documents.")
if ext not in EDITOR_TEXT_EXTENSIONS:
raise ValueError("Inline content can only be provided for Editor text documents.")
data = str(content or "").encode("utf-8")
if len(data) > MAX_SAVE_BYTES:
raise OverflowError("Document save exceeds maximum size")
@ -495,13 +495,91 @@ def close_session(session_id: str = "", file_id: str = "") -> int:
def read_text_for_editor(doc: dict[str, Any]) -> str:
path = Path(doc["path"])
ext = str(doc["extension"]).lower()
if ext == "md":
if ext in EDITOR_TEXT_EXTENSIONS:
return path.read_text(encoding="utf-8", errors="replace")
raise ValueError(f"Text editing is not available for .{ext}.")
def write_text_document(file_id: str, content: str) -> dict[str, Any]:
doc = get_document(file_id)
ext = str(doc.get("extension") or "").lower()
if ext not in EDITOR_TEXT_EXTENSIONS:
raise ValueError(f"Editor text saves are not available for .{ext}.")
return replace_document_bytes(file_id, str(content or "").encode("utf-8"), actor=f"editor:{ext}")
def write_markdown(file_id: str, content: str) -> dict[str, Any]:
return replace_document_bytes(file_id, str(content or "").encode("utf-8"), actor="editor:markdown")
return write_text_document(file_id, content)
def save_text_document_as(
file_id: str,
path: str | Path,
content: str,
context_id: str = "",
) -> dict[str, Any]:
target = normalize_path(path, context_id=context_id)
ext = normalize_extension(target.suffix.lstrip("."))
if ext not in EDITOR_TEXT_EXTENSIONS:
raise ValueError("Editor Save As only supports Markdown (.md) and text (.txt) files.")
if target.exists():
raise FileExistsError(f"Target already exists: {display_path(target)}")
data = str(content or "").encode("utf-8")
if len(data) > MAX_SAVE_BYTES:
raise OverflowError("Document save exceeds maximum size")
with connect() as conn:
source = get_document(file_id, conn=conn)
source_ext = str(source.get("extension") or "").lower()
if source_ext not in EDITOR_TEXT_EXTENSIONS:
raise ValueError(f"Editor Save As is not available for .{source_ext}.")
changed_at = now()
target.parent.mkdir(parents=True, exist_ok=True)
_write_atomic(target, data)
digest = sha256_bytes(data)
stat = target.stat()
new_file_id = uuid.uuid4().hex
conn.execute(
"""
INSERT INTO documents
(file_id, path, basename, extension, owner_id, size, version, sha256, last_modified, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
new_file_id,
str(target),
target.name,
ext,
str(source.get("owner_id") or "a0"),
stat.st_size,
1,
digest,
now_iso(),
changed_at,
changed_at,
),
)
_record_version(conn, new_file_id, target, "1", data)
conn.execute(
"INSERT INTO events (file_id, event_type, payload, created_at) VALUES (?, ?, ?, ?)",
(
file_id,
"saved_as",
json.dumps({"from": display_path(source["path"]), "to": display_path(target)}),
changed_at,
),
)
conn.execute(
"INSERT INTO events (file_id, event_type, payload, created_at) VALUES (?, ?, ?, ?)",
(
new_file_id,
"created_from_save_as",
json.dumps({"from": display_path(source["path"])}),
changed_at,
),
)
return get_document(new_file_id, conn=conn)
def replace_document_bytes(
@ -623,7 +701,7 @@ def create_document(
def _unique_document_path(title: str, ext: str, context_id: str = "") -> Path:
base = safe_document_stem(title, ext, "Document")
root = document_home(context_id) if ext == "md" else document_binary_home(context_id)
root = document_home(context_id) if ext in EDITOR_TEXT_EXTENSIONS else document_binary_home(context_id)
candidate = root / f"{base}.{ext}"
index = 2
while candidate.exists():