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";