Add Office file tabs and session closing

Track open Office sessions as tabs so Docs, Sheets, and Slides can switch between files without losing the active editor context. Add backend support to list and close WOPI sessions, revoking tokens and locks when a tab closes. Show open-file metadata in the Office start view and keep the mobile canvas rail reachable after closing the canvas.

Alessandro committed Apr 28, 2026 at 03:24 UTC 67bfd3e35093d6aea08ac6b4a3d7f31381e10ce9
6 files changed +558 -31
plugins/_office/api/office_session.py
+15 -1
@@ -24,6 +24,14 @@ class OfficeSession(ApiHandler):
24 return {"ok": True, **collabora_status.read_status()}
25 if action == "recent":
26 return {"ok": True, "documents": wopi_store.get_recent_documents()}
27 + if action == "open_documents":
28 + return {"ok": True, "documents": wopi_store.get_open_documents(limit=24)}
29 + if action == "close":
30 + closed = wopi_store.close_session(
31 + session_id=str(input.get("session_id") or ""),
32 + file_id=str(input.get("file_id") or ""),
33 + )
34 + return {"ok": True, "closed": closed, "documents": wopi_store.get_open_documents(limit=24)}
35 if action == "create":
36 doc = wopi_store.create_document(
37 kind=str(input.get("kind") or "document"),
@@ -34,7 +42,12 @@ class OfficeSession(ApiHandler):
42 )
43 return await self._open_document(doc, input, request)
44 if action == "open":
37 - doc = wopi_store.register_document(str(input.get("path") or ""))
45 + file_id = str(input.get("file_id") or "").strip()
46 + doc = (
47 + wopi_store.get_document(file_id)
48 + if file_id
49 + else wopi_store.register_document(str(input.get("path") or ""))
50 + )
51 return await self._open_document(doc, input, request)
52 return {"ok": False, "error": f"Unsupported office session action: {action}"}
53
@@ -74,6 +87,7 @@ class OfficeSession(ApiHandler):
87 return {
88 "ok": True,
89 "file_id": doc["file_id"],
90 + "session_id": session["session_id"],
91 "iframe_action": iframe_action,
92 "access_token": session["access_token"],
93 "access_token_ttl": session["access_token_ttl"],
plugins/_office/helpers/wopi_store.py
+40
@@ -221,6 +221,7 @@ def get_recent_documents(limit: int = 12) -> list[dict[str, Any]]:
221
222 def get_open_documents(limit: int = 6) -> list[dict[str, Any]]:
223 with connect() as conn:
224 + _clear_expired_sessions(conn)
225 rows = conn.execute(
226 """
227 SELECT
@@ -240,6 +241,38 @@ def get_open_documents(limit: int = 6) -> list[dict[str, Any]]:
241 return [dict(row) for row in rows]
242
243
244 +def close_session(session_id: str = "", file_id: str = "") -> int:
245 + session_id = str(session_id or "").strip()
246 + file_id = str(file_id or "").strip()
247 + if not session_id and not file_id:
248 + return 0
249 +
250 + with connect() as conn:
251 + _clear_expired_sessions(conn)
252 + if session_id:
253 + row = conn.execute("SELECT * FROM sessions WHERE session_id = ?", (session_id,)).fetchone()
254 + if not row:
255 + return 0
256 + conn.execute("DELETE FROM tokens WHERE session_id = ?", (session_id,))
257 + conn.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,))
258 + conn.execute("DELETE FROM locks WHERE session_id = ?", (session_id,))
259 + conn.execute(
260 + "INSERT INTO events (file_id, event_type, payload, created_at) VALUES (?, ?, ?, ?)",
261 + (row["file_id"], "close_session", json.dumps({"session_id": session_id}), now()),
262 + )
263 + return 1
264 +
265 + rows = conn.execute("SELECT session_id FROM sessions WHERE file_id = ?", (file_id,)).fetchall()
266 + conn.execute("DELETE FROM tokens WHERE file_id = ?", (file_id,))
267 + conn.execute("DELETE FROM sessions WHERE file_id = ?", (file_id,))
268 + conn.execute("DELETE FROM locks WHERE file_id = ?", (file_id,))
269 + conn.execute(
270 + "INSERT INTO events (file_id, event_type, payload, created_at) VALUES (?, ?, ?, ?)",
271 + (file_id, "close_document_sessions", json.dumps({"closed": len(rows)}), now()),
272 + )
273 + return len(rows)
274 +
275 +
276 def create_session(file_id: str, user_id: str, permission: str, origin: str, ttl_seconds: int = DEFAULT_TTL_SECONDS) -> dict[str, Any]:
277 permission = "write" if permission == "write" else "read"
278 token = secrets.token_urlsafe(32)
@@ -493,6 +526,13 @@ def _clear_expired_locks(conn: sqlite3.Connection) -> None:
526 conn.execute("DELETE FROM locks WHERE expires_at < ?", (now(),))
527
528
529 +def _clear_expired_sessions(conn: sqlite3.Connection) -> None:
530 + current = now()
531 + conn.execute("DELETE FROM tokens WHERE expires_at < ?", (current,))
532 + conn.execute("DELETE FROM sessions WHERE expires_at < ?", (current,))
533 + conn.execute("DELETE FROM locks WHERE expires_at < ?", (current,))
534 +
535 +
536 def _record_version(conn: sqlite3.Connection, file_id: str, path: Path, version: str, data: bytes) -> None:
537 if not data:
538 return
plugins/_office/webui/office-panel.html
+224 -3
@@ -38,11 +38,49 @@
38 <button type="button" class="office-icon-button" title="Save" @click="$store.office.save()" :disabled="!$store.office.session">
39 <span class="material-symbols-outlined">save</span>
40 </button>
41 + <button
42 + type="button"
43 + class="office-icon-button"
44 + title="Close file"
45 + aria-label="Close file"
46 + :disabled="!$store.office.session"
47 + @click="$confirmClick($event, () => $store.office.closeFile())"
48 + >
49 + <span class="material-symbols-outlined">close</span>
50 + </button>
51 <button type="button" class="office-icon-button" title="Refresh status" @click="$store.office.refresh()">
52 <span class="material-symbols-outlined">refresh</span>
53 </button>
54 </div>
55
56 + <div class="office-tabs" x-show="$store.office.tabs.length" role="tablist" aria-label="Open Office files" style="display: none;">
57 + <template x-for="tab in $store.office.tabs" :key="tab.tab_id">
58 + <div class="office-tab-shell" :class="{ 'is-active': $store.office.isActiveTab(tab) }">
59 + <button
60 + type="button"
61 + class="office-tab"
62 + role="tab"
63 + :aria-selected="$store.office.isActiveTab(tab).toString()"
64 + :title="$store.office.tabLabel(tab)"
65 + @click="$store.office.selectTab(tab.tab_id)"
66 + >
67 + <span class="material-symbols-outlined office-tab-icon" aria-hidden="true" x-text="$store.office.tabIcon(tab)"></span>
68 + <span class="office-tab-title" x-text="$store.office.tabTitle(tab)"></span>
69 + </button>
70 + <button
71 + type="button"
72 + class="office-tab-close"
73 + :title="'Close ' + $store.office.tabLabel(tab)"
74 + :aria-label="'Close ' + $store.office.tabLabel(tab)"
75 + :disabled="$store.office.loading"
76 + @click.stop="$confirmClick($event, () => $store.office.closeTab(tab.tab_id))"
77 + >
78 + <span class="material-symbols-outlined">close</span>
79 + </button>
80 + </div>
81 + </template>
82 + </div>
83 +
84 <div class="office-status-line" x-show="$store.office.message || $store.office.error || $store.office.loading" style="display: none;">
85 <span class="material-symbols-outlined" :class="{ spinning: $store.office.loading }" x-text="$store.office.loading ? 'progress_activity' : ($store.office.error ? 'error' : 'check_circle')"></span>
86 <span x-text="$store.office.error || $store.office.message || 'Working...'"></span>
@@ -85,11 +123,27 @@
123 <span>Presentation</span>
124 </button>
125 </div>
126 + <div class="office-recent" x-show="$store.office.openDocuments.length">
127 + <div class="office-list-label">Open files</div>
128 + <template x-for="doc in $store.office.openDocuments" :key="doc.file_id">
129 + <button type="button" class="office-recent-row" :title="doc.path" @click="$store.office.openPath(doc.path)">
130 + <span class="material-symbols-outlined" x-text="$store.office.tabIcon(doc)"></span>
131 + <span class="office-recent-text">
132 + <span x-text="$store.office.openDocumentLabel(doc)"></span>
133 + <small x-text="$store.office.openDocumentMeta(doc)"></small>
134 + </span>
135 + </button>
136 + </template>
137 + </div>
138 <div class="office-recent" x-show="$store.office.recent.length">
139 + <div class="office-list-label">Recent files</div>
140 <template x-for="doc in $store.office.recent" :key="doc.file_id">
141 <button type="button" class="office-recent-row" :title="doc.path" @click="$store.office.openPath(doc.path)">
91 - <span class="material-symbols-outlined">description</span>
92 - <span x-text="doc.basename"></span>
142 + <span class="material-symbols-outlined" x-text="$store.office.tabIcon(doc)"></span>
143 + <span class="office-recent-text">
144 + <span x-text="doc.basename"></span>
145 + <small x-text="String(doc.extension || '').toUpperCase()"></small>
146 + </span>
147 </button>
148 </template>
149 </div>
@@ -140,6 +194,14 @@
194 background: color-mix(in srgb, var(--color-background) 94%, #000 6%);
195 }
196
197 + .modal.modal-floating {
198 + pointer-events: none;
199 + }
200 +
201 + .modal.modal-floating .modal-inner {
202 + pointer-events: auto;
203 + }
204 +
205 .modal-inner.office-modal .modal-header {
206 min-height: 34px;
207 padding: 0.35rem 0.75rem 0.35rem 1rem;
@@ -200,6 +262,136 @@
262 overflow-x: auto;
263 }
264
265 + .office-tabs {
266 + --office-tab-height: 34px;
267 + --office-tab-close-size: 27px;
268 + display: flex;
269 + align-items: end;
270 + gap: 4px;
271 + min-height: 39px;
272 + min-width: 0;
273 + padding: 5px 9px 0;
274 + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 58%, transparent);
275 + background: color-mix(in srgb, var(--color-background) 92%, #000 8%);
276 + overflow-x: auto;
277 + overflow-y: hidden;
278 + scrollbar-width: thin;
279 + }
280 +
281 + .office-tabs::-webkit-scrollbar {
282 + height: 4px;
283 + }
284 +
285 + .office-tabs::-webkit-scrollbar-track {
286 + background: transparent;
287 + }
288 +
289 + .office-tabs::-webkit-scrollbar-thumb {
290 + background: color-mix(in srgb, var(--color-border) 76%, transparent);
291 + border-radius: 999px;
292 + }
293 +
294 + .office-tab-shell {
295 + flex: 0 1 220px;
296 + position: relative;
297 + display: grid;
298 + grid-template-columns: minmax(0, 1fr) var(--office-tab-close-size);
299 + align-items: center;
300 + gap: 3px;
301 + min-width: 132px;
302 + max-width: 260px;
303 + height: var(--office-tab-height);
304 + padding: 0 6px 0 10px;
305 + border: 1px solid transparent;
306 + border-radius: 7px 7px 0 0;
307 + opacity: 0.72;
308 + transition: border-color 0.16s ease, opacity 0.16s ease, background-color 0.16s ease;
309 + }
310 +
311 + .office-tab-shell:hover,
312 + .office-tab-shell:focus-within {
313 + opacity: 0.94;
314 + border-color: color-mix(in srgb, var(--color-border) 78%, transparent);
315 + }
316 +
317 + .office-tab-shell.is-active {
318 + z-index: 2;
319 + margin-bottom: -1px;
320 + opacity: 1;
321 + border-color: color-mix(in srgb, var(--color-border) 68%, transparent);
322 + background: color-mix(in srgb, var(--color-panel) 72%, transparent);
323 + }
324 +
325 + .office-tab,
326 + .office-tab-close {
327 + appearance: none;
328 + border: 0;
329 + background: transparent;
330 + color: inherit;
331 + font: inherit;
332 + cursor: pointer;
333 + }
334 +
335 + .office-tab {
336 + display: inline-flex;
337 + align-items: center;
338 + justify-content: flex-start;
339 + gap: 8px;
340 + min-width: 0;
341 + width: 100%;
342 + height: 100%;
343 + padding: 0;
344 + text-align: left;
345 + }
346 +
347 + .office-tab-icon {
348 + flex: 0 0 auto;
349 + color: color-mix(in srgb, var(--color-text) 72%, var(--color-primary) 28%);
350 + font-size: 18px;
351 + line-height: 1;
352 + }
353 +
354 + .office-tab-title {
355 + min-width: 0;
356 + overflow: hidden;
357 + text-overflow: ellipsis;
358 + white-space: nowrap;
359 + font-size: 0.84rem;
360 + font-weight: 650;
361 + }
362 +
363 + .office-tab-close {
364 + display: inline-flex;
365 + align-items: center;
366 + justify-content: center;
367 + width: var(--office-tab-close-size);
368 + min-width: var(--office-tab-close-size);
369 + height: var(--office-tab-close-size);
370 + min-height: var(--office-tab-close-size);
371 + padding: 0;
372 + border-radius: 6px;
373 + color: color-mix(in srgb, var(--color-text) 52%, var(--color-primary) 48%);
374 + opacity: 0.74;
375 + }
376 +
377 + .office-tab-close:hover,
378 + .office-tab-close.confirming {
379 + opacity: 1;
380 + background: color-mix(in srgb, var(--color-background-hover) 70%, transparent);
381 + color: var(--color-text);
382 + }
383 +
384 + .office-tab-close:focus-visible,
385 + .office-tab:focus-visible {
386 + outline: 1px solid color-mix(in srgb, var(--color-primary) 70%, transparent);
387 + outline-offset: 1px;
388 + }
389 +
390 + .office-tab-close .material-symbols-outlined {
391 + font-size: 15px;
392 + line-height: 1;
393 + }
394 +
395 .office-toolbar-spacer {
396 flex: 1 1 auto;
397 min-width: 8px;
@@ -392,6 +584,14 @@
584 max-width: 720px;
585 }
586
587 + .office-list-label {
588 + margin-top: 2px;
589 + color: var(--color-text-muted);
590 + font-size: 0.76rem;
591 + font-weight: 650;
592 + text-transform: uppercase;
593 + }
594 +
595 .office-recent-row {
596 justify-content: flex-start;
597 min-height: 36px;
@@ -399,12 +599,25 @@
599 text-align: left;
600 }
601
402 - .office-recent-row span:last-child {
602 + .office-recent-text {
603 + display: grid;
604 + min-width: 0;
605 + gap: 1px;
606 + line-height: 1.2;
607 + }
608 +
609 + .office-recent-text > span,
610 + .office-recent-text > small {
611 overflow: hidden;
612 text-overflow: ellipsis;
613 white-space: nowrap;
614 }
615
616 + .office-recent-text > small {
617 + color: var(--color-text-muted);
618 + font-size: 0.72rem;
619 + }
620 +
621 .office-frame-wrap {
622 display: flex;
623 position: absolute;
@@ -443,6 +656,10 @@
656 .office-health-pill span:last-child {
657 display: none;
658 }
659 + .office-tab-shell {
660 + flex-basis: 152px;
661 + min-width: 116px;
662 + }
663 .office-create-tile {
664 min-width: 104px;
665 }
@@ -455,6 +672,10 @@
672 .office-health-pill span:last-child {
673 display: none;
674 }
675 + .office-tab-shell {
676 + flex-basis: 152px;
677 + min-width: 116px;
678 + }
679 .office-create-tile {
680 min-width: 104px;
681 }
plugins/_office/webui/office-store.js
+255 -27
@@ -23,10 +23,39 @@ function parseMessage(data) {
23 return data && typeof data === "object" ? data : {};
24 }
25
26 +function nextAnimationFrame() {
27 + return new Promise((resolve) => {
28 + const schedule = globalThis.requestAnimationFrame || ((callback) => globalThis.setTimeout(callback, 16));
29 + schedule(() => resolve());
30 + });
31 +}
32 +
33 +function normalizeTabId(value) {
34 + return String(value || "").trim();
35 +}
36 +
37 +function makeTabId(session) {
38 + return normalizeTabId(session?.session_id)
39 + || normalizeTabId(session?.file_id)
40 + || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
41 +}
42 +
43 +function sameDocument(left = {}, right = {}) {
44 + const leftFileId = normalizeTabId(left.file_id);
45 + const rightFileId = normalizeTabId(right.file_id);
46 + if (leftFileId && rightFileId) return leftFileId === rightFileId;
47 + const leftPath = String(left.path || "").trim();
48 + const rightPath = String(right.path || "").trim();
49 + return Boolean(leftPath && rightPath && leftPath === rightPath);
50 +}
51 +
52 const model = {
53 status: null,
54 logs: null,
55 recent: [],
56 + openDocuments: [],
57 + tabs: [],
58 + activeTabId: "",
59 session: null,
60 loading: false,
61 error: "",
@@ -42,6 +71,7 @@ const model = {
71 _frameOrigin: "",
72 _mode: "canvas",
73 _floatingCleanup: null,
74 + _saveWaiters: [],
75
76 async init(element = null) {
77 return await this.onMount(element, { mode: "canvas" });
@@ -62,6 +92,7 @@ const model = {
92 this.setupCanvasSurface(element);
93 }
94 await this.refresh();
95 + this.ensureActiveTab();
96 if (this.session && this._root) {
97 await this.restartFrameLoad();
98 }
@@ -69,8 +100,13 @@ const model = {
100
101 async onOpen(payload = {}) {
102 await this.refresh();
72 - if (payload?.path) {
73 - await this.openPath(payload.path);
103 + if (payload?.path || payload?.file_id) {
104 + await this.openSession({
105 + action: "open",
106 + path: payload.path || "",
107 + file_id: payload.file_id || "",
108 + mode: "edit",
109 + });
110 } else if (this.session && !this.frameReady) {
111 await this.restartFrameLoad();
112 }
@@ -89,6 +125,8 @@ const model = {
125 this.status = await callJsonApi("/plugins/_office/office_session", { action: "status" });
126 const recent = await callJsonApi("/plugins/_office/office_session", { action: "recent" });
127 this.recent = recent?.documents || [];
128 + const openDocuments = await callJsonApi("/plugins/_office/office_session", { action: "open_documents" });
129 + this.openDocuments = openDocuments?.documents || [];
130 if (!this.status?.healthy) {
131 const logs = await callJsonApi("/plugins/_office/collabora_logs", {});
132 this.logs = logs;
@@ -134,6 +172,7 @@ const model = {
172 this.error = "";
173 this.message = "";
174 try {
175 + await this.save({ wait: true, timeoutMs: 900 });
176 await this.prepareBrowserHostForEditor();
177 const response = await callJsonApi("/plugins/_office/office_session", payload);
178 if (!response?.ok) {
@@ -141,14 +180,7 @@ const model = {
180 if (response?.status) this.status = response.status;
181 return;
182 }
144 - this.clearFrameTimers();
145 - this.session = response;
146 - this.frameReady = false;
147 - this._frameOrigin = "";
148 - this._frameAttempt = 0;
149 - this._frameRecoveryTried = false;
150 - await this.submitFrame();
151 - this.scheduleFrameWatch();
183 + await this.activateSession(response);
184 await this.refresh();
185 } catch (error) {
186 this.error = error instanceof Error ? error.message : String(error);
@@ -157,8 +189,32 @@ const model = {
189 }
190 },
191
192 + async activateSession(response) {
193 + const tab = this.normalizeTab(response);
194 + const existingIndex = this.findTabIndexForSession(tab);
195 + if (existingIndex >= 0) {
196 + const previous = this.tabs[existingIndex];
197 + if (previous?.session_id && previous.session_id !== tab.session_id) {
198 + await this.closeBackendSession(previous);
199 + }
200 + this.tabs.splice(existingIndex, 1, tab);
201 + } else {
202 + this.tabs.push(tab);
203 + }
204 + this.activeTabId = tab.tab_id;
205 + this.syncActiveSession();
206 + this.frameReady = false;
207 + this._frameOrigin = "";
208 + this._frameAttempt = 0;
209 + this._frameRecoveryTried = false;
210 + this.clearFrameTimers();
211 + await this.submitFrame();
212 + this.scheduleFrameWatch();
213 + },
214 +
215 async submitFrame() {
161 - await new Promise((resolve) => requestAnimationFrame(resolve));
216 + await nextAnimationFrame();
217 + this.syncActiveSession();
218 const session = this.session;
219 const frame = this.activeFrame();
220 if (!session || !frame?.name) return;
@@ -185,6 +241,8 @@ const model = {
241 },
242
243 async restartFrameLoad() {
244 + this.syncActiveSession();
245 + if (!this.session) return;
246 this.frameReady = false;
247 this._frameOrigin = "";
248 this._frameAttempt = 0;
@@ -254,24 +312,192 @@ const model = {
312 frame?.contentWindow?.postMessage(JSON.stringify(message), targetOrigin);
313 },
314
257 - save() {
258 - this.postToFrame({
259 - MessageId: "Action_Save",
260 - Values: {
261 - DontTerminateEdit: true,
262 - DontSaveIfUnmodified: true,
263 - },
315 + async save(options = {}) {
316 + const { wait = false, timeoutMs = 1500 } = options;
317 + if (!this.session || !this.activeFrame() || !this.frameReady) return true;
318 + if (!wait) {
319 + this.postToFrame({
320 + MessageId: "Action_Save",
321 + Values: {
322 + DontTerminateEdit: true,
323 + DontSaveIfUnmodified: true,
324 + },
325 + });
326 + return true;
327 + }
328 + return await new Promise((resolve) => {
329 + const timeout = globalThis.setTimeout(() => {
330 + this._saveWaiters = this._saveWaiters.filter((waiter) => waiter !== done);
331 + resolve(false);
332 + }, timeoutMs);
333 + const done = (ok) => {
334 + globalThis.clearTimeout(timeout);
335 + resolve(ok);
336 + };
337 + this._saveWaiters.push(done);
338 + this.postToFrame({
339 + MessageId: "Action_Save",
340 + Values: {
341 + DontTerminateEdit: true,
342 + DontSaveIfUnmodified: true,
343 + },
344 + });
345 });
346 },
347
348 + resolveSaveWaiters(ok = true) {
349 + const waiters = this._saveWaiters.splice(0);
350 + for (const waiter of waiters) waiter(ok);
351 + },
352 +
353 closeFile() {
268 - this.save();
269 - this.session = null;
270 - this.frameReady = false;
271 - this._frameOrigin = "";
272 - this._frameAttempt = 0;
273 - this._frameRecoveryTried = false;
274 - this.clearFrameTimers();
354 + return this.closeTab(this.activeTabId);
355 + },
356 +
357 + blankFrame() {
358 + const frame = this.activeFrame();
359 + if (frame) {
360 + frame.src = "about:blank";
361 + }
362 + },
363 +
364 + async closeTab(tabId = this.activeTabId, options = {}) {
365 + const normalized = normalizeTabId(tabId);
366 + const index = this.tabs.findIndex((tab) => tab.tab_id === normalized);
367 + if (index < 0) return;
368 +
369 + const tab = this.tabs[index];
370 + const wasActive = tab.tab_id === this.activeTabId;
371 + if (wasActive && !options.skipSave) {
372 + await this.save({ wait: true, timeoutMs: 1200 });
373 + }
374 + await this.closeBackendSession(tab);
375 + this.tabs.splice(index, 1);
376 +
377 + if (!this.tabs.length) {
378 + this.activeTabId = "";
379 + this.session = null;
380 + this.frameReady = false;
381 + this._frameOrigin = "";
382 + this._frameAttempt = 0;
383 + this._frameRecoveryTried = false;
384 + this.clearFrameTimers();
385 + this.blankFrame();
386 + await this.refresh();
387 + return;
388 + }
389 +
390 + if (wasActive) {
391 + const nextTab = this.tabs[Math.min(index, this.tabs.length - 1)];
392 + this.activeTabId = nextTab.tab_id;
393 + this.syncActiveSession();
394 + await this.restartFrameLoad();
395 + } else {
396 + this.syncActiveSession();
397 + }
398 + await this.refresh();
399 + },
400 +
401 + async closeBackendSession(tab) {
402 + if (!tab?.session_id && !tab?.file_id) return;
403 + try {
404 + await callJsonApi("/plugins/_office/office_session", {
405 + action: "close",
406 + session_id: tab.session_id || "",
407 + file_id: tab.session_id ? "" : (tab.file_id || ""),
408 + });
409 + } catch (error) {
410 + console.warn("Office session close skipped", error);
411 + }
412 + },
413 +
414 + async selectTab(tabId) {
415 + const tab = this.tabById(tabId);
416 + if (!tab) return;
417 + if (tab.tab_id === this.activeTabId && this.session) return;
418 + await this.save({ wait: true, timeoutMs: 900 });
419 + this.activeTabId = tab.tab_id;
420 + this.syncActiveSession();
421 + await this.restartFrameLoad();
422 + },
423 +
424 + normalizeTab(session) {
425 + const tabId = makeTabId(session);
426 + return {
427 + ...session,
428 + tab_id: tabId,
429 + session_id: normalizeTabId(session?.session_id) || tabId,
430 + title: String(session?.title || session?.basename || session?.path || "Office file"),
431 + opened_at: session?.opened_at || Date.now(),
432 + };
433 + },
434 +
435 + findTabIndexForSession(session) {
436 + return this.tabs.findIndex((tab) => sameDocument(tab, session));
437 + },
438 +
439 + tabById(tabId) {
440 + const normalized = normalizeTabId(tabId);
441 + return this.tabs.find((tab) => tab.tab_id === normalized) || null;
442 + },
443 +
444 + activeTab() {
445 + return this.tabById(this.activeTabId) || this.tabs[0] || null;
446 + },
447 +
448 + ensureActiveTab() {
449 + if (!this.tabs.length) {
450 + this.activeTabId = "";
451 + this.session = null;
452 + return;
453 + }
454 + if (!this.tabById(this.activeTabId)) {
455 + this.activeTabId = this.tabs[0].tab_id;
456 + }
457 + this.syncActiveSession();
458 + },
459 +
460 + syncActiveSession() {
461 + this.session = this.activeTab();
462 + },
463 +
464 + isActiveTab(tab) {
465 + return Boolean(tab?.tab_id && tab.tab_id === this.activeTabId);
466 + },
467 +
468 + tabTitle(tab) {
469 + const title = String(tab?.title || tab?.basename || "").trim();
470 + if (title) return title;
471 + const path = String(tab?.path || "").trim();
472 + return path.split("/").filter(Boolean).pop() || "Office file";
473 + },
474 +
475 + tabLabel(tab) {
476 + const extension = String(tab?.extension || "").trim().toUpperCase();
477 + return extension ? `${this.tabTitle(tab)} (${extension})` : this.tabTitle(tab);
478 + },
479 +
480 + tabIcon(tab) {
481 + const extension = String(tab?.extension || "").toLowerCase();
482 + if (["xlsx", "ods"].includes(extension)) return "table_chart";
483 + if (["pptx", "odp"].includes(extension)) return "co_present";
484 + if (["docx", "odt"].includes(extension)) return "article";
485 + return "description";
486 + },
487 +
488 + openDocumentLabel(doc) {
489 + const basename = String(doc?.basename || doc?.title || "").trim();
490 + const path = String(doc?.path || "").trim();
491 + return basename || path.split("/").filter(Boolean).pop() || "Office file";
492 + },
493 +
494 + openDocumentMeta(doc) {
495 + const sessions = Number(doc?.open_sessions || 0);
496 + const extension = String(doc?.extension || "").trim().toUpperCase();
497 + return [
498 + extension,
499 + sessions ? `${sessions} session${sessions === 1 ? "" : "s"}` : "",
500 + ].filter(Boolean).join(" / ");
501 },
502
503 onPostMessage(event) {
@@ -287,9 +513,11 @@ const model = {
513 if (this.message === "Still opening the editor... trying a fresh editor load.") this.message = "";
514 this.postToFrame({ MessageId: "Host_PostmessageReady" });
515 } else if (id === "UI_Close") {
290 - this.session = null;
516 + void this.closeTab(this.activeTabId, { skipSave: true });
517 } else if (id === "Action_Save_Resp") {
292 - this.message = message.Values?.success === false ? "Save did not complete." : "Saved";
518 + const ok = message.Values?.success !== false;
519 + this.message = ok ? "Saved" : "Save did not complete.";
520 + this.resolveSaveWaiters(ok);
521 }
522 },
523
tests/test_office_wopi_store.py
+20
@@ -86,6 +86,26 @@ def test_lock_conflicts_refresh_unlock_and_relock(office_state):
86 assert current == ""
87
88
89 +def test_close_session_revokes_token_lock_and_open_document_metadata(office_state):
90 + doc = wopi_store.create_document("document", "Close Test", "docx", "")
91 + session = wopi_store.create_session(doc["file_id"], "user-a", "write", "http://localhost:32080")
92 + ok, current = wopi_store.lock(doc["file_id"], "close-lock", session["session_id"], 120)
93 + assert ok is True
94 + assert current == "close-lock"
95 +
96 + open_docs = wopi_store.get_open_documents()
97 + assert len(open_docs) == 1
98 + assert open_docs[0]["file_id"] == doc["file_id"]
99 + assert open_docs[0]["open_sessions"] == 1
100 +
101 + assert wopi_store.close_session(session_id=session["session_id"]) == 1
102 + assert wopi_store.get_open_documents() == []
103 + assert wopi_store.get_lock(doc["file_id"]) == ""
104 + with pytest.raises(PermissionError):
105 + wopi_store.validate_token(session["access_token"], doc["file_id"])
106 + assert wopi_store.close_session(session_id=session["session_id"]) == 0
107 +
108 +
109 def test_put_file_requires_lock_and_updates_version_history(office_state):
110 doc = wopi_store.create_document("document", "Save Test", "docx", "before")
111 session = wopi_store.create_session(doc["file_id"], "user-a", "write", "http://localhost:32080")
webui/components/canvas/right-canvas.css
+4
@@ -316,6 +316,10 @@ body.right-canvas-mobile-mode .right-canvas.is-closed {
316 }
317
318 body.right-canvas-mobile-mode .right-canvas-rail {
319 + display: flex;
320 +}
321 +
322 +body.right-canvas-mobile-mode .right-canvas.is-open .right-canvas-rail {
323 display: none;
324 }
325