mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
style: format Context component modules
This commit is contained in:
1 parent
fc369cc65a
commit
3e078ef49d
11 files changed
+1175
-801
No files matched your search
@@ -181,5 +181,15 @@
|
||||
"src/ui/cctvControls.test.mjs",
|
||||
"src/ui/cctvFrames.js",
|
||||
"src/ui/cctvPresentation.js",
|
||||
"scripts/qa-camera-controls.mjs"
|
||||
"scripts/qa-camera-controls.mjs",
|
||||
"src/ui/context.js",
|
||||
"src/ui/contextActions.js",
|
||||
"src/ui/contextBindings.js",
|
||||
"src/ui/contextControls.js",
|
||||
"src/ui/contextControls.test.mjs",
|
||||
"src/ui/contextLayerChanges.js",
|
||||
"src/ui/contextPresentation.js",
|
||||
"src/ui/contextSession.js",
|
||||
"src/ui/contextSubscriptions.js",
|
||||
"src/ui/contextTransactions.js"
|
||||
]
|
||||
@@ -58,12 +58,22 @@ try {
|
||||
counts.cctv++;
|
||||
return cctvUnsubscribe();
|
||||
};
|
||||
const contextFields = ['_contextManagerUnsubscribe', '_dataManagerVisibilityRequestUnsubscribe', '_dataManagerVisibilityGuardUnsubscribe', '_dataManagerBeforeDestroyUnsubscribe'];
|
||||
const contextConnected = contextFields.every((field) => typeof ui._contextControls[field] === 'function');
|
||||
const contextFields = [
|
||||
'_contextManagerUnsubscribe',
|
||||
'_dataManagerVisibilityRequestUnsubscribe',
|
||||
'_dataManagerVisibilityGuardUnsubscribe',
|
||||
'_dataManagerBeforeDestroyUnsubscribe',
|
||||
];
|
||||
const contextConnected = contextFields.every(
|
||||
(field) => typeof ui._contextControls[field] === 'function',
|
||||
);
|
||||
for (const field of contextFields) {
|
||||
const unsubscribe = ui._contextControls[field];
|
||||
counts[field] = 0;
|
||||
ui._contextControls[field] = () => { counts[field]++; return unsubscribe?.(); };
|
||||
ui._contextControls[field] = () => {
|
||||
counts[field]++;
|
||||
return unsubscribe?.();
|
||||
};
|
||||
}
|
||||
const resizeHandler = ui._windowResizeHandler;
|
||||
const removeEventListener = window.removeEventListener;
|
||||
@@ -86,8 +96,13 @@ try {
|
||||
resizeHandler &&
|
||||
observed.includes('_commandDockTrayObserver'),
|
||||
),
|
||||
contextReleased: contextConnected && ui._contextControls.destroyed
|
||||
&& contextFields.every((field) => counts[field] === 1 && ui._contextControls[field] === null),
|
||||
contextReleased:
|
||||
contextConnected &&
|
||||
ui._contextControls.destroyed &&
|
||||
contextFields.every(
|
||||
(field) =>
|
||||
counts[field] === 1 && ui._contextControls[field] === null,
|
||||
),
|
||||
observersReleased: observed.every(
|
||||
(name) => counts[name] === 1 && ui[name] === null,
|
||||
),
|
||||
@@ -108,7 +123,10 @@ try {
|
||||
'real UI has the expected live resources before disposal',
|
||||
result.connected,
|
||||
);
|
||||
check('UI disposal releases all Context subscriptions and stops its controls', result.contextReleased);
|
||||
check(
|
||||
'UI disposal releases all Context subscriptions and stops its controls',
|
||||
result.contextReleased,
|
||||
);
|
||||
check(
|
||||
'UI disposal disconnects each active panel observer once',
|
||||
result.observersReleased,
|
||||
|
||||
+186
-144
@@ -1,27 +1,30 @@
|
||||
import { settleUserFacingContextAction, contextModeWord } from '../contextModePolicy.js';
|
||||
import {
|
||||
settleUserFacingContextAction,
|
||||
contextModeWord,
|
||||
} from '../contextModePolicy.js';
|
||||
|
||||
export async function _runUserFacingContextAction(
|
||||
operation,
|
||||
message = 'Context could not restore every layer; try again',
|
||||
{ falseIsFailure = true } = {},
|
||||
) {
|
||||
if (this.destroyed) return false;
|
||||
const notificationToken = Symbol('user-facing-context-action');
|
||||
this._userFacingContextNotificationTokens.add(notificationToken);
|
||||
try {
|
||||
return await settleUserFacingContextAction({
|
||||
operation: () => operation(notificationToken),
|
||||
falseIsFailure,
|
||||
onFailure: (error) => {
|
||||
if (this.destroyed) return;
|
||||
console.warn('[Context] user-facing transition failed', error);
|
||||
this.showToast(message);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
this._userFacingContextNotificationTokens.delete(notificationToken);
|
||||
}
|
||||
operation,
|
||||
message = 'Context could not restore every layer; try again',
|
||||
{ falseIsFailure = true } = {},
|
||||
) {
|
||||
if (this.destroyed) return false;
|
||||
const notificationToken = Symbol('user-facing-context-action');
|
||||
this._userFacingContextNotificationTokens.add(notificationToken);
|
||||
try {
|
||||
return await settleUserFacingContextAction({
|
||||
operation: () => operation(notificationToken),
|
||||
falseIsFailure,
|
||||
onFailure: (error) => {
|
||||
if (this.destroyed) return;
|
||||
console.warn('[Context] user-facing transition failed', error);
|
||||
this.showToast(message);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
this._userFacingContextNotificationTokens.delete(notificationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim the visual restore lane for an explicit Context transition.
|
||||
@@ -41,159 +44,197 @@ export async function _runUserFacingContextAction(
|
||||
* restore-driven ones, which must stay eligible for the shared visual state.
|
||||
*/
|
||||
export function _claimContextVisualAuthority() {
|
||||
this.actions.claimVisualAuthority();
|
||||
}
|
||||
this.actions.claimVisualAuthority();
|
||||
}
|
||||
|
||||
export function getContextModeState() {
|
||||
return {
|
||||
mode: this._contextMode || null,
|
||||
active: Boolean(this._contextMode),
|
||||
changing: Boolean(this._contextModeChanging),
|
||||
entering: this._contextModeEntering || null,
|
||||
canContact: !this._contextMode || this._contextMode === 'flights',
|
||||
canMission: !this._contextMode || this._contextMode === 'space-missions',
|
||||
snapshotCaptured: Boolean(this._contextSessionSnapshot),
|
||||
};
|
||||
}
|
||||
return {
|
||||
mode: this._contextMode || null,
|
||||
active: Boolean(this._contextMode),
|
||||
changing: Boolean(this._contextModeChanging),
|
||||
entering: this._contextModeEntering || null,
|
||||
canContact: !this._contextMode || this._contextMode === 'flights',
|
||||
canMission: !this._contextMode || this._contextMode === 'space-missions',
|
||||
snapshotCaptured: Boolean(this._contextSessionSnapshot),
|
||||
};
|
||||
}
|
||||
|
||||
export async function setContextMode(mode, {
|
||||
export async function setContextMode(
|
||||
mode,
|
||||
{
|
||||
notificationToken = null,
|
||||
signal = null,
|
||||
isCurrent = null,
|
||||
claimVisualAuthority = true,
|
||||
} = {}) {
|
||||
const requestIsCurrent = () => !this.destroyed && !signal?.aborted
|
||||
&& (typeof isCurrent !== 'function' || isCurrent());
|
||||
const cancellationResult = () => ({
|
||||
ok: false,
|
||||
action: 'set_context_mode',
|
||||
cancelled: true,
|
||||
error: 'Context request was superseded by a newer voice turn',
|
||||
...this.getContextModeState(),
|
||||
...(this._contextTransitionFailedLayerIds?.length
|
||||
? { failedLayerIds: [...this._contextTransitionFailedLayerIds] }
|
||||
: {}),
|
||||
});
|
||||
if (!requestIsCurrent()) return cancellationResult();
|
||||
try {
|
||||
if (!mode || mode === 'off') {
|
||||
// Validated explicit transition — Context owns detection, so take the
|
||||
// visual lane before a delayed shared restore can reclaim it. Internal
|
||||
// Cockpit choreography opts out: it is not an operator Context request.
|
||||
if (claimVisualAuthority) this._claimContextVisualAuthority();
|
||||
const result = await this._selectContextMode(null, { notificationToken, signal });
|
||||
if (result === null || (!requestIsCurrent() && result !== true)) return cancellationResult();
|
||||
const state = this.getContextModeState();
|
||||
return {
|
||||
ok: result === true,
|
||||
action: 'set_context_mode',
|
||||
mode: state.mode,
|
||||
...state,
|
||||
...(result === true ? {} : { error: 'Context mode transition did not complete' }),
|
||||
...(this._contextTransitionFailedLayerIds?.length
|
||||
? { failedLayerIds: [...this._contextTransitionFailedLayerIds] }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
const canonical = mode === 'contacts' ? 'flights' : mode;
|
||||
if (!['flights', 'space-missions'].includes(canonical)) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'set_context_mode',
|
||||
error: `Unknown context mode: ${mode}`,
|
||||
mode: this._contextMode,
|
||||
...this.getContextModeState(),
|
||||
};
|
||||
}
|
||||
const priorMode = this._contextMode;
|
||||
// Claimed only after the mode enum validates above, so a rejected request
|
||||
// takes no authority and leaves the shared visual state eligible. Internal
|
||||
} = {},
|
||||
) {
|
||||
const requestIsCurrent = () =>
|
||||
!this.destroyed &&
|
||||
!signal?.aborted &&
|
||||
(typeof isCurrent !== 'function' || isCurrent());
|
||||
const cancellationResult = () => ({
|
||||
ok: false,
|
||||
action: 'set_context_mode',
|
||||
cancelled: true,
|
||||
error: 'Context request was superseded by a newer voice turn',
|
||||
...this.getContextModeState(),
|
||||
...(this._contextTransitionFailedLayerIds?.length
|
||||
? { failedLayerIds: [...this._contextTransitionFailedLayerIds] }
|
||||
: {}),
|
||||
});
|
||||
if (!requestIsCurrent()) return cancellationResult();
|
||||
try {
|
||||
if (!mode || mode === 'off') {
|
||||
// Validated explicit transition — Context owns detection, so take the
|
||||
// visual lane before a delayed shared restore can reclaim it. Internal
|
||||
// Cockpit choreography opts out: it is not an operator Context request.
|
||||
if (claimVisualAuthority) this._claimContextVisualAuthority();
|
||||
const transitioned = await this._selectContextMode(canonical, { notificationToken, signal });
|
||||
if (transitioned === null || (!requestIsCurrent() && transitioned !== true)) return cancellationResult();
|
||||
const result = await this._selectContextMode(null, {
|
||||
notificationToken,
|
||||
signal,
|
||||
});
|
||||
if (result === null || (!requestIsCurrent() && result !== true))
|
||||
return cancellationResult();
|
||||
const state = this.getContextModeState();
|
||||
// A cross-mode switch tears the prior mode down before it commits, so a
|
||||
// cancelled or failed switch rests on Context OFF. Say that plainly:
|
||||
// reporting a bare "did not complete" while the operator's Context is
|
||||
// gone is the dishonesty this whole path was fixed for. The state fields
|
||||
// below carry the same verdict, so text and state cannot disagree.
|
||||
const crossModeSwitchLost = transitioned !== true
|
||||
&& Boolean(priorMode) && priorMode !== canonical && !state.mode;
|
||||
return {
|
||||
ok: transitioned === true,
|
||||
ok: result === true,
|
||||
action: 'set_context_mode',
|
||||
mode: state.mode,
|
||||
...state,
|
||||
...(transitioned === true ? {} : {
|
||||
// Named in the operator's vocabulary, not the internal id: this
|
||||
// string is read by the voice model, which takes 'contacts'.
|
||||
error: crossModeSwitchLost
|
||||
? `Switch to ${contextModeWord(canonical)} did not complete — Context is now off`
|
||||
: 'Context mode transition did not complete',
|
||||
...(crossModeSwitchLost ? { contextOff: true, priorMode } : {}),
|
||||
}),
|
||||
...(result === true
|
||||
? {}
|
||||
: { error: 'Context mode transition did not complete' }),
|
||||
...(this._contextTransitionFailedLayerIds?.length
|
||||
? { failedLayerIds: [...this._contextTransitionFailedLayerIds] }
|
||||
: {}),
|
||||
};
|
||||
} catch (error) {
|
||||
if (!requestIsCurrent()) {
|
||||
return {
|
||||
...cancellationResult(),
|
||||
...(Array.isArray(error?.failedLayerIds)
|
||||
? { failedLayerIds: [...error.failedLayerIds] }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
const canonical = mode === 'contacts' ? 'flights' : mode;
|
||||
if (!['flights', 'space-missions'].includes(canonical)) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'set_context_mode',
|
||||
error: error?.message || 'Context mode transition failed',
|
||||
...(Array.isArray(error?.failedLayerIds)
|
||||
? { failedLayerIds: [...error.failedLayerIds] }
|
||||
: {}),
|
||||
error: `Unknown context mode: ${mode}`,
|
||||
mode: this._contextMode,
|
||||
...this.getContextModeState(),
|
||||
};
|
||||
}
|
||||
const priorMode = this._contextMode;
|
||||
// Claimed only after the mode enum validates above, so a rejected request
|
||||
// takes no authority and leaves the shared visual state eligible. Internal
|
||||
// Cockpit choreography opts out: it is not an operator Context request.
|
||||
if (claimVisualAuthority) this._claimContextVisualAuthority();
|
||||
const transitioned = await this._selectContextMode(canonical, {
|
||||
notificationToken,
|
||||
signal,
|
||||
});
|
||||
if (transitioned === null || (!requestIsCurrent() && transitioned !== true))
|
||||
return cancellationResult();
|
||||
const state = this.getContextModeState();
|
||||
// A cross-mode switch tears the prior mode down before it commits, so a
|
||||
// cancelled or failed switch rests on Context OFF. Say that plainly:
|
||||
// reporting a bare "did not complete" while the operator's Context is
|
||||
// gone is the dishonesty this whole path was fixed for. The state fields
|
||||
// below carry the same verdict, so text and state cannot disagree.
|
||||
const crossModeSwitchLost =
|
||||
transitioned !== true &&
|
||||
Boolean(priorMode) &&
|
||||
priorMode !== canonical &&
|
||||
!state.mode;
|
||||
return {
|
||||
ok: transitioned === true,
|
||||
action: 'set_context_mode',
|
||||
mode: state.mode,
|
||||
...state,
|
||||
...(transitioned === true
|
||||
? {}
|
||||
: {
|
||||
// Named in the operator's vocabulary, not the internal id: this
|
||||
// string is read by the voice model, which takes 'contacts'.
|
||||
error: crossModeSwitchLost
|
||||
? `Switch to ${contextModeWord(canonical)} did not complete — Context is now off`
|
||||
: 'Context mode transition did not complete',
|
||||
...(crossModeSwitchLost ? { contextOff: true, priorMode } : {}),
|
||||
}),
|
||||
...(this._contextTransitionFailedLayerIds?.length
|
||||
? { failedLayerIds: [...this._contextTransitionFailedLayerIds] }
|
||||
: {}),
|
||||
};
|
||||
} catch (error) {
|
||||
if (!requestIsCurrent()) {
|
||||
return {
|
||||
...cancellationResult(),
|
||||
...(Array.isArray(error?.failedLayerIds)
|
||||
? { failedLayerIds: [...error.failedLayerIds] }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
action: 'set_context_mode',
|
||||
error: error?.message || 'Context mode transition failed',
|
||||
...(Array.isArray(error?.failedLayerIds)
|
||||
? { failedLayerIds: [...error.failedLayerIds] }
|
||||
: {}),
|
||||
...this.getContextModeState(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSelectedLayers() {
|
||||
if (this.destroyed) return Promise.resolve({ targetIds: [], items: [], clearedIds: [], notClearedIds: [], cancelled: true });
|
||||
if (this._clearSelectedLayersPromise) return this._clearSelectedLayersPromise;
|
||||
if (!this._dataManager?.clearSelectedLayers) {
|
||||
return Promise.resolve({ targetIds: [], items: [], clearedIds: [], notClearedIds: [] });
|
||||
}
|
||||
const generation = ++this._contextModeGeneration;
|
||||
const notificationToken = Symbol('clear-selected-layers');
|
||||
if (this._contextRestoreState) this._contextRestoreState.cancelled = true;
|
||||
this._contextModeChanging = true;
|
||||
this._contextMode = null;
|
||||
this._contextModeEntering = null;
|
||||
this._contextModeEntryIntent = null;
|
||||
this._contextModeReplacementIntent = null;
|
||||
this._contextSessionSnapshot = null;
|
||||
this._contextRestoreState = null;
|
||||
this._preservePanelStateDuringLayerClear = true;
|
||||
this._syncContextModeButtons();
|
||||
this._userFacingContextNotificationTokens.add(notificationToken);
|
||||
this.setClearBusy(true);
|
||||
|
||||
const managerOperation = this._dataManager.clearSelectedLayers({
|
||||
origin: 'user',
|
||||
notificationToken,
|
||||
if (this.destroyed)
|
||||
return Promise.resolve({
|
||||
targetIds: [],
|
||||
items: [],
|
||||
clearedIds: [],
|
||||
notClearedIds: [],
|
||||
cancelled: true,
|
||||
});
|
||||
this._clearSelectedLayersManagerPromise = managerOperation;
|
||||
const operation = managerOperation.then((result) => {
|
||||
if (this._clearSelectedLayersPromise) return this._clearSelectedLayersPromise;
|
||||
if (!this._dataManager?.clearSelectedLayers) {
|
||||
return Promise.resolve({
|
||||
targetIds: [],
|
||||
items: [],
|
||||
clearedIds: [],
|
||||
notClearedIds: [],
|
||||
});
|
||||
}
|
||||
const generation = ++this._contextModeGeneration;
|
||||
const notificationToken = Symbol('clear-selected-layers');
|
||||
if (this._contextRestoreState) this._contextRestoreState.cancelled = true;
|
||||
this._contextModeChanging = true;
|
||||
this._contextMode = null;
|
||||
this._contextModeEntering = null;
|
||||
this._contextModeEntryIntent = null;
|
||||
this._contextModeReplacementIntent = null;
|
||||
this._contextSessionSnapshot = null;
|
||||
this._contextRestoreState = null;
|
||||
this._preservePanelStateDuringLayerClear = true;
|
||||
this._syncContextModeButtons();
|
||||
this._userFacingContextNotificationTokens.add(notificationToken);
|
||||
this.setClearBusy(true);
|
||||
|
||||
const managerOperation = this._dataManager.clearSelectedLayers({
|
||||
origin: 'user',
|
||||
notificationToken,
|
||||
});
|
||||
this._clearSelectedLayersManagerPromise = managerOperation;
|
||||
const operation = managerOperation
|
||||
.then((result) => {
|
||||
if (result.targetIds.length === 0) {
|
||||
this.showToast('No selected data layers');
|
||||
} else if (result.notClearedIds.length > 0) {
|
||||
this.showToast(`${result.notClearedIds.length} data layer${result.notClearedIds.length === 1 ? '' : 's'} could not be cleared`);
|
||||
this.showToast(
|
||||
`${result.notClearedIds.length} data layer${result.notClearedIds.length === 1 ? '' : 's'} could not be cleared`,
|
||||
);
|
||||
} else {
|
||||
this.showToast(`Cleared ${result.clearedIds.length} data layer${result.clearedIds.length === 1 ? '' : 's'}`);
|
||||
this.showToast(
|
||||
`Cleared ${result.clearedIds.length} data layer${result.clearedIds.length === 1 ? '' : 's'}`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}).catch((error) => {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[Data] clear selected layers failed', error);
|
||||
this.showToast('Selected data layers could not be cleared');
|
||||
return {
|
||||
@@ -203,7 +244,8 @@ export function clearSelectedLayers() {
|
||||
notClearedIds: [],
|
||||
error,
|
||||
};
|
||||
}).finally(() => {
|
||||
})
|
||||
.finally(() => {
|
||||
this._userFacingContextNotificationTokens.delete(notificationToken);
|
||||
if (generation === this._contextModeGeneration) {
|
||||
this._contextModeChanging = false;
|
||||
@@ -214,6 +256,6 @@ export function clearSelectedLayers() {
|
||||
this._clearSelectedLayersManagerPromise = null;
|
||||
this._clearSelectedLayersPromise = null;
|
||||
});
|
||||
this._clearSelectedLayersPromise = operation;
|
||||
return operation;
|
||||
}
|
||||
this._clearSelectedLayersPromise = operation;
|
||||
return operation;
|
||||
}
|
||||
+100
-56
@@ -1,77 +1,121 @@
|
||||
import { shouldExpandGlobalContextPanel } from '../rightRailPolicy.js';
|
||||
|
||||
export function _initGlobalContextPanel() {
|
||||
const contextTabs = [this._globalContextFlightsBtn, this._globalContextMissionsBtn].filter(Boolean);
|
||||
contextTabs.forEach((tab, index) => this.listen(tab, 'keydown', (event) => {
|
||||
const contextTabs = [
|
||||
this._globalContextFlightsBtn,
|
||||
this._globalContextMissionsBtn,
|
||||
].filter(Boolean);
|
||||
contextTabs.forEach((tab, index) =>
|
||||
this.listen(tab, 'keydown', (event) => {
|
||||
let nextIndex = null;
|
||||
if (event.key === 'ArrowRight') nextIndex = (index + 1) % contextTabs.length;
|
||||
else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + contextTabs.length) % contextTabs.length;
|
||||
if (event.key === 'ArrowRight')
|
||||
nextIndex = (index + 1) % contextTabs.length;
|
||||
else if (event.key === 'ArrowLeft')
|
||||
nextIndex = (index - 1 + contextTabs.length) % contextTabs.length;
|
||||
else if (event.key === 'Home') nextIndex = 0;
|
||||
else if (event.key === 'End') nextIndex = contextTabs.length - 1;
|
||||
if (nextIndex === null) return;
|
||||
event.preventDefault();
|
||||
contextTabs[nextIndex].focus({ preventScroll: true });
|
||||
contextTabs[nextIndex].click();
|
||||
}));
|
||||
this.listen(this._globalContextFlightsBtn, 'click', () => {
|
||||
if (this.destroyed || this._contextModeChanging || this._clearSelectedLayersPromise) return;
|
||||
const nextMode = this._contextMode === 'flights' ? null : 'flights';
|
||||
this._claimContextVisualAuthority();
|
||||
void this._runUserFacingContextAction(
|
||||
(notificationToken) => this._selectContextMode(
|
||||
nextMode,
|
||||
{ notificationToken },
|
||||
),
|
||||
'Contacts could not complete the requested transition; try again',
|
||||
).then((succeeded) => {
|
||||
if (!this.destroyed && nextMode && shouldExpandGlobalContextPanel({
|
||||
}),
|
||||
);
|
||||
this.listen(this._globalContextFlightsBtn, 'click', () => {
|
||||
if (
|
||||
this.destroyed ||
|
||||
this._contextModeChanging ||
|
||||
this._clearSelectedLayersPromise
|
||||
)
|
||||
return;
|
||||
const nextMode = this._contextMode === 'flights' ? null : 'flights';
|
||||
this._claimContextVisualAuthority();
|
||||
void this._runUserFacingContextAction(
|
||||
(notificationToken) =>
|
||||
this._selectContextMode(nextMode, { notificationToken }),
|
||||
'Contacts could not complete the requested transition; try again',
|
||||
).then((succeeded) => {
|
||||
if (
|
||||
!this.destroyed &&
|
||||
nextMode &&
|
||||
shouldExpandGlobalContextPanel({
|
||||
action: 'contacts',
|
||||
explicitUserAction: true,
|
||||
succeeded: succeeded === true,
|
||||
})) this.actions.setPanelCollapsed('global-context-panel', false, { explicit: true });
|
||||
});
|
||||
})
|
||||
)
|
||||
this.actions.setPanelCollapsed('global-context-panel', false, {
|
||||
explicit: true,
|
||||
});
|
||||
});
|
||||
this.listen(this._globalContextMissionsBtn, 'click', () => {
|
||||
if (this.destroyed || this._contextModeChanging || this._clearSelectedLayersPromise) return;
|
||||
const nextMode = this._contextMode === 'space-missions' ? null : 'space-missions';
|
||||
this._claimContextVisualAuthority();
|
||||
void this._runUserFacingContextAction(
|
||||
(notificationToken) => this._selectContextMode(
|
||||
nextMode,
|
||||
{ notificationToken },
|
||||
),
|
||||
'Space Missions could not complete the requested transition; try again',
|
||||
).then((succeeded) => {
|
||||
if (!this.destroyed && nextMode && shouldExpandGlobalContextPanel({
|
||||
});
|
||||
this.listen(this._globalContextMissionsBtn, 'click', () => {
|
||||
if (
|
||||
this.destroyed ||
|
||||
this._contextModeChanging ||
|
||||
this._clearSelectedLayersPromise
|
||||
)
|
||||
return;
|
||||
const nextMode =
|
||||
this._contextMode === 'space-missions' ? null : 'space-missions';
|
||||
this._claimContextVisualAuthority();
|
||||
void this._runUserFacingContextAction(
|
||||
(notificationToken) =>
|
||||
this._selectContextMode(nextMode, { notificationToken }),
|
||||
'Space Missions could not complete the requested transition; try again',
|
||||
).then((succeeded) => {
|
||||
if (
|
||||
!this.destroyed &&
|
||||
nextMode &&
|
||||
shouldExpandGlobalContextPanel({
|
||||
action: 'space-missions',
|
||||
explicitUserAction: true,
|
||||
succeeded: succeeded === true,
|
||||
})) this.actions.setPanelCollapsed('global-context-panel', false, { explicit: true });
|
||||
});
|
||||
})
|
||||
)
|
||||
this.actions.setPanelCollapsed('global-context-panel', false, {
|
||||
explicit: true,
|
||||
});
|
||||
});
|
||||
this.listen(this._installationsSearchBtn, 'click', () => {
|
||||
if (this.destroyed || !this._dataManager?.layers?.has('military-installations')) return;
|
||||
const button = this._installationsSearchBtn;
|
||||
if (button.getAttribute('aria-busy') === 'true') return;
|
||||
button.setAttribute('aria-disabled', 'true');
|
||||
button.setAttribute('aria-busy', 'true');
|
||||
void this._runUserFacingContextAction(async (notificationToken) => {
|
||||
const enabled = await this._dataManager.setEnabled('military-installations', true, {
|
||||
});
|
||||
this.listen(this._installationsSearchBtn, 'click', () => {
|
||||
if (
|
||||
this.destroyed ||
|
||||
!this._dataManager?.layers?.has('military-installations')
|
||||
)
|
||||
return;
|
||||
const button = this._installationsSearchBtn;
|
||||
if (button.getAttribute('aria-busy') === 'true') return;
|
||||
button.setAttribute('aria-disabled', 'true');
|
||||
button.setAttribute('aria-busy', 'true');
|
||||
void this._runUserFacingContextAction(async (notificationToken) => {
|
||||
const enabled = await this._dataManager.setEnabled(
|
||||
'military-installations',
|
||||
true,
|
||||
{
|
||||
origin: 'user',
|
||||
notificationToken,
|
||||
});
|
||||
if (this.destroyed || enabled === false || !this._dataManager.isEnabled('military-installations')) return false;
|
||||
const searched = await this.installations.searchNearby?.();
|
||||
if (this.destroyed || searched === false) return false;
|
||||
const stats = this.installations.getStats?.();
|
||||
this.showToast(stats?.statusMessage || (stats?.status === 'zoom-in'
|
||||
? 'Zoom in to search mapped installations'
|
||||
: 'Nearby installations refreshed'));
|
||||
return true;
|
||||
}, 'Nearby installations could not be refreshed; try again').finally(() => {
|
||||
if (this.destroyed) return;
|
||||
button.setAttribute('aria-disabled', 'false');
|
||||
button.setAttribute('aria-busy', 'false');
|
||||
});
|
||||
},
|
||||
);
|
||||
if (
|
||||
this.destroyed ||
|
||||
enabled === false ||
|
||||
!this._dataManager.isEnabled('military-installations')
|
||||
)
|
||||
return false;
|
||||
const searched = await this.installations.searchNearby?.();
|
||||
if (this.destroyed || searched === false) return false;
|
||||
const stats = this.installations.getStats?.();
|
||||
this.showToast(
|
||||
stats?.statusMessage ||
|
||||
(stats?.status === 'zoom-in'
|
||||
? 'Zoom in to search mapped installations'
|
||||
: 'Nearby installations refreshed'),
|
||||
);
|
||||
return true;
|
||||
}, 'Nearby installations could not be refreshed; try again').finally(() => {
|
||||
if (this.destroyed) return;
|
||||
button.setAttribute('aria-disabled', 'false');
|
||||
button.setAttribute('aria-busy', 'false');
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
+89
-25
@@ -1,9 +1,25 @@
|
||||
import { _initGlobalContextPanel } from './contextBindings.js';
|
||||
import { _syncContextModeButtons } from './contextPresentation.js';
|
||||
import { _trackContextLayerReaction, _waitForContextLayerSettlement, _captureContextSessionSnapshot, _restoreContextSession, _restoreContextSessionAfterLayerSettles } from './contextSession.js';
|
||||
import { _selectContextMode, _deactivateContextForLayerChange, _clearLayersOutsideContextMode } from './contextTransactions.js';
|
||||
import {
|
||||
_trackContextLayerReaction,
|
||||
_waitForContextLayerSettlement,
|
||||
_captureContextSessionSnapshot,
|
||||
_restoreContextSession,
|
||||
_restoreContextSessionAfterLayerSettles,
|
||||
} from './contextSession.js';
|
||||
import {
|
||||
_selectContextMode,
|
||||
_deactivateContextForLayerChange,
|
||||
_clearLayersOutsideContextMode,
|
||||
} from './contextTransactions.js';
|
||||
import { _handleContextLayerChange } from './contextLayerChanges.js';
|
||||
import { _runUserFacingContextAction, _claimContextVisualAuthority, getContextModeState, setContextMode, clearSelectedLayers } from './contextActions.js';
|
||||
import {
|
||||
_runUserFacingContextAction,
|
||||
_claimContextVisualAuthority,
|
||||
getContextModeState,
|
||||
setContextMode,
|
||||
clearSelectedLayers,
|
||||
} from './contextActions.js';
|
||||
import { connectContextManager } from './contextSubscriptions.js';
|
||||
|
||||
/** Own Context transactions and controls through explicit layer and presentation ports. */
|
||||
@@ -31,15 +47,31 @@ export class ContextControls {
|
||||
this._clearSelectedLayersManagerPromise = null;
|
||||
this._initGlobalContextPanel();
|
||||
}
|
||||
showToast(...args) { if (!this.destroyed) this.actions.showToast(...args); }
|
||||
setClearBusy(...args) { if (!this.destroyed) this.actions.setClearBusy(...args); }
|
||||
get cockpitView() { return this.actions.getCockpit(); }
|
||||
showToast(...args) {
|
||||
if (!this.destroyed) this.actions.showToast(...args);
|
||||
}
|
||||
setClearBusy(...args) {
|
||||
if (!this.destroyed) this.actions.setClearBusy(...args);
|
||||
}
|
||||
get cockpitView() {
|
||||
return this.actions.getCockpit();
|
||||
}
|
||||
listen(target, type, callback, options = {}) {
|
||||
target?.addEventListener(type, callback, { ...options, signal: this.listeners.signal });
|
||||
target?.addEventListener(type, callback, {
|
||||
...options,
|
||||
signal: this.listeners.signal,
|
||||
});
|
||||
}
|
||||
connect(manager) {
|
||||
return connectContextManager.call(this, manager);
|
||||
}
|
||||
connect(manager) { return connectContextManager.call(this, manager); }
|
||||
disconnect() {
|
||||
for (const field of ['_contextManagerUnsubscribe', '_dataManagerBeforeDestroyUnsubscribe', '_dataManagerVisibilityGuardUnsubscribe', '_dataManagerVisibilityRequestUnsubscribe']) {
|
||||
for (const field of [
|
||||
'_contextManagerUnsubscribe',
|
||||
'_dataManagerBeforeDestroyUnsubscribe',
|
||||
'_dataManagerVisibilityGuardUnsubscribe',
|
||||
'_dataManagerVisibilityRequestUnsubscribe',
|
||||
]) {
|
||||
this[field]?.();
|
||||
this[field] = null;
|
||||
}
|
||||
@@ -55,20 +87,52 @@ export class ContextControls {
|
||||
this._contextMode = null;
|
||||
await this._restoreContextSession();
|
||||
}
|
||||
_initGlobalContextPanel(...args) { return _initGlobalContextPanel.call(this, ...args); }
|
||||
_syncContextModeButtons(...args) { return _syncContextModeButtons.call(this, ...args); }
|
||||
_trackContextLayerReaction(...args) { return _trackContextLayerReaction.call(this, ...args); }
|
||||
_waitForContextLayerSettlement(...args) { return _waitForContextLayerSettlement.call(this, ...args); }
|
||||
_captureContextSessionSnapshot(...args) { return _captureContextSessionSnapshot.call(this, ...args); }
|
||||
_restoreContextSession(...args) { return _restoreContextSession.call(this, ...args); }
|
||||
_restoreContextSessionAfterLayerSettles(...args) { return _restoreContextSessionAfterLayerSettles.call(this, ...args); }
|
||||
_selectContextMode(...args) { return _selectContextMode.call(this, ...args); }
|
||||
_deactivateContextForLayerChange(...args) { return _deactivateContextForLayerChange.call(this, ...args); }
|
||||
_clearLayersOutsideContextMode(...args) { return _clearLayersOutsideContextMode.call(this, ...args); }
|
||||
_handleContextLayerChange(...args) { return _handleContextLayerChange.call(this, ...args); }
|
||||
_runUserFacingContextAction(...args) { return _runUserFacingContextAction.call(this, ...args); }
|
||||
_claimContextVisualAuthority(...args) { return _claimContextVisualAuthority.call(this, ...args); }
|
||||
getContextModeState(...args) { return getContextModeState.call(this, ...args); }
|
||||
setContextMode(...args) { return setContextMode.call(this, ...args); }
|
||||
clearSelectedLayers(...args) { return clearSelectedLayers.call(this, ...args); }
|
||||
_initGlobalContextPanel(...args) {
|
||||
return _initGlobalContextPanel.call(this, ...args);
|
||||
}
|
||||
_syncContextModeButtons(...args) {
|
||||
return _syncContextModeButtons.call(this, ...args);
|
||||
}
|
||||
_trackContextLayerReaction(...args) {
|
||||
return _trackContextLayerReaction.call(this, ...args);
|
||||
}
|
||||
_waitForContextLayerSettlement(...args) {
|
||||
return _waitForContextLayerSettlement.call(this, ...args);
|
||||
}
|
||||
_captureContextSessionSnapshot(...args) {
|
||||
return _captureContextSessionSnapshot.call(this, ...args);
|
||||
}
|
||||
_restoreContextSession(...args) {
|
||||
return _restoreContextSession.call(this, ...args);
|
||||
}
|
||||
_restoreContextSessionAfterLayerSettles(...args) {
|
||||
return _restoreContextSessionAfterLayerSettles.call(this, ...args);
|
||||
}
|
||||
_selectContextMode(...args) {
|
||||
return _selectContextMode.call(this, ...args);
|
||||
}
|
||||
_deactivateContextForLayerChange(...args) {
|
||||
return _deactivateContextForLayerChange.call(this, ...args);
|
||||
}
|
||||
_clearLayersOutsideContextMode(...args) {
|
||||
return _clearLayersOutsideContextMode.call(this, ...args);
|
||||
}
|
||||
_handleContextLayerChange(...args) {
|
||||
return _handleContextLayerChange.call(this, ...args);
|
||||
}
|
||||
_runUserFacingContextAction(...args) {
|
||||
return _runUserFacingContextAction.call(this, ...args);
|
||||
}
|
||||
_claimContextVisualAuthority(...args) {
|
||||
return _claimContextVisualAuthority.call(this, ...args);
|
||||
}
|
||||
getContextModeState(...args) {
|
||||
return getContextModeState.call(this, ...args);
|
||||
}
|
||||
setContextMode(...args) {
|
||||
return setContextMode.call(this, ...args);
|
||||
}
|
||||
clearSelectedLayers(...args) {
|
||||
return clearSelectedLayers.call(this, ...args);
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,16 @@ import { test } from 'node:test';
|
||||
import { ContextControls } from './contextControls.js';
|
||||
|
||||
class Button extends EventTarget {
|
||||
constructor() { super(); this.attributes = new Map(); }
|
||||
getAttribute(name) { return this.attributes.get(name); }
|
||||
setAttribute(name, value) { this.attributes.set(name, value); }
|
||||
constructor() {
|
||||
super();
|
||||
this.attributes = new Map();
|
||||
}
|
||||
getAttribute(name) {
|
||||
return this.attributes.get(name);
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, value);
|
||||
}
|
||||
}
|
||||
const turn = () => new Promise((resolve) => setImmediate(resolve));
|
||||
function fixture(t, elements = {}) {
|
||||
@@ -24,15 +31,23 @@ function fixture(t, elements = {}) {
|
||||
setPanelCollapsed: () => calls.push('expand'),
|
||||
},
|
||||
});
|
||||
t.after(() => { controls.stop(); controls.disconnect(); });
|
||||
t.after(() => {
|
||||
controls.stop();
|
||||
controls.disconnect();
|
||||
});
|
||||
return { controls, calls };
|
||||
}
|
||||
|
||||
test('Context starts with an explicit idle snapshot', (t) => {
|
||||
const { controls } = fixture(t);
|
||||
assert.deepEqual(controls.getContextModeState(), {
|
||||
mode: null, active: false, changing: false, entering: null,
|
||||
canContact: true, canMission: true, snapshotCaptured: false,
|
||||
mode: null,
|
||||
active: false,
|
||||
changing: false,
|
||||
entering: null,
|
||||
canContact: true,
|
||||
canMission: true,
|
||||
snapshotCaptured: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,7 +55,10 @@ test('a pending Context tab cannot reopen a disposed panel', async (t) => {
|
||||
const tab = new Button();
|
||||
const { controls, calls } = fixture(t, { _globalContextFlightsBtn: tab });
|
||||
let release;
|
||||
controls._selectContextMode = () => new Promise((resolve) => { release = resolve; });
|
||||
controls._selectContextMode = () =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
tab.dispatchEvent(new Event('click'));
|
||||
assert.deepEqual(calls, ['claim']);
|
||||
controls.stop();
|
||||
@@ -56,16 +74,24 @@ test('stopping during installations enable prevents the delayed search', async (
|
||||
let release;
|
||||
controls._dataManager = {
|
||||
layers: new Map([['military-installations', {}]]),
|
||||
setEnabled: () => new Promise((resolve) => { release = resolve; }),
|
||||
setEnabled: () =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
isEnabled: () => true,
|
||||
};
|
||||
controls.installations.searchNearby = () => assert.fail('search started after stop');
|
||||
controls.installations.searchNearby = () =>
|
||||
assert.fail('search started after stop');
|
||||
button.dispatchEvent(new Event('click'));
|
||||
controls.stop();
|
||||
release(true);
|
||||
await turn();
|
||||
assert.equal(calls.length, 0);
|
||||
assert.equal(button.getAttribute('aria-busy'), 'true', 'disposed controls are not mutated by completion');
|
||||
assert.equal(
|
||||
button.getAttribute('aria-busy'),
|
||||
'true',
|
||||
'disposed controls are not mutated by completion',
|
||||
);
|
||||
});
|
||||
|
||||
test('a late installations search cannot publish a notice after disposal', async (t) => {
|
||||
@@ -74,9 +100,13 @@ test('a late installations search cannot publish a notice after disposal', async
|
||||
let release;
|
||||
controls._dataManager = {
|
||||
layers: new Map([['military-installations', {}]]),
|
||||
setEnabled: async () => true, isEnabled: () => true,
|
||||
setEnabled: async () => true,
|
||||
isEnabled: () => true,
|
||||
};
|
||||
controls.installations.searchNearby = () => new Promise((resolve) => { release = resolve; });
|
||||
controls.installations.searchNearby = () =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
button.dispatchEvent(new Event('click'));
|
||||
await turn();
|
||||
controls.stop();
|
||||
@@ -88,12 +118,21 @@ test('a late installations search cannot publish a notice after disposal', async
|
||||
test('reconnecting and disposal release each manager subscription exactly once', (t) => {
|
||||
const { controls } = fixture(t);
|
||||
const counts = new Map();
|
||||
const manager = (name) => Object.fromEntries([
|
||||
'subscribe', 'subscribeVisibilityRequests', 'addVisibilityGuard', 'subscribeBeforeDestroy',
|
||||
].map((method) => [method, () => () => {
|
||||
const key = `${name}:${method}`;
|
||||
counts.set(key, (counts.get(key) || 0) + 1);
|
||||
}]));
|
||||
const manager = (name) =>
|
||||
Object.fromEntries(
|
||||
[
|
||||
'subscribe',
|
||||
'subscribeVisibilityRequests',
|
||||
'addVisibilityGuard',
|
||||
'subscribeBeforeDestroy',
|
||||
].map((method) => [
|
||||
method,
|
||||
() => () => {
|
||||
const key = `${name}:${method}`;
|
||||
counts.set(key, (counts.get(key) || 0) + 1);
|
||||
},
|
||||
]),
|
||||
);
|
||||
controls.connect(manager('a'));
|
||||
controls.connect(manager('b'));
|
||||
assert.equal(counts.size, 4);
|
||||
|
||||
+208
-168
@@ -1,187 +1,227 @@
|
||||
import { cockpitEntryAllowed, isExplicitUserIntentOrigin, recordContextRestoreExplicitChange, recordContextSessionUserChange, shouldExitContextForLayerChange, spaceMissionEntryCancellationDisposition } from '../contextModePolicy.js';
|
||||
import {
|
||||
cockpitEntryAllowed,
|
||||
isExplicitUserIntentOrigin,
|
||||
recordContextRestoreExplicitChange,
|
||||
recordContextSessionUserChange,
|
||||
shouldExitContextForLayerChange,
|
||||
spaceMissionEntryCancellationDisposition,
|
||||
} from '../contextModePolicy.js';
|
||||
|
||||
export function _handleContextLayerChange(change) {
|
||||
if (this.destroyed) return;
|
||||
if (change?.layerId === 'radio' && [
|
||||
if (
|
||||
change?.layerId === 'radio' &&
|
||||
[
|
||||
'visibility-transition',
|
||||
'visibility',
|
||||
'visibility-cancelled',
|
||||
'visibility-failed',
|
||||
].includes(change.type)) {
|
||||
this.actions.refreshRadio();
|
||||
}
|
||||
if (change?.type === 'visibility-transition') return;
|
||||
// The effective mode must be read BEFORE the entering flag is cleared:
|
||||
// the entry layer's own enable event is the one that clears it, and the
|
||||
// session bookkeeping below needs to know a mode was being entered.
|
||||
const effectiveContextMode = this._contextModeEntering || this._contextMode;
|
||||
if (change?.type === 'visibility-cancelled') {
|
||||
const cancellationDisposition = spaceMissionEntryCancellationDisposition({
|
||||
change,
|
||||
});
|
||||
if (
|
||||
this._contextModeDeferredEntryIntent?.layerId === change.layerId
|
||||
&& this._contextModeDeferredEntryIntent.intentEpoch === change.intentEpoch
|
||||
) {
|
||||
if (cancellationDisposition !== 'replacement') {
|
||||
this._contextModeDeferredEntryIntent = null;
|
||||
}
|
||||
}
|
||||
if (cancellationDisposition === 'replacement') {
|
||||
this._contextModeEntering = 'space-missions';
|
||||
const entryIntent = this._contextModeEntryIntent;
|
||||
if (
|
||||
entryIntent?.generation === this._contextModeGeneration
|
||||
&& entryIntent.layerId === change.layerId
|
||||
&& entryIntent.intentEpoch === change.intentEpoch
|
||||
) {
|
||||
this._contextModeReplacementIntent = {
|
||||
generation: entryIntent.generation,
|
||||
layerId: change.layerId,
|
||||
intentEpoch: change.successorIntentEpoch,
|
||||
};
|
||||
}
|
||||
} else if (cancellationDisposition === 'restore') {
|
||||
this._contextModeEntering = null;
|
||||
this._contextModeEntryIntent = null;
|
||||
this._contextModeReplacementIntent = null;
|
||||
if (this._contextSessionSnapshot && !this._contextModeChanging) {
|
||||
this._contextMode = null;
|
||||
void this._trackContextLayerReaction(this._runUserFacingContextAction(
|
||||
async (notificationToken) => {
|
||||
await this._restoreContextSessionAfterLayerSettles(
|
||||
change.layerId,
|
||||
{ notificationToken },
|
||||
);
|
||||
return true;
|
||||
},
|
||||
'Space Missions cancellation could not restore the previous layer state',
|
||||
));
|
||||
}
|
||||
}
|
||||
this._syncContextModeButtons();
|
||||
return;
|
||||
}
|
||||
].includes(change.type)
|
||||
) {
|
||||
this.actions.refreshRadio();
|
||||
}
|
||||
if (change?.type === 'visibility-transition') return;
|
||||
// The effective mode must be read BEFORE the entering flag is cleared:
|
||||
// the entry layer's own enable event is the one that clears it, and the
|
||||
// session bookkeeping below needs to know a mode was being entered.
|
||||
const effectiveContextMode = this._contextModeEntering || this._contextMode;
|
||||
if (change?.type === 'visibility-cancelled') {
|
||||
const cancellationDisposition = spaceMissionEntryCancellationDisposition({
|
||||
change,
|
||||
});
|
||||
if (
|
||||
change?.layerId === 'rocket-launches'
|
||||
&& ['visibility', 'visibility-blocked', 'visibility-failed'].includes(change.type)
|
||||
this._contextModeDeferredEntryIntent?.layerId === change.layerId &&
|
||||
this._contextModeDeferredEntryIntent.intentEpoch === change.intentEpoch
|
||||
) {
|
||||
this._contextModeEntering = null;
|
||||
}
|
||||
if (change?.type === 'visibility-blocked') {
|
||||
if (!this._userFacingContextNotificationTokens.has(change.notificationToken)) {
|
||||
this.showToast(change.reason || 'That layer is unavailable in the current Context mode');
|
||||
if (cancellationDisposition !== 'replacement') {
|
||||
this._contextModeDeferredEntryIntent = null;
|
||||
}
|
||||
this._syncContextModeButtons();
|
||||
return;
|
||||
}
|
||||
if (change?.type === 'visibility-failed') {
|
||||
const failureMessage = `${change.layerId} could not ${change.enabled ? 'start' : 'stop'} cleanly`;
|
||||
// A failed direct Context-shell START has already had its siblings
|
||||
// cleared by the visibility guard. Wait outside the synchronous manager
|
||||
// notification for this queue to settle, then reconcile the complete
|
||||
// snapshot, including an uncertain failed shell.
|
||||
const needsDeferredShellRestore = (
|
||||
['military-awareness', 'rocket-launches'].includes(change.layerId)
|
||||
&& change.enabled
|
||||
&& this._contextSessionSnapshot
|
||||
&& !this._contextModeChanging
|
||||
);
|
||||
if (needsDeferredShellRestore) {
|
||||
this._contextMode = null;
|
||||
void this._trackContextLayerReaction(this._runUserFacingContextAction(
|
||||
async (notificationToken) => {
|
||||
await this._restoreContextSessionAfterLayerSettles(
|
||||
change.layerId,
|
||||
{ notificationToken },
|
||||
);
|
||||
// The wrapper owns failure announcements. On a successful rollback
|
||||
// announce the original activation failure here so the same direct
|
||||
// action still produces exactly one accessible notification.
|
||||
this.showToast(failureMessage);
|
||||
return true;
|
||||
},
|
||||
failureMessage,
|
||||
));
|
||||
} else if (!this._userFacingContextNotificationTokens.has(change.notificationToken)) {
|
||||
this.showToast(failureMessage);
|
||||
}
|
||||
this._syncContextModeButtons();
|
||||
return;
|
||||
}
|
||||
if (change?.type === 'visibility-will-change') {
|
||||
// Explicit entry capture happens on the synchronous visibility-requested
|
||||
// boundary. Keeping this later branch side-effect free prevents an
|
||||
// awaited Clear/guard from replacing that authoritative pre-entry view.
|
||||
return;
|
||||
}
|
||||
// Session bookkeeping must run BEFORE any exit path below: the exit
|
||||
// handlers restore `snapshot ∪ userAdded`, so a stale entry here becomes
|
||||
// a layer resurrected against the user's explicit disable.
|
||||
recordContextSessionUserChange({
|
||||
snapshot: this._contextSessionSnapshot,
|
||||
change,
|
||||
effectiveContextMode,
|
||||
});
|
||||
recordContextRestoreExplicitChange({
|
||||
restoreState: this._contextRestoreState,
|
||||
change,
|
||||
});
|
||||
if (shouldExitContextForLayerChange({
|
||||
contextMode: this._contextMode,
|
||||
globalContextEnabled: !!this._dataManager?.isEnabled('military-awareness'),
|
||||
change,
|
||||
})) {
|
||||
void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => (
|
||||
this._deactivateContextForLayerChange({ notificationToken })
|
||||
)));
|
||||
return;
|
||||
}
|
||||
if (!this._contextModeChanging) {
|
||||
if (change.layerId === 'military-awareness') {
|
||||
// The coordinator remains manager-addressable for restoration and
|
||||
// programmatic routes, but Contacts is selected only from the
|
||||
// dedicated right-side Global Context chooser.
|
||||
this._contextMode = change.enabled
|
||||
? null
|
||||
: (this._contextMode === 'flights' ? null : this._contextMode);
|
||||
if (change.enabled) {
|
||||
this._syncContextModeButtons();
|
||||
} else if (this._contextSessionSnapshot) {
|
||||
void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => (
|
||||
this._deactivateContextForLayerChange({ notificationToken })
|
||||
)));
|
||||
}
|
||||
} else if (change.layerId === 'rocket-launches') {
|
||||
const ownsContextEntry = isExplicitUserIntentOrigin(change.origin, change.layerId)
|
||||
|| this._contextMode === 'space-missions'
|
||||
|| effectiveContextMode === 'space-missions';
|
||||
if (!ownsContextEntry) return;
|
||||
this._contextMode = change.enabled ? 'space-missions' : (this._contextMode === 'space-missions' ? null : this._contextMode);
|
||||
if (change.enabled) {
|
||||
this._syncContextModeButtons();
|
||||
} else if (this._contextSessionSnapshot) {
|
||||
void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => (
|
||||
this._deactivateContextForLayerChange({ notificationToken })
|
||||
)));
|
||||
}
|
||||
} else if (
|
||||
this._contextMode === 'flights'
|
||||
&& ['flights', 'military', 'ais-live-vessels', 'military-installations'].includes(change.layerId)
|
||||
&& !change.enabled
|
||||
if (cancellationDisposition === 'replacement') {
|
||||
this._contextModeEntering = 'space-missions';
|
||||
const entryIntent = this._contextModeEntryIntent;
|
||||
if (
|
||||
entryIntent?.generation === this._contextModeGeneration &&
|
||||
entryIntent.layerId === change.layerId &&
|
||||
entryIntent.intentEpoch === change.intentEpoch
|
||||
) {
|
||||
void this._trackContextLayerReaction(this._runUserFacingContextAction((notificationToken) => (
|
||||
this._deactivateContextForLayerChange({ notificationToken })
|
||||
)));
|
||||
this._contextModeReplacementIntent = {
|
||||
generation: entryIntent.generation,
|
||||
layerId: change.layerId,
|
||||
intentEpoch: change.successorIntentEpoch,
|
||||
};
|
||||
}
|
||||
} else if (cancellationDisposition === 'restore') {
|
||||
this._contextModeEntering = null;
|
||||
this._contextModeEntryIntent = null;
|
||||
this._contextModeReplacementIntent = null;
|
||||
if (this._contextSessionSnapshot && !this._contextModeChanging) {
|
||||
this._contextMode = null;
|
||||
void this._trackContextLayerReaction(
|
||||
this._runUserFacingContextAction(async (notificationToken) => {
|
||||
await this._restoreContextSessionAfterLayerSettles(change.layerId, {
|
||||
notificationToken,
|
||||
});
|
||||
return true;
|
||||
}, 'Space Missions cancellation could not restore the previous layer state'),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (this.cockpitView?.active && !cockpitEntryAllowed({
|
||||
this._syncContextModeButtons();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
change?.layerId === 'rocket-launches' &&
|
||||
['visibility', 'visibility-blocked', 'visibility-failed'].includes(
|
||||
change.type,
|
||||
)
|
||||
) {
|
||||
this._contextModeEntering = null;
|
||||
}
|
||||
if (change?.type === 'visibility-blocked') {
|
||||
if (
|
||||
!this._userFacingContextNotificationTokens.has(change.notificationToken)
|
||||
) {
|
||||
this.showToast(
|
||||
change.reason ||
|
||||
'That layer is unavailable in the current Context mode',
|
||||
);
|
||||
}
|
||||
this._syncContextModeButtons();
|
||||
return;
|
||||
}
|
||||
if (change?.type === 'visibility-failed') {
|
||||
const failureMessage = `${change.layerId} could not ${change.enabled ? 'start' : 'stop'} cleanly`;
|
||||
// A failed direct Context-shell START has already had its siblings
|
||||
// cleared by the visibility guard. Wait outside the synchronous manager
|
||||
// notification for this queue to settle, then reconcile the complete
|
||||
// snapshot, including an uncertain failed shell.
|
||||
const needsDeferredShellRestore =
|
||||
['military-awareness', 'rocket-launches'].includes(change.layerId) &&
|
||||
change.enabled &&
|
||||
this._contextSessionSnapshot &&
|
||||
!this._contextModeChanging;
|
||||
if (needsDeferredShellRestore) {
|
||||
this._contextMode = null;
|
||||
void this._trackContextLayerReaction(
|
||||
this._runUserFacingContextAction(async (notificationToken) => {
|
||||
await this._restoreContextSessionAfterLayerSettles(change.layerId, {
|
||||
notificationToken,
|
||||
});
|
||||
// The wrapper owns failure announcements. On a successful rollback
|
||||
// announce the original activation failure here so the same direct
|
||||
// action still produces exactly one accessible notification.
|
||||
this.showToast(failureMessage);
|
||||
return true;
|
||||
}, failureMessage),
|
||||
);
|
||||
} else if (
|
||||
!this._userFacingContextNotificationTokens.has(change.notificationToken)
|
||||
) {
|
||||
this.showToast(failureMessage);
|
||||
}
|
||||
this._syncContextModeButtons();
|
||||
return;
|
||||
}
|
||||
if (change?.type === 'visibility-will-change') {
|
||||
// Explicit entry capture happens on the synchronous visibility-requested
|
||||
// boundary. Keeping this later branch side-effect free prevents an
|
||||
// awaited Clear/guard from replacing that authoritative pre-entry view.
|
||||
return;
|
||||
}
|
||||
// Session bookkeeping must run BEFORE any exit path below: the exit
|
||||
// handlers restore `snapshot ∪ userAdded`, so a stale entry here becomes
|
||||
// a layer resurrected against the user's explicit disable.
|
||||
recordContextSessionUserChange({
|
||||
snapshot: this._contextSessionSnapshot,
|
||||
change,
|
||||
effectiveContextMode,
|
||||
});
|
||||
recordContextRestoreExplicitChange({
|
||||
restoreState: this._contextRestoreState,
|
||||
change,
|
||||
});
|
||||
if (
|
||||
shouldExitContextForLayerChange({
|
||||
contextMode: this._contextMode,
|
||||
globalContextEnabled:
|
||||
!!this._dataManager?.isEnabled('military-awareness'),
|
||||
change,
|
||||
})
|
||||
) {
|
||||
void this._trackContextLayerReaction(
|
||||
this._runUserFacingContextAction((notificationToken) =>
|
||||
this._deactivateContextForLayerChange({ notificationToken }),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!this._contextModeChanging) {
|
||||
if (change.layerId === 'military-awareness') {
|
||||
// The coordinator remains manager-addressable for restoration and
|
||||
// programmatic routes, but Contacts is selected only from the
|
||||
// dedicated right-side Global Context chooser.
|
||||
this._contextMode = change.enabled
|
||||
? null
|
||||
: this._contextMode === 'flights'
|
||||
? null
|
||||
: this._contextMode;
|
||||
if (change.enabled) {
|
||||
this._syncContextModeButtons();
|
||||
} else if (this._contextSessionSnapshot) {
|
||||
void this._trackContextLayerReaction(
|
||||
this._runUserFacingContextAction((notificationToken) =>
|
||||
this._deactivateContextForLayerChange({ notificationToken }),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (change.layerId === 'rocket-launches') {
|
||||
const ownsContextEntry =
|
||||
isExplicitUserIntentOrigin(change.origin, change.layerId) ||
|
||||
this._contextMode === 'space-missions' ||
|
||||
effectiveContextMode === 'space-missions';
|
||||
if (!ownsContextEntry) return;
|
||||
this._contextMode = change.enabled
|
||||
? 'space-missions'
|
||||
: this._contextMode === 'space-missions'
|
||||
? null
|
||||
: this._contextMode;
|
||||
if (change.enabled) {
|
||||
this._syncContextModeButtons();
|
||||
} else if (this._contextSessionSnapshot) {
|
||||
void this._trackContextLayerReaction(
|
||||
this._runUserFacingContextAction((notificationToken) =>
|
||||
this._deactivateContextForLayerChange({ notificationToken }),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
this._contextMode === 'flights' &&
|
||||
[
|
||||
'flights',
|
||||
'military',
|
||||
'ais-live-vessels',
|
||||
'military-installations',
|
||||
].includes(change.layerId) &&
|
||||
!change.enabled
|
||||
) {
|
||||
void this._trackContextLayerReaction(
|
||||
this._runUserFacingContextAction((notificationToken) =>
|
||||
this._deactivateContextForLayerChange({ notificationToken }),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
this.cockpitView?.active &&
|
||||
!cockpitEntryAllowed({
|
||||
contextMode: this._contextMode,
|
||||
contextModeChanging: this._contextModeChanging,
|
||||
flightsEnabled: !!this._dataManager?.isEnabled('flights'),
|
||||
militaryEnabled: !!this._dataManager?.isEnabled('military'),
|
||||
})) {
|
||||
this.cockpitView.exit({ restoreTracking: false });
|
||||
}
|
||||
this._syncContextModeButtons();
|
||||
})
|
||||
) {
|
||||
this.cockpitView.exit({ restoreTracking: false });
|
||||
}
|
||||
this._syncContextModeButtons();
|
||||
}
|
||||
@@ -1,34 +1,44 @@
|
||||
|
||||
|
||||
export function _syncContextModeButtons() {
|
||||
if (this.destroyed) return;
|
||||
const flightsActive = this._contextMode === 'flights';
|
||||
const missionsActive = this._contextMode === 'space-missions';
|
||||
const panel = this._globalContextPanel;
|
||||
panel?.classList.toggle('context-enabled', flightsActive || missionsActive);
|
||||
panel?.setAttribute('data-context-mode', this._contextMode || 'none');
|
||||
this._globalContextFlightsBtn?.classList.toggle('active', flightsActive);
|
||||
this._globalContextFlightsBtn?.setAttribute('aria-selected', String(flightsActive));
|
||||
this._globalContextMissionsBtn?.classList.toggle('active', missionsActive);
|
||||
this._globalContextMissionsBtn?.setAttribute('aria-selected', String(missionsActive));
|
||||
const transitionBusy = Boolean(this._contextModeChanging);
|
||||
// Both Context choices stay in the ordinary Tab sequence. Arrow keys still
|
||||
// provide tablist navigation, but must not be the only way to reach Space
|
||||
// Missions from the keyboard. Semantic busy state keeps them perceivable
|
||||
// while synchronous click guards prevent a second transition.
|
||||
for (const button of [this._globalContextFlightsBtn, this._globalContextMissionsBtn]) {
|
||||
if (!button) continue;
|
||||
button.disabled = false;
|
||||
button.tabIndex = 0;
|
||||
button.setAttribute('aria-disabled', String(transitionBusy));
|
||||
button.setAttribute('aria-busy', String(transitionBusy));
|
||||
}
|
||||
if (this._contextModeStandby) this._contextModeStandby.hidden = flightsActive || missionsActive;
|
||||
if (this._contextFlightsView) this._contextFlightsView.hidden = !flightsActive;
|
||||
if (this._contextMissionsView) this._contextMissionsView.hidden = !missionsActive;
|
||||
this.cockpitView?.syncEntry();
|
||||
// Every _contextMode mutation funnels through here; the sync no-ops until
|
||||
// the transaction settles, so this is the activation/deactivation edge.
|
||||
this.actions.syncDetection();
|
||||
this.actions.scheduleLayout();
|
||||
const flightsActive = this._contextMode === 'flights';
|
||||
const missionsActive = this._contextMode === 'space-missions';
|
||||
const panel = this._globalContextPanel;
|
||||
panel?.classList.toggle('context-enabled', flightsActive || missionsActive);
|
||||
panel?.setAttribute('data-context-mode', this._contextMode || 'none');
|
||||
this._globalContextFlightsBtn?.classList.toggle('active', flightsActive);
|
||||
this._globalContextFlightsBtn?.setAttribute(
|
||||
'aria-selected',
|
||||
String(flightsActive),
|
||||
);
|
||||
this._globalContextMissionsBtn?.classList.toggle('active', missionsActive);
|
||||
this._globalContextMissionsBtn?.setAttribute(
|
||||
'aria-selected',
|
||||
String(missionsActive),
|
||||
);
|
||||
const transitionBusy = Boolean(this._contextModeChanging);
|
||||
// Both Context choices stay in the ordinary Tab sequence. Arrow keys still
|
||||
// provide tablist navigation, but must not be the only way to reach Space
|
||||
// Missions from the keyboard. Semantic busy state keeps them perceivable
|
||||
// while synchronous click guards prevent a second transition.
|
||||
for (const button of [
|
||||
this._globalContextFlightsBtn,
|
||||
this._globalContextMissionsBtn,
|
||||
]) {
|
||||
if (!button) continue;
|
||||
button.disabled = false;
|
||||
button.tabIndex = 0;
|
||||
button.setAttribute('aria-disabled', String(transitionBusy));
|
||||
button.setAttribute('aria-busy', String(transitionBusy));
|
||||
}
|
||||
if (this._contextModeStandby)
|
||||
this._contextModeStandby.hidden = flightsActive || missionsActive;
|
||||
if (this._contextFlightsView)
|
||||
this._contextFlightsView.hidden = !flightsActive;
|
||||
if (this._contextMissionsView)
|
||||
this._contextMissionsView.hidden = !missionsActive;
|
||||
this.cockpitView?.syncEntry();
|
||||
// Every _contextMode mutation funnels through here; the sync no-ops until
|
||||
// the transaction settles, so this is the activation/deactivation edge.
|
||||
this.actions.syncDetection();
|
||||
this.actions.scheduleLayout();
|
||||
}
|
||||
+139
-121
@@ -1,141 +1,156 @@
|
||||
import { contextRestoreLayerIds, contextSnapshotLayerIds, mergeContextTransitionErrors, settleContextIntentReplay } from '../contextModePolicy.js';
|
||||
import {
|
||||
contextRestoreLayerIds,
|
||||
contextSnapshotLayerIds,
|
||||
mergeContextTransitionErrors,
|
||||
settleContextIntentReplay,
|
||||
} from '../contextModePolicy.js';
|
||||
|
||||
export function _trackContextLayerReaction(promise) {
|
||||
const tracked = Promise.resolve(promise);
|
||||
this._contextLayerReactionPromises.add(tracked);
|
||||
void tracked.then(
|
||||
() => this._contextLayerReactionPromises.delete(tracked),
|
||||
() => this._contextLayerReactionPromises.delete(tracked),
|
||||
);
|
||||
return tracked;
|
||||
}
|
||||
const tracked = Promise.resolve(promise);
|
||||
this._contextLayerReactionPromises.add(tracked);
|
||||
void tracked.then(
|
||||
() => this._contextLayerReactionPromises.delete(tracked),
|
||||
() => this._contextLayerReactionPromises.delete(tracked),
|
||||
);
|
||||
return tracked;
|
||||
}
|
||||
|
||||
export async function _waitForContextLayerSettlement() {
|
||||
while (this._contextLayerReactionPromises.size > 0) {
|
||||
await Promise.allSettled([...this._contextLayerReactionPromises]);
|
||||
}
|
||||
while (this._contextLayerReactionPromises.size > 0) {
|
||||
await Promise.allSettled([...this._contextLayerReactionPromises]);
|
||||
}
|
||||
}
|
||||
|
||||
export function _captureContextSessionSnapshot({ excludeLayerIds = [] } = {}) {
|
||||
if (!this._dataManager || this._contextSessionSnapshot) return;
|
||||
const params = {};
|
||||
for (const layerId of ['military-awareness', 'satellites']) {
|
||||
const value = this._dataManager.getLayerParams(layerId);
|
||||
if (value) params[layerId] = value;
|
||||
}
|
||||
this._contextSessionSnapshot = {
|
||||
enabledLayerIds: contextSnapshotLayerIds(
|
||||
this._dataManager.getEnabledLayerIds(),
|
||||
this._contextRestoreState?.enabledLayerIds,
|
||||
excludeLayerIds,
|
||||
),
|
||||
userAdded: new Set(),
|
||||
userRemoved: new Set(),
|
||||
params,
|
||||
};
|
||||
if (!this._dataManager || this._contextSessionSnapshot) return;
|
||||
const params = {};
|
||||
for (const layerId of ['military-awareness', 'satellites']) {
|
||||
const value = this._dataManager.getLayerParams(layerId);
|
||||
if (value) params[layerId] = value;
|
||||
}
|
||||
this._contextSessionSnapshot = {
|
||||
enabledLayerIds: contextSnapshotLayerIds(
|
||||
this._dataManager.getEnabledLayerIds(),
|
||||
this._contextRestoreState?.enabledLayerIds,
|
||||
excludeLayerIds,
|
||||
),
|
||||
userAdded: new Set(),
|
||||
userRemoved: new Set(),
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
export async function _restoreContextSession({
|
||||
excludeLayerIds = [],
|
||||
notificationToken = null,
|
||||
signal = null,
|
||||
} = {}) {
|
||||
const snapshot = this._contextSessionSnapshot;
|
||||
if (!snapshot || !this._dataManager) return;
|
||||
// Clear the stored session before emitting restore notifications so none
|
||||
// of those transitions can be mistaken for a fresh Context entry.
|
||||
this._contextSessionSnapshot = null;
|
||||
const restoreState = {
|
||||
enabledLayerIds: contextRestoreLayerIds(snapshot),
|
||||
explicitLayerStates: new Map(),
|
||||
};
|
||||
this._contextRestoreState = restoreState;
|
||||
for (const [layerId, params] of Object.entries(snapshot.params)) {
|
||||
this._dataManager.setLayerParams(layerId, params);
|
||||
}
|
||||
let restoreError = null;
|
||||
const restoreSnapshot = async (restoreSignal = null) => {
|
||||
// Contacts owns the dependency intents it starts. Settle that
|
||||
// coordinator before restoring the remaining snapshot, otherwise its
|
||||
// dependency releases can supersede the restore's same-target requests
|
||||
// and make a valid Contacts-to-Missions handoff look like a failure.
|
||||
const contactsCoordinatorId = 'military-awareness';
|
||||
const settleContactsCoordinator = !restoreState.enabledLayerIds.has(contactsCoordinatorId)
|
||||
&& this._dataManager.isEffectivelyEnabled(contactsCoordinatorId);
|
||||
if (settleContactsCoordinator) {
|
||||
const coordinatorSettled = await this._dataManager.setEnabled(
|
||||
contactsCoordinatorId,
|
||||
false,
|
||||
{
|
||||
origin: 'context-restore',
|
||||
...(notificationToken ? { notificationToken } : {}),
|
||||
...(restoreSignal ? { signal: restoreSignal } : {}),
|
||||
},
|
||||
excludeLayerIds = [],
|
||||
notificationToken = null,
|
||||
signal = null,
|
||||
} = {}) {
|
||||
const snapshot = this._contextSessionSnapshot;
|
||||
if (!snapshot || !this._dataManager) return;
|
||||
// Clear the stored session before emitting restore notifications so none
|
||||
// of those transitions can be mistaken for a fresh Context entry.
|
||||
this._contextSessionSnapshot = null;
|
||||
const restoreState = {
|
||||
enabledLayerIds: contextRestoreLayerIds(snapshot),
|
||||
explicitLayerStates: new Map(),
|
||||
};
|
||||
this._contextRestoreState = restoreState;
|
||||
for (const [layerId, params] of Object.entries(snapshot.params)) {
|
||||
this._dataManager.setLayerParams(layerId, params);
|
||||
}
|
||||
let restoreError = null;
|
||||
const restoreSnapshot = async (restoreSignal = null) => {
|
||||
// Contacts owns the dependency intents it starts. Settle that
|
||||
// coordinator before restoring the remaining snapshot, otherwise its
|
||||
// dependency releases can supersede the restore's same-target requests
|
||||
// and make a valid Contacts-to-Missions handoff look like a failure.
|
||||
const contactsCoordinatorId = 'military-awareness';
|
||||
const settleContactsCoordinator =
|
||||
!restoreState.enabledLayerIds.has(contactsCoordinatorId) &&
|
||||
this._dataManager.isEffectivelyEnabled(contactsCoordinatorId);
|
||||
if (settleContactsCoordinator) {
|
||||
const coordinatorSettled = await this._dataManager.setEnabled(
|
||||
contactsCoordinatorId,
|
||||
false,
|
||||
{
|
||||
origin: 'context-restore',
|
||||
...(notificationToken ? { notificationToken } : {}),
|
||||
...(restoreSignal ? { signal: restoreSignal } : {}),
|
||||
},
|
||||
);
|
||||
if (coordinatorSettled === false) {
|
||||
const error = new Error(
|
||||
'Failed to settle Contacts before restoring Context',
|
||||
);
|
||||
if (coordinatorSettled === false) {
|
||||
const error = new Error('Failed to settle Contacts before restoring Context');
|
||||
error.failedLayerIds = [contactsCoordinatorId];
|
||||
throw error;
|
||||
}
|
||||
error.failedLayerIds = [contactsCoordinatorId];
|
||||
throw error;
|
||||
}
|
||||
await this._dataManager.restoreEnabledLayerIds(restoreState.enabledLayerIds, {
|
||||
}
|
||||
await this._dataManager.restoreEnabledLayerIds(
|
||||
restoreState.enabledLayerIds,
|
||||
{
|
||||
origin: 'context-restore',
|
||||
excludeLayerIds: settleContactsCoordinator
|
||||
? [...excludeLayerIds, contactsCoordinatorId]
|
||||
: excludeLayerIds,
|
||||
notificationToken,
|
||||
...(restoreSignal ? { signal: restoreSignal } : {}),
|
||||
});
|
||||
};
|
||||
try {
|
||||
await restoreSnapshot(signal);
|
||||
} catch (error) {
|
||||
restoreError = error;
|
||||
// A caller abort can arrive after only part of the exact restore has
|
||||
// settled. Finish that same target without the stale caller signal while
|
||||
// this restoreState still records newer explicit intents; replay below
|
||||
// then gives those newer intents final authority.
|
||||
if (signal?.aborted && !restoreState.cancelled) {
|
||||
try {
|
||||
await restoreSnapshot(null);
|
||||
restoreError = null;
|
||||
} catch (compensationError) {
|
||||
restoreError = mergeContextTransitionErrors(restoreError, compensationError);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
try {
|
||||
await restoreSnapshot(signal);
|
||||
} catch (error) {
|
||||
restoreError = error;
|
||||
// A caller abort can arrive after only part of the exact restore has
|
||||
// settled. Finish that same target without the stale caller signal while
|
||||
// this restoreState still records newer explicit intents; replay below
|
||||
// then gives those newer intents final authority.
|
||||
if (signal?.aborted && !restoreState.cancelled) {
|
||||
try {
|
||||
await restoreSnapshot(null);
|
||||
restoreError = null;
|
||||
} catch (compensationError) {
|
||||
restoreError = mergeContextTransitionErrors(
|
||||
restoreError,
|
||||
compensationError,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (this._contextRestoreState === restoreState) this._contextRestoreState = null;
|
||||
}
|
||||
// Clear Selected Layers owns a newer global OFF intent. A restore that was
|
||||
// already awaiting lifecycle work must not replay its captured companion
|
||||
// intent or recreate the discarded session after Clear invalidates it.
|
||||
if (restoreState.cancelled) return;
|
||||
// A direct Radio command may finish after restore has already copied its
|
||||
// target and queued the opposite state. Replay that newer intent only
|
||||
// after the stale queue drains; the replay origin cannot recurse here.
|
||||
const replaySignal = signal?.aborted ? null : signal;
|
||||
const replayError = await settleContextIntentReplay({
|
||||
restoreState,
|
||||
setEnabled: (layerId, enabled, options = {}) => this._dataManager.setEnabled(
|
||||
layerId,
|
||||
enabled,
|
||||
{ ...options, ...(replaySignal ? { signal: replaySignal } : {}) },
|
||||
),
|
||||
notificationToken,
|
||||
});
|
||||
restoreError = mergeContextTransitionErrors(restoreError, replayError);
|
||||
if (restoreError && !this._contextSessionSnapshot) {
|
||||
// Keep the exact still-pending target so a later exit/teardown can retry
|
||||
// instead of silently losing the user's pre-Context layer state.
|
||||
this._contextSessionSnapshot = {
|
||||
enabledLayerIds: new Set(restoreState.enabledLayerIds),
|
||||
userAdded: new Set(),
|
||||
userRemoved: new Set(),
|
||||
params: snapshot.params,
|
||||
};
|
||||
}
|
||||
if (restoreError) throw restoreError;
|
||||
} finally {
|
||||
if (this._contextRestoreState === restoreState)
|
||||
this._contextRestoreState = null;
|
||||
}
|
||||
// Clear Selected Layers owns a newer global OFF intent. A restore that was
|
||||
// already awaiting lifecycle work must not replay its captured companion
|
||||
// intent or recreate the discarded session after Clear invalidates it.
|
||||
if (restoreState.cancelled) return;
|
||||
// A direct Radio command may finish after restore has already copied its
|
||||
// target and queued the opposite state. Replay that newer intent only
|
||||
// after the stale queue drains; the replay origin cannot recurse here.
|
||||
const replaySignal = signal?.aborted ? null : signal;
|
||||
const replayError = await settleContextIntentReplay({
|
||||
restoreState,
|
||||
setEnabled: (layerId, enabled, options = {}) =>
|
||||
this._dataManager.setEnabled(layerId, enabled, {
|
||||
...options,
|
||||
...(replaySignal ? { signal: replaySignal } : {}),
|
||||
}),
|
||||
notificationToken,
|
||||
});
|
||||
restoreError = mergeContextTransitionErrors(restoreError, replayError);
|
||||
if (restoreError && !this._contextSessionSnapshot) {
|
||||
// Keep the exact still-pending target so a later exit/teardown can retry
|
||||
// instead of silently losing the user's pre-Context layer state.
|
||||
this._contextSessionSnapshot = {
|
||||
enabledLayerIds: new Set(restoreState.enabledLayerIds),
|
||||
userAdded: new Set(),
|
||||
userRemoved: new Set(),
|
||||
params: snapshot.params,
|
||||
};
|
||||
}
|
||||
if (restoreError) throw restoreError;
|
||||
}
|
||||
|
||||
/*
|
||||
* Cross-mode cancellation and failure deliberately settle on Context OFF.
|
||||
@@ -156,7 +171,10 @@ export async function _restoreContextSession({
|
||||
* ids preserved. A restore feature can be rebuilt post-launch on the
|
||||
* generation discipline the surrounding transaction already follows.
|
||||
*/
|
||||
export async function _restoreContextSessionAfterLayerSettles(layerId, { notificationToken = null } = {}) {
|
||||
await this._dataManager?.waitForLayerSettled?.(layerId);
|
||||
return this._restoreContextSession({ notificationToken });
|
||||
}
|
||||
export async function _restoreContextSessionAfterLayerSettles(
|
||||
layerId,
|
||||
{ notificationToken = null } = {},
|
||||
) {
|
||||
await this._dataManager?.waitForLayerSettled?.(layerId);
|
||||
return this._restoreContextSession({ notificationToken });
|
||||
}
|
||||
@@ -1,25 +1,38 @@
|
||||
import { contextLayerEnableBlockReason, isExplicitUserIntentOrigin, runWithContextModeChanging, settleContextModeChange, shouldCaptureContextSession, shouldDeferContextEntryDuringClear } from '../contextModePolicy.js';
|
||||
import {
|
||||
contextLayerEnableBlockReason,
|
||||
isExplicitUserIntentOrigin,
|
||||
runWithContextModeChanging,
|
||||
settleContextModeChange,
|
||||
shouldCaptureContextSession,
|
||||
shouldDeferContextEntryDuringClear,
|
||||
} from '../contextModePolicy.js';
|
||||
|
||||
export function connectContextManager(manager) {
|
||||
this.disconnect();
|
||||
if (this.destroyed) return;
|
||||
this._dataManager = manager || null;
|
||||
this._contextManagerUnsubscribe = this._dataManager?.subscribe?.((change) => {
|
||||
if (String(change?.type || '').startsWith('visibility')) this._handleContextLayerChange(change);
|
||||
if (String(change?.type || '').startsWith('visibility'))
|
||||
this._handleContextLayerChange(change);
|
||||
});
|
||||
if (typeof this._dataManager?.subscribeVisibilityRequests === 'function') {
|
||||
this._dataManagerVisibilityRequestUnsubscribe = this._dataManager.subscribeVisibilityRequests((change) => {
|
||||
if (typeof this._dataManager?.subscribeVisibilityRequests === 'function') {
|
||||
this._dataManagerVisibilityRequestUnsubscribe =
|
||||
this._dataManager.subscribeVisibilityRequests((change) => {
|
||||
if (this.destroyed) return;
|
||||
if (shouldCaptureContextSession(change)) {
|
||||
// This event is synchronous with intent publication, before an
|
||||
// awaited guard or Clear All can alter the rest of the layer set.
|
||||
// Manager effective visibility already includes both the new entry
|
||||
// intent and Clear's reserved OFF baseline.
|
||||
this._captureContextSessionSnapshot({ excludeLayerIds: [change.layerId] });
|
||||
if (shouldDeferContextEntryDuringClear({
|
||||
change,
|
||||
clearInFlight: Boolean(this._clearSelectedLayersPromise),
|
||||
})) {
|
||||
this._captureContextSessionSnapshot({
|
||||
excludeLayerIds: [change.layerId],
|
||||
});
|
||||
if (
|
||||
shouldDeferContextEntryDuringClear({
|
||||
change,
|
||||
clearInFlight: Boolean(this._clearSelectedLayersPromise),
|
||||
})
|
||||
) {
|
||||
this._contextModeDeferredEntryIntent = {
|
||||
layerId: change.layerId,
|
||||
intentEpoch: change.intentEpoch,
|
||||
@@ -29,9 +42,9 @@ export function connectContextManager(manager) {
|
||||
this._syncContextModeButtons();
|
||||
}
|
||||
} else if (
|
||||
change?.layerId === 'rocket-launches'
|
||||
&& change.enabled === false
|
||||
&& isExplicitUserIntentOrigin(change.origin, change.layerId)
|
||||
change?.layerId === 'rocket-launches' &&
|
||||
change.enabled === false &&
|
||||
isExplicitUserIntentOrigin(change.origin, change.layerId)
|
||||
) {
|
||||
this._contextModeDeferredEntryIntent = null;
|
||||
if (this._clearSelectedLayersPromise) {
|
||||
@@ -41,11 +54,14 @@ export function connectContextManager(manager) {
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if (typeof this._dataManager?.addVisibilityGuard === 'function') {
|
||||
this._dataManagerVisibilityGuardUnsubscribe = this._dataManager.addVisibilityGuard(async (change) => {
|
||||
}
|
||||
if (typeof this._dataManager?.addVisibilityGuard === 'function') {
|
||||
this._dataManagerVisibilityGuardUnsubscribe =
|
||||
this._dataManager.addVisibilityGuard(async (change) => {
|
||||
if (this.destroyed) return null;
|
||||
const layerName = this._dataManager?.layers?.get(change.layerId)?.module?.name || change.layerId;
|
||||
const layerName =
|
||||
this._dataManager?.layers?.get(change.layerId)?.module?.name ||
|
||||
change.layerId;
|
||||
const reason = contextLayerEnableBlockReason({
|
||||
contextMode: this._contextModeEntering || this._contextMode,
|
||||
change,
|
||||
@@ -53,23 +69,26 @@ export function connectContextManager(manager) {
|
||||
});
|
||||
if (reason) return reason;
|
||||
if (
|
||||
change.enabled
|
||||
&& ['military-awareness', 'rocket-launches'].includes(change.layerId)
|
||||
&& shouldCaptureContextSession(change)
|
||||
&& (
|
||||
!this._contextModeChanging
|
||||
|| (
|
||||
change.layerId === 'rocket-launches'
|
||||
&& this._contextModeDeferredEntryIntent?.intentEpoch === change.intentEpoch
|
||||
)
|
||||
)
|
||||
change.enabled &&
|
||||
['military-awareness', 'rocket-launches'].includes(change.layerId) &&
|
||||
shouldCaptureContextSession(change) &&
|
||||
(!this._contextModeChanging ||
|
||||
(change.layerId === 'rocket-launches' &&
|
||||
this._contextModeDeferredEntryIntent?.intentEpoch ===
|
||||
change.intentEpoch))
|
||||
) {
|
||||
const entryMode = change.layerId === 'rocket-launches' ? 'space-missions' : null;
|
||||
const deferredClearEntry = this._contextModeDeferredEntryIntent?.intentEpoch === change.intentEpoch;
|
||||
const entryMode =
|
||||
change.layerId === 'rocket-launches' ? 'space-missions' : null;
|
||||
const deferredClearEntry =
|
||||
this._contextModeDeferredEntryIntent?.intentEpoch ===
|
||||
change.intentEpoch;
|
||||
// A deferred entry owns the state after Clear settles. Restoring
|
||||
// Clear's transient busy flag here would leave Context stuck.
|
||||
const priorChanging = deferredClearEntry ? false : this._contextModeChanging;
|
||||
const notificationToken = change.notificationToken || Symbol('direct-context-shell-entry');
|
||||
const priorChanging = deferredClearEntry
|
||||
? false
|
||||
: this._contextModeChanging;
|
||||
const notificationToken =
|
||||
change.notificationToken || Symbol('direct-context-shell-entry');
|
||||
const ownsNotificationToken = !change.notificationToken;
|
||||
if (ownsNotificationToken) {
|
||||
this._userFacingContextNotificationTokens.add(notificationToken);
|
||||
@@ -80,10 +99,16 @@ export function connectContextManager(manager) {
|
||||
if (deferredClearEntry) {
|
||||
await this._clearSelectedLayersManagerPromise;
|
||||
if (this.destroyed) return false;
|
||||
if (this._contextModeDeferredEntryIntent?.intentEpoch !== change.intentEpoch) return false;
|
||||
if (
|
||||
this._contextModeDeferredEntryIntent?.intentEpoch !==
|
||||
change.intentEpoch
|
||||
)
|
||||
return false;
|
||||
this._contextModeDeferredEntryIntent = null;
|
||||
}
|
||||
await this._clearLayersOutsideContextMode(entryMode, { notificationToken });
|
||||
await this._clearLayersOutsideContextMode(entryMode, {
|
||||
notificationToken,
|
||||
});
|
||||
if (this.destroyed) return false;
|
||||
} catch (error) {
|
||||
this._contextModeEntering = null;
|
||||
@@ -94,30 +119,37 @@ export function connectContextManager(manager) {
|
||||
notificationToken,
|
||||
});
|
||||
} catch (restoreError) {
|
||||
console.warn(`[Context] ${change.layerId} rollback failed`, restoreError);
|
||||
console.warn(
|
||||
`[Context] ${change.layerId} rollback failed`,
|
||||
restoreError,
|
||||
);
|
||||
}
|
||||
return `${entryMode === 'space-missions' ? 'Space Missions' : 'Context'} could not start because another layer did not stop cleanly`;
|
||||
} finally {
|
||||
if (ownsNotificationToken) {
|
||||
this._userFacingContextNotificationTokens.delete(notificationToken);
|
||||
this._userFacingContextNotificationTokens.delete(
|
||||
notificationToken,
|
||||
);
|
||||
}
|
||||
if (!this.destroyed) settleContextModeChange(this, priorChanging);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
if (typeof this._dataManager?.subscribeBeforeDestroy === 'function') {
|
||||
this._dataManagerBeforeDestroyUnsubscribe = this._dataManager.subscribeBeforeDestroy(async ({ layerId } = {}) => {
|
||||
}
|
||||
if (typeof this._dataManager?.subscribeBeforeDestroy === 'function') {
|
||||
this._dataManagerBeforeDestroyUnsubscribe =
|
||||
this._dataManager.subscribeBeforeDestroy(async ({ layerId } = {}) => {
|
||||
if (this.destroyed || !this._contextSessionSnapshot) return;
|
||||
await runWithContextModeChanging(this, async () => {
|
||||
this._contextMode = null;
|
||||
this.cockpitView?.exit({ restoreTracking: false });
|
||||
this._syncContextModeButtons();
|
||||
await this._restoreContextSession({ excludeLayerIds: layerId ? [layerId] : [] });
|
||||
await this._restoreContextSession({
|
||||
excludeLayerIds: layerId ? [layerId] : [],
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
this._syncContextModeButtons();
|
||||
}
|
||||
|
||||
+248
-191
@@ -1,215 +1,272 @@
|
||||
import { contextAllowedLayerIds, mergeContextTransitionErrors } from '../contextModePolicy.js';
|
||||
import {
|
||||
contextAllowedLayerIds,
|
||||
mergeContextTransitionErrors,
|
||||
} from '../contextModePolicy.js';
|
||||
|
||||
export async function _selectContextMode(mode, { notificationToken = null, signal = null } = {}) {
|
||||
if (this.destroyed || !this._dataManager) return false;
|
||||
if (this._clearSelectedLayersPromise) return false;
|
||||
this._contextTransitionFailedLayerIds = [];
|
||||
const generation = ++this._contextModeGeneration;
|
||||
const isCurrent = () => !this.destroyed && generation === this._contextModeGeneration;
|
||||
this._contextModeEntryIntent = null;
|
||||
this._contextModeReplacementIntent = null;
|
||||
this._contextModeChanging = true;
|
||||
this._syncContextModeButtons();
|
||||
try {
|
||||
if (mode !== 'flights' && this.cockpitView?.active) {
|
||||
this.cockpitView.exit({ restoreTracking: false });
|
||||
}
|
||||
if (!mode) {
|
||||
this._contextMode = null;
|
||||
this._syncContextModeButtons();
|
||||
await this._restoreContextSession({ notificationToken, signal });
|
||||
return isCurrent();
|
||||
}
|
||||
// A cross-mode switch dismantles the prior mode BEFORE the new one is
|
||||
// committed. If the caller aborts in that window the switch never lands,
|
||||
// and the resting state is Context OFF — reported as such by
|
||||
// setContextMode rather than dressed up as a clean cancellation. See the
|
||||
// note above _restoreContextSessionAfterLayerSettles.
|
||||
const crossModeSwitch = Boolean(this._contextMode && this._contextMode !== mode);
|
||||
if (crossModeSwitch) {
|
||||
this._contextMode = null;
|
||||
await this._restoreContextSession({ notificationToken, signal });
|
||||
if (!isCurrent()) return false;
|
||||
if (signal?.aborted) return false;
|
||||
}
|
||||
this._captureContextSessionSnapshot();
|
||||
this._contextMode = mode;
|
||||
export async function _selectContextMode(
|
||||
mode,
|
||||
{ notificationToken = null, signal = null } = {},
|
||||
) {
|
||||
if (this.destroyed || !this._dataManager) return false;
|
||||
if (this._clearSelectedLayersPromise) return false;
|
||||
this._contextTransitionFailedLayerIds = [];
|
||||
const generation = ++this._contextModeGeneration;
|
||||
const isCurrent = () =>
|
||||
!this.destroyed && generation === this._contextModeGeneration;
|
||||
this._contextModeEntryIntent = null;
|
||||
this._contextModeReplacementIntent = null;
|
||||
this._contextModeChanging = true;
|
||||
this._syncContextModeButtons();
|
||||
try {
|
||||
if (mode !== 'flights' && this.cockpitView?.active) {
|
||||
this.cockpitView.exit({ restoreTracking: false });
|
||||
}
|
||||
if (!mode) {
|
||||
this._contextMode = null;
|
||||
this._syncContextModeButtons();
|
||||
// Replay isolation must settle before Space Missions starts. Contacts
|
||||
// keeps its non-dependency teardown in the background so slow source
|
||||
// shutdown does not delay cockpit entry.
|
||||
try {
|
||||
await this._clearLayersOutsideContextMode(mode, { notificationToken, signal });
|
||||
} catch (error) {
|
||||
if (!isCurrent()) return false;
|
||||
let transitionError = error;
|
||||
console.warn(`[Context] ${mode} isolation failed`, error);
|
||||
this._contextMode = null;
|
||||
this._syncContextModeButtons();
|
||||
try {
|
||||
await this._restoreContextSession({ notificationToken });
|
||||
} catch (restoreError) {
|
||||
transitionError = mergeContextTransitionErrors(transitionError, restoreError);
|
||||
this._contextTransitionFailedLayerIds = [...(transitionError.failedLayerIds || [])];
|
||||
throw transitionError;
|
||||
}
|
||||
this._contextTransitionFailedLayerIds = [...(transitionError?.failedLayerIds || [])];
|
||||
return false;
|
||||
}
|
||||
await this._restoreContextSession({ notificationToken, signal });
|
||||
return isCurrent();
|
||||
}
|
||||
// A cross-mode switch dismantles the prior mode BEFORE the new one is
|
||||
// committed. If the caller aborts in that window the switch never lands,
|
||||
// and the resting state is Context OFF — reported as such by
|
||||
// setContextMode rather than dressed up as a clean cancellation. See the
|
||||
// note above _restoreContextSessionAfterLayerSettles.
|
||||
const crossModeSwitch = Boolean(
|
||||
this._contextMode && this._contextMode !== mode,
|
||||
);
|
||||
if (crossModeSwitch) {
|
||||
this._contextMode = null;
|
||||
await this._restoreContextSession({ notificationToken, signal });
|
||||
if (!isCurrent()) return false;
|
||||
// Entry is one transaction: isolation succeeded above, so a failed mode
|
||||
// activation must roll the cleared layers back instead of stranding the
|
||||
// user in a half-entered mode with an orphaned snapshot.
|
||||
const entryLayerId = mode === 'flights' ? 'military-awareness' : 'rocket-launches';
|
||||
if (mode === 'flights') {
|
||||
this._dataManager.setLayerParams('military-awareness', { passive: false });
|
||||
}
|
||||
let activated = false;
|
||||
let activationError = null;
|
||||
let activationIntent = null;
|
||||
let terminalIntentOutcome = null;
|
||||
if (signal?.aborted) return false;
|
||||
}
|
||||
this._captureContextSessionSnapshot();
|
||||
this._contextMode = mode;
|
||||
this._syncContextModeButtons();
|
||||
// Replay isolation must settle before Space Missions starts. Contacts
|
||||
// keeps its non-dependency teardown in the background so slow source
|
||||
// shutdown does not delay cockpit entry.
|
||||
try {
|
||||
await this._clearLayersOutsideContextMode(mode, {
|
||||
notificationToken,
|
||||
signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isCurrent()) return false;
|
||||
let transitionError = error;
|
||||
console.warn(`[Context] ${mode} isolation failed`, error);
|
||||
this._contextMode = null;
|
||||
this._syncContextModeButtons();
|
||||
try {
|
||||
activationIntent = this._dataManager._setEnabledWithIntent(
|
||||
entryLayerId,
|
||||
true,
|
||||
{ notificationToken, ...(signal ? { signal } : {}) },
|
||||
await this._restoreContextSession({ notificationToken });
|
||||
} catch (restoreError) {
|
||||
transitionError = mergeContextTransitionErrors(
|
||||
transitionError,
|
||||
restoreError,
|
||||
);
|
||||
this._contextModeEntryIntent = {
|
||||
generation,
|
||||
layerId: entryLayerId,
|
||||
intentEpoch: activationIntent.intentEpoch,
|
||||
};
|
||||
activated = await activationIntent.promise;
|
||||
terminalIntentOutcome = await this._dataManager._waitForVisibilityIntent?.(
|
||||
this._contextTransitionFailedLayerIds = [
|
||||
...(transitionError.failedLayerIds || []),
|
||||
];
|
||||
throw transitionError;
|
||||
}
|
||||
this._contextTransitionFailedLayerIds = [
|
||||
...(transitionError?.failedLayerIds || []),
|
||||
];
|
||||
return false;
|
||||
}
|
||||
if (!isCurrent()) return false;
|
||||
// Entry is one transaction: isolation succeeded above, so a failed mode
|
||||
// activation must roll the cleared layers back instead of stranding the
|
||||
// user in a half-entered mode with an orphaned snapshot.
|
||||
const entryLayerId =
|
||||
mode === 'flights' ? 'military-awareness' : 'rocket-launches';
|
||||
if (mode === 'flights') {
|
||||
this._dataManager.setLayerParams('military-awareness', {
|
||||
passive: false,
|
||||
});
|
||||
}
|
||||
let activated = false;
|
||||
let activationError = null;
|
||||
let activationIntent = null;
|
||||
let terminalIntentOutcome = null;
|
||||
try {
|
||||
activationIntent = this._dataManager._setEnabledWithIntent(
|
||||
entryLayerId,
|
||||
true,
|
||||
{ notificationToken, ...(signal ? { signal } : {}) },
|
||||
);
|
||||
this._contextModeEntryIntent = {
|
||||
generation,
|
||||
layerId: entryLayerId,
|
||||
intentEpoch: activationIntent.intentEpoch,
|
||||
};
|
||||
activated = await activationIntent.promise;
|
||||
terminalIntentOutcome =
|
||||
await this._dataManager._waitForVisibilityIntent?.(
|
||||
entryLayerId,
|
||||
activationIntent.intentEpoch,
|
||||
);
|
||||
} catch (error) {
|
||||
activationError = error;
|
||||
}
|
||||
if (!isCurrent()) return false;
|
||||
let replacementIntent = mode === 'space-missions'
|
||||
&& this._contextModeReplacementIntent?.generation === generation
|
||||
&& this._contextModeReplacementIntent.layerId === entryLayerId
|
||||
} catch (error) {
|
||||
activationError = error;
|
||||
}
|
||||
if (!isCurrent()) return false;
|
||||
let replacementIntent =
|
||||
mode === 'space-missions' &&
|
||||
this._contextModeReplacementIntent?.generation === generation &&
|
||||
this._contextModeReplacementIntent.layerId === entryLayerId
|
||||
? this._contextModeReplacementIntent
|
||||
: null;
|
||||
while (replacementIntent) {
|
||||
const outcome = await this._dataManager._waitForVisibilityIntent?.(
|
||||
entryLayerId,
|
||||
replacementIntent.intentEpoch,
|
||||
);
|
||||
terminalIntentOutcome = outcome;
|
||||
if (!isCurrent()) return false;
|
||||
const replacementOwnsMode = outcome?.intentEpoch === replacementIntent.intentEpoch
|
||||
&& outcome.enabled === true
|
||||
&& outcome.succeeded === true;
|
||||
if (replacementOwnsMode) {
|
||||
this._contextModeEntering = null;
|
||||
this._contextModeEntryIntent = null;
|
||||
this._contextModeReplacementIntent = null;
|
||||
this._syncContextModeButtons();
|
||||
return true;
|
||||
}
|
||||
const successorEpoch = outcome?.cancellationReason === 'superseded'
|
||||
&& outcome.successorEnabled === true
|
||||
&& Number.isInteger(outcome.successorIntentEpoch)
|
||||
&& outcome.successorIntentEpoch > replacementIntent.intentEpoch
|
||||
? outcome.successorIntentEpoch
|
||||
: null;
|
||||
replacementIntent = successorEpoch === null ? null : {
|
||||
generation,
|
||||
layerId: entryLayerId,
|
||||
intentEpoch: successorEpoch,
|
||||
};
|
||||
}
|
||||
if (activationError || activated === false || !this._dataManager.isEnabled(entryLayerId)) {
|
||||
const cancelledAndSettled = terminalIntentOutcome?.succeeded === false
|
||||
&& ['caller-abort', 'resource-abort', 'superseded'].includes(
|
||||
terminalIntentOutcome.cancellationReason,
|
||||
);
|
||||
let transitionError = null;
|
||||
if (!cancelledAndSettled) {
|
||||
transitionError = activationError instanceof Error
|
||||
? activationError
|
||||
: new Error(`Context activation failed for: ${entryLayerId}`);
|
||||
transitionError.failedLayerIds = [...new Set([
|
||||
...(transitionError.failedLayerIds || []),
|
||||
entryLayerId,
|
||||
])];
|
||||
this._contextTransitionFailedLayerIds = [...transitionError.failedLayerIds];
|
||||
console.warn(`[Context] ${mode} activation failed; restoring previous layers`, activationError || 'not enabled');
|
||||
}
|
||||
this._contextMode = null;
|
||||
while (replacementIntent) {
|
||||
const outcome = await this._dataManager._waitForVisibilityIntent?.(
|
||||
entryLayerId,
|
||||
replacementIntent.intentEpoch,
|
||||
);
|
||||
terminalIntentOutcome = outcome;
|
||||
if (!isCurrent()) return false;
|
||||
const replacementOwnsMode =
|
||||
outcome?.intentEpoch === replacementIntent.intentEpoch &&
|
||||
outcome.enabled === true &&
|
||||
outcome.succeeded === true;
|
||||
if (replacementOwnsMode) {
|
||||
this._contextModeEntering = null;
|
||||
this._contextModeEntryIntent = null;
|
||||
this._contextModeReplacementIntent = null;
|
||||
this._syncContextModeButtons();
|
||||
try {
|
||||
await this._restoreContextSession({
|
||||
excludeLayerIds: [entryLayerId],
|
||||
notificationToken,
|
||||
});
|
||||
} catch (restoreError) {
|
||||
transitionError = mergeContextTransitionErrors(transitionError, restoreError);
|
||||
this._contextTransitionFailedLayerIds = [...(transitionError?.failedLayerIds || [])];
|
||||
throw transitionError;
|
||||
}
|
||||
// `null` means the requested entry was cancelled and its exact rollback
|
||||
// completed. The action wrapper treats that as a silent non-commit,
|
||||
// while callers still require literal `true` before expanding Context.
|
||||
return cancelledAndSettled ? null : false;
|
||||
}
|
||||
this._contextModeEntryIntent = null;
|
||||
return true;
|
||||
} finally {
|
||||
if (isCurrent()) {
|
||||
this._contextModeChanging = false;
|
||||
this._syncContextModeButtons();
|
||||
return true;
|
||||
}
|
||||
const successorEpoch =
|
||||
outcome?.cancellationReason === 'superseded' &&
|
||||
outcome.successorEnabled === true &&
|
||||
Number.isInteger(outcome.successorIntentEpoch) &&
|
||||
outcome.successorIntentEpoch > replacementIntent.intentEpoch
|
||||
? outcome.successorIntentEpoch
|
||||
: null;
|
||||
replacementIntent =
|
||||
successorEpoch === null
|
||||
? null
|
||||
: {
|
||||
generation,
|
||||
layerId: entryLayerId,
|
||||
intentEpoch: successorEpoch,
|
||||
};
|
||||
}
|
||||
if (
|
||||
activationError ||
|
||||
activated === false ||
|
||||
!this._dataManager.isEnabled(entryLayerId)
|
||||
) {
|
||||
const cancelledAndSettled =
|
||||
terminalIntentOutcome?.succeeded === false &&
|
||||
['caller-abort', 'resource-abort', 'superseded'].includes(
|
||||
terminalIntentOutcome.cancellationReason,
|
||||
);
|
||||
let transitionError = null;
|
||||
if (!cancelledAndSettled) {
|
||||
transitionError =
|
||||
activationError instanceof Error
|
||||
? activationError
|
||||
: new Error(`Context activation failed for: ${entryLayerId}`);
|
||||
transitionError.failedLayerIds = [
|
||||
...new Set([...(transitionError.failedLayerIds || []), entryLayerId]),
|
||||
];
|
||||
this._contextTransitionFailedLayerIds = [
|
||||
...transitionError.failedLayerIds,
|
||||
];
|
||||
console.warn(
|
||||
`[Context] ${mode} activation failed; restoring previous layers`,
|
||||
activationError || 'not enabled',
|
||||
);
|
||||
}
|
||||
this._contextMode = null;
|
||||
this._contextModeEntryIntent = null;
|
||||
this._contextModeReplacementIntent = null;
|
||||
this._syncContextModeButtons();
|
||||
try {
|
||||
await this._restoreContextSession({
|
||||
excludeLayerIds: [entryLayerId],
|
||||
notificationToken,
|
||||
});
|
||||
} catch (restoreError) {
|
||||
transitionError = mergeContextTransitionErrors(
|
||||
transitionError,
|
||||
restoreError,
|
||||
);
|
||||
this._contextTransitionFailedLayerIds = [
|
||||
...(transitionError?.failedLayerIds || []),
|
||||
];
|
||||
throw transitionError;
|
||||
}
|
||||
// `null` means the requested entry was cancelled and its exact rollback
|
||||
// completed. The action wrapper treats that as a silent non-commit,
|
||||
// while callers still require literal `true` before expanding Context.
|
||||
return cancelledAndSettled ? null : false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function _deactivateContextForLayerChange({ notificationToken = null } = {}) {
|
||||
this._contextModeGeneration += 1;
|
||||
this._contextModeChanging = true;
|
||||
this._contextMode = null;
|
||||
this._contextModeEntryIntent = null;
|
||||
this._contextModeReplacementIntent = null;
|
||||
if (this.cockpitView?.active) this.cockpitView.exit({ restoreTracking: false });
|
||||
this._syncContextModeButtons();
|
||||
try {
|
||||
await this._restoreContextSession({ notificationToken });
|
||||
} finally {
|
||||
return true;
|
||||
} finally {
|
||||
if (isCurrent()) {
|
||||
this._contextModeChanging = false;
|
||||
this._syncContextModeButtons();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function _deactivateContextForLayerChange({
|
||||
notificationToken = null,
|
||||
} = {}) {
|
||||
this._contextModeGeneration += 1;
|
||||
this._contextModeChanging = true;
|
||||
this._contextMode = null;
|
||||
this._contextModeEntryIntent = null;
|
||||
this._contextModeReplacementIntent = null;
|
||||
if (this.cockpitView?.active)
|
||||
this.cockpitView.exit({ restoreTracking: false });
|
||||
this._syncContextModeButtons();
|
||||
try {
|
||||
await this._restoreContextSession({ notificationToken });
|
||||
} finally {
|
||||
this._contextModeChanging = false;
|
||||
this._syncContextModeButtons();
|
||||
}
|
||||
}
|
||||
|
||||
export async function _clearLayersOutsideContextMode(
|
||||
mode = null,
|
||||
{ notificationToken = null, signal = null } = {},
|
||||
) {
|
||||
const allowed = contextAllowedLayerIds(mode);
|
||||
const pending = [];
|
||||
for (const [layerId] of this._dataManager.layers || []) {
|
||||
// Effective visibility: a disallowed layer still mid-ENABLING must be
|
||||
// isolated too, or it settles ON inside the exclusive mode.
|
||||
if (!allowed.has(layerId) && this._dataManager.isEffectivelyEnabled(layerId)) {
|
||||
pending.push({
|
||||
layerId,
|
||||
transition: this._dataManager.setEnabled(layerId, false, {
|
||||
notificationToken,
|
||||
...(signal ? { signal } : {}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
const results = await Promise.all(pending.map(({ transition }) => transition));
|
||||
const failed = pending
|
||||
.filter(({ layerId }, index) => results[index] === false || this._dataManager.isEnabled(layerId))
|
||||
.map(({ layerId }) => layerId);
|
||||
if (failed.length > 0) {
|
||||
const error = new Error(`Context isolation failed for: ${failed.join(', ')}`);
|
||||
error.failedLayerIds = failed;
|
||||
throw error;
|
||||
mode = null,
|
||||
{ notificationToken = null, signal = null } = {},
|
||||
) {
|
||||
const allowed = contextAllowedLayerIds(mode);
|
||||
const pending = [];
|
||||
for (const [layerId] of this._dataManager.layers || []) {
|
||||
// Effective visibility: a disallowed layer still mid-ENABLING must be
|
||||
// isolated too, or it settles ON inside the exclusive mode.
|
||||
if (
|
||||
!allowed.has(layerId) &&
|
||||
this._dataManager.isEffectivelyEnabled(layerId)
|
||||
) {
|
||||
pending.push({
|
||||
layerId,
|
||||
transition: this._dataManager.setEnabled(layerId, false, {
|
||||
notificationToken,
|
||||
...(signal ? { signal } : {}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
const results = await Promise.all(
|
||||
pending.map(({ transition }) => transition),
|
||||
);
|
||||
const failed = pending
|
||||
.filter(
|
||||
({ layerId }, index) =>
|
||||
results[index] === false || this._dataManager.isEnabled(layerId),
|
||||
)
|
||||
.map(({ layerId }) => layerId);
|
||||
if (failed.length > 0) {
|
||||
const error = new Error(
|
||||
`Context isolation failed for: ${failed.join(', ')}`,
|
||||
);
|
||||
error.failedLayerIds = failed;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user