codex-overleaf-link 1.8.6 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +45 -21
  2. package/bin/codex-overleaf-link.mjs +58 -0
  3. package/extension/assets/icons/codex-overleaf-dialog-icon.png +0 -0
  4. package/extension/assets/icons/codex-overleaf-icon.png +0 -0
  5. package/extension/assets/icons/icon128.png +0 -0
  6. package/extension/assets/icons/icon16.png +0 -0
  7. package/extension/assets/icons/icon32.png +0 -0
  8. package/extension/assets/icons/icon48.png +0 -0
  9. package/extension/bootstrap/background.js +404 -0
  10. package/extension/bootstrap/manifest.template.json +51 -0
  11. package/extension/bootstrap/popup.css +26 -0
  12. package/extension/bootstrap/popup.html +38 -0
  13. package/extension/bootstrap/popup.js +44 -0
  14. package/extension/bootstrap/runtimeContext.js +2 -0
  15. package/extension/runtime-manifest.json +60 -0
  16. package/extension/src/background.js +532 -0
  17. package/extension/src/content/applyResultFormatters.js +387 -0
  18. package/extension/src/content/composerAttachments.js +522 -0
  19. package/extension/src/content/composerPanel.js +254 -0
  20. package/extension/src/content/contentRuntime.js +8897 -0
  21. package/extension/src/content/contextTray.js +686 -0
  22. package/extension/src/content/diagnosticsController.js +659 -0
  23. package/extension/src/content/diagnosticsPanel.js +342 -0
  24. package/extension/src/content/diffReviewPanel.js +797 -0
  25. package/extension/src/content/localSkillsPanel.js +280 -0
  26. package/extension/src/content/markdownText.js +606 -0
  27. package/extension/src/content/mirrorHealth.js +92 -0
  28. package/extension/src/content/modelPicker.js +489 -0
  29. package/extension/src/content/nativeChannel.js +62 -0
  30. package/extension/src/content/otWarmMirror.js +964 -0
  31. package/extension/src/content/otWarmMirrorController.js +174 -0
  32. package/extension/src/content/panelMaintenance.js +367 -0
  33. package/extension/src/content/panelRenderer.js +253 -0
  34. package/extension/src/content/recentProjects.js +1063 -0
  35. package/extension/src/content/reviewHunks.js +247 -0
  36. package/extension/src/content/runController.js +242 -0
  37. package/extension/src/content/runTimelineView.js +1131 -0
  38. package/extension/src/content/sessionManager.js +487 -0
  39. package/extension/src/content/sessionPanel.js +263 -0
  40. package/extension/src/content/settingsPanel.js +527 -0
  41. package/extension/src/content/themeController.js +91 -0
  42. package/extension/src/content/updateIdle.js +67 -0
  43. package/extension/src/content/writebackController.js +300 -0
  44. package/extension/src/content/writebackOrchestrator.js +850 -0
  45. package/extension/src/contentScript.js +8 -0
  46. package/extension/src/page/compileBridge.js +460 -0
  47. package/extension/src/page/overleafCapabilities.js +100 -0
  48. package/extension/src/page/overleafEditor.js +282 -0
  49. package/extension/src/page/overleafProjectSnapshot.js +234 -0
  50. package/extension/src/page/overleafRealtimeObserver.js +491 -0
  51. package/extension/src/page/pageBridgeCapability.js +71 -0
  52. package/extension/src/page/snapshotRouter.js +948 -0
  53. package/extension/src/page/trackedChangesLifecycle.js +1506 -0
  54. package/extension/src/page/treeOperations.js +1584 -0
  55. package/extension/src/page/writeGuard.js +142 -0
  56. package/extension/src/page/writebackRouter.js +1934 -0
  57. package/extension/src/pageBridge.js +2224 -0
  58. package/extension/src/popup.js +247 -0
  59. package/extension/src/shared/agentTranscript.js +18 -4
  60. package/extension/src/shared/compatibility.js +1 -1
  61. package/extension/styles/panel.css +4048 -0
  62. package/native-host/src/index.js +9 -2
  63. package/native-host/src/managedInstall.js +393 -0
  64. package/native-host/src/managedLauncherRuntime.js +71 -0
  65. package/native-host/src/nativeHostPlatform.js +16 -0
  66. package/native-host/src/runtimeInstaller.js +6 -1
  67. package/native-host/src/taskRunnerRuntime.js +8 -0
  68. package/native-host/src/updateArchive.js +253 -0
  69. package/native-host/src/updateManager.js +548 -0
  70. package/native-host/src/updateTrust.js +156 -0
  71. package/package.json +11 -2
  72. package/scripts/install-managed.mjs +64 -0
  73. package/scripts/uninstall-managed.mjs +48 -0
@@ -0,0 +1,1063 @@
1
+ (function initCodexOverleafRecentProjects() {
2
+ 'use strict';
3
+
4
+ // Recent-projects dashboard — the cross-project welcome variant carved out
5
+ // of contentRuntime.js (v1.4.8 structural-debt phase 4): the account-scoped
6
+ // project-name cache + DOM enrichment, the welcome/empty/degraded states,
7
+ // row rendering, and the per-project/variant switchers. Code moved verbatim;
8
+ // runtime collaborators are factory-injected (the account scope id stays in
9
+ // the runtime and is read through a getter).
10
+ function create(deps = {}) {
11
+ const {
12
+ tr,
13
+ tx,
14
+ openCustomInstructionsSettings,
15
+ enterProject,
16
+ applyStateToPanel,
17
+ getPanel,
18
+ getCachedAccountScopeId,
19
+ refreshAccountScopeId,
20
+ showPluginConfirm,
21
+ showPluginToast,
22
+ sendBackgroundNative,
23
+ PANEL_STATE_BASE_KEY,
24
+ PROJECT_EDITOR_RESERVED_IDS,
25
+ STATUS_BADGE_CLASS
26
+ } = deps;
27
+
28
+ // chrome.storage.local cache key (spec §5.6.3). The cache is keyed by
29
+ // accountScopeId so a second account on the same Chrome profile cannot
30
+ // see another account's project names.
31
+ const PROJECT_NAME_CACHE_STORAGE_KEY = 'projectNameCacheByAccount';
32
+ // In-memory mirror of the cache so `lookupProjectName` can be synchronous
33
+ // (the row renderer is sync). The async loader populates this on panel
34
+ // mount and on each opportunistic enrichment call.
35
+ let projectNameCacheMirror = {};
36
+
37
+ function loadProjectNameCacheFromStorage() {
38
+ return new Promise(function (resolve) {
39
+ try {
40
+ chrome.storage.local.get(PROJECT_NAME_CACHE_STORAGE_KEY, function (items) {
41
+ var stored = items && items[PROJECT_NAME_CACHE_STORAGE_KEY];
42
+ if (stored && typeof stored === 'object' && !Array.isArray(stored)) {
43
+ projectNameCacheMirror = stored;
44
+ }
45
+ resolve(projectNameCacheMirror);
46
+ });
47
+ } catch (_error) {
48
+ resolve(projectNameCacheMirror);
49
+ }
50
+ });
51
+ }
52
+
53
+ // v1.8.1: "Clear all history" must also drop the cached project names —
54
+ // they can carry sensitive titles and live in chrome.storage.local, not in
55
+ // the IndexedDB stores that clearAllStores wipes.
56
+ async function resetProjectNameCache() {
57
+ projectNameCacheMirror = {};
58
+ try {
59
+ await chrome.storage.local.remove(PROJECT_NAME_CACHE_STORAGE_KEY);
60
+ } catch (_error) { /* storage unavailable — mirror is already empty */ }
61
+ }
62
+
63
+ function persistProjectNameCacheToStorage() {
64
+ return new Promise(function (resolve) {
65
+ try {
66
+ var payload = {};
67
+ payload[PROJECT_NAME_CACHE_STORAGE_KEY] = projectNameCacheMirror;
68
+ chrome.storage.local.set(payload, function () {
69
+ resolve();
70
+ });
71
+ } catch (_error) {
72
+ resolve();
73
+ }
74
+ });
75
+ }
76
+
77
+ function lookupProjectName(projectId) {
78
+ var accountScopeId = getCachedAccountScopeId();
79
+ if (!accountScopeId) {
80
+ return '';
81
+ }
82
+ var bucket = projectNameCacheMirror[accountScopeId];
83
+ if (!bucket || typeof bucket !== 'object') {
84
+ return '';
85
+ }
86
+ var name = bucket[projectId];
87
+ return typeof name === 'string' && name ? name : '';
88
+ }
89
+
90
+ // Spec §5.6.4 — opportunistic enrichment from the project-list page DOM.
91
+ // Best-effort: selectors here are pinned at user-test time against the
92
+ // live Overleaf project-list page; if they fail we no-op so the cached
93
+ // render survives. The two patterns the spec calls out are
94
+ // `[data-project-id]` + `[data-project-name]` (semantic markers) and the
95
+ // legacy `.project-list-table` row shape (anchor href = /project/<id> +
96
+ // an adjacent text node).
97
+ async function opportunisticEnrichmentFromDom() {
98
+ var accountScopeId = getCachedAccountScopeId();
99
+ if (!accountScopeId) {
100
+ return;
101
+ }
102
+ try {
103
+ var bucket = projectNameCacheMirror[accountScopeId] || {};
104
+ var merged = false;
105
+ // Primary selector: an element annotated with both data-project-id and
106
+ // data-project-name. Mirrors the Overleaf "v1.x project-list-table" data
107
+ // attributes when present. Selectors below are best-effort and are the
108
+ // implementer-pinned attempt; falling through to the legacy anchor
109
+ // selector keeps the enrichment alive across markup churn.
110
+ var nodes = document.querySelectorAll('[data-project-id][data-project-name]');
111
+ for (var i = 0; i < nodes.length; i++) {
112
+ var node = nodes[i];
113
+ var pid = node.getAttribute('data-project-id');
114
+ var pname = node.getAttribute('data-project-name');
115
+ if (isValidProjectId(pid) && typeof pname === 'string' && pname && bucket[pid] !== pname) {
116
+ bucket[pid] = pname;
117
+ merged = true;
118
+ }
119
+ }
120
+ // Legacy fallback: project-list-table row with an anchor to /project/<id>
121
+ // whose accessible text contains the project name. This is the path
122
+ // that survives if Overleaf strips the data attributes.
123
+ var anchors = document.querySelectorAll('a[href^="/project/"]');
124
+ for (var j = 0; j < anchors.length; j++) {
125
+ var anchor = anchors[j];
126
+ var href = anchor.getAttribute('href') || '';
127
+ var match = href.match(/^\/project\/([a-f0-9]{24})/);
128
+ if (!match) continue;
129
+ var anchorId = match[1];
130
+ var text = (anchor.textContent || '').trim();
131
+ if (text && bucket[anchorId] !== text && text.length <= 200) {
132
+ bucket[anchorId] = text;
133
+ merged = true;
134
+ }
135
+ }
136
+ if (merged) {
137
+ projectNameCacheMirror[accountScopeId] = bucket;
138
+ await persistProjectNameCacheToStorage();
139
+ // If the variant is currently visible, refresh row names in place.
140
+ var visibleList = getPanel() && getPanel().querySelector('[data-recent-projects-list]');
141
+ if (visibleList) {
142
+ var rows = visibleList.querySelectorAll('[data-recent-projects-row]');
143
+ for (var k = 0; k < rows.length; k++) {
144
+ var row = rows[k];
145
+ var rowPid = row.getAttribute('data-project-id');
146
+ var nameEl = row.querySelector('.recent-projects-row-name');
147
+ if (rowPid && nameEl) {
148
+ var cached = lookupProjectName(rowPid);
149
+ if (cached) {
150
+ nameEl.textContent = cached;
151
+ }
152
+ }
153
+ }
154
+ }
155
+ }
156
+ } catch (_error) {
157
+ // Selector / DOM enrichment failures are silent by design — the cached
158
+ // render path keeps working. Cache invariants are unaffected.
159
+ }
160
+ }
161
+
162
+ // Cache the project name for the currently-mounted project on per-project
163
+ // entry. Called from `enterProject` so the cache fills naturally as the
164
+ // user visits projects. Cheap, idempotent.
165
+ function rememberCurrentProjectName(projectId) {
166
+ if (!isValidProjectId(projectId)) {
167
+ return;
168
+ }
169
+ var accountScopeId = getCachedAccountScopeId();
170
+ if (!accountScopeId) {
171
+ return;
172
+ }
173
+ var name = readCurrentProjectNameFromDom();
174
+ if (!name) {
175
+ return;
176
+ }
177
+ var bucket = projectNameCacheMirror[accountScopeId] || {};
178
+ if (bucket[projectId] === name) {
179
+ return;
180
+ }
181
+ bucket[projectId] = name;
182
+ projectNameCacheMirror[accountScopeId] = bucket;
183
+ persistProjectNameCacheToStorage().catch(function () { /* swallow */ });
184
+ }
185
+
186
+ function readCurrentProjectNameFromDom() {
187
+ // Best-effort: try the editor title bar element, then document.title.
188
+ // Both pinned at user-test time. Falsy return = quiet skip.
189
+ try {
190
+ var titleEl = document.querySelector('[data-project-name], .project-name');
191
+ if (titleEl) {
192
+ var text = (titleEl.textContent || '').trim();
193
+ if (text) return text;
194
+ }
195
+ } catch (_error) { /* swallow */ }
196
+ try {
197
+ var docTitle = (document.title || '').trim();
198
+ // Overleaf typically formats as "<Project Name> - Overleaf" or
199
+ // "<Project Name> - Online LaTeX Editor"; strip the " - " suffix.
200
+ if (docTitle) {
201
+ var stripped = docTitle.replace(/\s*[-–]\s*Overleaf.*$/i, '').trim();
202
+ if (stripped && stripped.toLowerCase() !== 'overleaf') {
203
+ return stripped;
204
+ }
205
+ }
206
+ } catch (_error) { /* swallow */ }
207
+ return '';
208
+ }
209
+
210
+ // ISO timestamp → short human-readable relative time. Locale-agnostic in
211
+ // the literal punctuation so the same renderer works in en + zh; the
212
+ // numeric/word parts are bilingual.
213
+ function formatRelativeTime(iso) {
214
+ if (typeof iso !== 'string' || !iso) {
215
+ return '';
216
+ }
217
+ var then = Date.parse(iso);
218
+ if (!Number.isFinite(then)) {
219
+ return '';
220
+ }
221
+ var now = Date.now();
222
+ var diffMs = now - then;
223
+ if (diffMs < 0) diffMs = 0;
224
+ var sec = Math.round(diffMs / 1000);
225
+ if (sec < 45) return tx('just now', '刚刚');
226
+ var min = Math.round(sec / 60);
227
+ if (min < 60) return tx(min + ' min ago', min + ' 分钟前');
228
+ var hr = Math.round(min / 60);
229
+ if (hr < 24) return tx(hr + ' hr ago', hr + ' 小时前');
230
+ var day = Math.round(hr / 24);
231
+ if (day < 30) return tx(day + ' day ago', day + ' 天前');
232
+ var month = Math.round(day / 30);
233
+ if (month < 12) return tx(month + ' mo ago', month + ' 个月前');
234
+ var year = Math.round(month / 12);
235
+ return tx(year + ' yr ago', year + ' 年前');
236
+ }
237
+
238
+ function textNode(text, className) {
239
+ var el = document.createElement('span');
240
+ if (className) {
241
+ el.className = className;
242
+ }
243
+ el.textContent = text == null ? '' : String(text);
244
+ return el;
245
+ }
246
+
247
+ function renderWelcomeHeader(stats) {
248
+ var el = document.createElement('div');
249
+ el.className = 'recent-projects-welcome';
250
+ el.setAttribute('data-recent-projects-welcome', '');
251
+ var title = document.createElement('div');
252
+ title.className = 'recent-projects-welcome-title';
253
+ title.textContent = tr('recentProjects_welcome');
254
+ var subtitle = document.createElement('div');
255
+ subtitle.className = 'recent-projects-welcome-subtitle';
256
+ // v1.8.1: the most expensive spot on the first screen now carries a
257
+ // glanceable summary instead of boilerplate once the rows are known.
258
+ subtitle.textContent = stats && stats.projectCount
259
+ ? tr('recentProjects_welcome_stats', { projects: stats.projectCount, sessions: stats.sessionTotal })
260
+ : tr('recentProjects_welcome_subtitle');
261
+ el.appendChild(title);
262
+ el.appendChild(subtitle);
263
+ return el;
264
+ }
265
+
266
+ function renderEmptyState() {
267
+ var el = document.createElement('div');
268
+ el.className = 'recent-projects-empty';
269
+ el.setAttribute('data-recent-projects-empty', '');
270
+ el.textContent = tr('recentProjects_empty');
271
+ return el;
272
+ }
273
+
274
+ function renderDegradedState() {
275
+ var el = document.createElement('div');
276
+ el.className = 'recent-projects-degraded';
277
+ el.setAttribute('data-recent-projects-degraded', '');
278
+ // v1.8.1: the user IS signed in on /project — telling them to sign in
279
+ // was a dead end. Explain the real condition and offer a retry.
280
+ var hint = document.createElement('div');
281
+ hint.textContent = tr('recentProjects_degraded_hint');
282
+ el.appendChild(hint);
283
+ var retry = document.createElement('button');
284
+ retry.type = 'button';
285
+ retry.className = 'recent-projects-show-all';
286
+ retry.setAttribute('data-recent-projects-retry', '');
287
+ retry.textContent = tr('recentProjects_retryScope');
288
+ retry.addEventListener('click', function () {
289
+ if (typeof refreshAccountScopeId === 'function') {
290
+ Promise.resolve(refreshAccountScopeId())
291
+ .then(function () { return renderRecentProjectsVariant(); })
292
+ .catch(function () { /* stays degraded */ });
293
+ } else {
294
+ renderRecentProjectsVariant();
295
+ }
296
+ });
297
+ el.appendChild(retry);
298
+ return el;
299
+ }
300
+
301
+ // Spec §5.9 — settings entry, scope-aware. The "account" scope hides
302
+ // project-only sections inside the settings page (governance, sensitive,
303
+ // skills tied to projects, custom instructions, project diagnostics).
304
+ function renderSettingsEntry(options) {
305
+ var scope = options && options.scope === 'account' ? 'account' : 'project';
306
+ var entry = document.createElement('button');
307
+ entry.type = 'button';
308
+ entry.className = 'recent-projects-settings-entry';
309
+ entry.setAttribute('data-recent-projects-settings-entry', '');
310
+ entry.setAttribute('data-settings-scope', scope);
311
+ var label = document.createElement('span');
312
+ label.className = 'recent-projects-settings-entry-label';
313
+ label.textContent = tr('recentProjects_settings_entry');
314
+ entry.appendChild(label);
315
+ entry.addEventListener('click', function () {
316
+ openSettingsInScope(scope);
317
+ });
318
+ return entry;
319
+ }
320
+
321
+ // Open the existing settings panel with the requested scope. For
322
+ // scope === 'account' the project-only sections inside the settings panel
323
+ // are hidden via a data attribute on the panel root (CSS / template
324
+ // governs the actual display). For scope === 'project' the existing
325
+ // behavior is unchanged.
326
+ function openSettingsInScope(scope) {
327
+ if (getPanel()) {
328
+ // Single data attribute the settings template / CSS can read to hide
329
+ // project-only blocks. Two values: 'account' (no project active) and
330
+ // 'project' (per-project variant, existing behavior). Keeping this on
331
+ // the panel root (not on the settings slot) lets the renderer choose
332
+ // its scope before opening; it survives view-attribute changes.
333
+ getPanel().dataset.settingsScope = scope;
334
+ }
335
+ openCustomInstructionsSettings();
336
+ }
337
+
338
+ // v1.8.1: a session record can be stuck at 'running' forever if the tab
339
+ // closed / browser crashed mid-run and the project was never reopened in
340
+ // the editor (which is what settles running -> interrupted). v1.8.3 also
341
+ // treats old display-only 'pending' rows as stale: that preserves the stored
342
+ // run state while avoiding a dashboard chip that looks actively in progress
343
+ // days later.
344
+ var ZOMBIE_RUNNING_MS = 30 * 60 * 1000;
345
+
346
+ function settleDashboardRunStatus(status, lastTouchedIso) {
347
+ if (status !== 'running' && status !== 'pending') {
348
+ return status;
349
+ }
350
+ var touched = Date.parse(lastTouchedIso || '');
351
+ if (!Number.isFinite(touched) || (Date.now() - touched) > ZOMBIE_RUNNING_MS) {
352
+ return status === 'running' ? 'interrupted' : 'stale';
353
+ }
354
+ return status;
355
+ }
356
+
357
+ function renderStatusBadge(status) {
358
+ var safeStatus = (typeof status === 'string' && STATUS_BADGE_CLASS[status])
359
+ ? status
360
+ : 'pending';
361
+ var cls = STATUS_BADGE_CLASS[safeStatus];
362
+ var el = document.createElement('span');
363
+ el.className = 'recent-projects-row-badge ' + cls;
364
+ el.setAttribute('data-status', safeStatus);
365
+ el.textContent = tr('recentProjects_badge_' + safeStatus);
366
+ return el;
367
+ }
368
+
369
+ function isValidProjectId(id) {
370
+ return typeof id === 'string' && /^[a-f0-9]{24}$/.test(id) && !PROJECT_EDITOR_RESERVED_IDS.has(id);
371
+ }
372
+
373
+ function openProjectFromRow(projectId) {
374
+ if (!isValidProjectId(projectId)) {
375
+ return;
376
+ }
377
+ window.location.assign('https://www.overleaf.com/project/' + encodeURIComponent(projectId));
378
+ }
379
+
380
+ function renderRecentProjectRow(row) {
381
+ var projectId = row && row.projectId;
382
+ var valid = isValidProjectId(projectId);
383
+ var el = document.createElement('button');
384
+ el.type = 'button';
385
+ el.className = 'recent-projects-row';
386
+ el.setAttribute('data-recent-projects-row', '');
387
+ el.setAttribute('data-project-id', projectId || '');
388
+ if (!valid) {
389
+ el.disabled = true;
390
+ el.setAttribute('aria-disabled', 'true');
391
+ }
392
+ var name = lookupProjectName(projectId);
393
+ if (!name) {
394
+ name = isValidProjectId(projectId)
395
+ ? tr('recentProjects_unnamed', { prefix: projectId.slice(0, 8) })
396
+ : tr('recentProjects_row_projectLinkUnavailable');
397
+ }
398
+ var nameNode = textNode(name, 'recent-projects-row-name');
399
+ nameNode.title = name;
400
+ el.appendChild(nameNode);
401
+ var rowTime = textNode(formatRelativeTime(row && row.lastActivityAt), 'recent-projects-row-time');
402
+ rowTime.setAttribute('data-rel-time', String(row && row.lastActivityAt || ''));
403
+ el.appendChild(rowTime);
404
+ // v1.8.2: the session count lives in the row meta, matching its type
405
+ // scale, instead of inflating the expand toggle.
406
+ var sessionCount = Number(row && row.sessionCount) || 0;
407
+ if (sessionCount > 1) {
408
+ el.appendChild(textNode(tr('recentProjects_sessionsCount', { count: sessionCount }), 'recent-projects-row-sessions'));
409
+ }
410
+ el.appendChild(renderStatusBadge(settleDashboardRunStatus(row && row.primaryStatusBadge, row && row.lastActivityAt)));
411
+ if (valid) {
412
+ el.addEventListener('click', function () {
413
+ openProjectFromRow(projectId);
414
+ });
415
+ } else {
416
+ el.appendChild(textNode(tr('recentProjects_row_projectLinkUnavailable'), 'recent-projects-row-warning'));
417
+ }
418
+ if (!valid) {
419
+ // Dead entry: the session records behind it carry an empty/garbage
420
+ // projectId, so it can never be opened or expanded. Offer a cleanup
421
+ // action that removes those records (and with them, this row).
422
+ var deadWrap = document.createElement('div');
423
+ deadWrap.className = 'recent-projects-row-wrap';
424
+ var deadHead = document.createElement('div');
425
+ deadHead.className = 'recent-projects-row-head';
426
+ var cleanup = document.createElement('button');
427
+ cleanup.type = 'button';
428
+ cleanup.className = 'recent-projects-row-cleanup';
429
+ cleanup.setAttribute('data-row-cleanup', '');
430
+ cleanup.title = tr('recentProjects_cleanup');
431
+ cleanup.setAttribute('aria-label', tr('recentProjects_cleanup'));
432
+ cleanup.textContent = '×';
433
+ cleanup.addEventListener('click', function () {
434
+ cleanupDeadProjectEntry(projectId).catch(function () { /* swallow */ });
435
+ });
436
+ deadHead.appendChild(el);
437
+ deadHead.appendChild(cleanup);
438
+ deadWrap.appendChild(deadHead);
439
+ return deadWrap;
440
+ }
441
+ // Wrap the row with an expand toggle + a lazily-populated session list so
442
+ // sessions can be managed (delete/rename) without entering the project.
443
+ var wrap = document.createElement('div');
444
+ wrap.className = 'recent-projects-row-wrap';
445
+ wrap.setAttribute('data-project-row-wrap', projectId);
446
+ var expand = document.createElement('button');
447
+ expand.type = 'button';
448
+ expand.className = 'recent-projects-row-expand';
449
+ expand.setAttribute('data-row-expand', '');
450
+ expand.setAttribute('aria-expanded', 'false');
451
+ // v1.8.1 a11y: tie the toggle to the container it reveals.
452
+ var sessionsElId = 'codex-project-sessions-' + String(projectId || '').slice(0, 24);
453
+ expand.setAttribute('aria-controls', sessionsElId);
454
+ expand.title = tr('recentProjects_sessions_toggle');
455
+ expand.setAttribute('aria-label', tr('recentProjects_sessions_toggle'));
456
+ expand.textContent = tr('recentProjects_sessions_action');
457
+ var head = document.createElement('div');
458
+ head.className = 'recent-projects-row-head';
459
+ head.appendChild(el);
460
+ head.appendChild(expand);
461
+ var sessionsEl = document.createElement('div');
462
+ sessionsEl.className = 'recent-projects-sessions';
463
+ sessionsEl.setAttribute('data-project-sessions', '');
464
+ sessionsEl.id = sessionsElId;
465
+ sessionsEl.setAttribute('role', 'region');
466
+ sessionsEl.setAttribute('aria-label', tr('recentProjects_sessions_toggle'));
467
+ sessionsEl.hidden = true;
468
+ expand.addEventListener('click', function () {
469
+ toggleProjectSessions(wrap, projectId);
470
+ });
471
+ wrap.appendChild(head);
472
+ wrap.appendChild(sessionsEl);
473
+ return wrap;
474
+ }
475
+
476
+ function toggleProjectSessions(wrap, projectId) {
477
+ var sessionsEl = wrap.querySelector('[data-project-sessions]');
478
+ var expand = wrap.querySelector('[data-row-expand]');
479
+ if (!sessionsEl || !expand) {
480
+ return;
481
+ }
482
+ var opening = sessionsEl.hidden;
483
+ sessionsEl.hidden = !opening;
484
+ expand.setAttribute('aria-expanded', opening ? 'true' : 'false');
485
+ expand.textContent = tr(opening ? 'recentProjects_sessions_action_open' : 'recentProjects_sessions_action');
486
+ wrap.setAttribute('data-expanded', opening ? 'true' : 'false');
487
+ if (opening) {
488
+ renderProjectSessions(sessionsEl, projectId).catch(function () { /* swallow */ });
489
+ }
490
+ }
491
+
492
+ function sessionRecordDisplayTitle(record) {
493
+ var SessionState = window.CodexOverleafSessionState;
494
+ if (record && typeof record.title === 'string' && record.title.trim()) {
495
+ return record.title.trim();
496
+ }
497
+ var derived = SessionState ? SessionState.deriveSessionTitle(record && record.runs, record && record.task) : '';
498
+ return derived || tr('newSessionFallback');
499
+ }
500
+
501
+ async function loadProjectSessionRecords(projectId) {
502
+ var StorageDb = window.CodexOverleafStorageDb;
503
+ if (!StorageDb) {
504
+ return [];
505
+ }
506
+ var records = await StorageDb.getAllByIndex('sessions', 'projectId', projectId);
507
+ var scope = getCachedAccountScopeId();
508
+ return (records || [])
509
+ .filter(function (record) { return record && (!scope || record.accountScopeId === scope); })
510
+ .sort(function (a, b) {
511
+ return String(b.updatedAt || b.createdAt || '').localeCompare(String(a.updatedAt || a.createdAt || ''));
512
+ });
513
+ }
514
+
515
+ async function renderProjectSessions(sessionsEl, projectId) {
516
+ var StorageDb = window.CodexOverleafStorageDb;
517
+ sessionsEl.innerHTML = '';
518
+ var sessionsLoading = document.createElement('div');
519
+ sessionsLoading.className = 'recent-projects-loading';
520
+ sessionsLoading.textContent = tx('Loading sessions…', '正在加载会话…');
521
+ sessionsEl.appendChild(sessionsLoading);
522
+ var records = [];
523
+ try {
524
+ records = await loadProjectSessionRecords(projectId);
525
+ } catch (_error) {
526
+ records = [];
527
+ }
528
+ sessionsLoading.remove();
529
+ if (!records.length) {
530
+ sessionsEl.appendChild(textNode(tr('recentProjects_sessions_empty'), 'recent-projects-sessions-empty'));
531
+ return;
532
+ }
533
+ for (var i = 0; i < records.length; i++) {
534
+ sessionsEl.appendChild(renderProjectSessionRow(sessionsEl, projectId, records[i]));
535
+ }
536
+ }
537
+
538
+ function renderProjectSessionRow(sessionsEl, projectId, record) {
539
+ var StorageDb = window.CodexOverleafStorageDb;
540
+ var running = Boolean(StorageDb)
541
+ && settleDashboardRunStatus(StorageDb.derivePrimaryStatusBadge(record), record.updatedAt || record.lastActivityAt) === 'running';
542
+ var row = document.createElement('div');
543
+ row.className = 'recent-projects-session-row';
544
+ row.setAttribute('data-project-session-row', record.id);
545
+ if (running) {
546
+ row.setAttribute('data-running', 'true');
547
+ }
548
+ var title = textNode(sessionRecordDisplayTitle(record), 'recent-projects-session-title');
549
+ title.title = sessionRecordDisplayTitle(record);
550
+ var time = textNode(formatRelativeTime(record.updatedAt || record.createdAt), 'recent-projects-session-time');
551
+ time.setAttribute('data-rel-time', String(record.updatedAt || record.createdAt || ''));
552
+ var rename = document.createElement('button');
553
+ rename.type = 'button';
554
+ rename.className = 'recent-projects-session-action';
555
+ rename.setAttribute('data-session-rename-dash', '');
556
+ rename.title = tr('renameSession');
557
+ rename.setAttribute('aria-label', tr('renameSession'));
558
+ rename.textContent = '✎';
559
+ var del = document.createElement('button');
560
+ del.type = 'button';
561
+ del.className = 'recent-projects-session-action recent-projects-session-action--delete';
562
+ del.setAttribute('data-session-delete-dash', '');
563
+ del.title = tr('deleteSession');
564
+ del.setAttribute('aria-label', tr('deleteSession'));
565
+ del.textContent = '×';
566
+ if (running) {
567
+ // Recently-touched running sessions may genuinely be live in another
568
+ // tab: keep rename locked, but let delete through a stronger confirm
569
+ // instead of a permanent dead-end (fleet: zombie sessions were
570
+ // undeletable from the very surface built to manage them).
571
+ rename.disabled = true;
572
+ del.title = tr('recentProjects_zombieRunningNote');
573
+ }
574
+ rename.addEventListener('click', function () {
575
+ beginDashboardSessionRename(sessionsEl, projectId, record, row, title);
576
+ });
577
+ del.addEventListener('click', function () {
578
+ deleteDashboardSession(sessionsEl, projectId, record).catch(function () { /* swallow */ });
579
+ });
580
+ row.appendChild(title);
581
+ row.appendChild(time);
582
+ row.appendChild(rename);
583
+ row.appendChild(del);
584
+ // v1.8.1 O2: session rows were click-dead. Clicking one now records it
585
+ // as the project's active session (the pref the editor reads on load)
586
+ // and opens the project straight into it.
587
+ row.classList.add('recent-projects-session-row--linked');
588
+ row.setAttribute('role', 'button');
589
+ row.tabIndex = 0;
590
+ var activate = function () {
591
+ activateSessionAndOpenProject(projectId, record).catch(function () { /* navigation is best-effort */ });
592
+ };
593
+ row.addEventListener('click', function (event) {
594
+ if (event.target.closest('button') || event.target.closest('input')) {
595
+ return;
596
+ }
597
+ activate();
598
+ });
599
+ row.addEventListener('keydown', function (event) {
600
+ if (event.key === 'Enter') {
601
+ event.preventDefault();
602
+ activate();
603
+ }
604
+ });
605
+ return row;
606
+ }
607
+
608
+ async function activateSessionAndOpenProject(projectId, record) {
609
+ var Migration = window.CodexOverleafStorageMigration;
610
+ try {
611
+ if (Migration && Migration.loadPrefs && Migration.savePrefs) {
612
+ var prefs = await Migration.loadPrefs();
613
+ prefs = prefs && typeof prefs === 'object' ? prefs : {};
614
+ var map = prefs.activeSessionByProject && typeof prefs.activeSessionByProject === 'object'
615
+ ? prefs.activeSessionByProject
616
+ : {};
617
+ map[projectId] = record.id;
618
+ prefs.activeSessionByProject = map;
619
+ await Migration.savePrefs(prefs);
620
+ }
621
+ } catch (_error) { /* fall through: opening the project still works */ }
622
+ openProjectFromRow(projectId);
623
+ }
624
+
625
+ // Remove every session record behind a dead (invalid-projectId) dashboard
626
+ // entry. Matching is done over a full scan instead of the projectId index:
627
+ // records with an undefined projectId are not indexed at all. The panel
628
+ // panel-state blob is deliberately left alone — an empty projectId would
629
+ // map getProjectStorageKey onto the global legacy key.
630
+ async function cleanupDeadProjectEntry(projectId) {
631
+ var StorageDb = window.CodexOverleafStorageDb;
632
+ if (!StorageDb) {
633
+ return;
634
+ }
635
+ var approved = await showPluginConfirm({
636
+ title: tr('recentProjects_cleanup_title'),
637
+ message: tr('recentProjects_cleanup_message'),
638
+ confirmLabel: tr('recentProjects_cleanup_confirm'),
639
+ cancelLabel: tr('confirmDefaultCancel'),
640
+ destructive: true
641
+ });
642
+ if (!approved) {
643
+ return;
644
+ }
645
+ var scope = getCachedAccountScopeId();
646
+ var wanted = String(projectId || '');
647
+ var all = [];
648
+ try {
649
+ all = await StorageDb.getAllSessions();
650
+ } catch (_error) {
651
+ all = [];
652
+ }
653
+ var records = (all || []).filter(function (record) {
654
+ return record
655
+ && (!scope || record.accountScopeId === scope)
656
+ && String(record.projectId || '') === wanted;
657
+ });
658
+ var removed = 0;
659
+ for (var i = 0; i < records.length; i++) {
660
+ try {
661
+ await StorageDb.deleteRecord('sessions', records[i].id);
662
+ removed++;
663
+ } catch (_error) { /* keep going */ }
664
+ try {
665
+ await sendBackgroundNative({
666
+ method: 'codex.history.clearPlugin',
667
+ params: {
668
+ sessionId: records[i].id,
669
+ threadId: records[i].codexThreadId || ''
670
+ }
671
+ });
672
+ } catch (_error) { /* best effort: the records are already gone locally */ }
673
+ }
674
+ showPluginToast(tr('recentProjects_cleanup_done', { count: String(removed) }), { status: 'info' });
675
+ await renderRecentProjectsVariant();
676
+ }
677
+
678
+ function projectPanelStateKey(projectId) {
679
+ var StorageKeys = window.CodexOverleafStorageKeys;
680
+ return StorageKeys.getProjectStorageKey(PANEL_STATE_BASE_KEY, 'https://www.overleaf.com/project/' + projectId);
681
+ }
682
+
683
+ // Apply `mutate(normalizedState) -> nextState` to the project's stored panel
684
+ // state and write it back compacted — the same normalize/prepare pipeline
685
+ // saveState uses, so opening the project later sees a coherent state.
686
+ // NOTE: if the project is open in another tab, that tab's in-memory state
687
+ // wins on its next save; the IndexedDB record mutation below still holds.
688
+ async function mutateProjectPanelState(projectId, mutate) {
689
+ var SessionState = window.CodexOverleafSessionState;
690
+ var key = projectPanelStateKey(projectId);
691
+ var stored = await chrome.storage.local.get(key);
692
+ var blob = stored && stored[key];
693
+ if (!blob || !SessionState) {
694
+ return false;
695
+ }
696
+ var nextState = mutate(SessionState.normalizePanelState(blob));
697
+ var payload = {};
698
+ payload[key] = SessionState.prepareStateForStorage(nextState);
699
+ await chrome.storage.local.set(payload);
700
+ return true;
701
+ }
702
+
703
+ async function deleteDashboardSession(sessionsEl, projectId, record) {
704
+ var StorageDb = window.CodexOverleafStorageDb;
705
+ var SessionState = window.CodexOverleafSessionState;
706
+ // v1.8.1 P1b: ANY record whose stored badge is 'running' gets the
707
+ // stronger confirm — even one the 30-minute heuristic settled to
708
+ // interrupted. Activity timestamps are only bumped once a minute during
709
+ // a run, so the heuristic alone must never be the last line of defense
710
+ // for deleting what could be a live Codex thread.
711
+ var storedRunning = Boolean(StorageDb) && StorageDb.derivePrimaryStatusBadge(record) === 'running';
712
+ var approved = await showPluginConfirm({
713
+ title: tr('deleteSessionTitle'),
714
+ message: [
715
+ sessionRecordDisplayTitle(record),
716
+ '',
717
+ storedRunning ? tr('recentProjects_zombieDeleteConfirm') : tr('deleteSessionMessage')
718
+ ].join('\n'),
719
+ confirmLabel: tr('deleteSessionConfirm'),
720
+ cancelLabel: tr('confirmDefaultCancel'),
721
+ destructive: true
722
+ });
723
+ if (!approved) {
724
+ return;
725
+ }
726
+ try {
727
+ await mutateProjectPanelState(projectId, function (state) {
728
+ return SessionState.deleteSession(state, record.id);
729
+ });
730
+ } catch (_error) { /* storage blob may be absent; record removal still proceeds */ }
731
+ try {
732
+ if (StorageDb) {
733
+ await StorageDb.deleteRecord('sessions', record.id);
734
+ }
735
+ } catch (_error) { /* swallow */ }
736
+ try {
737
+ var response = await sendBackgroundNative({
738
+ method: 'codex.history.clearPlugin',
739
+ params: {
740
+ sessionId: record.id,
741
+ threadId: record.codexThreadId || ''
742
+ }
743
+ });
744
+ if (!response || !response.ok) {
745
+ showPluginToast(tr('deleteSessionHistoryFailedToast', { message: (response && response.error && response.error.message) || 'native host did not return success' }), { status: 'warning', sticky: true });
746
+ } else if (response.result && response.result.skipped) {
747
+ showPluginToast(tr('deleteSessionNoThreadToast'), { status: 'info' });
748
+ } else {
749
+ showPluginToast(tr('deleteSessionDoneToast'), { status: 'completed' });
750
+ }
751
+ } catch (error) {
752
+ showPluginToast(tr('deleteSessionHistoryFailedToast', { message: error.message }), { status: 'warning', sticky: true });
753
+ }
754
+ // Re-render the whole variant so the row summary/badge reflect the
755
+ // deletion, then restore this project's expanded session list.
756
+ // v1.8.1: refresh the whole variant so the row's session count, badge,
757
+ // summary and the welcome stats all stay fresh — but restore the scroll
758
+ // position and this project's expanded list so the delete feels in-place.
759
+ var rootForScroll = getPanel() && getPanel().querySelector('[data-recent-projects-root]');
760
+ await renderRecentProjectsVariant({
761
+ expandProjectId: projectId,
762
+ restoreScrollTop: rootForScroll ? rootForScroll.scrollTop : undefined
763
+ });
764
+ }
765
+
766
+ function beginDashboardSessionRename(sessionsEl, projectId, record, row, titleEl) {
767
+ var SessionState = window.CodexOverleafSessionState;
768
+ var StorageDb = window.CodexOverleafStorageDb;
769
+ if (row.querySelector('input')) {
770
+ return;
771
+ }
772
+ var seed = (record.titleSource === 'manual' && record.title) ? record.title : '';
773
+ var input = document.createElement('input');
774
+ input.type = 'text';
775
+ input.maxLength = 80;
776
+ input.className = 'recent-projects-session-rename-input';
777
+ input.value = seed;
778
+ titleEl.hidden = true;
779
+ row.insertBefore(input, titleEl);
780
+ input.focus();
781
+ input.select();
782
+ var settled = false;
783
+ var finish = function (commit) {
784
+ if (settled) {
785
+ return;
786
+ }
787
+ settled = true;
788
+ var nextRaw = input.value;
789
+ input.remove();
790
+ titleEl.hidden = false;
791
+ // Unchanged input is a cancel: never rewrites the title source.
792
+ if (!commit || nextRaw.trim() === seed.trim()) {
793
+ return;
794
+ }
795
+ commitDashboardSessionRename(sessionsEl, projectId, record, nextRaw).catch(function () { /* swallow */ });
796
+ };
797
+ input.addEventListener('keydown', function (event) {
798
+ if (event.key === 'Enter') {
799
+ event.preventDefault();
800
+ finish(true);
801
+ } else if (event.key === 'Escape') {
802
+ event.preventDefault();
803
+ finish(false);
804
+ }
805
+ });
806
+ input.addEventListener('blur', function () { finish(true); });
807
+ }
808
+
809
+ async function commitDashboardSessionRename(sessionsEl, projectId, record, rawTitle) {
810
+ var SessionState = window.CodexOverleafSessionState;
811
+ var StorageDb = window.CodexOverleafStorageDb;
812
+ if (!SessionState) {
813
+ return;
814
+ }
815
+ // Single source of truth for the ghost guard: the shared renameSession
816
+ // helper decides manual vs auto exactly like the in-panel rename.
817
+ var renamed = SessionState.renameSession(
818
+ SessionState.normalizePanelState({ sessions: [record], activeSessionId: record.id }),
819
+ record.id,
820
+ rawTitle,
821
+ { placeholderTitle: tr('newSessionFallback') }
822
+ );
823
+ var next = (renamed.sessions || []).find(function (session) { return session.id === record.id; });
824
+ if (!next) {
825
+ return;
826
+ }
827
+ try {
828
+ await mutateProjectPanelState(projectId, function (state) {
829
+ return SessionState.renameSession(state, record.id, rawTitle, { placeholderTitle: tr('newSessionFallback') });
830
+ });
831
+ } catch (_error) { /* blob may be absent; record update still proceeds */ }
832
+ try {
833
+ if (StorageDb) {
834
+ await StorageDb.putRecord('sessions', StorageDb.buildSessionRecord(Object.assign({}, record, {
835
+ title: next.title,
836
+ titleSource: next.titleSource
837
+ })));
838
+ }
839
+ } catch (_error) { /* swallow */ }
840
+ await renderProjectSessions(sessionsEl, projectId);
841
+ }
842
+
843
+ function ensureRecentProjectsRoot() {
844
+ if (!getPanel()) {
845
+ return null;
846
+ }
847
+ var existing = getPanel().querySelector('[data-recent-projects-root]');
848
+ if (existing) {
849
+ return existing;
850
+ }
851
+ var rootEl = document.createElement('section');
852
+ rootEl.className = 'recent-projects-root';
853
+ rootEl.setAttribute('data-recent-projects-root', '');
854
+ // Insert as a sibling of the existing per-project main / composer slots
855
+ // so the variant lives inside the panel root (page-scoped) and the
856
+ // existing data-view CSS rules can hide it when the per-project view is
857
+ // active.
858
+ getPanel().appendChild(rootEl);
859
+ return rootEl;
860
+ }
861
+
862
+ // v1.8.1: synchronous dashboard shell for cold loads — flips the panel to
863
+ // the recent-projects view and paints the header + loading line BEFORE the
864
+ // async account-scope/IndexedDB work, so the per-project session UI never
865
+ // flashes on /project.
866
+ // v1.8.1: keep relative timestamps honest on a long-open dashboard tab.
867
+ // Cheap text-only sweep — no re-render, no scroll reset.
868
+ var relTimeTimer = null;
869
+ function startRelativeTimeTicker() {
870
+ if (relTimeTimer) {
871
+ return;
872
+ }
873
+ relTimeTimer = setInterval(function () {
874
+ if (document.visibilityState !== 'visible') {
875
+ return;
876
+ }
877
+ var panelEl = getPanel();
878
+ if (!panelEl || panelEl.dataset.view !== 'recent-projects') {
879
+ return;
880
+ }
881
+ var nodes = panelEl.querySelectorAll('[data-rel-time]');
882
+ for (var i = 0; i < nodes.length; i++) {
883
+ var iso = nodes[i].getAttribute('data-rel-time');
884
+ if (iso) {
885
+ nodes[i].textContent = formatRelativeTime(iso);
886
+ }
887
+ }
888
+ }, 60000);
889
+ }
890
+
891
+ function mountDashboardShell() {
892
+ var panelEl = getPanel();
893
+ if (!panelEl) {
894
+ return;
895
+ }
896
+ panelEl.dataset.view = 'recent-projects';
897
+ var rootEl = ensureRecentProjectsRoot();
898
+ if (!rootEl || (rootEl.childNodes || []).length) {
899
+ return;
900
+ }
901
+ rootEl.appendChild(renderWelcomeHeader());
902
+ var loading = document.createElement('div');
903
+ loading.className = 'recent-projects-loading';
904
+ loading.textContent = tx('Loading projects\u2026', '正在加载项目…');
905
+ rootEl.appendChild(loading);
906
+ }
907
+
908
+ async function renderRecentProjectsVariant(options) {
909
+ if (!getPanel()) {
910
+ return;
911
+ }
912
+ // Toggle panel into recent-projects mode. Page-scoped: never replaces
913
+ // top-level page DOM. The existing data-view-driven CSS hides per-
914
+ // project regions when the panel root carries this value.
915
+ getPanel().dataset.view = 'recent-projects';
916
+ startRelativeTimeTicker();
917
+ var rootEl = ensureRecentProjectsRoot();
918
+ if (!rootEl) {
919
+ return;
920
+ }
921
+ rootEl.innerHTML = '';
922
+ rootEl.appendChild(renderWelcomeHeader());
923
+
924
+ var accountScopeId = (window.codexOverleafDeriveAccountScopeId || function () { return null; })();
925
+ if (!accountScopeId) {
926
+ rootEl.appendChild(renderDegradedState());
927
+ rootEl.appendChild(renderSettingsEntry({ scope: 'account' }));
928
+ opportunisticEnrichmentFromDom().catch(function () { /* swallow */ });
929
+ return;
930
+ }
931
+
932
+ var listContainer = document.createElement('div');
933
+ listContainer.className = 'recent-projects-list';
934
+ listContainer.setAttribute('data-recent-projects-list', '');
935
+ rootEl.appendChild(listContainer);
936
+
937
+ // Slow IndexedDB reads must not render as "no projects": show a loading
938
+ // line for the await window below (cleared before rows/empty render).
939
+ var listLoading = document.createElement('div');
940
+ listLoading.className = 'recent-projects-loading';
941
+ listLoading.textContent = tx('Loading projects…', '正在加载项目…');
942
+ listContainer.appendChild(listLoading);
943
+
944
+ // Pre-warm the project-name cache mirror so the synchronous
945
+ // `lookupProjectName` calls inside `renderRecentProjectRow` see the
946
+ // latest data the first time the variant renders.
947
+ try {
948
+ await loadProjectNameCacheFromStorage();
949
+ } catch (_error) { /* swallow; fall back to empty mirror */ }
950
+
951
+ var rows = [];
952
+ var showAll = Boolean(options && options.showAll);
953
+ // Fetch one extra row beyond the fold so "show all" only renders when
954
+ // there really is more than one page of projects.
955
+ var pageLimit = 10;
956
+ try {
957
+ var StorageDb = window.CodexOverleafStorageDb;
958
+ if (StorageDb) {
959
+ rows = await StorageDb.listRecentProjectsAcrossAccount({
960
+ accountScopeId: accountScopeId,
961
+ limit: showAll ? 500 : pageLimit + 1
962
+ });
963
+ }
964
+ } catch (_error) {
965
+ rows = [];
966
+ }
967
+
968
+ listLoading.remove();
969
+ // v1.8.1 P2: a refresh must not clobber what the user had open — restore
970
+ // every previously-expanded project and the scroll position.
971
+ var restoreExpanded = Array.isArray(options && options.restoreExpanded) ? options.restoreExpanded : [];
972
+ // v1.8.1 O1: fill the welcome subtitle with a real summary now that the
973
+ // rows are known (header was painted before the async read).
974
+ var welcomeSubtitle = rootEl.querySelector('.recent-projects-welcome-subtitle');
975
+ if (welcomeSubtitle && rows && rows.length) {
976
+ var sessionTotal = 0;
977
+ for (var st = 0; st < rows.length; st++) {
978
+ sessionTotal += Number(rows[st].sessionCount) || 1;
979
+ }
980
+ welcomeSubtitle.textContent = tr('recentProjects_welcome_stats', {
981
+ projects: rows.length,
982
+ sessions: sessionTotal
983
+ });
984
+ }
985
+ var hasMore = !showAll && rows && rows.length > pageLimit;
986
+ var visibleRows = hasMore ? rows.slice(0, pageLimit) : rows;
987
+ if (!visibleRows || !visibleRows.length) {
988
+ listContainer.appendChild(renderEmptyState());
989
+ } else {
990
+ for (var i = 0; i < visibleRows.length; i++) {
991
+ listContainer.appendChild(renderRecentProjectRow(visibleRows[i]));
992
+ }
993
+ }
994
+ if (hasMore) {
995
+ var showAllButton = document.createElement('button');
996
+ showAllButton.type = 'button';
997
+ showAllButton.className = 'recent-projects-show-all';
998
+ showAllButton.setAttribute('data-recent-projects-show-all', '');
999
+ showAllButton.textContent = tx('Show all projects', '查看全部项目');
1000
+ showAllButton.addEventListener('click', function () {
1001
+ renderRecentProjectsVariant(Object.assign({}, options, { showAll: true }));
1002
+ });
1003
+ listContainer.appendChild(showAllButton);
1004
+ }
1005
+ rootEl.appendChild(renderSettingsEntry({ scope: 'account' }));
1006
+ var expandProjectId = options && options.expandProjectId;
1007
+ var toReopen = restoreExpanded.slice();
1008
+ if (expandProjectId && toReopen.indexOf(expandProjectId) === -1) {
1009
+ toReopen.push(expandProjectId);
1010
+ }
1011
+ for (var reopenIndex = 0; reopenIndex < toReopen.length; reopenIndex++) {
1012
+ var wrap = listContainer.querySelector('[data-project-row-wrap="' + toReopen[reopenIndex] + '"]');
1013
+ if (wrap) {
1014
+ toggleProjectSessions(wrap, toReopen[reopenIndex]);
1015
+ }
1016
+ }
1017
+ if (options && Number.isFinite(options.restoreScrollTop)) {
1018
+ rootEl.scrollTop = options.restoreScrollTop;
1019
+ var scrollerParent = rootEl.parentElement;
1020
+ if (scrollerParent && scrollerParent.scrollHeight > scrollerParent.clientHeight) {
1021
+ scrollerParent.scrollTop = options.restoreScrollTop;
1022
+ }
1023
+ }
1024
+ opportunisticEnrichmentFromDom().catch(function () { /* swallow */ });
1025
+ }
1026
+
1027
+ function renderPerProjectVariant() {
1028
+ // Per-project mount: ensure the panel view attribute is back on the
1029
+ // session view and the variant root is detached so the per-project DOM
1030
+ // is the only thing visible. The existing applyStateToPanel path renders
1031
+ // the actual per-project UI; this function is the symmetric variant-
1032
+ // swap point for the SPA hook (spec §5.7.2 / acceptance §3).
1033
+ if (!getPanel()) {
1034
+ return;
1035
+ }
1036
+ if (getPanel().dataset.view === 'recent-projects') {
1037
+ getPanel().dataset.view = 'session';
1038
+ }
1039
+ getPanel().dataset.settingsScope = 'project';
1040
+ var existing = getPanel().querySelector('[data-recent-projects-root]');
1041
+ if (existing) {
1042
+ existing.remove();
1043
+ }
1044
+ }
1045
+
1046
+ return {
1047
+ loadProjectNameCacheFromStorage,
1048
+ rememberCurrentProjectName,
1049
+ formatRelativeTime,
1050
+ textNode,
1051
+ renderStatusBadge,
1052
+ isValidProjectId,
1053
+ openProjectFromRow,
1054
+ renderRecentProjectRow,
1055
+ renderRecentProjectsVariant,
1056
+ mountDashboardShell,
1057
+ resetProjectNameCache,
1058
+ renderPerProjectVariant
1059
+ };
1060
+ }
1061
+
1062
+ window.CodexOverleafRecentProjects = { create };
1063
+ })();