Add Office document rename action

Add a pencil action beside Save that reuses the existing file browser rename modal for open Office documents. Preserve document metadata after filesystem renames, retarget active LibreOffice desktop sessions to the new path, and cover the rename flow in Office regression tests.

Alessandro committed May 2, 2026 at 19:31 UTC 27b3624a97288566d447d99ded3af8372498b17a
8 files changed +221 -2
plugins/_office/api/office_session.py
+23
@@ -51,6 +51,8 @@ class OfficeSession(ApiHandler):
51 return await self._open_document(doc, input, request)
52 if action == "save":
53 return self._save(input)
54 + if action == "renamed":
55 + return self._renamed(input, context_id)
56 if action == "desktop_save":
57 return self._desktop_save(input)
58 if action == "desktop_sync":
@@ -108,6 +110,27 @@ class OfficeSession(ApiHandler):
110 return {"ok": False, "error": "session_id is required."}
111 return markdown_sessions.get_manager().save(session_id, text=input.get("text"))
112
113 + def _renamed(self, input: dict, context_id: str = "") -> dict:
114 + file_id = str(input.get("file_id") or "").strip()
115 + path = str(input.get("path") or "").strip()
116 + if not file_id:
117 + return {"ok": False, "error": "file_id is required."}
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)
122 + except Exception as exc:
123 + return {"ok": False, "error": str(exc)}
124 + desktop = None
125 + if str(updated.get("extension") or "").lower() in libreoffice_desktop.OFFICIAL_EXTENSIONS:
126 + desktop = libreoffice_desktop.get_manager().retarget_document(file_id, updated)
127 + return {
128 + "ok": True,
129 + "document": _public_doc(updated),
130 + "version": document_store.item_version(updated),
131 + "desktop": desktop,
132 + }
133 +
134 def _desktop(self) -> dict:
135 desktop = libreoffice_desktop.get_manager().ensure_system_desktop()
136 if not desktop.get("available"):
plugins/_office/helpers/document_store.py
+35
@@ -268,6 +268,41 @@ def get_document(file_id: str, conn: sqlite3.Connection | None = None) -> dict[s
268 return _fetch(active)
269
270
271 +def update_document_path(file_id: str, path: str | Path, context_id: str = "") -> dict[str, Any]:
272 + resolved = normalize_path(path, context_id=context_id)
273 + if not resolved.exists():
274 + raise FileNotFoundError(str(resolved))
275 + ext = normalize_extension(resolved.suffix.lstrip("."))
276 + data = resolved.read_bytes()
277 + digest = sha256_bytes(data)
278 + stat = resolved.stat()
279 + changed_at = now()
280 +
281 + with connect() as conn:
282 + doc = get_document(file_id, conn=conn)
283 + row = conn.execute("SELECT file_id FROM documents WHERE path = ?", (str(resolved),)).fetchone()
284 + if row and row["file_id"] != file_id:
285 + raise ValueError(f"Document path is already registered: {display_path(resolved)}")
286 + conn.execute(
287 + """
288 + UPDATE documents
289 + SET path=?, basename=?, extension=?, size=?, sha256=?, last_modified=?, updated_at=?
290 + WHERE file_id=?
291 + """,
292 + (str(resolved), resolved.name, ext, stat.st_size, digest, now_iso(), changed_at, file_id),
293 + )
294 + conn.execute(
295 + "INSERT INTO events (file_id, event_type, payload, created_at) VALUES (?, ?, ?, ?)",
296 + (
297 + file_id,
298 + "renamed",
299 + json.dumps({"from": display_path(doc["path"]), "to": display_path(resolved)}),
300 + changed_at,
301 + ),
302 + )
303 + return get_document(file_id, conn=conn)
304 +
305 +
306 def get_open_documents(limit: int = 6) -> list[dict[str, Any]]:
307 with connect() as conn:
308 _clear_expired_sessions(conn)
plugins/_office/helpers/libreoffice_desktop.py
+11
@@ -185,6 +185,17 @@ class LibreOfficeDesktopManager:
185 updated = document_store.register_document(doc["path"])
186 return {"ok": True, "session_id": session.session_id, "document": _public_doc(updated)}
187
188 + def retarget_document(self, file_id: str, doc: dict[str, Any]) -> dict[str, Any]:
189 + session = self._find_by_file_id(file_id)
190 + if not session:
191 + return {"ok": True, "updated": False}
192 + with self._lock:
193 + session.path = str(doc["path"])
194 + session.title = str(doc["basename"])
195 + session.extension = str(doc["extension"])
196 + self._write_manifest(session)
197 + return {"ok": True, "updated": True, "desktop": session.public(doc)}
198 +
199 def close(self, session_id: str, save_first: bool = True) -> dict[str, Any]:
200 with self._lock:
201 normalized = str(session_id or "").strip()
plugins/_office/webui/office-panel.html
+3
@@ -38,6 +38,9 @@
38 <span class="office-toolbar-spacer"></span>
39
40 <div class="office-tool-group office-tool-actions" x-show="$store.office.session && !$store.office.isDesktopSession()" style="display: none;">
41 + <button type="button" class="office-icon-button" title="Rename" aria-label="Rename" :disabled="$store.office.saving" @click="$store.office.renameActiveFile()">
42 + <span class="material-symbols-outlined">edit</span>
43 + </button>
44 <button type="button" class="office-icon-button" title="Save" aria-label="Save" :class="{ 'is-primary': $store.office.dirty }" :disabled="$store.office.saving" @click="$store.office.save()">
45 <span class="material-symbols-outlined" :class="{ spinning: $store.office.saving }" x-text="$store.office.saving ? 'progress_activity' : 'save'"></span>
46 </button>
plugins/_office/webui/office-store.js
+74 -1
@@ -34,6 +34,13 @@ function extensionOf(path = "") {
34 return index >= 0 ? name.slice(index + 1) : "";
35 }
36
37 +function parentPath(path = "") {
38 + const normalized = String(path || "").split("?")[0].split("#")[0].replace(/\/+$/, "");
39 + const index = normalized.lastIndexOf("/");
40 + if (index <= 0) return "/";
41 + return normalized.slice(0, index);
42 +}
43 +
44 function uniqueTabId(session = {}) {
45 return String(session.file_id || session.session_id || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`);
46 }
@@ -508,10 +515,76 @@ const model = {
515 }
516 },
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 + }
524 +
525 + const session = this.session;
526 + const path = session.path || session.document?.path || "";
527 + if (!path) {
528 + this.error = "This document does not have a file path to rename.";
529 + return;
530 + }
531 + const name = basename(path || session.title || "");
532 + const extension = extensionOf(name);
533 + await fileBrowserStore.openRenameModal(
534 + {
535 + name,
536 + path,
537 + is_dir: false,
538 + size: session.document?.size || 0,
539 + modified: session.document?.last_modified || "",
540 + type: "document",
541 + },
542 + {
543 + currentPath: parentPath(path),
544 + validateName: (newName) => {
545 + if (!extension) return true;
546 + return extensionOf(newName) === extension || `Keep the .${extension} extension for this open document.`;
547 + },
548 + onRenamed: async ({ path: renamedPath }) => {
549 + await this.handleActiveFileRenamed(session, renamedPath);
550 + },
551 + },
552 + );
553 + },
554 +
555 + async handleActiveFileRenamed(session, renamedPath) {
556 + const response = await callOffice("renamed", {
557 + file_id: session.file_id || "",
558 + path: renamedPath,
559 + });
560 + if (response?.ok === false) throw new Error(response.error || "Rename failed.");
561 +
562 + const document = normalizeDocument(response.document || session.document || {});
563 + const updated = {
564 + ...session,
565 + document,
566 + title: document.title || document.basename || basename(document.path),
567 + path: document.path || renamedPath,
568 + extension: document.extension || session.extension,
569 + file_id: document.file_id || session.file_id,
570 + version: document.version || response.version || session.version,
571 + desktop: response.desktop?.desktop || session.desktop,
572 + dirty: false,
573 + };
574 + this.replaceSession(session, updated);
575 + this.dirty = false;
576 + this.setMessage("Renamed");
577 + await this.refresh();
578 + },
579 +
580 replaceActiveSession(next) {
581 if (!this.session) return;
582 + this.replaceSession(this.session, next);
583 + },
584 +
585 + replaceSession(previous, next) {
586 this.session = next;
514 - const index = this.tabs.findIndex((tab) => tab.tab_id === next.tab_id);
587 + const index = this.tabs.findIndex((tab) => tab.tab_id === (previous?.tab_id || next.tab_id));
588 if (index >= 0 && this.isVisibleOfficeTab(next)) this.tabs.splice(index, 1, next);
589 this.queueRender();
590 this.updateDesktopMonitor();
tests/test_office_canvas_setup.py
+14
@@ -34,8 +34,13 @@ def test_document_canvas_uses_markdown_editor_and_official_libreoffice_desktop_f
34 assert "format_align_center" not in panel
35 assert "is-native-tile" not in panel
36 assert "hasOfficialOffice()" in panel
37 + assert 'title="Rename"' in panel
38 + assert "@click=\"$store.office.renameActiveFile()\"" in panel
39 assert "office_save" in store
40 assert "desktop_save" in store
41 + assert "openRenameModal" in store
42 + assert 'callOffice("renamed"' in store
43 + assert "handleActiveFileRenamed" in store
44 assert "--office-zoom" not in panel
45 assert "zoom: 1" not in store
46 assert 'callOffice("desktop")' in store
@@ -137,6 +142,15 @@ def test_office_surface_filters_tabs_to_desktop_and_markdown_without_dashboard()
142 assert "isVisibleOfficeTab" in store
143 assert "return this.tabs.filter((tab) => this.isVisibleOfficeTab(tab));" in store
144
145 + file_browser_store = (
146 + PROJECT_ROOT / "webui" / "components" / "modals" / "file-browser" / "file-browser-store.js"
147 + ).read_text(encoding="utf-8")
148 +
149 + assert "renameAfterConfirm" in file_browser_store
150 + assert "renameValidateName" in file_browser_store
151 + assert "options.onRenamed" in file_browser_store
152 + assert "options.validateName" in file_browser_store
153 +
154
155 def test_right_canvas_surface_is_branded_as_desktop():
156 surface = (
tests/test_office_document_store.py
+14
@@ -263,6 +263,20 @@ def test_markdown_save_tracks_version_history(office_state):
263 assert Path(updated["path"]).read_text(encoding="utf-8").endswith("Second\n")
264
265
266 +def test_document_path_update_preserves_file_id_after_rename(office_state):
267 + doc = document_store.create_document("document", "Rename Me", "md", "Body")
268 + original = Path(doc["path"])
269 + renamed = original.with_name("Renamed.md")
270 + original.rename(renamed)
271 +
272 + updated = document_store.update_document_path(doc["file_id"], renamed)
273 +
274 + assert updated["file_id"] == doc["file_id"]
275 + assert updated["basename"] == "Renamed.md"
276 + assert updated["path"] == str(renamed)
277 + assert document_store.get_document(doc["file_id"])["path"] == str(renamed)
278 +
279 +
280 def test_direct_markdown_edits_refresh_open_canvas_session(office_state, monkeypatch):
281 manager = markdown_sessions.MarkdownSessionManager()
282 monkeypatch.setattr(markdown_sessions, "_manager", manager, raising=False)
webui/components/modals/file-browser/file-browser-store.js
+47 -1
@@ -23,6 +23,8 @@ const model = {
23 renameMode: "rename",
24 isRenaming: false,
25 renameError: null,
26 + renameAfterConfirm: null,
27 + renameValidateName: null,
28 openDropdownPath: null, // Track which dropdown is currently open
29
30 // --- Lifecycle -----------------------------------------------------------
@@ -148,12 +150,26 @@ const model = {
150 return `${trimmedBase}/${name}`;
151 },
152
153 + parentPath(path) {
154 + const normalized = this.normalizePath(String(path || "")).replace(/\/+$/, "");
155 + const index = normalized.lastIndexOf("/");
156 + if (index <= 0) return "/";
157 + return normalized.slice(0, index);
158 + },
159 +
160 + siblingPath(path, name) {
161 + const parent = this.parentPath(path);
162 + return parent === "/" ? `/${name}` : `${parent}/${name}`;
163 + },
164 +
165 resetRenameState() {
166 this.renameTarget = null;
167 this.renameName = "";
168 this.renameMode = "rename";
169 this.isRenaming = false;
170 this.renameError = null;
171 + this.renameAfterConfirm = null;
172 + this.renameValidateName = null;
173 },
174
175 // --- Sorting -------------------------------------------------------------
@@ -258,12 +274,20 @@ const model = {
274 },
275
276 // --- Rename / Create -----------------------------------------------------
261 - async openRenameModal(file) {
277 + async openRenameModal(file, options = {}) {
278 this.resetRenameState();
279 this.renameTarget = file;
280 this.renameName = file?.name || "";
281 this.renameMode = "rename";
282 this.renameError = null;
283 + this.renameAfterConfirm = typeof options.onRenamed === "function" ? options.onRenamed : null;
284 + this.renameValidateName = typeof options.validateName === "function" ? options.validateName : null;
285 + if (typeof options.currentPath === "string" && options.currentPath) {
286 + this.browser.currentPath = options.currentPath;
287 + }
288 + if (Array.isArray(options.entries)) {
289 + this.browser.entries = options.entries;
290 + }
291 window.openModal("modals/file-browser/rename-modal.html");
292 },
293
@@ -299,6 +323,13 @@ const model = {
323 this.renameError = "No item selected for rename.";
324 return;
325 }
326 + if (this.renameValidateName) {
327 + const validation = this.renameValidateName(newName, this.renameTarget);
328 + if (validation !== true) {
329 + this.renameError = typeof validation === "string" ? validation : "Name is not valid.";
330 + return;
331 + }
332 + }
333
334 // UX: pre-validate duplicates so we can show a clean inline error (no toast spam)
335 const duplicate = (this.browser.entries || []).some((entry) => {
@@ -317,6 +348,11 @@ const model = {
348 this.renameError = null;
349
350 try {
351 + const previousPath = this.renameTarget?.path || "";
352 + const renamedPath =
353 + this.renameMode === "create-folder"
354 + ? this.buildChildPath(newName)
355 + : this.siblingPath(previousPath, newName);
356 const payload =
357 this.renameMode === "create-folder"
358 ? {
@@ -344,6 +380,16 @@ const model = {
380 }
381
382 await this.fetchFiles(this.browser.currentPath);
383 + if (this.renameAfterConfirm) {
384 + await this.renameAfterConfirm({
385 + action: this.renameMode,
386 + previousPath,
387 + path: renamedPath,
388 + name: newName,
389 + target: this.renameTarget,
390 + response: data,
391 + });
392 + }
393 this.closeRenameModal();
394 } catch (error) {
395 const message = error?.message || "Rename failed";