fix popout scopes issues

This commit is contained in:
Boof2015
2026-05-15 15:35:59 -04:00
parent 023b87801a
commit 2ac1554e8a
6 changed files with 367 additions and 33 deletions
+20 -5
View File
@@ -26,6 +26,7 @@ import { resolveNativeThemeSource } from '../shared/themeState'
import { resolveWindowCapabilities } from '../shared/windowCapabilities' import { resolveWindowCapabilities } from '../shared/windowCapabilities'
import { import {
clampDraggedMainWindowBounds, clampDraggedMainWindowBounds,
clampRestoredWindowBounds,
raiseWindowAboveNormalPopouts, raiseWindowAboveNormalPopouts,
resolveExpandedMainWindowBounds, resolveExpandedMainWindowBounds,
} from '../shared/windowGeometry' } from '../shared/windowGeometry'
@@ -98,6 +99,7 @@ const NOW_PLAYING_CONFIG_DEFAULTS = {
const STATIC_APP_ICON_FILENAME = 'icon.png' const STATIC_APP_ICON_FILENAME = 'icon.png'
const MAIN_WINDOW_SYNC_SUPPRESSION_MS = 180 const MAIN_WINDOW_SYNC_SUPPRESSION_MS = 180
const MAIN_WINDOW_VISIBLE_GRAB_MARGIN = 64 const MAIN_WINDOW_VISIBLE_GRAB_MARGIN = 64
const RESTORED_WINDOW_VISIBLE_MARGIN = 64
const runtimeWindowCapabilities = resolveWindowCapabilities({ const runtimeWindowCapabilities = resolveWindowCapabilities({
platform: process.platform, platform: process.platform,
argv: process.argv, argv: process.argv,
@@ -582,7 +584,11 @@ function syncMainWindowLogicalBounds(window: BrowserWindow, bounds = window.getB
} }
function applyMainWindowLogicalBounds(window: BrowserWindow, bounds: WindowBounds): void { function applyMainWindowLogicalBounds(window: BrowserWindow, bounds: WindowBounds): void {
const logicalBounds = normalizeMainWindowBounds(bounds) const logicalBounds = clampRestoredWindowBounds(
normalizeMainWindowBounds(bounds),
getDisplayWorkAreas(),
RESTORED_WINDOW_VISIBLE_MARGIN,
)
mainWindowLogicalBounds = logicalBounds mainWindowLogicalBounds = logicalBounds
suppressMainWindowSync() suppressMainWindowSync()
const expandedBounds = resolveExpandedMainWindowBounds(logicalBounds, getSettingsHeight(window), getDisplayWorkAreas()) const expandedBounds = resolveExpandedMainWindowBounds(logicalBounds, getSettingsHeight(window), getDisplayWorkAreas())
@@ -605,10 +611,12 @@ function applyLogicalBounds(window: BrowserWindow, bounds: WindowBounds): void {
return return
} }
window.setBounds({ const nextBounds = clampRestoredWindowBounds({
...bounds, ...bounds,
height: nextHeight, height: nextHeight,
}) }, getDisplayWorkAreas(), RESTORED_WINDOW_VISIBLE_MARGIN)
window.setBounds(nextBounds)
} }
function setWindowHeight(window: BrowserWindow, bounds: WindowBounds, height: number, y = bounds.y): void { function setWindowHeight(window: BrowserWindow, bounds: WindowBounds, height: number, y = bounds.y): void {
@@ -1121,9 +1129,12 @@ function createScopePopoutWindow(kind: ScopeKind, rawBounds?: WindowBounds): Bro
width: POPOUT_DEFAULTS.width, width: POPOUT_DEFAULTS.width,
height: POPOUT_DEFAULTS.height, height: POPOUT_DEFAULTS.height,
} }
const bounds = shouldRestoreGeometry const normalizedBounds = shouldRestoreGeometry
? normalizeBounds(rawBounds, fallbackBounds) ? normalizeBounds(rawBounds, fallbackBounds)
: fallbackBounds : fallbackBounds
const bounds = shouldRestoreGeometry
? clampRestoredWindowBounds(normalizedBounds, getDisplayWorkAreas(), RESTORED_WINDOW_VISIBLE_MARGIN)
: normalizedBounds
suppressNextPopoutBoundsEvents.add(kind) suppressNextPopoutBoundsEvents.add(kind)
const options: BrowserWindowConstructorOptions = { const options: BrowserWindowConstructorOptions = {
@@ -1212,7 +1223,11 @@ function syncScopePopouts(nextState: ScopePopoutSyncStateMap): void {
if (supportsGeometryPersistence() && desired.bounds) { if (supportsGeometryPersistence() && desired.bounds) {
const currentBounds = popoutWindow.getBounds() const currentBounds = popoutWindow.getBounds()
const nextBounds = normalizeBounds(desired.bounds, currentBounds) const nextBounds = clampRestoredWindowBounds(
normalizeBounds(desired.bounds, currentBounds),
getDisplayWorkAreas(),
RESTORED_WINDOW_VISIBLE_MARGIN,
)
const hasBoundsDelta = const hasBoundsDelta =
currentBounds.x !== nextBounds.x currentBounds.x !== nextBounds.x
|| currentBounds.y !== nextBounds.y || currentBounds.y !== nextBounds.y
@@ -60,6 +60,7 @@ const defaultTheme = resolveTheme(createDefaultTheme())
export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps): JSX.Element { export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps): JSX.Element {
const [snapshot, setSnapshot] = useState<ScopePopoutSnapshot<ScopeKind> | null>(null) const [snapshot, setSnapshot] = useState<ScopePopoutSnapshot<ScopeKind> | null>(null)
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(false) const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(false)
const [cursorInsideWindow, setCursorInsideWindow] = useState(false)
const prevMiniSettingsOpenRef = useRef(false) const prevMiniSettingsOpenRef = useRef(false)
const frameTarget = usePerformanceStore((s) => s.frameTarget) const frameTarget = usePerformanceStore((s) => s.frameTarget)
const miniSettingsOpen = useUiStore((s) => s.settingsOpen) const miniSettingsOpen = useUiStore((s) => s.settingsOpen)
@@ -78,6 +79,30 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps)
frameScheduler.setFrameTarget(frameTarget) frameScheduler.setFrameTarget(frameTarget)
}, [frameScheduler, frameTarget]) }, [frameScheduler, frameTarget])
useEffect(() => {
let isDisposed = false
const syncCursorInsideWindow = (): void => {
void window.electronAPI.isCursorInsideWindow()
.then((isInside) => {
if (!isDisposed) {
setCursorInsideWindow(isInside)
}
})
.catch(() => {
// Renderer pointer events still update the chrome when cursor polling is unavailable.
})
}
syncCursorInsideWindow()
const interval = setInterval(syncCursorInsideWindow, 120)
return () => {
isDisposed = true
clearInterval(interval)
}
}, [])
useEffect(() => { useEffect(() => {
const unsubscribeSnapshot = window.electronAPI.onScopePopoutSnapshot((nextSnapshot) => { const unsubscribeSnapshot = window.electronAPI.onScopePopoutSnapshot((nextSnapshot) => {
if (nextSnapshot.kind !== scopeKind) return if (nextSnapshot.kind !== scopeKind) return
@@ -182,6 +207,9 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps)
return ( return (
<div <div
className="scope-popout" className="scope-popout"
onMouseEnter={() => setCursorInsideWindow(true)}
onMouseMove={() => setCursorInsideWindow(true)}
onMouseLeave={() => setCursorInsideWindow(false)}
onMouseDown={useNativeDragRegions ? undefined : handleAltDragStart} onMouseDown={useNativeDragRegions ? undefined : handleAltDragStart}
onMouseUp={useNativeDragRegions ? undefined : handleAltDragEnd} onMouseUp={useNativeDragRegions ? undefined : handleAltDragEnd}
> >
@@ -193,6 +221,7 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps)
className={[ className={[
'scope-popout__chrome', 'scope-popout__chrome',
miniSettingsOpen ? 'is-expanded' : '', miniSettingsOpen ? 'is-expanded' : '',
cursorInsideWindow ? 'is-cursor-inside' : '',
].join(' ').trim()} ].join(' ').trim()}
> >
<header className={`scope-popout__header ${useNativeDragRegions ? 'is-native-drag' : ''}`.trim()}> <header className={`scope-popout__header ${useNativeDragRegions ? 'is-native-drag' : ''}`.trim()}>
+84 -10
View File
@@ -30,6 +30,8 @@ const ACTIVE_PROFILE_KEY = 'prism:activeProfile'
const PROFILE_GEOMETRY_SYNC_WINDOW_MS = 800 const PROFILE_GEOMETRY_SYNC_WINDOW_MS = 800
interface PersistedSettingsState { interface PersistedSettingsState {
activeProfileId?: string | null
profileBaselineSignature?: string
scopeOrder: ScopeKind[] scopeOrder: ScopeKind[]
hiddenScopes: ScopeKind[] hiddenScopes: ScopeKind[]
widthWeights: Record<ScopeKind, number> widthWeights: Record<ScopeKind, number>
@@ -112,6 +114,29 @@ function buildPersistedScopePopouts(scopePopouts: ScopePopoutStateMap): ScopePop
: stripScopePopoutBounds(normalizedScopePopouts) : stripScopePopoutBounds(normalizedScopePopouts)
} }
function buildProfileBaselineSignature(profile: Profile | null): string | undefined {
if (!profile) {
return undefined
}
const normalizedProfile = normalizeProfile(profile, profile.name)
const scopePopouts = SCOPE_KINDS.reduce((acc, kind) => {
acc[kind] = {
poppedOut: normalizedProfile.scopePopouts[kind]?.poppedOut === true,
}
return acc
}, {} as Record<ScopeKind, { poppedOut: boolean }>)
return JSON.stringify({
name: normalizedProfile.name,
scopeOrder: normalizeScopeOrder(normalizedProfile.scopeOrder),
hiddenScopes: normalizeHiddenScopes(normalizedProfile.hiddenScopes),
widthWeights: normalizeWidthWeights(normalizedProfile.widthWeights),
scopeSettings: mergeScopeSettings(normalizedProfile.scopeSettings),
scopePopouts,
})
}
function restoreBaselineScopePopoutBounds( function restoreBaselineScopePopoutBounds(
scopePopouts: ScopePopoutStateMap, scopePopouts: ScopePopoutStateMap,
baseline: Profile | null, baseline: Profile | null,
@@ -144,6 +169,30 @@ function restoreBaselineGeometry(
} }
} }
function restoreBaselinePopoutOpenState(
state: Pick<SettingsState, 'savedProfileBaseline'>,
workingState: WorkingSettingsState,
): WorkingSettingsState {
const baseline = state.savedProfileBaseline
if (!baseline) {
return workingState
}
const baselinePopouts = normalizeScopePopouts(baseline.scopePopouts)
return {
...workingState,
scopePopouts: SCOPE_KINDS.reduce((acc, kind) => {
acc[kind] = {
...workingState.scopePopouts[kind],
poppedOut: baselinePopouts[kind]?.poppedOut === true,
windowBounds: workingState.scopePopouts[kind]?.windowBounds
?? baselinePopouts[kind]?.windowBounds,
}
return acc
}, {} as ScopePopoutStateMap),
}
}
function loadFromStorage(): Partial<PersistedSettingsState> { function loadFromStorage(): Partial<PersistedSettingsState> {
if (!canUseBrowserStorage()) { if (!canUseBrowserStorage()) {
return {} return {}
@@ -165,7 +214,9 @@ function loadFromStorage(): Partial<PersistedSettingsState> {
return {} return {}
} }
function saveToStorage(state: WorkingSettingsState): void { function saveToStorage(
state: WorkingSettingsState & Pick<SettingsState, 'activeProfileId' | 'savedProfileBaseline'>,
): void {
if (!canUseBrowserStorage()) { if (!canUseBrowserStorage()) {
return return
} }
@@ -177,6 +228,8 @@ function saveToStorage(state: WorkingSettingsState): void {
: undefined : undefined
localStorage.setItem(STORAGE_KEY, JSON.stringify({ localStorage.setItem(STORAGE_KEY, JSON.stringify({
activeProfileId: state.activeProfileId,
profileBaselineSignature: buildProfileBaselineSignature(state.savedProfileBaseline),
scopeOrder: state.scopeOrder, scopeOrder: state.scopeOrder,
hiddenScopes: Array.from(state.hiddenScopes), hiddenScopes: Array.from(state.hiddenScopes),
widthWeights: state.widthWeights, widthWeights: state.widthWeights,
@@ -189,7 +242,9 @@ function saveToStorage(state: WorkingSettingsState): void {
} }
} }
function persistWorkingState(state: WorkingSettingsState): void { function persistWorkingState(
state: WorkingSettingsState & Pick<SettingsState, 'activeProfileId' | 'savedProfileBaseline'>,
): void {
saveToStorage(state) saveToStorage(state)
} }
@@ -202,6 +257,26 @@ function hasPersistedWorkingState(state: Partial<PersistedSettingsState>): boole
|| 'windowBounds' in state || 'windowBounds' in state
} }
function canRestorePersistedWorkingState(
state: Partial<PersistedSettingsState>,
snapshot: ProfileLibrarySnapshot,
): boolean {
if (!hasPersistedWorkingState(state)) {
return false
}
if (!snapshot.activeProfileId || state.activeProfileId !== snapshot.activeProfileId) {
return false
}
const activeProfile = snapshot.profiles[snapshot.activeProfileId]
if (!activeProfile || typeof state.profileBaselineSignature !== 'string') {
return false
}
return state.profileBaselineSignature === buildProfileBaselineSignature(activeProfile)
}
function loadLegacyProfileMigrationPayload(): LegacyProfileMigrationPayload | null { function loadLegacyProfileMigrationPayload(): LegacyProfileMigrationPayload | null {
try { try {
const rawProfiles = localStorage.getItem(PROFILES_STORAGE_KEY) const rawProfiles = localStorage.getItem(PROFILES_STORAGE_KEY)
@@ -558,19 +633,18 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
} }
} }
// If localStorage has a working state from a previous session, preserve it so the // Preserve a previous working draft only when it belongs to the current active
// user picks up exactly where they left off (dirty or not). The saved profile becomes // profile baseline. Legacy or stale state should not override saved popout state.
// the baseline for dirty-state comparison but the working values are left as-is.
// On first launch (no stored state) we load the profile normally.
const storedWorkingState = loadFromStorage() const storedWorkingState = loadFromStorage()
const hasStoredWorkingState = hasPersistedWorkingState(storedWorkingState) const shouldRestoreWorkingState = canRestorePersistedWorkingState(storedWorkingState, snapshot)
applyProfileSnapshot(set, snapshot, { loadActiveProfile: !hasStoredWorkingState }) applyProfileSnapshot(set, snapshot, { loadActiveProfile: !shouldRestoreWorkingState })
if (hasStoredWorkingState) { if (shouldRestoreWorkingState) {
set((state) => { set((state) => {
const nextWorkingState = restoreBaselineGeometry( const persistedWorkingState = restoreBaselineGeometry(
state, state,
createWorkingStateFromPersistedState(storedWorkingState), createWorkingStateFromPersistedState(storedWorkingState),
) )
const nextWorkingState = restoreBaselinePopoutOpenState(state, persistedWorkingState)
return commitWorkingState(state, nextWorkingState, state.savedProfileBaseline) return commitWorkingState(state, nextWorkingState, state.savedProfileBaseline)
}) })
+2
View File
@@ -1977,6 +1977,8 @@ button.toolbar__version:hover {
.scope-popout__viewport:hover .scope-popout__chrome, .scope-popout__viewport:hover .scope-popout__chrome,
.scope-popout__chrome:hover, .scope-popout__chrome:hover,
.scope-popout__chrome:focus-within,
.scope-popout__chrome.is-cursor-inside,
.scope-popout__chrome.is-expanded { .scope-popout__chrome.is-expanded {
max-height: 58px; max-height: 58px;
opacity: 1; opacity: 1;
+9
View File
@@ -145,6 +145,15 @@ export function clampDraggedMainWindowBounds(
return clampBoundsWithVisibleMargin(actualBounds, envelope, visibleMargin) return clampBoundsWithVisibleMargin(actualBounds, envelope, visibleMargin)
} }
export function clampRestoredWindowBounds(
bounds: WindowBounds,
workAreas: readonly WorkAreaBounds[],
visibleMargin: number,
): WindowBounds {
const envelope = buildDisplayEnvelope(bounds, bounds, workAreas)
return clampBoundsWithVisibleMargin(bounds, envelope, visibleMargin)
}
export function raiseWindowAboveNormalPopouts( export function raiseWindowAboveNormalPopouts(
mainWindow: StackableWindowLike | null, mainWindow: StackableWindowLike | null,
popouts: Iterable<StackableWindowLike>, popouts: Iterable<StackableWindowLike>,
+223 -18
View File
@@ -31,6 +31,7 @@ import {
import { resolveWindowCapabilities } from '../src/shared/windowCapabilities' import { resolveWindowCapabilities } from '../src/shared/windowCapabilities'
import { import {
clampDraggedMainWindowBounds, clampDraggedMainWindowBounds,
clampRestoredWindowBounds,
raiseWindowAboveNormalPopouts, raiseWindowAboveNormalPopouts,
resolveExpandedMainWindowBounds, resolveExpandedMainWindowBounds,
} from '../src/shared/windowGeometry' } from '../src/shared/windowGeometry'
@@ -2906,6 +2907,19 @@ test('dragged main-window bounds keep a visible grab margin without sticking at
assert.equal(clamped.y, 120) assert.equal(clamped.y, 120)
}) })
test('restored window bounds clamp back to a visible display margin', () => {
const clamped = clampRestoredWindowBounds(
{ x: 1800, y: 1400, width: 420, height: 240 },
[{ x: 0, y: 0, width: 800, height: 600 }],
64,
)
assert.equal(clamped.x, 736)
assert.equal(clamped.y, 536)
assert.equal(clamped.width, 420)
assert.equal(clamped.height, 240)
})
test('raiseWindowAboveNormalPopouts raises the main window when an unpinned popout exists', () => { test('raiseWindowAboveNormalPopouts raises the main window when an unpinned popout exists', () => {
const main = createFakeStackableWindow() const main = createFakeStackableWindow()
const normalPopout = createFakeStackableWindow(false) const normalPopout = createFakeStackableWindow(false)
@@ -2989,19 +3003,16 @@ test('initializeProfiles restores persisted dirty window bounds while keeping th
const previousSettingsState = useSettingsStore.getState() const previousSettingsState = useSettingsStore.getState()
const fakeStorage = installFakeLocalStorage() const fakeStorage = installFakeLocalStorage()
const restoredBounds: WindowBounds[] = [] const restoredBounds: WindowBounds[] = []
const profile = createDefaultProfile(DEFAULT_PROFILE_NAME)
profile.windowBounds = { x: 10, y: 20, width: 900, height: 180 }
const dirtyBounds = { x: 44, y: 55, width: 900, height: 180 } const dirtyBounds = { x: 44, y: 55, width: 900, height: 180 }
const fakeWindow = installFakeElectronWindow({ const fakeWindow = installFakeElectronWindow({
getProfileSnapshot: async () => { getProfileSnapshot: async () => ({
const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) activeProfileId: DEFAULT_PROFILE_ID,
profile.windowBounds = { x: 10, y: 20, width: 900, height: 180 } profiles: {
[DEFAULT_PROFILE_ID]: profile,
return { },
activeProfileId: DEFAULT_PROFILE_ID, }),
profiles: {
[DEFAULT_PROFILE_ID]: profile,
},
}
},
getWindowBounds: async () => dirtyBounds, getWindowBounds: async () => dirtyBounds,
setWindowBounds: (bounds: WindowBounds) => { setWindowBounds: (bounds: WindowBounds) => {
restoredBounds.push(bounds) restoredBounds.push(bounds)
@@ -3009,17 +3020,24 @@ test('initializeProfiles restores persisted dirty window bounds while keeping th
}) })
try { try {
const profile = createDefaultProfile(DEFAULT_PROFILE_NAME) useSettingsStore.getState().applyExternalProfileSnapshot({
profile.windowBounds = { x: 10, y: 20, width: 900, height: 180 } activeProfileId: DEFAULT_PROFILE_ID,
profiles: {
[DEFAULT_PROFILE_ID]: profile,
},
})
await Promise.resolve()
await Promise.resolve()
const rawStored = fakeStorage.getItem('prism:settings')
assert.ok(rawStored)
const stored = JSON.parse(rawStored) as Record<string, unknown>
fakeStorage.setItem('prism:settings', JSON.stringify({ fakeStorage.setItem('prism:settings', JSON.stringify({
scopeOrder: profile.scopeOrder, ...stored,
hiddenScopes: profile.hiddenScopes,
widthWeights: profile.widthWeights,
scopeSettings: profile.scopeSettings,
scopePopouts: profile.scopePopouts,
windowBounds: dirtyBounds, windowBounds: dirtyBounds,
})) }))
restoredBounds.length = 0
useSettingsStore.setState(previousSettingsState)
await useSettingsStore.getState().initializeProfiles() await useSettingsStore.getState().initializeProfiles()
@@ -3154,6 +3172,193 @@ test('initializeProfiles ignores stale persisted theme-only state and loads the
} }
}) })
test('initializeProfiles ignores legacy persisted inline popouts and restores active profile popouts', async () => {
const previousSettingsState = useSettingsStore.getState()
const fakeStorage = installFakeLocalStorage()
const savedPopoutBounds = { x: 140, y: 60, width: 420, height: 240 }
const fakeWindow = installFakeElectronWindow({
getProfileSnapshot: async () => {
const profile = createDefaultProfile(DEFAULT_PROFILE_NAME)
profile.scopePopouts.spectrum = {
poppedOut: true,
windowBounds: savedPopoutBounds,
}
return {
activeProfileId: DEFAULT_PROFILE_ID,
profiles: {
[DEFAULT_PROFILE_ID]: profile,
},
}
},
getWindowBounds: async () => ({ x: 10, y: 20, width: 900, height: 180 }),
setWindowBounds: () => {},
})
try {
const staleProfile = createDefaultProfile(DEFAULT_PROFILE_NAME)
staleProfile.scopePopouts.spectrum = {
poppedOut: false,
windowBounds: savedPopoutBounds,
}
fakeStorage.setItem('prism:settings', JSON.stringify({
scopeOrder: staleProfile.scopeOrder,
hiddenScopes: staleProfile.hiddenScopes,
widthWeights: staleProfile.widthWeights,
scopeSettings: staleProfile.scopeSettings,
scopePopouts: staleProfile.scopePopouts,
}))
await useSettingsStore.getState().initializeProfiles()
const state = useSettingsStore.getState()
assert.equal(state.scopePopouts.spectrum.poppedOut, true)
assert.deepEqual(state.scopePopouts.spectrum.windowBounds, savedPopoutBounds)
assert.equal(state.hasUnsavedProfileChanges, false)
} finally {
useSettingsStore.setState(previousSettingsState)
fakeWindow.restore()
fakeStorage.restore()
}
})
test('initializeProfiles restores saved popout open state while preserving matching dirty draft values', async () => {
const previousSettingsState = useSettingsStore.getState()
const fakeStorage = installFakeLocalStorage()
const savedPopoutBounds = { x: 140, y: 60, width: 420, height: 240 }
const dirtyPopoutBounds = { x: 180, y: 72, width: 440, height: 260 }
const profile = createDefaultProfile(DEFAULT_PROFILE_NAME)
profile.scopePopouts.spectrum = {
poppedOut: true,
windowBounds: savedPopoutBounds,
}
const fakeWindow = installFakeElectronWindow({
getProfileSnapshot: async () => ({
activeProfileId: DEFAULT_PROFILE_ID,
profiles: {
[DEFAULT_PROFILE_ID]: profile,
},
}),
getWindowBounds: async () => ({ x: 10, y: 20, width: 900, height: 180 }),
setWindowBounds: () => {},
})
try {
useSettingsStore.getState().applyExternalProfileSnapshot({
activeProfileId: DEFAULT_PROFILE_ID,
profiles: {
[DEFAULT_PROFILE_ID]: profile,
},
})
await Promise.resolve()
await Promise.resolve()
const rawStored = fakeStorage.getItem('prism:settings')
assert.ok(rawStored)
const stored = JSON.parse(rawStored) as {
activeProfileId?: string | null
profileBaselineSignature?: string
scopePopouts: ScopePopoutStateMap
}
assert.equal(stored.activeProfileId, DEFAULT_PROFILE_ID)
assert.equal(typeof stored.profileBaselineSignature, 'string')
fakeStorage.setItem('prism:settings', JSON.stringify({
...stored,
scopeSettings: {
...profile.scopeSettings,
spectrum: {
...profile.scopeSettings.spectrum,
smoothing: 0.42,
},
},
scopePopouts: {
...stored.scopePopouts,
spectrum: {
poppedOut: false,
windowBounds: dirtyPopoutBounds,
},
},
}))
useSettingsStore.setState(previousSettingsState)
await useSettingsStore.getState().initializeProfiles()
const state = useSettingsStore.getState()
assert.equal(state.scopePopouts.spectrum.poppedOut, true)
assert.deepEqual(state.scopePopouts.spectrum.windowBounds, dirtyPopoutBounds)
assert.equal(state.scopeSettings.spectrum.smoothing, 0.42)
assert.equal(state.hasUnsavedProfileChanges, true)
} finally {
useSettingsStore.setState(previousSettingsState)
fakeWindow.restore()
fakeStorage.restore()
}
})
test('initializeProfiles discards persisted popout state when the saved profile baseline changed', async () => {
const previousSettingsState = useSettingsStore.getState()
const fakeStorage = installFakeLocalStorage()
const savedPopoutBounds = { x: 140, y: 60, width: 420, height: 240 }
const originalProfile = createDefaultProfile(DEFAULT_PROFILE_NAME)
const changedProfile = createDefaultProfile(DEFAULT_PROFILE_NAME)
changedProfile.scopePopouts.spectrum = {
poppedOut: true,
windowBounds: savedPopoutBounds,
}
let snapshotProfile = originalProfile
const fakeWindow = installFakeElectronWindow({
getProfileSnapshot: async () => ({
activeProfileId: DEFAULT_PROFILE_ID,
profiles: {
[DEFAULT_PROFILE_ID]: snapshotProfile,
},
}),
getWindowBounds: async () => ({ x: 10, y: 20, width: 900, height: 180 }),
setWindowBounds: () => {},
})
try {
useSettingsStore.getState().applyExternalProfileSnapshot({
activeProfileId: DEFAULT_PROFILE_ID,
profiles: {
[DEFAULT_PROFILE_ID]: originalProfile,
},
})
await Promise.resolve()
await Promise.resolve()
const rawStored = fakeStorage.getItem('prism:settings')
assert.ok(rawStored)
const stored = JSON.parse(rawStored) as {
scopePopouts: ScopePopoutStateMap
}
fakeStorage.setItem('prism:settings', JSON.stringify({
...stored,
scopePopouts: {
...stored.scopePopouts,
spectrum: {
poppedOut: false,
windowBounds: { x: 180, y: 72, width: 440, height: 260 },
},
},
}))
snapshotProfile = changedProfile
useSettingsStore.setState(previousSettingsState)
await useSettingsStore.getState().initializeProfiles()
const state = useSettingsStore.getState()
assert.equal(state.scopePopouts.spectrum.poppedOut, true)
assert.deepEqual(state.scopePopouts.spectrum.windowBounds, savedPopoutBounds)
assert.equal(state.hasUnsavedProfileChanges, false)
} finally {
useSettingsStore.setState(previousSettingsState)
fakeWindow.restore()
fakeStorage.restore()
}
})
test('profiles without saved window bounds mark the first user move dirty after load sync completes', async () => { test('profiles without saved window bounds mark the first user move dirty after load sync completes', async () => {
const previousSettingsState = useSettingsStore.getState() const previousSettingsState = useSettingsStore.getState()
const currentBounds = { x: 10, y: 20, width: 900, height: 180 } const currentBounds = { x: 10, y: 20, width: 900, height: 180 }