From 052197b0893c27d214948557cb959b3a18d94aae Mon Sep 17 00:00:00 2001 From: yoratoni Date: Wed, 12 Aug 2026 22:52:41 +0200 Subject: [PATCH 1/3] feat: added a preferences window with theming and per-store persistence split Extracted a generic JsonStore that owns one JSON file with an atomic write, a serialized update lock and a lazy in-memory cache, then split the persisted store into three files: config.json for user-authored preferences, state.json for the transient view state (files pane width, search filters, window placement, recent projects) and cache.json for the LFS lock cache. Introduced a preferences window with General, Appearance and Services tabs, driven by a draft that only commits on Apply. Mirrored React95 themes onto the Tailwind theme tokens so title bars and chrome repaint the moment a theme is picked, and hydrated the preferences before the first paint so the first frame already lands on the picked theme. Wired the auto-lock, MCP and updater services to a broadcast that reschedules or drops their state in place when their cadence or endpoint changes. Replaced the PNG window control icons with themable SVG glyphs so they follow the picked theme's title-bar text color. --- .vscode/settings.json | 9 +- src/main/config/app.ts | 28 ++- src/main/config/cache.ts | 11 ++ src/main/config/state.ts | 24 +++ src/main/config/store.ts | 6 +- src/main/config/windows.ts | 39 ++-- src/main/index.ts | 9 + src/main/lib/autoLock/index.ts | 2 + src/main/lib/autoLock/service.ts | 41 ++++- src/main/lib/constants.ts | 15 +- src/main/lib/dialogs/index.ts | 17 +- src/main/lib/lfsCommands/handlers.ts | 18 +- src/main/lib/mcp/client.ts | 3 +- src/main/lib/mcp/index.ts | 2 + src/main/lib/mcp/service.ts | 53 +++++- src/main/lib/preferences/handlers.ts | 26 +++ src/main/lib/preferences/index.ts | 82 +++++++++ src/main/lib/preferences/service.ts | 36 ++++ src/main/lib/projects/handlers.ts | 11 +- src/main/lib/projects/service.ts | 49 ++--- src/main/lib/stores/cache.ts | 28 +++ src/main/lib/stores/config.ts | 167 ++++-------------- src/main/lib/stores/json.ts | 124 +++++++++++++ src/main/lib/stores/preferences.ts | 9 + src/main/lib/stores/state.ts | 40 +++++ src/main/lib/updater/index.ts | 5 +- src/main/lib/updater/service.ts | 41 ++++- src/main/lib/utils/stores.ts | 20 +++ src/main/lib/viewState/handlers.ts | 12 ++ src/main/lib/viewState/service.ts | 28 +++ src/main/lib/windows/index.ts | 15 ++ src/main/lib/windows/placement.ts | 10 +- src/main/types/store.d.ts | 54 ++++-- src/preload/index.ts | 19 +- src/preload/routes/preferences.ts | 19 ++ src/preload/routes/projects.ts | 3 - src/preload/routes/viewState.ts | 11 ++ src/renderer/PreferencesApp.tsx | 83 +++++++++ src/renderer/components/bars/Menu.tsx | 2 +- src/renderer/components/bars/Title.tsx | 9 +- src/renderer/components/contexts/Project.tsx | 7 +- src/renderer/components/contexts/TreeView.tsx | 32 ++-- src/renderer/components/fields/StatusTask.tsx | 2 +- src/renderer/components/layouts/AppRoot.tsx | 40 ++++- .../components/layouts/main/Frame.tsx | 2 +- .../components/preferences/AppearanceTab.tsx | 45 +++++ .../components/preferences/GeneralTab.tsx | 33 ++++ .../components/preferences/ServicesTab.tsx | 98 ++++++++++ src/renderer/components/spaces/Workspace.tsx | 8 +- src/renderer/components/ui/WindowGlyph.tsx | 31 ++++ src/renderer/components/views/TreeView.tsx | 2 +- src/renderer/config/menus.ts | 3 +- src/renderer/config/preferences.ts | 11 ++ src/renderer/config/themes.ts | 112 ++++++++++++ src/renderer/hooks/useMenuActions.tsx | 3 + src/renderer/hooks/usePreferences.tsx | 26 +++ src/renderer/hooks/usePreferencesDraft.tsx | 46 +++++ src/renderer/lib/constants.ts | 71 ++++++-- src/renderer/lib/utils/preferences.ts | 31 ++++ src/renderer/lib/utils/search.ts | 14 +- src/renderer/lib/utils/theme.ts | 55 ++++++ src/renderer/main.tsx | 14 +- .../public/assets/icons/window/close.png | Bin 139 -> 0 bytes .../public/assets/icons/window/maximize.png | Bin 127 -> 0 bytes .../public/assets/icons/window/minimize.png | Bin 118 -> 0 bytes src/renderer/styles/globals.css | 12 ++ src/renderer/styles/react95.css | 48 +++-- src/renderer/styles/tailwind-theme.css | 24 ++- 68 files changed, 1588 insertions(+), 362 deletions(-) create mode 100644 src/main/config/cache.ts create mode 100644 src/main/config/state.ts create mode 100644 src/main/lib/preferences/handlers.ts create mode 100644 src/main/lib/preferences/index.ts create mode 100644 src/main/lib/preferences/service.ts create mode 100644 src/main/lib/stores/cache.ts create mode 100644 src/main/lib/stores/json.ts create mode 100644 src/main/lib/stores/preferences.ts create mode 100644 src/main/lib/stores/state.ts create mode 100644 src/main/lib/utils/stores.ts create mode 100644 src/main/lib/viewState/handlers.ts create mode 100644 src/main/lib/viewState/service.ts create mode 100644 src/preload/routes/preferences.ts create mode 100644 src/preload/routes/viewState.ts create mode 100644 src/renderer/PreferencesApp.tsx create mode 100644 src/renderer/components/preferences/AppearanceTab.tsx create mode 100644 src/renderer/components/preferences/GeneralTab.tsx create mode 100644 src/renderer/components/preferences/ServicesTab.tsx create mode 100644 src/renderer/components/ui/WindowGlyph.tsx create mode 100644 src/renderer/config/preferences.ts create mode 100644 src/renderer/config/themes.ts create mode 100644 src/renderer/hooks/usePreferences.tsx create mode 100644 src/renderer/hooks/usePreferencesDraft.tsx create mode 100644 src/renderer/lib/utils/preferences.ts create mode 100644 src/renderer/lib/utils/theme.ts delete mode 100644 src/renderer/public/assets/icons/window/close.png delete mode 100644 src/renderer/public/assets/icons/window/maximize.png delete mode 100644 src/renderer/public/assets/icons/window/minimize.png diff --git a/.vscode/settings.json b/.vscode/settings.json index 768c5eb..83c44dd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -56,6 +56,7 @@ }, // CSpell "cSpell.words": [ + "aiee", "Artur", "asar", "ASSETDATA", @@ -74,12 +75,15 @@ "iconutil", "IEND", "konsole", + "millenium", "Nwnp", "OFPA", "Paeth", + "peggys", "Qfecheck", "scrollview", "SERIALSIZES", + "shelbi", "SOFTOBJECTPATH", "TEMPLATEINDEX", "uasset", @@ -87,7 +91,10 @@ "umap", "UPACKAGE", "uproject", - "webfonts" + "vermillion", + "vistaesque", + "webfonts", + "wmii" ], "[json]": { "editor.defaultFormatter": "vscode.json-language-features" diff --git a/src/main/config/app.ts b/src/main/config/app.ts index b1c5ee7..b424639 100644 --- a/src/main/config/app.ts +++ b/src/main/config/app.ts @@ -1,28 +1,24 @@ -import STORE_CONFIG from "@/main/config/store" +import AUTO_LOCK_CONFIG from "@main/config/autoLock" +import MCP_CONFIG from "@main/config/mcp" +import STORE_CONFIG from "@main/config/store" +import UPDATER_CONFIG from "@main/config/updater" import type { AppConfig } from "@/main/types/store" /** - * The default configuration used when no config file exists yet, or when the existing - * one is missing fields. + * The default configuration used when no config file exists yet, + * or when the existing one is missing fields. */ const DEFAULT_APP_CONFIG: AppConfig = { version: STORE_CONFIG.configVersion, preferences: { + theme: "original", startupBehavior: "reopen-last", - filesPaneWidth: 320, - searchIsRegex: false, - isAdvancedSearchOpened: false, - searchIncludePatterns: "", - searchExcludePatterns: "", - isShowingMyLocksOnly: false, + autoLockTickIntervalMs: AUTO_LOCK_CONFIG.tickIntervalMs, + mcpEndpoint: MCP_CONFIG.endpoint, + mcpProbeIntervalMs: MCP_CONFIG.probeIntervalMs, + isAutomaticUpdateCheckEnabled: true, + updaterCheckIntervalMs: UPDATER_CONFIG.checkIntervalMs, }, - windowPlacement: { - bounds: null, - isMaximized: false, - isFullScreen: false, - }, - recentProjects: [], - lfsLockCache: {}, } export default DEFAULT_APP_CONFIG diff --git a/src/main/config/cache.ts b/src/main/config/cache.ts new file mode 100644 index 0000000..7c17ace --- /dev/null +++ b/src/main/config/cache.ts @@ -0,0 +1,11 @@ +import type { AppCache } from "@/main/types/store" + +/** + * The default cache used when no cache file exists yet, or when the existing one + * is missing fields. + */ +const DEFAULT_APP_CACHE: AppCache = { + lfsLocks: {}, +} + +export default DEFAULT_APP_CACHE diff --git a/src/main/config/state.ts b/src/main/config/state.ts new file mode 100644 index 0000000..c73ea4b --- /dev/null +++ b/src/main/config/state.ts @@ -0,0 +1,24 @@ +import type { AppState } from "@/main/types/store" + +/** + * The default state used when no state file exists yet, or when the existing one + * is missing fields. + */ +const DEFAULT_APP_STATE: AppState = { + windowPlacement: { + bounds: null, + isMaximized: false, + isFullScreen: false, + }, + recentProjects: [], + view: { + filesPaneWidth: 320, + searchIsRegex: false, + isAdvancedSearchOpened: false, + searchIncludePatterns: "", + searchExcludePatterns: "", + isShowingMyLocksOnly: false, + }, +} + +export default DEFAULT_APP_STATE diff --git a/src/main/config/store.ts b/src/main/config/store.ts index 54ff2e5..6b24140 100644 --- a/src/main/config/store.ts +++ b/src/main/config/store.ts @@ -1,9 +1,11 @@ /** - * The configuration options for the persisted application store. + * The configuration options for the persisted application stores. */ const STORE_CONFIG = { dirName: "gitgame", - fileName: "config.json", + configFileName: "config.json", + stateFileName: "state.json", + cacheFileName: "cache.json", configVersion: 1, maxRecentProjects: 10, } diff --git a/src/main/config/windows.ts b/src/main/config/windows.ts index dbffd44..b6312af 100644 --- a/src/main/config/windows.ts +++ b/src/main/config/windows.ts @@ -14,6 +14,23 @@ const WEB_PREFERENCES: WebPreferences = { autoplayPolicy: "no-user-gesture-required", } +/** + * The shared shape of every window that is not the main one, frameless, fixed + * size and centered, each role adds only what makes it its own. + */ +const SECONDARY_WINDOW: BrowserWindowConstructorOptions = { + frame: false, + resizable: false, + minimizable: false, + maximizable: false, + fullscreenable: false, + show: false, + center: true, + roundedCorners: false, + icon: appIcon, + webPreferences: WEB_PREFERENCES, +} + /** * The default `BrowserWindow` options for every window kind the app opens, * keyed by role. @@ -21,12 +38,13 @@ const WEB_PREFERENCES: WebPreferences = { const WINDOWS_CONFIG: { main: BrowserWindowConstructorOptions dialog: BrowserWindowConstructorOptions + preferences: BrowserWindowConstructorOptions } = { main: { width: 1280, height: 800, - minWidth: 640, - minHeight: 480, + minWidth: 960, + minHeight: 640, show: false, frame: false, thickFrame: true, @@ -43,17 +61,12 @@ const WINDOWS_CONFIG: { icon: appIcon, webPreferences: WEB_PREFERENCES, }, - dialog: { - frame: false, - resizable: false, - minimizable: false, - maximizable: false, - fullscreenable: false, - show: false, - center: true, - roundedCorners: false, - icon: appIcon, - webPreferences: WEB_PREFERENCES, + // Sized per variant by the dialogs service, from `DIALOGS_CONFIG` + dialog: SECONDARY_WINDOW, + preferences: { + ...SECONDARY_WINDOW, + width: 640, + height: 560, }, } diff --git a/src/main/index.ts b/src/main/index.ts index 47af68b..53b5337 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -9,11 +9,14 @@ import { registerGitCommandsHandlers } from "@main/lib/gitCommands/handlers" import { registerLfsCommandsHandlers } from "@main/lib/lfsCommands/handlers" import { startMcp } from "@main/lib/mcp" import { registerMcpHandlers } from "@main/lib/mcp/handlers" +import { startPreferences } from "@main/lib/preferences" +import { registerPreferencesHandlers } from "@main/lib/preferences/handlers" import { registerProjectsHandlers } from "@main/lib/projects/handlers" import { registerShellsHandlers } from "@main/lib/shells/handlers" import { startUpdater } from "@main/lib/updater" import { registerUpdaterHandlers } from "@main/lib/updater/handlers" import { registerUProjectHandlers } from "@main/lib/uproject/handlers" +import { registerViewStateHandlers } from "@main/lib/viewState/handlers" import { attachWindowStateBroadcaster, registerWindowsControlHandlers, setMainWindow } from "@main/lib/windows" import { attachWindowPlacementPersistence, restoreWindowPlacement } from "@main/lib/windows/placement" import { app, BrowserWindow, shell } from "electron" @@ -71,6 +74,12 @@ app.whenReady().then(async () => { registerUpdaterHandlers() registerMcpHandlers() registerAutoLockHandlers() + registerViewStateHandlers() + registerPreferencesHandlers() + + // Hydrate the preferences before anything paints, the renderer reads the theme + // synchronously at preload and would otherwise flash the default one + await startPreferences() // Create the main window await createMainWindow() diff --git a/src/main/lib/autoLock/index.ts b/src/main/lib/autoLock/index.ts index e5f5761..22b42a8 100644 --- a/src/main/lib/autoLock/index.ts +++ b/src/main/lib/autoLock/index.ts @@ -1,6 +1,7 @@ import { autoLockService } from "@main/lib/autoLock/service" import CONSTANTS from "@main/lib/constants" import { autoLockStore } from "@main/lib/stores/autoLock" +import { preferencesStore } from "@main/lib/stores/preferences" import { BrowserWindow } from "electron" import type { AutoLockState } from "@/main/types/autoLock" @@ -23,6 +24,7 @@ function broadcastAutoLockState(next: AutoLockState) { */ export function startAutoLock() { autoLockStore.subscribe(broadcastAutoLockState) + preferencesStore.subscribe(() => autoLockService.reconfigure()) } export { autoLockService } diff --git a/src/main/lib/autoLock/service.ts b/src/main/lib/autoLock/service.ts index 049cbc2..473ab39 100644 --- a/src/main/lib/autoLock/service.ts +++ b/src/main/lib/autoLock/service.ts @@ -1,6 +1,6 @@ -import AUTO_LOCK_CONFIG from "@main/config/autoLock" import { reconcileAutoLock } from "@main/lib/autoLock/reconcile" import { autoLockStore } from "@main/lib/stores/autoLock" +import { preferencesStore } from "@main/lib/stores/preferences" import { convertErrorToMessage } from "@main/lib/utils/errors" import type { AutoLockReconcileResult } from "@/main/types/autoLock" @@ -27,6 +27,13 @@ export class AutoLockService { */ private _dir: string | null = null + /** + * The interval the loop is currently running at, compared on every + * preference change so an unrelated one does not restart the timer and + * push the countdown out of step with the last reconcile. + */ + private _tickIntervalMs = preferencesStore.get().autoLockTickIntervalMs + /** * Runs a single reconcile pass, updates the store with the outcome, and * skips silently when no directory is bound or another pass is already in @@ -63,6 +70,20 @@ export class AutoLockService { } } + /** + * Restarts the tick timer at the interval the preferences currently carry, + * kept apart from `start` so a preference change can reschedule the loop + * without rebinding the directory. + */ + private _restartTimer() { + const { autoLockTickIntervalMs } = preferencesStore.get() + + if (this._timer) clearInterval(this._timer) + this._timer = setInterval(() => this._tick(), autoLockTickIntervalMs) + + autoLockStore.set({ tickIntervalMs: autoLockTickIntervalMs }) + } + /** * Runs a reconcile pass on demand, mostly a hook for a "reconcile now" * button and the initial tick after `start`. @@ -74,8 +95,8 @@ export class AutoLockService { /** * Starts the periodic reconcile loop bound to a repository directory, - * fires an immediate tick then repeats at `AUTO_LOCK_CONFIG.tickIntervalMs`, - * a no-op re-bind when the same directory is already active. + * fires an immediate tick then repeats at the preferred interval, a no-op + * re-bind when the same directory is already active. * @param dir A path inside the repository to reconcile against. */ start(dir: string) { @@ -90,7 +111,19 @@ export class AutoLockService { }) this._tick() - this._timer = setInterval(() => this._tick(), AUTO_LOCK_CONFIG.tickIntervalMs) + this._restartTimer() + } + + /** + * Reschedules the loop when the preferred tick interval has moved, any + * other preference change leaves the running timer alone. + */ + reconfigure() { + const { autoLockTickIntervalMs } = preferencesStore.get() + if (autoLockTickIntervalMs === this._tickIntervalMs) return + + this._tickIntervalMs = autoLockTickIntervalMs + if (this._timer) this._restartTimer() } /** diff --git a/src/main/lib/constants.ts b/src/main/lib/constants.ts index 494abc9..41c7334 100644 --- a/src/main/lib/constants.ts +++ b/src/main/lib/constants.ts @@ -38,8 +38,15 @@ const CONSTANTS = { projectsGetRecent: "projects:get-recent", projectsRemoveRecent: "projects:remove-recent", projectsClearRecent: "projects:clear-recent", - projectsGetPreferences: "projects:get-preferences", - projectsSetPreferences: "projects:set-preferences", + // View state + viewStateGet: "view-state:get", + viewStateSet: "view-state:set", + // Preferences + preferencesGetInitial: "preferences:get-initial", + preferencesGet: "preferences:get", + preferencesSet: "preferences:set", + preferencesChanged: "preferences:changed", + preferencesOpenWindow: "preferences:open-window", // UProject uprojectOpen: "uproject:open", // Shells @@ -105,9 +112,7 @@ const CONSTANTS = { noTerminalMessage: "No terminal could be launched, none of the ones we know about are installed or on PATH.", /** - * The terminals tried in order when opening a folder, first launch wins, Linux - * has no canonical terminal so `x-terminal-emulator` (the Debian alternatives - * symlink to the user's default) is tried first, then common fallbacks. + * The terminals tried in order when opening a folder. */ terminals: { win32: [ diff --git a/src/main/lib/dialogs/index.ts b/src/main/lib/dialogs/index.ts index f9fe586..a2403e9 100644 --- a/src/main/lib/dialogs/index.ts +++ b/src/main/lib/dialogs/index.ts @@ -1,6 +1,5 @@ -import path from "node:path" import CONSTANTS from "@main/lib/constants" -import { attachWindowStateBroadcaster } from "@main/lib/windows" +import { attachWindowStateBroadcaster, loadRendererRoute } from "@main/lib/windows" import { BrowserWindow, ipcMain } from "electron" import DIALOGS_CONFIG from "@/main/config/dialogs" import WINDOWS_CONFIG from "@/main/config/windows" @@ -76,18 +75,6 @@ class DialogRegistry { */ const dialogRegistry = new DialogRegistry() -/** - * Loads the renderer's dialog route into the given window, in development or production. - * @param window The dialog window to load. - */ -function loadDialog(window: BrowserWindow) { - if (process.env.ELECTRON_RENDERER_URL) { - window.loadURL(`${process.env.ELECTRON_RENDERER_URL}#/dialog`) - } else { - window.loadFile(path.join(__dirname, "..", "renderer", "index.html"), { hash: "/dialog" }) - } -} - /** * Creates a frameless, Win95-styled dialog window sized for its variant and loads * the renderer's dialog route into it. @@ -124,7 +111,7 @@ function createDialog( // A window closed without an explicit response resolves as a cancel window.on("closed", () => dialogRegistry.forget(window.id)) - loadDialog(window) + loadRendererRoute(window, "/dialog") return window } diff --git a/src/main/lib/lfsCommands/handlers.ts b/src/main/lib/lfsCommands/handlers.ts index d146e75..5a5f842 100644 --- a/src/main/lib/lfsCommands/handlers.ts +++ b/src/main/lib/lfsCommands/handlers.ts @@ -1,7 +1,7 @@ import CONSTANTS from "@main/lib/constants" import { safeHandle } from "@main/lib/ipc" import { getLockableFiles, listLocks, lockPaths, migrateLocks, unlockPaths } from "@main/lib/lfsCommands/service" -import { configStore } from "@main/lib/stores/config" +import { cacheStore } from "@main/lib/stores/cache" import type { LfsLockProgress } from "@/main/types/lfsCommands" /** @@ -11,17 +11,17 @@ export function registerLfsCommandsHandlers() { safeHandle(CONSTANTS.ipc.lfsCommandsListLocks, async (_event, dir: string) => { const locks = await listLocks(dir) - configStore.update(config => { - config.lfsLockCache[dir] = locks - return config + cacheStore.update(cache => { + cache.lfsLocks[dir] = locks + return cache }) return locks }) safeHandle(CONSTANTS.ipc.lfsCommandsGetCachedLocks, async (_event, dir: string) => { - const config = await configStore.get() - return config.lfsLockCache[dir] ?? [] + const { lfsLocks } = await cacheStore.get() + return lfsLocks[dir] ?? [] }) safeHandle(CONSTANTS.ipc.lfsCommandsGetLockableFiles, (_event, dir: string) => getLockableFiles(dir)) @@ -50,9 +50,9 @@ export function registerLfsCommandsHandlers() { // Any migration that actually touched a lock invalidates the cached lock list if (migrations.some(m => m.status === "migrated" || m.status === "failed-unlock")) { const locks = await listLocks(dir) - configStore.update(config => { - config.lfsLockCache[dir] = locks - return config + cacheStore.update(cache => { + cache.lfsLocks[dir] = locks + return cache }) } diff --git a/src/main/lib/mcp/client.ts b/src/main/lib/mcp/client.ts index d1a9cb7..a67b62c 100644 --- a/src/main/lib/mcp/client.ts +++ b/src/main/lib/mcp/client.ts @@ -1,5 +1,6 @@ import MCP_CONFIG from "@main/config/mcp" import CONSTANTS from "@main/lib/constants" +import { preferencesStore } from "@main/lib/stores/preferences" import { app } from "electron" import type { McpServerInfo } from "@/main/types/mcp" @@ -65,7 +66,7 @@ export class McpClient { if (this._sessionId) headers[CONSTANTS.mcp.sessionHeader] = this._sessionId - const response = await fetch(MCP_CONFIG.endpoint, { + const response = await fetch(preferencesStore.get().mcpEndpoint, { method: "POST", headers, body: JSON.stringify(body), diff --git a/src/main/lib/mcp/index.ts b/src/main/lib/mcp/index.ts index debfbb7..3a3433e 100644 --- a/src/main/lib/mcp/index.ts +++ b/src/main/lib/mcp/index.ts @@ -1,6 +1,7 @@ import CONSTANTS from "@main/lib/constants" import { mcpService } from "@main/lib/mcp/service" import { mcpStore } from "@main/lib/mcp/store" +import { preferencesStore } from "@main/lib/stores/preferences" import { BrowserWindow } from "electron" import type { McpState } from "@/main/types/mcp" @@ -22,5 +23,6 @@ function broadcastMcpState(next: McpState) { */ export function startMcp() { mcpStore.subscribe(broadcastMcpState) + preferencesStore.subscribe(next => mcpService.reconfigure(next)) mcpService.start() } diff --git a/src/main/lib/mcp/service.ts b/src/main/lib/mcp/service.ts index 6f81aa1..09c2403 100644 --- a/src/main/lib/mcp/service.ts +++ b/src/main/lib/mcp/service.ts @@ -1,8 +1,10 @@ import MCP_CONFIG from "@main/config/mcp" import { McpError, mcpClient } from "@main/lib/mcp/client" import { mcpStore } from "@main/lib/mcp/store" +import { preferencesStore } from "@main/lib/stores/preferences" import { convertErrorToMessage } from "@main/lib/utils/errors" import type { McpState } from "@/main/types/mcp" +import type { AppPreferences } from "@/main/types/store" /** * The periodic probe lifecycle owner, keeps the store in sync with whether the @@ -22,6 +24,18 @@ export class McpService { */ private _probeInFlight = false + /** + * The endpoint the loop is currently pointed at, compared on every + * preference change so only a real move drops the session. + */ + private _endpoint = preferencesStore.get().mcpEndpoint + + /** + * The interval the probe loop is currently running at, compared on every + * preference change so an unrelated one does not restart the timer. + */ + private _probeIntervalMs = preferencesStore.get().mcpProbeIntervalMs + /** * Waits until the given start time has been in the past for at least * `minMs`, so a "connecting" state that was emitted is guaranteed to stay @@ -34,6 +48,18 @@ export class McpService { if (remaining > 0) await new Promise(resolve => setTimeout(resolve, remaining)) } + /** + * Restarts the probe timer at the interval the preferences currently carry. + */ + private _restartTimer() { + const { mcpProbeIntervalMs } = preferencesStore.get() + + if (this._probeTimer) clearInterval(this._probeTimer) + this._probeTimer = setInterval(() => this.probe(), mcpProbeIntervalMs) + + mcpStore.set({ probeIntervalMs: mcpProbeIntervalMs }) + } + /** * Runs a single probe against the MCP server, updating the store with the * result, the `connecting` phase is held for at least @@ -78,14 +104,35 @@ export class McpService { /** * Starts the periodic probe loop that keeps the connection state in sync - * with whether the editor is running, ticks at `MCP_CONFIG.probeIntervalMs`, - * a no-op when the loop is already running. + * with whether the editor is running, ticks at the preferred interval, a + * no-op when the loop is already running. */ start() { if (this._probeTimer) return this.probe() - this._probeTimer = setInterval(() => this.probe(), MCP_CONFIG.probeIntervalMs) + this._restartTimer() + } + + /** + * Applies a preference change, reschedules the loop and, when the endpoint + * itself moved, drops the session and probes the new one right away. + * @param next The new preferences. + */ + reconfigure(next: AppPreferences) { + const hasEndpointChanged = next.mcpEndpoint !== this._endpoint + const hasIntervalChanged = next.mcpProbeIntervalMs !== this._probeIntervalMs + + this._endpoint = next.mcpEndpoint + this._probeIntervalMs = next.mcpProbeIntervalMs + + if (hasEndpointChanged) { + mcpClient.resetSession() + mcpStore.set({ status: "disconnected", server: null, error: null }) + } + + if (hasIntervalChanged && this._probeTimer) this._restartTimer() + if (hasEndpointChanged) this.probe() } /** diff --git a/src/main/lib/preferences/handlers.ts b/src/main/lib/preferences/handlers.ts new file mode 100644 index 0000000..5a06281 --- /dev/null +++ b/src/main/lib/preferences/handlers.ts @@ -0,0 +1,26 @@ +import CONSTANTS from "@main/lib/constants" +import { safeHandle } from "@main/lib/ipc" +import { openPreferencesWindow } from "@main/lib/preferences" +import { setPreferences } from "@main/lib/preferences/service" +import { configStore } from "@main/lib/stores/config" +import { preferencesStore } from "@main/lib/stores/preferences" +import { ipcMain } from "electron" +import type { AppPreferences } from "@/main/types/store" + +/** + * Registers the IPC handlers that expose the preferences to the renderer, + * covering the synchronous read every window makes before its first paint, the + * async read/write pair, and opening the preferences window itself. + */ +export function registerPreferencesHandlers() { + // Synchronous on purpose: the preload bridge reads this before the renderer + // paints, so the first frame is already on the right theme + ipcMain.on(CONSTANTS.ipc.preferencesGetInitial, event => { + event.returnValue = configStore.getCached().preferences + }) + + safeHandle(CONSTANTS.ipc.preferencesGet, () => preferencesStore.get()) + safeHandle(CONSTANTS.ipc.preferencesSet, (_event, patch: Partial) => setPreferences(patch)) + + ipcMain.on(CONSTANTS.ipc.preferencesOpenWindow, () => openPreferencesWindow()) +} diff --git a/src/main/lib/preferences/index.ts b/src/main/lib/preferences/index.ts new file mode 100644 index 0000000..04c70ff --- /dev/null +++ b/src/main/lib/preferences/index.ts @@ -0,0 +1,82 @@ +import WINDOWS_CONFIG from "@main/config/windows" +import CONSTANTS from "@main/lib/constants" +import { loadPreferences } from "@main/lib/preferences/service" +import { preferencesStore } from "@main/lib/stores/preferences" +import { attachWindowStateBroadcaster, getMainWindow, loadRendererRoute } from "@main/lib/windows" +import { BrowserWindow } from "electron" +import type { AppPreferences } from "@/main/types/store" + +/** + * Owns the preferences window, so the menu item can open it without ever + * putting a second copy on screen. + */ +class PreferencesWindowController { + /** + * The open preferences window, `null` when none is on screen. + */ + private _window: BrowserWindow | null = null + + /** + * Opens the preferences window, or focuses the one already open, parented + * to the main window but deliberately not modal so the user can keep + * working while comparing themes. + */ + open() { + if (this._window && !this._window.isDestroyed()) { + if (this._window.isMinimized()) this._window.restore() + this._window.focus() + + return + } + + this._window = new BrowserWindow({ + ...WINDOWS_CONFIG.preferences, + parent: getMainWindow() ?? undefined, + }) + + // Broadcast focus and visibility changes so the window's title bar can + // reflect the active state consistently with the main window + attachWindowStateBroadcaster(this._window) + + this._window.once("ready-to-show", () => this._window?.show()) + this._window.on("closed", () => { + this._window = null + }) + + loadRendererRoute(this._window, "/preferences") + } +} + +/** + * The single app-wide preferences window controller. + */ +const preferencesWindowController = new PreferencesWindowController() + +/** + * Pushes the preferences to every open window, so a change made in the + * preferences window reaches the main window as it is made. + * @param next The new preferences. + */ +function broadcastPreferences(next: AppPreferences) { + BrowserWindow.getAllWindows().forEach(window => { + if (window.isDestroyed()) return + window.webContents.send(CONSTANTS.ipc.preferencesChanged, next) + }) +} + +/** + * Opens the preferences window, or focuses the one already open. + */ +export function openPreferencesWindow() { + preferencesWindowController.open() +} + +/** + * Starts the preferences subsystem, hydrates the store from disk then mirrors + * every later change onto every window, awaited before the first window is + * created so the renderer never paints on the wrong theme. + */ +export async function startPreferences() { + await loadPreferences() + preferencesStore.subscribe(broadcastPreferences) +} diff --git a/src/main/lib/preferences/service.ts b/src/main/lib/preferences/service.ts new file mode 100644 index 0000000..1373461 --- /dev/null +++ b/src/main/lib/preferences/service.ts @@ -0,0 +1,36 @@ +import { configStore } from "@main/lib/stores/config" +import { preferencesStore } from "@main/lib/stores/preferences" +import type { AppPreferences } from "@/main/types/store" + +/** + * Hydrates the in-memory preferences from disk, awaited once at startup so the + * synchronous preload bridge and every later read see the persisted values. + * @returns The loaded preferences. + */ +export async function loadPreferences(): Promise { + const { preferences } = await configStore.get() + preferencesStore.set(preferences) + + return preferences +} + +/** + * Merges the given fields into the preferences, persists them, and notifies + * every subscriber. + * @param patch The preference fields to update. + * @returns The updated preferences. + */ +export async function setPreferences(patch: Partial): Promise { + const updated = await configStore.update(config => { + config.preferences = { + ...config.preferences, + ...patch, + } + + return undefined + }) + + preferencesStore.set(updated.preferences) + + return updated.preferences +} diff --git a/src/main/lib/projects/handlers.ts b/src/main/lib/projects/handlers.ts index 9576388..aa651be 100644 --- a/src/main/lib/projects/handlers.ts +++ b/src/main/lib/projects/handlers.ts @@ -3,18 +3,15 @@ import { safeHandle } from "@main/lib/ipc" import { addLocalProject, clearRecentProjects, - getPreferences, getRecentProjects, openProject, removeRecentProject, - setPreferences, } from "@main/lib/projects/service" import { BrowserWindow } from "electron" -import type { AppPreferences } from "@/main/types/store" /** - * Registers the IPC handlers for the project service (folder picker, recent - * projects, and preferences). + * Registers the IPC handlers for the project service (folder picker and recent + * projects). */ export function registerProjectsHandlers() { safeHandle(CONSTANTS.ipc.projectsAddLocal, event => addLocalProject(BrowserWindow.fromWebContents(event.sender))) @@ -22,8 +19,4 @@ export function registerProjectsHandlers() { safeHandle(CONSTANTS.ipc.projectsGetRecent, () => getRecentProjects()) safeHandle(CONSTANTS.ipc.projectsRemoveRecent, (_event, dir: string) => removeRecentProject(dir)) safeHandle(CONSTANTS.ipc.projectsClearRecent, () => clearRecentProjects()) - safeHandle(CONSTANTS.ipc.projectsGetPreferences, () => getPreferences()) - safeHandle(CONSTANTS.ipc.projectsSetPreferences, (_event, preferences: Partial) => - setPreferences(preferences), - ) } diff --git a/src/main/lib/projects/service.ts b/src/main/lib/projects/service.ts index 424e13d..d0a8b1c 100644 --- a/src/main/lib/projects/service.ts +++ b/src/main/lib/projects/service.ts @@ -1,12 +1,11 @@ import path from "node:path" import STORE_CONFIG from "@main/config/store" import { getRepositoryRoot, isRepository } from "@main/lib/gitCommands/service" -import { configStore } from "@main/lib/stores/config" +import { stateStore } from "@main/lib/stores/state" import { readUProject } from "@main/lib/uproject/service" import { pathExists } from "@main/lib/utils/fs" import { app, type BrowserWindow, dialog } from "electron" import type { OpenProjectResult, Project } from "@/main/types/projects" -import type { AppPreferences } from "@/main/types/store" import type { UProject } from "@/main/types/uproject" /** @@ -24,9 +23,9 @@ async function rememberProject(root: string, uproject: UProject): Promise { - const others = config.recentProjects.filter(project => project.path !== root) - config.recentProjects = [entry, ...others].slice(0, STORE_CONFIG.maxRecentProjects) + await stateStore.update(state => { + const others = state.recentProjects.filter(project => project.path !== root) + state.recentProjects = [entry, ...others].slice(0, STORE_CONFIG.maxRecentProjects) return undefined }) @@ -84,8 +83,8 @@ export async function openProject(dir: string): Promise { * @returns The outcome of the open attempt, including cancellation. */ export async function addLocalProject(window: BrowserWindow | null): Promise { - const config = await configStore.get() - const mostRecent = config.recentProjects[0] + const { recentProjects } = await stateStore.get() + const mostRecent = recentProjects[0] const defaultPath = mostRecent ? path.dirname(mostRecent.path) : app.getPath("home") const options: Electron.OpenDialogOptions = { properties: ["openDirectory"], defaultPath } @@ -106,7 +105,7 @@ export async function addLocalProject(window: BrowserWindow | null): Promise { - return (await configStore.get()).recentProjects + return (await stateStore.get()).recentProjects } /** @@ -115,8 +114,8 @@ export async function getRecentProjects(): Promise { * @returns The updated recent projects list. */ export async function removeRecentProject(dir: string): Promise { - const updated = await configStore.update(config => { - config.recentProjects = config.recentProjects.filter(project => project.path !== dir) + const updated = await stateStore.update(state => { + state.recentProjects = state.recentProjects.filter(project => project.path !== dir) return undefined }) @@ -128,36 +127,10 @@ export async function removeRecentProject(dir: string): Promise { * @returns The updated recent projects list, which is always empty. */ export async function clearRecentProjects(): Promise { - const updated = await configStore.update(config => { - config.recentProjects = [] + const updated = await stateStore.update(state => { + state.recentProjects = [] return undefined }) return updated.recentProjects } - -/** - * Returns the current application preferences. - * @returns The preferences. - */ -export async function getPreferences(): Promise { - return (await configStore.get()).preferences -} - -/** - * Merges the given fields into the application preferences and persists them. - * @param preferences The preference fields to update. - * @returns The updated preferences. - */ -export async function setPreferences(preferences: Partial): Promise { - const updated = await configStore.update(config => { - config.preferences = { - ...config.preferences, - ...preferences, - } - - return undefined - }) - - return updated.preferences -} diff --git a/src/main/lib/stores/cache.ts b/src/main/lib/stores/cache.ts new file mode 100644 index 0000000..d20fb2e --- /dev/null +++ b/src/main/lib/stores/cache.ts @@ -0,0 +1,28 @@ +import DEFAULT_APP_CACHE from "@main/config/cache" +import STORE_CONFIG from "@main/config/store" +import { JsonStore } from "@main/lib/stores/json" +import type { AppCache } from "@/main/types/store" + +/** + * Merges a possibly partial on-disk cache with the defaults so the rest of the + * app can rely on every field being present. + * @param partial The raw parsed cache, which may be missing fields. + * @returns A complete `AppCache`. + */ +function normalizeCache(partial: Partial): AppCache { + return { + lfsLocks: + partial.lfsLocks && typeof partial.lfsLocks === "object" && !Array.isArray(partial.lfsLocks) + ? partial.lfsLocks + : {}, + } +} + +/** + * The single app-wide cache store, holding the re-generable LFS lock snapshots. + */ +export const cacheStore = new JsonStore({ + fileName: STORE_CONFIG.cacheFileName, + defaults: DEFAULT_APP_CACHE, + normalize: normalizeCache, +}) diff --git a/src/main/lib/stores/config.ts b/src/main/lib/stores/config.ts index f21cfc9..4cf801a 100644 --- a/src/main/lib/stores/config.ts +++ b/src/main/lib/stores/config.ts @@ -1,147 +1,40 @@ -import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises" -import path from "node:path" +import DEFAULT_APP_CONFIG from "@main/config/app" import STORE_CONFIG from "@main/config/store" -import { app } from "electron" -import DEFAULT_APP_CONFIG from "@/main/config/app" +import { JsonStore } from "@main/lib/stores/json" import type { AppConfig } from "@/main/types/store" /** - * The persistent JSON-backed application config, cached in memory after the - * first read and updated through a serialized write lock so concurrent callers - * cannot race on the temp file or clobber each other's read-modify-write. + * Merges a possibly partial or outdated on-disk config with the defaults so the + * rest of the app can rely on every field being present. + * @param partial The raw parsed config, which may be missing fields. + * @returns A complete `AppConfig`. */ -export class ConfigStore { - /** - * The in-memory cache, lazily loaded on first access so repeated reads do - * not hit the disk. - */ - private _cache: AppConfig | null = null - - /** - * The write chain, every update tacks onto this promise so writes run one - * at a time and each sees the result of the previous one. - */ - private _updateLock: Promise = Promise.resolve() - - /** - * A per-process counter used to give each write a unique temp file name. - */ - private _writeCounter = 0 - - /** - * Resolves the absolute path to the directory holding the config file. - * @returns The absolute config directory path. - */ - private _getConfigDir(): string { - return path.join(app.getPath("appData"), STORE_CONFIG.dirName) - } - - /** - * Resolves the absolute path to the config file itself. - * @returns The absolute config file path. - */ - private _getConfigPath(): string { - return path.join(this._getConfigDir(), STORE_CONFIG.fileName) - } - - /** - * Merges a possibly partial or outdated on-disk config with the defaults so - * the rest of the app can rely on every field being present. - * @param partial The raw parsed config, which may be missing fields. - * @returns A complete `AppConfig`. - */ - private _normalizeConfig(partial: Partial): AppConfig { - return { - version: STORE_CONFIG.configVersion, - preferences: { - ...DEFAULT_APP_CONFIG.preferences, - ...(partial.preferences ?? {}), - }, - windowPlacement: { - ...DEFAULT_APP_CONFIG.windowPlacement, - ...(partial.windowPlacement ?? {}), - }, - recentProjects: Array.isArray(partial.recentProjects) ? partial.recentProjects : [], - lfsLockCache: - partial.lfsLockCache && typeof partial.lfsLockCache === "object" && !Array.isArray(partial.lfsLockCache) - ? partial.lfsLockCache - : {}, - } - } - - /** - * Reads and parses the config from disk, falling back to the defaults when - * the file is absent or unreadable/corrupt. - * @returns The loaded config. - */ - private async _readFromDisk(): Promise { - try { - const raw = await readFile(this._getConfigPath(), "utf-8") - return this._normalizeConfig(JSON.parse(raw) as Partial) - } catch { - return structuredClone(DEFAULT_APP_CONFIG) - } - } - - /** - * Persists the config to disk atomically, writes to a temp file then - * renames, so a crash mid-write cannot corrupt the existing config. - * @param config The config to persist. - */ - private async _saveConfig(config: AppConfig): Promise { - const target = this._getConfigPath() - const temp = `${target}.${process.pid}.${this._writeCounter++}.tmp` - - await mkdir(this._getConfigDir(), { recursive: true }) - await writeFile(temp, JSON.stringify(config, null, 4), "utf-8") - - try { - await rename(temp, target) - } catch (error) { - // Leave no orphaned temp file behind if the rename fails - await rm(temp, { force: true }) - throw error - } - } - - /** - * Returns the current config, loading it from disk on first access. - * @returns The cached config. - */ - async get(): Promise { - if (!this._cache) this._cache = await this._readFromDisk() - return this._cache - } - - /** - * Applies a mutation to a copy of the current config, persists it, and - * updates the in-memory cache. - * @param mutator A function that mutates the draft config in place, or - * returns a replacement config. - * @returns The updated config. - */ - update(mutator: (config: AppConfig) => AppConfig | undefined): Promise { - const run = this._updateLock.then(async () => { - const current = await this.get() - - const draft = structuredClone(current) - const result = mutator(draft) ?? draft - - await this._saveConfig(result) - - this._cache = result - return result - }) - - // Keep the chain alive even if this update fails - this._updateLock = run.catch(() => undefined) - - return run +function normalizeConfig(partial: Partial): AppConfig { + const preferences = partial.preferences ?? DEFAULT_APP_CONFIG.preferences + + return { + version: STORE_CONFIG.configVersion, + preferences: { + theme: preferences.theme ?? DEFAULT_APP_CONFIG.preferences.theme, + startupBehavior: preferences.startupBehavior ?? DEFAULT_APP_CONFIG.preferences.startupBehavior, + autoLockTickIntervalMs: + preferences.autoLockTickIntervalMs ?? DEFAULT_APP_CONFIG.preferences.autoLockTickIntervalMs, + mcpEndpoint: preferences.mcpEndpoint ?? DEFAULT_APP_CONFIG.preferences.mcpEndpoint, + mcpProbeIntervalMs: preferences.mcpProbeIntervalMs ?? DEFAULT_APP_CONFIG.preferences.mcpProbeIntervalMs, + isAutomaticUpdateCheckEnabled: + preferences.isAutomaticUpdateCheckEnabled ?? + DEFAULT_APP_CONFIG.preferences.isAutomaticUpdateCheckEnabled, + updaterCheckIntervalMs: + preferences.updaterCheckIntervalMs ?? DEFAULT_APP_CONFIG.preferences.updaterCheckIntervalMs, + }, } } /** - * The single app-wide config store, callers grab this rather than constructing - * their own. + * The single app-wide config store, holding the user-authored preferences. */ -export const configStore = new ConfigStore() +export const configStore = new JsonStore({ + fileName: STORE_CONFIG.configFileName, + defaults: DEFAULT_APP_CONFIG, + normalize: normalizeConfig, +}) diff --git a/src/main/lib/stores/json.ts b/src/main/lib/stores/json.ts new file mode 100644 index 0000000..44f0ed0 --- /dev/null +++ b/src/main/lib/stores/json.ts @@ -0,0 +1,124 @@ +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises" +import { getStoreDir, getStorePath } from "@main/lib/utils/stores" + +/** + * The wiring a `JsonStore` needs to own one file on disk. + */ +export type JsonStoreOptions = { + fileName: string + defaults: T + normalize: (partial: Partial) => T +} + +/** + * A persistent JSON-backed store, cached in memory after the first read and + * updated through a serialized write lock so concurrent callers cannot race on + * the temp file or clobber each other's read-modify-write. + */ +export class JsonStore { + /** + * The file name, defaults and normalizer this instance was built with. + */ + private _options: JsonStoreOptions + + /** + * The in-memory cache, lazily loaded on first access so repeated reads do + * not hit the disk. + */ + private _cache: T | null = null + + /** + * The write chain, every update tacks onto this promise so writes run one + * at a time and each sees the result of the previous one. + */ + private _updateLock: Promise = Promise.resolve() + + /** + * A per-process counter used to give each write a unique temp file name. + */ + private _writeCounter = 0 + + constructor(options: JsonStoreOptions) { + this._options = options + } + + /** + * Reads and parses the store from disk, falling back to the defaults when + * the file is absent or unreadable/corrupt. + * @returns The loaded value. + */ + private async _readFromDisk(): Promise { + try { + const raw = await readFile(getStorePath(this._options.fileName), "utf-8") + return this._options.normalize(JSON.parse(raw) as Partial) + } catch { + return structuredClone(this._options.defaults) + } + } + + /** + * Persists the store to disk atomically, writes to a temp file then + * renames, so a crash mid-write cannot corrupt the existing file. + * @param value The value to persist. + */ + private async _save(value: T): Promise { + const target = getStorePath(this._options.fileName) + const temp = `${target}.${process.pid}.${this._writeCounter++}.tmp` + + await mkdir(getStoreDir(), { recursive: true }) + await writeFile(temp, JSON.stringify(value, null, 4), "utf-8") + + try { + await rename(temp, target) + } catch (error) { + // Leave no orphaned temp file behind if the rename fails + await rm(temp, { force: true }) + throw error + } + } + + /** + * Returns the current value, loading it from disk on first access. + * @returns The cached value. + */ + async get(): Promise { + if (!this._cache) this._cache = await this._readFromDisk() + return this._cache + } + + /** + * Reads the cache without touching the disk, for the synchronous preload + * bridge, callers must have awaited `get` at least once beforehand or they + * get the defaults. + * @returns The cached value, or the defaults when nothing is loaded yet. + */ + getCached(): T { + return this._cache ?? this._options.defaults + } + + /** + * Applies a mutation to a copy of the current value, persists it, and + * updates the in-memory cache. + * @param mutator A function that mutates the draft in place, or returns a + * replacement value. + * @returns The updated value. + */ + update(mutator: (value: T) => T | undefined): Promise { + const run = this._updateLock.then(async () => { + const current = await this.get() + + const draft = structuredClone(current) + const result = mutator(draft) ?? draft + + await this._save(result) + + this._cache = result + return result + }) + + // Keep the chain alive even if this update fails + this._updateLock = run.catch(() => undefined) + + return run + } +} diff --git a/src/main/lib/stores/preferences.ts b/src/main/lib/stores/preferences.ts new file mode 100644 index 0000000..fa6adf8 --- /dev/null +++ b/src/main/lib/stores/preferences.ts @@ -0,0 +1,9 @@ +import DEFAULT_APP_CONFIG from "@main/config/app" +import { ObservableStore } from "@main/lib/stores/observable" +import type { AppPreferences } from "@/main/types/store" + +/** + * The single app-wide preferences store, subscribed to by every window and by + * the services a preference tunes, so one change lands everywhere at once. + */ +export const preferencesStore = new ObservableStore(DEFAULT_APP_CONFIG.preferences) diff --git a/src/main/lib/stores/state.ts b/src/main/lib/stores/state.ts new file mode 100644 index 0000000..1e2e8a8 --- /dev/null +++ b/src/main/lib/stores/state.ts @@ -0,0 +1,40 @@ +import DEFAULT_APP_STATE from "@main/config/state" +import STORE_CONFIG from "@main/config/store" +import { JsonStore } from "@main/lib/stores/json" +import type { AppState } from "@/main/types/store" + +/** + * Merges a possibly partial on-disk state with the defaults so the rest of the + * app can rely on every field being present. + * @param partial The raw parsed state, which may be missing fields. + * @returns A complete `AppState`. + */ +function normalizeState(partial: Partial): AppState { + const view = partial.view ?? DEFAULT_APP_STATE.view + + return { + windowPlacement: { + ...DEFAULT_APP_STATE.windowPlacement, + ...(partial.windowPlacement ?? {}), + }, + recentProjects: Array.isArray(partial.recentProjects) ? partial.recentProjects : [], + view: { + filesPaneWidth: view.filesPaneWidth ?? DEFAULT_APP_STATE.view.filesPaneWidth, + searchIsRegex: view.searchIsRegex ?? DEFAULT_APP_STATE.view.searchIsRegex, + isAdvancedSearchOpened: view.isAdvancedSearchOpened ?? DEFAULT_APP_STATE.view.isAdvancedSearchOpened, + searchIncludePatterns: view.searchIncludePatterns ?? DEFAULT_APP_STATE.view.searchIncludePatterns, + searchExcludePatterns: view.searchExcludePatterns ?? DEFAULT_APP_STATE.view.searchExcludePatterns, + isShowingMyLocksOnly: view.isShowingMyLocksOnly ?? DEFAULT_APP_STATE.view.isShowingMyLocksOnly, + }, + } +} + +/** + * The single app-wide state store, holding the window placement, the recent + * projects and the view state. + */ +export const stateStore = new JsonStore({ + fileName: STORE_CONFIG.stateFileName, + defaults: DEFAULT_APP_STATE, + normalize: normalizeState, +}) diff --git a/src/main/lib/updater/index.ts b/src/main/lib/updater/index.ts index 36af200..76bc95f 100644 --- a/src/main/lib/updater/index.ts +++ b/src/main/lib/updater/index.ts @@ -1,6 +1,7 @@ import CONSTANTS from "@main/lib/constants" import { createDialogWindow, openDialog } from "@main/lib/dialogs" -import { initUpdater } from "@main/lib/updater/service" +import { preferencesStore } from "@main/lib/stores/preferences" +import { initUpdater, rescheduleUpdateChecks } from "@main/lib/updater/service" import { updaterStore } from "@main/lib/updater/store" import { getMainWindow } from "@main/lib/windows" import { BrowserWindow } from "electron" @@ -151,5 +152,7 @@ export function startUpdater() { updateDialogController.onStateChange(next) }) + preferencesStore.subscribe(() => rescheduleUpdateChecks()) + initUpdater() } diff --git a/src/main/lib/updater/service.ts b/src/main/lib/updater/service.ts index 484904a..292778d 100644 --- a/src/main/lib/updater/service.ts +++ b/src/main/lib/updater/service.ts @@ -1,5 +1,6 @@ import UPDATER_CONFIG from "@main/config/updater" import CONSTANTS from "@main/lib/constants" +import { preferencesStore } from "@main/lib/stores/preferences" import { normalizeReleaseNotes, parseDownloadProgress } from "@main/lib/updater/parse" import { updaterSimulator } from "@main/lib/updater/simulator" import { CLEARED_RELEASE_FIELDS, updaterStore } from "@main/lib/updater/store" @@ -7,6 +8,19 @@ import { convertErrorToMessage } from "@main/lib/utils/errors" import { app } from "electron" import { autoUpdater, type ProgressInfo, type UpdateInfo } from "electron-updater" +/** + * The periodic background check timer, held so a preference change can + * reschedule it, `null` while automatic checks are off. + */ +let checkTimer: NodeJS.Timeout | null = null + +/** + * The interval the running check timer was scheduled at, `null` while automatic + * checks are off, compared on every preference change so only a real change to + * the cadence reschedules. + */ +let scheduledCheckIntervalMs: number | null = null + /** * Mirrors the `autoUpdater` lifecycle onto the updater state. */ @@ -141,6 +155,26 @@ export function installUpdate() { setImmediate(() => autoUpdater.quitAndInstall()) } +/** + * Reschedules the periodic background check onto the current preferences. + */ +export function rescheduleUpdateChecks() { + const { isAutomaticUpdateCheckEnabled, updaterCheckIntervalMs } = preferencesStore.get() + + const isUnchanged = scheduledCheckIntervalMs === (isAutomaticUpdateCheckEnabled ? updaterCheckIntervalMs : null) + if (isUnchanged) return + + if (checkTimer) { + clearInterval(checkTimer) + checkTimer = null + } + + scheduledCheckIntervalMs = isAutomaticUpdateCheckEnabled ? updaterCheckIntervalMs : null + if (!app.isPackaged || !isAutomaticUpdateCheckEnabled) return + + checkTimer = setInterval(() => checkForUpdates(), updaterCheckIntervalMs) +} + /** * Configures the auto-updater to hand every decision to the user, then schedules * the periodic background checks in packaged builds. @@ -154,6 +188,9 @@ export function initUpdater() { if (!app.isPackaged) return - setTimeout(() => checkForUpdates(), UPDATER_CONFIG.startupCheckDelayMs) - setInterval(() => checkForUpdates(), UPDATER_CONFIG.checkIntervalMs) + setTimeout(() => { + if (preferencesStore.get().isAutomaticUpdateCheckEnabled) checkForUpdates() + }, UPDATER_CONFIG.startupCheckDelayMs) + + rescheduleUpdateChecks() } diff --git a/src/main/lib/utils/stores.ts b/src/main/lib/utils/stores.ts new file mode 100644 index 0000000..5658ec2 --- /dev/null +++ b/src/main/lib/utils/stores.ts @@ -0,0 +1,20 @@ +import path from "node:path" +import STORE_CONFIG from "@main/config/store" +import { app } from "electron" + +/** + * Resolves the absolute path to the directory holding every persisted store file. + * @returns The absolute store directory path. + */ +export function getStoreDir(): string { + return path.join(app.getPath("appData"), STORE_CONFIG.dirName) +} + +/** + * Resolves the absolute path to one store file. + * @param fileName The store's file name. + * @returns The absolute store file path. + */ +export function getStorePath(fileName: string): string { + return path.join(getStoreDir(), fileName) +} diff --git a/src/main/lib/viewState/handlers.ts b/src/main/lib/viewState/handlers.ts new file mode 100644 index 0000000..a898b4e --- /dev/null +++ b/src/main/lib/viewState/handlers.ts @@ -0,0 +1,12 @@ +import CONSTANTS from "@main/lib/constants" +import { safeHandle } from "@main/lib/ipc" +import { getViewState, setViewState } from "@main/lib/viewState/service" +import type { AppViewState } from "@/main/types/store" + +/** + * Registers the IPC handlers for the persisted view state. + */ +export function registerViewStateHandlers() { + safeHandle(CONSTANTS.ipc.viewStateGet, () => getViewState()) + safeHandle(CONSTANTS.ipc.viewStateSet, (_event, view: Partial) => setViewState(view)) +} diff --git a/src/main/lib/viewState/service.ts b/src/main/lib/viewState/service.ts new file mode 100644 index 0000000..94116e4 --- /dev/null +++ b/src/main/lib/viewState/service.ts @@ -0,0 +1,28 @@ +import { stateStore } from "@main/lib/stores/state" +import type { AppViewState } from "@/main/types/store" + +/** + * Returns the persisted view state. + * @returns The view state. + */ +export async function getViewState(): Promise { + return (await stateStore.get()).view +} + +/** + * Merges the given fields into the persisted view state. + * @param view The view state fields to update. + * @returns The updated view state. + */ +export async function setViewState(view: Partial): Promise { + const updated = await stateStore.update(state => { + state.view = { + ...state.view, + ...view, + } + + return undefined + }) + + return updated.view +} diff --git a/src/main/lib/windows/index.ts b/src/main/lib/windows/index.ts index 622ad2f..471122f 100644 --- a/src/main/lib/windows/index.ts +++ b/src/main/lib/windows/index.ts @@ -1,3 +1,4 @@ +import path from "node:path" import CONSTANTS from "@main/lib/constants" import type { WindowState } from "@preload/index" import { BrowserWindow, ipcMain } from "electron" @@ -8,6 +9,20 @@ import { BrowserWindow, ipcMain } from "electron" */ let mainWindow: BrowserWindow | null = null +/** + * Loads one of the renderer's hash routes into a window, from the Vite dev + * server in development or the bundled HTML file in production. + * @param window The window to load into. + * @param route The hash route to load, with its leading slash and no `#`. + */ +export function loadRendererRoute(window: BrowserWindow, route: string) { + if (process.env.ELECTRON_RENDERER_URL) { + window.loadURL(`${process.env.ELECTRON_RENDERER_URL}#${route}`) + } else { + window.loadFile(path.join(__dirname, "..", "renderer", "index.html"), { hash: route }) + } +} + /** * Registers the main application window and forgets it once it is closed. * @param window The main `BrowserWindow` instance. diff --git a/src/main/lib/windows/placement.ts b/src/main/lib/windows/placement.ts index 9d795e6..b6d8669 100644 --- a/src/main/lib/windows/placement.ts +++ b/src/main/lib/windows/placement.ts @@ -1,5 +1,5 @@ import CONSTANTS from "@main/lib/constants" -import { configStore } from "@main/lib/stores/config" +import { stateStore } from "@main/lib/stores/state" import { type BrowserWindow, type Rectangle, screen } from "electron" import type { WindowBounds, WindowPlacement } from "@/main/types/store" @@ -87,7 +87,7 @@ function readPlacement(window: BrowserWindow): WindowPlacement { * @param window The window to restore. */ export async function restoreWindowPlacement(window: BrowserWindow): Promise { - const { windowPlacement } = await configStore.get() + const { windowPlacement } = await stateStore.get() if (isUsableBounds(windowPlacement.bounds)) { window.setBounds(fitBoundsToScreen(windowPlacement.bounds)) @@ -112,9 +112,9 @@ export function attachWindowPlacementPersistence(window: BrowserWindow) { const save = () => { if (window.isDestroyed() || window.isMinimized()) return - configStore.update(config => { - config.windowPlacement = readPlacement(window) - return config + stateStore.update(state => { + state.windowPlacement = readPlacement(window) + return state }) } diff --git a/src/main/types/store.d.ts b/src/main/types/store.d.ts index 9c6a23c..fd8c358 100644 --- a/src/main/types/store.d.ts +++ b/src/main/types/store.d.ts @@ -7,16 +7,24 @@ import type { Project } from "@/main/types/projects" export type StartupBehavior = "reopen-last" | "start-clean" /** - * The user-facing application preferences. + * The user-authored settings. */ export type AppPreferences = { + theme: string startupBehavior: StartupBehavior - filesPaneWidth: number - searchIsRegex: boolean - isAdvancedSearchOpened: boolean - searchIncludePatterns: string - searchExcludePatterns: string - isShowingMyLocksOnly: boolean + autoLockTickIntervalMs: number + mcpEndpoint: string + mcpProbeIntervalMs: number + isAutomaticUpdateCheckEnabled: boolean + updaterCheckIntervalMs: number +} + +/** + * The full shape of the persisted application configuration file. + */ +export type AppConfig = { + version: number + preferences: AppPreferences } /** @@ -30,8 +38,9 @@ export type WindowBounds = { } /** - * The size, position and state a window is put back to on the next launch, - * `bounds` is `null` until the app has been run once. + * The size, position and state a window is put back to on the next launch. + * + * Note: `bounds` is `null` until the app has been run once. */ export type WindowPlacement = { bounds: WindowBounds | null @@ -40,12 +49,29 @@ export type WindowPlacement = { } /** - * The full shape of the persisted application configuration file. + * The view state the app writes on its own as the user works. */ -export type AppConfig = { - version: number - preferences: AppPreferences +export type AppViewState = { + filesPaneWidth: number + searchIsRegex: boolean + isAdvancedSearchOpened: boolean + searchIncludePatterns: string + searchExcludePatterns: string + isShowingMyLocksOnly: boolean +} + +/** + * The full shape of the persisted application state file. + */ +export type AppState = { windowPlacement: WindowPlacement recentProjects: Project[] - lfsLockCache: Record + view: AppViewState +} + +/** + * The full shape of the persisted cache file. + */ +export type AppCache = { + lfsLocks: Record } diff --git a/src/preload/index.ts b/src/preload/index.ts index e1fd54c..0440200 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -6,10 +6,12 @@ import fileTreeApiRoutes from "@preload/routes/fileTree" import gitCommandsApiRoutes from "@preload/routes/gitCommands" import lfsCommandsApiRoutes from "@preload/routes/lfsCommands" import mcpApiRoutes from "@preload/routes/mcp" +import preferencesApiRoutes from "@preload/routes/preferences" import projectsApiRoutes from "@preload/routes/projects" import shellsApiRoutes from "@preload/routes/shells" import updaterApiRoutes from "@preload/routes/updater" import uprojectApiRoutes from "@preload/routes/uproject" +import viewStateApiRoutes from "@preload/routes/viewState" import windowsApiRoutes from "@preload/routes/windows" import { contextBridge } from "electron" import type { AppSound } from "@/main/types/audio" @@ -20,7 +22,7 @@ import type { GitBranch, GitCommit, GitStatus } from "@/main/types/gitCommands" import type { LfsLock, LfsLockMigration, LfsLockResult } from "@/main/types/lfsCommands" import type { EditorActivity, McpState } from "@/main/types/mcp" import type { OpenProjectResult, Project } from "@/main/types/projects" -import type { AppPreferences } from "@/main/types/store" +import type { AppPreferences, AppViewState } from "@/main/types/store" import type { UpdaterSimulation, UpdaterState } from "@/main/types/updater" import type { UProject } from "@/main/types/uproject" @@ -104,14 +106,19 @@ export type GitgameApi = { listTools: () => Promise callTool: (toolset: string, name: string, args?: Record) => Promise } + preferences: { + initial: AppPreferences + get: () => Promise + set: (patch: Partial) => Promise + onChange: (callback: (preferences: AppPreferences) => void) => () => void + openWindow: () => void + } projects: { addLocal: () => Promise open: (dir: string) => Promise getRecent: () => Promise removeRecent: (dir: string) => Promise clearRecent: () => Promise - getPreferences: () => Promise - setPreferences: (preferences: Partial) => Promise } shells: { openExternal: (url: string) => void @@ -130,6 +137,10 @@ export type GitgameApi = { uproject: { open: (dir: string) => Promise } + viewState: { + get: () => Promise + set: (view: Partial) => Promise + } windows: { getState: () => Promise onStateChange: (callback: (state: WindowState) => void) => () => void @@ -157,10 +168,12 @@ const api: GitgameApi = { gitCommands: gitCommandsApiRoutes, lfsCommands: lfsCommandsApiRoutes, mcp: mcpApiRoutes, + preferences: preferencesApiRoutes, projects: projectsApiRoutes, shells: shellsApiRoutes, updater: updaterApiRoutes, uproject: uprojectApiRoutes, + viewState: viewStateApiRoutes, windows: windowsApiRoutes, } as const diff --git a/src/preload/routes/preferences.ts b/src/preload/routes/preferences.ts new file mode 100644 index 0000000..f7a44d3 --- /dev/null +++ b/src/preload/routes/preferences.ts @@ -0,0 +1,19 @@ +import CONSTANTS from "@main/lib/constants" +import type { GitgameApi } from "@preload/index" +import { safeInvoke } from "@preload/lib/ipc" +import { ipcRenderer } from "electron" +import type { AppPreferences } from "@/main/types/store" + +const preferencesApiRoutes: GitgameApi["preferences"] = { + initial: ipcRenderer.sendSync(CONSTANTS.ipc.preferencesGetInitial) as AppPreferences, + get: () => safeInvoke(CONSTANTS.ipc.preferencesGet), + set: patch => safeInvoke(CONSTANTS.ipc.preferencesSet, patch), + onChange: callback => { + const listener = (_: unknown, next: AppPreferences) => callback(next) + ipcRenderer.on(CONSTANTS.ipc.preferencesChanged, listener) + return () => ipcRenderer.off(CONSTANTS.ipc.preferencesChanged, listener) + }, + openWindow: () => ipcRenderer.send(CONSTANTS.ipc.preferencesOpenWindow), +} + +export default preferencesApiRoutes diff --git a/src/preload/routes/projects.ts b/src/preload/routes/projects.ts index 119ac4f..fd90953 100644 --- a/src/preload/routes/projects.ts +++ b/src/preload/routes/projects.ts @@ -2,7 +2,6 @@ import CONSTANTS from "@main/lib/constants" import type { GitgameApi } from "@preload/index" import { safeInvoke } from "@preload/lib/ipc" import type { OpenProjectResult, Project } from "@/main/types/projects" -import type { AppPreferences } from "@/main/types/store" const projectsApiRoutes: GitgameApi["projects"] = { addLocal: () => safeInvoke(CONSTANTS.ipc.projectsAddLocal), @@ -10,8 +9,6 @@ const projectsApiRoutes: GitgameApi["projects"] = { getRecent: () => safeInvoke(CONSTANTS.ipc.projectsGetRecent), removeRecent: dir => safeInvoke(CONSTANTS.ipc.projectsRemoveRecent, dir), clearRecent: () => safeInvoke(CONSTANTS.ipc.projectsClearRecent), - getPreferences: () => safeInvoke(CONSTANTS.ipc.projectsGetPreferences), - setPreferences: preferences => safeInvoke(CONSTANTS.ipc.projectsSetPreferences, preferences), } export default projectsApiRoutes diff --git a/src/preload/routes/viewState.ts b/src/preload/routes/viewState.ts new file mode 100644 index 0000000..268f0b4 --- /dev/null +++ b/src/preload/routes/viewState.ts @@ -0,0 +1,11 @@ +import CONSTANTS from "@main/lib/constants" +import type { GitgameApi } from "@preload/index" +import { safeInvoke } from "@preload/lib/ipc" +import type { AppViewState } from "@/main/types/store" + +const viewStateApiRoutes: GitgameApi["viewState"] = { + get: () => safeInvoke(CONSTANTS.ipc.viewStateGet), + set: view => safeInvoke(CONSTANTS.ipc.viewStateSet, view), +} + +export default viewStateApiRoutes diff --git a/src/renderer/PreferencesApp.tsx b/src/renderer/PreferencesApp.tsx new file mode 100644 index 0000000..d02a8e3 --- /dev/null +++ b/src/renderer/PreferencesApp.tsx @@ -0,0 +1,83 @@ +import AppRoot from "@renderer/components/layouts/AppRoot" +import MainLayout from "@renderer/components/layouts/main" +import usePreferencesDraft from "@renderer/hooks/usePreferencesDraft" +import { useCallback, useEffect, useState } from "react" +import { Button, Tab, TabBody, Tabs } from "react95" +import TitleBar from "@/renderer/components/bars/Title" +import AppearanceTab from "@/renderer/components/preferences/AppearanceTab" +import GeneralTab from "@/renderer/components/preferences/GeneralTab" +import ServicesTab from "@/renderer/components/preferences/ServicesTab" + +/** + * Which panel of the preferences window is on screen. + */ +type PreferencesTab = "general" | "appearance" | "services" + +export default function PreferencesApp() { + const { draft, isDirty, setField, apply } = usePreferencesDraft() + const [tab, setTab] = useState("general") + + /** + * Closes the window, leaving any uncommitted edit behind. + */ + const handleCancel = useCallback(() => { + window.api.windows.close() + }, []) + + /** + * Commits the draft and closes the window. + */ + const handleConfirm = useCallback(async () => { + await apply() + window.api.windows.close() + }, [apply]) + + // Confirm on enter, cancel on escape, consistent with every other window + useEffect(() => { + /** + * Confirms on enter, cancels on escape. + * @param event The keyboard event. + */ + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") handleCancel() + if (event.key === "Enter") handleConfirm() + } + + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [handleCancel, handleConfirm]) + + return ( + + + + +
+ setTab(value as PreferencesTab)}> + General + Appearance + Services + + + + {tab === "general" && } + {tab === "appearance" && } + {tab === "services" && } + + +
+ + + + + +
+
+
+
+ ) +} diff --git a/src/renderer/components/bars/Menu.tsx b/src/renderer/components/bars/Menu.tsx index 359afde..e344898 100644 --- a/src/renderer/components/bars/Menu.tsx +++ b/src/renderer/components/bars/Menu.tsx @@ -75,7 +75,7 @@ export default function MenuBar({ menus, onAction, rightSlot }: MenuBarProps) { onMouseDown={event => event.preventDefault()} onClick={() => setOpenIndex(openIndex === index ? null : index)} onMouseEnter={() => handleTriggerEnter(index)} - className="px-2! bg-secondary!" + className="px-2! bg-material!" > {menu.label} diff --git a/src/renderer/components/bars/Title.tsx b/src/renderer/components/bars/Title.tsx index 9acdfcc..cafc205 100644 --- a/src/renderer/components/bars/Title.tsx +++ b/src/renderer/components/bars/Title.tsx @@ -1,6 +1,7 @@ import { cn } from "@cybearl/cypack/frontend" import CONSTANTS from "@main/lib/constants" import Icon from "@renderer/components/ui/Icon" +import WindowGlyph from "@renderer/components/ui/WindowGlyph" import useWindowStates from "@renderer/hooks/useWindowStates" import type { CSSProperties } from "react" import { Button } from "react95" @@ -19,7 +20,7 @@ export default function TitleBar({ title, icon, mode = "full", onClose }: TitleB
window.api.windows.minimize()} className="w-6! h-5.5! min-w-0!" > - + )} @@ -71,7 +72,7 @@ export default function TitleBar({ title, icon, mode = "full", onClose }: TitleB onClick={() => (onClose ? onClose() : window.api.windows.close())} className="w-6! h-5.5! min-w-0! ml-1" > - +
)} diff --git a/src/renderer/components/contexts/Project.tsx b/src/renderer/components/contexts/Project.tsx index 58a070f..80b21c5 100644 --- a/src/renderer/components/contexts/Project.tsx +++ b/src/renderer/components/contexts/Project.tsx @@ -181,14 +181,11 @@ export default function ProjectProvider({ children }: ProjectProviderProps) { setIsLoading(true) try { - const [projects, preferences] = await Promise.all([ - window.api.projects.getRecent(), - window.api.projects.getPreferences(), - ]) + const projects = await window.api.projects.getRecent() setRecentProjects(projects) - if (preferences.startupBehavior === "reopen-last" && projects[0]) { + if (window.api.preferences.initial.startupBehavior === "reopen-last" && projects[0]) { await openProject(projects[0].path) } } catch (err) { diff --git a/src/renderer/components/contexts/TreeView.tsx b/src/renderer/components/contexts/TreeView.tsx index d5a4bb1..7b0abd3 100644 --- a/src/renderer/components/contexts/TreeView.tsx +++ b/src/renderer/components/contexts/TreeView.tsx @@ -13,7 +13,7 @@ import { import { collectMatchingFiles, parsePatternList, - persistSearchPreferences, + persistSearchViewState, type SearchFilters, } from "@renderer/lib/utils/search" import { @@ -132,24 +132,24 @@ export default function TreeViewProvider({ children }: TreeViewProviderProps) { setExpandedPaths([]) }, [currentProject?.path]) - // Hydrate the persisted search preferences once on mount, so the search + // Hydrate the persisted search view state once on mount, so the search // scope survives across sessions useEffect(() => { let cancelled = false - window.api.projects - .getPreferences() - .then(preferences => { + window.api.viewState + .get() + .then(view => { if (cancelled) return - setIsRegex(preferences.searchIsRegex) - setIsAdvancedOpen(preferences.isAdvancedSearchOpened) - setIncludeText(preferences.searchIncludePatterns) - setExcludeText(preferences.searchExcludePatterns) - setIsShowingMyLocksOnly(preferences.isShowingMyLocksOnly) + setIsRegex(view.searchIsRegex) + setIsAdvancedOpen(view.isAdvancedSearchOpened) + setIncludeText(view.searchIncludePatterns) + setExcludeText(view.searchExcludePatterns) + setIsShowingMyLocksOnly(view.isShowingMyLocksOnly) }) .catch(error => { - console.error("Failed to load search preferences:", error) + console.error("Failed to load the search view state:", error) }) return () => { @@ -268,7 +268,7 @@ export default function TreeViewProvider({ children }: TreeViewProviderProps) { */ const setIsRegexPersisted = useCallback((next: boolean) => { setIsRegex(next) - persistSearchPreferences({ searchIsRegex: next }) + persistSearchViewState({ searchIsRegex: next }) }, []) /** @@ -278,7 +278,7 @@ export default function TreeViewProvider({ children }: TreeViewProviderProps) { */ const setIsAdvancedOpenPersisted = useCallback((next: boolean) => { setIsAdvancedOpen(next) - persistSearchPreferences({ isAdvancedSearchOpened: next }) + persistSearchViewState({ isAdvancedSearchOpened: next }) }, []) /** @@ -287,21 +287,21 @@ export default function TreeViewProvider({ children }: TreeViewProviderProps) { */ const setIsShowingMyLocksOnlyPersisted = useCallback((next: boolean) => { setIsShowingMyLocksOnly(next) - persistSearchPreferences({ isShowingMyLocksOnly: next }) + persistSearchViewState({ isShowingMyLocksOnly: next }) }, []) /** * Persists the current include-globs text, e.g. when the input loses focus. */ const commitIncludeText = useCallback(() => { - persistSearchPreferences({ searchIncludePatterns: includeText }) + persistSearchViewState({ searchIncludePatterns: includeText }) }, [includeText]) /** * Persists the current exclude-globs text, e.g. when the input loses focus. */ const commitExcludeText = useCallback(() => { - persistSearchPreferences({ searchExcludePatterns: excludeText }) + persistSearchViewState({ searchExcludePatterns: excludeText }) }, [excludeText]) /** diff --git a/src/renderer/components/fields/StatusTask.tsx b/src/renderer/components/fields/StatusTask.tsx index 25ba460..41e2b2e 100644 --- a/src/renderer/components/fields/StatusTask.tsx +++ b/src/renderer/components/fields/StatusTask.tsx @@ -35,7 +35,7 @@ export default function StatusTaskField({ className }: StatusTaskFieldProps) { return Math.max(0, Math.min(100, currentTask.progress)) }, [currentTask?.progress, tick]) - // Cycle the tick while the task is indeterminate so the react95 bar animates + // Cycle the tick while the task is indeterminate so the React95 bar animates useEffect(() => { if (!currentTask || currentTask.progress != null) return diff --git a/src/renderer/components/layouts/AppRoot.tsx b/src/renderer/components/layouts/AppRoot.tsx index e05ccc6..fb59e9d 100644 --- a/src/renderer/components/layouts/AppRoot.tsx +++ b/src/renderer/components/layouts/AppRoot.tsx @@ -1,25 +1,51 @@ -import type { ReactNode } from "react" +import usePreferences from "@renderer/hooks/usePreferences" +import { type ReactNode, useMemo } from "react" import { createScrollbars } from "react95" -import originalTheme from "react95/dist/themes/original" import { createGlobalStyle, StyleSheetManager, ThemeProvider } from "styled-components" +import { resolveTheme } from "@/renderer/config/themes" import { shouldForwardStyledProp } from "@/renderer/lib/utils/styled" +import { buildSizerGripUrl, buildThemeVariables } from "@/renderer/lib/utils/theme" /** - * Applies react95's Win95 scrollbar styling globally. + * Mirrors the active theme onto the CSS custom properties the Tailwind + * utilities read. */ -const GlobalScrollbars = createGlobalStyle` +const GlobalStyles = createGlobalStyle` + :root { + ${({ theme }) => buildThemeVariables(theme)} + } + + body { + background-color: ${({ theme }) => theme.material}; + } + ${createScrollbars()} + + .tree-scrollview > div::-webkit-scrollbar-corner { + background-image: ${({ theme }) => buildSizerGripUrl(theme)}; + background-position: bottom right; + background-size: 16px 16px; + background-repeat: no-repeat; + } ` type AppRootProps = { children: ReactNode + themeKey?: string } -export default function AppRoot({ children }: AppRootProps) { +export default function AppRoot({ children, themeKey }: AppRootProps) { + const { preferences } = usePreferences() + + /** + * The theme this window paints with. + */ + const theme = useMemo(() => resolveTheme(themeKey ?? preferences.theme), [themeKey, preferences.theme]) + return ( - - + + {children} diff --git a/src/renderer/components/layouts/main/Frame.tsx b/src/renderer/components/layouts/main/Frame.tsx index bf8c13a..0532ea8 100644 --- a/src/renderer/components/layouts/main/Frame.tsx +++ b/src/renderer/components/layouts/main/Frame.tsx @@ -7,7 +7,7 @@ type MainLayoutFrameProps = { export default function MainLayoutFrame({ children }: MainLayoutFrameProps) { return ( - +
{children}
) diff --git a/src/renderer/components/preferences/AppearanceTab.tsx b/src/renderer/components/preferences/AppearanceTab.tsx new file mode 100644 index 0000000..06e5198 --- /dev/null +++ b/src/renderer/components/preferences/AppearanceTab.tsx @@ -0,0 +1,45 @@ +import { cn } from "@cybearl/cypack/frontend" +import { ScrollView } from "react95" +import type { AppPreferences } from "@/main/types/store" +import { THEME_GROUPS } from "@/renderer/config/themes" + +type AppearanceTabProps = { + draft: AppPreferences + onChange: (key: K, value: AppPreferences[K]) => void +} + +export default function AppearanceTab({ draft, onChange }: AppearanceTabProps) { + return ( +
+

+ Theme, this window repaints as you pick so you can see it before you commit. +

+ + +
+ {THEME_GROUPS.map(group => ( +
+ + {group.label} + + + {group.keys.map(key => ( + + ))} +
+ ))} +
+
+
+ ) +} diff --git a/src/renderer/components/preferences/GeneralTab.tsx b/src/renderer/components/preferences/GeneralTab.tsx new file mode 100644 index 0000000..30befe7 --- /dev/null +++ b/src/renderer/components/preferences/GeneralTab.tsx @@ -0,0 +1,33 @@ +import { GroupBox, Radio } from "react95" +import type { AppPreferences, StartupBehavior } from "@/main/types/store" + +type GeneralTabProps = { + draft: AppPreferences + onChange: (key: K, value: AppPreferences[K]) => void +} + +export default function GeneralTab({ draft, onChange }: GeneralTabProps) { + return ( +
+ +
+ onChange("startupBehavior", event.target.value as StartupBehavior)} + /> + + onChange("startupBehavior", event.target.value as StartupBehavior)} + /> +
+
+
+ ) +} diff --git a/src/renderer/components/preferences/ServicesTab.tsx b/src/renderer/components/preferences/ServicesTab.tsx new file mode 100644 index 0000000..c5587e0 --- /dev/null +++ b/src/renderer/components/preferences/ServicesTab.tsx @@ -0,0 +1,98 @@ +import { Checkbox, GroupBox, NumberInput, TextInput } from "react95" +import type { AppPreferences } from "@/main/types/store" +import PREFERENCES_CONFIG from "@/renderer/config/preferences" +import CONSTANTS from "@/renderer/lib/constants" +import { fromDisplayUnits, toDisplayUnits } from "@/renderer/lib/utils/preferences" + +type ServicesTabProps = { + draft: AppPreferences + onChange: (key: K, value: AppPreferences[K]) => void +} + +export default function ServicesTab({ draft, onChange }: ServicesTabProps) { + return ( +
+ + {/* biome-ignore lint/a11y/noLabelWithoutControl: React95 renders the input inside, out of the linter's reach */} + + + + +
+ {/* biome-ignore lint/a11y/noLabelWithoutControl: React95 renders the input inside, out of the linter's reach */} + + + {/* biome-ignore lint/a11y/noLabelWithoutControl: React95 renders the input inside, out of the linter's reach */} + +
+
+ + +
+ onChange("isAutomaticUpdateCheckEnabled", event.target.checked)} + /> + + {/* biome-ignore lint/a11y/noLabelWithoutControl: React95 renders the input inside, out of the linter's reach */} + +
+
+
+ ) +} diff --git a/src/renderer/components/spaces/Workspace.tsx b/src/renderer/components/spaces/Workspace.tsx index 066be08..7270292 100644 --- a/src/renderer/components/spaces/Workspace.tsx +++ b/src/renderer/components/spaces/Workspace.tsx @@ -23,7 +23,7 @@ export default function Workspace() { * @param finalWidth The width to persist, in pixels. */ const persistWidth = useCallback((finalWidth: number) => { - window.api.projects.setPreferences({ filesPaneWidth: finalWidth }) + window.api.viewState.set({ filesPaneWidth: finalWidth }) }, []) const { width, setWidth, handleDragStart } = useResizablePaneWidth({ @@ -43,9 +43,9 @@ export default function Workspace() { useEffect(() => { let cancelled = false - window.api.projects.getPreferences().then(preferences => { + window.api.viewState.get().then(view => { if (cancelled) return - setWidth(preferences.filesPaneWidth) + setWidth(view.filesPaneWidth) }) return () => { @@ -59,7 +59,7 @@ export default function Workspace() {
-
+
diff --git a/src/renderer/components/ui/WindowGlyph.tsx b/src/renderer/components/ui/WindowGlyph.tsx new file mode 100644 index 0000000..6754f14 --- /dev/null +++ b/src/renderer/components/ui/WindowGlyph.tsx @@ -0,0 +1,31 @@ +import { cn } from "@cybearl/cypack/frontend" +import CONSTANTS from "@/renderer/lib/constants" + +/** + * Which of the three title bar controls a glyph draws. + */ +export type WindowGlyphVariant = keyof typeof CONSTANTS.WINDOW_GLYPH_RECTS + +type WindowGlyphProps = { + variant: WindowGlyphVariant + className?: string +} + +export default function WindowGlyph({ variant, className }: WindowGlyphProps) { + return ( + + ) +} diff --git a/src/renderer/components/views/TreeView.tsx b/src/renderer/components/views/TreeView.tsx index bc5889f..1646786 100644 --- a/src/renderer/components/views/TreeView.tsx +++ b/src/renderer/components/views/TreeView.tsx @@ -24,7 +24,7 @@ export default function TreeView() { } = useTreeViewContext() /** - * The visible file tree mapped to react95 tree leaves, only built when not + * The visible file tree mapped to React95 tree leaves, only built when not * searching so the flat-results path skips the tree work entirely. */ const tree = useMemo( diff --git a/src/renderer/config/menus.ts b/src/renderer/config/menus.ts index bc05028..3eac8d6 100644 --- a/src/renderer/config/menus.ts +++ b/src/renderer/config/menus.ts @@ -20,6 +20,7 @@ export type MenuAction = | { type: "search:toggle-advanced" } | { type: "lfs:toggle-show-my-locks" } | { type: "updater:check" } + | { type: "preferences:open" } | { type: "devtools:test-confirm" } | { type: "devtools:test-error" } | { type: "devtools:test-error-with-details" } @@ -201,7 +202,7 @@ export function buildTopLevelMenus( type: "item", label: "Options...", accelerator: "Ctrl+,", - isDisabled: true, + action: { type: "preferences:open" }, }, { type: "separator", diff --git a/src/renderer/config/preferences.ts b/src/renderer/config/preferences.ts new file mode 100644 index 0000000..5cfa54a --- /dev/null +++ b/src/renderer/config/preferences.ts @@ -0,0 +1,11 @@ +/** + * The bounds and step of every numeric preference field, in the unit the field + * displays rather than the milliseconds the preference stores. + */ +const PREFERENCES_CONFIG = { + autoLockIntervalSeconds: { min: 5, max: 600, step: 5 }, + mcpProbeIntervalSeconds: { min: 1, max: 120, step: 1 }, + updaterCheckIntervalHours: { min: 1, max: 168, step: 1 }, +} + +export default PREFERENCES_CONFIG diff --git a/src/renderer/config/themes.ts b/src/renderer/config/themes.ts new file mode 100644 index 0000000..b467fcc --- /dev/null +++ b/src/renderer/config/themes.ts @@ -0,0 +1,112 @@ +import type { Theme } from "react95/dist/common/themes/types" +import themes from "react95/dist/themes" + +/** + * The key identifying one of React95's bundled themes. + */ +export type ThemeKey = keyof typeof themes + +/** + * A group of themes shown together under one heading in the theme picker. + */ +export type ThemeGroup = { + label: string + keys: ThemeKey[] +} + +/** + * The theme applied when the preferred one is missing or no longer shipped by + * React95. + */ +export const FALLBACK_THEME_KEY: ThemeKey = "original" + +/** + * Every React95 theme, grouped so the ones that hold up at GitGame's density + * come first and the novelty ones stay out of the way without being hidden. + */ +export const THEME_GROUPS: ThemeGroup[] = [ + { + label: "Classic", + keys: [ + "original", + "millenium", + "windows1", + "ash", + "coldGray", + "slate", + "stormClouds", + "rainyDay", + "olive", + "maple", + "brick", + "marine", + "water", + "denim", + "blue", + "spruce", + "white", + "blackAndWhite", + "highContrast", + ], + }, + { + label: "Dark", + keys: [ + "modernDark", + "tokyoDark", + "violetDark", + "lilacRoseDark", + "solarizedDark", + "darkTeal", + "eggplant", + "redWine", + "plum", + "vistaesqueMidnight", + "matrix", + "counterStrike", + "molecule", + "wmii", + "toner", + ], + }, + { + label: "Colorful", + keys: [ + "azureOrange", + "candy", + "cherry", + "honey", + "bee", + "lilac", + "rose", + "raspberry", + "vermillion", + "seawater", + "shelbiTeal", + "vaporTeal", + "polarized", + "solarizedLight", + "peggysPastels", + "travel", + "fxDev", + "powerShell", + "hotChocolate", + "ninjaTurtles", + "theSixtiesUSA", + ], + }, + { + label: "Novelty", + keys: ["hotdogStand", "aiee", "tooSexy", "pamelaAnderson"], + }, +] + +/** + * Resolves a stored theme key to the theme itself, falling back to the default + * when the key is unknown. + * @param key The stored theme key. + * @returns The matching React95 theme. + */ +export function resolveTheme(key: string): Theme { + return themes[key as ThemeKey] ?? themes[FALLBACK_THEME_KEY] +} diff --git a/src/renderer/hooks/useMenuActions.tsx b/src/renderer/hooks/useMenuActions.tsx index 28497cd..96b3f95 100644 --- a/src/renderer/hooks/useMenuActions.tsx +++ b/src/renderer/hooks/useMenuActions.tsx @@ -83,6 +83,9 @@ export default function useMenuActions(): UseMenuActionsResult { case "lfs:toggle-show-my-locks": setIsShowingMyLocksOnly(!isShowingMyLocksOnly) break + case "preferences:open": + window.api.preferences.openWindow() + break case "updater:check": window.api.updater.check(true).catch(error => reportError("Failed to check for updates", error)) break diff --git a/src/renderer/hooks/usePreferences.tsx b/src/renderer/hooks/usePreferences.tsx new file mode 100644 index 0000000..dfafff3 --- /dev/null +++ b/src/renderer/hooks/usePreferences.tsx @@ -0,0 +1,26 @@ +import { useEffect, useState } from "react" +import type { AppPreferences } from "@/main/types/store" + +/** + * The result of the `usePreferences` hook. + */ +export type UsePreferencesResult = { + preferences: AppPreferences +} + +/** + * Tracks the committed application preferences. + * @returns The live preferences. + */ +export default function usePreferences(): UsePreferencesResult { + const [preferences, setPreferences] = useState(window.api.preferences.initial) + + // Follow the main process, a change committed in the preferences window has + // to land here too + useEffect(() => { + const unsubscribe = window.api.preferences.onChange(setPreferences) + return unsubscribe + }, []) + + return { preferences } +} diff --git a/src/renderer/hooks/usePreferencesDraft.tsx b/src/renderer/hooks/usePreferencesDraft.tsx new file mode 100644 index 0000000..76cee76 --- /dev/null +++ b/src/renderer/hooks/usePreferencesDraft.tsx @@ -0,0 +1,46 @@ +import usePreferences from "@renderer/hooks/usePreferences" +import { useCallback, useState } from "react" +import type { AppPreferences } from "@/main/types/store" +import { arePreferencesEqual } from "@/renderer/lib/utils/preferences" + +/** + * The result of the `usePreferencesDraft` hook. + */ +export type UsePreferencesDraftResult = { + draft: AppPreferences + isDirty: boolean + setField: (key: K, value: AppPreferences[K]) => void + apply: () => Promise +} + +/** + * Holds the edits made in the preferences window apart from the committed + * preferences, so nothing reaches the rest of the app until the user says so + * and closing the window is all a cancel has to do. + * @returns The draft, whether it has diverged, and the way to edit and commit it. + */ +export default function usePreferencesDraft(): UsePreferencesDraftResult { + const { preferences } = usePreferences() + const [draft, setDraft] = useState(preferences) + + const isDirty = !arePreferencesEqual(draft, preferences) + + /** + * Records an edit to one field of the draft. + * @param key The field to change. + * @param value Its new value. + */ + const setField = useCallback((key: K, value: AppPreferences[K]) => { + setDraft(current => ({ ...current, [key]: value })) + }, []) + + /** + * Commits the draft, the broadcast that follows lands back here and settles + * the dirty flag on its own. + */ + const apply = useCallback(async () => { + await window.api.preferences.set(draft) + }, [draft]) + + return { draft, isDirty, setField, apply } +} diff --git a/src/renderer/lib/constants.ts b/src/renderer/lib/constants.ts index 15d091b..e5e5ebc 100644 --- a/src/renderer/lib/constants.ts +++ b/src/renderer/lib/constants.ts @@ -28,25 +28,78 @@ const CONSTANTS = { BOOT_SCREEN_MINIMUM_DURATION_MS: 1024, /** - * The delay between two boot progress updates, coarse enough to keep the tiled - * progress bar from repainting faster than a tile can appear. + * The delay between two boot progress updates. */ BOOT_SCREEN_PROGRESS_TICK_MS: 128, /** - * The delay between two status bar countdown-bar samples, small enough to - * look smooth against the 5-10 second service intervals without wasting - * cycles when nothing else is happening. + * The delay between two status bar countdown-bar samples. */ STATUS_BAR_TICK_MS: 200, /** * The extra width the status bar has to win back before its fields climb - * from their own row up next to the caption again, keeps a drag that sits - * right on the threshold from rattling between the two layouts. + * from their own row up next to the caption again. */ STATUS_BAR_STACKING_SLACK_PX: 16, + /** + * The top-left corner of each highlight dot in the Win95 sizer grip. + */ + SIZER_GRIP_DOTS: [ + [10, 4], + [6, 8], + [10, 8], + [2, 12], + [6, 12], + [10, 12], + ], + + /** + * How far the sizer grip's shadow dots sit below and to the right of the + * highlight dots they shade. + */ + SIZER_GRIP_SHADOW_OFFSET: 2, + + /** + * How many milliseconds one second is worth, for the preference fields. + */ + MS_PER_SECOND: 1000, + + /** + * How many milliseconds one hour is worth, for the preference fields. + */ + MS_PER_HOUR: 60 * 60 * 1000, + + /** + * The rectangles making up each title bar control glyph, on the 12x10 grid they + * are drawn on, as `[x, y, width, height]`. + */ + WINDOW_GLYPH_RECTS: { + minimize: [[2, 7, 7, 2]], + maximize: [ + [1, 1, 10, 3], + [1, 4, 1, 5], + [10, 4, 1, 5], + [1, 8, 10, 1], + ], + close: [ + [2, 1, 2, 2], + [8, 1, 2, 2], + [3, 2, 2, 2], + [7, 2, 2, 2], + [4, 3, 2, 2], + [6, 3, 2, 2], + [5, 4, 2, 2], + [4, 5, 2, 2], + [6, 5, 2, 2], + [3, 6, 2, 2], + [7, 6, 2, 2], + [2, 7, 2, 2], + [8, 7, 2, 2], + ], + }, + /** * External resources opened from the `Help` menu. */ @@ -56,9 +109,7 @@ const CONSTANTS = { }, /** - * The label of the menu item revealing the project folder, per platform, so the - * wording names the file manager the user actually gets, platforms without an entry - * fall back to `DEFAULT_FILE_MANAGER_LABEL`. + * The label of the menu item revealing the project folder, per platform. */ FILE_MANAGER_LABELS: { win32: "Show in Explorer", diff --git a/src/renderer/lib/utils/preferences.ts b/src/renderer/lib/utils/preferences.ts new file mode 100644 index 0000000..9520dac --- /dev/null +++ b/src/renderer/lib/utils/preferences.ts @@ -0,0 +1,31 @@ +import type { AppPreferences } from "@/main/types/store" + +/** + * Checks whether two sets of preferences carry the same values. + * @param a The first set. + * @param b The second set. + * @returns Whether every field matches. + */ +export function arePreferencesEqual(a: AppPreferences, b: AppPreferences): boolean { + return (Object.keys(a) as (keyof AppPreferences)[]).every(key => a[key] === b[key]) +} + +/** + * Converts a stored duration into the unit its field displays. + * @param ms The stored duration, in milliseconds. + * @param unitMs How many milliseconds one displayed unit is worth. + * @returns The duration in display units. + */ +export function toDisplayUnits(ms: number, unitMs: number): number { + return Math.round(ms / unitMs) +} + +/** + * Converts a duration typed into a field back to the stored unit. + * @param value The duration in display units. + * @param unitMs How many milliseconds one displayed unit is worth. + * @returns The duration in milliseconds. + */ +export function fromDisplayUnits(value: number, unitMs: number): number { + return Math.round(value * unitMs) +} diff --git a/src/renderer/lib/utils/search.ts b/src/renderer/lib/utils/search.ts index bd6a8d9..d51e61a 100644 --- a/src/renderer/lib/utils/search.ts +++ b/src/renderer/lib/utils/search.ts @@ -1,6 +1,6 @@ import picomatch from "picomatch" import type { FileTreeNode } from "@/main/types/fileTree" -import type { AppPreferences } from "@/main/types/store" +import type { AppViewState } from "@/main/types/store" /** * The user-facing search filters applied on top of the file tree. @@ -103,12 +103,12 @@ export function collectMatchingFiles(nodes: FileTreeNode[], filters: SearchFilte } /** - * Persists a subset of the search preferences without awaiting the result, so - * the UI stays responsive; failures are logged but do not surface. - * @param patch The preference fields to persist. + * Persists a subset of the search view state without awaiting the result, so + * the UI stays responsive, failures are logged but do not surface. + * @param patch The view state fields to persist. */ -export function persistSearchPreferences(patch: Partial) { - window.api.projects.setPreferences(patch).catch(error => { - console.error("Failed to persist search preferences:", error) +export function persistSearchViewState(patch: Partial) { + window.api.viewState.set(patch).catch(error => { + console.error("Failed to persist the search view state:", error) }) } diff --git a/src/renderer/lib/utils/theme.ts b/src/renderer/lib/utils/theme.ts new file mode 100644 index 0000000..72b428b --- /dev/null +++ b/src/renderer/lib/utils/theme.ts @@ -0,0 +1,55 @@ +import type { Theme } from "react95/dist/common/themes/types" +import CONSTANTS from "@/renderer/lib/constants" + +/** + * Converts a camelCase theme token name into its kebab-case custom property suffix. + * @param token The camelCase token name. + * @returns The kebab-case suffix. + */ +function toKebabCase(token: string): string { + return token.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`) +} + +/** + * Draws one diagonal of the sizer grip. + * @param color The fill color of the dots. + * @param offset How far to shift every dot down and to the right. + * @returns The `rect` elements making up the diagonal. + */ +function buildSizerGripDots(color: string, offset: number): string { + const rects = CONSTANTS.SIZER_GRIP_DOTS.map( + ([x, y]) => ``, + ).join("") + + return `${rects}` +} + +/** + * Builds the classic Win95 sizer grip drawn in the `ScrollView` corner, tinted + * with the active theme's border colors. + * @param theme The active react95 theme. + * @returns The pattern as a CSS `url()` value. + */ +export function buildSizerGripUrl(theme: Theme): string { + const svg = + '' + + buildSizerGripDots(theme.borderLightest, 0) + + buildSizerGripDots(theme.borderDark, CONSTANTS.SIZER_GRIP_SHADOW_OFFSET) + + "" + + return `url("data:image/svg+xml,${encodeURIComponent(svg)}")` +} + +/** + * Builds the `--color-*` declarations mirroring a react95 theme, so Tailwind + * utilities and plain CSS rules follow the active theme without either of them + * having to reach into styled-components. + * @param theme The active react95 theme. + * @returns The declaration block, one custom property per line. + */ +export function buildThemeVariables(theme: Theme): string { + return Object.entries(theme) + .filter(([token, value]) => token !== "name" && typeof value === "string") + .map(([token, value]) => `--color-${toKebabCase(token)}: ${value};`) + .join("\n") +} diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx index ed4d593..d25efe4 100644 --- a/src/renderer/main.tsx +++ b/src/renderer/main.tsx @@ -1,5 +1,6 @@ import App from "@renderer/App" import DialogApp from "@renderer/DialogApp" +import PreferencesApp from "@renderer/PreferencesApp" import { StrictMode } from "react" import { createRoot } from "react-dom/client" @@ -10,9 +11,14 @@ const root = document.getElementById("root") if (!root) throw new Error("Root element #root not found in index.html.") /** - * The dialog windows load the same bundle on the `#/dialog` route, rendering the - * Win95-styled dialog instead of the main application shell. + * The shell this window renders, picked from the route it was loaded on. + * @returns The root component for this window. */ -const isDialogWindow = window.location.hash.startsWith("#/dialog") +function resolveApp() { + if (window.location.hash.startsWith("#/dialog")) return + if (window.location.hash.startsWith("#/preferences")) return -createRoot(root).render({isDialogWindow ? : }) + return +} + +createRoot(root).render({resolveApp()}) diff --git a/src/renderer/public/assets/icons/window/close.png b/src/renderer/public/assets/icons/window/close.png deleted file mode 100644 index 57bc226ea2800e41c2f457afc1125f21ce4e4498..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 139 zcmeAS@N?(olHy`uVBq!ia0vp^5d`HGl41?JYD@< J);T3K0RW!t9UK4v diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index 47370c0..2bb5fdf 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -10,3 +10,15 @@ height: 100%; } } + +/* + * Half of React95's themes give the title bar a `linear-gradient` rather than a + * flat color, which `background-color` silently drops. + */ +@utility bg-header { + background: var(--color-header-background); +} + +@utility bg-header-inactive { + background: var(--color-header-not-active-background); +} diff --git a/src/renderer/styles/react95.css b/src/renderer/styles/react95.css index 4bd5246..e9e7771 100644 --- a/src/renderer/styles/react95.css +++ b/src/renderer/styles/react95.css @@ -2,6 +2,7 @@ /* * Inlined from `react95`'s `styleReset` so the renderer no longer needs a * styled-components `createGlobalStyle` wrapper just to inject these rules. + * * Note: should be kept in sync with `node_modules/react95/dist/common/styleReset.mjs`. */ html, @@ -119,7 +120,6 @@ padding: 0; font-family: var(--font-sans); line-height: 1.5; - background-color: #c0c0c0; } ol, @@ -175,10 +175,7 @@ /** * Fixes the vertical positioning of the summary element's pseudo-element `+`/`-` - * glyph. Restoring `content-box` reinstates the intended 13x13 toggle after - * Tailwind's `border-box` preflight, and `padding-top` nudges the glyph down - * inside the box so it sits optically centered rather than hugging the top - * edge of the 9px content area. Tune the padding if the glyph needs more shift. + * glyph. */ details > summary::before { box-sizing: content-box; @@ -196,8 +193,7 @@ details > summary::before { /** * Emphasizes the currently selected tree item without cascading into descendants - * of a selected folder. Uses `text-shadow` to simulate bold so the pixel-font - * glyph metrics stay identical to the regular weight, avoiding any reflow. + * of a selected folder. */ .tree-scrollview li[aria-selected="true"] > button, .tree-scrollview li[aria-selected="true"] > details > summary, @@ -206,12 +202,36 @@ details > summary::before { } /** - * Draws the classic Win95 sizer-grip pattern in the ScrollView corner between - * the vertical and horizontal scrollbars. + * Restores React95's checkbox and radio chrome to `content-box`. */ -.tree-scrollview > div::-webkit-scrollbar-corner { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' shape-rendering='crispEdges'%3E%3Cg fill='%23ffffff'%3E%3Crect x='10' y='4' width='2' height='2'/%3E%3Crect x='6' y='8' width='2' height='2'/%3E%3Crect x='10' y='8' width='2' height='2'/%3E%3Crect x='2' y='12' width='2' height='2'/%3E%3Crect x='6' y='12' width='2' height='2'/%3E%3Crect x='10' y='12' width='2' height='2'/%3E%3C/g%3E%3Cg fill='%23808080'%3E%3Crect x='12' y='6' width='2' height='2'/%3E%3Crect x='8' y='10' width='2' height='2'/%3E%3Crect x='12' y='10' width='2' height='2'/%3E%3Crect x='4' y='14' width='2' height='2'/%3E%3Crect x='8' y='14' width='2' height='2'/%3E%3Crect x='12' y='14' width='2' height='2'/%3E%3C/g%3E%3C/svg%3E"); - background-position: bottom right; - background-size: 16px 16px; - background-repeat: no-repeat; +[role="presentation"]::before, +[role="presentation"]::after, +[data-testid="checkmarkIcon"]::after, +[data-testid="indeterminateIcon"]::after { + box-sizing: content-box; +} + +/** + * Drops the Win95 dotted focus rectangle everywhere. + */ +:focus, +:focus-visible { + outline: none; +} + +:focus::after, +:focus-visible::after, +:active::after { + /* biome-ignore lint/complexity/noImportantStyles: forced against React95 own styling */ + outline: none !important; +} + +/** + * Keeps a selected tab's growth vertical. + */ +[role="tab"][aria-selected="true"] { + /* biome-ignore lint/complexity/noImportantStyles: forced against React95 own styling */ + margin-right: 0 !important; + margin-left: 0; + padding: 0 8px; } diff --git a/src/renderer/styles/tailwind-theme.css b/src/renderer/styles/tailwind-theme.css index f05eaec..029575d 100644 --- a/src/renderer/styles/tailwind-theme.css +++ b/src/renderer/styles/tailwind-theme.css @@ -1,9 +1,23 @@ @theme { - /* Overall colors */ - --color-primary: #ffffff; - --color-secondary: #c6c6c6; - --color-inactive: #808080; - --color-active: #000080; + /* + * The React95 theme tokens, named exactly as React95 names them so the runtime + * bridge in `AppRoot` can mirror the active theme onto them mechanically. The + * values here are `original`'s, and only ever show through before that bridge + * mounts. + */ + --color-material: #c6c6c6; + --color-material-text: #0a0a0a; + --color-material-text-disabled: #848584; + --color-header-background: #060084; + --color-header-text: #fefefe; + --color-header-not-active-background: #7f787f; + --color-header-not-active-text: #c6c6c6; + --color-border-lightest: #fefefe; + --color-border-light: #dfdfdf; + --color-border-dark: #848584; + --color-border-darkest: #0a0a0a; + --color-canvas: #ffffff; + --color-canvas-text: #0a0a0a; /* The MS Sans Serif font registered in `./fonts.css`, with system fallbacks */ --font-sans: "ms_sans_serif", "Microsoft Sans Serif", system-ui, sans-serif; From 0d5b0bc96e4c539be4ed5b8a49e28731fae58045 Mon Sep 17 00:00:00 2001 From: yoratoni Date: Thu, 13 Aug 2026 15:49:45 +0200 Subject: [PATCH 2/3] feat: regenerated the clangd compile database when C++ sources are added or removed Adding or removing a C++ file invalidates compile_commands.json, and until it is rewritten clangd has no entry for the new translation unit. That rewrite was a batch script run by hand per project, so a new source file meant dropping out of the editor before the language server could see it at all. Resolved the engine install in the app rather than leaning on a per-project script: a launcher install comes off the per-version registry key, a source build off the user's Builds key keyed by the GUID a .uproject carries, macOS off the shared install directory, and any of them can be overridden by hand for a layout we cannot discover. Read the editor target off the project's own *Editor.Target.cs instead of guessing it from the project name, then drove the engine's build tool directly, passing -OutputDir so the database lands in the project rather than in the engine install. Scoped the watchers to the project's Source tree and to every plugin's, at whatever depth the plugin sits, and pruned the generated trees the build tool churns through so its own output cannot retrigger the watch. Gated every event on a re-scan comparing the file set, so only a file coming or going spends an invocation while an edit to one already in the database spends nothing. Collapsed bursts through a settle timer so a branch switch rewriting hundreds of files ends in a single run, and coalesced a change landing mid-run onto one replay. Left it off by default, with the toggle, the settle delay and the engine override under Options > Services, a status chip carrying the running step and the last outcome, and two Repository items for the fast path and the full project-files regeneration. Kept the full one manual only so an open IDE never has its solution rewritten underneath it, and surfaced it as a hint on the chip whenever a build or plugin descriptor moves. --- .vscode/settings.json | 6 + src/main/config/app.ts | 4 + src/main/config/compileDb.ts | 137 ++++++++ src/main/index.ts | 4 + src/main/lib/compileDb/engine.ts | 145 ++++++++ src/main/lib/compileDb/handlers.ts | 17 + src/main/lib/compileDb/index.ts | 35 ++ src/main/lib/compileDb/paths.ts | 88 +++++ src/main/lib/compileDb/run.ts | 237 +++++++++++++ src/main/lib/compileDb/scan.ts | 82 +++++ src/main/lib/compileDb/service.ts | 329 ++++++++++++++++++ src/main/lib/compileDb/targets.ts | 25 ++ src/main/lib/compileDb/watch.ts | 34 ++ src/main/lib/constants.ts | 15 + src/main/lib/stores/compileDb.ts | 29 ++ src/main/lib/stores/config.ts | 4 + src/main/types/compileDb.d.ts | 58 +++ src/main/types/store.d.ts | 3 + src/preload/index.ts | 10 + src/preload/routes/compileDb.ts | 19 + src/renderer/App.tsx | 2 + src/renderer/components/contexts/Project.tsx | 11 + .../components/fields/StatusCompileDb.tsx | 94 +++++ .../components/preferences/ServicesTab.tsx | 41 +++ src/renderer/config/menus.ts | 15 + src/renderer/config/preferences.ts | 1 + src/renderer/hooks/useCompileDbState.tsx | 30 ++ src/renderer/hooks/useMenuActions.tsx | 6 + src/renderer/lib/utils/compileDb.ts | 39 +++ 29 files changed, 1520 insertions(+) create mode 100644 src/main/config/compileDb.ts create mode 100644 src/main/lib/compileDb/engine.ts create mode 100644 src/main/lib/compileDb/handlers.ts create mode 100644 src/main/lib/compileDb/index.ts create mode 100644 src/main/lib/compileDb/paths.ts create mode 100644 src/main/lib/compileDb/run.ts create mode 100644 src/main/lib/compileDb/scan.ts create mode 100644 src/main/lib/compileDb/service.ts create mode 100644 src/main/lib/compileDb/targets.ts create mode 100644 src/main/lib/compileDb/watch.ts create mode 100644 src/main/lib/stores/compileDb.ts create mode 100644 src/main/types/compileDb.d.ts create mode 100644 src/preload/routes/compileDb.ts create mode 100644 src/renderer/components/fields/StatusCompileDb.tsx create mode 100644 src/renderer/hooks/useCompileDbState.tsx create mode 100644 src/renderer/lib/utils/compileDb.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 83c44dd..7f05584 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -65,10 +65,13 @@ "browsable", "CHUNKID", "CHUNKIDS", + "clangd", "downsample", "EXPORTMAP", "fguid", "gatherable", + "HKCU", + "HKLM", "icns", "ICONDIR", "ICONDIRENTRY", @@ -80,7 +83,9 @@ "OFPA", "Paeth", "peggys", + "projectfiles", "Qfecheck", + "Regen", "scrollview", "SERIALSIZES", "shelbi", @@ -90,6 +95,7 @@ "uassets", "umap", "UPACKAGE", + "uplugin", "uproject", "vermillion", "vistaesque", diff --git a/src/main/config/app.ts b/src/main/config/app.ts index b424639..af625c1 100644 --- a/src/main/config/app.ts +++ b/src/main/config/app.ts @@ -1,4 +1,5 @@ import AUTO_LOCK_CONFIG from "@main/config/autoLock" +import COMPILE_DB_CONFIG from "@main/config/compileDb" import MCP_CONFIG from "@main/config/mcp" import STORE_CONFIG from "@main/config/store" import UPDATER_CONFIG from "@main/config/updater" @@ -16,6 +17,9 @@ const DEFAULT_APP_CONFIG: AppConfig = { autoLockTickIntervalMs: AUTO_LOCK_CONFIG.tickIntervalMs, mcpEndpoint: MCP_CONFIG.endpoint, mcpProbeIntervalMs: MCP_CONFIG.probeIntervalMs, + isCompileDbAutoRegenEnabled: false, // Off by default since it's pretty specific + compileDbDebounceMs: COMPILE_DB_CONFIG.debounceMs, + unrealEngineRoot: "", isAutomaticUpdateCheckEnabled: true, updaterCheckIntervalMs: UPDATER_CONFIG.checkIntervalMs, }, diff --git a/src/main/config/compileDb.ts b/src/main/config/compileDb.ts new file mode 100644 index 0000000..ae872ea --- /dev/null +++ b/src/main/config/compileDb.ts @@ -0,0 +1,137 @@ +/** + * The configuration for the compile-database watcher. + */ +const COMPILE_DB_CONFIG = { + /** + * How long a burst of source-tree changes is left to settle before the + * regeneration runs, a branch switch can rewrite hundreds of files and has to + * collapse into a single run. + */ + debounceMs: 2500, + + /** + * How long a single build-tool invocation is given before it is killed, the + * compile database takes about two seconds and a project-files step about + * five, so this only ever fires on an invocation that is stuck. + */ + stepTimeoutMs: 5 * 60 * 1000, + + /** + * The buffer an invocation's output is captured into, the build tool is + * verbose enough on a failure to overrun the default one. + */ + maxOutputBufferSize: 4 * 1024 * 1024, + + /** + * How much of a run's combined output is kept for the details dialog, taken + * from the tail because the build tool puts the reason it failed at the end. + */ + maxOutputChars: 8000, + + /** + * The engine-relative path of the build entry point, keyed by + * `process.platform`. + */ + buildScriptPaths: { + win32: ["Engine", "Build", "BatchFiles", "Build.bat"], + darwin: ["Engine", "Build", "BatchFiles", "Mac", "Build.sh"], + linux: ["Engine", "Build", "BatchFiles", "Linux", "Build.sh"], + } as Record, + + /** + * The platform name the target is generated for, keyed by `process.platform`. + */ + targetPlatforms: { + win32: "Win64", + darwin: "Mac", + linux: "Linux", + } as Record, + + /** + * The build configuration the database is generated for, `Development` is what + * the editor itself is built as. + */ + configuration: "Development", + + /** + * The build-tool mode that writes a clangd-readable compile database, the + * `-vscode` project-files generator writes `cl.exe` response files instead and + * cannot stand in for it. + */ + clangDatabaseMode: "-mode=GenerateClangDatabase", + + /** + * The suffix every target descriptor carries, trimmed off the file name to + * recover the target name itself. + */ + targetDescriptorSuffix: ".Target.cs", + + /** + * The suffix marking the editor target among a project's descriptors, the one + * the compile database has to be generated against so editor-only modules are + * covered. + */ + editorTargetDescriptorSuffix: "Editor.Target.cs", + + /** + * The suffix appended to the project name when a project carries no editor + * target descriptor to read the name off. + */ + editorTargetFallbackSuffix: "Editor", + + /** + * Where the engine installs are recorded on Windows, launcher installs land + * under a per-version key and source builds under the user's `Builds` key, + * keyed there by the GUID a `.uproject` carries as its association. + */ + registry: { + installedEnginesKey: "HKLM\\SOFTWARE\\EpicGames\\Unreal Engine", + installedDirectoryValueName: "InstalledDirectory", + sourceBuildsKey: "HKCU\\SOFTWARE\\Epic Games\\Unreal Engine\\Builds", + }, + + /** + * Where launcher installs land on macOS, joined with the prefixed version to + * stand in for the registry lookup Windows gets. + */ + macEnginesDir: "/Users/Shared/Epic Games", + macEngineDirPrefix: "UE_", + + /** + * The root-relative directory holding the project's own modules. + */ + sourceRootDirName: "Source", + + /** + * The root-relative directory holding the project's plugins, each of which + * carries its own `Source` tree. + */ + pluginsDirName: "Plugins", + + /** + * The extensions that make a file part of the compile database. + */ + sourceExtensions: [".cpp", ".c", ".cc", ".cxx", ".h", ".hpp", ".hxx", ".inl"], + + /** + * The extensions that describe the build itself rather than take part in it, + * `.cs` covers the `.Build.cs` and `.Target.cs` module descriptors, which live + * inside the source trees alongside the code they describe. + */ + moduleDescriptorExtensions: [".cs"], + + /** + * The extension of a plugin's own descriptor, which sits above the plugin's + * `Source` tree rather than inside it and so is matched on its own. + */ + pluginDescriptorExtension: ".uplugin", + + /** + * The directory names never descended into or watched, the build tool writes + * generated `.cpp` and `.h` files under `Intermediate` that would otherwise + * look like hand-written sources and retrigger the watcher on every run. + */ + ignoredDirNames: ["Intermediate", "Binaries", "Saved", "DerivedDataCache", ".git", "node_modules"], +} + +export default COMPILE_DB_CONFIG diff --git a/src/main/index.ts b/src/main/index.ts index 53b5337..ddd4d59 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3,6 +3,8 @@ import WINDOWS_CONFIG from "@main/config/windows" import { registerAppHandlers } from "@main/lib/app/handlers" import { startAutoLock } from "@main/lib/autoLock" import { registerAutoLockHandlers } from "@main/lib/autoLock/handlers" +import { startCompileDb } from "@main/lib/compileDb" +import { registerCompileDbHandlers } from "@main/lib/compileDb/handlers" import { registerDialogsHandlers } from "@main/lib/dialogs" import { registerFileTreeHandlers } from "@main/lib/fileTree/handlers" import { registerGitCommandsHandlers } from "@main/lib/gitCommands/handlers" @@ -74,6 +76,7 @@ app.whenReady().then(async () => { registerUpdaterHandlers() registerMcpHandlers() registerAutoLockHandlers() + registerCompileDbHandlers() registerViewStateHandlers() registerPreferencesHandlers() @@ -88,6 +91,7 @@ app.whenReady().then(async () => { startUpdater() startMcp() startAutoLock() + startCompileDb() // Re-create a window in the app when the dock icon is clicked (macOS) app.on("activate", () => { diff --git a/src/main/lib/compileDb/engine.ts b/src/main/lib/compileDb/engine.ts new file mode 100644 index 0000000..d7c7222 --- /dev/null +++ b/src/main/lib/compileDb/engine.ts @@ -0,0 +1,145 @@ +import { execFile } from "node:child_process" +import path from "node:path" +import { promisify } from "node:util" +import COMPILE_DB_CONFIG from "@main/config/compileDb" +import CONSTANTS from "@main/lib/constants" +import { preferencesStore } from "@main/lib/stores/preferences" +import { pathExists } from "@main/lib/utils/fs" +import type { UProject, UProjectEngineVersion } from "@/main/types/uproject" + +/** + * Promisified version of `child_process.execFile`. + */ +const execFileAsync = promisify(execFile) + +/** + * Escapes the regex meta-characters in a literal, needed because a source build is + * keyed by the GUID a `.uproject` carries, braces and all. + * @param literal The text to match literally. + * @returns The text with every meta-character escaped. + */ +function escapeForRegExp(literal: string): string { + return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +/** + * Reads a single value out of the Windows registry, the same lookup the hand + * written generator scripts do, `null` for a key or value that is not there. + * @param key The full registry key, in `HKLM\...` short-root form. + * @param valueName The name of the value to read out of that key. + * @returns The value's data, or `null` when the lookup found nothing. + */ +async function queryRegistryValue(key: string, valueName: string): Promise { + try { + const { stdout } = await execFileAsync("reg", ["query", key, "/v", valueName], { windowsHide: true }) + + // The data sits after the name and the type on its own line and can hold + // spaces itself, so everything past the type token is taken verbatim + const match = stdout.match(new RegExp(`^\\s*${escapeForRegExp(valueName)}\\s+REG_\\w+\\s+(.*)$`, "m")) + + return match?.[1].trim() || null + } catch { + return null + } +} + +/** + * Resolves the install directory of a launcher-installed engine from its version. + * @param version The major/minor pair parsed off the project's engine association. + * @returns The engine root, or `null` when that version is not installed. + */ +function resolveVersionedEngineRoot(version: UProjectEngineVersion): Promise { + const key = `${COMPILE_DB_CONFIG.registry.installedEnginesKey}\\${version.major}.${version.minor}` + return queryRegistryValue(key, COMPILE_DB_CONFIG.registry.installedDirectoryValueName) +} + +/** + * Resolves the install directory of a source-built engine from the opaque + * association a `.uproject` carries for it, registered per user rather than per + * machine and keyed by the association itself. + * @param engineAssociation The raw `EngineAssociation` value from the `.uproject`. + * @returns The engine root, or `null` when no build is registered under it. + */ +function resolveSourceBuildEngineRoot(engineAssociation: string): Promise { + return queryRegistryValue(COMPILE_DB_CONFIG.registry.sourceBuildsKey, engineAssociation) +} + +/** + * Resolves where a launcher install of a given version lands on macOS, which has + * no registry to ask and puts every install under one shared directory. + * @param version The major/minor pair parsed off the project's engine association. + * @returns The engine root, or `null` when nothing sits at that path. + */ +async function resolveMacEngineRoot(version: UProjectEngineVersion): Promise { + const root = path.join( + COMPILE_DB_CONFIG.macEnginesDir, + `${COMPILE_DB_CONFIG.macEngineDirPrefix}${version.major}.${version.minor}`, + ) + + return (await pathExists(root)) ? root : null +} + +/** + * Walks the lookups in turn and keeps the first engine root any of them yields, + * the preference override comes first so a source build on a platform we cannot + * discover automatically can always be pointed at by hand. + * @param uproject The parsed `.uproject` metadata of the open project. + * @returns The engine root, or `null` when none of the lookups found one. + */ +async function discoverEngineRoot(uproject: UProject): Promise { + const { unrealEngineRoot } = preferencesStore.get() + if (unrealEngineRoot.trim()) return unrealEngineRoot.trim() + + if (process.platform === "win32") { + if (uproject.engineVersion) { + const versioned = await resolveVersionedEngineRoot(uproject.engineVersion) + if (versioned) return versioned + } + + // A project on a source build carries a GUID rather than a version, and a + // versioned one can still have been overridden by a local build + if (uproject.engineAssociation) { + const sourceBuild = await resolveSourceBuildEngineRoot(uproject.engineAssociation) + if (sourceBuild) return sourceBuild + } + + return null + } + + return uproject.engineVersion ? resolveMacEngineRoot(uproject.engineVersion) : null +} + +/** + * Builds the absolute path of the build entry point inside an engine install for + * the current platform. + * @param engineRoot The absolute engine root path. + * @returns The absolute path of the platform's build script. + */ +export function resolveBuildScriptPath(engineRoot: string): string { + const segments = COMPILE_DB_CONFIG.buildScriptPaths[process.platform] ?? COMPILE_DB_CONFIG.buildScriptPaths.linux + return path.join(engineRoot, ...segments) +} + +/** + * Resolves the engine install a project is associated with and confirms it holds + * a build script we can drive, the one lookup a hand-written generator script + * would otherwise have to hard-code per machine. + * @param uproject The parsed `.uproject` metadata of the open project. + * @returns The engine root, guaranteed to hold the platform's build script. + * @throws When no install could be found, or the one found holds no build script. + */ +export async function resolveEngineRoot(uproject: UProject): Promise { + const engineRoot = await discoverEngineRoot(uproject) + + if (!engineRoot) { + throw new Error( + `${CONSTANTS.compileDb.missingEngineMessage} (engine association: "${uproject.engineAssociation || "none"}")`, + ) + } + + if (!(await pathExists(resolveBuildScriptPath(engineRoot)))) { + throw new Error(`${CONSTANTS.compileDb.missingBuildScriptMessage}\n\n${engineRoot}`) + } + + return engineRoot +} diff --git a/src/main/lib/compileDb/handlers.ts b/src/main/lib/compileDb/handlers.ts new file mode 100644 index 0000000..12064ae --- /dev/null +++ b/src/main/lib/compileDb/handlers.ts @@ -0,0 +1,17 @@ +import { compileDbService } from "@main/lib/compileDb/service" +import CONSTANTS from "@main/lib/constants" +import { safeHandle } from "@main/lib/ipc" +import { compileDbStore } from "@main/lib/stores/compileDb" +import type { CompileDbRunKind } from "@/main/types/compileDb" + +/** + * Registers the IPC handlers that expose the compile-database watcher to the + * renderer, covers the manual regeneration and the lifecycle handles the project + * context drives the watcher with. + */ +export function registerCompileDbHandlers() { + safeHandle(CONSTANTS.ipc.compileDbGetState, () => compileDbStore.get()) + safeHandle(CONSTANTS.ipc.compileDbRegenerate, (_event, kind: CompileDbRunKind) => compileDbService.regenerate(kind)) + safeHandle(CONSTANTS.ipc.compileDbStart, (_event, dir: string) => compileDbService.start(dir)) + safeHandle(CONSTANTS.ipc.compileDbStop, () => compileDbService.stop()) +} diff --git a/src/main/lib/compileDb/index.ts b/src/main/lib/compileDb/index.ts new file mode 100644 index 0000000..e589a14 --- /dev/null +++ b/src/main/lib/compileDb/index.ts @@ -0,0 +1,35 @@ +import { compileDbService } from "@main/lib/compileDb/service" +import CONSTANTS from "@main/lib/constants" +import { compileDbStore } from "@main/lib/stores/compileDb" +import { preferencesStore } from "@main/lib/stores/preferences" +import { BrowserWindow } from "electron" +import type { CompileDbState } from "@/main/types/compileDb" + +/** + * Pushes the compile-database state to every open window, so the status chip + * follows the same transitions the watcher records. + * @param next The new compile-database state. + */ +function broadcastCompileDbState(next: CompileDbState) { + BrowserWindow.getAllWindows().forEach(window => { + if (window.isDestroyed()) return + window.webContents.send(CONSTANTS.ipc.compileDbStateChanged, next) + }) +} + +/** + * Starts the compile-database subsystem, subscribes the store to the broadcast so + * every window follows the watcher, the service itself is started by the renderer + * through the project lifecycle. + */ +export function startCompileDb() { + compileDbStore.subscribe(broadcastCompileDbState) + + // Reconfiguring attaches watchers, which can fail on a tree that was renamed + // out from under it, and an unhandled rejection here would take the app down + preferencesStore.subscribe(() => { + compileDbService.reconfigure().catch(error => console.error("[compileDb] reconfigure failed:", error)) + }) +} + +export { compileDbService } diff --git a/src/main/lib/compileDb/paths.ts b/src/main/lib/compileDb/paths.ts new file mode 100644 index 0000000..91593c6 --- /dev/null +++ b/src/main/lib/compileDb/paths.ts @@ -0,0 +1,88 @@ +import path from "node:path" +import COMPILE_DB_CONFIG from "@main/config/compileDb" +import type { CompileDbFileKind } from "@/main/types/compileDb" + +/** + * Rewrites a path onto forward slashes, so the segment checks below read the + * same on Windows as they do everywhere else. + * @param target The path to normalize. + * @returns The path with forward-slash separators. + */ +export function toPosixPath(target: string): string { + return target.split(path.sep).join("/") +} + +/** + * Whether any segment of a path is one of the generated directories, checked on + * the whole path rather than the leaf so a nested `Intermediate` is caught too. + * @param posixPath The forward-slash repo-relative path. + * @returns True when the path sits under a generated directory. + */ +function hasIgnoredSegment(posixPath: string): boolean { + return posixPath.split("/").some(segment => COMPILE_DB_CONFIG.ignoredDirNames.includes(segment)) +} + +/** + * Whether a repo-relative path sits anywhere under the project's plugins. + * @param posixPath The forward-slash repo-relative path. + * @returns True when the path belongs to a plugin. + */ +function isInsidePlugins(posixPath: string): boolean { + return posixPath.split("/")[0] === COMPILE_DB_CONFIG.pluginsDirName +} + +/** + * Whether a repo-relative path sits inside one of the source trees, either the + * project's own `Source` or the `Source` of any plugin, at whatever depth that + * plugin sits at. + * @param posixPath The forward-slash repo-relative path. + * @returns True when the path belongs to a source tree. + */ +function isInsideSourceTree(posixPath: string): boolean { + const segments = posixPath.split("/") + + if (segments[0] === COMPILE_DB_CONFIG.sourceRootDirName) return true + if (segments[0] === COMPILE_DB_CONFIG.pluginsDirName) { + return segments.slice(2).includes(COMPILE_DB_CONFIG.sourceRootDirName) + } + + return false +} + +/** + * Resolves what a repo-relative path counts as for the compile database, `null` + * for anything outside the source trees, under a generated directory, or holding + * an extension the database does not care about. + * @param relativePath The repo-relative path, in either separator style. + * @returns The kind the file counts as, or `null` when it is not relevant. + */ +export function classifySourcePath(relativePath: string): CompileDbFileKind | null { + const posixPath = toPosixPath(relativePath) + if (hasIgnoredSegment(posixPath)) return null + + const extension = path.posix.extname(posixPath).toLowerCase() + + // A plugin's own descriptor sits above its `Source` tree rather than inside it, + // and enabling a module in one moves the build with no source file moving + if (extension === COMPILE_DB_CONFIG.pluginDescriptorExtension) { + return isInsidePlugins(posixPath) ? "descriptor" : null + } + + if (!isInsideSourceTree(posixPath)) return null + + if (COMPILE_DB_CONFIG.moduleDescriptorExtensions.includes(extension)) return "descriptor" + if (COMPILE_DB_CONFIG.sourceExtensions.includes(extension)) return "source" + + return null +} + +/** + * Builds the absolute paths of the directories worth watching, the project's own + * `Source` and the whole `Plugins` tree, kept at `Plugins` rather than each + * plugin's `Source` so a plugin added while the app is open is covered too. + * @param root The absolute repository root path. + * @returns The absolute directory paths to watch. + */ +export function resolveWatchRoots(root: string): string[] { + return [path.join(root, COMPILE_DB_CONFIG.sourceRootDirName), path.join(root, COMPILE_DB_CONFIG.pluginsDirName)] +} diff --git a/src/main/lib/compileDb/run.ts b/src/main/lib/compileDb/run.ts new file mode 100644 index 0000000..9874aa2 --- /dev/null +++ b/src/main/lib/compileDb/run.ts @@ -0,0 +1,237 @@ +import { execFile } from "node:child_process" +import { promisify } from "node:util" +import COMPILE_DB_CONFIG from "@main/config/compileDb" +import CONSTANTS from "@main/lib/constants" +import type { CompileDbContext, CompileDbRunKind, CompileDbRunResult } from "@/main/types/compileDb" + +/** + * Promisified version of `child_process.execFile`. + */ +const execFileAsync = promisify(execFile) + +/** + * A single build-tool invocation, the label is what the status field names while + * the invocation is in flight and what a failure is reported against. + */ +type CompileDbStep = { + label: string + args: string[] +} + +/** + * Keeps the tail of a block of output, taken from the end because the build tool + * puts the reason it failed there, and marks the cut so a truncated log does not + * read as a complete one. + * @param text The output to trim. + * @returns The trimmed output. + */ +function trimTail(text: string): string { + const trimmed = text.trim() + if (trimmed.length <= COMPILE_DB_CONFIG.maxOutputChars) return trimmed + + return `[...]\n${trimmed.slice(-COMPILE_DB_CONFIG.maxOutputChars)}` +} + +/** + * Folds an invocation's two streams into the one block the details dialog shows, + * the build tool writes its progress to stdout and its diagnostics to stderr and + * both matter when a run fails. + * @param stdout The captured standard output. + * @param stderr The captured standard error. + * @returns The combined and trimmed output. + */ +function combineOutput(stdout: string, stderr: string): string { + return trimTail(`${stdout}${stderr}`) +} + +/** + * Quotes an argument that carries whitespace, so a project living under a path + * with a space in it survives the trip through the Windows command interpreter. + * @param arg The argument to quote. + * @returns The argument, quoted when it needs to be. + */ +function quoteForShell(arg: string): string { + return /\s/.test(arg) ? `"${arg}"` : arg +} + +/** + * Builds the command an invocation actually goes through, a `.bat` cannot be + * executed directly so on Windows the command line is handed to the command + * interpreter, whose `/s` strips the outer pair of quotes back off. + * @param context The resolved engine, project and target the run is bound to. + * @param args The build-tool arguments for this invocation. + * @returns The command, its arguments, and whether they are already quoted. + */ +function buildInvocation( + context: CompileDbContext, + args: string[], +): { command: string; args: string[]; isVerbatim: boolean } { + if (process.platform !== "win32") { + return { + command: context.buildScriptPath, + args, + isVerbatim: false, + } + } + + const commandLine = [context.buildScriptPath, ...args].map(quoteForShell).join(" ") + + return { + command: process.env.ComSpec ?? "cmd.exe", + args: ["/d", "/s", "/c", `"${commandLine}"`], + isVerbatim: true, + } +} + +/** + * Runs one build-tool invocation and captures how it went, a non-zero exit is + * reported rather than thrown so the caller can attribute it to its step. + * @param context The resolved engine, project and target the run is bound to. + * @param step The invocation to run. + * @returns The exit code and the trimmed output. + * @throws When the build script cannot be spawned at all. + */ +async function runStep(context: CompileDbContext, step: CompileDbStep): Promise<{ exitCode: number; output: string }> { + const { command, args, isVerbatim } = buildInvocation(context, step.args) + + try { + const { stdout, stderr } = await execFileAsync(command, args, { + cwd: context.projectRoot, + windowsHide: true, + windowsVerbatimArguments: isVerbatim, + timeout: COMPILE_DB_CONFIG.stepTimeoutMs, + maxBuffer: COMPILE_DB_CONFIG.maxOutputBufferSize, + }) + + return { exitCode: 0, output: combineOutput(stdout, stderr) } + } catch (error) { + const typedError = error as NodeJS.ErrnoException & { + stdout?: string + stderr?: string + code?: number | string + killed?: boolean + } + + // A build script that cannot be spawned surfaces as a spawn error with a + // string code (e.g "ENOENT"), which is a broken engine install rather than + // a failed generation + if (typeof typedError.code === "string") { + throw new Error(`${CONSTANTS.compileDb.spawnFailureMessage} (${typedError.code})\n\n${command}`) + } + + const exitCode = typeof typedError.code === "number" ? typedError.code : 1 + const output = combineOutput(typedError.stdout ?? "", typedError.stderr ?? "") + + if (typedError.killed) { + return { exitCode, output: `${CONSTANTS.compileDb.timedOutMessage}\n\n${output}` } + } + + return { exitCode, output } + } +} + +/** + * Builds the invocation that regenerates the Visual Studio solution. + * @param context The resolved engine, project and target the run is bound to. + * @returns The invocation. + */ +function buildSolutionStep(context: CompileDbContext): CompileDbStep { + return { + label: "Solution", + args: ["-projectfiles", `-project=${context.uprojectPath}`, "-game", "-engine"], + } +} + +/** + * Builds the invocation that regenerates the editor workspace. + * @param context The resolved engine, project and target the run is bound to. + * @returns The invocation. + */ +function buildWorkspaceStep(context: CompileDbContext): CompileDbStep { + return { + label: "Workspace", + args: ["-projectfiles", "-vscode", `-project=${context.uprojectPath}`, "-game", "-engine"], + } +} + +/** + * Builds the invocation that regenerates the clangd compile database, the only + * one of the three a new or removed source file actually needs. + * @param context The resolved engine, project and target the run is bound to. + * @returns The invocation. + */ +function buildClangDatabaseStep(context: CompileDbContext): CompileDbStep { + return { + label: "Database", + args: [ + COMPILE_DB_CONFIG.clangDatabaseMode, + `-project=${context.uprojectPath}`, + context.target, + context.targetPlatform, + COMPILE_DB_CONFIG.configuration, + "-game", + // Without this the build tool writes the database into the engine + // install rather than into the project + `-OutputDir=${context.projectRoot}`, + ], + } +} + +/** + * Builds the invocations a run performs, the fast path touches the compile + * database alone while the full one regenerates the solution and the workspace + * ahead of it, in that order, because the database is what clangd reads back. + * @param kind Which regeneration to perform. + * @param context The resolved engine, project and target the run is bound to. + * @returns The invocations to run in order. + */ +function buildSteps(kind: CompileDbRunKind, context: CompileDbContext): CompileDbStep[] { + if (kind === "fast") return [buildClangDatabaseStep(context)] + return [buildSolutionStep(context), buildWorkspaceStep(context), buildClangDatabaseStep(context)] +} + +/** + * Runs a regeneration end to end, stopping at the first invocation that exits + * non-zero so a broken solution step cannot be followed by a database written + * against it. + * @param kind Which regeneration to perform. + * @param context The resolved engine, project and target the run is bound to. + * @param onStep Called with each invocation's label as it starts. + * @returns The outcome of the run. + * @throws When a build script cannot be spawned at all. + */ +export async function runRegeneration( + kind: CompileDbRunKind, + context: CompileDbContext, + onStep: (label: string) => void, +): Promise { + const startedAt = Date.now() + const outputs: string[] = [] + + for (const step of buildSteps(kind, context)) { + onStep(step.label) + + const { exitCode, output } = await runStep(context, step) + outputs.push(`--- ${step.label} ---\n${output}`) + + if (exitCode !== 0) { + return { + kind, + ok: false, + exitCode, + failedStep: step.label, + output: trimTail(outputs.join("\n\n")), + durationMs: Date.now() - startedAt, + } + } + } + + return { + kind, + ok: true, + exitCode: 0, + failedStep: null, + output: trimTail(outputs.join("\n\n")), + durationMs: Date.now() - startedAt, + } +} diff --git a/src/main/lib/compileDb/scan.ts b/src/main/lib/compileDb/scan.ts new file mode 100644 index 0000000..09e0557 --- /dev/null +++ b/src/main/lib/compileDb/scan.ts @@ -0,0 +1,82 @@ +import { readdir } from "node:fs/promises" +import path from "node:path" +import COMPILE_DB_CONFIG from "@main/config/compileDb" +import { classifySourcePath, resolveWatchRoots, toPosixPath } from "@main/lib/compileDb/paths" +import type { CompileDbFileKind } from "@/main/types/compileDb" + +/** + * Walks a directory tree and collects every file that counts toward the compile + * database, prunes the generated directories on the way down rather than + * classifying their contents, so a populated `Intermediate` costs nothing. + * @param root The absolute repository root path, paths are keyed relative to it. + * @param dir The absolute directory currently being walked. + * @param collected The map each match is added to. + */ +async function collectFrom(root: string, dir: string, collected: Map): Promise { + // A source tree that does not exist is not an error, plenty of UE projects + // carry no plugins at all + const entries = await readdir(dir, { withFileTypes: true }).catch(() => []) + + for (const entry of entries) { + const absolutePath = path.join(dir, entry.name) + + if (entry.isDirectory()) { + if (COMPILE_DB_CONFIG.ignoredDirNames.includes(entry.name)) continue + await collectFrom(root, absolutePath, collected) + + continue + } + + if (!entry.isFile()) continue + + const relativePath = path.relative(root, absolutePath) + const kind = classifySourcePath(relativePath) + + if (kind) collected.set(toPosixPath(relativePath), kind) + } +} + +/** + * Scans the project's source trees for every file the compile database is built + * from, the baseline the watcher diffs each later change against. + * @param root The absolute repository root path. + * @returns The repo-relative path of each relevant file mapped to its kind. + */ +export async function scanSourceFiles(root: string): Promise> { + const collected = new Map() + + for (const watchRoot of resolveWatchRoots(root)) { + await collectFrom(root, watchRoot, collected) + } + + return collected +} + +/** + * Diffs a fresh scan against the previous one, reporting only whether files were + * added or removed and whether any of them described the build, an edit to a file + * that is already in the database never shows up here because clangd rereads the + * file itself. + * @param previous The file map from the last scan. + * @param next The file map from the current scan. + * @returns Whether the set moved, and whether a descriptor was part of the move. + */ +export function diffSourceFiles( + previous: Map, + next: Map, +): { hasChanged: boolean; hasDescriptorChanged: boolean } { + const changed: CompileDbFileKind[] = [] + + for (const [filePath, kind] of next) { + if (!previous.has(filePath)) changed.push(kind) + } + + for (const [filePath, kind] of previous) { + if (!next.has(filePath)) changed.push(kind) + } + + return { + hasChanged: changed.length > 0, + hasDescriptorChanged: changed.includes("descriptor"), + } +} diff --git a/src/main/lib/compileDb/service.ts b/src/main/lib/compileDb/service.ts new file mode 100644 index 0000000..a06c4cc --- /dev/null +++ b/src/main/lib/compileDb/service.ts @@ -0,0 +1,329 @@ +import type { FSWatcher } from "node:fs" +import COMPILE_DB_CONFIG from "@main/config/compileDb" +import { resolveBuildScriptPath, resolveEngineRoot } from "@main/lib/compileDb/engine" +import { runRegeneration } from "@main/lib/compileDb/run" +import { diffSourceFiles, scanSourceFiles } from "@main/lib/compileDb/scan" +import { resolveEditorTarget } from "@main/lib/compileDb/targets" +import { watchSourceTrees } from "@main/lib/compileDb/watch" +import CONSTANTS from "@main/lib/constants" +import { compileDbStore } from "@main/lib/stores/compileDb" +import { preferencesStore } from "@main/lib/stores/preferences" +import { readUProject } from "@main/lib/uproject/service" +import { convertErrorToMessage } from "@main/lib/utils/errors" +import type { CompileDbContext, CompileDbFileKind, CompileDbRunKind, CompileDbRunResult } from "@/main/types/compileDb" + +/** + * The compile-database watcher's lifecycle owner, holds the source-file set the + * project was last seen with, regenerates the database whenever a file is added + * or removed, and guards against two build-tool invocations running at once. + */ +export class CompileDbService { + /** + * The repository directory the watcher is bound to, `null` when stopped. + */ + private _dir: string | null = null + + /** + * The resolved engine, project and target the runs go through, cached because + * resolving it reads the registry, and dropped whenever the project or the + * engine-folder preference moves. + */ + private _context: CompileDbContext | null = null + + /** + * The watchers attached to the source trees, empty while auto-regeneration is + * turned off, the manual entry point does not need them. + */ + private _watchers: FSWatcher[] = [] + + /** + * The source files the project was last scanned with, what an incoming change + * is diffed against to tell a new or removed file from a plain edit. + */ + private _knownFiles = new Map() + + /** + * The pending settle timer, held so a burst of changes collapses into the one + * re-scan at the end of it rather than one per event. + */ + private _debounceTimer: NodeJS.Timeout | null = null + + /** + * Whether a run is currently in flight. + */ + private _isRunning = false + + /** + * Whether a change asked for a run while another was already in flight, which + * is replayed once that one lands so the file behind it still reaches the + * database. + */ + private _hasPendingRun = false + + /** + * Whether the pending run has to be the full one, held apart from the flag + * itself so a full run asked for mid-flight is not downgraded to a fast one. + */ + private _isPendingRunFull = false + + /** + * Whether auto-regeneration is turned on, mirrored off the preferences so a + * change to an unrelated one does not tear the watchers down. + */ + private _isEnabled = preferencesStore.get().isCompileDbAutoRegenEnabled + + /** + * The engine folder the cached context was resolved against, compared on every + * preference change so pointing the app at another engine drops the context. + */ + private _engineRootOverride = preferencesStore.get().unrealEngineRoot + + /** + * Detaches every watcher and forgets the pending settle timer, kept apart from + * `stop` so turning auto-regeneration off can leave the project bound and the + * manual entry point working. + */ + private _closeWatchers() { + for (const watcher of this._watchers) { + watcher.close() + } + + this._watchers = [] + + if (this._debounceTimer) { + clearTimeout(this._debounceTimer) + this._debounceTimer = null + } + } + + /** + * Resolves everything a run needs and caches it, the engine folder comes from + * the preference override or from wherever the platform records its installs, + * and the target from the project's own editor target descriptor. + * @returns The resolved context. + * @throws When no project is bound, it holds no `.uproject`, or no engine + * install could be resolved for it. + */ + private async _ensureContext(): Promise { + if (this._context) return this._context + if (!this._dir) throw new Error(CONSTANTS.compileDb.noProjectMessage) + + const uproject = await readUProject(this._dir) + if (!uproject) throw new Error(CONSTANTS.uproject.missingFileMessage) + + const engineRoot = await resolveEngineRoot(uproject) + const target = await resolveEditorTarget(this._dir, uproject.name) + + this._context = { + projectRoot: this._dir, + uprojectPath: uproject.path, + engineRoot, + buildScriptPath: resolveBuildScriptPath(engineRoot), + target, + targetPlatform: + COMPILE_DB_CONFIG.targetPlatforms[process.platform] ?? COMPILE_DB_CONFIG.targetPlatforms.linux, + } + + compileDbStore.set({ engineRoot, target }) + + return this._context + } + + /** + * Runs a regeneration and records the outcome, a run asked for while another is + * in flight is coalesced onto it rather than dropped or stacked. + * @param kind Which regeneration to perform. + * @returns The run's outcome, or `null` when it was coalesced or failed to start. + */ + private async _run(kind: CompileDbRunKind): Promise { + if (this._isRunning) { + this._hasPendingRun = true + if (kind === "full") this._isPendingRunFull = true + + return null + } + + // Cleared here rather than in the replay so the flags can only ever + // describe a change that landed after this run committed to its scan + this._isRunning = true + this._hasPendingRun = false + this._isPendingRunFull = false + compileDbStore.set({ isRunning: true, lastError: null }) + + try { + const context = await this._ensureContext() + const result = await runRegeneration(kind, context, label => compileDbStore.set({ runningStep: label })) + + compileDbStore.set({ + lastRunAt: new Date().toISOString(), + lastResult: result, + // The full run covers the solution as well, so whatever asked for + // one has been answered + isFullRegenSuggested: kind === "full" ? false : compileDbStore.get().isFullRegenSuggested, + }) + + return result + } catch (error) { + compileDbStore.set({ + lastError: convertErrorToMessage(error), + lastRunAt: new Date().toISOString(), + }) + + return null + } finally { + this._isRunning = false + compileDbStore.set({ isRunning: false, runningStep: null }) + + if (this._hasPendingRun) void this._run(this._isPendingRunFull ? "full" : "fast") + } + } + + /** + * Re-scans the source trees and regenerates only when the file set actually + * moved, an edit to a file that is already in the database never gets here + * because the database entry exists and clangd rereads the file itself. + * @param dir The repository the scan was scheduled for. + */ + private async _evaluate(dir: string | null) { + if (!dir) return + + const next = await scanSourceFiles(dir) + + // The project can be closed or switched while the scan is in flight, and + // its result must not be written over whatever replaced it + if (this._dir !== dir) return + + const { hasChanged, hasDescriptorChanged } = diffSourceFiles(this._knownFiles, next) + + this._knownFiles = next + compileDbStore.set({ trackedFileCount: next.size }) + + if (!hasChanged) return + + // A build or plugin descriptor can move the solution too, which is left to + // the user rather than rewritten under an open IDE + if (hasDescriptorChanged) compileDbStore.set({ isFullRegenSuggested: true }) + + await this._run("fast") + } + + /** + * Restarts the settle timer, so a `git pull` or a branch switch rewriting + * hundreds of files ends in one re-scan rather than hundreds. + */ + private _scheduleEvaluate() { + const { compileDbDebounceMs } = preferencesStore.get() + + if (this._debounceTimer) clearTimeout(this._debounceTimer) + this._debounceTimer = setTimeout(() => this._evaluate(this._dir), compileDbDebounceMs) + } + + /** + * Runs a regeneration on demand, the entry point behind the menu items and the + * status chip, and the only way the full regeneration is ever reached. + * @param kind Which regeneration to perform. + * @returns The run's outcome, or `null` when it was coalesced or failed to start. + */ + regenerate(kind: CompileDbRunKind): Promise { + return this._run(kind) + } + + /** + * Binds the watcher to a repository, takes the baseline scan the later diffs + * are made against, and attaches the watchers when auto-regeneration is on. + * The baseline itself never triggers a run. + * @param dir The absolute repository root path. + */ + async start(dir: string) { + if (this._dir === dir) return + + // The mirrors are seeded at construction, which happens before the stored + // preferences are hydrated, so they are only trustworthy once refreshed + const { isCompileDbAutoRegenEnabled, unrealEngineRoot } = preferencesStore.get() + this._isEnabled = isCompileDbAutoRegenEnabled + this._engineRootOverride = unrealEngineRoot + + this.stop() + this._dir = dir + compileDbStore.set({ enabled: this._isEnabled, dir }) + + this._knownFiles = await scanSourceFiles(dir) + compileDbStore.set({ trackedFileCount: this._knownFiles.size }) + + // A project with no C++ at all has no compile database to keep up to date, + // and asking where its engine lives would only produce a pointless error + if (this._knownFiles.size === 0) return + + try { + await this._ensureContext() + } catch (error) { + compileDbStore.set({ lastError: convertErrorToMessage(error) }) + } + + if (this._isEnabled) this._watchers = await watchSourceTrees(dir, () => this._scheduleEvaluate()) + } + + /** + * Applies a preference change, attaches or detaches the watchers when + * auto-regeneration is toggled, and drops the cached context when the app has + * been pointed at another engine folder. + */ + async reconfigure() { + const { isCompileDbAutoRegenEnabled, unrealEngineRoot } = preferencesStore.get() + + if (unrealEngineRoot !== this._engineRootOverride) { + this._engineRootOverride = unrealEngineRoot + this._context = null + compileDbStore.set({ engineRoot: null, target: null, lastError: null }) + } + + if (isCompileDbAutoRegenEnabled === this._isEnabled) return + + this._isEnabled = isCompileDbAutoRegenEnabled + compileDbStore.set({ enabled: isCompileDbAutoRegenEnabled }) + + if (!isCompileDbAutoRegenEnabled) { + this._closeWatchers() + return + } + + if (this._dir && this._watchers.length === 0 && this._knownFiles.size > 0) { + this._watchers = await watchSourceTrees(this._dir, () => this._scheduleEvaluate()) + } + } + + /** + * Unbinds the watcher, called on project close or app quit so it cannot race + * with a project switch, a run already in flight is left to finish because + * killing the build tool halfway would leave the database inconsistent. + */ + stop() { + this._closeWatchers() + + this._dir = null + this._context = null + this._knownFiles = new Map() + + // A run in flight is left alone, but its replay is dropped, it belongs to a + // project that is no longer open and would otherwise land on the next one + this._hasPendingRun = false + this._isPendingRunFull = false + + compileDbStore.set({ + enabled: false, + dir: null, + engineRoot: null, + target: null, + isFullRegenSuggested: false, + trackedFileCount: 0, + lastResult: null, + lastError: null, + }) + } +} + +/** + * The single app-wide compile-database service, driven by the renderer through + * the project lifecycle. + */ +export const compileDbService = new CompileDbService() diff --git a/src/main/lib/compileDb/targets.ts b/src/main/lib/compileDb/targets.ts new file mode 100644 index 0000000..ebb22db --- /dev/null +++ b/src/main/lib/compileDb/targets.ts @@ -0,0 +1,25 @@ +import { readdir } from "node:fs/promises" +import path from "node:path" +import COMPILE_DB_CONFIG from "@main/config/compileDb" + +/** + * Finds the editor target among a project's target descriptors, the one carrying + * the editor-only modules, and falls back to the conventional name when a project + * keeps its descriptors somewhere we do not look. + * @param root The absolute repository root path. + * @param projectName The `.uproject` file's base name, used for the fallback. + * @returns The name of the editor target to build the database against. + */ +export async function resolveEditorTarget(root: string, projectName: string): Promise { + const sourceDir = path.join(root, COMPILE_DB_CONFIG.sourceRootDirName) + const entries = await readdir(sourceDir, { withFileTypes: true }).catch(() => []) + + const suffix = COMPILE_DB_CONFIG.editorTargetDescriptorSuffix.toLowerCase() + const descriptor = entries.find(entry => entry.isFile() && entry.name.toLowerCase().endsWith(suffix)) + + if (descriptor) { + return descriptor.name.slice(0, -COMPILE_DB_CONFIG.targetDescriptorSuffix.length) + } + + return `${projectName}${COMPILE_DB_CONFIG.editorTargetFallbackSuffix}` +} diff --git a/src/main/lib/compileDb/watch.ts b/src/main/lib/compileDb/watch.ts new file mode 100644 index 0000000..5b816a4 --- /dev/null +++ b/src/main/lib/compileDb/watch.ts @@ -0,0 +1,34 @@ +import { type FSWatcher, watch } from "node:fs" +import { resolveWatchRoots } from "@main/lib/compileDb/paths" +import { pathExists } from "@main/lib/utils/fs" + +/** + * Attaches a recursive watcher to each source tree that exists, scoped to those + * rather than the repository root so the trees the build tool churns through + * never reach us, the callback carries no path because an event only means + * "re-scan". + * @param root The absolute repository root path. + * @param onChange Called on every event inside a watched tree. + * @returns The attached watchers, to be closed by the caller. + */ +export async function watchSourceTrees(root: string, onChange: () => void): Promise { + const watchers: FSWatcher[] = [] + + for (const watchRoot of resolveWatchRoots(root)) { + if (!(await pathExists(watchRoot))) continue + + // A tree can still go away between that check and this call, and a watcher + // can die later on for the same reason, neither of which is worth taking + // the main process down over, the next project bind sets things up again + try { + const watcher = watch(watchRoot, { recursive: true, persistent: false }, () => onChange()) + + watcher.on("error", () => watcher.close()) + watchers.push(watcher) + } catch (error) { + console.error(`[compileDb] could not watch "${watchRoot}":`, error) + } + } + + return watchers +} diff --git a/src/main/lib/constants.ts b/src/main/lib/constants.ts index 41c7334..589ef91 100644 --- a/src/main/lib/constants.ts +++ b/src/main/lib/constants.ts @@ -82,6 +82,12 @@ const CONSTANTS = { autoLockStateChanged: "auto-lock:state-changed", autoLockStart: "auto-lock:start", autoLockStop: "auto-lock:stop", + // Compile database + compileDbGetState: "compile-db:get-state", + compileDbStateChanged: "compile-db:state-changed", + compileDbRegenerate: "compile-db:regenerate", + compileDbStart: "compile-db:start", + compileDbStop: "compile-db:stop", }, windows: { /** @@ -106,6 +112,15 @@ const CONSTANTS = { openFailureMessage: "The system could not open the .uproject file, make sure Unreal Engine is installed and registered as the handler for .uproject files.", }, + compileDb: { + missingEngineMessage: + "No Unreal Engine install could be found for this project, set the engine folder by hand in the options if you build the engine from source.", + missingBuildScriptMessage: + "The engine folder resolved for this project holds no build script, so it is either incomplete or not an engine root at all.", + spawnFailureMessage: "The engine's build script could not be launched.", + timedOutMessage: "The engine's build script did not finish in time and was stopped.", + noProjectMessage: "No project is open, so there is nothing to regenerate the compile database for.", + }, shells: { missingFolderMessage: "The folder no longer exists on disk.", openFolderFailureMessage: "The system could not open the folder in the file manager.", diff --git a/src/main/lib/stores/compileDb.ts b/src/main/lib/stores/compileDb.ts new file mode 100644 index 0000000..0b429cc --- /dev/null +++ b/src/main/lib/stores/compileDb.ts @@ -0,0 +1,29 @@ +import { ObservableStore } from "@main/lib/stores/observable" +import type { CompileDbState } from "@/main/types/compileDb" + +/** + * Builds the state the compile-database watcher starts from, idle with no project + * bound yet, filled in when the service is started. + * @returns The initial compile-database state. + */ +function createInitialState(): CompileDbState { + return { + enabled: false, + dir: null, + engineRoot: null, + target: null, + isRunning: false, + runningStep: null, + isFullRegenSuggested: false, + trackedFileCount: 0, + lastRunAt: null, + lastResult: null, + lastError: null, + } +} + +/** + * The single app-wide compile-database store, subscribed to by every window so + * the status chip and the menu items follow the same transitions. + */ +export const compileDbStore = new ObservableStore(createInitialState()) diff --git a/src/main/lib/stores/config.ts b/src/main/lib/stores/config.ts index 4cf801a..1ad7b81 100644 --- a/src/main/lib/stores/config.ts +++ b/src/main/lib/stores/config.ts @@ -21,6 +21,10 @@ function normalizeConfig(partial: Partial): AppConfig { preferences.autoLockTickIntervalMs ?? DEFAULT_APP_CONFIG.preferences.autoLockTickIntervalMs, mcpEndpoint: preferences.mcpEndpoint ?? DEFAULT_APP_CONFIG.preferences.mcpEndpoint, mcpProbeIntervalMs: preferences.mcpProbeIntervalMs ?? DEFAULT_APP_CONFIG.preferences.mcpProbeIntervalMs, + isCompileDbAutoRegenEnabled: + preferences.isCompileDbAutoRegenEnabled ?? DEFAULT_APP_CONFIG.preferences.isCompileDbAutoRegenEnabled, + compileDbDebounceMs: preferences.compileDbDebounceMs ?? DEFAULT_APP_CONFIG.preferences.compileDbDebounceMs, + unrealEngineRoot: preferences.unrealEngineRoot ?? DEFAULT_APP_CONFIG.preferences.unrealEngineRoot, isAutomaticUpdateCheckEnabled: preferences.isAutomaticUpdateCheckEnabled ?? DEFAULT_APP_CONFIG.preferences.isAutomaticUpdateCheckEnabled, diff --git a/src/main/types/compileDb.d.ts b/src/main/types/compileDb.d.ts new file mode 100644 index 0000000..6d2e2d7 --- /dev/null +++ b/src/main/types/compileDb.d.ts @@ -0,0 +1,58 @@ +/** + * Which regeneration a run performed, `fast` for the clangd compile database on + * its own and `full` for the solution and the editor workspace alongside it. + */ +export type CompileDbRunKind = "fast" | "full" + +/** + * What a file counts as inside a source tree, `source` for a translation unit or + * header the compile database indexes, `descriptor` for a build or plugin + * descriptor whose change can also move the solution. + */ +export type CompileDbFileKind = "source" | "descriptor" + +/** + * Everything a regeneration needs resolved before it can run, gathered once when + * the watcher binds to a project so a change does not pay for the lookups again. + */ +export type CompileDbContext = { + projectRoot: string + uprojectPath: string + engineRoot: string + buildScriptPath: string + target: string + targetPlatform: string +} + +/** + * The outcome of a single regeneration, `output` is the tail of the combined + * streams so a failure can be read without opening a terminal, and `failedStep` + * names which of the steps in a `full` run gave up. + */ +export type CompileDbRunResult = { + kind: CompileDbRunKind + ok: boolean + exitCode: number + failedStep: string | null + output: string + durationMs: number +} + +/** + * The compile-database watcher's observable state, broadcast to every open + * window so the status chip and the menu items follow the same transitions the + * watcher records. + */ +export type CompileDbState = { + enabled: boolean + dir: string | null + engineRoot: string | null + target: string | null + isRunning: boolean + runningStep: string | null + isFullRegenSuggested: boolean + trackedFileCount: number + lastRunAt: string | null + lastResult: CompileDbRunResult | null + lastError: string | null +} diff --git a/src/main/types/store.d.ts b/src/main/types/store.d.ts index fd8c358..f152ffd 100644 --- a/src/main/types/store.d.ts +++ b/src/main/types/store.d.ts @@ -15,6 +15,9 @@ export type AppPreferences = { autoLockTickIntervalMs: number mcpEndpoint: string mcpProbeIntervalMs: number + isCompileDbAutoRegenEnabled: boolean + compileDbDebounceMs: number + unrealEngineRoot: string isAutomaticUpdateCheckEnabled: boolean updaterCheckIntervalMs: number } diff --git a/src/preload/index.ts b/src/preload/index.ts index 0440200..876f6a9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,6 +1,7 @@ import appApiRoutes from "@preload/routes/app" import audioApiRoutes from "@preload/routes/audio" import autoLockApiRoutes from "@preload/routes/autoLock" +import compileDbApiRoutes from "@preload/routes/compileDb" import dialogsApiRoutes from "@preload/routes/dialogs" import fileTreeApiRoutes from "@preload/routes/fileTree" import gitCommandsApiRoutes from "@preload/routes/gitCommands" @@ -16,6 +17,7 @@ import windowsApiRoutes from "@preload/routes/windows" import { contextBridge } from "electron" import type { AppSound } from "@/main/types/audio" import type { AutoLockReconcileResult, AutoLockState } from "@/main/types/autoLock" +import type { CompileDbRunKind, CompileDbRunResult, CompileDbState } from "@/main/types/compileDb" import type { ConfirmDialogOptions, DialogOptions } from "@/main/types/dialogs" import type { FileTreeNode } from "@/main/types/fileTree" import type { GitBranch, GitCommit, GitStatus } from "@/main/types/gitCommands" @@ -62,6 +64,13 @@ export type GitgameApi = { start: (dir: string) => Promise stop: () => Promise } + compileDb: { + getState: () => Promise + onStateChange: (callback: (state: CompileDbState) => void) => () => void + regenerate: (kind: CompileDbRunKind) => Promise + start: (dir: string) => Promise + stop: () => Promise + } dialogs: { confirm: (options: ConfirmDialogOptions) => Promise message: (title: string, message: string) => void @@ -163,6 +172,7 @@ const api: GitgameApi = { app: appApiRoutes, audio: audioApiRoutes, autoLock: autoLockApiRoutes, + compileDb: compileDbApiRoutes, dialogs: dialogsApiRoutes, fileTree: fileTreeApiRoutes, gitCommands: gitCommandsApiRoutes, diff --git a/src/preload/routes/compileDb.ts b/src/preload/routes/compileDb.ts new file mode 100644 index 0000000..ab525f9 --- /dev/null +++ b/src/preload/routes/compileDb.ts @@ -0,0 +1,19 @@ +import CONSTANTS from "@main/lib/constants" +import type { GitgameApi } from "@preload/index" +import { safeInvoke } from "@preload/lib/ipc" +import { ipcRenderer } from "electron" +import type { CompileDbState } from "@/main/types/compileDb" + +const compileDbApiRoutes: GitgameApi["compileDb"] = { + getState: () => safeInvoke(CONSTANTS.ipc.compileDbGetState), + onStateChange: callback => { + const listener = (_: unknown, state: CompileDbState) => callback(state) + ipcRenderer.on(CONSTANTS.ipc.compileDbStateChanged, listener) + return () => ipcRenderer.off(CONSTANTS.ipc.compileDbStateChanged, listener) + }, + regenerate: kind => safeInvoke(CONSTANTS.ipc.compileDbRegenerate, kind), + start: (dir: string) => safeInvoke(CONSTANTS.ipc.compileDbStart, dir), + stop: () => safeInvoke(CONSTANTS.ipc.compileDbStop), +} + +export default compileDbApiRoutes diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 082fcda..10ba7b0 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -12,6 +12,7 @@ import MenuBar from "@/renderer/components/bars/Menu" import StatusBar from "@/renderer/components/bars/Status" import TitleBar from "@/renderer/components/bars/Title" import StatusAutoLockField from "@/renderer/components/fields/StatusAutoLock" +import StatusCompileDbField from "@/renderer/components/fields/StatusCompileDb" import StatusMcpField from "@/renderer/components/fields/StatusMcp" import StatusTaskField from "@/renderer/components/fields/StatusTask" import StatusUpdateField from "@/renderer/components/fields/StatusUpdate" @@ -104,6 +105,7 @@ function AppShell() { + diff --git a/src/renderer/components/contexts/Project.tsx b/src/renderer/components/contexts/Project.tsx index 80b21c5..b129749 100644 --- a/src/renderer/components/contexts/Project.tsx +++ b/src/renderer/components/contexts/Project.tsx @@ -147,6 +147,17 @@ export default function ProjectProvider({ children }: ProjectProviderProps) { window.api.autoLock.start(currentProject.path) }, [currentProject?.path]) + // Binds the compile-database watcher to the open project the same way, so the + // source trees it watches follow the project rather than the app + useEffect(() => { + if (!currentProject?.path) { + window.api.compileDb.stop() + return + } + + window.api.compileDb.start(currentProject.path) + }, [currentProject?.path]) + // Fetches the current project's origin URL whenever the open project // changes, so downstream consumers (e.g the menu bar) can enable/disable // remote-scoped actions accordingly diff --git a/src/renderer/components/fields/StatusCompileDb.tsx b/src/renderer/components/fields/StatusCompileDb.tsx new file mode 100644 index 0000000..af27c9b --- /dev/null +++ b/src/renderer/components/fields/StatusCompileDb.tsx @@ -0,0 +1,94 @@ +import batExecIcon from "@react95-icons/BatExec_16x16_4.png" +import useCompileDbState from "@renderer/hooks/useCompileDbState" +import { buildCompileDbFailureDetails, buildCompileDbLabel } from "@renderer/lib/utils/compileDb" +import { useCallback, useEffect, useMemo, useState } from "react" +import StatusBarFrame, { type StatusBarLabelState } from "@/renderer/components/frames/StatusBar" +import TileProgressBar from "@/renderer/components/ui/TileProgressBar" +import STATUS_CONFIG from "@/renderer/config/status" + +type StatusCompileDbFieldProps = { + className?: string +} + +export default function StatusCompileDbField({ className }: StatusCompileDbFieldProps) { + const state = useCompileDbState() + + const [tick, setTick] = useState(0) + + /** + * Whether the last regeneration failed. + */ + const hasFailed = Boolean(state?.lastError || state?.lastResult?.ok === false) + + /** + * The chip label built from the current watcher state, `null` while the first + * read has not landed yet so nothing renders on mount. + */ + const label = useMemo(() => (state ? buildCompileDbLabel(state) : null), [state]) + + /** + * Whether the chip has anything worth taking a slot in the status bar for, a + * blueprint-only project has no database to keep up to date, and a project the + * watcher is off for only earns a slot once something has run by hand. + */ + const isVisible = useMemo(() => { + if (!state) return false + if (state.isRunning || state.lastResult || state.lastError) return true + + return state.enabled && state.trackedFileCount > 0 + }, [state]) + + /** + * The tone the label takes. + */ + const labelState = useMemo(() => { + if (state?.isRunning) return "pending" + return hasFailed || !state?.enabled ? "muted" : "default" + }, [state?.isRunning, state?.enabled, hasFailed]) + + /** + * Handles a chip click, surfaces the failure when there is one to read and + * regenerates the database on demand otherwise. + */ + const handleClick = useCallback(() => { + if (!state) return + + if (hasFailed) { + window.api.dialogs.errorWithDetails( + "Compile database: the last regeneration failed", + state.lastError ?? "The engine's build tool exited with an error.", + buildCompileDbFailureDetails(state), + ) + + return + } + + window.api.compileDb.regenerate("fast") + }, [state, hasFailed]) + + // Cycle the tick while a run is in flight, it has no interval to count down + // against so the bar can only ever be indeterminate + useEffect(() => { + if (!state?.isRunning) return + + const id = window.setInterval(() => { + setTick(previous => (previous + STATUS_CONFIG.indeterminateTickStep) % 100) + }, STATUS_CONFIG.indeterminateTickMs) + + return () => window.clearInterval(id) + }, [state?.isRunning]) + + if (!state?.dir || !label || !isVisible) return null + + return ( + + + + ) +} diff --git a/src/renderer/components/preferences/ServicesTab.tsx b/src/renderer/components/preferences/ServicesTab.tsx index c5587e0..43f045a 100644 --- a/src/renderer/components/preferences/ServicesTab.tsx +++ b/src/renderer/components/preferences/ServicesTab.tsx @@ -65,6 +65,47 @@ export default function ServicesTab({ draft, onChange }: ServicesTabProps) {
+ +
+ onChange("isCompileDbAutoRegenEnabled", event.target.checked)} + /> + + {/* biome-ignore lint/a11y/noLabelWithoutControl: React95 renders the input inside, out of the linter's reach */} + + + {/* biome-ignore lint/a11y/noLabelWithoutControl: React95 renders the input inside, out of the linter's reach */} + +
+
+
(null) + + useEffect(() => { + let hasReceivedPush = false + + const unsubscribe = window.api.compileDb.onStateChange(next => { + hasReceivedPush = true + setState(next) + }) + + window.api.compileDb.getState().then(initial => { + if (!hasReceivedPush) setState(initial) + }) + + return unsubscribe + }, []) + + return state +} diff --git a/src/renderer/hooks/useMenuActions.tsx b/src/renderer/hooks/useMenuActions.tsx index 96b3f95..e18d1f5 100644 --- a/src/renderer/hooks/useMenuActions.tsx +++ b/src/renderer/hooks/useMenuActions.tsx @@ -85,6 +85,12 @@ export default function useMenuActions(): UseMenuActionsResult { break case "preferences:open": window.api.preferences.openWindow() + break + case "compile-db:regenerate": + window.api.compileDb + .regenerate(action.kind) + .catch(error => reportError("Can't regenerate the compile database", error)) + break case "updater:check": window.api.updater.check(true).catch(error => reportError("Failed to check for updates", error)) diff --git a/src/renderer/lib/utils/compileDb.ts b/src/renderer/lib/utils/compileDb.ts new file mode 100644 index 0000000..4ff6402 --- /dev/null +++ b/src/renderer/lib/utils/compileDb.ts @@ -0,0 +1,39 @@ +import dedent from "dedent" +import type { CompileDbState } from "@/main/types/compileDb" + +/** + * Builds the short label shown in the status bar chip, names the step while a run + * is in flight and settles on the outcome of the last one afterwards, every branch + * kept inside the width the shared status frame gives it. + * @param state The current compile-database state. + * @returns The chip label. + */ +export function buildCompileDbLabel(state: CompileDbState): string { + if (state.isRunning) return state.runningStep ?? "Running" + if (state.lastError) return "Error" + + if (state.lastResult && !state.lastResult.ok) return "Failed" + if (state.isFullRegenSuggested) return "Full regen" + if (state.lastResult) return `${(state.lastResult.durationMs / 1000).toFixed(1)}s` + + return `${state.trackedFileCount} files` +} + +/** + * Builds the details block shown when the chip is clicked on a failure, pairs the + * step that gave up with the tail of the build tool's own output. + * @param state The current compile-database state. + * @returns The formatted details block. + */ +export function buildCompileDbFailureDetails(state: CompileDbState): string { + if (state.lastError) return state.lastError + + if (!state.lastResult) return "No further details available." + + return dedent` + Step: ${state.lastResult.failedStep ?? "unknown"} + Exit code: ${state.lastResult.exitCode} + + ${state.lastResult.output || "The build tool produced no output."} + ` +} From aef63594c6a1303e49d9b5cb78c0e12e9b9b2c0c Mon Sep 17 00:00:00 2001 From: yoratoni Date: Thu, 13 Aug 2026 15:49:53 +0200 Subject: [PATCH 3/3] test: added unit tests for the compile database path classification Covered the pure half of the compile database watcher, which decides whether a path counts toward the database and whether a re-scan moved the file set. Both carry the cases that are easy to get wrong: a plugin grouped a folder deeper than usual, a plugin descriptor sitting above its own source tree, and the generated sources the build tool writes under Intermediate, which look hand-written and must not count. These are the first tests in the repo, dropped into the tests/unit folder the vitest config already pointed at. Kept their helpers out of the test files themselves under tests/lib/utils, reachable through a @tests alias so nothing has to climb out with a relative import. --- tests/lib/utils/compileDb.ts | 11 +++++ tests/unit/compileDb.test.ts | 94 ++++++++++++++++++++++++++++++++++++ tsconfig.json | 3 ++ 3 files changed, 108 insertions(+) create mode 100644 tests/lib/utils/compileDb.ts create mode 100644 tests/unit/compileDb.test.ts diff --git a/tests/lib/utils/compileDb.ts b/tests/lib/utils/compileDb.ts new file mode 100644 index 0000000..0a3145d --- /dev/null +++ b/tests/lib/utils/compileDb.ts @@ -0,0 +1,11 @@ +import type { CompileDbFileKind } from "@/main/types/compileDb" + +/** + * Builds a scan result from a list of path and kind pairs, so a test can state the + * file set it cares about inline instead of walking a fixture tree on disk. + * @param entries The pairs to build the scan result from. + * @returns The scan result. + */ +export function buildScan(entries: [string, CompileDbFileKind][]): Map { + return new Map(entries) +} diff --git a/tests/unit/compileDb.test.ts b/tests/unit/compileDb.test.ts new file mode 100644 index 0000000..82c7fb1 --- /dev/null +++ b/tests/unit/compileDb.test.ts @@ -0,0 +1,94 @@ +import { classifySourcePath } from "@main/lib/compileDb/paths" +import { diffSourceFiles } from "@main/lib/compileDb/scan" +import { buildScan } from "@tests/lib/utils/compileDb" +import { describe, expect, it } from "vitest" + +describe("classifySourcePath", () => { + it("counts the project's own sources and module descriptors", () => { + expect(classifySourcePath("Source/ProjectWindy/ProjectWindy.cpp")).toBe("source") + expect(classifySourcePath("Source/ProjectWindy/ProjectWindy.h")).toBe("source") + expect(classifySourcePath("Source/ProjectWindy/ProjectWindy.Build.cs")).toBe("descriptor") + expect(classifySourcePath("Source/ProjectWindyEditor.Target.cs")).toBe("descriptor") + }) + + it("counts a plugin's sources, which is where a runtime plugin's nodes live", () => { + expect(classifySourcePath("Plugins/WindyToolkit/Source/WindyToolkit/Private/Thing.cpp")).toBe("source") + expect(classifySourcePath("Plugins/WindyToolkit/Source/WindyToolkit/Public/Thing.h")).toBe("source") + expect(classifySourcePath("Plugins/WindyToolkit/Source/WindyToolkit/WindyToolkit.Build.cs")).toBe("descriptor") + }) + + it("counts sources under a plugin nested a folder deeper", () => { + expect(classifySourcePath("Plugins/GameFeatures/MyFeature/Source/Runtime/Thing.cpp")).toBe("source") + expect(classifySourcePath("Plugins/Marketplace/Vendor/Plugin/Source/Thing.h")).toBe("source") + }) + + it("counts a plugin descriptor, which sits above the plugin's source tree", () => { + expect(classifySourcePath("Plugins/WindyToolkit/WindyToolkit.uplugin")).toBe("descriptor") + expect(classifySourcePath("Plugins/GameFeatures/MyFeature/MyFeature.uplugin")).toBe("descriptor") + }) + + it("ignores generated sources, which the build tool rewrites on every run", () => { + expect(classifySourcePath("Source/ProjectWindy/Intermediate/Build/Module.Gen.cpp")).toBeNull() + expect( + classifySourcePath("Plugins/WindyToolkit/Intermediate/Build/Win64/UnrealEditor/Thing.gen.cpp"), + ).toBeNull() + expect(classifySourcePath("Plugins/WindyToolkit/Binaries/Win64/UnrealEditor-WindyToolkit.dll")).toBeNull() + }) + + it("ignores everything outside a source tree", () => { + expect(classifySourcePath("Content/Blueprints/BP_Hero.uasset")).toBeNull() + expect(classifySourcePath("Plugins/WindyToolkit/Resources/Icon128.png")).toBeNull() + expect(classifySourcePath("Plugins/WindyToolkit/Content/Thing.uasset")).toBeNull() + expect(classifySourcePath("ProjectWindy.uproject")).toBeNull() + expect(classifySourcePath("Config/DefaultEngine.ini")).toBeNull() + }) + + it("ignores a stray descriptor extension outside the plugins tree", () => { + expect(classifySourcePath("Tools/Helper.uplugin")).toBeNull() + expect(classifySourcePath("Scripts/Build.cs")).toBeNull() + }) + + it("reads Windows separators the same as forward slashes", () => { + expect(classifySourcePath("Plugins\\WindyToolkit\\Source\\WindyToolkit\\Private\\Thing.cpp")).toBe("source") + expect(classifySourcePath("Plugins\\WindyToolkit\\WindyToolkit.uplugin")).toBe("descriptor") + }) + + it("matches extensions regardless of case", () => { + expect(classifySourcePath("Source/ProjectWindy/Thing.CPP")).toBe("source") + expect(classifySourcePath("Source/ProjectWindy/Thing.Build.CS")).toBe("descriptor") + }) +}) + +describe("diffSourceFiles", () => { + const baseline = buildScan([ + ["Source/ProjectWindy/ProjectWindy.cpp", "source"], + ["Source/ProjectWindy/ProjectWindy.Build.cs", "descriptor"], + ]) + + it("reports nothing when the set is untouched, which is what an edit looks like", () => { + expect(diffSourceFiles(baseline, buildScan([...baseline]))).toEqual({ + hasChanged: false, + hasDescriptorChanged: false, + }) + }) + + it("reports an added source without asking for a full regeneration", () => { + const next = buildScan([...baseline, ["Plugins/Kit/Source/Kit/New.cpp", "source"]]) + + expect(diffSourceFiles(baseline, next)).toEqual({ hasChanged: true, hasDescriptorChanged: false }) + }) + + it("reports a removed source", () => { + const next = buildScan([["Source/ProjectWindy/ProjectWindy.Build.cs", "descriptor"]]) + + expect(diffSourceFiles(baseline, next)).toEqual({ hasChanged: true, hasDescriptorChanged: false }) + }) + + it("asks for a full regeneration when a descriptor moves either way", () => { + const added = buildScan([...baseline, ["Plugins/Kit/Kit.uplugin", "descriptor"]]) + const removed = buildScan([["Source/ProjectWindy/ProjectWindy.cpp", "source"]]) + + expect(diffSourceFiles(baseline, added)).toEqual({ hasChanged: true, hasDescriptorChanged: true }) + expect(diffSourceFiles(baseline, removed)).toEqual({ hasChanged: true, hasDescriptorChanged: true }) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 19d75cc..6e2eaea 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -39,6 +39,9 @@ // Scripts "@scripts/*": ["./scripts/*"], + // Tests + "@tests/*": ["./tests/*"], + // Source "@/*": ["./src/*"],