Fix canvas markdown rename before save

Route Office canvas renames through the document store so dirty or missing-on-disk Markdown sessions can be materialized at the new path without hitting the generic workdir filesystem rename endpoint. Add regression coverage for missing draft materialization, dirty markdown rename, and the custom rename hook contract.

Alessandro committed May 2, 2026 at 20:51 UTC dd696732c8a48728337e9f5982004feeffd7c2aa
6 files changed +181 -18
plugins/_office/api/office_session.py
+7 -1
@@ -118,7 +118,12 @@ class OfficeSession(ApiHandler):
118 if not path:
119 return {"ok": False, "error": "path is required."}
120 try:
121 - updated = document_store.update_document_path(file_id, path, context_id=context_id)
121 + updated = document_store.rename_document(
122 + file_id,
123 + path,
124 + content=input.get("text") if "text" in input else None,
125 + context_id=context_id,
126 + )
127 except Exception as exc:
128 return {"ok": False, "error": str(exc)}
129 desktop = None
@@ -129,6 +134,7 @@ class OfficeSession(ApiHandler):
134 "document": _public_doc(updated),
135 "version": document_store.item_version(updated),
136 "desktop": desktop,
137 + "refreshFiles": False,
138 }
139
140 def _desktop(self) -> dict:
plugins/_office/helpers/document_store.py
+91
@@ -303,6 +303,97 @@ def update_document_path(file_id: str, path: str | Path, context_id: str = "") -
303 return get_document(file_id, conn=conn)
304
305
306 +def rename_document(
307 + file_id: str,
308 + path: str | Path,
309 + content: str | None = None,
310 + context_id: str = "",
311 +) -> dict[str, Any]:
312 + resolved = normalize_path(path, context_id=context_id)
313 + ext = normalize_extension(resolved.suffix.lstrip("."))
314 + data = None
315 + if content is not None:
316 + if ext != "md":
317 + raise ValueError("Inline content can only be provided for Markdown documents.")
318 + data = str(content or "").encode("utf-8")
319 + if len(data) > MAX_SAVE_BYTES:
320 + raise OverflowError("Document save exceeds maximum size")
321 +
322 + changed_at = now()
323 + with connect() as conn:
324 + doc = get_document(file_id, conn=conn)
325 + source = Path(doc["path"])
326 + source_resolved = source.resolve(strict=False)
327 + changed_path = str(source_resolved) != str(resolved)
328 + source_exists = source.exists()
329 +
330 + if ext != str(doc["extension"]).lower():
331 + raise ValueError("Document extension cannot change during rename.")
332 +
333 + row = conn.execute("SELECT file_id FROM documents WHERE path = ?", (str(resolved),)).fetchone()
334 + if row and row["file_id"] != file_id:
335 + raise ValueError(f"Document path is already registered: {display_path(resolved)}")
336 + if changed_path and resolved.exists():
337 + raise FileExistsError(f"Target already exists: {display_path(resolved)}")
338 + if not source_exists and data is None:
339 + raise FileNotFoundError(str(source_resolved))
340 +
341 + previous = source.read_bytes() if source_exists else b""
342 + content_changed = data is not None and data != previous
343 +
344 + if changed_path and data is None:
345 + resolved.parent.mkdir(parents=True, exist_ok=True)
346 + source.rename(resolved)
347 + final_data = resolved.read_bytes()
348 + elif data is not None:
349 + if content_changed:
350 + _record_version(conn, file_id, source_resolved, item_version(doc), previous)
351 + _write_atomic(resolved, data)
352 + if changed_path and source_exists:
353 + source.unlink(missing_ok=True)
354 + final_data = data
355 + else:
356 + final_data = previous
357 +
358 + stat = resolved.stat()
359 + next_version = int(doc["version"]) + 1 if content_changed else int(doc["version"])
360 + conn.execute(
361 + """
362 + UPDATE documents
363 + SET path=?, basename=?, extension=?, size=?, version=?, sha256=?, last_modified=?, updated_at=?
364 + WHERE file_id=?
365 + """,
366 + (
367 + str(resolved),
368 + resolved.name,
369 + ext,
370 + stat.st_size,
371 + next_version,
372 + sha256_bytes(final_data),
373 + now_iso(),
374 + changed_at,
375 + file_id,
376 + ),
377 + )
378 + conn.execute(
379 + "INSERT INTO events (file_id, event_type, payload, created_at) VALUES (?, ?, ?, ?)",
380 + (
381 + file_id,
382 + "renamed",
383 + json.dumps(
384 + {
385 + "from": display_path(source_resolved),
386 + "to": display_path(resolved),
387 + "saved": content_changed,
388 + "materialized": not source_exists,
389 + }
390 + ),
391 + changed_at,
392 + ),
393 + )
394 + return get_document(file_id, conn=conn)
395 +
396 +
397 def get_open_documents(limit: int = 6) -> list[dict[str, Any]]:
398 with connect() as conn:
399 _clear_expired_sessions(conn)
plugins/_office/webui/office-store.js
+16 -8
@@ -517,10 +517,6 @@ const model = {
517
518 async renameActiveFile() {
519 if (!this.session || this.isDesktopSession() || this.saving) return;
520 - if (this.dirty || this.session.dirty) {
521 - await this.save();
522 - if (this.error) return;
523 - }
520
521 const session = this.session;
522 const path = session.path || session.document?.path || "";
@@ -545,15 +541,26 @@ const model = {
541 if (!extension) return true;
542 return extensionOf(newName) === extension || `Keep the .${extension} extension for this open document.`;
543 },
548 - onRenamed: async ({ path: renamedPath }) => {
549 - await this.handleActiveFileRenamed(session, renamedPath);
544 + performRename: async ({ path: renamedPath }) => {
545 + const payload = {
546 + file_id: session.file_id || "",
547 + path: renamedPath,
548 + };
549 + if (this.isMarkdown(session)) {
550 + this.syncEditorText();
551 + payload.text = this.session?.tab_id === session.tab_id ? this.editorText : session.text || "";
552 + }
553 + return await callOffice("renamed", payload);
554 + },
555 + onRenamed: async ({ path: renamedPath, response }) => {
556 + await this.handleActiveFileRenamed(session, renamedPath, response);
557 },
558 },
559 );
560 },
561
555 - async handleActiveFileRenamed(session, renamedPath) {
556 - const response = await callOffice("renamed", {
562 + async handleActiveFileRenamed(session, renamedPath, renameResponse = null) {
563 + const response = renameResponse || await callOffice("renamed", {
564 file_id: session.file_id || "",
565 path: renamedPath,
566 });
@@ -569,6 +576,7 @@ const model = {
576 file_id: document.file_id || session.file_id,
577 version: document.version || response.version || session.version,
578 desktop: response.desktop?.desktop || session.desktop,
579 + text: this.session?.tab_id === session.tab_id ? this.editorText : session.text,
580 dirty: false,
581 };
582 this.replaceSession(session, updated);
tests/test_office_canvas_setup.py
+4
@@ -40,6 +40,8 @@ def test_document_canvas_uses_markdown_editor_and_official_libreoffice_desktop_f
40 assert "desktop_save" in store
41 assert "openRenameModal" in store
42 assert 'callOffice("renamed"' in store
43 + assert "performRename" in store
44 + assert "payload.text" in store
45 assert "handleActiveFileRenamed" in store
46 assert "--office-zoom" not in panel
47 assert "zoom: 1" not in store
@@ -147,8 +149,10 @@ def test_office_surface_filters_tabs_to_desktop_and_markdown_without_dashboard()
149 ).read_text(encoding="utf-8")
150
151 assert "renameAfterConfirm" in file_browser_store
152 + assert "renamePerformAction" in file_browser_store
153 assert "renameValidateName" in file_browser_store
154 assert "options.onRenamed" in file_browser_store
155 + assert "options.performRename" in file_browser_store
156 assert "options.validateName" in file_browser_store
157
158
tests/test_office_document_store.py
+34
@@ -277,6 +277,40 @@ def test_document_path_update_preserves_file_id_after_rename(office_state):
277 assert document_store.get_document(doc["file_id"])["path"] == str(renamed)
278
279
280 +def test_document_rename_materializes_missing_markdown_with_editor_text(office_state):
281 + doc = document_store.create_document("document", "Unsaved Draft", "md", "Seed")
282 + original = Path(doc["path"])
283 + original.unlink()
284 + renamed = original.with_name("Renamed Draft.md")
285 +
286 + updated = document_store.rename_document(
287 + doc["file_id"],
288 + renamed,
289 + content="# Renamed Draft\n\nCanvas text",
290 + )
291 +
292 + assert updated["file_id"] == doc["file_id"]
293 + assert updated["basename"] == "Renamed Draft.md"
294 + assert updated["path"] == str(renamed)
295 + assert renamed.read_text(encoding="utf-8") == "# Renamed Draft\n\nCanvas text"
296 +
297 +
298 +def test_document_rename_saves_dirty_markdown_and_removes_original(office_state):
299 + doc = document_store.create_document("document", "Dirty Rename", "md", "Old")
300 + original = Path(doc["path"])
301 + renamed = original.with_name("Clean Rename.md")
302 +
303 + updated = document_store.rename_document(
304 + doc["file_id"],
305 + renamed,
306 + content="# Clean Rename\n\nFresh text",
307 + )
308 +
309 + assert updated["version"] == 2
310 + assert not original.exists()
311 + assert renamed.read_text(encoding="utf-8") == "# Clean Rename\n\nFresh text"
312 +
313 +
314 def test_direct_markdown_edits_refresh_open_canvas_session(office_state, monkeypatch):
315 manager = markdown_sessions.MarkdownSessionManager()
316 monkeypatch.setattr(markdown_sessions, "_manager", manager, raising=False)
webui/components/modals/file-browser/file-browser-store.js
+29 -9
@@ -24,6 +24,7 @@ const model = {
24 isRenaming: false,
25 renameError: null,
26 renameAfterConfirm: null,
27 + renamePerformAction: null,
28 renameValidateName: null,
29 openDropdownPath: null, // Track which dropdown is currently open
30 searchQuery: "",
@@ -245,6 +246,7 @@ const model = {
246 this.isRenaming = false;
247 this.renameError = null;
248 this.renameAfterConfirm = null;
249 + this.renamePerformAction = null;
250 this.renameValidateName = null;
251 },
252
@@ -364,6 +366,7 @@ const model = {
366 this.renameMode = "rename";
367 this.renameError = null;
368 this.renameAfterConfirm = typeof options.onRenamed === "function" ? options.onRenamed : null;
369 + this.renamePerformAction = typeof options.performRename === "function" ? options.performRename : null;
370 this.renameValidateName = typeof options.validateName === "function" ? options.validateName : null;
371 if (typeof options.currentPath === "string" && options.currentPath) {
372 this.browser.currentPath = options.currentPath;
@@ -451,18 +454,35 @@ const model = {
454 newName: newName,
455 };
456
454 - const resp = await fetchApi("/rename_work_dir_file", {
455 - method: "POST",
456 - headers: { "Content-Type": "application/json" },
457 - body: JSON.stringify(payload),
458 - });
457 + let data = {};
458 + if (this.renamePerformAction) {
459 + data = await this.renamePerformAction({
460 + action: this.renameMode,
461 + previousPath,
462 + path: renamedPath,
463 + name: newName,
464 + target: this.renameTarget,
465 + payload,
466 + }) || {};
467 + if (data.error || data.ok === false) {
468 + throw new Error(data.error || "Rename failed");
469 + }
470 + } else {
471 + const resp = await fetchApi("/rename_work_dir_file", {
472 + method: "POST",
473 + headers: { "Content-Type": "application/json" },
474 + body: JSON.stringify(payload),
475 + });
476
460 - const data = await resp.json().catch(() => ({}));
461 - if (!resp.ok || data.error) {
462 - throw new Error(data.error || "Rename failed");
477 + data = await resp.json().catch(() => ({}));
478 + if (!resp.ok || data.error) {
479 + throw new Error(data.error || "Rename failed");
480 + }
481 }
482
465 - await this.fetchFiles(this.browser.currentPath);
483 + if (!this.renamePerformAction || data.refreshFiles !== false) {
484 + await this.fetchFiles(this.browser.currentPath);
485 + }
486 if (this.renameAfterConfirm) {
487 await this.renameAfterConfirm({
488 action: this.renameMode,