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.
- package/README.md +45 -21
- package/bin/codex-overleaf-link.mjs +58 -0
- package/extension/assets/icons/codex-overleaf-dialog-icon.png +0 -0
- package/extension/assets/icons/codex-overleaf-icon.png +0 -0
- package/extension/assets/icons/icon128.png +0 -0
- package/extension/assets/icons/icon16.png +0 -0
- package/extension/assets/icons/icon32.png +0 -0
- package/extension/assets/icons/icon48.png +0 -0
- package/extension/bootstrap/background.js +404 -0
- package/extension/bootstrap/manifest.template.json +51 -0
- package/extension/bootstrap/popup.css +26 -0
- package/extension/bootstrap/popup.html +38 -0
- package/extension/bootstrap/popup.js +44 -0
- package/extension/bootstrap/runtimeContext.js +2 -0
- package/extension/runtime-manifest.json +60 -0
- package/extension/src/background.js +532 -0
- package/extension/src/content/applyResultFormatters.js +387 -0
- package/extension/src/content/composerAttachments.js +522 -0
- package/extension/src/content/composerPanel.js +254 -0
- package/extension/src/content/contentRuntime.js +8897 -0
- package/extension/src/content/contextTray.js +686 -0
- package/extension/src/content/diagnosticsController.js +659 -0
- package/extension/src/content/diagnosticsPanel.js +342 -0
- package/extension/src/content/diffReviewPanel.js +797 -0
- package/extension/src/content/localSkillsPanel.js +280 -0
- package/extension/src/content/markdownText.js +606 -0
- package/extension/src/content/mirrorHealth.js +92 -0
- package/extension/src/content/modelPicker.js +489 -0
- package/extension/src/content/nativeChannel.js +62 -0
- package/extension/src/content/otWarmMirror.js +964 -0
- package/extension/src/content/otWarmMirrorController.js +174 -0
- package/extension/src/content/panelMaintenance.js +367 -0
- package/extension/src/content/panelRenderer.js +253 -0
- package/extension/src/content/recentProjects.js +1063 -0
- package/extension/src/content/reviewHunks.js +247 -0
- package/extension/src/content/runController.js +242 -0
- package/extension/src/content/runTimelineView.js +1131 -0
- package/extension/src/content/sessionManager.js +487 -0
- package/extension/src/content/sessionPanel.js +263 -0
- package/extension/src/content/settingsPanel.js +527 -0
- package/extension/src/content/themeController.js +91 -0
- package/extension/src/content/updateIdle.js +67 -0
- package/extension/src/content/writebackController.js +300 -0
- package/extension/src/content/writebackOrchestrator.js +850 -0
- package/extension/src/contentScript.js +8 -0
- package/extension/src/page/compileBridge.js +460 -0
- package/extension/src/page/overleafCapabilities.js +100 -0
- package/extension/src/page/overleafEditor.js +282 -0
- package/extension/src/page/overleafProjectSnapshot.js +234 -0
- package/extension/src/page/overleafRealtimeObserver.js +491 -0
- package/extension/src/page/pageBridgeCapability.js +71 -0
- package/extension/src/page/snapshotRouter.js +948 -0
- package/extension/src/page/trackedChangesLifecycle.js +1506 -0
- package/extension/src/page/treeOperations.js +1584 -0
- package/extension/src/page/writeGuard.js +142 -0
- package/extension/src/page/writebackRouter.js +1934 -0
- package/extension/src/pageBridge.js +2224 -0
- package/extension/src/popup.js +247 -0
- package/extension/src/shared/agentTranscript.js +18 -4
- package/extension/src/shared/compatibility.js +1 -1
- package/extension/styles/panel.css +4048 -0
- package/native-host/src/index.js +9 -2
- package/native-host/src/managedInstall.js +393 -0
- package/native-host/src/managedLauncherRuntime.js +71 -0
- package/native-host/src/nativeHostPlatform.js +16 -0
- package/native-host/src/runtimeInstaller.js +6 -1
- package/native-host/src/taskRunnerRuntime.js +8 -0
- package/native-host/src/updateArchive.js +253 -0
- package/native-host/src/updateManager.js +548 -0
- package/native-host/src/updateTrust.js +156 -0
- package/package.json +11 -2
- package/scripts/install-managed.mjs +64 -0
- 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
|
+
})();
|