mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-20 12:29:51 +02:00
improve profile system
This commit is contained in:
+100
-43
@@ -1,4 +1,4 @@
|
|||||||
import { app, BrowserWindow, desktopCapturer, dialog, ipcMain, Menu, screen, session, shell } from 'electron'
|
import { app, BrowserWindow, desktopCapturer, dialog, ipcMain, Menu, nativeTheme, screen, session, shell } from 'electron'
|
||||||
import type { BrowserWindowConstructorOptions, MenuItemConstructorOptions, OpenDialogOptions, WebContents } from 'electron'
|
import type { BrowserWindowConstructorOptions, MenuItemConstructorOptions, OpenDialogOptions, WebContents } from 'electron'
|
||||||
import { extname, join, resolve } from 'path'
|
import { extname, join, resolve } from 'path'
|
||||||
import type {
|
import type {
|
||||||
@@ -22,7 +22,9 @@ import type {
|
|||||||
ThemeLibrarySnapshot,
|
ThemeLibrarySnapshot,
|
||||||
} from '../types/theme'
|
} from '../types/theme'
|
||||||
import { RESIZE_DIRECTIONS, type ResizeDirection } from '../types/windowResize'
|
import { RESIZE_DIRECTIONS, type ResizeDirection } from '../types/windowResize'
|
||||||
|
import type { DialogOptions, DialogResult } from '../types/dialog'
|
||||||
import { normalizeProfile } from '../shared/profileState'
|
import { normalizeProfile } from '../shared/profileState'
|
||||||
|
import { resolveNativeThemeSource } from '../shared/themeState'
|
||||||
import { calculateResizedWindowBounds } from '../shared/windowResize'
|
import { calculateResizedWindowBounds } from '../shared/windowResize'
|
||||||
import { FileBackedProfileLibrary } from './profileLibrary'
|
import { FileBackedProfileLibrary } from './profileLibrary'
|
||||||
import { AstraIntegrationService } from './services/astraIntegration'
|
import { AstraIntegrationService } from './services/astraIntegration'
|
||||||
@@ -216,10 +218,22 @@ async function processPendingThemeOpenPaths(): Promise<void> {
|
|||||||
|
|
||||||
if (!latestSnapshot || !mainWindow || mainWindow.isDestroyed()) return
|
if (!latestSnapshot || !mainWindow || mainWindow.isDestroyed()) return
|
||||||
|
|
||||||
|
applyNativeThemeSnapshot(latestSnapshot)
|
||||||
focusMainWindow()
|
focusMainWindow()
|
||||||
mainWindow.webContents.send('themes:external-activated', latestSnapshot)
|
mainWindow.webContents.send('themes:external-activated', latestSnapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyNativeThemeSnapshot(snapshot: ThemeLibrarySnapshot): void {
|
||||||
|
const activeTheme = snapshot.activeThemeId
|
||||||
|
? snapshot.themes[snapshot.activeThemeId] ?? null
|
||||||
|
: null
|
||||||
|
nativeTheme.themeSource = resolveNativeThemeSource(activeTheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncNativeThemeAppearance(): Promise<void> {
|
||||||
|
applyNativeThemeSnapshot(await getThemeLibrary().getSnapshot())
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleMainWindowBoundsSave(window: BrowserWindow): void {
|
function scheduleMainWindowBoundsSave(window: BrowserWindow): void {
|
||||||
if (!isMainRendererWindow(window) || !mainRendererReady) return
|
if (!isMainRendererWindow(window) || !mainRendererReady) return
|
||||||
|
|
||||||
@@ -620,6 +634,51 @@ function loadRendererTarget(window: BrowserWindow, query: Record<string, string>
|
|||||||
void window.loadFile(join(__dirname, '../renderer/index.html'), { query })
|
void window.loadFile(join(__dirname, '../renderer/index.html'), { query })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function showCustomDialog(options: DialogOptions): Promise<DialogResult> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const height = options.type === 'prompt' ? 200 : 160
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 380,
|
||||||
|
height,
|
||||||
|
frame: false,
|
||||||
|
transparent: true,
|
||||||
|
backgroundColor: '#00000000',
|
||||||
|
resizable: false,
|
||||||
|
alwaysOnTop: true,
|
||||||
|
hasShadow: false,
|
||||||
|
skipTaskbar: true,
|
||||||
|
show: false,
|
||||||
|
webPreferences: {
|
||||||
|
preload: join(__dirname, '../preload/index.js'),
|
||||||
|
sandbox: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
win.center()
|
||||||
|
loadRendererTarget(win, { mode: 'dialog' })
|
||||||
|
|
||||||
|
const onResult = (_event: Electron.IpcMainEvent, result: DialogResult) => {
|
||||||
|
if (_event.sender !== win.webContents) return
|
||||||
|
resolve(result)
|
||||||
|
win.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.on('dialog:result', onResult)
|
||||||
|
|
||||||
|
win.webContents.once('did-finish-load', () => {
|
||||||
|
win.webContents.send('dialog:config', options)
|
||||||
|
win.show()
|
||||||
|
})
|
||||||
|
|
||||||
|
win.once('closed', () => {
|
||||||
|
ipcMain.removeListener('dialog:result', onResult)
|
||||||
|
resolve({ buttonIndex: options.cancelId ?? options.buttons.length - 1 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function createMainWindow(): void {
|
function createMainWindow(): void {
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
...WINDOW_DEFAULTS,
|
...WINDOW_DEFAULTS,
|
||||||
@@ -1081,45 +1140,28 @@ function setupIPC(): void {
|
|||||||
return getProfileLibrary().importProfileFromPath(path)
|
return getProfileLibrary().importProfileFromPath(path)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('profiles:prompt-unsaved', async (event, profileName: string | null) => {
|
ipcMain.handle('profiles:prompt-unsaved', async (_event, profileName: string | null) => {
|
||||||
const targetWindow = getWindowFromSender(event.sender) ?? mainWindow ?? undefined
|
const result = await showCustomDialog({
|
||||||
const { response } = targetWindow
|
type: 'confirm',
|
||||||
? await dialog.showMessageBox(targetWindow, {
|
title: 'Unsaved Profile Changes',
|
||||||
type: 'warning',
|
message: profileName
|
||||||
title: 'Unsaved Profile Changes',
|
? `Save changes to "${profileName}"?`
|
||||||
message: profileName
|
: 'Save unsaved profile changes?',
|
||||||
? `Save changes to "${profileName}"?`
|
detail: 'Your profile changes will be lost if you continue without saving.',
|
||||||
: 'Save unsaved profile changes?',
|
buttons: ['Save', 'Discard', 'Cancel'],
|
||||||
detail: 'Your profile changes will be lost if you continue without saving.',
|
defaultId: 0,
|
||||||
buttons: ['Save', 'Discard', 'Cancel'],
|
cancelId: 2,
|
||||||
defaultId: 0,
|
})
|
||||||
cancelId: 2,
|
|
||||||
noLink: true,
|
|
||||||
})
|
|
||||||
: await dialog.showMessageBox({
|
|
||||||
type: 'warning',
|
|
||||||
title: 'Unsaved Profile Changes',
|
|
||||||
message: profileName
|
|
||||||
? `Save changes to "${profileName}"?`
|
|
||||||
: 'Save unsaved profile changes?',
|
|
||||||
detail: 'Your profile changes will be lost if you continue without saving.',
|
|
||||||
buttons: ['Save', 'Discard', 'Cancel'],
|
|
||||||
defaultId: 0,
|
|
||||||
cancelId: 2,
|
|
||||||
noLink: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response === 0) {
|
|
||||||
return 'save'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response === 1) {
|
|
||||||
return 'discard'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (result.buttonIndex === 0) return 'save'
|
||||||
|
if (result.buttonIndex === 1) return 'discard'
|
||||||
return 'cancel'
|
return 'cancel'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('dialog:show', async (_event, options: DialogOptions) => {
|
||||||
|
return showCustomDialog(options)
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('profiles:reveal-folder', async () => {
|
ipcMain.handle('profiles:reveal-folder', async () => {
|
||||||
const folderPath = getProfileLibrary().getProfilesDirectory()
|
const folderPath = getProfileLibrary().getProfilesDirectory()
|
||||||
const openResult = await shell.openPath(folderPath)
|
const openResult = await shell.openPath(folderPath)
|
||||||
@@ -1133,23 +1175,33 @@ function setupIPC(): void {
|
|||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('themes:get-snapshot', async () => {
|
ipcMain.handle('themes:get-snapshot', async () => {
|
||||||
return getThemeLibrary().getSnapshot()
|
const snapshot = await getThemeLibrary().getSnapshot()
|
||||||
|
applyNativeThemeSnapshot(snapshot)
|
||||||
|
return snapshot
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('themes:load', async (_event, id: string) => {
|
ipcMain.handle('themes:load', async (_event, id: string) => {
|
||||||
return getThemeLibrary().loadTheme(id)
|
const snapshot = await getThemeLibrary().loadTheme(id)
|
||||||
|
applyNativeThemeSnapshot(snapshot)
|
||||||
|
return snapshot
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('themes:rename', async (_event, id: string, name: string) => {
|
ipcMain.handle('themes:rename', async (_event, id: string, name: string) => {
|
||||||
return getThemeLibrary().renameTheme(id, name)
|
const snapshot = await getThemeLibrary().renameTheme(id, name)
|
||||||
|
applyNativeThemeSnapshot(snapshot)
|
||||||
|
return snapshot
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('themes:delete', async (_event, id: string) => {
|
ipcMain.handle('themes:delete', async (_event, id: string) => {
|
||||||
return getThemeLibrary().deleteTheme(id)
|
const snapshot = await getThemeLibrary().deleteTheme(id)
|
||||||
|
applyNativeThemeSnapshot(snapshot)
|
||||||
|
return snapshot
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('themes:reload', async () => {
|
ipcMain.handle('themes:reload', async () => {
|
||||||
return getThemeLibrary().reloadThemes()
|
const snapshot = await getThemeLibrary().reloadThemes()
|
||||||
|
applyNativeThemeSnapshot(snapshot)
|
||||||
|
return snapshot
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('themes:import-dialog', async () => {
|
ipcMain.handle('themes:import-dialog', async () => {
|
||||||
@@ -1171,7 +1223,9 @@ function setupIPC(): void {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return getThemeLibrary().importThemeFromPath(result.filePaths[0])
|
const snapshot = await getThemeLibrary().importThemeFromPath(result.filePaths[0])
|
||||||
|
applyNativeThemeSnapshot(snapshot)
|
||||||
|
return snapshot
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('themes:reveal-folder', async () => {
|
ipcMain.handle('themes:reveal-folder', async () => {
|
||||||
@@ -1183,7 +1237,9 @@ function setupIPC(): void {
|
|||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('themes:migrate-legacy', async (_event, payload: LegacyThemeMigrationPayload) => {
|
ipcMain.handle('themes:migrate-legacy', async (_event, payload: LegacyThemeMigrationPayload) => {
|
||||||
return getThemeLibrary().migrateLegacyTheme(payload)
|
const migration = await getThemeLibrary().migrateLegacyTheme(payload)
|
||||||
|
applyNativeThemeSnapshot(migration.snapshot)
|
||||||
|
return migration
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.on('profile-menu:open', (event, rawRequest: unknown) => {
|
ipcMain.on('profile-menu:open', (event, rawRequest: unknown) => {
|
||||||
@@ -1324,6 +1380,7 @@ if (!hasSingleInstanceLock) {
|
|||||||
void getAstraIntegrationService().initialize()
|
void getAstraIntegrationService().initialize()
|
||||||
setupIPC()
|
setupIPC()
|
||||||
await getWindowStateStore().initialize()
|
await getWindowStateStore().initialize()
|
||||||
|
await syncNativeThemeAppearance()
|
||||||
createMainWindow()
|
createMainWindow()
|
||||||
queueProfileOpenPaths(extractProfilePathsFromArgv(process.argv))
|
queueProfileOpenPaths(extractProfilePathsFromArgv(process.argv))
|
||||||
queueThemeOpenPaths(extractThemePathsFromArgv(process.argv))
|
queueThemeOpenPaths(extractThemePathsFromArgv(process.argv))
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import type {
|
|||||||
LegacyThemeMigrationResult,
|
LegacyThemeMigrationResult,
|
||||||
ThemeLibrarySnapshot,
|
ThemeLibrarySnapshot,
|
||||||
} from '../types/theme'
|
} from '../types/theme'
|
||||||
|
import type { DialogOptions, DialogResult } from '../types/dialog'
|
||||||
import type { ResizeDirection } from '../types/windowResize'
|
import type { ResizeDirection } from '../types/windowResize'
|
||||||
import type { VisualizerDSP } from '../renderer/audio/native/visualizer-dsp'
|
import type { VisualizerDSP } from '../renderer/audio/native/visualizer-dsp'
|
||||||
|
|
||||||
@@ -217,6 +218,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.on('scope-popout:session', handler)
|
ipcRenderer.on('scope-popout:session', handler)
|
||||||
return () => ipcRenderer.removeListener('scope-popout:session', handler)
|
return () => ipcRenderer.removeListener('scope-popout:session', handler)
|
||||||
},
|
},
|
||||||
|
showDialog: (options: DialogOptions) => ipcRenderer.invoke('dialog:show', options) as Promise<DialogResult>,
|
||||||
|
onDialogConfig: (callback: (options: DialogOptions) => void) => {
|
||||||
|
const handler = (_event: Electron.IpcRendererEvent, options: DialogOptions): void => callback(options)
|
||||||
|
ipcRenderer.on('dialog:config', handler)
|
||||||
|
return () => ipcRenderer.removeListener('dialog:config', handler)
|
||||||
|
},
|
||||||
|
sendDialogResult: (result: DialogResult) => ipcRenderer.send('dialog:result', result),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Native DSP module — load if available, gracefully degrade if not
|
// Native DSP module — load if available, gracefully degrade if not
|
||||||
|
|||||||
@@ -77,10 +77,15 @@ export default function App(): JSX.Element {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
const unsubscribeCloseRequested = window.electronAPI.onMainCloseRequested(() => {
|
const unsubscribeCloseRequested = window.electronAPI.onMainCloseRequested(() => {
|
||||||
void (async () => {
|
void window.electronAPI.getWindowBounds()
|
||||||
const shouldClose = await guardProfileTransition(async () => {})
|
.then((bounds) => {
|
||||||
window.electronAPI.respondToCloseRequest(shouldClose)
|
if (bounds) {
|
||||||
})()
|
updateMainWindowBounds(bounds)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
window.electronAPI.respondToCloseRequest(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback, type JSX, type KeyboardEvent } from 'react'
|
||||||
|
import type { DialogOptions, DialogResult } from '../../types/dialog'
|
||||||
|
|
||||||
|
export default function DialogApp(): JSX.Element {
|
||||||
|
const [config, setConfig] = useState<DialogOptions | null>(null)
|
||||||
|
const [inputValue, setInputValue] = useState('')
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = window.electronAPI.onDialogConfig((options) => {
|
||||||
|
setConfig(options)
|
||||||
|
setInputValue(options.defaultValue ?? '')
|
||||||
|
})
|
||||||
|
return unsubscribe
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config?.type === 'prompt' && inputRef.current) {
|
||||||
|
inputRef.current.focus()
|
||||||
|
inputRef.current.select()
|
||||||
|
}
|
||||||
|
}, [config])
|
||||||
|
|
||||||
|
const submit = useCallback((buttonIndex: number) => {
|
||||||
|
if (!config) return
|
||||||
|
const result: DialogResult = { buttonIndex }
|
||||||
|
if (config.type === 'prompt') {
|
||||||
|
result.value = inputValue
|
||||||
|
}
|
||||||
|
window.electronAPI.sendDialogResult(result)
|
||||||
|
}, [config, inputValue])
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((e: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (!config) return
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const defaultId = config.defaultId ?? 0
|
||||||
|
submit(defaultId)
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
const cancelId = config.cancelId ?? config.buttons.length - 1
|
||||||
|
submit(cancelId)
|
||||||
|
}
|
||||||
|
}, [config, submit])
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return <div className="dialog-root" />
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryIndex = config.defaultId ?? 0
|
||||||
|
const cancelId = config.cancelId ?? config.buttons.length - 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dialog-root" onKeyDown={handleKeyDown} tabIndex={-1}>
|
||||||
|
<div className="dialog-window">
|
||||||
|
<div className="dialog-content">
|
||||||
|
<div className="dialog-title">{config.title}</div>
|
||||||
|
<div className="dialog-message">{config.message}</div>
|
||||||
|
{config.detail && (
|
||||||
|
<div className="dialog-detail">{config.detail}</div>
|
||||||
|
)}
|
||||||
|
{config.type === 'prompt' && (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
className="dialog-input"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.stopPropagation()
|
||||||
|
submit(primaryIndex)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="dialog-buttons">
|
||||||
|
{config.buttons.map((label, i) => (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
type="button"
|
||||||
|
className={[
|
||||||
|
'dialog-btn',
|
||||||
|
i === primaryIndex ? 'dialog-btn--primary' : '',
|
||||||
|
i === cancelId && i !== primaryIndex ? 'dialog-btn--cancel' : '',
|
||||||
|
label.toLowerCase() === 'delete' || label.toLowerCase() === 'discard'
|
||||||
|
? 'dialog-btn--danger'
|
||||||
|
: '',
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
|
onClick={() => submit(i)}
|
||||||
|
autoFocus={i === primaryIndex && config.type !== 'prompt'}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<style>{`
|
||||||
|
.dialog-root {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 8px;
|
||||||
|
background: transparent;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-window {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--panel-surface);
|
||||||
|
border: 1px solid var(--panel-outline);
|
||||||
|
box-shadow:
|
||||||
|
0 22px 56px rgba(0, 0, 0, 0.58),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 20px 20px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-message {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-detail {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.45;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-input {
|
||||||
|
margin-top: 6px;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--input-bg);
|
||||||
|
border: 1px solid var(--input-border);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 6px 9px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-input:focus {
|
||||||
|
border-color: var(--input-border-focus);
|
||||||
|
background: var(--input-bg-focus);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 0 14px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--control-bg);
|
||||||
|
border-color: var(--control-border);
|
||||||
|
transition: background 0.1s, border-color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn:hover {
|
||||||
|
background: var(--control-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn--primary {
|
||||||
|
background: rgba(var(--accent-rgb), 0.18);
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.32);
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn--primary:hover {
|
||||||
|
background: rgba(var(--accent-rgb), 0.26);
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.48);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn--danger {
|
||||||
|
background: rgba(248, 113, 113, 0.12);
|
||||||
|
border-color: rgba(248, 113, 113, 0.28);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-btn--danger:hover {
|
||||||
|
background: rgba(248, 113, 113, 0.2);
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -113,13 +113,22 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleSaveNew = useCallback(async () => {
|
const handleSaveNew = useCallback(async () => {
|
||||||
|
setIsProfileMenuOpen(false)
|
||||||
const count = Object.keys(useSettingsStore.getState().profiles).length
|
const count = Object.keys(useSettingsStore.getState().profiles).length
|
||||||
|
const result = await window.electronAPI.showDialog({
|
||||||
|
type: 'prompt',
|
||||||
|
title: 'Save as New Profile',
|
||||||
|
message: 'Profile name',
|
||||||
|
buttons: ['Save', 'Cancel'],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 1,
|
||||||
|
defaultValue: `Profile ${count}`,
|
||||||
|
})
|
||||||
|
if (result.buttonIndex !== 0 || !result.value?.trim()) return
|
||||||
try {
|
try {
|
||||||
await saveProfile(`Profile ${count}`)
|
await saveProfile(result.value.trim())
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.alert(getErrorMessage(error, 'Could not save the profile.'))
|
window.alert(getErrorMessage(error, 'Could not save the profile.'))
|
||||||
} finally {
|
|
||||||
setIsProfileMenuOpen(false)
|
|
||||||
}
|
}
|
||||||
}, [saveProfile])
|
}, [saveProfile])
|
||||||
|
|
||||||
@@ -140,18 +149,22 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextName = window.prompt('Rename profile', profile.name)?.trim()
|
setIsProfileMenuOpen(false)
|
||||||
if (!nextName) {
|
const result = await window.electronAPI.showDialog({
|
||||||
setIsProfileMenuOpen(false)
|
type: 'prompt',
|
||||||
return
|
title: 'Rename Profile',
|
||||||
}
|
message: `New name for "${profile.name}"`,
|
||||||
|
buttons: ['Rename', 'Cancel'],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 1,
|
||||||
|
defaultValue: profile.name,
|
||||||
|
})
|
||||||
|
if (result.buttonIndex !== 0 || !result.value?.trim()) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await renameProfile(id, nextName)
|
await renameProfile(id, result.value.trim())
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.alert(getErrorMessage(error, 'Could not rename the profile.'))
|
window.alert(getErrorMessage(error, 'Could not rename the profile.'))
|
||||||
} finally {
|
|
||||||
setIsProfileMenuOpen(false)
|
|
||||||
}
|
}
|
||||||
}, [renameProfile])
|
}, [renameProfile])
|
||||||
|
|
||||||
@@ -162,17 +175,22 @@ export default function Toolbar({ onOpenSettings, settingsOpen }: ToolbarProps):
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!window.confirm(`Delete "${profile.name}"?`)) {
|
setIsProfileMenuOpen(false)
|
||||||
setIsProfileMenuOpen(false)
|
const result = await window.electronAPI.showDialog({
|
||||||
return
|
type: 'confirm',
|
||||||
}
|
title: 'Delete Profile',
|
||||||
|
message: `Delete "${profile.name}"?`,
|
||||||
|
detail: 'This cannot be undone.',
|
||||||
|
buttons: ['Delete', 'Cancel'],
|
||||||
|
defaultId: 1,
|
||||||
|
cancelId: 1,
|
||||||
|
})
|
||||||
|
if (result.buttonIndex !== 0) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteProfile(id)
|
await deleteProfile(id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.alert(getErrorMessage(error, 'Could not delete the profile.'))
|
window.alert(getErrorMessage(error, 'Could not delete the profile.'))
|
||||||
} finally {
|
|
||||||
setIsProfileMenuOpen(false)
|
|
||||||
}
|
}
|
||||||
}, [deleteProfile])
|
}, [deleteProfile])
|
||||||
|
|
||||||
|
|||||||
Vendored
+4
@@ -28,6 +28,7 @@ import type {
|
|||||||
LegacyThemeMigrationResult,
|
LegacyThemeMigrationResult,
|
||||||
ThemeLibrarySnapshot,
|
ThemeLibrarySnapshot,
|
||||||
} from '../types/theme'
|
} from '../types/theme'
|
||||||
|
import type { DialogOptions, DialogResult } from '../types/dialog'
|
||||||
import type { ResizeDirection } from '../types/windowResize'
|
import type { ResizeDirection } from '../types/windowResize'
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -111,6 +112,9 @@ declare global {
|
|||||||
onScopePopoutSnapshot: (callback: (snapshot: ScopePopoutSnapshot) => void) => () => void
|
onScopePopoutSnapshot: (callback: (snapshot: ScopePopoutSnapshot) => void) => () => void
|
||||||
onScopePopoutAudio: (callback: (kind: ScopeKind, batch: ScopePopoutAudioBatch) => void) => () => void
|
onScopePopoutAudio: (callback: (kind: ScopeKind, batch: ScopePopoutAudioBatch) => void) => () => void
|
||||||
onScopePopoutSession: (callback: (kind: ScopeKind, session: ScopePopoutSessionState) => void) => () => void
|
onScopePopoutSession: (callback: (kind: ScopeKind, session: ScopePopoutSessionState) => void) => () => void
|
||||||
|
showDialog: (options: DialogOptions) => Promise<DialogResult>
|
||||||
|
onDialogConfig: (callback: (options: DialogOptions) => void) => () => void
|
||||||
|
sendDialogResult: (result: DialogResult) => void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -2,6 +2,7 @@ import React from 'react'
|
|||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import ScopePopoutWindow from './popouts/ScopePopoutWindow'
|
import ScopePopoutWindow from './popouts/ScopePopoutWindow'
|
||||||
|
import DialogApp from './components/DialogApp'
|
||||||
import './styles/globals.css'
|
import './styles/globals.css'
|
||||||
import '@fontsource/inter/400.css'
|
import '@fontsource/inter/400.css'
|
||||||
import '@fontsource/inter/500.css'
|
import '@fontsource/inter/500.css'
|
||||||
@@ -14,14 +15,20 @@ function isScopeKind(value: string | null): value is ScopeKind {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
const windowMode = params.get('mode')
|
||||||
const windowRole = params.get('window')
|
const windowRole = params.get('window')
|
||||||
const scopeKind = params.get('scope')
|
const scopeKind = params.get('scope')
|
||||||
|
|
||||||
const root = windowRole === 'scope-popout'
|
let root: React.ReactElement
|
||||||
? isScopeKind(scopeKind)
|
if (windowMode === 'dialog') {
|
||||||
|
root = <DialogApp />
|
||||||
|
} else if (windowRole === 'scope-popout') {
|
||||||
|
root = isScopeKind(scopeKind)
|
||||||
? <ScopePopoutWindow scopeKind={scopeKind} />
|
? <ScopePopoutWindow scopeKind={scopeKind} />
|
||||||
: <div>Invalid scope popout</div>
|
: <div>Invalid scope popout</div>
|
||||||
: <App />
|
} else {
|
||||||
|
root = <App />
|
||||||
|
}
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@@ -83,6 +83,10 @@ function canUseElectronAPI(): boolean {
|
|||||||
return typeof window !== 'undefined' && typeof window.electronAPI !== 'undefined'
|
return typeof window !== 'undefined' && typeof window.electronAPI !== 'undefined'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canUseBrowserStorage(): boolean {
|
||||||
|
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
|
||||||
|
}
|
||||||
|
|
||||||
function getErrorMessage(error: unknown, fallback: string): string {
|
function getErrorMessage(error: unknown, fallback: string): string {
|
||||||
return error instanceof Error && error.message
|
return error instanceof Error && error.message
|
||||||
? error.message
|
? error.message
|
||||||
@@ -90,9 +94,20 @@ function getErrorMessage(error: unknown, fallback: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadFromStorage(): Partial<PersistedSettingsState> {
|
function loadFromStorage(): Partial<PersistedSettingsState> {
|
||||||
|
if (!canUseBrowserStorage()) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
if (raw) return JSON.parse(raw) as Partial<PersistedSettingsState>
|
if (!raw) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw) as unknown
|
||||||
|
if (typeof parsed === 'object' && parsed !== null) {
|
||||||
|
return parsed as Partial<PersistedSettingsState>
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore localStorage read failures.
|
// Ignore localStorage read failures.
|
||||||
}
|
}
|
||||||
@@ -100,6 +115,10 @@ function loadFromStorage(): Partial<PersistedSettingsState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function saveToStorage(state: WorkingSettingsState): void {
|
function saveToStorage(state: WorkingSettingsState): void {
|
||||||
|
if (!canUseBrowserStorage()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||||
themeId: state.themeId,
|
themeId: state.themeId,
|
||||||
@@ -116,9 +135,17 @@ function saveToStorage(state: WorkingSettingsState): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function persistWorkingState(state: WorkingSettingsState): void {
|
function persistWorkingState(state: WorkingSettingsState): void {
|
||||||
if (!canUseElectronAPI()) {
|
saveToStorage(state)
|
||||||
saveToStorage(state)
|
}
|
||||||
}
|
|
||||||
|
function hasPersistedWorkingState(state: Partial<PersistedSettingsState>): boolean {
|
||||||
|
return 'themeId' in state
|
||||||
|
|| 'scopeOrder' in state
|
||||||
|
|| 'hiddenScopes' in state
|
||||||
|
|| 'widthWeights' in state
|
||||||
|
|| 'scopeSettings' in state
|
||||||
|
|| 'scopePopouts' in state
|
||||||
|
|| 'windowBounds' in state
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadLegacyProfileMigrationPayload(): LegacyProfileMigrationPayload | null {
|
function loadLegacyProfileMigrationPayload(): LegacyProfileMigrationPayload | null {
|
||||||
@@ -178,6 +205,20 @@ function createWorkingStateFromProfile(profile: Profile): WorkingSettingsState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createWorkingStateFromPersistedState(state: Partial<PersistedSettingsState>): WorkingSettingsState {
|
||||||
|
return {
|
||||||
|
themeId: typeof state.themeId === 'string' && state.themeId.trim()
|
||||||
|
? state.themeId.trim()
|
||||||
|
: null,
|
||||||
|
scopeOrder: normalizeScopeOrder(state.scopeOrder),
|
||||||
|
hiddenScopes: new Set<ScopeKind>(normalizeHiddenScopes(state.hiddenScopes)),
|
||||||
|
widthWeights: normalizeWidthWeights(state.widthWeights),
|
||||||
|
scopeSettings: mergeScopeSettings(state.scopeSettings),
|
||||||
|
scopePopouts: normalizeScopePopouts(state.scopePopouts),
|
||||||
|
windowBounds: state.windowBounds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getActiveProfileName(state: Pick<SettingsState, 'activeProfileId' | 'profiles'>): string | null {
|
function getActiveProfileName(state: Pick<SettingsState, 'activeProfileId' | 'profiles'>): string | null {
|
||||||
if (!state.activeProfileId) {
|
if (!state.activeProfileId) {
|
||||||
return null
|
return null
|
||||||
@@ -454,18 +495,8 @@ async function restoreSavedProfileBaseline(
|
|||||||
syncCurrentMainWindowBounds(set)
|
syncCurrentMainWindowBounds(set)
|
||||||
}
|
}
|
||||||
|
|
||||||
const stored = canUseElectronAPI() ? {} : loadFromStorage()
|
const stored = loadFromStorage()
|
||||||
const initialWorkingState: WorkingSettingsState = {
|
const initialWorkingState = createWorkingStateFromPersistedState(stored)
|
||||||
themeId: typeof stored.themeId === 'string' && stored.themeId.trim()
|
|
||||||
? stored.themeId.trim()
|
|
||||||
: null,
|
|
||||||
scopeOrder: normalizeScopeOrder(stored.scopeOrder),
|
|
||||||
hiddenScopes: new Set<ScopeKind>(normalizeHiddenScopes(stored.hiddenScopes)),
|
|
||||||
widthWeights: normalizeWidthWeights(stored.widthWeights),
|
|
||||||
scopeSettings: mergeScopeSettings(stored.scopeSettings),
|
|
||||||
scopePopouts: normalizeScopePopouts(stored.scopePopouts),
|
|
||||||
windowBounds: stored.windowBounds,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||||
...initialWorkingState,
|
...initialWorkingState,
|
||||||
@@ -496,7 +527,34 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applyProfileSnapshot(set, snapshot, { loadActiveProfile: true })
|
// If localStorage has a working state from a previous session, preserve it so the
|
||||||
|
// user picks up exactly where they left off (dirty or not). The saved profile becomes
|
||||||
|
// 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 hasStoredWorkingState = hasPersistedWorkingState(storedWorkingState)
|
||||||
|
applyProfileSnapshot(set, snapshot, { loadActiveProfile: !hasStoredWorkingState })
|
||||||
|
if (hasStoredWorkingState) {
|
||||||
|
set((state) => commitWorkingState(
|
||||||
|
state,
|
||||||
|
createWorkingStateFromPersistedState(storedWorkingState),
|
||||||
|
state.savedProfileBaseline,
|
||||||
|
))
|
||||||
|
|
||||||
|
// Restore window position: use the working-state bounds (last known position,
|
||||||
|
// possibly dirty) or fall back to the saved profile's bounds if none exist.
|
||||||
|
const state = get()
|
||||||
|
const boundsToApply = state.windowBounds ?? state.savedProfileBaseline?.windowBounds ?? null
|
||||||
|
if (boundsToApply) {
|
||||||
|
window.electronAPI.setWindowBounds(boundsToApply)
|
||||||
|
}
|
||||||
|
syncCurrentMainWindowBounds(set)
|
||||||
|
const { themeId } = state
|
||||||
|
const { themes, loadTheme, activeThemeId } = useThemeStore.getState()
|
||||||
|
if (themeId && themeId !== activeThemeId && themes[themeId]) {
|
||||||
|
void loadTheme(themeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
applyExternalProfileSnapshot: (snapshot: ProfileLibrarySnapshot) => {
|
applyExternalProfileSnapshot: (snapshot: ProfileLibrarySnapshot) => {
|
||||||
|
|||||||
@@ -322,6 +322,10 @@ function parseCssColor(value: string): RgbaColor | null {
|
|||||||
return { r, g, b, a }
|
return { r, g, b, a }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPerceivedBrightness(color: RgbaColor): number {
|
||||||
|
return ((color.r * 299) + (color.g * 587) + (color.b * 114)) / 1000
|
||||||
|
}
|
||||||
|
|
||||||
function toCssColor(color: RgbaColor): string {
|
function toCssColor(color: RgbaColor): string {
|
||||||
if (Math.abs(color.a - 1) < 0.001) {
|
if (Math.abs(color.a - 1) < 0.001) {
|
||||||
return `rgb(${color.r}, ${color.g}, ${color.b})`
|
return `rgb(${color.r}, ${color.g}, ${color.b})`
|
||||||
@@ -1142,6 +1146,16 @@ export function resolveTheme(theme: PrismTheme): PrismResolvedTheme {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveNativeThemeSource(theme: PrismTheme | null | undefined): 'dark' | 'light' {
|
||||||
|
const resolved = resolveTheme(theme ?? createDefaultTheme())
|
||||||
|
const parsed = parseCssColor(resolved.interface.menuBg) ?? parseThemeChannelColor(resolved.interface.menuBg)
|
||||||
|
if (!parsed) {
|
||||||
|
return 'dark'
|
||||||
|
}
|
||||||
|
|
||||||
|
return getPerceivedBrightness(parsed) >= 156 ? 'light' : 'dark'
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveLegacyThemeToPresetId(payload: LegacyThemeMigrationPayload): string | null {
|
export function resolveLegacyThemeToPresetId(payload: LegacyThemeMigrationPayload): string | null {
|
||||||
switch (payload.presetId) {
|
switch (payload.presetId) {
|
||||||
case 'default':
|
case 'default':
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export interface DialogOptions {
|
||||||
|
type: 'confirm' | 'prompt'
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
detail?: string
|
||||||
|
buttons: string[]
|
||||||
|
defaultId?: number
|
||||||
|
cancelId?: number
|
||||||
|
defaultValue?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DialogResult {
|
||||||
|
buttonIndex: number
|
||||||
|
value?: string
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
createDefaultProfile,
|
createDefaultProfile,
|
||||||
} from '../src/shared/profileState'
|
} from '../src/shared/profileState'
|
||||||
import { calculateResizedWindowBounds } from '../src/shared/windowResize'
|
import { calculateResizedWindowBounds } from '../src/shared/windowResize'
|
||||||
import { createDefaultTheme, resolveTheme } from '../src/shared/themeState'
|
import { createDefaultTheme, resolveNativeThemeSource, resolveTheme } from '../src/shared/themeState'
|
||||||
import { usePerformanceStore } from '../src/renderer/stores/performanceStore'
|
import { usePerformanceStore } from '../src/renderer/stores/performanceStore'
|
||||||
import { buildProfileDraft, profilesMatch } from '../src/renderer/stores/profileDraft'
|
import { buildProfileDraft, profilesMatch } from '../src/renderer/stores/profileDraft'
|
||||||
import {
|
import {
|
||||||
@@ -223,6 +223,8 @@ function createScopePopouts(poppedOutScopes: ScopeKind[] = []): ScopePopoutState
|
|||||||
|
|
||||||
function installFakeLocalStorage(): {
|
function installFakeLocalStorage(): {
|
||||||
getSetCount: () => number
|
getSetCount: () => number
|
||||||
|
getItem: (key: string) => string | null
|
||||||
|
setItem: (key: string, value: string) => void
|
||||||
restore: () => void
|
restore: () => void
|
||||||
} {
|
} {
|
||||||
const storage = new Map<string, string>()
|
const storage = new Map<string, string>()
|
||||||
@@ -254,6 +256,12 @@ function installFakeLocalStorage(): {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
getSetCount: () => setCount,
|
getSetCount: () => setCount,
|
||||||
|
getItem(key: string): string | null {
|
||||||
|
return storage.get(key) ?? null
|
||||||
|
},
|
||||||
|
setItem(key: string, value: string): void {
|
||||||
|
storage.set(key, value)
|
||||||
|
},
|
||||||
restore(): void {
|
restore(): void {
|
||||||
if (previousLocalStorage === undefined) {
|
if (previousLocalStorage === undefined) {
|
||||||
delete globalWithStorage.localStorage
|
delete globalWithStorage.localStorage
|
||||||
@@ -1097,6 +1105,21 @@ test('buildProfileDraft preserves unlinked themes instead of coercing the active
|
|||||||
assert.equal(draft.themeId, null)
|
assert.equal(draft.themeId, null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('resolveNativeThemeSource follows the active theme brightness for native UI', () => {
|
||||||
|
const darkTheme = createDefaultTheme()
|
||||||
|
const lightTheme = createDefaultTheme()
|
||||||
|
lightTheme.app.background = 'rgb(248, 250, 252)'
|
||||||
|
lightTheme.app.surface = 'rgba(255, 255, 255, 0.96)'
|
||||||
|
lightTheme.app.surfaceAlt = 'rgba(255, 255, 255, 0.92)'
|
||||||
|
lightTheme.app.text = 'rgb(15, 23, 42)'
|
||||||
|
lightTheme.app.textMuted = 'rgba(15, 23, 42, 0.48)'
|
||||||
|
lightTheme.controls.menuSurface = 'rgb(255, 255, 255)'
|
||||||
|
lightTheme.controls.menuBorder = 'rgba(15, 23, 42, 0.12)'
|
||||||
|
|
||||||
|
assert.equal(resolveNativeThemeSource(darkTheme), 'dark')
|
||||||
|
assert.equal(resolveNativeThemeSource(lightTheme), 'light')
|
||||||
|
})
|
||||||
|
|
||||||
test('toggleScope appends astra to the scope order when it is enabled from an opt-in profile', () => {
|
test('toggleScope appends astra to the scope order when it is enabled from an opt-in profile', () => {
|
||||||
const previousSettingsState = useSettingsStore.getState()
|
const previousSettingsState = useSettingsStore.getState()
|
||||||
const fakeWindow = installFakeElectronWindow()
|
const fakeWindow = installFakeElectronWindow()
|
||||||
@@ -1118,7 +1141,7 @@ test('toggleScope appends astra to the scope order when it is enabled from an op
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('main-window bounds updates stay in memory in Electron mode until save', () => {
|
test('main-window bounds updates persist working state in Electron mode', () => {
|
||||||
const previousSettingsState = useSettingsStore.getState()
|
const previousSettingsState = useSettingsStore.getState()
|
||||||
const fakeStorage = installFakeLocalStorage()
|
const fakeStorage = installFakeLocalStorage()
|
||||||
const fakeWindow = installFakeElectronWindow()
|
const fakeWindow = installFakeElectronWindow()
|
||||||
@@ -1131,15 +1154,69 @@ test('main-window bounds updates stay in memory in Electron mode until save', ()
|
|||||||
|
|
||||||
useSettingsStore.getState().updateMainWindowBounds({ x: 10, y: 20, width: 900, height: 180 })
|
useSettingsStore.getState().updateMainWindowBounds({ x: 10, y: 20, width: 900, height: 180 })
|
||||||
assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false)
|
assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false)
|
||||||
assert.equal(fakeStorage.getSetCount(), 0)
|
assert.equal(fakeStorage.getSetCount(), 1)
|
||||||
|
|
||||||
useSettingsStore.getState().updateMainWindowBounds({ x: 24, y: 20, width: 900, height: 180 })
|
useSettingsStore.getState().updateMainWindowBounds({ x: 24, y: 20, width: 900, height: 180 })
|
||||||
assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, true)
|
assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, true)
|
||||||
assert.equal(fakeStorage.getSetCount(), 0)
|
assert.equal(fakeStorage.getSetCount(), 2)
|
||||||
|
|
||||||
useSettingsStore.getState().updateMainWindowBounds({ x: 10, y: 20, width: 900, height: 180 })
|
useSettingsStore.getState().updateMainWindowBounds({ x: 10, y: 20, width: 900, height: 180 })
|
||||||
assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false)
|
assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false)
|
||||||
assert.equal(fakeStorage.getSetCount(), 0)
|
assert.equal(fakeStorage.getSetCount(), 3)
|
||||||
|
} finally {
|
||||||
|
useSettingsStore.setState(previousSettingsState)
|
||||||
|
fakeWindow.restore()
|
||||||
|
fakeStorage.restore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('initializeProfiles restores persisted dirty window bounds while keeping the saved profile as baseline', async () => {
|
||||||
|
const previousSettingsState = useSettingsStore.getState()
|
||||||
|
const fakeStorage = installFakeLocalStorage()
|
||||||
|
const restoredBounds: WindowBounds[] = []
|
||||||
|
const dirtyBounds = { x: 44, y: 55, width: 900, height: 180 }
|
||||||
|
const fakeWindow = installFakeElectronWindow({
|
||||||
|
getProfileSnapshot: async () => {
|
||||||
|
const profile = createDefaultProfile(DEFAULT_PROFILE_NAME)
|
||||||
|
profile.themeId = 'theme_default'
|
||||||
|
profile.windowBounds = { x: 10, y: 20, width: 900, height: 180 }
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeProfileId: DEFAULT_PROFILE_ID,
|
||||||
|
profiles: {
|
||||||
|
[DEFAULT_PROFILE_ID]: profile,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getWindowBounds: async () => dirtyBounds,
|
||||||
|
setWindowBounds: (bounds: WindowBounds) => {
|
||||||
|
restoredBounds.push(bounds)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = createDefaultProfile(DEFAULT_PROFILE_NAME)
|
||||||
|
profile.themeId = 'theme_default'
|
||||||
|
profile.windowBounds = { x: 10, y: 20, width: 900, height: 180 }
|
||||||
|
|
||||||
|
fakeStorage.setItem('prism:settings', JSON.stringify({
|
||||||
|
themeId: profile.themeId,
|
||||||
|
scopeOrder: profile.scopeOrder,
|
||||||
|
hiddenScopes: profile.hiddenScopes,
|
||||||
|
widthWeights: profile.widthWeights,
|
||||||
|
scopeSettings: profile.scopeSettings,
|
||||||
|
scopePopouts: profile.scopePopouts,
|
||||||
|
windowBounds: dirtyBounds,
|
||||||
|
}))
|
||||||
|
|
||||||
|
await useSettingsStore.getState().initializeProfiles()
|
||||||
|
|
||||||
|
const state = useSettingsStore.getState()
|
||||||
|
assert.equal(state.activeProfileId, DEFAULT_PROFILE_ID)
|
||||||
|
assert.deepEqual(state.windowBounds, dirtyBounds)
|
||||||
|
assert.deepEqual(state.savedProfileBaseline?.windowBounds, profile.windowBounds)
|
||||||
|
assert.equal(state.hasUnsavedProfileChanges, true)
|
||||||
|
assert.deepEqual(restoredBounds, [dirtyBounds])
|
||||||
} finally {
|
} finally {
|
||||||
useSettingsStore.setState(previousSettingsState)
|
useSettingsStore.setState(previousSettingsState)
|
||||||
fakeWindow.restore()
|
fakeWindow.restore()
|
||||||
@@ -1332,7 +1409,7 @@ test('geometry sync window extends while load-time macOS bound updates continue'
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('popout bounds updates stay in memory in Electron mode until save', () => {
|
test('popout bounds updates persist working state in Electron mode', () => {
|
||||||
const previousSettingsState = useSettingsStore.getState()
|
const previousSettingsState = useSettingsStore.getState()
|
||||||
const fakeStorage = installFakeLocalStorage()
|
const fakeStorage = installFakeLocalStorage()
|
||||||
const fakeWindow = installFakeElectronWindow()
|
const fakeWindow = installFakeElectronWindow()
|
||||||
@@ -1348,15 +1425,15 @@ test('popout bounds updates stay in memory in Electron mode until save', () => {
|
|||||||
|
|
||||||
useSettingsStore.getState().updatePopoutBounds('spectrum', { x: 140, y: 60, width: 420, height: 240 })
|
useSettingsStore.getState().updatePopoutBounds('spectrum', { x: 140, y: 60, width: 420, height: 240 })
|
||||||
assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false)
|
assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false)
|
||||||
assert.equal(fakeStorage.getSetCount(), 0)
|
assert.equal(fakeStorage.getSetCount(), 1)
|
||||||
|
|
||||||
useSettingsStore.getState().updatePopoutBounds('spectrum', { x: 180, y: 60, width: 420, height: 240 })
|
useSettingsStore.getState().updatePopoutBounds('spectrum', { x: 180, y: 60, width: 420, height: 240 })
|
||||||
assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, true)
|
assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, true)
|
||||||
assert.equal(fakeStorage.getSetCount(), 0)
|
assert.equal(fakeStorage.getSetCount(), 2)
|
||||||
|
|
||||||
useSettingsStore.getState().updatePopoutBounds('spectrum', { x: 140, y: 60, width: 420, height: 240 })
|
useSettingsStore.getState().updatePopoutBounds('spectrum', { x: 140, y: 60, width: 420, height: 240 })
|
||||||
assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false)
|
assert.equal(useSettingsStore.getState().hasUnsavedProfileChanges, false)
|
||||||
assert.equal(fakeStorage.getSetCount(), 0)
|
assert.equal(fakeStorage.getSetCount(), 3)
|
||||||
} finally {
|
} finally {
|
||||||
useSettingsStore.setState(previousSettingsState)
|
useSettingsStore.setState(previousSettingsState)
|
||||||
fakeWindow.restore()
|
fakeWindow.restore()
|
||||||
|
|||||||
Reference in New Issue
Block a user