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
@@ -0,0 +1,153 @@
1
+ (function (root, factory) {
2
+ const api = factory();
3
+ if (typeof module === 'object' && module.exports) module.exports = api;
4
+ if (root) root.CodexOverleafTrackedChangeCaptureController = api;
5
+ })(typeof window !== 'undefined' ? window : globalThis, function () {
6
+ 'use strict';
7
+ function create(deps = {}) {
8
+ const now = deps.now || (() => Date.now());
9
+ const defer = deps.setTimeout || setTimeout;
10
+ const cancelTimer = deps.clearTimeout || clearTimeout;
11
+ let timer = null, busy = false, disposed = false;
12
+ const runs = () => deps.getRuns() || [];
13
+ const pending = run => Array.isArray(run.trackedChangeCaptures)
14
+ && run.trackedChangeCaptures.some(capture => capture.state === 'pending');
15
+ const terminal = run => ['accepted', 'rejected'].includes(run.trackedChangeStatus)
16
+ || run.undoStatus === 'applied' || run.forkSnapshot === true;
17
+ function buildPostFiles(run) {
18
+ const expectedFiles = Array.isArray(run?.undoExpectedFiles) ? run.undoExpectedFiles : [];
19
+ const appliedOperations = Array.isArray(run?.appliedOperations) ? run.appliedOperations : [];
20
+ if (!expectedFiles.length || !appliedOperations.length) return [];
21
+ const postFilesByPath = deps.buildExpectedFilesAfterOperations({ files: expectedFiles }, appliedOperations);
22
+ const expectedPaths = new Set(expectedFiles.map(file => file?.path).filter(Boolean));
23
+ return Array.from(postFilesByPath.entries())
24
+ .filter(([path, content]) => expectedPaths.has(path) && typeof content === 'string')
25
+ .map(([path, content]) => ({ path, content }));
26
+ }
27
+ function hasLegacyUntrackedCheckpoint(run) {
28
+ if (!run || run.executionSnapshot || run.trackedChangeStatus || run.undoStatus === 'applied'
29
+ || run.forkSnapshot || run.undoTrackedChanges?.length || run.trackedChangeCaptures?.length) return false;
30
+ const operations = Array.isArray(run.undoOperations) ? run.undoOperations : [];
31
+ const before = Array.isArray(run.undoExpectedFiles) ? run.undoExpectedFiles : [];
32
+ if (!operations.length || !before.length || before.some(file => !file?.path || typeof file.content !== 'string')) return false;
33
+ const paths = new Set(before.map(file => file.path));
34
+ if (paths.size !== before.length || operations.some(op => op?.type !== 'edit' || !paths.has(op.path))) return false;
35
+ const complete = files => Array.isArray(files) && files.length === paths.size
36
+ && new Set(files.map(file => file?.path)).size === paths.size
37
+ && files.every(file => paths.has(file?.path) && typeof file.content === 'string');
38
+ const storedPost = run.undoBaseFiles;
39
+ try {
40
+ const post = buildPostFiles(run);
41
+ if (!complete(storedPost) || !complete(post)) return false;
42
+ const postByPath = new Map(post.map(file => [file.path, file.content]));
43
+ if (!storedPost.every(file => postByPath.get(file.path) === file.content)) return false;
44
+ const restored = deps.buildExpectedFilesAfterOperations({ files: storedPost }, operations);
45
+ // A legacy checkpoint must prove both directions; empty refs alone grant nothing.
46
+ return before.every(file => restored.get(file.path) === file.content);
47
+ } catch (_error) { return false; }
48
+ }
49
+ function schedule() {
50
+ if (disposed || timer !== null || busy || !runs().some(pending)) return;
51
+ timer = defer(() => {
52
+ timer = null;
53
+ flush().catch(error => deps.onError?.(error));
54
+ }, 500);
55
+ }
56
+ function record(run, captures) {
57
+ if (!Array.isArray(captures) || !captures.length) return;
58
+ const next = [...(run.trackedChangeCaptures || []), ...captures.filter(Boolean)];
59
+ if (next.every(capture => capture.state === 'observed')) return;
60
+ run.trackedChangeCaptures = next;
61
+ deps.notify?.(run, next.some(capture => capture.state === 'pending') ? 'pending' : 'needs_review', {
62
+ captures: next.map(capture => ({ path: capture.path, reason: capture.reason, ...capture.diagnostics }))
63
+ });
64
+ deps.persist().then(schedule).catch(error => deps.onError?.(error));
65
+ }
66
+ async function flush() {
67
+ if (disposed || busy) return;
68
+ busy = true;
69
+ try {
70
+ for (const run of runs().filter(pending)) {
71
+ if (terminal(run)) { run.trackedChangeCaptures = []; await deps.persist(); continue; }
72
+ if (run.runProjectId !== deps.getProjectId()) continue;
73
+ const newer = runs().some(other => other.id !== run.id
74
+ && other.runProjectId === run.runProjectId
75
+ && Date.parse(other.startedAt) > Date.parse(run.startedAt));
76
+ const original = run.trackedChangeCaptures;
77
+ const captures = original.map(capture => ({ ...capture }));
78
+ const postFiles = buildPostFiles(run);
79
+ for (let i = 0; i < captures.length; i++) {
80
+ const capture = captures[i];
81
+ if (capture.state !== 'pending') continue;
82
+ const expected = postFiles.find(file => file.path === capture.path);
83
+ if (newer || !expected || now() >= capture.expiresAt || run.status === 'cancelled') {
84
+ capture.state = 'needs_review';
85
+ capture.reason = newer ? 'capture_superseded' : !expected ? 'capture_baseline_missing'
86
+ : run.status === 'cancelled' ? 'capture_cancelled' : 'capture_expired';
87
+ continue;
88
+ }
89
+ let response;
90
+ try {
91
+ response = await deps.callPageBridge('reconcileTrackedChangeCapture', {
92
+ runProjectId: run.runProjectId, capture, expectedContent: expected.content
93
+ });
94
+ } catch (_error) {
95
+ response = null;
96
+ }
97
+ // Never patch a superseded hydrated record or another project's state.
98
+ if (disposed || deps.getProjectId() !== run.runProjectId
99
+ || !runs().includes(run) || terminal(run) || run.trackedChangeCaptures !== original) return;
100
+ const superseded = runs().some(other => other.id !== run.id
101
+ && other.runProjectId === run.runProjectId
102
+ && Date.parse(other.startedAt) > Date.parse(run.startedAt));
103
+ if (superseded || run.status === 'cancelled') {
104
+ captures[i] = { ...capture, state: 'needs_review', reason: 'capture_superseded' };
105
+ } else if (response?.capture?.path === capture.path
106
+ && response.capture.runProjectId === run.runProjectId
107
+ && ['observed', 'pending', 'needs_review'].includes(response.capture.state)) {
108
+ captures[i] = response.capture;
109
+ } else {
110
+ captures[i] = { ...capture, state: 'needs_review', reason: 'capture_bridge_unavailable' };
111
+ }
112
+ }
113
+ if (!runs().includes(run) || terminal(run) || deps.getProjectId() !== run.runProjectId) continue;
114
+ if (captures.length && captures.every(capture => capture.state === 'observed')) {
115
+ const refs = deps.normalizeTrackedChanges([
116
+ ...(run.undoTrackedChanges || []), ...captures.flatMap(capture => capture.refs || [])
117
+ ]);
118
+ if (refs.length) {
119
+ run.undoTrackedChanges = refs;
120
+ run.trackedChangeStatus = 'pending';
121
+ run.trackedChangeCaptures = [];
122
+ await deps.persist();
123
+ deps.refresh(run);
124
+ deps.notify?.(run, 'observed', { count: refs.length });
125
+ continue;
126
+ }
127
+ }
128
+ const changed = JSON.stringify(original.map(summary)) !== JSON.stringify(captures.map(summary));
129
+ if (changed) {
130
+ run.trackedChangeCaptures = captures;
131
+ await deps.persist();
132
+ if (!pending(run)) deps.notify?.(run, 'needs_review', {
133
+ reasons: captures.filter(capture => capture.state !== 'observed').map(capture => capture.reason)
134
+ });
135
+ }
136
+ }
137
+ } finally {
138
+ busy = false;
139
+ if (runs().some(run => pending(run) && run.runProjectId === deps.getProjectId())) schedule();
140
+ }
141
+ }
142
+ function summary(capture) {
143
+ return { state: capture.state, reason: capture.reason, refs: (capture.refs || []).map(ref => ref.key) };
144
+ }
145
+ function dispose() {
146
+ disposed = true;
147
+ if (timer !== null) cancelTimer(timer);
148
+ timer = null;
149
+ }
150
+ return { record, schedule, flush, dispose, buildPostFiles, hasLegacyUntrackedCheckpoint };
151
+ }
152
+ return { create };
153
+ });
@@ -90,7 +90,8 @@
90
90
  install: 'codex-overleaf/consent-update-install',
91
91
  retry: 'codex-overleaf/consent-update-check',
92
92
  later: 'codex-overleaf/consent-update-later',
93
- dismiss: 'codex-overleaf/consent-update-dismiss'
93
+ dismiss: 'codex-overleaf/consent-update-dismiss',
94
+ reload: 'codex-overleaf/consent-update-reload'
94
95
  }[action]);
95
96
  } catch (error) {
96
97
  currentView = await reconcileAfterActionError(error, {
@@ -212,7 +213,10 @@
212
213
  const stateName = currentView?.state?.state || 'idle';
213
214
  let requestedAction = 'check';
214
215
  try {
215
- if (stateName === 'update_available') {
216
+ if (['reload_required', 'reload_tabs_required'].includes(stateName)) {
217
+ requestedAction = 'reload';
218
+ currentView = await request('codex-overleaf/consent-update-reload');
219
+ } else if (stateName === 'update_available') {
216
220
  requestedAction = 'install';
217
221
  currentView = await request('codex-overleaf/consent-update-install');
218
222
  } else if (['failed', 'rolled_back'].includes(stateName)) {
@@ -276,7 +280,7 @@
276
280
  body.append(recovery, command);
277
281
  }
278
282
 
279
- if (!['update_available', 'failed', 'rolled_back'].includes(state.state)) {
283
+ if (!['update_available', 'failed', 'rolled_back', 'reload_required', 'reload_tabs_required'].includes(state.state)) {
280
284
  const bar = document.createElement('div');
281
285
  bar.className = 'codex-update-notice-progress' + (progress.determinate ? '' : ' is-indeterminate');
282
286
  bar.setAttribute('role', 'progressbar');
@@ -328,7 +332,11 @@
328
332
  let statusText = '';
329
333
  let buttonText = tx('Check for updates', '检查更新');
330
334
 
331
- if (stateName === 'checking') {
335
+ if (['reload_required', 'reload_tabs_required'].includes(stateName)) {
336
+ summaryText = tx(`Installed v${latestVersion}; running extension v${currentVersion}.`, `已安装 v${latestVersion};扩展当前运行 v${currentVersion}。`);
337
+ statusText = getCopy(state).detail;
338
+ buttonText = stateName === 'reload_required' ? tx('Reload extension', '重新加载扩展') : tx('Refresh Overleaf tabs', '刷新 Overleaf 标签页');
339
+ } else if (stateName === 'checking') {
332
340
  statusText = tx('Checking the latest stable release…', '正在检查最新稳定版本…');
333
341
  buttonText = tx('Checking…', '检查中…');
334
342
  } else if (stateName === 'update_available') {
@@ -355,6 +363,10 @@
355
363
 
356
364
  function visibleActions(state) {
357
365
  if (actionInFlight) return [];
366
+ if (['reload_required', 'reload_tabs_required'].includes(state)) {
367
+ return [{ id: 'reload', label: state === 'reload_required'
368
+ ? tx('Reload extension', '重新加载扩展') : tx('Refresh Overleaf tabs', '刷新 Overleaf 标签页'), primary: true }];
369
+ }
358
370
  if (state === 'update_available') {
359
371
  return [
360
372
  { id: 'later', label: tx('Later', '稍后'), primary: false },
@@ -380,6 +392,14 @@
380
392
  const target = state.latestVersion ? 'v' + state.latestVersion : '';
381
393
  const blocker = blockersCopy(state.blockers?.length ? state.blockers : [state.blocker]);
382
394
  return {
395
+ reload_required: {
396
+ eyebrow: tx('Installed, awaiting reload', '已安装,等待加载'), title: target,
397
+ detail: state.message || tx('The new files are installed. Reload the extension when Overleaf is saved and idle.', '新版文件已安装,请在 Overleaf 保存并空闲后重新加载扩展。')
398
+ },
399
+ reload_tabs_required: {
400
+ eyebrow: tx('Updated runtime is ready', '新版运行组件已就绪'), title: target,
401
+ detail: state.message || tx('Refresh the saved, idle Overleaf tabs to load the updated panel.', '刷新已保存且空闲的 Overleaf 标签页以加载新版面板。')
402
+ },
383
403
  update_available: {
384
404
  eyebrow: tx('Update available', '发现新版本'),
385
405
  title: target,
@@ -18,6 +18,7 @@
18
18
  let tx;
19
19
  let getLocale;
20
20
  let appendRunEvent;
21
+ let appendRunRecordEvent;
21
22
  // In-flight background mirror refresh (v1.7.5) — see the writeback path.
22
23
  let pendingMirrorRefresh = null;
23
24
  let appendChangeSummary;
@@ -69,6 +70,34 @@
69
70
  : null;
70
71
  }
71
72
 
73
+ function invalidateMirrorAfterUndo(runId, projectId, result = {}) {
74
+ if (!projectId || (result?.changedDocument !== true && !result?.applied?.length)) return Promise.resolve('not-attempted');
75
+ const pending = Promise.resolve(pendingMirrorRefresh).catch(() => {}).then(async () => {
76
+ const response = await sendBackgroundNative({ method: 'mirror.invalidate', params: { projectId } });
77
+ if (!response?.ok || response.result?.invalidated !== true) {
78
+ throw new Error(response?.error?.message || 'Native host did not confirm mirror invalidation.');
79
+ }
80
+ if (getCurrentProjectId() === projectId) {
81
+ resetContextProject();
82
+ await callPageBridge('invalidateProjectSnapshot', { invalidateFileList: true });
83
+ }
84
+ return 'invalidated';
85
+ }).catch(error => {
86
+ appendRunRecordEvent?.(runId, {
87
+ status: 'warning',
88
+ title: tx(
89
+ `Overleaf undo finished, but local mirror invalidation failed: ${error.message}. Refresh this project or reconnect the native host before running again.`,
90
+ `Overleaf 撤销已完成,但本地镜像失效处理失败:${error.message}。请刷新项目或重新连接 Native Host 后再运行。`
91
+ )
92
+ });
93
+ return 'failed';
94
+ }).finally(() => {
95
+ if (pendingMirrorRefresh === pending) pendingMirrorRefresh = null;
96
+ });
97
+ pendingMirrorRefresh = pending;
98
+ return pending;
99
+ }
100
+
72
101
  async function applySyncChangesToOverleaf(syncChanges = [], project = {}, options = {}) {
73
102
  const runMode = options.mode || getState().mode;
74
103
  const runRequireReviewing = typeof options.requireReviewing === 'boolean'
@@ -822,6 +851,7 @@
822
851
  tx,
823
852
  getLocale,
824
853
  appendRunEvent,
854
+ appendRunRecordEvent,
825
855
  appendChangeSummary,
826
856
  appendCompletionReport,
827
857
  appendOperationsPreview,
@@ -867,6 +897,7 @@
867
897
  return {
868
898
  applySyncChangesToOverleaf,
869
899
  resolveCompileLogContext,
900
+ invalidateMirrorAfterUndo,
870
901
  getPendingMirrorRefresh: () => pendingMirrorRefresh
871
902
  };
872
903
  }
@@ -36,6 +36,7 @@
36
36
  size,
37
37
  sha256: String(params.sha256).toLowerCase(),
38
38
  mimeType: String(params.mimeType || 'application/octet-stream'),
39
+ projectId: treeOperations.getProjectId(),
39
40
  overwrite: params.overwrite === true,
40
41
  chunks: [],
41
42
  received: 0
@@ -66,6 +67,7 @@
66
67
  const transfer = transfers.get(transferId);
67
68
  if (!transfer) return failure('binary_upload_transfer_missing', 'Asset upload transfer was not found.');
68
69
  try {
70
+ assertTransferProject(transfer);
69
71
  if (transfer.received !== transfer.size) return failure('binary_upload_incomplete', 'Asset upload is incomplete.');
70
72
  const bytes = joinChunks(transfer.chunks, transfer.size);
71
73
  const digest = await sha256Hex(bytes, windowRef.crypto);
@@ -84,9 +86,12 @@
84
86
  const errors = [];
85
87
  for (const attempt of attempts) {
86
88
  try {
89
+ assertTransferProject(transfer);
87
90
  const result = await attempt();
88
- const observed = result?.ok ? await waitForPath(transfer.path, 12000) : null;
89
- const hashVerified = observed ? await verifyRemoteHash(observed, transfer).catch(() => false) : false;
91
+ const evidence = result?.ok ? await waitForPath(transfer, result, 12000) : null;
92
+ const observed = evidence?.observed;
93
+ const hashVerified = evidence?.hashVerified === true;
94
+ assertTransferProject(transfer);
90
95
  const overwriteVerified = !transfer.overwrite || hashVerified || result?.confirmedByTransport === true;
91
96
  if (result?.ok && observed && overwriteVerified) {
92
97
  return { ok: true, written: true, path: transfer.path, method: result.method, changedDocument: true };
@@ -97,7 +102,8 @@
97
102
  errors.push(error.message);
98
103
  }
99
104
  }
100
- return failure('binary_upload_unavailable', errors.filter(Boolean).join('; ') || 'No supported Overleaf asset upload path succeeded.');
105
+ return { ...failure('binary_upload_unavailable', errors.filter(Boolean).join('; ') || 'No supported Overleaf asset upload path succeeded.'),
106
+ changedDocument: transfer.mutationAttempted === true };
101
107
  } finally {
102
108
  transfers.delete(transferId);
103
109
  }
@@ -107,6 +113,13 @@
107
113
  return { ok: true, aborted: transfers.delete(String(params.transferId || '')) };
108
114
  }
109
115
 
116
+ function assertTransferProject(transfer) {
117
+ if (transfers.get(transfer.id) !== transfer) throw new Error('Asset upload was cancelled or replaced.');
118
+ if (!transfer.projectId || treeOperations.getProjectId() !== transfer.projectId) {
119
+ throw new Error('Project changed during asset upload.');
120
+ }
121
+ }
122
+
110
123
  async function uploadWithMultipart(file, transfer) {
111
124
  const projectId = treeOperations.getProjectId();
112
125
  if (!projectId || typeof windowRef.fetch !== 'function') return { ok: false, reason: 'Project upload endpoint is unavailable.' };
@@ -120,6 +133,7 @@
120
133
  const csrf = findCsrfToken();
121
134
  const headers = csrf ? { 'X-CSRF-Token': csrf } : {};
122
135
  const query = folderId ? `?folder_id=${encodeURIComponent(folderId)}` : '';
136
+ assertTransferProject(transfer);
123
137
  const response = await windowRef.fetch(`/project/${encodeURIComponent(projectId)}/upload${query}`, {
124
138
  method: 'POST', body: form, credentials: 'same-origin', headers
125
139
  });
@@ -143,11 +157,16 @@
143
157
  async function uploadWithDom(file, transfer) {
144
158
  const parentPath = transfer.path.split('/').slice(0, -1).join('/');
145
159
  if (parentPath) {
146
- const folderNode = treeOperations.findFileTreeNode(parentPath);
147
- if (!folderNode) return { ok: false, reason: `Target folder ${parentPath} is unavailable in the Overleaf file tree.` };
148
- folderNode?.dispatchEvent(new windowRef.MouseEvent('click', { bubbles: true }));
149
- await delay(120);
160
+ if (typeof deps.prepareUploadParent !== 'function') {
161
+ return { ok: false, reason: 'Verified folder selection is unavailable for nested asset upload.' };
162
+ }
163
+ await deps.prepareUploadParent(parentPath, {
164
+ createMissing: true,
165
+ onMutation: () => { transfer.mutationAttempted = true; },
166
+ isCurrent: () => treeOperations.getProjectId() === transfer.projectId && transfers.get(transfer.id) === transfer
167
+ });
150
168
  }
169
+ assertTransferProject(transfer);
151
170
  let input = findFileInput();
152
171
  if (!input) {
153
172
  const button = findUploadButton();
@@ -158,10 +177,12 @@
158
177
  if (!input) return { ok: false, reason: 'Overleaf upload file input was not found.' };
159
178
  const data = new windowRef.DataTransfer();
160
179
  data.items.add(file);
180
+ assertTransferProject(transfer);
161
181
  assignFilesToInput(input, data.files, windowRef);
182
+ transfer.mutationAttempted = true;
162
183
  input.dispatchEvent(new windowRef.Event('input', { bubbles: true }));
163
184
  input.dispatchEvent(new windowRef.Event('change', { bubbles: true }));
164
- await clickReplaceIfShown();
185
+ await clickReplaceIfShown(transfer);
165
186
  return { ok: true, method: 'overleaf.file-input' };
166
187
  }
167
188
 
@@ -179,9 +200,9 @@
179
200
  }
180
201
 
181
202
  async function findFolderId(parentPath) {
182
- const node = parentPath ? treeOperations.findFileTreeNode(parentPath) : null;
183
- const element = node?.closest?.('[data-entity-id], [data-folder-id], [data-id]') || node;
184
- const domId = element?.dataset?.entityId || element?.dataset?.folderId || element?.dataset?.id;
203
+ const node = parentPath ? (deps.findFolderNode?.(parentPath)
204
+ || findNativeTreeNode(parentPath, 'folder') || treeOperations.findFileTreeNode(parentPath)) : null;
205
+ const domId = readTreeEntityId(node);
185
206
  if (domId) return domId;
186
207
  for (const root of [windowRef._ide, windowRef.Overleaf, windowRef.overleaf, windowRef.OL]) {
187
208
  const folder = root?.project?.rootFolder?.[0] || root?.project?.rootFolder || root?.rootFolder?.[0] || root?.rootFolder;
@@ -223,31 +244,82 @@
223
244
  }
224
245
  return null;
225
246
  }
226
- async function clickReplaceIfShown() {
247
+ async function clickReplaceIfShown(transfer) {
227
248
  await delay(180);
228
- const button = Array.from(documentRef.querySelectorAll('button')).find(node => /replace|overwrite|替换|覆盖/i.test(node.textContent || ''));
249
+ assertTransferProject(transfer);
250
+ if (!transfer.overwrite) return;
251
+ const button = Array.from(documentRef.querySelectorAll('[role="dialog"] button')).find(node => !isCodexOwnedNode(node)
252
+ && /replace|overwrite|替换|覆盖/i.test(node.textContent || ''));
229
253
  if (button) button.click();
230
254
  }
231
- async function waitForPath(projectPath, timeoutMs) {
255
+ function readTreeEntityId(node) {
256
+ const owner = node?.closest?.('[data-entity-id], [data-file-id], [data-folder-id], [data-id]');
257
+ const row = node?.closest?.('[role="treeitem"]') || node;
258
+ const entity = row?.querySelector?.(':scope > .entity[data-file-id]');
259
+ for (const candidate of [node, entity, owner]) {
260
+ const id = candidate?.dataset?.entityId || candidate?.dataset?.fileId
261
+ || candidate?.dataset?.folderId || candidate?.dataset?.id;
262
+ if (id) return id;
263
+ }
264
+ return '';
265
+ }
266
+ function findNativeTreeNode(projectPath, type) {
267
+ const root = documentRef.querySelector?.('[data-testid="file-tree-list-root"]');
268
+ for (const row of root?.querySelectorAll?.('[role="treeitem"]') || []) {
269
+ const entity = row.querySelector?.(':scope > .entity[data-file-id]');
270
+ if (entity?.dataset?.fileType === type && readNativeTreePath(row, root) === projectPath) return row;
271
+ }
272
+ return null;
273
+ }
274
+ function readNativeTreePath(row, root) {
275
+ const parts = [row.getAttribute('aria-label')];
276
+ let group = row.closest('[role="tree"]');
277
+ const seen = new Set();
278
+ while (group && group !== root) {
279
+ if (seen.has(group) || seen.size >= 64) return '';
280
+ seen.add(group);
281
+ const folder = group.previousElementSibling;
282
+ const entity = folder?.querySelector?.(':scope > .entity[data-file-id]');
283
+ if (folder?.getAttribute?.('role') !== 'treeitem' || entity?.dataset?.fileType !== 'folder') return '';
284
+ parts.unshift(folder.getAttribute('aria-label'));
285
+ group = group.parentElement?.closest?.('[role="tree"]');
286
+ }
287
+ if (group !== root || parts.some(part => !part || /[/\\]/.test(part))) return '';
288
+ return parts.join('/');
289
+ }
290
+ async function waitForPath(transfer, result, timeoutMs) {
291
+ const projectPath = transfer.path;
232
292
  const deadline = Date.now() + timeoutMs;
293
+ let lastObserved = null;
233
294
  while (Date.now() < deadline) {
234
- if (treeOperations.projectPathExists(projectPath)) {
235
- const node = treeOperations.findFileTreeNode(projectPath);
236
- return {
237
- path: projectPath,
238
- id: node?.dataset?.entityId || node?.dataset?.fileId || node?.dataset?.id || ''
239
- };
295
+ assertTransferProject(transfer);
296
+ const node = findNativeTreeNode(projectPath, 'file')
297
+ || treeOperations.findFileTreeNode(projectPath, { invalidateCache: true });
298
+ let observed = node || treeOperations.projectPathExists(projectPath)
299
+ ? { path: projectPath, id: readTreeEntityId(node) } : null;
300
+ if (!observed?.id) {
301
+ try {
302
+ const list = await snapshotRouter?.buildProjectFileList({
303
+ force: true, maxAgeMs: 0, preferLightweight: true, allowZipFallback: false
304
+ });
305
+ const file = (list?.files || []).find(item => item.path === projectPath);
306
+ if (file) observed = file;
307
+ } catch (_error) { /* continue until the deadline */ }
308
+ }
309
+ assertTransferProject(transfer);
310
+ if (observed) {
311
+ lastObserved = observed;
312
+ const hashVerified = await verifyRemoteHash(observed, transfer).catch(() => false);
313
+ assertTransferProject(transfer);
314
+ // An existing path is not replacement evidence. Re-read identity and
315
+ // bytes while the native uploader and file-tree events settle.
316
+ if (!transfer.overwrite || hashVerified || result.confirmedByTransport === true) {
317
+ return { observed, hashVerified };
318
+ }
240
319
  }
241
320
  await delay(250);
242
- try {
243
- const list = await snapshotRouter?.buildProjectFileList({
244
- force: true, maxAgeMs: 0, preferLightweight: true, allowZipFallback: false
245
- });
246
- const file = (list?.files || []).find(item => item.path === projectPath);
247
- if (file) return file;
248
- } catch (_error) { /* continue until the deadline */ }
249
321
  }
250
- return null;
322
+ return { observed: lastObserved, hashVerified: false };
251
323
  }
252
324
  async function verifyRemoteHash(observed, transfer) {
253
325
  const fileId = observed?.id || observed?._id || observed?.entityId || observed?.fileId || '';
@@ -339,5 +411,5 @@
339
411
  return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('');
340
412
  }
341
413
  function failure(code, reason) { return { ok: false, code, reason, changedDocument: false }; }
342
- return { chooseOverleafFileInput, create, formatUploadHttpError, joinChunks, normalizePath };
414
+ return { assignFilesToInput, chooseOverleafFileInput, create, formatUploadHttpError, joinChunks, normalizePath };
343
415
  });