codex-overleaf-link 2.3.6 → 2.4.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 +62 -34
- package/README.zh-CN.md +546 -0
- package/extension/bootstrap/manifest.template.json +1 -1
- package/extension/src/background.js +3 -0
- package/extension/src/content/activeTurnControl.js +1 -0
- package/extension/src/content/assetTransferBroker.js +2 -0
- package/extension/src/content/contentRuntime.js +122 -118
- package/extension/src/content/contextTray.js +4 -0
- package/extension/src/content/diagnosticsController.js +4 -0
- package/extension/src/content/diagnosticsPanel.js +128 -4
- package/extension/src/content/generated/content.bundle.js +1233 -550
- package/extension/src/content/generated/content.bundle.meta.json +4 -3
- package/extension/src/content/legacyGlobalRegistry.js +1 -0
- package/extension/src/content/modelPicker.js +21 -9
- package/extension/src/content/modelPickerSupport.js +38 -1
- package/extension/src/content/nativeCompatibilityController.js +4 -1
- package/extension/src/content/otWarmMirror.js +34 -30
- package/extension/src/content/otWarmMirrorController.js +20 -0
- package/extension/src/content/pageBridgeClient.js +2 -0
- package/extension/src/content/panelMaintenance.js +8 -4
- package/extension/src/content/recentProjects.js +354 -355
- package/extension/src/content/runResultActions.js +16 -1
- package/extension/src/content/runTimelineView.js +9 -11
- package/extension/src/content/scopedPersistenceCoordinator.js +2 -1
- package/extension/src/content/scopedPersistencePanelState.js +45 -2
- package/extension/src/content/sessionForkController.js +20 -11
- package/extension/src/content/sessionPersistence.js +4 -1
- package/extension/src/content/settingsPanel.js +16 -2
- package/extension/src/content/trackedChangeCaptureController.js +153 -0
- package/extension/src/content/writebackOrchestrator.js +31 -0
- package/extension/src/page/binaryAssetUploader.js +101 -29
- package/extension/src/page/overleafRealtimeObserver.js +4 -1
- package/extension/src/page/saveState.js +44 -1
- package/extension/src/page/textFileCreator.js +493 -0
- package/extension/src/page/trackedChangeCapture.js +342 -0
- package/extension/src/page/trackedChangesLifecycle.js +149 -150
- package/extension/src/page/treeOperations.js +40 -29
- package/extension/src/page/writebackRouter.js +95 -49
- package/extension/src/pageBridge.js +65 -25
- package/extension/src/shared/compatibility.js +1 -1
- package/extension/src/shared/i18n.js +94 -40
- package/extension/src/shared/pageRpcContract.js +2 -1
- package/extension/src/shared/sessionState.js +6 -2
- package/extension/src/shared/storageDb.js +11 -12
- package/extension/src/shared/storageRunActions.js +73 -1
- package/extension/src/shared/writebackSettlement.js +19 -5
- package/extension/styles/panel.css +254 -405
- package/native-host/src/codexSessionRunner.js +41 -38
- package/native-host/src/codexSessionTiming.js +68 -0
- package/native-host/src/readProgressGuard.js +84 -17
- package/native-host/src/taskRunnerRuntime.js +25 -0
- package/package.json +5 -1
|
@@ -107,7 +107,22 @@
|
|
|
107
107
|
if (!copied) throw new Error('clipboard_copy_failed');
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
return Object.freeze({ configureResultActions });
|
|
110
|
+
return Object.freeze({ configureResultActions, projectUndoAvailability });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function projectUndoAvailability(run, projectRunSettlement) {
|
|
114
|
+
const payload = run.recoveryPayload && typeof run.recoveryPayload === 'object'
|
|
115
|
+
? run.recoveryPayload : run;
|
|
116
|
+
if (!Array.isArray(payload.appliedOperations)) return projectRunSettlement(run);
|
|
117
|
+
// Forward binary writes do not provide a rollback. Filter them only for
|
|
118
|
+
// display; preserve the canonical settlement and every real recovery field.
|
|
119
|
+
const appliedOperations = payload.appliedOperations.filter(operation =>
|
|
120
|
+
operation?.type !== 'binary-create' && operation?.type !== 'overwrite-binary');
|
|
121
|
+
if (appliedOperations.length === payload.appliedOperations.length) return projectRunSettlement(run);
|
|
122
|
+
return projectRunSettlement({
|
|
123
|
+
...run,
|
|
124
|
+
recoveryPayload: { ...payload, appliedOperations }
|
|
125
|
+
});
|
|
111
126
|
}
|
|
112
127
|
|
|
113
128
|
return Object.freeze({ create });
|
|
@@ -932,7 +932,7 @@
|
|
|
932
932
|
return;
|
|
933
933
|
}
|
|
934
934
|
|
|
935
|
-
const projection =
|
|
935
|
+
const projection = RunResultActions.projectUndoAvailability(run, projectRunSettlement);
|
|
936
936
|
if (!projection.canUndo && run.undoStatus !== 'applied') {
|
|
937
937
|
button.hidden = true;
|
|
938
938
|
return;
|
|
@@ -960,12 +960,12 @@
|
|
|
960
960
|
// same executable state as `pending`.
|
|
961
961
|
function configureLifecycleUndoButton(button, run) {
|
|
962
962
|
const status = run.trackedChangeStatus || '';
|
|
963
|
+
const inFlight = trackedChangeInFlight.get(run.id);
|
|
963
964
|
const projection = projectRunSettlement(run);
|
|
964
965
|
// §7 settlement matrix: needs_review keeps BOTH controls visible AND
|
|
965
966
|
// actionable. Branch placed before the terminal branches so a
|
|
966
967
|
// needs_review run is never treated as terminal.
|
|
967
968
|
if (status === 'needs_review') {
|
|
968
|
-
const inFlight = trackedChangeInFlight.get(run.id);
|
|
969
969
|
button.hidden = false;
|
|
970
970
|
button.disabled = !projection.canUndo || inFlight === 'reject' || inFlight === 'accept';
|
|
971
971
|
button.textContent = tr('undoRun');
|
|
@@ -976,14 +976,14 @@
|
|
|
976
976
|
});
|
|
977
977
|
return;
|
|
978
978
|
}
|
|
979
|
-
if (status === 'rejected') {
|
|
979
|
+
if (status === 'rejected' && !inFlight) {
|
|
980
980
|
button.hidden = false;
|
|
981
981
|
button.disabled = true;
|
|
982
982
|
button.textContent = tr('undoApplied');
|
|
983
983
|
button.title = tr('undoAppliedTitle');
|
|
984
984
|
return;
|
|
985
985
|
}
|
|
986
|
-
if (status === 'accepted') {
|
|
986
|
+
if (status === 'accepted' && !inFlight) {
|
|
987
987
|
// The run was accepted — terminal. Undo stays visible but greyed so the
|
|
988
988
|
// card shows both controls disabled, never removed.
|
|
989
989
|
button.hidden = false;
|
|
@@ -992,11 +992,10 @@
|
|
|
992
992
|
button.title = tr('undoRunTitle');
|
|
993
993
|
return;
|
|
994
994
|
}
|
|
995
|
-
if (status !== 'pending') {
|
|
995
|
+
if (status !== 'pending' && !inFlight) {
|
|
996
996
|
button.hidden = true;
|
|
997
997
|
return;
|
|
998
998
|
}
|
|
999
|
-
const inFlight = trackedChangeInFlight.get(run.id);
|
|
1000
999
|
button.hidden = false;
|
|
1001
1000
|
button.disabled = !projection.canUndo || inFlight === 'reject' || inFlight === 'accept';
|
|
1002
1001
|
button.textContent = tr('undoRun');
|
|
@@ -1034,12 +1033,12 @@
|
|
|
1034
1033
|
}
|
|
1035
1034
|
|
|
1036
1035
|
const status = run.trackedChangeStatus || '';
|
|
1036
|
+
const inFlight = trackedChangeInFlight.get(run.id);
|
|
1037
1037
|
const projection = projectRunSettlement(run);
|
|
1038
1038
|
// §7 settlement matrix: needs_review keeps BOTH controls visible AND
|
|
1039
1039
|
// actionable. It remains an internal retryable proof state, while the
|
|
1040
1040
|
// primary button label stays in the same executable state as `pending`.
|
|
1041
1041
|
if (status === 'needs_review') {
|
|
1042
|
-
const inFlight = trackedChangeInFlight.get(run.id);
|
|
1043
1042
|
button.hidden = false;
|
|
1044
1043
|
button.disabled = !projection.canAccept || inFlight === 'accept' || inFlight === 'reject';
|
|
1045
1044
|
if (inFlight === 'accept') {
|
|
@@ -1052,14 +1051,14 @@
|
|
|
1052
1051
|
wireAcceptInlineConfirm(button, run.id);
|
|
1053
1052
|
return;
|
|
1054
1053
|
}
|
|
1055
|
-
if (status === 'accepted') {
|
|
1054
|
+
if (status === 'accepted' && !inFlight) {
|
|
1056
1055
|
button.hidden = false;
|
|
1057
1056
|
button.disabled = true;
|
|
1058
1057
|
button.textContent = tr('runAcceptTrackedDone');
|
|
1059
1058
|
button.title = tr('runAcceptTrackedDoneTitle');
|
|
1060
1059
|
return;
|
|
1061
1060
|
}
|
|
1062
|
-
if (status === 'rejected') {
|
|
1061
|
+
if (status === 'rejected' && !inFlight) {
|
|
1063
1062
|
// The run was rejected — terminal. Accept All stays visible but greyed so
|
|
1064
1063
|
// the card shows both controls disabled, never removed.
|
|
1065
1064
|
button.hidden = false;
|
|
@@ -1068,12 +1067,11 @@
|
|
|
1068
1067
|
button.title = tr('runAcceptTrackedTitle');
|
|
1069
1068
|
return;
|
|
1070
1069
|
}
|
|
1071
|
-
if (status !== 'pending') {
|
|
1070
|
+
if (status !== 'pending' && !inFlight) {
|
|
1072
1071
|
button.hidden = true;
|
|
1073
1072
|
return;
|
|
1074
1073
|
}
|
|
1075
1074
|
|
|
1076
|
-
const inFlight = trackedChangeInFlight.get(run.id);
|
|
1077
1075
|
button.hidden = false;
|
|
1078
1076
|
button.disabled = !projection.canAccept || inFlight === 'accept' || inFlight === 'reject';
|
|
1079
1077
|
if (inFlight === 'accept') {
|
|
@@ -95,6 +95,7 @@
|
|
|
95
95
|
}
|
|
96
96
|
return Object.freeze({
|
|
97
97
|
createController: createController,
|
|
98
|
-
persistPanelState: PanelState?.persistPanelState || null
|
|
98
|
+
persistPanelState: PanelState?.persistPanelState || null,
|
|
99
|
+
applyReviewTransition: PanelState?.applyReviewTransition || null
|
|
99
100
|
});
|
|
100
101
|
});
|
|
@@ -15,6 +15,42 @@
|
|
|
15
15
|
return JSON.parse(JSON.stringify(value));
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
async function applyReviewTransition({ getState, runId, transition, persist }) {
|
|
19
|
+
const targets = () => Array.from(new Set([
|
|
20
|
+
...(getState().runs || []),
|
|
21
|
+
...(getState().sessions || []).flatMap(session => session.runs || [])
|
|
22
|
+
])).filter(run => run.id === runId);
|
|
23
|
+
const records = targets();
|
|
24
|
+
const sessions = (getState().sessions || []).filter(session =>
|
|
25
|
+
(session.runs || []).some(run => run.id === runId));
|
|
26
|
+
if (!records.length || !sessions.length) throw reviewPersistenceError();
|
|
27
|
+
const previous = cloneSnapshot(records[0]);
|
|
28
|
+
for (const record of records) Object.assign(record, cloneSnapshot(transition));
|
|
29
|
+
for (const session of sessions) {
|
|
30
|
+
session.updatedAt = new Date(Math.max(Date.now(), (Date.parse(session.updatedAt) || 0) + 1,
|
|
31
|
+
(Date.parse(session.lastActivityAt) || 0) + 1)).toISOString();
|
|
32
|
+
session.lastActivityAt = session.updatedAt;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
await persist({ preserveRunActionPayload: true, reviewRunIds: [runId] });
|
|
36
|
+
} catch (error) {
|
|
37
|
+
// The remote action may have succeeded. Keep its recovery data and expose uncertainty.
|
|
38
|
+
for (const record of targets()) {
|
|
39
|
+
Object.assign(record, cloneSnapshot(previous), { trackedChangeStatus: 'needs_review' });
|
|
40
|
+
for (const field of ['settlement', 'settlementFacts']) {
|
|
41
|
+
if (!Object.prototype.hasOwnProperty.call(previous, field)) delete record[field];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function reviewPersistenceError() {
|
|
49
|
+
return Object.assign(new Error('The review outcome was not durably saved. Check Overleaf before retrying.'), {
|
|
50
|
+
code: 'review_state_not_persisted'
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
18
54
|
async function persistPanelState(input) {
|
|
19
55
|
var state = cloneSnapshot(input.state || {});
|
|
20
56
|
var compactState = cloneSnapshot(input.compactState || {});
|
|
@@ -35,6 +71,9 @@
|
|
|
35
71
|
code: 'stale_view'
|
|
36
72
|
});
|
|
37
73
|
}
|
|
74
|
+
var reviewRuns = (input.reviewRunIds || []).map(id =>
|
|
75
|
+
(state.sessions || []).flatMap(session => session.runs || []).find(run => run.id === id));
|
|
76
|
+
if (reviewRuns.some(run => !run)) throw reviewPersistenceError();
|
|
38
77
|
var latestPrefs = typeof Migration.loadPrefs === 'function'
|
|
39
78
|
? await Migration.loadPrefs(accountScopeId, projectId)
|
|
40
79
|
: {};
|
|
@@ -94,18 +133,22 @@
|
|
|
94
133
|
.filter(function (item) { return !queueTombstones[item?.id]; })
|
|
95
134
|
}), { preserveRunActionPayload: true });
|
|
96
135
|
});
|
|
97
|
-
await input.SessionPersistence.writeSessions({
|
|
136
|
+
var written = await input.SessionPersistence.writeSessions({
|
|
98
137
|
Migration: Migration,
|
|
99
138
|
StorageDb: StorageDb,
|
|
100
139
|
projectId: projectId,
|
|
101
140
|
accountScopeId: accountScopeId,
|
|
102
141
|
deletedSessionIds: deletedSessionIds,
|
|
103
142
|
sessionRecords: sessionRecords,
|
|
143
|
+
reviewRunIds: input.reviewRunIds,
|
|
104
144
|
queueClaims: persistenceMeta.queueClaims || {},
|
|
105
145
|
queueTombstones: queueTombstones,
|
|
106
146
|
writerId: input.persistenceContext?.writerId || ''
|
|
107
147
|
});
|
|
148
|
+
if (!reviewRuns.every(reviewRun => (written || []).some(session => (session.runs || []).some(run =>
|
|
149
|
+
run.id === reviewRun.id && run.trackedChangeStatus === reviewRun.trackedChangeStatus
|
|
150
|
+
)))) throw reviewPersistenceError();
|
|
108
151
|
}
|
|
109
152
|
|
|
110
|
-
return Object.freeze({ persistPanelState: persistPanelState });
|
|
153
|
+
return Object.freeze({ persistPanelState: persistPanelState, applyReviewTransition: applyReviewTransition });
|
|
111
154
|
});
|
|
@@ -16,19 +16,28 @@
|
|
|
16
16
|
const source = findSource(previousState, runId);
|
|
17
17
|
if (!source) return failFork('session_fork_source_missing', tr('forkRunUnavailable'));
|
|
18
18
|
const { session, run, runIndex } = source;
|
|
19
|
-
|
|
19
|
+
const draftThreadId = run.codexTurnId && run.interruptedDraft?.turnId === run.codexTurnId
|
|
20
|
+
? run.interruptedDraft.threadId : '';
|
|
21
|
+
// Provider switches replace the session thread, not the selected node's origin.
|
|
22
|
+
const sourceThreadId = String(run.codexThreadId || draftThreadId || session.codexThreadId || '').trim();
|
|
23
|
+
if (run.status === 'running' || !sourceThreadId || !run.codexTurnId) {
|
|
20
24
|
return failFork('session_fork_unavailable', tr('forkRunUnavailable'));
|
|
21
25
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
26
|
+
let response;
|
|
27
|
+
try {
|
|
28
|
+
response = await sendBackgroundNative({
|
|
29
|
+
method: 'codex.thread.fork',
|
|
30
|
+
params: {
|
|
31
|
+
threadId: sourceThreadId,
|
|
32
|
+
lastTurnId: run.codexTurnId,
|
|
33
|
+
speedTier: session.speedTier,
|
|
34
|
+
loadCodexLocalSkills: previousState.loadCodexLocalSkills !== false,
|
|
35
|
+
loadCodexOverleafSkills: previousState.loadCodexOverleafSkills !== false
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return failFork(error?.code || 'session_fork_failed', error?.message || tr('forkRunFailed'));
|
|
40
|
+
}
|
|
32
41
|
if (!response?.ok || !response.result?.threadId) {
|
|
33
42
|
return failFork(response?.error?.code || 'session_fork_failed', response?.error?.message || tr('forkRunFailed'));
|
|
34
43
|
}
|
|
@@ -73,7 +73,10 @@
|
|
|
73
73
|
const existingById = new Map(existingSessions
|
|
74
74
|
.filter(record => !deleted.has(record.id))
|
|
75
75
|
.map(record => [record.id, record]));
|
|
76
|
-
const
|
|
76
|
+
const mergedRecords = sessionRecords.map(record => StorageDb.mergeSessionReviewState?.(
|
|
77
|
+
record, existingById.get(record.id), options.reviewRunIds
|
|
78
|
+
) || record);
|
|
79
|
+
const writable = mergedRecords.filter(record => {
|
|
77
80
|
const existing = existingById.get(record.id);
|
|
78
81
|
if (!existing) return true;
|
|
79
82
|
return (Date.parse(record.updatedAt || '') || 0) >= (Date.parse(existing.updatedAt || '') || 0);
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
<div class="codex-custom-instructions-head">
|
|
39
39
|
<button type="button" data-settings-back title="Back" aria-label="Back">‹</button>
|
|
40
40
|
<div>
|
|
41
|
-
<div class="codex-custom-instructions-title" data-i18n="projectSettingsTitle">Project Settings</div>
|
|
42
|
-
<div class="codex-custom-instructions-subtitle" data-i18n="projectSettingsSubtitle">Customize how Codex behaves in this and all projects.</div>
|
|
41
|
+
<div class="codex-custom-instructions-title" data-settings-title data-i18n="projectSettingsTitle">Project Settings</div>
|
|
42
|
+
<div class="codex-custom-instructions-subtitle" data-settings-subtitle data-i18n="projectSettingsSubtitle">Customize how Codex behaves in this and all projects.</div>
|
|
43
43
|
</div>
|
|
44
44
|
</div>
|
|
45
45
|
<div class="codex-project-settings-status" data-project-settings-status></div>
|
|
@@ -304,6 +304,7 @@
|
|
|
304
304
|
if (!root) {
|
|
305
305
|
return;
|
|
306
306
|
}
|
|
307
|
+
refreshSettingsScope(instance, root);
|
|
307
308
|
const button = getButton(instance);
|
|
308
309
|
if (button) {
|
|
309
310
|
button.dataset.active = 'true';
|
|
@@ -312,6 +313,18 @@
|
|
|
312
313
|
root.querySelector('[data-custom-instructions-input]')?.focus?.();
|
|
313
314
|
}
|
|
314
315
|
|
|
316
|
+
function refreshSettingsScope(instance, root) {
|
|
317
|
+
const accountScope = instance.container?.closest?.('[data-settings-scope]')?.dataset?.settingsScope === 'account';
|
|
318
|
+
const prefix = accountScope ? 'recentProjects_settings' : 'projectSettings';
|
|
319
|
+
for (const [selector, suffix] of [['[data-settings-title]', 'Title'], ['[data-settings-subtitle]', 'Subtitle']]) {
|
|
320
|
+
const label = root.querySelector(selector);
|
|
321
|
+
if (!label) continue;
|
|
322
|
+
const key = prefix + suffix;
|
|
323
|
+
label.setAttribute('data-i18n', key);
|
|
324
|
+
label.textContent = t(instance, key);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
315
328
|
function hide(target) {
|
|
316
329
|
const instance = target?._instance || target;
|
|
317
330
|
const root = getRoot(instance);
|
|
@@ -346,6 +359,7 @@
|
|
|
346
359
|
if (!root) {
|
|
347
360
|
return;
|
|
348
361
|
}
|
|
362
|
+
refreshSettingsScope(instance, root);
|
|
349
363
|
if (Object.prototype.hasOwnProperty.call(state, 'customInstructions')) {
|
|
350
364
|
const input = root.querySelector('[data-custom-instructions-input]');
|
|
351
365
|
if (input) {
|
|
@@ -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
|
+
});
|
|
@@ -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
|
}
|