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