diff --git a/.vscode/settings.json b/.vscode/settings.json index 768c5eb..7f05584 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -56,6 +56,7 @@ }, // CSpell "cSpell.words": [ + "aiee", "Artur", "asar", "ASSETDATA", @@ -64,30 +65,42 @@ "browsable", "CHUNKID", "CHUNKIDS", + "clangd", "downsample", "EXPORTMAP", "fguid", "gatherable", + "HKCU", + "HKLM", "icns", "ICONDIR", "ICONDIRENTRY", "iconutil", "IEND", "konsole", + "millenium", "Nwnp", "OFPA", "Paeth", + "peggys", + "projectfiles", "Qfecheck", + "Regen", "scrollview", "SERIALSIZES", + "shelbi", "SOFTOBJECTPATH", "TEMPLATEINDEX", "uasset", "uassets", "umap", "UPACKAGE", + "uplugin", "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..af625c1 100644 --- a/src/main/config/app.ts +++ b/src/main/config/app.ts @@ -1,28 +1,28 @@ -import STORE_CONFIG from "@/main/config/store" +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" 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, + isCompileDbAutoRegenEnabled: false, // Off by default since it's pretty specific + compileDbDebounceMs: COMPILE_DB_CONFIG.debounceMs, + unrealEngineRoot: "", + 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/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/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..ddd4d59 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3,17 +3,22 @@ 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" 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 +76,13 @@ app.whenReady().then(async () => { registerUpdaterHandlers() registerMcpHandlers() registerAutoLockHandlers() + registerCompileDbHandlers() + 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() @@ -79,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/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/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 494abc9..589ef91 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 @@ -75,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: { /** @@ -99,15 +112,22 @@ 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.", 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/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 f21cfc9..1ad7b81 100644 --- a/src/main/lib/stores/config.ts +++ b/src/main/lib/stores/config.ts @@ -1,147 +1,44 @@ -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, + 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, + 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/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 9c6a23c..f152ffd 100644 --- a/src/main/types/store.d.ts +++ b/src/main/types/store.d.ts @@ -7,16 +7,27 @@ 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 + isCompileDbAutoRegenEnabled: boolean + compileDbDebounceMs: number + unrealEngineRoot: string + isAutomaticUpdateCheckEnabled: boolean + updaterCheckIntervalMs: number +} + +/** + * The full shape of the persisted application configuration file. + */ +export type AppConfig = { + version: number + preferences: AppPreferences } /** @@ -30,8 +41,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 +52,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..876f6a9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,26 +1,30 @@ 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" 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" 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" 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" @@ -60,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 @@ -104,14 +115,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 +146,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 @@ -152,15 +172,18 @@ const api: GitgameApi = { app: appApiRoutes, audio: audioApiRoutes, autoLock: autoLockApiRoutes, + compileDb: compileDbApiRoutes, dialogs: dialogsApiRoutes, fileTree: fileTreeApiRoutes, 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/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/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/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/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..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 @@ -181,14 +192,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/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/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..43f045a --- /dev/null +++ b/src/renderer/components/preferences/ServicesTab.tsx @@ -0,0 +1,139 @@ +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("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 */} + +
+
+ + +
+ 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..7d6acab 100644 --- a/src/renderer/config/menus.ts +++ b/src/renderer/config/menus.ts @@ -1,3 +1,4 @@ +import type { CompileDbRunKind } from "@/main/types/compileDb" import type { Project } from "@/main/types/projects" import type { UpdaterSimulation } from "@/main/types/updater" import CONSTANTS from "@/renderer/lib/constants" @@ -19,7 +20,9 @@ export type MenuAction = | { type: "search:toggle-regex" } | { type: "search:toggle-advanced" } | { type: "lfs:toggle-show-my-locks" } + | { type: "compile-db:regenerate"; kind: CompileDbRunKind } | { type: "updater:check" } + | { type: "preferences:open" } | { type: "devtools:test-confirm" } | { type: "devtools:test-error" } | { type: "devtools:test-error-with-details" } @@ -201,7 +204,7 @@ export function buildTopLevelMenus( type: "item", label: "Options...", accelerator: "Ctrl+,", - isDisabled: true, + action: { type: "preferences:open" }, }, { type: "separator", @@ -319,6 +322,19 @@ export function buildTopLevelMenus( action: remoteBrowsableUrl ? { type: "shell:open-external", url: remoteBrowsableUrl } : undefined, }, { type: "separator" }, + { + type: "item", + label: "Regenerate Compile Database", + isDisabled: !currentProject, + action: { type: "compile-db:regenerate", kind: "fast" }, + }, + { + type: "item", + label: "Regenerate Project Files", + isDisabled: !currentProject, + action: { type: "compile-db:regenerate", kind: "full" }, + }, + { type: "separator" }, { type: "item", label: "Repository Settings...", diff --git a/src/renderer/config/preferences.ts b/src/renderer/config/preferences.ts new file mode 100644 index 0000000..f99005f --- /dev/null +++ b/src/renderer/config/preferences.ts @@ -0,0 +1,12 @@ +/** + * 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 }, + compileDbDebounceSeconds: { min: 1, max: 60, 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/useCompileDbState.tsx b/src/renderer/hooks/useCompileDbState.tsx new file mode 100644 index 0000000..9a06dd5 --- /dev/null +++ b/src/renderer/hooks/useCompileDbState.tsx @@ -0,0 +1,30 @@ +import { useEffect, useState } from "react" +import type { CompileDbState } from "@/main/types/compileDb" + +/** + * Follows the compile-database watcher state broadcast by the main process, + * subscribes before the first read so a transition landing mid-mount cannot be + * missed. + * @returns The current compile-database state, or `null` until the first read + * resolves. + */ +export default function useCompileDbState(): CompileDbState | null { + const [state, setState] = useState(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 28497cd..e18d1f5 100644 --- a/src/renderer/hooks/useMenuActions.tsx +++ b/src/renderer/hooks/useMenuActions.tsx @@ -82,6 +82,15 @@ export default function useMenuActions(): UseMenuActionsResult { break case "lfs:toggle-show-my-locks": setIsShowingMyLocksOnly(!isShowingMyLocksOnly) + 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/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/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."} + ` +} 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 57bc226..0000000 Binary files a/src/renderer/public/assets/icons/window/close.png and /dev/null differ diff --git a/src/renderer/public/assets/icons/window/maximize.png b/src/renderer/public/assets/icons/window/maximize.png deleted file mode 100644 index c19abd8..0000000 Binary files a/src/renderer/public/assets/icons/window/maximize.png and /dev/null differ diff --git a/src/renderer/public/assets/icons/window/minimize.png b/src/renderer/public/assets/icons/window/minimize.png deleted file mode 100644 index ed87e65..0000000 Binary files a/src/renderer/public/assets/icons/window/minimize.png and /dev/null differ 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; 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/*"],