codex-overleaf-link 2.3.5 → 2.4.0

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 (57) hide show
  1. package/README.md +63 -31
  2. package/README.zh-CN.md +543 -0
  3. package/extension/bootstrap/manifest.template.json +1 -1
  4. package/extension/src/background.js +7 -0
  5. package/extension/src/backgroundUpdateCoordinator.js +88 -4
  6. package/extension/src/content/activeTurnControl.js +1 -0
  7. package/extension/src/content/assetTransferBroker.js +2 -0
  8. package/extension/src/content/contentRuntime.js +77 -57
  9. package/extension/src/content/contextTray.js +4 -0
  10. package/extension/src/content/diagnosticsController.js +17 -3
  11. package/extension/src/content/diagnosticsPanel.js +129 -5
  12. package/extension/src/content/generated/content.bundle.js +1422 -348
  13. package/extension/src/content/generated/content.bundle.meta.json +7 -3
  14. package/extension/src/content/legacyGlobalRegistry.js +3 -0
  15. package/extension/src/content/modelPicker.js +21 -9
  16. package/extension/src/content/modelPickerSupport.js +38 -1
  17. package/extension/src/content/nativeCompatibilityController.js +4 -1
  18. package/extension/src/content/otWarmMirror.js +126 -92
  19. package/extension/src/content/otWarmMirrorController.js +122 -1
  20. package/extension/src/content/pageBridgeClient.js +2 -0
  21. package/extension/src/content/panelMaintenance.js +26 -15
  22. package/extension/src/content/providerSettingsCoordinator.js +1 -0
  23. package/extension/src/content/runFailureNotice.js +104 -0
  24. package/extension/src/content/runResultActions.js +16 -1
  25. package/extension/src/content/runScrollLayout.js +124 -0
  26. package/extension/src/content/runTimelineView.js +37 -11
  27. package/extension/src/content/sessionForkController.js +20 -11
  28. package/extension/src/content/settingsPanel.js +28 -11
  29. package/extension/src/content/trackedChangeCaptureController.js +153 -0
  30. package/extension/src/content/updateNotice.js +24 -4
  31. package/extension/src/content/writebackOrchestrator.js +31 -0
  32. package/extension/src/page/binaryAssetUploader.js +101 -29
  33. package/extension/src/page/overleafRealtimeObserver.js +120 -25
  34. package/extension/src/page/textFileCreator.js +493 -0
  35. package/extension/src/page/trackedChangeCapture.js +342 -0
  36. package/extension/src/page/trackedChangesLifecycle.js +145 -149
  37. package/extension/src/page/treeOperations.js +30 -2
  38. package/extension/src/page/writebackRouter.js +94 -48
  39. package/extension/src/pageBridge.js +62 -25
  40. package/extension/src/shared/compatibility.js +1 -1
  41. package/extension/src/shared/i18n.js +77 -37
  42. package/extension/src/shared/managedUpdateProjection.js +1 -1
  43. package/extension/src/shared/otText.js +0 -1
  44. package/extension/src/shared/pageRpcContract.js +2 -1
  45. package/extension/src/shared/sessionState.js +23 -23
  46. package/extension/src/shared/sessionTitle.js +120 -0
  47. package/extension/src/shared/storageDb.js +10 -12
  48. package/extension/src/shared/storageRunActions.js +29 -1
  49. package/extension/src/shared/updateRuntimeIdentity.js +32 -0
  50. package/extension/src/shared/writebackSettlement.js +19 -5
  51. package/extension/styles/panel.css +128 -24
  52. package/native-host/src/mirrorWorkspace.js +20 -1
  53. package/native-host/src/releaseProxy.js +177 -0
  54. package/native-host/src/releaseTransport.js +154 -0
  55. package/native-host/src/taskRunnerRuntime.js +25 -0
  56. package/native-host/src/updateManager.js +40 -39
  57. package/package.json +5 -1
@@ -50,6 +50,122 @@
50
50
  collectElements,
51
51
  } = deps;
52
52
 
53
+ function nativeReviewBlocked(path, code, applied = [], requestStarted = false) {
54
+ const reason = requestStarted
55
+ ? 'The native review request could not be fully verified; some target changes may already be accepted. No text replay was attempted. Check Overleaf Review before retrying.'
56
+ : 'The native tracked-change scope could not be safely isolated. No review mutation was attempted. Use Overleaf Review to handle the changes individually.';
57
+ return { ok: false, applied, skipped: [{ trackedChange: { path }, result: {
58
+ ok: false, code, reason, failure: {
59
+ ...buildPageFailure('tracked_changes_remain', { file: path, operationType: 'review',
60
+ changedDocument: requestStarted, userMessage: reason,
61
+ evidence: { originalCode: code, writeStarted: requestStarted, acceptedCount: applied.length } }),
62
+ // Preserve recovery instead of applying Accept's legacy partial-success policy.
63
+ severity: 'blocked', terminalState: 'blocked'
64
+ }
65
+ } }] };
66
+ }
67
+
68
+ async function waitForNativeReview(path, refs, untracked = false) {
69
+ const prepare = () => untracked ? prepareUntrackedUndo(path) : prepareTrackedChangeReview(path, refs);
70
+ for (let attempt = 0; attempt < 16; attempt++) {
71
+ const scope = prepare();
72
+ if (scope.ok) return scope;
73
+ await delay(150);
74
+ }
75
+ return prepare();
76
+ }
77
+
78
+ async function prepareNativeReview(params, allowUntrackedUndo = false) {
79
+ const refs = normalizeTrackedChangeRefs(params.trackedChanges || []);
80
+ // A configured browser adapter fails closed even while its ledger is loading.
81
+ // Legacy adapter callers without native integration retain their original path.
82
+ if (typeof deps.getCodeMirrorEditorView !== 'function'
83
+ && !refs.some(ref => ref.key.startsWith('native:'))) return null;
84
+ const untracked = allowUntrackedUndo && params.untrackedUndo === true && !refs.length;
85
+ if (untracked) {
86
+ const keys = files => Array.isArray(files) && files.length && files.every(file =>
87
+ normalizeSafeProjectPath(file?.path) && typeof file.content === 'string')
88
+ ? files.map(file => normalizeSafeProjectPath(file.path)).sort() : [];
89
+ const before = keys(params.expectedFiles), after = keys(params.postFiles);
90
+ if (!before.length || new Set(before).size !== before.length || JSON.stringify(before) !== JSON.stringify(after))
91
+ return nativeReviewBlocked('', 'native_untracked_undo_checkpoint_unavailable');
92
+ }
93
+ const checkpoints = [...(Array.isArray(params.expectedFiles) ? params.expectedFiles : []),
94
+ ...(Array.isArray(params.postFiles) ? params.postFiles : [])];
95
+ const paths = [...new Set([...refs.map(ref => ref.path),
96
+ ...checkpoints.map(file => normalizeSafeProjectPath(file?.path))])];
97
+ if ((!refs.length && !untracked) || refs.some(ref => ref.invalidProjectPath || !ref.path) || paths.some(path => !path))
98
+ return nativeReviewBlocked('', 'native_review_identity_unavailable');
99
+ const files = [], deadline = Date.now() + 90000;
100
+ for (const path of paths) {
101
+ if (Date.now() >= deadline || checkWritebackRunProjectId(params))
102
+ return nativeReviewBlocked(path, 'native_review_context_changed');
103
+ if (getActiveFilePath() !== path && !(await openFileByPath(path)).ok)
104
+ return nativeReviewBlocked(path, 'native_review_file_unavailable');
105
+ const scope = await waitForNativeReview(path, refs, untracked);
106
+ if (!scope.ok) return nativeReviewBlocked(path, scope.reason || 'native_review_unavailable');
107
+ files.push({ path, nativeDocId: scope.nativeDocId, scope });
108
+ }
109
+ return { ok: true, files, refs, deadline };
110
+ }
111
+
112
+ async function acceptNativeReview(params, plan) {
113
+ const applied = [];
114
+ let token = window?.document?.querySelector?.('meta[name="ol-csrfToken"]')?.getAttribute('content');
115
+ try { token = JSON.parse(token); } catch (_error) { /* Older pages expose plain text. */ }
116
+ if (typeof token !== 'string' || !token || token.length > 4096
117
+ || typeof window?.fetch !== 'function' || typeof window?.AbortController !== 'function')
118
+ return nativeReviewBlocked('', 'native_accept_transport_unavailable');
119
+ // History-OT uses snapshot-range operations, not the classic ID endpoint.
120
+ // An unsupported engine must never fall through to text undo/replay.
121
+ const unsupported = plan.files.find(file => !file.scope.acceptByIdSupported);
122
+ if (unsupported) return nativeReviewBlocked(unsupported.path, 'native_accept_protocol_unsupported');
123
+ for (const file of plan.files) {
124
+ const path = file.path;
125
+ if (Date.now() >= plan.deadline || checkWritebackRunProjectId(params))
126
+ return nativeReviewBlocked(path, 'native_review_context_changed', applied, applied.length > 0);
127
+ if (getActiveFilePath() !== path && !(await openFileByPath(path)).ok)
128
+ return nativeReviewBlocked(path, 'native_review_file_unavailable', applied, applied.length > 0);
129
+ await waitForNativeReview(path, plan.refs);
130
+ const scope = prepareTrackedChangeReview(path, plan.refs), text = readActiveEditorText();
131
+ if (!scope.ok || !scope.acceptByIdSupported || scope.nativeDocId !== file.nativeDocId
132
+ || typeof text !== 'string' || Date.now() >= plan.deadline || checkWritebackRunProjectId(params))
133
+ return nativeReviewBlocked(path, 'native_review_scope_changed', applied, applied.length > 0);
134
+ const preserved = JSON.stringify(scope.unrelated.map(ref => ref.key).sort()), ids = new Set(scope.ids);
135
+ const controller = new window.AbortController();
136
+ const timer = window.setTimeout(() => controller.abort(), Math.min(12000, Math.max(1, plan.deadline - Date.now())));
137
+ try {
138
+ // Same ID-scoped endpoint and CSRF header used by Overleaf's RangesProvider.
139
+ const response = await window.fetch('/project/' + encodeURIComponent(params.runProjectId)
140
+ + '/doc/' + encodeURIComponent(scope.nativeDocId) + '/changes/accept', {
141
+ method: 'POST', credentials: 'same-origin', redirect: 'error', signal: controller.signal,
142
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-Csrf-Token': token },
143
+ body: JSON.stringify({ change_ids: scope.ids })
144
+ });
145
+ if (!response.ok) return nativeReviewBlocked(path, 'native_accept_http_' + response.status, applied, true);
146
+ } catch (_error) {
147
+ return nativeReviewBlocked(path, 'native_accept_request_unconfirmed', applied, true);
148
+ } finally { window.clearTimeout(timer); }
149
+ let verified = false;
150
+ const until = Math.min(plan.deadline, Date.now() + 8000);
151
+ while (Date.now() < until) {
152
+ if (checkWritebackRunProjectId(params) || getActiveFilePath() !== path || readActiveEditorText() !== text)
153
+ return nativeReviewBlocked(path, 'native_accept_content_or_context_changed', applied, true);
154
+ const after = prepareTrackedChangeCapture(path);
155
+ if (after.source === 'native' && after.ready && after.nativeDocId === scope.nativeDocId) {
156
+ if (JSON.stringify(after.refs.filter(ref => !ids.has(ref.id)).map(ref => ref.key).sort()) !== preserved)
157
+ return nativeReviewBlocked(path, 'native_accept_unrelated_changes_changed', applied, true);
158
+ if (!after.refs.some(ref => ids.has(ref.id))) { verified = true; break; }
159
+ }
160
+ await delay(150);
161
+ }
162
+ if (!verified) return nativeReviewBlocked(path, 'native_accept_not_verified', applied, true);
163
+ applied.push(...scope.targets.map(trackedChange => ({ trackedChange,
164
+ result: { ok: true, method: 'overleaf-native-accept', verified: true } })));
165
+ }
166
+ return { ok: true, verified: true, applied, skipped: [] };
167
+ }
168
+
53
169
  async function rejectTrackedChanges(params = {}) {
54
170
  // Welcome-panel + write-guard: defense-in-depth.
55
171
  // The pageBridge wrapper already runs the runProjectId guard, but a
@@ -77,6 +193,10 @@
77
193
  };
78
194
  }
79
195
 
196
+ const nativeReview = await prepareNativeReview(params, true);
197
+ if (nativeReview && !nativeReview.ok) return nativeReview;
198
+ const mixedFile = nativeReview?.files.find(file => file.scope.unrelated.length);
199
+ if (mixedFile) return nativeReviewBlocked(mixedFile.path, 'native_reject_mixed_changes');
80
200
  const editorUndo = await rejectTrackedChangesViaEditorUndo(expectedFiles, postFiles, applied);
81
201
  if (editorUndo.ok) {
82
202
  if (applied.length > 0) {
@@ -250,21 +370,8 @@
250
370
  };
251
371
  }
252
372
 
253
- // Accept All — instead of hunting Overleaf's per-change Accept controls (which
254
- // is unreliable in real browser use), reuse two proven-reliable mechanisms:
255
- //
256
- // 1. Editor-undo the run's tracked writeback back to its pre-write content
257
- // (the reject path's primary mechanism), which makes every one of the
258
- // run's tracked changes vanish.
259
- // 2. Switch Overleaf to Editing mode (Track Changes OFF) and re-apply the
260
- // run's post-write content as a plain, untracked edit.
261
- //
262
- // Net result: the run's new content lands as permanent, untracked text — the
263
- // run is decisively accepted, with no DOM-control hunting.
264
- //
265
- // If the editor-undo cannot reach the pre-write state (content drifted — e.g.
266
- // the user edited after the run), this bails WITHOUT re-writing so it never
267
- // makes the document worse, mirroring Undo's safety stance.
373
+ // Native-ledger browsers accept exact IDs without changing document text.
374
+ // The original undo/replay flow remains only for legacy adapter callers.
268
375
  async function acceptTrackedChanges(params = {}) {
269
376
  // Welcome-panel + write-guard: defense-in-depth.
270
377
  // The pageBridge wrapper already runs the runProjectId guard, but a
@@ -273,6 +380,8 @@
273
380
  // guard uses.
274
381
  const writeGuardBlock = checkWritebackRunProjectId(params);
275
382
  if (writeGuardBlock) return writeGuardBlock;
383
+ const nativeReview = await prepareNativeReview(params);
384
+ if (nativeReview) return nativeReview.ok ? acceptNativeReview(params, nativeReview) : nativeReview;
276
385
  const expectedFiles = Array.isArray(params.expectedFiles) ? params.expectedFiles : [];
277
386
  const postFiles = Array.isArray(params.postFiles) ? params.postFiles : [];
278
387
  const applied = [];
@@ -377,20 +486,8 @@
377
486
  };
378
487
  }
379
488
 
380
- // Step 2: switch to Editing mode (Track Changes OFF) so the replay lands as
381
- // plain, untracked text.
382
- //
383
- // ensureEditing() short-circuits on isEditingConfirmedForNoTraceUndo, whose
384
- // "already Editing" detection is negation-based and false-positives while
385
- // Track Changes is actually ON (e.g. the Reviewing mode shows only as a
386
- // dropdown-trigger label with no active aria attribute, so reviewing is not
387
- // positively confirmed and a stray "Editing" menu option is read as the
388
- // current mode). Trusting that short-circuit replays the run while tracked.
389
- //
390
- // So do not trust the short-circuit here: read the reviewing state
391
- // explicitly, and if Reviewing/Track Changes is on — or Editing is not
392
- // positively confirmed OFF — force the toggle via setReviewingEnabled(false)
393
- // rather than ensureEditing's lenient path.
489
+ // Legacy replay requires positively confirmed Editing. The strict
490
+ // detector and its rationale are kept in forceEditingForAcceptReplay.
394
491
  const modeBefore = getReviewingState({});
395
492
  pushDiagnostic('modeBefore', summarizeReviewingStateForDiagnostics(modeBefore));
396
493
  const editingSwitch = await forceEditingForAcceptReplay();
@@ -1039,6 +1136,7 @@
1039
1136
  const paths = Array.from(expectedByPath.keys()).filter(path => postByPath.has(path));
1040
1137
  const rebasedExpectedByPath = new Map();
1041
1138
  const rebasedPostByPath = new Map();
1139
+ const undoRebaseProofs = new Map();
1042
1140
  if (!paths.length) {
1043
1141
  return {
1044
1142
  ok: false,
@@ -1084,6 +1182,9 @@
1084
1182
  }
1085
1183
  rebasedExpectedByPath.set(path, ready.expectedContent);
1086
1184
  rebasedPostByPath.set(path, ready.postContent);
1185
+ if (ready.prefixLength || ready.suffixLength) {
1186
+ undoRebaseProofs.set(path, { version: 1, path, beforeUndoContent: ready.postContent });
1187
+ }
1087
1188
  }
1088
1189
 
1089
1190
  const operations = paths.map(path => ({
@@ -1106,7 +1207,11 @@
1106
1207
  path: item.operation?.path || '',
1107
1208
  label: 'No-trace snapshot undo'
1108
1209
  },
1109
- result: item.result
1210
+ result: item.result?.ok !== false && item.result?.verified === true
1211
+ && undoRebaseProofs.has(item.operation?.path)
1212
+ && item.result.verifiedContent === rebasedExpectedByPath.get(item.operation?.path)
1213
+ ? { ...item.result, undoRebaseProof: undoRebaseProofs.get(item.operation.path) }
1214
+ : item.result
1110
1215
  });
1111
1216
  return {
1112
1217
  ok: !(result.skipped || []).length,
@@ -1444,124 +1549,11 @@
1444
1549
 
1445
1550
 
1446
1551
 
1447
- function diffTrackedChangeRefs(before = [], after = []) {
1448
- const beforeKeys = new Set((before || []).map(ref => ref.key).filter(Boolean));
1449
- const seen = new Set();
1450
- const added = [];
1451
- for (const ref of after || []) {
1452
- if (!ref?.key || beforeKeys.has(ref.key) || seen.has(ref.key)) {
1453
- continue;
1454
- }
1455
- seen.add(ref.key);
1456
- added.push(ref);
1457
- }
1458
- return added;
1459
- }
1460
-
1461
- async function waitForTrackedChangeDiff(trackedBefore, paths, options = {}) {
1462
- const waitMs = Number.isFinite(Number(options.waitMs)) ? Number(options.waitMs) : 3000;
1463
- const intervalMs = Number.isFinite(Number(options.intervalMs)) ? Number(options.intervalMs) : 180;
1464
- const deadline = Date.now() + Math.max(0, waitMs);
1465
- let latest = [];
1466
- while (Date.now() <= deadline) {
1467
- const trackedAfter = collectTrackedChangeRefsForPaths(paths);
1468
- latest = diffTrackedChangeRefs(trackedBefore, trackedAfter);
1469
- if (latest.length > 0) {
1470
- return {
1471
- ok: true,
1472
- trackedChanges: latest,
1473
- waitMs
1474
- };
1475
- }
1476
- await delay(intervalMs);
1477
- }
1478
- return {
1479
- ok: true,
1480
- trackedChanges: latest,
1481
- waitMs
1482
- };
1483
- }
1484
-
1485
- function collectTrackedChangeRefsForPaths(paths = []) {
1486
- const pathSet = new Set((paths || []).filter(Boolean));
1487
- const activePath = getActiveFilePath();
1488
- return collectTrackedChangeNodes()
1489
- .map(node => trackedChangeRefFromNode(node, activePath))
1490
- .filter(ref => ref.key)
1491
- .filter(ref => !pathSet.size || !ref.path || pathSet.has(ref.path));
1492
- }
1493
-
1494
- function collectTrackedChangeNodes() {
1495
- const selector = [
1496
- '[data-change-id]',
1497
- '[data-review-id]',
1498
- '[data-track-change-id]',
1499
- '[data-ol-change-id]',
1500
- '[data-path][class*="change" i]',
1501
- '.ol-cm-change',
1502
- '.review-panel-entry-change',
1503
- '[class*="track-change" i]',
1504
- '[class*="review-change" i]',
1505
- '[class*="suggest" i]',
1506
- '[aria-label*="change" i]',
1507
- '[title*="change" i]'
1508
- ].join(',');
1509
- return uniqueNodes([
1510
- ...collectElements(selector, 1200),
1511
- ...collectElements('*', 3500).filter(isTrackedChangeNode)
1512
- ]).filter(isTrackedChangeNode);
1513
- }
1514
-
1515
- function isTrackedChangeNode(node) {
1516
- if (!node || isInsideCodexPanel(node)) {
1517
- return false;
1518
- }
1519
- const signal = normalizeReviewingSignalText(readNodeSignalText(node));
1520
- const tag = (node.tagName || '').toLowerCase();
1521
- if (tag === 'button' && /\b(?:accept|reject|decline|discard|批准|接受|拒绝|丢弃)\b/i.test(signal)) {
1522
- return false;
1523
- }
1524
- if (readTrackedChangeId(node)) {
1525
- return true;
1526
- }
1527
- return /\b(?:tracked change|track change|review change|suggestion|insert(?:ion)?|delet(?:e|ion)|change)\b/i.test(signal)
1528
- || /留痕|建议|插入|删除|更改|修改/.test(signal);
1529
- }
1530
-
1531
- function trackedChangeRefFromNode(node, fallbackPath = '') {
1532
- const id = readTrackedChangeId(node);
1533
- const key = id ? `id:${id}` : `sig:${compact(readNodeSignalText(node), 180)}`;
1534
- const path = normalizeSafeProjectPath(
1535
- node.getAttribute?.('data-path')
1536
- || node.getAttribute?.('data-file-path')
1537
- || node.getAttribute?.('data-doc-path')
1538
- || fallbackPath
1539
- || ''
1540
- );
1541
- return {
1542
- key,
1543
- id,
1544
- path,
1545
- label: compact(readNodeSignalText(node), 180)
1546
- };
1547
- }
1548
-
1549
- function readTrackedChangeId(node) {
1550
- for (const attribute of [
1551
- 'data-change-id',
1552
- 'data-review-id',
1553
- 'data-track-change-id',
1554
- 'data-ol-change-id',
1555
- 'data-id',
1556
- 'id'
1557
- ]) {
1558
- const value = node.getAttribute?.(attribute) || '';
1559
- if (value && /\b(?:change|review|track|suggest)|\d|[a-f0-9-]{8,}/i.test(value)) {
1560
- return String(value);
1561
- }
1562
- }
1563
- return '';
1564
- }
1552
+ const captureModule = window?.CodexOverleafTrackedChangeCapture
1553
+ || (typeof module === 'object' && module.exports ? require('./trackedChangeCapture.js') : null);
1554
+ const { collectTrackedChangeNodes, trackedChangeRefFromNode, collectTrackedChangeRefsForPaths,
1555
+ waitForTrackedChangeDiff, prepareTrackedChangeCapture, prepareTrackedChangeReview, prepareUntrackedUndo, getTrackedChangeCaptureStatus,
1556
+ captureTrackedWrite, reconcileTrackedChangeCapture } = captureModule.create(deps);
1565
1557
 
1566
1558
  function orderTrackedChangesForReviewAction(refs = []) {
1567
1559
  return (refs || []).slice().reverse();
@@ -1573,7 +1565,8 @@
1573
1565
  return null;
1574
1566
  }
1575
1567
  return collectTrackedChangeNodes()
1576
- .find(node => trackedChangeRefFromNode(node, ref.path || getActiveFilePath()).key === targetKey)
1568
+ .find(node => trackedChangeRefFromNode(node, ref.path || getActiveFilePath()).key === targetKey
1569
+ || (targetKey.startsWith('sig:') && 'sig:' + compact(readNodeSignalText(node), 180) === targetKey))
1577
1570
  || null;
1578
1571
  }
1579
1572
 
@@ -1639,6 +1632,9 @@
1639
1632
  acceptTrackedChanges,
1640
1633
  rejectTrackedChanges,
1641
1634
  collectTrackedChangeRefsForPaths,
1635
+ prepareTrackedChangeCapture, getTrackedChangeCaptureStatus,
1636
+ captureTrackedWrite,
1637
+ reconcileTrackedChangeCapture,
1642
1638
  waitForTrackedChangeDiff
1643
1639
  };
1644
1640
  }
@@ -97,6 +97,25 @@
97
97
  return match ? decodeURIComponent(match[1]) : null;
98
98
  }
99
99
 
100
+ function readNativeTreePath(node) {
101
+ const row = node?.closest?.('[role="treeitem"]');
102
+ const root = document.querySelector?.('[data-testid="file-tree-list-root"]');
103
+ if (!row || !root?.contains?.(row) || !row.querySelector?.('[data-file-type="doc"][data-file-id]')) return '';
104
+ const parts = [row.getAttribute('aria-label')];
105
+ let group = row.closest('[role="tree"]');
106
+ const seen = new Set();
107
+ while (group && group !== root) {
108
+ if (seen.has(group)) return '';
109
+ seen.add(group);
110
+ const folder = group.previousElementSibling;
111
+ if (folder?.getAttribute?.('role') !== 'treeitem' || !folder.querySelector?.('[data-file-type="folder"]')) return '';
112
+ parts.unshift(folder.getAttribute('aria-label'));
113
+ group = group.parentElement?.closest?.('[role="tree"]');
114
+ }
115
+ if (group !== root || parts.some(part => !part || /[/\\]/.test(part))) return '';
116
+ return normalizeSafeProjectPath(parts.join('/'));
117
+ }
118
+
100
119
  function getActiveFilePath() {
101
120
  const editorStorePath = getEditorStoreFilePath({ disambiguateBasename: true });
102
121
  if (editorStorePath && window.CodexOverleafProjectFiles.isTextProjectPath(editorStorePath) && !hasMultipleTextPathExtensions(editorStorePath)) {
@@ -121,7 +140,7 @@
121
140
  for (const selector of selectors) {
122
141
  const nodes = Array.from(document.querySelectorAll?.(selector) || []);
123
142
  for (const node of nodes) {
124
- const path = node ? readProjectPathFromNode(node) : '';
143
+ const path = node ? readNativeTreePath(node) || readProjectPathFromNode(node) : '';
125
144
  if (isActiveFilePathCandidate(path, node)) {
126
145
  candidates.push({
127
146
  node,
@@ -408,6 +427,11 @@
408
427
  const pathNodes = typeof event?.composedPath === 'function'
409
428
  ? event.composedPath()
410
429
  : [];
430
+ if (pathNodes.some(node => node?.id === 'codex-overleaf-panel')
431
+ || event?.target?.closest?.('#codex-overleaf-panel')) return '';
432
+ const insideTree = pathNodes.some(node => /(?:^|\s)(?:file-tree|project-tree|file-tree-list)(?:\s|$)/.test(String(node?.className || '')))
433
+ || event?.target?.closest?.('.file-tree, .project-tree, .file-tree-list');
434
+ if (!insideTree) return '';
411
435
  for (const node of pathNodes) {
412
436
  if (!node || typeof node.getAttribute !== 'function') {
413
437
  continue;
@@ -1355,7 +1379,10 @@
1355
1379
  const activeFileMatches = !filePath || getActiveFilePath() === filePath;
1356
1380
  const text = readActiveEditorText();
1357
1381
  lastText = text;
1358
- const textReady = window.CodexOverleafProjectFiles.isUsableProjectFileContent(text);
1382
+ const knownEmptyEditor = options.allowEmpty === true && typeof text === 'string' && /^(?:\r?\n)*$/.test(text)
1383
+ && Boolean(getActiveEditorIdentity());
1384
+ const textReady = options.requireEmpty === true ? knownEmptyEditor
1385
+ : knownEmptyEditor || window.CodexOverleafProjectFiles.isUsableProjectFileContent(text);
1359
1386
  const signatureChanged = !options.notSignature || contentSignature(text) !== options.notSignature;
1360
1387
  if (activeFileMatches && textReady && signatureChanged) {
1361
1388
  return {
@@ -1552,6 +1579,7 @@
1552
1579
  collectInternalRootKeys,
1553
1580
  collectProjectTextPaths,
1554
1581
  contentSignature,
1582
+ invalidateDomProjectPathCache,
1555
1583
  createFile,
1556
1584
  deleteFile,
1557
1585
  fileTreeMethodNames,