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,