diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 90f8106d..ba41017b 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -2450,6 +2450,13 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} } if (url.protocol === 'devtools:') return true; if (url.protocol === `${UI_PROTOCOL}:`) return true; if (url.protocol !== 'http:' && url.protocol !== 'https:') return false; + // In development the renderer is served by Vite while state.localOrigin + // remains the separate local API server. Permit same-origin reloads from + // the renderer itself so Vite full-reload fallbacks stay in Electron. + try { + if (new URL(browserWindow.webContents.getURL()).origin === url.origin) return true; + } catch { + } if (state.localOrigin) { try { if (new URL(state.localOrigin).origin === url.origin) return true; @@ -4681,6 +4688,9 @@ const isLocalSender = (webContents) => { if (!raw) return false; const url = new URL(raw); if (url.protocol === `${UI_PROTOCOL}:` && url.hostname === 'app') return true; + // Electron dev renders from Vite while the local API is served on a + // separate port. This exact loopback HMR origin is trusted only in dev. + if (isDev && url.origin === `http://127.0.0.1:${process.env.OPENCHAMBER_HMR_UI_PORT || '5173'}`) return true; if (url.protocol !== 'http:' && url.protocol !== 'https:') return false; if (state.localOrigin) { try { diff --git a/packages/ui/src/contexts/ThemeSystemContext.tsx b/packages/ui/src/contexts/ThemeSystemContext.tsx index 7ddf2b3c..516676b4 100644 --- a/packages/ui/src/contexts/ThemeSystemContext.tsx +++ b/packages/ui/src/contexts/ThemeSystemContext.tsx @@ -19,6 +19,7 @@ import { DEFAULT_LIGHT_THEME_ID, DEFAULT_DARK_THEME_ID, } from '@/lib/theme/themes'; +import { withPrColors } from '@/lib/theme/themes/prColors'; import { ThemeSystemContext, type ThemeContextValue } from './theme-system-context'; import type { VSCodeThemePayload } from '@/lib/theme/vscode/adapter'; import { runtimeFetch } from '@/lib/runtime-fetch'; @@ -158,6 +159,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro const [preferences, setPreferences] = useState(() => buildInitialPreferences(defaultThemeId)); const [systemPrefersDark, setSystemPrefersDark] = useState(() => getInitialSystemPreference()); const [customThemes, setCustomThemes] = useState([]); + const [developmentThemes, setDevelopmentThemes] = useState([]); const [embeddedBootstrapTheme] = useState(() => readEmbeddedCurrentTheme()); const [embeddedSyncedTheme, setEmbeddedSyncedTheme] = useState(null); const [customThemesLoading, setCustomThemesLoading] = useState(false); @@ -204,10 +206,32 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro // Custom themes first so they can override built-ins with the same id. customThemes.forEach(add); + // Vite publishes valid built-in JSON edits through this development-only + // runtime channel, avoiding a full page reload for theme work. + developmentThemes.forEach(add); themes.forEach(add); return merged; - }, [customThemes, embeddedBootstrapTheme, embeddedSyncedTheme, isVSCode, vscodeTheme]); + }, [customThemes, developmentThemes, embeddedBootstrapTheme, embeddedSyncedTheme, isVSCode, vscodeTheme]); + + useEffect(() => { + const handleThemeHmr = (event: Event) => { + const theme = (event as CustomEvent).detail; + if (!isValidTheme(theme)) return; + + const nextTheme = withPrColors(theme); + setDevelopmentThemes((previous) => { + const index = previous.findIndex((candidate) => candidate.metadata.id === nextTheme.metadata.id); + if (index < 0) return [...previous, nextTheme]; + const next = [...previous]; + next[index] = nextTheme; + return next; + }); + }; + + window.addEventListener('openchamber:theme-hmr', handleThemeHmr); + return () => window.removeEventListener('openchamber:theme-hmr', handleThemeHmr); + }, []); const getThemeByIdFromAvailable = useCallback( (themeId: string): Theme | undefined => availableThemes.find((theme) => theme.metadata.id === themeId), diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx index 6d0a41a3..fea06385 100644 --- a/packages/web/src/main.tsx +++ b/packages/web/src/main.tsx @@ -125,6 +125,12 @@ const start = async (): Promise => { void start(); +if (import.meta.hot) { + import.meta.hot.on('openchamber:theme-updated', (theme: unknown) => { + window.dispatchEvent(new CustomEvent('openchamber:theme-hmr', { detail: theme })); + }); +} + if (import.meta.env.PROD) { registerPwaServiceWorker(); } else { diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index 553a892b..2b1c4fe7 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -11,6 +11,31 @@ const packageJson = JSON.parse(readFileSync(path.resolve(__dirname, 'package.jso const pwaDevEnabled = process.env.OPENCHAMBER_DISABLE_PWA_DEV !== '1'; const reactScanToggle = (process.env.VITE_ENABLE_REACT_SCAN ?? '').toLowerCase(); const enableReactScan = reactScanToggle === '1' || reactScanToggle === 'true' || reactScanToggle === 'on' || reactScanToggle === 'yes'; +const themeDirectory = path.resolve(__dirname, '../ui/src/lib/theme/themes'); + +const themeJsonHmrPlugin = () => ({ + name: 'openchamber-theme-json-hmr', + handleHotUpdate({ file, server }: { file: string; server: { ws: { send: (payload: unknown) => void } } }) { + if (!file.startsWith(`${themeDirectory}${path.sep}`) || path.extname(file) !== '.json') { + return; + } + + try { + server.ws.send({ + type: 'custom', + event: 'openchamber:theme-updated', + data: JSON.parse(readFileSync(file, 'utf-8')), + }); + // Theme JSON is applied by the runtime event listener. Returning no + // modules prevents Vite's otherwise unavoidable page-reload fallback. + return []; + } catch { + // Leave the previous valid theme active while an editor writes invalid + // or incomplete JSON; the next valid save will replace it. + return []; + } + }, +}); export default defineConfig({ root: path.resolve(__dirname, '.'), @@ -39,6 +64,7 @@ export default defineConfig({ }, }, themeStoragePlugin(), + themeJsonHmrPlugin(), VitePWA({ strategies: 'injectManifest', srcDir: 'src',