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,493 @@
1
+ (function (root, factory) {
2
+ if (typeof module === 'object' && module.exports) module.exports = factory();
3
+ else root.CodexOverleafTextFileCreator = factory();
4
+ })(typeof window !== 'undefined' ? window : globalThis, function () {
5
+ 'use strict';
6
+
7
+ function create(deps = {}) {
8
+ const { window, document, treeOperations, uploadHelpers, snapshotRouter } = deps;
9
+ const { getProjectId, findFileTreeNode, projectPathExists, collectProjectTextPaths,
10
+ invalidateDomProjectPathCache } = treeOperations;
11
+ const normalizeSafeProjectPath = value => {
12
+ const path = String(value || '').replace(/\\/g, '/').replace(/\/+/g, '/').replace(/^\/+|\/+$/g, '').trim();
13
+ return path && !path.split('/').some(part => !part || part === '.' || part === '..') ? path : '';
14
+ };
15
+ const normalizeDomText = value => String(value || '').replace(/\s+/g, ' ').trim();
16
+ const delay = ms => new Promise(resolve => window.setTimeout(resolve, ms));
17
+ const controlName = node => {
18
+ const labelledBy = (node.getAttribute('aria-labelledby') || '').split(/\s+/).filter(Boolean)
19
+ .map(id => document.getElementById?.(id)?.textContent || '').join(' ');
20
+ const explicit = normalizeDomText(labelledBy || node.getAttribute('aria-label'));
21
+ if (explicit) return explicit;
22
+ // Material Symbols are text nodes too: raw textContent produces
23
+ // "note_addNew file". Keep visually-hidden labels but exclude icons
24
+ // deliberately removed from the accessibility tree.
25
+ const copy = node.cloneNode?.(true);
26
+ copy?.querySelectorAll('[aria-hidden="true"], [hidden], svg').forEach(child => child.remove());
27
+ return normalizeDomText((copy ? copy.textContent : node.textContent) || node.getAttribute('title'));
28
+ };
29
+ const findFolder = folderPath => {
30
+ let group = document.querySelector('[data-testid="file-tree-list-root"], .file-tree-list[role="tree"]');
31
+ if (!group?.children) return findFileTreeNode(folderPath, { invalidateCache: true });
32
+ let folder = null;
33
+ for (const part of folderPath.split('/')) {
34
+ if (!group) return null;
35
+ const inner = Array.from(group.children).find(node => node.classList?.contains('file-tree-folder-list-inner')) || group;
36
+ const matches = Array.from(inner.children).filter(node => node.getAttribute('role') === 'treeitem'
37
+ && node.querySelector('[data-file-type="folder"]')
38
+ && normalizeDomText(node.getAttribute('aria-label')) === part);
39
+ if (matches.length !== 1) return null;
40
+ folder = matches[0];
41
+ // Current Overleaf renders the child list beside its folder row.
42
+ // Older layouts put it inside the row. Neither path uses text-file
43
+ // extension heuristics to identify a folder.
44
+ const sibling = folder.nextElementSibling;
45
+ group = sibling?.getAttribute('role') === 'tree' ? sibling : folder.querySelector('[role="tree"]');
46
+ }
47
+ return folder;
48
+ };
49
+
50
+ async function createFolderWithDom(entryPath, options = {}) {
51
+ const target = normalizeSafeProjectPath(entryPath);
52
+ const projectId = getProjectId();
53
+ const assertCurrent = () => {
54
+ if (!target || !projectId || getProjectId() !== projectId || options.isCurrent?.() === false) {
55
+ throw new Error('Project changed or folder creation was cancelled.');
56
+ }
57
+ };
58
+ const visible = node => Boolean(node && !node.disabled && node.getClientRects?.().length);
59
+ const dialogs = () => Array.from(document.querySelectorAll('[role="dialog"]')).filter(visible);
60
+ const waitFor = async read => {
61
+ const deadline = Date.now() + 5000;
62
+ do {
63
+ assertCurrent();
64
+ invalidateDomProjectPathCache();
65
+ const value = read();
66
+ if (value) return value;
67
+ await delay(100);
68
+ } while (Date.now() < deadline);
69
+ throw new Error('Overleaf did not confirm folder creation before timeout.');
70
+ };
71
+ const choose = (scope, pattern) => {
72
+ const matches = Array.from(scope.querySelectorAll('button')).filter(node => visible(node) && pattern.test(controlName(node)));
73
+ if (matches.length !== 1) throw new Error('A unique Overleaf folder control could not be identified.');
74
+ return matches[0];
75
+ };
76
+ const parts = target.split('/');
77
+ const name = parts.pop();
78
+ const parentPath = parts.join('/');
79
+ const expandParent = async () => {
80
+ if (!parentPath) return;
81
+ const parent = findFolder(parentPath);
82
+ if (!parent) throw new Error('The target parent folder is unavailable.');
83
+ if (parent.getAttribute?.('aria-expanded') !== 'false') return;
84
+ assertCurrent();
85
+ const toggle = parent.querySelector('.folder-expand-collapse-button') || parent.querySelector('.file-tree-entity-button');
86
+ if (!toggle) throw new Error('The target folder cannot be expanded safely.');
87
+ toggle.click();
88
+ await waitFor(() => findFolder(parentPath)?.getAttribute('aria-expanded') === 'true');
89
+ };
90
+ let ownedDialog = null;
91
+ try {
92
+ assertCurrent();
93
+ if (findFolder(target)) return;
94
+ if (projectPathExists(target)) throw new Error('The target path already exists as a file.');
95
+ if (dialogs().length) throw new Error('An existing Overleaf dialog must be closed before creating folders.');
96
+ const anchorPath = collectProjectTextPaths('').find(path => !path.includes('/'));
97
+ const anchor = parentPath ? findFolder(parentPath) : anchorPath && findFileTreeNode(anchorPath, { invalidateCache: true });
98
+ if (!anchor) throw new Error('The target parent folder could not be selected safely.');
99
+ if (!parentPath || anchor.getAttribute?.('aria-selected') !== 'true') {
100
+ (anchor.matches?.('button') ? anchor : anchor.querySelector?.('.file-tree-entity-button') || anchor).click();
101
+ }
102
+ await delay(100);
103
+ assertCurrent();
104
+ await expandParent();
105
+ const tree = document.querySelector('#ide-rail-tabs-tabpane-file-tree, #ide-redesign-file-tree, .ide-redesign-file-tree, .file-tree');
106
+ if (!tree) throw new Error('The Overleaf file-tree toolbar is unavailable.');
107
+ choose(tree, /^(?:New folder|新建文件夹)$/i).click();
108
+ ownedDialog = await waitFor(() => dialogs().length === 1 && dialogs()[0]);
109
+ const inputs = Array.from(ownedDialog.querySelectorAll('input[type="text"], input:not([type])')).filter(visible);
110
+ if (inputs.length !== 1) throw new Error('The Overleaf name field could not be identified safely.');
111
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
112
+ if (!setter) throw new Error('The Overleaf name field cannot be edited.');
113
+ setter.call(inputs[0], name);
114
+ inputs[0].dispatchEvent(new window.Event('input', { bubbles: true }));
115
+ inputs[0].dispatchEvent(new window.Event('change', { bubbles: true }));
116
+ await delay(0);
117
+ assertCurrent();
118
+ if (findFolder(target) || projectPathExists(target)) throw new Error('The target path appeared during creation; no replacement was attempted.');
119
+ options.onMutation?.();
120
+ choose(ownedDialog, /^(?:Create|创建)$/i).click();
121
+ await waitFor(() => !dialogs().length);
122
+ await expandParent();
123
+ await waitFor(() => findFolder(target));
124
+ } finally {
125
+ if (ownedDialog && getProjectId() === projectId && dialogs().includes(ownedDialog)) {
126
+ const cancel = Array.from(ownedDialog.querySelectorAll('button')).filter(node => visible(node) && /^(?:Cancel|取消)$/i.test(controlName(node)));
127
+ if (cancel.length === 1) cancel[0].click();
128
+ }
129
+ }
130
+ }
131
+
132
+ // Upload a complete text file through Overleaf, then verify server ZIP
133
+ // bytes. Never write into whichever editor happens to be active.
134
+ async function createTextFileWithDom(operation, options = {}) {
135
+ const filePath = operation.path;
136
+ const target = normalizeSafeProjectPath(filePath);
137
+ const projectId = getProjectId();
138
+ if (!target || !projectId || typeof operation.content !== 'string' || !window.CodexOverleafProjectFiles?.isTextProjectPath(target)) {
139
+ return { ok: false, code: 'invalid_project_path', reason: 'A valid text-file path and project are required.' };
140
+ }
141
+ let mutationAttempted = false;
142
+ const assertCurrent = () => {
143
+ if (getProjectId() !== projectId || options.isCurrent?.() === false) {
144
+ throw new Error('Project changed or the write was cancelled.');
145
+ }
146
+ };
147
+ const visible = node => Boolean(node && !node.disabled && node.getClientRects?.().length);
148
+ const dialogs = () => Array.from(document.querySelectorAll('[role="dialog"]')).filter(visible);
149
+ const waitFor = async read => {
150
+ const until = Date.now() + 5000;
151
+ do {
152
+ assertCurrent();
153
+ invalidateDomProjectPathCache();
154
+ const value = read();
155
+ if (value) return value;
156
+ await delay(100);
157
+ } while (Date.now() < until);
158
+ throw new Error('Overleaf did not confirm the requested file-tree change before timeout.');
159
+ };
160
+ const chooseButton = (scope, pattern) => {
161
+ const matches = Array.from(scope.querySelectorAll('button')).filter(node => visible(node)
162
+ && pattern.test(controlName(node)));
163
+ if (matches.length !== 1) throw new Error('A unique Overleaf file-tree control could not be identified.');
164
+ return matches[0];
165
+ };
166
+ const ensureFolderExpanded = async folderPath => {
167
+ const node = findFolder(folderPath);
168
+ if (!node) throw new Error('The target parent folder is unavailable.');
169
+ if (node.getAttribute?.('aria-expanded') !== 'false') return;
170
+ assertCurrent();
171
+ const toggle = node.querySelector('.folder-expand-collapse-button') || node.querySelector('.file-tree-entity-button');
172
+ if (!toggle) throw new Error('The target folder cannot be expanded safely.');
173
+ toggle.click();
174
+ await waitFor(() => findFolder(folderPath)?.getAttribute('aria-expanded') === 'true');
175
+ };
176
+ const readServerFiles = async () => {
177
+ assertCurrent();
178
+ snapshotRouter.invalidateCache?.();
179
+ const result = await snapshotRouter.fetchProjectZipSnapshot({ force: true, maxAgeMs: 0,
180
+ includeBinaryFiles: false, includeContent: true, zipTimeoutMs: 15000 });
181
+ assertCurrent();
182
+ if (result?.ok !== true || !Array.isArray(result.files)) throw new Error('Server source ZIP is unavailable; text creation cannot be verified safely.');
183
+ return result.files;
184
+ };
185
+ const uploadText = async () => {
186
+ const parentPath = target.split('/').slice(0, -1).join('/');
187
+ const anchorPath = collectProjectTextPaths('').find(path => !path.includes('/'));
188
+ const anchor = parentPath ? findFolder(parentPath) : anchorPath && findFileTreeNode(anchorPath, { invalidateCache: true });
189
+ if (!anchor || dialogs().length) throw new Error('The upload parent cannot be selected safely.');
190
+ assertCurrent();
191
+ if (!parentPath || anchor.getAttribute?.('aria-selected') !== 'true') {
192
+ (anchor.matches?.('button') ? anchor : anchor.querySelector?.('.file-tree-entity-button') || anchor).click();
193
+ }
194
+ await delay(100);
195
+ if (parentPath) await ensureFolderExpanded(parentPath);
196
+ assertCurrent();
197
+ const tree = document.querySelector('#ide-rail-tabs-tabpane-file-tree, #ide-redesign-file-tree, .ide-redesign-file-tree, .file-tree');
198
+ chooseButton(tree, /^(?:Upload|上传)$/i).click();
199
+ const dialog = await waitFor(() => dialogs().length === 1 && dialogs()[0]);
200
+ const input = await waitFor(() => uploadHelpers.chooseOverleafFileInput(dialog.querySelectorAll('input[type="file"]')));
201
+ if (projectPathExists(target)) throw new Error('The target appeared before upload; no replacement was attempted.');
202
+ const file = new window.File([operation.content], target.split('/').pop(), { type: 'text/plain' });
203
+ const data = new window.DataTransfer();
204
+ data.items.add(file);
205
+ assertCurrent();
206
+ uploadHelpers.assignFilesToInput(input, data.files, window);
207
+ mutationAttempted = true;
208
+ input.dispatchEvent(new window.Event('change', { bubbles: true }));
209
+ // Never click Replace/Overwrite. A concurrent name conflict must stop.
210
+ const deadline = Date.now() + 15000;
211
+ do {
212
+ assertCurrent();
213
+ const conflict = dialogs().some(d => /replace|overwrite|替换|覆盖/i.test(d.innerText || ''));
214
+ if (conflict) throw new Error('Overleaf reported an upload conflict; replacement was not authorized.');
215
+ const file = (await readServerFiles()).find(entry => entry.path === target);
216
+ if (file && typeof file.content === 'string' && file.content === operation.content) {
217
+ if (dialogs().includes(dialog)) {
218
+ const close = Array.from(dialog.querySelectorAll('button')).find(node => visible(node)
219
+ && /^(?:Close dialog|Close|Done|Cancel|关闭|完成|取消)$/i.test(controlName(node)));
220
+ if (close) close.click();
221
+ }
222
+ await waitFor(() => !dialogs().length);
223
+ return;
224
+ }
225
+ await delay(250);
226
+ } while (Date.now() < deadline);
227
+ throw new Error('The new file content was not confirmed in the server source ZIP. No editor content was modified.');
228
+ };
229
+ try {
230
+ assertCurrent();
231
+ if (projectPathExists(target)) return { ok: false, code: 'target_file_already_exists', reason: 'The target file already exists.' };
232
+ if (!uploadHelpers?.assignFilesToInput || !snapshotRouter?.fetchProjectZipSnapshot) throw new Error('Verified text upload support is unavailable.');
233
+ if ((await readServerFiles()).some(file => file.path === target)) {
234
+ return { ok: false, code: 'target_file_already_exists', reason: 'The server already contains the target file.' };
235
+ }
236
+ const parts = target.split('/');
237
+ for (let depth = 1; depth < parts.length; depth += 1) {
238
+ const folderPath = parts.slice(0, depth).join('/');
239
+ if (!findFolder(folderPath)) await createFolderWithDom(folderPath, {
240
+ isCurrent: () => { assertCurrent(); return true; },
241
+ onMutation: () => { mutationAttempted = true; }
242
+ });
243
+ await ensureFolderExpanded(folderPath);
244
+ }
245
+ await uploadText();
246
+ return { ok: true, method: 'overleaf.native-text-upload', changedDocument: true,
247
+ verified: true, verification: 'overleaf-zip' };
248
+ } catch (error) {
249
+ return {
250
+ ok: false,
251
+ code: mutationAttempted ? 'file_tree_operation_unverified' : 'file_tree_controls_unavailable',
252
+ reason: error.message,
253
+ changedDocument: mutationAttempted
254
+ };
255
+ }
256
+ }
257
+ // Binary uploads use the same folder hierarchy as verified text uploads.
258
+ // A folder is not a text document and must never use basename fallback.
259
+ async function prepareUploadParent(folderPath, options = {}) {
260
+ const target = normalizeSafeProjectPath(folderPath);
261
+ const projectId = getProjectId();
262
+ if (!target || !projectId) throw new Error('A valid upload parent and project are required.');
263
+ const assertCurrent = () => {
264
+ if (getProjectId() !== projectId || options.isCurrent?.() === false) {
265
+ throw new Error('Project changed or the upload was cancelled.');
266
+ }
267
+ };
268
+ const expand = async path => {
269
+ assertCurrent();
270
+ const node = findFolder(path);
271
+ if (!node) throw new Error(`Target folder ${path} is unavailable in the Overleaf file tree.`);
272
+ if (node.getAttribute('aria-expanded') !== 'false') return node;
273
+ const toggle = node.querySelector('.folder-expand-collapse-button');
274
+ if (!toggle) throw new Error('The upload parent cannot be expanded safely.');
275
+ toggle.click();
276
+ const deadline = Date.now() + 5000;
277
+ do {
278
+ assertCurrent();
279
+ invalidateDomProjectPathCache();
280
+ const expanded = findFolder(path);
281
+ if (expanded?.getAttribute('aria-expanded') === 'true') return expanded;
282
+ await delay(100);
283
+ } while (Date.now() < deadline);
284
+ throw new Error('Overleaf did not confirm the expanded upload parent.');
285
+ };
286
+ const parts = target.split('/');
287
+ for (let depth = 1; depth <= parts.length; depth += 1) {
288
+ const path = parts.slice(0, depth).join('/');
289
+ assertCurrent();
290
+ if (!findFolder(path) && options.createMissing === true) await createFolderWithDom(path, {
291
+ isCurrent: () => { assertCurrent(); return true; }, onMutation: options.onMutation
292
+ });
293
+ await expand(path);
294
+ }
295
+ assertCurrent();
296
+ const parent = findFolder(target);
297
+ if (parent.getAttribute('aria-selected') !== 'true') {
298
+ (parent.querySelector('.file-tree-entity-button') || parent).click();
299
+ await delay(100);
300
+ }
301
+ const selected = await expand(target);
302
+ if (selected.getAttribute('aria-selected') !== 'true') {
303
+ throw new Error('Overleaf did not confirm the selected upload parent.');
304
+ }
305
+ return selected;
306
+ }
307
+
308
+ async function prepareEditor(params = {}) {
309
+ const hasEditor = () => {
310
+ return ['.cm-content', '.ace_editor', '.ProseMirror', 'textarea'].some(selector => {
311
+ const nodes = deps.collectElements?.(selector, 120) || document.querySelectorAll(selector);
312
+ return Array.from(nodes).some(node => {
313
+ if (!node || node.closest?.('#codex-overleaf-panel, [hidden], [aria-hidden="true"], [inert]')) return false;
314
+ if (/invisible element to manage focus/i.test(node.getAttribute?.('aria-label') || '')) return false;
315
+ if (typeof node.getClientRects === 'function' && !node.getClientRects().length) return false;
316
+ const style = window.getComputedStyle?.(node);
317
+ return style?.display !== 'none' && !['hidden', 'collapse'].includes(style?.visibility);
318
+ });
319
+ });
320
+ };
321
+ if (hasEditor()) return { ok: true };
322
+ const projectId = params.runProjectId || params.projectId || getProjectId();
323
+ const readCancellation = deps.readWriteCancellationSequence || (() => 0);
324
+ const cancellation = readCancellation();
325
+ const isCurrent = () => Boolean(projectId) && getProjectId() === projectId && readCancellation() === cancellation;
326
+ const aborted = () => ({ ok: false, code: 'aborted_project_changed',
327
+ reason: 'Project changed or the operation was cancelled before editor preparation.' });
328
+ if (!isCurrent()) return aborted();
329
+ const paths = collectProjectTextPaths('');
330
+ const activePath = treeOperations.getActiveFilePath?.() || '';
331
+ const target = paths.includes(activePath) ? activePath : paths[0];
332
+ if (!target) return { ok: false, code: 'target_editor_not_ready',
333
+ reason: 'No known text file is available to prepare Overleaf Editing/Reviewing controls.' };
334
+ const opened = await treeOperations.openFileByPath(target, { force: true });
335
+ if (!isCurrent()) return aborted();
336
+ if (!opened.ok) return opened;
337
+ const deadline = Date.now() + 5000;
338
+ while (!hasEditor() && Date.now() < deadline) {
339
+ if (!isCurrent()) return aborted();
340
+ await delay(100);
341
+ }
342
+ if (!isCurrent()) return aborted();
343
+ if (!hasEditor()) return { ok: false, code: 'target_editor_not_ready',
344
+ reason: 'Overleaf did not expose its text editor before mode preparation timed out.' };
345
+ return { ok: true };
346
+ }
347
+
348
+ async function ensureWriteMode(reviewing, params = {}) {
349
+ const ensureMode = reviewing ? deps.ensureReviewing : deps.ensureEditing;
350
+ if (typeof ensureMode !== 'function') return { ok: false, reason: 'Editor mode controls are unavailable.' };
351
+ const prepared = await prepareEditor(params);
352
+ return prepared.ok ? ensureMode(params) : prepared;
353
+ }
354
+
355
+ // Only the router's checked text pre-image authorizes this fallback.
356
+ // Never treat a missing row, a closed modal, or a successful click as a
357
+ // deletion receipt: absence must be read from the server source ZIP.
358
+ async function deleteFile(operation, options = {}) {
359
+ const target = normalizeSafeProjectPath(operation.path);
360
+ const projectId = getProjectId();
361
+ const expected = options.expectedContent;
362
+ const canDelete = options.canDelete || (() => typeof deps.readActiveEditorText === 'function'
363
+ && treeOperations.getActiveFilePath?.() === target && deps.readActiveEditorText() === expected);
364
+ let mutationAttempted = false;
365
+ let stage = 'preflight';
366
+ let ownedDialog = null;
367
+ const visible = node => Boolean(node && !node.disabled && node.getClientRects?.().length);
368
+ const dialogs = () => Array.from(document.querySelectorAll('[role="dialog"]')).filter(visible);
369
+ const assertCurrent = () => {
370
+ if (!projectId || getProjectId() !== projectId || options.isCurrent?.() === false) {
371
+ throw new Error('Project changed or the deletion was cancelled.');
372
+ }
373
+ };
374
+ const waitFor = async (read, timeoutMs = 5000) => {
375
+ const deadline = Date.now() + timeoutMs;
376
+ do {
377
+ assertCurrent();
378
+ invalidateDomProjectPathCache();
379
+ const value = await read();
380
+ if (value) return value;
381
+ await delay(100);
382
+ } while (Date.now() < deadline);
383
+ throw new Error(`Overleaf did not confirm ${stage} before timeout.`);
384
+ };
385
+ const readServerFiles = async () => {
386
+ assertCurrent();
387
+ snapshotRouter.invalidateCache?.();
388
+ const result = await snapshotRouter.fetchProjectZipSnapshot({ force: true, maxAgeMs: 0,
389
+ includeBinaryFiles: false, includeContent: true, zipTimeoutMs: 15000 });
390
+ assertCurrent();
391
+ if (result?.ok !== true || !Array.isArray(result.files)) {
392
+ throw new Error('Server source ZIP is unavailable; deletion cannot be verified safely.');
393
+ }
394
+ return result.files;
395
+ };
396
+ const choose = (scope, selector, pattern) => {
397
+ const matches = Array.from(scope.querySelectorAll(selector)).filter(node => visible(node)
398
+ && node.getAttribute('aria-disabled') !== 'true' && pattern.test(controlName(node)));
399
+ if (matches.length !== 1) throw new Error('A unique Overleaf deletion control could not be identified.');
400
+ return matches[0];
401
+ };
402
+ const resolveRow = () => {
403
+ const node = findFileTreeNode(target, { invalidateCache: true });
404
+ return node?.closest?.('[role="treeitem"]') || node;
405
+ };
406
+ // Current Overleaf keeps the stable document ID on the draggable
407
+ // entity inside the row. The menu only mounts on hover/selection.
408
+ const rowIdentity = row => row?.querySelector('[data-file-type="doc"][data-file-id]')?.getAttribute('data-file-id')
409
+ || row?.getAttribute('data-entity-id') || row?.getAttribute('data-doc-id')
410
+ || row?.getAttribute('data-file-id') || row?.querySelector('[id^="menu-button-"]')?.id || '';
411
+ try {
412
+ if (!target || !projectId || typeof expected !== 'string' || typeof canDelete !== 'function'
413
+ || !window.CodexOverleafProjectFiles?.isTextProjectPath(target)) {
414
+ throw new Error('A checked text-file pre-image is required for deletion.');
415
+ }
416
+ assertCurrent();
417
+ if (dialogs().length) throw new Error('An existing Overleaf dialog must be closed before deleting files.');
418
+ if (!snapshotRouter?.fetchProjectZipSnapshot) throw new Error('Verified deletion support is unavailable.');
419
+ const original = (await readServerFiles()).find(file => file.path === target);
420
+ if (!original || original.content !== expected || canDelete() !== true) {
421
+ throw new Error('The file no longer matches the checked pre-image; deletion was stopped.');
422
+ }
423
+ const parentPath = target.split('/').slice(0, -1).join('/');
424
+ if (parentPath) await prepareUploadParent(parentPath, options);
425
+ const row = resolveRow();
426
+ const identity = rowIdentity(row);
427
+ if (!row || !identity || row.querySelector('[data-file-type="folder"]')) {
428
+ throw new Error('An exact Overleaf text-file row could not be identified.');
429
+ }
430
+ stage = 'file selection';
431
+ (row.querySelector('.file-tree-entity-details, .file-tree-entity-button') || row).click();
432
+ await waitFor(() => {
433
+ const selected = Array.from(document.querySelectorAll('[data-testid="file-tree-list-root"] [role="treeitem"][aria-selected="true"]'));
434
+ return selected.length === 1 && rowIdentity(selected[0]) === identity && canDelete() === true;
435
+ });
436
+ assertCurrent();
437
+ const currentRow = resolveRow();
438
+ if (rowIdentity(currentRow) !== identity) throw new Error('The target file identity changed.');
439
+ stage = 'delete context menu';
440
+ // Overleaf handles contextmenu below the treeitem, on its entity.
441
+ // Dispatch from the visible label so all native ancestors receive it.
442
+ const eventTarget = currentRow.querySelector('.item-name span, .file-tree-entity-details') || currentRow;
443
+ const rect = eventTarget.getBoundingClientRect();
444
+ eventTarget.dispatchEvent(new window.MouseEvent('contextmenu', { bubbles: true, cancelable: true,
445
+ button: 2, buttons: 2, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 }));
446
+ const menu = await waitFor(() => {
447
+ const menus = Array.from(document.querySelectorAll('[role="menu"]')).filter(node => visible(node)
448
+ && !node.closest('#codex-overleaf-panel'));
449
+ return menus.length === 1 && menus[0];
450
+ });
451
+ choose(menu, '[role="menuitem"]', /^(?:Delete|删除)$/i).click();
452
+ stage = 'delete confirmation';
453
+ ownedDialog = await waitFor(() => dialogs().length === 1 && dialogs()[0]);
454
+ const verifyDialog = () => {
455
+ const entries = Array.from(ownedDialog.querySelectorAll('li')).map(controlName);
456
+ const name = target.split('/').pop();
457
+ return dialogs().length === 1 && dialogs()[0] === ownedDialog
458
+ && entries.length === 1 && (entries[0] === name || entries[0] === target);
459
+ };
460
+ if (!verifyDialog()) throw new Error('The deletion dialog does not name exactly the authorized file.');
461
+ const latest = (await readServerFiles()).find(file => file.path === target);
462
+ assertCurrent();
463
+ if (!latest || latest.content !== expected || canDelete() !== true
464
+ || rowIdentity(resolveRow()) !== identity || !verifyDialog()) {
465
+ throw new Error('The target changed before confirmation; deletion was stopped.');
466
+ }
467
+ const confirm = choose(ownedDialog, 'button', /^(?:Delete|删除)$/i);
468
+ stage = 'server deletion receipt';
469
+ mutationAttempted = true;
470
+ confirm.click();
471
+ await waitFor(async () => !(await readServerFiles()).some(file => file.path === target), 15000);
472
+ return { ok: true, method: 'overleaf.native-text-delete', changedDocument: true,
473
+ verified: true, verification: 'overleaf-zip' };
474
+ } catch (error) {
475
+ return { ok: false, code: mutationAttempted ? 'file_tree_operation_unverified' : 'file_tree_controls_unavailable',
476
+ reason: error.message, stage: stage.replace(/ /g, '_'), changedDocument: mutationAttempted };
477
+ } finally {
478
+ // Close only the dialog opened by this operation; never another modal.
479
+ if (ownedDialog && dialogs().includes(ownedDialog) && getProjectId() === projectId) {
480
+ const cancel = Array.from(ownedDialog.querySelectorAll('button')).filter(node => visible(node)
481
+ && /^(?:Cancel|取消)$/i.test(controlName(node)));
482
+ if (cancel.length === 1) cancel[0].click();
483
+ }
484
+ }
485
+ }
486
+
487
+ async function createFile(operation, options = {}) {
488
+ return createTextFileWithDom(operation, options);
489
+ }
490
+ return { createFile, deleteFile, prepareEditor, ensureWriteMode, prepareUploadParent, findFolderNode: findFolder };
491
+ }
492
+ return { create };
493
+ });