style: format Context component modules

This commit is contained in:
Sameh Khamis committed 2026-09-13 01:41:01 -07:00
1 parent fc369cc65a
commit 3e078ef49d
11 files changed
+1175 -801

No files matched your search

+11 -1
View File
@@ -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"
]
+24 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
+57 -18
View File
@@ -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
View File
@@ -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();
}
+41 -31
View File
@@ -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
View File
@@ -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 });
}
+72 -40
View File
@@ -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
View File
@@ -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;
}
}