mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-07-09 17:08:29 +00:00
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:
parent
6ccd48e81f
commit
bd584da2f4
21 changed files with 967 additions and 108 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue