From feaa64239c78b7a947daa8de0579a71d3d596b0e Mon Sep 17 00:00:00 2001 From: yoratoni Date: Wed, 5 Aug 2026 00:04:23 +0200 Subject: [PATCH 01/12] refactor: reworked the main process state into class-based stores and owners Replaced the module-scoped lets and the paired maps that could drift with small owned classes: ObservableStore as the shared get/set/emit/subscribe base, ConfigStore keeping the atomic temp-file write behind a serialized update lock, UpdaterStore adding dismissal tracking and the busy predicate on top of the base, UpdaterSimulator owning the fake Dev Tools flows so the service only asks whether one is active, UpdateDialogController owning the update window together with the was-downloading and was-open context its transitions need, and DialogRegistry keeping each dialog's options and its awaiter's resolver in a single entry. --- src/main/lib/dialogs/index.ts | 115 +++++++++++---- src/main/lib/lfsCommands/handlers.ts | 8 +- src/main/lib/projects/service.ts | 26 ++-- src/main/lib/store/index.ts | 130 ----------------- src/main/lib/stores/config.ts | 143 +++++++++++++++++++ src/main/lib/stores/observable.ts | 58 ++++++++ src/main/lib/updater/handlers.ts | 9 +- src/main/lib/updater/index.ts | 201 ++++++++++++++++----------- src/main/lib/updater/service.ts | 124 +++++------------ src/main/lib/updater/simulation.ts | 8 +- src/main/lib/updater/simulator.ts | 91 ++++++++++++ src/main/lib/updater/store.ts | 125 +++++++---------- 12 files changed, 605 insertions(+), 433 deletions(-) delete mode 100644 src/main/lib/store/index.ts create mode 100644 src/main/lib/stores/config.ts create mode 100644 src/main/lib/stores/observable.ts create mode 100644 src/main/lib/updater/simulator.ts diff --git a/src/main/lib/dialogs/index.ts b/src/main/lib/dialogs/index.ts index bcb7c7c..f9fe586 100644 --- a/src/main/lib/dialogs/index.ts +++ b/src/main/lib/dialogs/index.ts @@ -7,15 +7,74 @@ import WINDOWS_CONFIG from "@/main/config/windows" import type { ConfirmDialogOptions, DialogOptions } from "@/main/types/dialogs" /** - * The options of every open dialog window, keyed by their `BrowserWindow` ID. + * A single dialog's registry entry, pairs its options with an optional promise + * resolver so the two used to live in separate maps that could drift, kept + * together here to make that structurally impossible. */ -const dialogOptions = new Map() +type DialogEntry = { + options: DialogOptions + resolve?: (result: boolean) => void +} /** - * The resolvers of the dialog windows awaiting a boolean response, keyed by their - * `BrowserWindow` ID. + * The registry of open dialog windows, keyed by their `BrowserWindow` id, one + * entry per window carrying its options and the resolver of any awaiter. */ -const pendingDialogs = new Map void>() +class DialogRegistry { + /** + * The entries keyed by `BrowserWindow.id`, options and any pending + * resolver travel together here. + */ + private _entries = new Map() + + /** + * Records a new dialog window and the resolver of any awaiter, called + * once when the window is created. + * @param id The window id. + * @param options The dialog contents and variant. + * @param resolve The promise resolver, if the caller is awaiting a response. + */ + register(id: number, options: DialogOptions, resolve?: (result: boolean) => void) { + this._entries.set(id, { options, resolve }) + } + + /** + * Reads the options of an open dialog window. + * @param id The window id. + * @returns The options, or `null` when no dialog is registered under that id. + */ + getOptions(id: number): DialogOptions | null { + return this._entries.get(id)?.options ?? null + } + + /** + * Resolves the awaiter of a dialog window with the given result and drops + * the entry, an already-resolved or unknown id is a no-op. + * @param id The window id. + * @param result The response to hand to the awaiter. + */ + respond(id: number, result: boolean) { + const entry = this._entries.get(id) + if (!entry) return + + this._entries.delete(id) + entry.resolve?.(result) + } + + /** + * Drops the entry for a closed window, cancelling any still-pending + * awaiter, called from the window's `closed` handler. + * @param id The window id. + */ + forget(id: number) { + this.respond(id, false) + } +} + +/** + * The single app-wide dialog registry. + */ +const dialogRegistry = new DialogRegistry() /** * Loads the renderer's dialog route into the given window, in development or production. @@ -35,12 +94,14 @@ function loadDialog(window: BrowserWindow) { * @param parent The window that owns the dialog, or `null` to show it detached. * @param options The dialog contents and variant. * @param isModal Whether the dialog blocks its parent, defaults to blocking whenever it has one. + * @param resolve The promise resolver, if the caller is awaiting a response. * @returns The created dialog window. */ -export function createDialogWindow( +function createDialog( parent: BrowserWindow | null, options: DialogOptions, - isModal = parent !== null, + isModal: boolean, + resolve?: (result: boolean) => void, ): BrowserWindow { const size = DIALOGS_CONFIG.sizes[options.variant] @@ -52,7 +113,7 @@ export function createDialogWindow( height: size.height, }) - dialogOptions.set(window.id, options) + dialogRegistry.register(window.id, options, resolve) // Broadcast focus and visibility changes so the dialog's title bar can // reflect the active state consistently with the main window @@ -61,21 +122,29 @@ export function createDialogWindow( window.once("ready-to-show", () => window.show()) // A window closed without an explicit response resolves as a cancel - window.on("closed", () => { - dialogOptions.delete(window.id) - - const resolve = pendingDialogs.get(window.id) - if (resolve) { - pendingDialogs.delete(window.id) - resolve(false) - } - }) + window.on("closed", () => dialogRegistry.forget(window.id)) loadDialog(window) return window } +/** + * Creates a dialog window without awaiting its response, used by callers that + * manage the window lifecycle themselves (e.g the updater dialog). + * @param parent The window that owns the dialog, or `null` to show it detached. + * @param options The dialog contents and variant. + * @param isModal Whether the dialog blocks its parent, defaults to blocking whenever it has one. + * @returns The created dialog window. + */ +export function createDialogWindow( + parent: BrowserWindow | null, + options: DialogOptions, + isModal = parent !== null, +): BrowserWindow { + return createDialog(parent, options, isModal) +} + /** * Opens a modal dialog window and resolves once the user responds or closes it * (a close counts as a cancel). @@ -85,8 +154,7 @@ export function createDialogWindow( */ export function openDialog(parent: BrowserWindow | null, options: DialogOptions): Promise { return new Promise(resolve => { - const window = createDialogWindow(parent, options) - pendingDialogs.set(window.id, resolve) + createDialog(parent, options, parent !== null, resolve) }) } @@ -118,19 +186,14 @@ export function registerDialogsHandlers() { ipcMain.handle(CONSTANTS.ipc.dialogsGetOptions, event => { const window = BrowserWindow.fromWebContents(event.sender) - return window ? (dialogOptions.get(window.id) ?? null) : null + return window ? dialogRegistry.getOptions(window.id) : null }) ipcMain.on(CONSTANTS.ipc.dialogsRespond, (event, result: boolean) => { const window = BrowserWindow.fromWebContents(event.sender) if (!window) return - const resolve = pendingDialogs.get(window.id) - if (resolve) { - pendingDialogs.delete(window.id) - resolve(result) - } - + dialogRegistry.respond(window.id, result) window.close() }) } diff --git a/src/main/lib/lfsCommands/handlers.ts b/src/main/lib/lfsCommands/handlers.ts index fd1b340..d146e75 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 { getConfig, updateConfig } from "@main/lib/store" +import { configStore } from "@main/lib/stores/config" import type { LfsLockProgress } from "@/main/types/lfsCommands" /** @@ -11,7 +11,7 @@ export function registerLfsCommandsHandlers() { safeHandle(CONSTANTS.ipc.lfsCommandsListLocks, async (_event, dir: string) => { const locks = await listLocks(dir) - updateConfig(config => { + configStore.update(config => { config.lfsLockCache[dir] = locks return config }) @@ -20,7 +20,7 @@ export function registerLfsCommandsHandlers() { }) safeHandle(CONSTANTS.ipc.lfsCommandsGetCachedLocks, async (_event, dir: string) => { - const config = await getConfig() + const config = await configStore.get() return config.lfsLockCache[dir] ?? [] }) @@ -50,7 +50,7 @@ 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) - updateConfig(config => { + configStore.update(config => { config.lfsLockCache[dir] = locks return config }) diff --git a/src/main/lib/projects/service.ts b/src/main/lib/projects/service.ts index d1e8ef8..424e13d 100644 --- a/src/main/lib/projects/service.ts +++ b/src/main/lib/projects/service.ts @@ -1,7 +1,7 @@ import path from "node:path" import STORE_CONFIG from "@main/config/store" import { getRepositoryRoot, isRepository } from "@main/lib/gitCommands/service" -import { getConfig, updateConfig } from "@main/lib/store" +import { configStore } from "@main/lib/stores/config" import { readUProject } from "@main/lib/uproject/service" import { pathExists } from "@main/lib/utils/fs" import { app, type BrowserWindow, dialog } from "electron" @@ -24,7 +24,7 @@ async function rememberProject(root: string, uproject: UProject): Promise { + await configStore.update(config => { const others = config.recentProjects.filter(project => project.path !== root) config.recentProjects = [entry, ...others].slice(0, STORE_CONFIG.maxRecentProjects) @@ -35,13 +35,9 @@ async function rememberProject(root: string, uproject: UProject): Promise { * @returns The outcome of the open attempt, including cancellation. */ export async function addLocalProject(window: BrowserWindow | null): Promise { - const config = await getConfig() + const config = await configStore.get() const mostRecent = config.recentProjects[0] const defaultPath = mostRecent ? path.dirname(mostRecent.path) : app.getPath("home") @@ -110,7 +106,7 @@ export async function addLocalProject(window: BrowserWindow | null): Promise { - return (await getConfig()).recentProjects + return (await configStore.get()).recentProjects } /** @@ -119,7 +115,7 @@ export async function getRecentProjects(): Promise { * @returns The updated recent projects list. */ export async function removeRecentProject(dir: string): Promise { - const updated = await updateConfig(config => { + const updated = await configStore.update(config => { config.recentProjects = config.recentProjects.filter(project => project.path !== dir) return undefined }) @@ -132,7 +128,7 @@ 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 updateConfig(config => { + const updated = await configStore.update(config => { config.recentProjects = [] return undefined }) @@ -145,7 +141,7 @@ export async function clearRecentProjects(): Promise { * @returns The preferences. */ export async function getPreferences(): Promise { - return (await getConfig()).preferences + return (await configStore.get()).preferences } /** @@ -154,7 +150,7 @@ export async function getPreferences(): Promise { * @returns The updated preferences. */ export async function setPreferences(preferences: Partial): Promise { - const updated = await updateConfig(config => { + const updated = await configStore.update(config => { config.preferences = { ...config.preferences, ...preferences, diff --git a/src/main/lib/store/index.ts b/src/main/lib/store/index.ts deleted file mode 100644 index 3ee43a6..0000000 --- a/src/main/lib/store/index.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises" -import path from "node:path" -import STORE_CONFIG from "@main/config/store" -import { app } from "electron" -import DEFAULT_APP_CONFIG from "@/main/config/app" -import type { AppConfig } from "@/main/types/store" - -/** - * The in-memory cache of the config, lazily loaded on first access so repeated - * reads do not hit the disk. - */ -let cache: AppConfig | null = null - -/** - * Serializes config updates so concurrent writers cannot race on the temp file - * or clobber each other's read-modify-write. - */ -let updateLock: Promise = Promise.resolve() - -/** - * A per-process counter used to give each write a unique temp file name. - */ -let writeCounter = 0 - -/** - * Resolves the absolute path to the directory holding the config file. - * @returns The absolute config directory path. - */ -function 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. - */ -function getConfigPath(): string { - return path.join(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`. - */ -function normalizeConfig(partial: Partial): AppConfig { - return { - version: STORE_CONFIG.configVersion, - preferences: { - ...DEFAULT_APP_CONFIG.preferences, - ...(partial.preferences ?? {}), - }, - 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. - */ -async function readFromDisk(): Promise { - try { - const raw = await readFile(getConfigPath(), "utf-8") - return normalizeConfig(JSON.parse(raw) as Partial) - } catch { - return structuredClone(DEFAULT_APP_CONFIG) - } -} - -/** - * Persists the config to disk atomically (write to a temp file, then rename) so - * a crash mid-write cannot corrupt the existing config. - * @param config The config to persist. - */ -async function saveConfig(config: AppConfig): Promise { - const target = getConfigPath() - const temp = `${target}.${process.pid}.${writeCounter++}.tmp` - - await mkdir(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. - */ -export async function getConfig(): Promise { - if (!cache) cache = await readFromDisk() - return 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. - */ -export function updateConfig(mutator: (config: AppConfig) => AppConfig | undefined): Promise { - // Chain onto the lock so concurrent updates run one at a time, each reading - // the result of the previous one - const run = updateLock.then(async () => { - const current = await getConfig() - - const draft = structuredClone(current) - const result = mutator(draft) ?? draft - - await saveConfig(result) - - cache = result - return result - }) - - // Keep the chain alive even if this update fails - updateLock = run.catch(() => undefined) - - return run -} diff --git a/src/main/lib/stores/config.ts b/src/main/lib/stores/config.ts new file mode 100644 index 0000000..9ec54ae --- /dev/null +++ b/src/main/lib/stores/config.ts @@ -0,0 +1,143 @@ +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises" +import path from "node:path" +import STORE_CONFIG from "@main/config/store" +import { app } from "electron" +import DEFAULT_APP_CONFIG from "@/main/config/app" +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. + */ +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 ?? {}), + }, + 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 + } +} + +/** + * The single app-wide config store, callers grab this rather than constructing + * their own. + */ +export const configStore = new ConfigStore() diff --git a/src/main/lib/stores/observable.ts b/src/main/lib/stores/observable.ts new file mode 100644 index 0000000..ccd8fc0 --- /dev/null +++ b/src/main/lib/stores/observable.ts @@ -0,0 +1,58 @@ +/** + * A minimal observable state container, holds a single mutable value and + * notifies its subscribers whenever it changes, subclass to add + * domain-specific helpers on top of the same shared quartet. + */ +export class ObservableStore { + /** + * The current state, the only source of truth the store carries. + */ + private _state: T + + /** + * The set of subscribers notified on every state transition. + */ + private _listeners = new Set<(next: T) => void>() + + constructor(initialState: T) { + this._state = initialState + } + + /** + * Reads the current state. + * @returns The current state. + */ + get(): T { + return this._state + } + + /** + * Notifies every subscriber of the current state without changing it, + * used to re-broadcast on demand when a caller needs the listeners poked + * without a real transition. + */ + emit() { + this._listeners.forEach(listener => { + listener(this._state) + }) + } + + /** + * Applies a partial patch to the state and notifies subscribers. + * @param patch The fields to change. + */ + set(patch: Partial) { + this._state = { ...this._state, ...patch } + this.emit() + } + + /** + * Subscribes to state transitions. + * @param listener The callback invoked with each new state. + * @returns A function that removes the subscription. + */ + subscribe(listener: (next: T) => void): () => void { + this._listeners.add(listener) + return () => this._listeners.delete(listener) + } +} diff --git a/src/main/lib/updater/handlers.ts b/src/main/lib/updater/handlers.ts index cd038f7..4476c2a 100644 --- a/src/main/lib/updater/handlers.ts +++ b/src/main/lib/updater/handlers.ts @@ -1,8 +1,9 @@ import CONSTANTS from "@main/lib/constants" import { safeHandle } from "@main/lib/ipc" import { openUpdateDialog } from "@main/lib/updater" -import { checkForUpdates, downloadUpdate, installUpdate, simulateUpdate } from "@main/lib/updater/service" -import { getUpdaterState } from "@main/lib/updater/store" +import { checkForUpdates, downloadUpdate, installUpdate } from "@main/lib/updater/service" +import { updaterSimulator } from "@main/lib/updater/simulator" +import { updaterStore } from "@main/lib/updater/store" import { ipcMain } from "electron" import type { UpdaterSimulation } from "@/main/types/updater" @@ -12,7 +13,7 @@ import type { UpdaterSimulation } from "@/main/types/updater" * the status bar, and trigger the restart that installs the staged version. */ export function registerUpdaterHandlers() { - safeHandle(CONSTANTS.ipc.updaterGetState, () => getUpdaterState()) + safeHandle(CONSTANTS.ipc.updaterGetState, () => updaterStore.get()) ipcMain.on(CONSTANTS.ipc.updaterOpenDialog, () => openUpdateDialog()) @@ -30,6 +31,6 @@ export function registerUpdaterHandlers() { ipcMain.on(CONSTANTS.ipc.updaterSimulate, (_event, scenario: UpdaterSimulation) => { openUpdateDialog() - simulateUpdate(scenario) + updaterSimulator.start(scenario) }) } diff --git a/src/main/lib/updater/index.ts b/src/main/lib/updater/index.ts index c5e8b45..36af200 100644 --- a/src/main/lib/updater/index.ts +++ b/src/main/lib/updater/index.ts @@ -1,16 +1,126 @@ import CONSTANTS from "@main/lib/constants" import { createDialogWindow, openDialog } from "@main/lib/dialogs" import { initUpdater } from "@main/lib/updater/service" -import { dismissAvailableVersion, isAvailableVersionDismissed, subscribeToUpdaterState } from "@main/lib/updater/store" +import { updaterStore } from "@main/lib/updater/store" import { getMainWindow } from "@main/lib/windows" import { BrowserWindow } from "electron" import type { UpdaterState, UpdaterStatus } from "@/main/types/updater" /** - * The open update dialog window, tracked so a second check cannot stack another - * one on top of it. + * Owns the update dialog window and the transitions that need cross-tick + * context (was-downloading, was-open), so the two used to live as a + * module-scoped `let` and a closure variable that could drift apart. */ -let updateWindow: BrowserWindow | null = null +class UpdateDialogController { + /** + * The open dialog window, `null` when no dialog is on screen. + */ + private _window: BrowserWindow | null = null + + /** + * The previously observed status, needed by the terminal surface so a + * `not-available`/`error` landing after `downloading` still gets told to + * the user even when they never opened the dialog. + */ + private _previousStatus: UpdaterStatus = "idle" + + /** + * Closes the dialog programmatically, without the close counting as a + * dismissal, so a result with nothing to act on can be handed over to a + * smaller dialog instead. + * @returns `true` when a dialog was actually open. + */ + private _close(): boolean { + if (!this._window || this._window.isDestroyed()) return false + + this._window.close() + this._window = null + + return true + } + + /** + * Hands a result that carries nothing to act on over to a plain dialog, + * the update window is sized for release notes and progress these states + * do not have, errors go through the error variant so they chime like any + * other failure. + * @param next The updater state that ended the flow. + * @param wasDownloading Whether the state interrupted a download the user started. + */ + private _surfaceTerminalResult(next: UpdaterState, wasDownloading: boolean) { + const wasDialogOpen = this._close() + + // Automatic checks stay silent, only a waiting user gets told + if (!wasDialogOpen && !wasDownloading) return + + if (next.status === "error") { + openDialog(getMainWindow(), { + variant: "error", + title: "Software Update", + message: next.error ?? "The update could not be checked.", + }) + + return + } + + openDialog(getMainWindow(), { + variant: "message", + title: "Software Update", + message: `GitGame is up to date.\n\nYou are running version ${next.currentVersion}, the latest one published.`, + }) + } + + /** + * Opens the update dialog, or focuses the one already open, parented to + * the main window but deliberately not modal so a download never blocks + * the app. + */ + open() { + if (this._window && !this._window.isDestroyed()) { + if (this._window.isMinimized()) this._window.restore() + this._window.focus() + + return + } + + this._window = createDialogWindow( + getMainWindow(), + { + variant: "update", + title: "Software Update", + }, + false, + ) + + // Closing the dialog on a merely available update counts as "not now", + // whichever way it was closed, so the periodic checks stop reopening + // it for that version + this._window.on("closed", () => { + this._window = null + updaterStore.dismissAvailableVersion() + }) + } + + /** + * Handles a store transition, opens the dialog on a fresh available + * version and surfaces terminal results when a flow ends. + * @param next The new updater state. + */ + onStateChange(next: UpdaterState) { + if (next.status === "available" && !updaterStore.isAvailableVersionDismissed()) this.open() + + if (next.status === "not-available" || next.status === "error") { + this._surfaceTerminalResult(next, this._previousStatus === "downloading") + } + + this._previousStatus = next.status + } +} + +/** + * The single app-wide update dialog controller. + */ +const updateDialogController = new UpdateDialogController() /** * Pushes the updater state to every open window, so both the dialog and the main @@ -25,76 +135,10 @@ function broadcastUpdaterState(next: UpdaterState) { } /** - * Opens the update dialog, or focuses the one already open. It is parented to the - * main window but deliberately not modal, so a download never blocks the app. + * Opens the update dialog, or focuses the one already open. */ export function openUpdateDialog() { - if (updateWindow && !updateWindow.isDestroyed()) { - if (updateWindow.isMinimized()) updateWindow.restore() - updateWindow.focus() - - return - } - - updateWindow = createDialogWindow( - getMainWindow(), - { - variant: "update", - title: "Software Update", - }, - false, - ) - - // Closing the dialog on a merely available update counts as "not now", whichever - // way it was closed, so the periodic checks stop reopening it for that version - updateWindow.on("closed", () => { - updateWindow = null - dismissAvailableVersion() - }) -} - -/** - * Closes the update dialog, without the close counting as a dismissal, so a result - * with nothing to act on can be handed over to a smaller dialog instead. - * @returns `true` when a dialog was actually open, meaning someone is waiting on the result. - */ -function closeUpdateDialog(): boolean { - if (!updateWindow || updateWindow.isDestroyed()) return false - - updateWindow.close() - updateWindow = null - - return true -} - -/** - * Hands a result that carries nothing to act on over to a plain dialog, since the - * update window is sized for release notes and progress that these states do not - * have, errors go through the error variant so they chime like any other failure. - * @param next The updater state that ended the flow. - * @param wasDownloading Whether the state interrupted a download the user started. - */ -function surfaceTerminalResult(next: UpdaterState, wasDownloading: boolean) { - const wasDialogOpen = closeUpdateDialog() - - // Automatic checks stay silent, only a waiting user gets told - if (!wasDialogOpen && !wasDownloading) return - - if (next.status === "error") { - openDialog(getMainWindow(), { - variant: "error", - title: "Software Update", - message: next.error ?? "The update could not be checked.", - }) - - return - } - - openDialog(getMainWindow(), { - variant: "message", - title: "Software Update", - message: `GitGame is up to date.\n\nYou are running version ${next.currentVersion}, the latest one published.`, - }) + updateDialogController.open() } /** @@ -102,18 +146,9 @@ function surfaceTerminalResult(next: UpdaterState, wasDownloading: boolean) { * dialog whenever a new version turns up. */ export function startUpdater() { - let previousStatus: UpdaterStatus = "idle" - - subscribeToUpdaterState(next => { + updaterStore.subscribe(next => { broadcastUpdaterState(next) - - if (next.status === "available" && !isAvailableVersionDismissed()) openUpdateDialog() - - if (next.status === "not-available" || next.status === "error") { - surfaceTerminalResult(next, previousStatus === "downloading") - } - - previousStatus = next.status + updateDialogController.onStateChange(next) }) initUpdater() diff --git a/src/main/lib/updater/service.ts b/src/main/lib/updater/service.ts index 4a5a2d7..484904a 100644 --- a/src/main/lib/updater/service.ts +++ b/src/main/lib/updater/service.ts @@ -1,70 +1,18 @@ import UPDATER_CONFIG from "@main/config/updater" import CONSTANTS from "@main/lib/constants" import { normalizeReleaseNotes, parseDownloadProgress } from "@main/lib/updater/parse" -import { buildSimulatedProgress, buildSimulatedState } from "@main/lib/updater/simulation" -import { - CLEARED_RELEASE_FIELDS, - clearDismissedVersion, - emitUpdaterState, - getUpdaterState, - isUpdaterBusy, - setUpdaterState, -} from "@main/lib/updater/store" +import { updaterSimulator } from "@main/lib/updater/simulator" +import { CLEARED_RELEASE_FIELDS, updaterStore } from "@main/lib/updater/store" import { convertErrorToMessage } from "@main/lib/utils/errors" import { app } from "electron" import { autoUpdater, type ProgressInfo, type UpdateInfo } from "electron-updater" -import type { UpdaterSimulation } from "@/main/types/updater" - -/** - * The fake flow currently being driven from the `Dev Tools` menu, if any. While - * set, the real `autoUpdater` is never called. - */ -let simulation: UpdaterSimulation | null = null - -/** - * The interval stepping a simulated download forward. - */ -let simulationTimer: NodeJS.Timeout | null = null - -/** - * Stops any simulated download still stepping forward. - */ -function stopSimulationTimer() { - if (!simulationTimer) return - - clearInterval(simulationTimer) - simulationTimer = null -} - -/** - * Steps a fake download to completion so the progress bar and the restart prompt - * can be exercised without a real release. - */ -function runSimulatedDownload() { - let percent = 0 - - stopSimulationTimer() - - simulationTimer = setInterval(() => { - percent = Math.min(100, percent + UPDATER_CONFIG.simulatedDownload.tickPercent) - - if (percent >= 100) { - stopSimulationTimer() - setUpdaterState({ status: "downloaded", progress: null }) - - return - } - - setUpdaterState({ status: "downloading", progress: buildSimulatedProgress(percent) }) - }, UPDATER_CONFIG.simulatedDownload.tickMs) -} /** * Mirrors the `autoUpdater` lifecycle onto the updater state. */ function attachAutoUpdaterEvents() { autoUpdater.on("update-available", (info: UpdateInfo) => { - setUpdaterState({ + updaterStore.set({ status: "available", version: info.version, releaseNotes: normalizeReleaseNotes(info.releaseNotes), @@ -74,20 +22,35 @@ function attachAutoUpdaterEvents() { }) autoUpdater.on("update-not-available", () => { - setUpdaterState({ status: "not-available", ...CLEARED_RELEASE_FIELDS, error: null }) + updaterStore.set({ + status: "not-available", + ...CLEARED_RELEASE_FIELDS, + error: null, + }) }) autoUpdater.on("download-progress", (info: ProgressInfo) => { - setUpdaterState({ status: "downloading", progress: parseDownloadProgress(info) }) + updaterStore.set({ + status: "downloading", + progress: parseDownloadProgress(info), + }) }) autoUpdater.on("update-downloaded", (info: UpdateInfo) => { - setUpdaterState({ status: "downloaded", version: info.version, progress: null }) + updaterStore.set({ + status: "downloaded", + version: info.version, + progress: null, + }) }) autoUpdater.on("error", error => { console.error("[updater] error:", error) - setUpdaterState({ status: "error", error: convertErrorToMessage(error), progress: null }) + updaterStore.set({ + status: "error", + error: convertErrorToMessage(error), + progress: null, + }) }) } @@ -98,18 +61,18 @@ function attachAutoUpdaterEvents() { * @param isManualCheck Whether the user asked for this check. */ export async function checkForUpdates(isManualCheck = false) { - if (simulation) { - emitUpdaterState() + if (updaterSimulator.isActive()) { + updaterStore.emit() return } - if (isUpdaterBusy()) { - if (isManualCheck) emitUpdaterState() + if (updaterStore.isBusy()) { + if (isManualCheck) updaterStore.emit() return } if (!app.isPackaged) { - setUpdaterState({ + updaterStore.set({ status: "error", ...CLEARED_RELEASE_FIELDS, error: CONSTANTS.updater.devCheckMessage, @@ -118,7 +81,7 @@ export async function checkForUpdates(isManualCheck = false) { return } - setUpdaterState({ + updaterStore.set({ status: "checking", ...CLEARED_RELEASE_FIELDS, error: null, @@ -138,10 +101,10 @@ export async function checkForUpdates(isManualCheck = false) { * call while a download is running is a no-op. */ export async function downloadUpdate() { - const state = getUpdaterState() + const state = updaterStore.get() if (state.status !== "available" || !state.canAutoInstall) return - setUpdaterState({ + updaterStore.set({ status: "downloading", progress: { percent: 0, @@ -151,8 +114,8 @@ export async function downloadUpdate() { }, }) - if (simulation) { - runSimulatedDownload() + if (updaterSimulator.isActive()) { + updaterSimulator.runDownload() return } @@ -168,9 +131,9 @@ export async function downloadUpdate() { * triggered it can unwind before the app goes away. */ export function installUpdate() { - if (getUpdaterState().status !== "downloaded") return + if (updaterStore.get().status !== "downloaded") return - if (simulation) { + if (updaterSimulator.isActive()) { console.log("[updater] simulated install, skipping the real restart") return } @@ -178,23 +141,6 @@ export function installUpdate() { setImmediate(() => autoUpdater.quitAndInstall()) } -/** - * Drives a fake updater flow from the `Dev Tools` menu, ignored in packaged - * builds so it can never interfere with a real update. - * @param scenario The flow to simulate. - */ -export function simulateUpdate(scenario: UpdaterSimulation) { - if (app.isPackaged) return - - stopSimulationTimer() - simulation = scenario - - // A fresh simulated version is never treated as already dismissed - clearDismissedVersion() - - setUpdaterState(buildSimulatedState(scenario, new Date().toISOString())) -} - /** * Configures the auto-updater to hand every decision to the user, then schedules * the periodic background checks in packaged builds. @@ -203,7 +149,7 @@ export function initUpdater() { autoUpdater.autoDownload = false autoUpdater.autoInstallOnAppQuit = true - setUpdaterState({ currentVersion: app.getVersion() }) + updaterStore.set({ currentVersion: app.getVersion() }) attachAutoUpdaterEvents() if (!app.isPackaged) return diff --git a/src/main/lib/updater/simulation.ts b/src/main/lib/updater/simulation.ts index 3384764..bac89cf 100644 --- a/src/main/lib/updater/simulation.ts +++ b/src/main/lib/updater/simulation.ts @@ -19,13 +19,11 @@ const SIMULATED_RELEASE_NOTES = [ * @returns The progress snapshot. */ export function buildSimulatedProgress(percent: number): UpdaterDownloadProgress { - const total = UPDATER_CONFIG.simulatedDownload.totalBytes - return { percent, - transferred: Math.round((total * percent) / 100), - total, - bytesPerSecond: total / 20, + transferred: Math.round((UPDATER_CONFIG.simulatedDownload.totalBytes * percent) / 100), + total: UPDATER_CONFIG.simulatedDownload.totalBytes, + bytesPerSecond: UPDATER_CONFIG.simulatedDownload.totalBytes / 20, } } diff --git a/src/main/lib/updater/simulator.ts b/src/main/lib/updater/simulator.ts new file mode 100644 index 0000000..0c4eec8 --- /dev/null +++ b/src/main/lib/updater/simulator.ts @@ -0,0 +1,91 @@ +import UPDATER_CONFIG from "@main/config/updater" +import { buildSimulatedProgress, buildSimulatedState } from "@main/lib/updater/simulation" +import { updaterStore } from "@main/lib/updater/store" +import { app } from "electron" +import type { UpdaterSimulation } from "@/main/types/updater" + +/** + * The fake updater flows driven from the `Dev Tools` menu, self-contained + * state machine so the real `autoUpdater` path can check `isActive()` and + * bail without touching a hidden `let` in the service. + */ +export class UpdaterSimulator { + /** + * The fake flow currently being driven, `null` when no simulation is on. + */ + private _scenario: UpdaterSimulation | null = null + + /** + * The interval stepping a simulated download forward. + */ + private _timer: NodeJS.Timeout | null = null + + /** + * Stops any simulated download still stepping forward. + */ + private _stopTimer() { + if (!this._timer) return + + clearInterval(this._timer) + this._timer = null + } + + /** + * Whether a simulated flow is currently on, callers use this to skip the + * real `autoUpdater` path. + * @returns `true` while a simulation is running. + */ + isActive(): boolean { + return this._scenario !== null + } + + /** + * Drives a fake updater flow from the `Dev Tools` menu, ignored in + * packaged builds so it can never interfere with a real update. + * @param scenario The flow to simulate. + */ + start(scenario: UpdaterSimulation) { + if (app.isPackaged) return + + this._stopTimer() + this._scenario = scenario + + // A fresh simulated version is never treated as already dismissed + updaterStore.clearDismissedVersion() + updaterStore.set(buildSimulatedState(scenario, new Date().toISOString())) + } + + /** + * Steps a fake download to completion so the progress bar and the restart + * prompt can be exercised without a real release. + */ + runDownload() { + let percent = 0 + + this._stopTimer() + + this._timer = setInterval(() => { + percent = Math.min(100, percent + UPDATER_CONFIG.simulatedDownload.tickPercent) + + if (percent >= 100) { + this._stopTimer() + updaterStore.set({ + status: "downloaded", + progress: null, + }) + + return + } + + updaterStore.set({ + status: "downloading", + progress: buildSimulatedProgress(percent), + }) + }, UPDATER_CONFIG.simulatedDownload.tickMs) + } +} + +/** + * The single app-wide updater simulator, driven by the Dev Tools menu. + */ +export const updaterSimulator = new UpdaterSimulator() diff --git a/src/main/lib/updater/store.ts b/src/main/lib/updater/store.ts index 4cf5b01..9cc8acf 100644 --- a/src/main/lib/updater/store.ts +++ b/src/main/lib/updater/store.ts @@ -1,4 +1,5 @@ import UPDATER_CONFIG from "@main/config/updater" +import { ObservableStore } from "@main/lib/stores/observable" import type { UpdaterState } from "@/main/types/updater" /** @@ -31,91 +32,61 @@ function createInitialState(): UpdaterState { } /** - * The single source of truth for the updater, owned by the main process so the - * dialog can be closed and reopened without losing where the update got to. + * The updater's observable state store, extends the base with dismissal + * tracking and a busy predicate specific to the update lifecycle. */ -let state: UpdaterState = createInitialState() +export class UpdaterStore extends ObservableStore { + /** + * The version whose dialog the user closed without acting, so the periodic + * checks stop reopening it, cleared implicitly once the feed offers a + * different version. + */ + private _dismissedVersion: string | null = null -/** - * The subscribers notified on every state transition. - */ -const listeners = new Set<(next: UpdaterState) => void>() - -/** - * The version whose dialog the user closed without acting, so the periodic checks - * stop reopening it. Cleared implicitly once the feed offers a different version. - */ -let dismissedVersion: string | null = null - -/** - * Reads the current updater state. - * @returns The current state. - */ -export function getUpdaterState(): UpdaterState { - return state -} - -/** - * Notifies every subscriber of the current state, without changing it. - */ -export function emitUpdaterState() { - listeners.forEach(listener => { - listener(state) - }) -} - -/** - * Applies a partial transition to the updater state and notifies subscribers. - * @param patch The fields to change. - */ -export function setUpdaterState(patch: Partial) { - state = { ...state, ...patch } - emitUpdaterState() -} - -/** - * Subscribes to updater state transitions. - * @param listener The callback invoked with each new state. - * @returns A function that removes the subscription. - */ -export function subscribeToUpdaterState(listener: (next: UpdaterState) => void): () => void { - listeners.add(listener) + constructor() { + super(createInitialState()) + } - return () => { - listeners.delete(listener) + /** + * Whether a check or a download is already under way, in which case + * starting another one would stack dialogs and race two writers onto the + * same partial installer. + * @returns `true` while the updater is mid-flight. + */ + isBusy(): boolean { + const current = this.get() + return current.status === "checking" || current.status === "downloading" || current.status === "downloaded" } -} -/** - * Whether a check or a download is already under way, in which case starting - * another one would stack dialogs and race two writers onto the same partial - * installer. - * @returns `true` while the updater is mid-flight. - */ -export function isUpdaterBusy(): boolean { - return state.status === "checking" || state.status === "downloading" || state.status === "downloaded" -} + /** + * Records the pending version as dismissed, so the periodic checks stop + * reopening the dialog for it, only meaningful while an update is merely + * `available` since a downloaded one is surfaced by the status bar instead. + */ + dismissAvailableVersion() { + const current = this.get() + if (current.status === "available" && current.version) this._dismissedVersion = current.version + } -/** - * Records the pending version as dismissed, so the periodic checks stop reopening - * the dialog for it. Only meaningful while an update is merely `available`, since a - * download that already ran is surfaced by the status bar instead. - */ -export function dismissAvailableVersion() { - if (state.status === "available" && state.version) dismissedVersion = state.version -} + /** + * Whether the pending version is one the user already closed the dialog on. + * @returns `true` when the dialog should not be reopened on its own. + */ + isAvailableVersionDismissed(): boolean { + const current = this.get() + return current.version !== null && current.version === this._dismissedVersion + } -/** - * Whether the pending version is one the user already closed the dialog on. - * @returns `true` when the dialog should not be reopened on its own. - */ -export function isAvailableVersionDismissed(): boolean { - return state.version !== null && state.version === dismissedVersion + /** + * Forgets any dismissal, so the next available version prompts again. + */ + clearDismissedVersion() { + this._dismissedVersion = null + } } /** - * Forgets any dismissal, so the next available version prompts again. + * The single app-wide updater store, subscribed to by every open window so the + * dialog and status bar follow the same transitions. */ -export function clearDismissedVersion() { - dismissedVersion = null -} +export const updaterStore = new UpdaterStore() From 3894ce7e552377d25131d09464f1eb6157c6c0e2 Mon Sep 17 00:00:00 2001 From: yoratoni Date: Wed, 5 Aug 2026 00:04:54 +0200 Subject: [PATCH 02/12] feat: added an Unreal Engine MCP client that auto-locks the assets being edited Added a streamable-HTTP JSON-RPC client for the local Unreal MCP, covering the initialize handshake and its session header, both the plain JSON and SSE response modes, a hard per-request timeout so a hung editor cannot stall the loop, and a wrapper around the call_tool dispatcher the editor toolsets sit behind. A probe loop keeps the connection state fresh every 5 seconds, and a reconcile loop runs every 10 seconds while a project is open: it asks the editor for its dirty asset tabs and current level, maps the /Game virtual paths onto their Content .uasset and .umap counterparts, merges that with the lockable files git already reports as modified, then diffs the result against the locks we own and applies only the delta. Both loops skip a tick while the previous one is still in flight, and both broadcast their state to every window, so the two new status bar chips follow the same transitions with a countdown bar to the next tick, the auto-lock one opening the per-file failures on click or forcing a reconcile when there are none. --- src/main/config/autoLock.ts | 8 + src/main/config/mcp.ts | 13 + src/main/index.ts | 13 + src/main/lib/autoLock/handlers.ts | 27 ++ src/main/lib/autoLock/index.ts | 28 ++ src/main/lib/autoLock/paths.ts | 34 ++ src/main/lib/autoLock/reconcile.ts | 54 ++++ src/main/lib/autoLock/service.ts | 119 +++++++ src/main/lib/autoLock/targets.ts | 50 +++ src/main/lib/constants.ts | 53 +++- src/main/lib/mcp/client.ts | 294 ++++++++++++++++++ src/main/lib/mcp/handlers.ts | 21 ++ src/main/lib/mcp/index.ts | 26 ++ src/main/lib/mcp/service.ts | 106 +++++++ src/main/lib/mcp/store.ts | 23 ++ src/main/lib/mcp/tools.ts | 98 ++++++ src/main/lib/stores/autoLock.ts | 27 ++ src/main/types/autoLock.d.ts | 28 ++ src/main/types/mcp.d.ts | 39 +++ src/preload/index.ts | 26 +- src/preload/routes/autoLock.ts | 20 ++ src/preload/routes/mcp.ts | 20 ++ src/renderer/components/contexts/Project.tsx | 22 +- .../components/fields/StatusAutoLock.tsx | 81 +++++ src/renderer/components/fields/StatusMcp.tsx | 63 ++++ .../components/ui/TileProgressBar.tsx | 47 +++ src/renderer/hooks/useAutoLockState.tsx | 28 ++ src/renderer/hooks/useMcpState.tsx | 28 ++ src/renderer/lib/constants.ts | 33 ++ src/renderer/lib/utils/autoLock.ts | 79 +++++ src/renderer/lib/utils/mcp.ts | 34 ++ 31 files changed, 1530 insertions(+), 12 deletions(-) create mode 100644 src/main/config/autoLock.ts create mode 100644 src/main/config/mcp.ts create mode 100644 src/main/lib/autoLock/handlers.ts create mode 100644 src/main/lib/autoLock/index.ts create mode 100644 src/main/lib/autoLock/paths.ts create mode 100644 src/main/lib/autoLock/reconcile.ts create mode 100644 src/main/lib/autoLock/service.ts create mode 100644 src/main/lib/autoLock/targets.ts create mode 100644 src/main/lib/mcp/client.ts create mode 100644 src/main/lib/mcp/handlers.ts create mode 100644 src/main/lib/mcp/index.ts create mode 100644 src/main/lib/mcp/service.ts create mode 100644 src/main/lib/mcp/store.ts create mode 100644 src/main/lib/mcp/tools.ts create mode 100644 src/main/lib/stores/autoLock.ts create mode 100644 src/main/types/autoLock.d.ts create mode 100644 src/main/types/mcp.d.ts create mode 100644 src/preload/routes/autoLock.ts create mode 100644 src/preload/routes/mcp.ts create mode 100644 src/renderer/components/fields/StatusAutoLock.tsx create mode 100644 src/renderer/components/fields/StatusMcp.tsx create mode 100644 src/renderer/components/ui/TileProgressBar.tsx create mode 100644 src/renderer/hooks/useAutoLockState.tsx create mode 100644 src/renderer/hooks/useMcpState.tsx create mode 100644 src/renderer/lib/utils/autoLock.ts create mode 100644 src/renderer/lib/utils/mcp.ts diff --git a/src/main/config/autoLock.ts b/src/main/config/autoLock.ts new file mode 100644 index 0000000..37c9163 --- /dev/null +++ b/src/main/config/autoLock.ts @@ -0,0 +1,8 @@ +/** + * The configuration for the auto-lock loop. + */ +const AUTO_LOCK_CONFIG = { + tickIntervalMs: 10 * 1000, +} + +export default AUTO_LOCK_CONFIG diff --git a/src/main/config/mcp.ts b/src/main/config/mcp.ts new file mode 100644 index 0000000..5e0d622 --- /dev/null +++ b/src/main/config/mcp.ts @@ -0,0 +1,13 @@ +/** + * The configuration for the Unreal Engine MCP client. + */ +const MCP_CONFIG = { + endpoint: "http://127.0.0.1:8000/mcp", + protocolVersion: "2025-06-18", + clientName: "gitgame", + probeIntervalMs: 5 * 1000, + requestTimeoutMs: 5 * 1000, + minConnectingDisplayMs: 500, +} + +export default MCP_CONFIG diff --git a/src/main/index.ts b/src/main/index.ts index 6f36a49..89ff7d4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,10 +1,14 @@ import path from "node:path" 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 { 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 { registerProjectsHandlers } from "@main/lib/projects/handlers" import { registerShellsHandlers } from "@main/lib/shells/handlers" import { startUpdater } from "@main/lib/updater" @@ -48,6 +52,7 @@ function createMainWindow(): BrowserWindow { // Create the main application window when Electron is ready app.whenReady().then(() => { + // Handlers registerAppHandlers() registerWindowsControlHandlers() registerDialogsHandlers() @@ -58,8 +63,16 @@ app.whenReady().then(() => { registerUProjectHandlers() registerShellsHandlers() registerUpdaterHandlers() + registerMcpHandlers() + registerAutoLockHandlers() + + // Create the main window createMainWindow() + + // Start services startUpdater() + startMcp() + startAutoLock() // Re-create a window in the app when the dock icon is clicked (macOS) app.on("activate", () => { diff --git a/src/main/lib/autoLock/handlers.ts b/src/main/lib/autoLock/handlers.ts new file mode 100644 index 0000000..dbfc5c3 --- /dev/null +++ b/src/main/lib/autoLock/handlers.ts @@ -0,0 +1,27 @@ +import { reconcileAutoLock } from "@main/lib/autoLock/reconcile" +import { autoLockService } from "@main/lib/autoLock/service" +import { collectAutoLockTargets } from "@main/lib/autoLock/targets" +import CONSTANTS from "@main/lib/constants" +import { safeHandle } from "@main/lib/ipc" +import { getEditorActivity } from "@main/lib/mcp/tools" +import { autoLockStore } from "@main/lib/stores/autoLock" + +/** + * Registers the IPC handlers that expose the auto-lock pipeline to the + * renderer, covers the dry-run preview, the one-shot reconcile, and the + * lifecycle handles for the periodic loop. + */ +export function registerAutoLockHandlers() { + safeHandle(CONSTANTS.ipc.autoLockPreviewTargets, async (_event, dir: string) => { + const activity = await getEditorActivity() + const targets = await collectAutoLockTargets(activity, dir) + + return [...targets] + }) + + safeHandle(CONSTANTS.ipc.autoLockReconcile, (_event, dir: string) => reconcileAutoLock(dir)) + + safeHandle(CONSTANTS.ipc.autoLockGetState, () => autoLockStore.get()) + safeHandle(CONSTANTS.ipc.autoLockStart, (_event, dir: string) => autoLockService.start(dir)) + safeHandle(CONSTANTS.ipc.autoLockStop, () => autoLockService.stop()) +} diff --git a/src/main/lib/autoLock/index.ts b/src/main/lib/autoLock/index.ts new file mode 100644 index 0000000..e5f5761 --- /dev/null +++ b/src/main/lib/autoLock/index.ts @@ -0,0 +1,28 @@ +import { autoLockService } from "@main/lib/autoLock/service" +import CONSTANTS from "@main/lib/constants" +import { autoLockStore } from "@main/lib/stores/autoLock" +import { BrowserWindow } from "electron" +import type { AutoLockState } from "@/main/types/autoLock" + +/** + * Pushes the auto-lock state to every open window, so the status chip and any + * manual controls follow the same transitions the loop records. + * @param next The new auto-lock state. + */ +function broadcastAutoLockState(next: AutoLockState) { + BrowserWindow.getAllWindows().forEach(window => { + if (window.isDestroyed()) return + window.webContents.send(CONSTANTS.ipc.autoLockStateChanged, next) + }) +} + +/** + * Starts the auto-lock subsystem, subscribes the store to the broadcast so + * every window follows the loop, the service itself is started by the + * renderer through the project lifecycle. + */ +export function startAutoLock() { + autoLockStore.subscribe(broadcastAutoLockState) +} + +export { autoLockService } diff --git a/src/main/lib/autoLock/paths.ts b/src/main/lib/autoLock/paths.ts new file mode 100644 index 0000000..cc4972c --- /dev/null +++ b/src/main/lib/autoLock/paths.ts @@ -0,0 +1,34 @@ +import CONSTANTS from "@main/lib/constants" + +/** + * Converts a UE virtual content path into a repo-relative disk path with the + * given file extension, `null` for paths outside `/Game/` (plugin and engine + * roots are not covered by this first pass). + * @param virtualPath The UE virtual path like `/Game/Blueprints/BP_Hero`. + * @param extension The disk-file extension to append. + * @returns The repo-relative path with forward-slash separators, or `null`. + */ +function virtualToDisk(virtualPath: string, extension: string): string | null { + if (!virtualPath.startsWith(CONSTANTS.mcp.gamePrefix)) return null + + const relative = virtualPath.slice(CONSTANTS.mcp.gamePrefix.length) + return `${CONSTANTS.mcp.contentDir}/${relative}${extension}` +} + +/** + * Resolves a UE asset's virtual path to its repo-relative `.uasset` on disk. + * @param virtualPath The virtual path returned by `GetOpenAssets` or `is_dirty`. + * @returns The repo-relative disk path, or `null` when outside `/Game/`. + */ +export function resolveAssetDiskPath(virtualPath: string): string | null { + return virtualToDisk(virtualPath, CONSTANTS.mcp.assetExtension) +} + +/** + * Resolves a UE level's virtual path to its repo-relative `.umap` on disk. + * @param virtualPath The virtual path returned by `get_current_level`. + * @returns The repo-relative disk path, or `null` when outside `/Game/`. + */ +export function resolveLevelDiskPath(virtualPath: string): string | null { + return virtualToDisk(virtualPath, CONSTANTS.mcp.levelExtension) +} diff --git a/src/main/lib/autoLock/reconcile.ts b/src/main/lib/autoLock/reconcile.ts new file mode 100644 index 0000000..0763057 --- /dev/null +++ b/src/main/lib/autoLock/reconcile.ts @@ -0,0 +1,54 @@ +import { collectAutoLockTargets } from "@main/lib/autoLock/targets" +import { listLocks, lockPaths, unlockPaths } from "@main/lib/lfsCommands/service" +import { getEditorActivity } from "@main/lib/mcp/tools" +import type { AutoLockReconcileResult } from "@/main/types/autoLock" + +/** + * Splits the target set against the paths we already hold locks on into the + * lock and unlock deltas, targets not yet locked go into `toLock` and locks + * whose file is no longer a target go into `toUnlock`. + * @param targets The disk paths the current activity says should be locked. + * @param mineLocked The disk paths we already own an LFS lock for. + * @returns The delta sets to apply. + */ +function diffLockSets(targets: Set, mineLocked: Set): { toLock: string[]; toUnlock: string[] } { + const toLock: string[] = [] + for (const path of targets) { + if (!mineLocked.has(path)) toLock.push(path) + } + + const toUnlock: string[] = [] + for (const path of mineLocked) { + if (!targets.has(path)) toUnlock.push(path) + } + + return { toLock, toUnlock } +} + +/** + * Runs a single auto-lock reconcile pass: reads the editor activity, builds + * the target set, diffs it against the caller's existing LFS locks, then locks + * the new targets and unlocks anything that is no longer in the set. + * @param dir A path inside the repository. + * @returns The target set, and the results of both the lock and unlock calls. + */ +export async function reconcileAutoLock(dir: string): Promise { + const activity = await getEditorActivity() + const targets = await collectAutoLockTargets(activity, dir) + + const allLocks = await listLocks(dir) + const mineLocked = new Set(allLocks.filter(lock => lock.isMine).map(lock => lock.path)) + + const { toLock, toUnlock } = diffLockSets(targets, mineLocked) + + const [locked, unlocked] = await Promise.all([ + toLock.length > 0 ? lockPaths(dir, toLock) : Promise.resolve([]), + toUnlock.length > 0 ? unlockPaths(dir, toUnlock) : Promise.resolve([]), + ]) + + return { + targets: [...targets], + locked, + unlocked, + } +} diff --git a/src/main/lib/autoLock/service.ts b/src/main/lib/autoLock/service.ts new file mode 100644 index 0000000..049cbc2 --- /dev/null +++ b/src/main/lib/autoLock/service.ts @@ -0,0 +1,119 @@ +import AUTO_LOCK_CONFIG from "@main/config/autoLock" +import { reconcileAutoLock } from "@main/lib/autoLock/reconcile" +import { autoLockStore } from "@main/lib/stores/autoLock" +import { convertErrorToMessage } from "@main/lib/utils/errors" +import type { AutoLockReconcileResult } from "@/main/types/autoLock" + +/** + * The periodic auto-lock lifecycle owner, drives `reconcileAutoLock` on a + * timer, updates the store around each tick, and guards against overlapping + * calls so a slow reconcile cannot stack a second one on top. + */ +export class AutoLockService { + /** + * The current tick timer, held so restarting cannot leave a second one + * running in the background. + */ + private _timer: NodeJS.Timeout | null = null + + /** + * Whether a reconcile is currently in flight, guards the periodic timer + * from stacking a second pass on top of one that has not landed yet. + */ + private _inFlight = false + + /** + * The repository directory the loop is bound to, `null` when stopped. + */ + private _dir: string | null = null + + /** + * 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 + * flight. + * @returns The reconcile result, or `null` when the tick was skipped. + */ + private async _tick(): Promise { + if (this._inFlight || !this._dir) return null + this._inFlight = true + autoLockStore.set({ isReconciling: true }) + + try { + const result = await reconcileAutoLock(this._dir) + const failures = [...result.locked, ...result.unlocked].filter(item => !item.ok) + + autoLockStore.set({ + lastReconciledAt: new Date().toISOString(), + lockedCount: result.targets.length, + failures, + lastError: null, + }) + + return result + } catch (error) { + autoLockStore.set({ + lastError: convertErrorToMessage(error), + lastReconciledAt: new Date().toISOString(), + }) + + return null + } finally { + this._inFlight = false + autoLockStore.set({ isReconciling: false }) + } + } + + /** + * Runs a reconcile pass on demand, mostly a hook for a "reconcile now" + * button and the initial tick after `start`. + * @returns The reconcile result, or `null` when the tick was skipped. + */ + reconcile(): Promise { + return this._tick() + } + + /** + * 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. + * @param dir A path inside the repository to reconcile against. + */ + start(dir: string) { + if (this._timer && this._dir === dir) return + + this.stop() + this._dir = dir + autoLockStore.set({ + enabled: true, + dir, + lastError: null, + }) + + this._tick() + this._timer = setInterval(() => this._tick(), AUTO_LOCK_CONFIG.tickIntervalMs) + } + + /** + * Stops the periodic loop, called on project close or app quit so the + * loop does not race with a project switch. + */ + stop() { + if (this._timer) { + clearInterval(this._timer) + this._timer = null + } + + this._dir = null + autoLockStore.set({ + enabled: false, + dir: null, + isReconciling: false, + }) + } +} + +/** + * The single app-wide auto-lock service, driven by the renderer through the + * project lifecycle. + */ +export const autoLockService = new AutoLockService() diff --git a/src/main/lib/autoLock/targets.ts b/src/main/lib/autoLock/targets.ts new file mode 100644 index 0000000..3ef42ce --- /dev/null +++ b/src/main/lib/autoLock/targets.ts @@ -0,0 +1,50 @@ +import { resolveAssetDiskPath, resolveLevelDiskPath } from "@main/lib/autoLock/paths" +import { getStatus } from "@main/lib/gitCommands/service" +import { filterLockable } from "@main/lib/lfsCommands/service" +import type { EditorActivity } from "@/main/types/mcp" + +/** + * Pulls the LFS-lockable subset of files currently modified in the working + * tree, catches on-disk changes the MCP does not see (OFPA external actors + * once UE has saved them, plus anything already saved and staged). + * @param dir A path inside the repository. + * @returns The repo-relative paths of dirty, lockable files. + */ +async function collectDirtyLockableFromGit(dir: string): Promise { + const status = await getStatus(dir) + const changed = status.changes + .filter(change => !change.isUntracked && !change.isConflicted) + .map(change => change.path) + + if (changed.length === 0) return [] + + return filterLockable(dir, changed) +} + +/** + * Builds the set of repo-relative disk paths that should be locked, merges the + * MCP editor activity (current level, dirty asset editor tabs) with what git + * already sees as modified in the working tree. + * @param activity The editor activity snapshot from the MCP. + * @param dir A path inside the repository, used to read the working tree. + * @returns The set of disk paths that should be under an active LFS lock. + */ +export async function collectAutoLockTargets(activity: EditorActivity, dir: string): Promise> { + const targets = new Set() + + if (activity.currentLevel) { + const levelDiskPath = resolveLevelDiskPath(activity.currentLevel) + if (levelDiskPath) targets.add(levelDiskPath) + } + + for (const assetPath of activity.dirtyAssets) { + const assetDiskPath = resolveAssetDiskPath(assetPath) + if (assetDiskPath) targets.add(assetDiskPath) + } + + for (const filePath of await collectDirtyLockableFromGit(dir)) { + targets.add(filePath) + } + + return targets +} diff --git a/src/main/lib/constants.ts b/src/main/lib/constants.ts index 1a3bc46..6e48cf7 100644 --- a/src/main/lib/constants.ts +++ b/src/main/lib/constants.ts @@ -8,6 +8,7 @@ const CONSTANTS = { ipc: { // App appGetVersion: "app:get-version", + appConsumeFirstLoad: "app:consume-first-load", // Windows windowsGetState: "windows:get-state", windowsStateChanged: "windows:state-changed", @@ -60,6 +61,20 @@ const CONSTANTS = { updaterInstall: "updater:install", updaterOpenDialog: "updater:open-dialog", updaterSimulate: "updater:simulate", + // MCP + mcpGetState: "mcp:get-state", + mcpStateChanged: "mcp:state-changed", + mcpProbe: "mcp:probe", + mcpGetEditorActivity: "mcp:get-editor-activity", + mcpListTools: "mcp:list-tools", + mcpCallTool: "mcp:call-tool", + // Auto-lock + autoLockPreviewTargets: "auto-lock:preview-targets", + autoLockReconcile: "auto-lock:reconcile", + autoLockGetState: "auto-lock:get-state", + autoLockStateChanged: "auto-lock:state-changed", + autoLockStart: "auto-lock:start", + autoLockStop: "auto-lock:stop", }, updater: { devCheckMessage: @@ -77,11 +92,9 @@ const CONSTANTS = { noTerminalMessage: "No terminal could be launched, none of the ones we know about are installed or on PATH.", /** - * The terminals tried in order when opening a folder in a terminal, the first one that - * launches wins. - * - * Note: Linux has no standard terminal, hence the list, `x-terminal-emulator` comes - * first because it is the Debian alternatives symlink pointing at the user's choice. + * 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. */ terminals: { win32: [ @@ -105,6 +118,36 @@ const CONSTANTS = { logRecordSeparator: "\x1e", logFormat: ["%H", "%h", "%s", "%an", "%ae", "%aI"], // Joined with field separator and ends with record separator }, + mcp: { + sessionHeader: "mcp-session-id", + scratchLevelPrefix: "/Temp/", + gamePrefix: "/Game/", + contentDir: "Content", + assetExtension: ".uasset", + levelExtension: ".umap", + // The Unreal MCP exposes editor tools through a `call_tool` dispatcher rather + // than as first-class MCP tools, so each entry pairs the toolset owning it with + // its short name + dispatcher: "call_tool", + tools: { + getOpenAssets: { + toolset: "EditorToolset.EditorAppToolset", + name: "GetOpenAssets", + }, + getCurrentLevel: { + toolset: "editor_toolset.toolsets.scene.SceneTools", + name: "get_current_level", + }, + isDirty: { + toolset: "editor_toolset.toolsets.asset.AssetTools", + name: "is_dirty", + }, + getPluginContentPaths: { + toolset: "editor_toolset.toolsets.asset.AssetTools", + name: "get_plugin_content_paths", + }, + }, + }, } as const export default CONSTANTS diff --git a/src/main/lib/mcp/client.ts b/src/main/lib/mcp/client.ts new file mode 100644 index 0000000..d1a9cb7 --- /dev/null +++ b/src/main/lib/mcp/client.ts @@ -0,0 +1,294 @@ +import MCP_CONFIG from "@main/config/mcp" +import CONSTANTS from "@main/lib/constants" +import { app } from "electron" +import type { McpServerInfo } from "@/main/types/mcp" + +/** + * A JSON-RPC error surfaced by the MCP server, carries the numeric code and + * whatever human-readable message came back, so callers can distinguish a + * transport failure from a server-reported one. + */ +export class McpError extends Error { + readonly code: number + + constructor(code: number, message: string) { + super(message) + this.name = "McpError" + this.code = code + } +} + +/** + * The identifier of an editor tool as advertised by the Unreal MCP, pairs the + * toolset owning the tool with its short name so callers can dispatch to it + * through `call_tool` without concatenating strings. + */ +export type ToolSpec = { toolset: string; name: string } + +/** + * The streamable-HTTP client for the Unreal Engine MCP, owns the JSON-RPC + * session, request-id counter, and transport helpers behind a small class so + * callers get a single stable instance to talk to. + */ +export class McpClient { + /** + * The session id handed out by the server on `initialize`, cleared whenever + * a caller resets the session so the next probe starts a fresh handshake. + */ + private _sessionId: string | null = null + + /** + * The next JSON-RPC request id, incremented on every outbound call so + * pipelined responses can be matched back. + */ + private _nextRequestId = 1 + + /** + * POSTs a JSON-RPC payload with a hard timeout, so a hung editor cannot + * stall the probe loop forever. + * @param body The JSON-RPC payload to POST. + * @param acceptStream Whether to advertise SSE in the `Accept` header, + * needed for the `initialize` handshake since some servers stream that + * response. + * @returns The parsed JSON response and the response headers. + * @throws When the request times out or the server does not respond with 2xx. + */ + private async _post(body: unknown, acceptStream: boolean): Promise<{ json: unknown; headers: Headers }> { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), MCP_CONFIG.requestTimeoutMs) + + try { + const headers: Record = { + "Content-Type": "application/json", + Accept: acceptStream ? "application/json, text/event-stream" : "application/json", + } + + if (this._sessionId) headers[CONSTANTS.mcp.sessionHeader] = this._sessionId + + const response = await fetch(MCP_CONFIG.endpoint, { + method: "POST", + headers, + body: JSON.stringify(body), + signal: controller.signal, + }) + + if (!response.ok) { + const bodyPreview = (await response.text()).slice(0, 500).trim() + const suffix = bodyPreview ? `: ${bodyPreview}` : "" + throw new McpError(response.status, `HTTP ${response.status} ${response.statusText}${suffix}`) + } + + const text = await response.text() + const json = text ? this._parseResponsePayload(text, response.headers.get("content-type") ?? "") : null + + return { + json, + headers: response.headers, + } + } finally { + clearTimeout(timeout) + } + } + + /** + * Parses either a plain JSON body or the first data event of an SSE stream, + * so the same call site can accept both response modes without branching. + * @param text The raw response body. + * @param contentType The `Content-Type` header of the response. + * @returns The parsed JSON payload. + * @throws When the payload cannot be parsed. + */ + private _parseResponsePayload(text: string, contentType: string): unknown { + if (contentType.includes("text/event-stream")) { + const dataLine = text + .split(/\r?\n/) + .find(line => line.startsWith("data:")) + ?.slice(5) + .trim() + + if (!dataLine) throw new McpError(-32603, "SSE response carried no data event") + + return JSON.parse(dataLine) + } + + return JSON.parse(text) + } + + /** + * Unwraps a JSON-RPC envelope, turning a `{ error }` payload into a thrown + * `McpError` and returning the `result` when the call succeeded. + * @param payload The parsed response body. + * @returns The `result` field of the envelope. + * @throws When the envelope carries a JSON-RPC error. + */ + private _unwrapResult(payload: unknown): unknown { + if (!payload || typeof payload !== "object") { + throw new McpError(-32603, "MCP response was not a JSON-RPC envelope") + } + + const envelope = payload as { + result?: unknown + error?: { + code: number + message: string + } + } + + if (envelope.error) throw new McpError(envelope.error.code, envelope.error.message) + + return envelope.result + } + + /** + * Runs the MCP `initialize` handshake and captures the session id, so later + * calls can piggyback on the same session. + * @returns The server's advertised identity. + * @throws When the handshake fails or the response is malformed. + */ + async initialize(): Promise { + this._sessionId = null + + const request = { + jsonrpc: "2.0", + id: this._nextRequestId++, + method: "initialize", + params: { + protocolVersion: MCP_CONFIG.protocolVersion, + capabilities: {}, + clientInfo: { + name: MCP_CONFIG.clientName, + version: app.getVersion(), + }, + }, + } + + const { json, headers } = await this._post(request, true) + + const result = this._unwrapResult(json) as { + protocolVersion?: string + serverInfo?: { + name?: string + version?: string + } + } | null + + if (!result) throw new McpError(-32603, "MCP initialize returned no result") + + this._sessionId = headers.get(CONSTANTS.mcp.sessionHeader) + + await this._post( + { + jsonrpc: "2.0", + method: "notifications/initialized", + }, + false, + ) + + return { + name: result.serverInfo?.name ?? "unknown", + version: result.serverInfo?.version ?? "unknown", + protocolVersion: result.protocolVersion ?? MCP_CONFIG.protocolVersion, + } + } + + /** + * Runs the MCP `tools/list` method and returns the raw tool descriptors, + * used to inspect what a server exposes when a `tools/call` is failing. + * @returns The list of tools the server advertises. + */ + async listTools(): Promise { + const request = { + jsonrpc: "2.0", + id: this._nextRequestId++, + method: "tools/list", + params: {}, + } + + const { json } = await this._post(request, true) + + return this._unwrapResult(json) + } + + /** + * Invokes an editor tool through the Unreal MCP's `call_tool` dispatcher + * and returns its structured result, callers cast the return value to the + * shape declared by the tool's outputSchema. + * @param toolsetName The dotted name of the toolset owning the tool. + * @param toolName The short name of the tool within the toolset. + * @param args The arguments to pass to the tool. + * @returns The `structuredContent` field of the `CallToolResult`. + * @throws When the tool reports `isError`, or the response is malformed. + */ + async callTool(toolsetName: string, toolName: string, args: Record = {}): Promise { + const request = { + jsonrpc: "2.0", + id: this._nextRequestId++, + method: "tools/call", + params: { + name: CONSTANTS.mcp.dispatcher, + arguments: { + toolset_name: toolsetName, + tool_name: toolName, + arguments: args, + }, + }, + } + + const { json } = await this._post(request, true) + + const result = this._unwrapResult(json) as { + structuredContent?: unknown + content?: Array<{ type: string; text?: string }> + isError?: boolean + } | null + + const label = `${toolsetName}.${toolName}` + if (!result) throw new McpError(-32603, `Tool "${label}" returned no result`) + + if (result.isError) { + const message = result.content?.find(item => item.type === "text")?.text ?? "Unknown tool error" + throw new McpError(-32603, `Tool "${label}" failed: ${message}`) + } + + // Dispatched tools do not propagate the underlying "outputSchema", so the + // Unreal MCP wraps the return value as JSON in a text content item + // rather than in "structuredContent", fall back to parsing that + if (result.structuredContent !== undefined) return result.structuredContent + + const textItem = result.content?.find(item => item.type === "text")?.text + if (!textItem) return null + + try { + return JSON.parse(textItem) + } catch { + return textItem + } + } + + /** + * Convenience wrapper around `callTool` that takes the `{ toolset, name }` + * pairs declared in `CONSTANTS.mcp.tools`, so callers do not repeat + * `spec.toolset, spec.name` at every call site. + * @param spec The tool identifier from the constants. + * @param args The arguments to pass to the tool. + * @returns The tool's structured result. + */ + callEditorTool(spec: ToolSpec, args?: Record): Promise { + return this.callTool(spec.toolset, spec.name, args) + } + + /** + * Drops the current session so the next request goes through a fresh + * handshake, called by the service when a transport error suggests the + * editor is gone. + */ + resetSession() { + this._sessionId = null + } +} + +/** + * The single app-wide MCP client, one editor per session so a shared instance + * is enough, callers grab this rather than constructing their own. + */ +export const mcpClient = new McpClient() diff --git a/src/main/lib/mcp/handlers.ts b/src/main/lib/mcp/handlers.ts new file mode 100644 index 0000000..3777144 --- /dev/null +++ b/src/main/lib/mcp/handlers.ts @@ -0,0 +1,21 @@ +import CONSTANTS from "@main/lib/constants" +import { safeHandle } from "@main/lib/ipc" +import { mcpClient } from "@main/lib/mcp/client" +import { mcpService } from "@main/lib/mcp/service" +import { mcpStore } from "@main/lib/mcp/store" +import { getEditorActivity } from "@main/lib/mcp/tools" + +/** + * Registers the IPC handlers that expose the MCP connection to the renderer, + * covering connection state, forced probe, editor activity snapshot, and the + * raw `tools/list` output for debugging. + */ +export function registerMcpHandlers() { + safeHandle(CONSTANTS.ipc.mcpGetState, () => mcpStore.get()) + safeHandle(CONSTANTS.ipc.mcpProbe, () => mcpService.probe()) + safeHandle(CONSTANTS.ipc.mcpGetEditorActivity, () => getEditorActivity()) + safeHandle(CONSTANTS.ipc.mcpListTools, () => mcpClient.listTools()) + safeHandle(CONSTANTS.ipc.mcpCallTool, (_event, toolset: string, name: string, args?: Record) => + mcpClient.callTool(toolset, name, args), + ) +} diff --git a/src/main/lib/mcp/index.ts b/src/main/lib/mcp/index.ts new file mode 100644 index 0000000..debfbb7 --- /dev/null +++ b/src/main/lib/mcp/index.ts @@ -0,0 +1,26 @@ +import CONSTANTS from "@main/lib/constants" +import { mcpService } from "@main/lib/mcp/service" +import { mcpStore } from "@main/lib/mcp/store" +import { BrowserWindow } from "electron" +import type { McpState } from "@/main/types/mcp" + +/** + * Pushes the MCP state to every open window, so the status bar and any + * later auto-lock UI can follow the same transitions. + * @param next The new MCP state. + */ +function broadcastMcpState(next: McpState) { + BrowserWindow.getAllWindows().forEach(window => { + if (window.isDestroyed()) return + window.webContents.send(CONSTANTS.ipc.mcpStateChanged, next) + }) +} + +/** + * Starts the MCP client, mirroring its connection state onto every window and + * launching the periodic probe loop that keeps that state fresh. + */ +export function startMcp() { + mcpStore.subscribe(broadcastMcpState) + mcpService.start() +} diff --git a/src/main/lib/mcp/service.ts b/src/main/lib/mcp/service.ts new file mode 100644 index 0000000..6f81aa1 --- /dev/null +++ b/src/main/lib/mcp/service.ts @@ -0,0 +1,106 @@ +import MCP_CONFIG from "@main/config/mcp" +import { McpError, mcpClient } from "@main/lib/mcp/client" +import { mcpStore } from "@main/lib/mcp/store" +import { convertErrorToMessage } from "@main/lib/utils/errors" +import type { McpState } from "@/main/types/mcp" + +/** + * The periodic probe lifecycle owner, keeps the store in sync with whether the + * editor is running and guards against overlapping in-flight probes, one + * instance per app so the app-wide `mcpService` singleton is enough. + */ +export class McpService { + /** + * The current probe loop's timer, held so restarting the loop cannot leave + * a second timer behind. + */ + private _probeTimer: NodeJS.Timeout | null = null + + /** + * Whether a probe is currently in flight, guards the periodic timer from + * stacking a second call on top of one that has not landed yet. + */ + private _probeInFlight = false + + /** + * 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 + * visible long enough for the user to notice the transition. + * @param startedAt The epoch time when the transition began. + * @param minMs The minimum elapsed time to enforce. + */ + private async _waitForMinDisplay(startedAt: number, minMs: number) { + const remaining = minMs - (Date.now() - startedAt) + if (remaining > 0) await new Promise(resolve => setTimeout(resolve, remaining)) + } + + /** + * Runs a single probe against the MCP server, updating the store with the + * result, the `connecting` phase is held for at least + * `MCP_CONFIG.minConnectingDisplayMs` so a fast probe does not flicker past. + * @returns The state that resulted from the probe. + */ + async probe(): Promise { + if (this._probeInFlight) return mcpStore.get() + this._probeInFlight = true + + const wasConnected = mcpStore.get().status === "connected" + const startedAt = Date.now() + + if (!wasConnected) mcpStore.set({ status: "connecting", error: null }) + + try { + const server = await mcpClient.initialize() + if (!wasConnected) await this._waitForMinDisplay(startedAt, MCP_CONFIG.minConnectingDisplayMs) + + mcpStore.set({ + status: "connected", + server, + error: null, + lastProbedAt: new Date().toISOString(), + }) + } catch (error) { + mcpClient.resetSession() + if (!wasConnected) await this._waitForMinDisplay(startedAt, MCP_CONFIG.minConnectingDisplayMs) + + mcpStore.set({ + status: "disconnected", + server: null, + error: error instanceof McpError ? error.message : convertErrorToMessage(error), + lastProbedAt: new Date().toISOString(), + }) + } finally { + this._probeInFlight = false + } + + return mcpStore.get() + } + + /** + * 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. + */ + start() { + if (this._probeTimer) return + + this.probe() + this._probeTimer = setInterval(() => this.probe(), MCP_CONFIG.probeIntervalMs) + } + + /** + * Stops the periodic probe loop, a hook for tests and for a future + * "pause auto-probe" preference. + */ + stop() { + if (!this._probeTimer) return + + clearInterval(this._probeTimer) + this._probeTimer = null + } +} + +/** + * The single app-wide MCP service, driven by the app entry point. + */ +export const mcpService = new McpService() diff --git a/src/main/lib/mcp/store.ts b/src/main/lib/mcp/store.ts new file mode 100644 index 0000000..3ecedbe --- /dev/null +++ b/src/main/lib/mcp/store.ts @@ -0,0 +1,23 @@ +import MCP_CONFIG from "@main/config/mcp" +import { ObservableStore } from "@main/lib/stores/observable" +import type { McpState } from "@/main/types/mcp" + +/** + * Builds the state the MCP starts from, before the first probe has run. + * @returns The initial MCP state. + */ +function createInitialState(): McpState { + return { + status: "disconnected", + server: null, + error: null, + lastProbedAt: null, + probeIntervalMs: MCP_CONFIG.probeIntervalMs, + } +} + +/** + * The single app-wide MCP connection store, subscribed to by every window so + * the status chip can follow the same transitions the service records. + */ +export const mcpStore = new ObservableStore(createInitialState()) diff --git a/src/main/lib/mcp/tools.ts b/src/main/lib/mcp/tools.ts new file mode 100644 index 0000000..18f2f2f --- /dev/null +++ b/src/main/lib/mcp/tools.ts @@ -0,0 +1,98 @@ +import { mcpClient } from "@main/lib/mcp/client" +import { mcpStore } from "@main/lib/mcp/store" +import CONSTANTS from "@/main/lib/constants" +import type { EditorActivity } from "@/main/types/mcp" + +/** + * Whether the MCP is currently connected, tool calls issued while + * disconnected would fail on the wire and return misleading data. + * @returns `true` when the initialize handshake has landed. + */ +function isConnected(): boolean { + return mcpStore.get().status === "connected" +} + +/** + * Lists the assets currently open in asset editors, keyed by their virtual + * `/Game/*` or plugin content path. + * @returns The open asset paths, or an empty list when the MCP is offline. + */ +export async function getOpenAssets(): Promise { + if (!isConnected()) return [] + + const result = (await mcpClient.callEditorTool(CONSTANTS.mcp.tools.getOpenAssets)) as { + returnValue?: string[] + } | null + return result?.returnValue ?? [] +} + +/** + * Reads the content path of the currently loaded level, treating scratch + * `/Temp/*` levels as absent since they have no on-disk asset to lock. + * @returns The level path, or `null` when the editor is on a scratch level. + */ +export async function getCurrentLevel(): Promise { + if (!isConnected()) return null + + const result = (await mcpClient.callEditorTool(CONSTANTS.mcp.tools.getCurrentLevel)) as { + returnValue?: string + } | null + return result?.returnValue && !result.returnValue.startsWith(CONSTANTS.mcp.scratchLevelPrefix) + ? result.returnValue + : null +} + +/** + * Reads the dirty flag of a single asset, `true` when the editor holds + * unsaved edits for it, `false` otherwise. + * @param assetPath The virtual content path of the asset. + * @returns The dirty flag, or `false` when the MCP is offline. + */ +export async function isAssetDirty(assetPath: string): Promise { + if (!isConnected()) return false + + const result = (await mcpClient.callEditorTool(CONSTANTS.mcp.tools.isDirty, { asset_path: assetPath })) as { + returnValue?: boolean + } | null + + return Boolean(result?.returnValue) +} + +/** + * Filters a list of asset paths down to those the editor currently holds + * unsaved edits for, checks fan out concurrently since each `is_dirty` is + * an independent localhost round-trip. + * @param assetPaths The candidate paths to check. + * @returns The subset that are dirty. + */ +async function filterDirtyAssets(assetPaths: string[]): Promise { + if (assetPaths.length === 0) return [] + + const flags = await Promise.all(assetPaths.map(path => isAssetDirty(path))) + return assetPaths.filter((_, index) => flags[index]) +} + +/** + * Collects the editor's active-editing snapshot, dirty tabs from asset editors + * plus the loaded level, OFPA external actors are out of scope for this pass + * since UE's public asset API does not expose per-actor packages. + * @returns The editor activity snapshot. + */ +export async function getEditorActivity(): Promise { + if (!isConnected()) { + return { + openAssets: [], + dirtyAssets: [], + currentLevel: null, + } + } + + const [openAssets, currentLevel] = await Promise.all([getOpenAssets(), getCurrentLevel()]) + const dirtyAssets = await filterDirtyAssets(openAssets) + + return { + openAssets, + dirtyAssets, + currentLevel, + } +} diff --git a/src/main/lib/stores/autoLock.ts b/src/main/lib/stores/autoLock.ts new file mode 100644 index 0000000..55890c2 --- /dev/null +++ b/src/main/lib/stores/autoLock.ts @@ -0,0 +1,27 @@ +import AUTO_LOCK_CONFIG from "@main/config/autoLock" +import { ObservableStore } from "@main/lib/stores/observable" +import type { AutoLockState } from "@/main/types/autoLock" + +/** + * Builds the state the auto-lock loop starts from, disabled with no project + * bound yet, filled in when the service is started. + * @returns The initial auto-lock state. + */ +function createInitialState(): AutoLockState { + return { + enabled: false, + dir: null, + isReconciling: false, + lastReconciledAt: null, + lockedCount: 0, + failures: [], + lastError: null, + tickIntervalMs: AUTO_LOCK_CONFIG.tickIntervalMs, + } +} + +/** + * The single app-wide auto-lock store, subscribed to by every window so the + * status chip and any manual controls follow the same transitions. + */ +export const autoLockStore = new ObservableStore(createInitialState()) diff --git a/src/main/types/autoLock.d.ts b/src/main/types/autoLock.d.ts new file mode 100644 index 0000000..04a22d8 --- /dev/null +++ b/src/main/types/autoLock.d.ts @@ -0,0 +1,28 @@ +import type { LfsLockResult } from "@/main/types/lfsCommands" + +/** + * The outcome of a single auto-lock reconcile pass, carries the target set the + * pass computed together with the lock and unlock results, so the renderer can + * surface the diff even when nothing changed. + */ +export type AutoLockReconcileResult = { + targets: string[] + locked: LfsLockResult[] + unlocked: LfsLockResult[] +} + +/** + * The auto-lock loop's observable state, broadcast to every open window so the + * status chip and any manual controls can follow the same transitions the + * background loop records. + */ +export type AutoLockState = { + enabled: boolean + dir: string | null + isReconciling: boolean + lastReconciledAt: string | null + lockedCount: number + failures: LfsLockResult[] + lastError: string | null + tickIntervalMs: number +} diff --git a/src/main/types/mcp.d.ts b/src/main/types/mcp.d.ts new file mode 100644 index 0000000..63c7c1b --- /dev/null +++ b/src/main/types/mcp.d.ts @@ -0,0 +1,39 @@ +/** + * The connection state to the UE MCP server: + * - `disconnected`: When the editor is closed or unreachable. + * - `connecting`: While a probe is in flight. + * - `connected`: Once the JSON-RPC `initialize` handshake has landed. + */ +export type McpConnectionStatus = "disconnected" | "connecting" | "connected" + +/** + * The identifying information the MCP server returned during `initialize`, + * kept around so the status bar can name the server it is talking to. + */ +export type McpServerInfo = { + name: string + version: string + protocolVersion: string +} + +/** + * The full MCP connection state, broadcast to every open window on each + * transition, mirrors the shape used by the updater state so the renderer + * can follow the same subscribe/emit pattern. + */ +export type McpState = { + status: McpConnectionStatus + server: McpServerInfo | null + error: string | null + lastProbedAt: string | null + probeIntervalMs: number +} + +/** + * A snapshot of the editor's currently-edited assets. + */ +export type EditorActivity = { + openAssets: string[] + dirtyAssets: string[] + currentLevel: string | null +} diff --git a/src/preload/index.ts b/src/preload/index.ts index ca5de60..e1fd54c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,19 +1,24 @@ import appApiRoutes from "@preload/routes/app" import audioApiRoutes from "@preload/routes/audio" +import autoLockApiRoutes from "@preload/routes/autoLock" 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 projectsApiRoutes from "@preload/routes/projects" import shellsApiRoutes from "@preload/routes/shells" import updaterApiRoutes from "@preload/routes/updater" import uprojectApiRoutes from "@preload/routes/uproject" 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 { 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 { UpdaterSimulation, UpdaterState } from "@/main/types/updater" @@ -42,9 +47,18 @@ export type GitgameApi = { } app: { version: string + isFirstLoad: boolean } audio: { - playError: () => void + play: (sound: AppSound) => void + } + autoLock: { + previewTargets: (dir: string) => Promise + reconcile: (dir: string) => Promise + getState: () => Promise + onStateChange: (callback: (state: AutoLockState) => void) => () => void + start: (dir: string) => Promise + stop: () => Promise } dialogs: { confirm: (options: ConfirmDialogOptions) => Promise @@ -82,6 +96,14 @@ export type GitgameApi = { ) => Promise migrateLocks: (dir: string) => Promise } + mcp: { + getState: () => Promise + onStateChange: (callback: (state: McpState) => void) => () => void + probe: () => Promise + getEditorActivity: () => Promise + listTools: () => Promise + callTool: (toolset: string, name: string, args?: Record) => Promise + } projects: { addLocal: () => Promise open: (dir: string) => Promise @@ -129,10 +151,12 @@ const api: GitgameApi = { }, app: appApiRoutes, audio: audioApiRoutes, + autoLock: autoLockApiRoutes, dialogs: dialogsApiRoutes, fileTree: fileTreeApiRoutes, gitCommands: gitCommandsApiRoutes, lfsCommands: lfsCommandsApiRoutes, + mcp: mcpApiRoutes, projects: projectsApiRoutes, shells: shellsApiRoutes, updater: updaterApiRoutes, diff --git a/src/preload/routes/autoLock.ts b/src/preload/routes/autoLock.ts new file mode 100644 index 0000000..6885ea1 --- /dev/null +++ b/src/preload/routes/autoLock.ts @@ -0,0 +1,20 @@ +import CONSTANTS from "@main/lib/constants" +import type { GitgameApi } from "@preload/index" +import { safeInvoke } from "@preload/lib/ipc" +import { ipcRenderer } from "electron" +import type { AutoLockState } from "@/main/types/autoLock" + +const autoLockApiRoutes: GitgameApi["autoLock"] = { + previewTargets: (dir: string) => safeInvoke(CONSTANTS.ipc.autoLockPreviewTargets, dir), + reconcile: (dir: string) => safeInvoke(CONSTANTS.ipc.autoLockReconcile, dir), + getState: () => safeInvoke(CONSTANTS.ipc.autoLockGetState), + onStateChange: callback => { + const listener = (_: unknown, state: AutoLockState) => callback(state) + ipcRenderer.on(CONSTANTS.ipc.autoLockStateChanged, listener) + return () => ipcRenderer.off(CONSTANTS.ipc.autoLockStateChanged, listener) + }, + start: (dir: string) => safeInvoke(CONSTANTS.ipc.autoLockStart, dir), + stop: () => safeInvoke(CONSTANTS.ipc.autoLockStop), +} + +export default autoLockApiRoutes diff --git a/src/preload/routes/mcp.ts b/src/preload/routes/mcp.ts new file mode 100644 index 0000000..22131be --- /dev/null +++ b/src/preload/routes/mcp.ts @@ -0,0 +1,20 @@ +import CONSTANTS from "@main/lib/constants" +import type { GitgameApi } from "@preload/index" +import { safeInvoke } from "@preload/lib/ipc" +import { ipcRenderer } from "electron" +import type { McpState } from "@/main/types/mcp" + +const mcpApiRoutes: GitgameApi["mcp"] = { + getState: () => safeInvoke(CONSTANTS.ipc.mcpGetState), + onStateChange: callback => { + const listener = (_: unknown, state: McpState) => callback(state) + ipcRenderer.on(CONSTANTS.ipc.mcpStateChanged, listener) + return () => ipcRenderer.off(CONSTANTS.ipc.mcpStateChanged, listener) + }, + probe: () => safeInvoke(CONSTANTS.ipc.mcpProbe), + getEditorActivity: () => safeInvoke(CONSTANTS.ipc.mcpGetEditorActivity), + listTools: () => safeInvoke(CONSTANTS.ipc.mcpListTools), + callTool: (toolset, name, args) => safeInvoke(CONSTANTS.ipc.mcpCallTool, toolset, name, args), +} + +export default mcpApiRoutes diff --git a/src/renderer/components/contexts/Project.tsx b/src/renderer/components/contexts/Project.tsx index a0bbf9f..58a070f 100644 --- a/src/renderer/components/contexts/Project.tsx +++ b/src/renderer/components/contexts/Project.tsx @@ -124,21 +124,31 @@ export default function ProjectProvider({ children }: ProjectProviderProps) { * metadata with the copy the main process resolved at launch time. */ const openInUnrealEditor = useCallback(async () => { - const root = currentProject?.path - if (!root) return + if (!currentProject) return try { - const uproject = await window.api.uproject.open(root) + const uproject = await window.api.uproject.open(currentProject.path) // Guards against the project being closed or swapped while the editor was launching - setCurrentProject(current => (current?.path === root ? { ...current, uproject } : current)) + setCurrentProject(current => (current?.path === currentProject.path ? { ...current, uproject } : current)) } catch (error) { reportError("Can't open the project in Unreal Editor", error) } + }, [currentProject]) + + // Starts the auto-lock loop whenever a project is open and stops it when + // there is none, so the periodic reconcile is scoped to the active project + useEffect(() => { + if (!currentProject?.path) { + window.api.autoLock.stop() + return + } + + window.api.autoLock.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 + // changes, so downstream consumers (e.g the menu bar) can enable/disable // remote-scoped actions accordingly useEffect(() => { if (!currentProject?.path) { @@ -165,7 +175,7 @@ export default function ProjectProvider({ children }: ProjectProviderProps) { }, [currentProject?.path]) // On mount, load the recent projects and, when configured to do so, re-open - // the most recently opened project. + // the most recently opened project useEffect(() => { const init = async () => { setIsLoading(true) diff --git a/src/renderer/components/fields/StatusAutoLock.tsx b/src/renderer/components/fields/StatusAutoLock.tsx new file mode 100644 index 0000000..e2792df --- /dev/null +++ b/src/renderer/components/fields/StatusAutoLock.tsx @@ -0,0 +1,81 @@ +import { cn } from "@cybearl/cypack/frontend" +import lockIcon from "@react95-icons/Lock_16x16_4.png" +import Icon from "@renderer/components/ui/Icon" +import TileProgressBar from "@renderer/components/ui/TileProgressBar" +import useAutoLockState from "@renderer/hooks/useAutoLockState" +import { buildAutoLockFailuresDetails, buildAutoLockLabel, computeAutoLockProgress } from "@renderer/lib/utils/autoLock" +import { useCallback, useEffect, useMemo, useState } from "react" +import { Frame } from "react95" +import CONSTANTS from "@/renderer/lib/constants" + +type StatusAutoLockFieldProps = { + className?: string +} + +export default function StatusAutoLockField({ className }: StatusAutoLockFieldProps) { + const state = useAutoLockState() + + const [now, setNow] = useState(() => Date.now()) + + /** + * The chip label built from the current auto-lock state, `null` while the + * first read has not landed yet so nothing renders on mount. + */ + const label = useMemo(() => (state ? buildAutoLockLabel(state) : null), [state]) + + /** + * The countdown bar's fill, expressed as a 0-100 percentage of the elapsed + * fraction of the tick interval since the last reconcile landed. + */ + const progress = useMemo(() => (state ? computeAutoLockProgress(state, now) : 0), [state, now]) + + /** + * Handles a chip click, opens the failures dialog when the last reconcile + * left some files unlocked or unreleased, forces an immediate reconcile + * otherwise so a manual click always does something useful. + */ + const handleClick = useCallback(() => { + if (!state) return + + if (state.failures.length > 0) { + window.api.dialogs.errorWithDetails( + "Auto-lock: some files couldn't be locked", + `${state.failures.length} file${state.failures.length === 1 ? "" : "s"} failed during the last reconcile.`, + buildAutoLockFailuresDetails(state.failures), + ) + return + } + + if (state.dir) window.api.autoLock.reconcile(state.dir) + }, [state]) + + // Re-sample "now" on a short interval so the bar fills smoothly between + // ticks, the state itself only broadcasts on transitions and cannot drive + // the animation + useEffect(() => { + const id = window.setInterval(() => setNow(Date.now()), CONSTANTS.STATUS_BAR_TICK_MS) + return () => window.clearInterval(id) + }, []) + + if (!state?.enabled || !label) return null + + const hasErrors = state.failures.length > 0 + + return ( + +
+ + Auto-lock [{label}] +
+ + + + ) +} diff --git a/src/renderer/components/fields/StatusMcp.tsx b/src/renderer/components/fields/StatusMcp.tsx new file mode 100644 index 0000000..29e9d1a --- /dev/null +++ b/src/renderer/components/fields/StatusMcp.tsx @@ -0,0 +1,63 @@ +import { cn } from "@cybearl/cypack/frontend" +import serverIcon from "@react95-icons/Nwnp32ServerIcon_16x16_4.png" +import Icon from "@renderer/components/ui/Icon" +import TileProgressBar from "@renderer/components/ui/TileProgressBar" +import useMcpState from "@renderer/hooks/useMcpState" +import { buildMcpStatusLabel, computeProbeProgress } from "@renderer/lib/utils/mcp" +import { useEffect, useMemo, useState } from "react" +import { Frame } from "react95" +import CONSTANTS from "@/renderer/lib/constants" + +type StatusMcpFieldProps = { + className?: string +} + +export default function StatusMcpField({ className }: StatusMcpFieldProps) { + const state = useMcpState() + + const [now, setNow] = useState(() => Date.now()) + + /** + * The chip label built from the current MCP state, `null` while the first + * read has not landed yet so nothing renders on mount. + */ + const label = useMemo(() => (state ? buildMcpStatusLabel(state) : null), [state]) + + /** + * The countdown bar's fill, expressed as a 0-100 percentage of the elapsed + * fraction of the probe interval since the last probe landed. + */ + const progress = useMemo(() => (state ? computeProbeProgress(state, now) : 0), [state, now]) + + // Re-sample "now" on a short interval so the bar fills smoothly between probes, + // the state itself only broadcasts on transitions and cannot drive the animation + useEffect(() => { + const id = window.setInterval(() => setNow(Date.now()), CONSTANTS.STATUS_BAR_TICK_MS) + return () => window.clearInterval(id) + }, []) + + if (!state || !label) return null + + return ( + +
+ + + MCP [{label}] + +
+ + + + ) +} diff --git a/src/renderer/components/ui/TileProgressBar.tsx b/src/renderer/components/ui/TileProgressBar.tsx new file mode 100644 index 0000000..7bdde6e --- /dev/null +++ b/src/renderer/components/ui/TileProgressBar.tsx @@ -0,0 +1,47 @@ +import { cn } from "@cybearl/cypack/frontend" +import { ProgressBar } from "react95" +import styled, { css } from "styled-components" + +/** + * Recolors the tile ProgressBar's tiles to `theme.borderDark` when muted, so + * the inactive bar shows as light shadow-grey instead of the near-black a + * `grayscale` filter lands on when applied to the saturated `theme.progress` + * blue. + */ +const Wrapper = styled.div<{ $muted: boolean }>` + display: contents; + + ${({ $muted, theme }) => + $muted && + css` + [data-testid="tileProgress"] > span { + background: ${theme.borderDark}; + } + `} +` + +type TileProgressBarProps = { + value: number + muted?: boolean + className?: string +} + +/** + * The react95 tile ProgressBar wrapped with a status-bar-friendly muted state, + * used by the status chips to fade the bar to shadow-grey when the underlying + * service is idle or errored. + * @param props Tile bar props, `muted` toggles the grey recolor. + * @returns The wrapped ProgressBar. + */ +export default function TileProgressBar({ value, muted = false, className }: TileProgressBarProps) { + return ( + + + + ) +} diff --git a/src/renderer/hooks/useAutoLockState.tsx b/src/renderer/hooks/useAutoLockState.tsx new file mode 100644 index 0000000..82218b5 --- /dev/null +++ b/src/renderer/hooks/useAutoLockState.tsx @@ -0,0 +1,28 @@ +import { useEffect, useState } from "react" +import type { AutoLockState } from "@/main/types/autoLock" + +/** + * Follows the auto-lock loop state broadcast by the main process, subscribes + * before the first read so a transition landing mid-mount cannot be missed. + * @returns The current auto-lock state, or `null` until the first read resolves. + */ +export default function useAutoLockState(): AutoLockState | null { + const [state, setState] = useState(null) + + useEffect(() => { + let hasReceivedPush = false + + const unsubscribe = window.api.autoLock.onStateChange(next => { + hasReceivedPush = true + setState(next) + }) + + window.api.autoLock.getState().then(initial => { + if (!hasReceivedPush) setState(initial) + }) + + return unsubscribe + }, []) + + return state +} diff --git a/src/renderer/hooks/useMcpState.tsx b/src/renderer/hooks/useMcpState.tsx new file mode 100644 index 0000000..8ac5bef --- /dev/null +++ b/src/renderer/hooks/useMcpState.tsx @@ -0,0 +1,28 @@ +import { useEffect, useState } from "react" +import type { McpState } from "@/main/types/mcp" + +/** + * Follows the MCP connection state broadcast by the main process, subscribing + * before the first read so a transition landing mid-mount cannot be missed. + * @returns The current MCP state, or `null` until the first read resolves. + */ +export default function useMcpState(): McpState | null { + const [state, setState] = useState(null) + + useEffect(() => { + let hasReceivedPush = false + + const unsubscribe = window.api.mcp.onStateChange(next => { + hasReceivedPush = true + setState(next) + }) + + window.api.mcp.getState().then(initial => { + if (!hasReceivedPush) setState(initial) + }) + + return unsubscribe + }, []) + + return state +} diff --git a/src/renderer/lib/constants.ts b/src/renderer/lib/constants.ts index 919390c..d016fab 100644 --- a/src/renderer/lib/constants.ts +++ b/src/renderer/lib/constants.ts @@ -7,6 +7,39 @@ const CONSTANTS = { */ BYTE_UNITS: ["B", "KB", "MB", "GB"], + /** + * The Tailwind sizing classes for each icon scale. + */ + ICON_SIZE_CLASSES: { + sm: "size-4", + md: "size-8", + lg: "size-16", + none: "", + }, + + /** + * The gutter an inline icon keeps between itself and the label that follows it. + */ + ICON_INLINE_GUTTER_CLASS: "mr-2", + + /** + * The shortest time the boot screen stays up. + */ + 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. + */ + 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. + */ + STATUS_BAR_TICK_MS: 200, + /** * External resources opened from the `Help` menu. */ diff --git a/src/renderer/lib/utils/autoLock.ts b/src/renderer/lib/utils/autoLock.ts new file mode 100644 index 0000000..93d5fb6 --- /dev/null +++ b/src/renderer/lib/utils/autoLock.ts @@ -0,0 +1,79 @@ +import dedent from "dedent" +import type { AutoLockState } from "@/main/types/autoLock" +import type { LfsLockResult } from "@/main/types/lfsCommands" + +/** + * Builds the short label shown in the status bar chip, mirrors the MCP chip's + * naming so the two read as a pair, includes the failure count only when + * there is something worth surfacing. + * @param state The current auto-lock state. + * @returns The chip label. + */ +export function buildAutoLockLabel(state: AutoLockState): string { + if (state.failures.length > 0) return `${state.lockedCount} held, ${state.failures.length} err` + return `${state.lockedCount} held` +} + +/** + * Builds the tooltip shown on hover, carries the last reconcile time and the + * most recent error message so a stuck loop can be diagnosed without opening + * DevTools. + * @param state The current auto-lock state. + * @returns The tooltip text. + */ +export function buildAutoLockTooltip(state: AutoLockState): string { + if (state.lastError) { + return dedent` + Auto-lock error: ${state.lastError} + Click to reconcile now + ` + } + + if (!state.lastReconciledAt) { + return dedent` + Auto-lock: waiting for the first reconcile... + Click to reconcile now + ` + } + + const lastReconciled = new Date(state.lastReconciledAt).toLocaleTimeString() + return dedent` + Auto-lock: ${state.lockedCount} file(s) held + Last reconcile at ${lastReconciled} + Click to reconcile now + ` +} + +/** + * Builds the details block shown in the failures dialog, one entry per failed + * lock or unlock, path followed by the raw error message underneath. + * @param failures The per-file failure records from the last reconcile. + * @returns The formatted details block. + */ +export function buildAutoLockFailuresDetails(failures: LfsLockResult[]): string { + return failures + .map( + failure => + dedent` + ${failure.path} + ${failure.error ?? "Unknown error"} + `, + ) + .join("\n\n") +} + +/** + * Computes the fill percentage (0-100) of the countdown bar, based on the + * time elapsed since the last reconcile as a fraction of the tick interval. + * @param state The current auto-lock state. + * @param now The current epoch time in ms, sampled by the chip's tick. + * @returns The bar fill percentage, `0` before the first reconcile lands. + */ +export function computeAutoLockProgress(state: AutoLockState, now: number): number { + if (!state.lastReconciledAt) return 0 + + const elapsed = now - new Date(state.lastReconciledAt).getTime() + const fraction = elapsed / state.tickIntervalMs + + return Math.min(100, Math.max(0, fraction * 100)) +} diff --git a/src/renderer/lib/utils/mcp.ts b/src/renderer/lib/utils/mcp.ts new file mode 100644 index 0000000..d0ab2f2 --- /dev/null +++ b/src/renderer/lib/utils/mcp.ts @@ -0,0 +1,34 @@ +import type { McpState } from "@/main/types/mcp" + +/** + * Computes the fill percentage (0-100) of the countdown bar, based on the time + * elapsed since the last probe landed as a fraction of the probe interval. + * @param state The current MCP state. + * @param now The current epoch time in ms, sampled by the chip's tick. + * @returns The bar fill percentage, `0` before the first probe lands. + */ +export function computeProbeProgress(state: McpState, now: number): number { + if (!state.lastProbedAt) return 0 + + const elapsed = now - new Date(state.lastProbedAt).getTime() + const fraction = elapsed / state.probeIntervalMs + + return Math.min(100, Math.max(0, fraction * 100)) +} + +/** + * Builds the short label shown in the status bar chip, one word per state so + * the chip stays narrow at every width. + * @param state The current MCP state. + * @returns The chip label. + */ +export function buildMcpStatusLabel(state: McpState): string { + switch (state.status) { + case "connected": + return "Connected" + case "connecting": + return "Connecting..." + case "disconnected": + return "Offline" + } +} From d2067d7ae15e32cf70a97c5d7aee0f103715c8b4 Mon Sep 17 00:00:00 2001 From: yoratoni Date: Wed, 5 Aug 2026 00:05:11 +0200 Subject: [PATCH 03/12] feat: added the Windows 95 sound set and a boot screen Replaced the single error.mp3 with the five original Windows 95 system sounds behind a single audio.play route that caches each element on first use, chord on errors, ding on plain message dialogs, chimes when an update turns up, tada once one finishes downloading, and startup when the app is done booting. Added the boot screen that stands in for the workspace and the welcome view until the project context has finished its initial load, with a tiled progress bar that lands on 100% as the minimum display time runs out, so the startup sound plays over something rather than an empty window. It is gated on a new synchronous first-load IPC flag consumed by the first renderer to ask, so a reload or an HMR refresh boots through instantly and silently, and the welcome view no longer flashes over the project it is about to reopen. --- src/main/assets/sounds/chimes.wav | Bin 0 -> 15932 bytes src/main/assets/sounds/chord.wav | Bin 0 -> 24994 bytes src/main/assets/sounds/ding.wav | Bin 0 -> 11586 bytes src/main/assets/sounds/error.mp3 | Bin 26259 -> 0 bytes src/main/assets/sounds/startup.wav | Bin 0 -> 135876 bytes src/main/assets/sounds/tada.wav | Bin 0 -> 27516 bytes src/main/lib/app/handlers.ts | 21 +++++--- src/main/types/audio.d.ts | 9 ++++ src/preload/lib/audio.ts | 28 ++++++----- src/preload/lib/constants.ts | 25 ++++++++++ src/preload/routes/app.ts | 1 + src/preload/routes/audio.ts | 6 +-- src/renderer/App.tsx | 34 +++++++++++-- src/renderer/components/dialogs/Message.tsx | 14 ++---- src/renderer/components/dialogs/Update.tsx | 17 ++++--- src/renderer/components/views/Loading.tsx | 31 ++++++++++++ src/renderer/hooks/useBoot.tsx | 52 ++++++++++++++++++++ src/renderer/types/assets.d.ts | 8 +++ 18 files changed, 205 insertions(+), 41 deletions(-) create mode 100644 src/main/assets/sounds/chimes.wav create mode 100644 src/main/assets/sounds/chord.wav create mode 100644 src/main/assets/sounds/ding.wav delete mode 100644 src/main/assets/sounds/error.mp3 create mode 100644 src/main/assets/sounds/startup.wav create mode 100644 src/main/assets/sounds/tada.wav create mode 100644 src/main/types/audio.d.ts create mode 100644 src/preload/lib/constants.ts create mode 100644 src/renderer/components/views/Loading.tsx create mode 100644 src/renderer/hooks/useBoot.tsx diff --git a/src/main/assets/sounds/chimes.wav b/src/main/assets/sounds/chimes.wav new file mode 100644 index 0000000000000000000000000000000000000000..e509be3244c672b206fa94cbdef53cb11108a899 GIT binary patch literal 15932 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 diff --git a/src/main/assets/sounds/startup.wav b/src/main/assets/sounds/startup.wav new file mode 100644 index 0000000000000000000000000000000000000000..0e3f936cfe33395ae458c7c77283530095cd6427 GIT binary patch literal 135876 zcmeFaXLn>-dY#$Qr};Ls<_BmvT1y;>GNelECc9ZxS(R1kkw|#&O~AWAI3NtdA`pQ< z00;!U_ui+ktaN3XY*HjeEiGx*nl+;j^F!v@=LV9T`2q8Zm+Y?U1Tx~@bKmp6d+*nr zq6c^HzWJa3=`YH^F1usvas1x@^ow8o;-Bz;|GexM=0E@B7yrXAS{faVwg2Nk{YCH} zuYX*De_Vn8pI2an>mPn)C=`xFqR~hsG~)9(?ft#Iy?p}?k9Q;zA5TxuWV17wsflDf z67&tboeqc7nEGv%9C??i})mqN9oA`1p8oY&04Q z47(izeSE6jH9Qhcq-W+9mseJ~mKWz{^wF_MI2?{dN5_&AQ!}&k3kwT#+3ATybY$4s z-`(EY+}P02*lg?Q?z21HLqkIz*C2n~QeRzBT3l3IQdU{p)YdyV#Qi2y6BDWASS&o^ z8*)3H4ySuK5RQ*e%`UEPY;A9EZ?3N{&hp8za4_Ka`A5RB#KiRM!t&bs#`@awd}d-S z?Dq`zb$7P6cXajiJBIw>(ZqO)&m7ecdOKPhYpW|ODynMgn`|9D0}l7l@bD0e*4Ne6 zTwhaJURG9KSySKA(d!rra61#}$;otToJ9x+S@cjeo|>Lt-8s5^@#g&xKmGjkPaofX z_u}g8Xm4v{ZDo0RWo2!Bb9-lJcV}y3WnpF_9vmL*X=|#jDl6eZOUtWj8=7tHon2j> z?c7LZX~E-%_wL@kd;d{EX?0_JpUW50TS=!`(rD0cy)8FOp5#ln4^A(xpFX|5JUiao zTAiPn93P91YQ?8noXws6gTsTp?X`tWGU9dg*cxlA^pp)P9es{rmMI>O^OlDky&YQi z^0M-Z>bmB37Cm4VXdLs<%j(qnR8>{iHnev3>vnjd)6>(F6Nyo5#O<)#2OSiOSiqZv%RgYqr2ZZ9E@QH3(Ko( ztIG@7$z+tL>Fe%jZ)@-D8E_7d#FCSl+1XkBy@=1%*WOfHSym$3YVPQF2lRL67Z(?> zS8mBaUenax=k&2z*wwtRSdewc-0OJ`jKCF$PtLFH9G;$EUS6CY z?QSg3PNfo~vDoNXYI<&EbN~3_`q}em*XKt&E7|ez&_HK%T~#?>T2Wo!(%#)a=ybW9 z?Ao@bnzF*Y`**(m=Fa`R!isuZuj}vEHZz%IZHL`1Cr`q@O=T8VH+K&Xj}G@)+j&+Y z$hLO5hkU_Ua*A)=J32i(J3T&--KO+f*z+!rS6hA(KQfOMq}W6Du9n*JqWru^j~+iM zuBgRsaYR19-#g^&@3J*imlZvEoTnevwRG8sgYnc<2A?;{qI;cvownwtrWVX@C^$B? zu)cS~gFbu8p50wv%CZLW(Rd;?HM_L2cYN^_gMNN}cCfiP6%ROjTI(uHiwcX1%c|;{ zY+bzrgM)(uJ#3)L;>Y*D{p!mvZ{5E8sHn23({2o5eo+=Lx5w7?xN)+f(edf|m972b zvy02i^W*(3>}xz0;=YHyftw#5ot~ec^0U?Xsbs|O!O`~Nfju~`q@4GR_JP;Y-BMfj z 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 z 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 literal 0 HcmV?d00001 diff --git a/src/main/assets/sounds/tada.wav b/src/main/assets/sounds/tada.wav new file mode 100644 index 0000000000000000000000000000000000000000..b5ca819d99bc79e2517e822f82de66ab715676ac GIT binary patch literal 27516 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 literal 0 HcmV?d00001 diff --git a/src/main/lib/app/handlers.ts b/src/main/lib/app/handlers.ts index e062a06..e7164af 100644 --- a/src/main/lib/app/handlers.ts +++ b/src/main/lib/app/handlers.ts @@ -2,16 +2,23 @@ import CONSTANTS from "@main/lib/constants" import { app, ipcMain } from "electron" /** - * Registers the IPC handler that returns the application version baked in at - * build time by electron-builder (via `extraMetadata.version`), or the value - * from `package.json` when running in development. - * - * Note: uses a synchronous channel so the preload script can resolve the - * version once at load time and expose it as a plain string, avoiding an - * async round-trip on every read from the renderer. + * Whether a renderer has yet to load since the app started, cleared by the first one to ask + * so that a reload or an HMR refresh no longer passes for a launch. + */ +let isFirstLoadPending = true + +/** + * Registers the synchronous IPC handlers returning the application version, baked in at + * build time by electron-builder or read from `package.json` in dev, and whether the + * calling renderer is the first one to load since the app started. */ export function registerAppHandlers() { ipcMain.on(CONSTANTS.ipc.appGetVersion, event => { event.returnValue = app.getVersion() }) + + ipcMain.on(CONSTANTS.ipc.appConsumeFirstLoad, event => { + event.returnValue = isFirstLoadPending + isFirstLoadPending = false + }) } diff --git a/src/main/types/audio.d.ts b/src/main/types/audio.d.ts new file mode 100644 index 0000000..f101555 --- /dev/null +++ b/src/main/types/audio.d.ts @@ -0,0 +1,9 @@ +/** + * The original Windows 95 system sounds the app can play: + * - `chord`: The alert chord every error raises. + * - `ding`: The informational dialog beep. + * - `chimes`: The passive notification chime. + * - `tada`: The completion fanfare. + * - `startup`: The boot sound. + */ +export type AppSound = "chimes" | "chord" | "ding" | "startup" | "tada" diff --git a/src/preload/lib/audio.ts b/src/preload/lib/audio.ts index de062f4..6dd8094 100644 --- a/src/preload/lib/audio.ts +++ b/src/preload/lib/audio.ts @@ -1,19 +1,25 @@ -import errorSound from "@main/assets/sounds/error.mp3" +import CONSTANTS from "@preload/lib/constants" +import type { AppSound } from "@/main/types/audio" /** - * The cached error-chime audio element. + * The audio elements built so far, keyed by sound, so each file is only fetched + * and decoded the first time it is asked for. */ -let errorAudio: HTMLAudioElement | null = null +const cachedSounds = new Map() /** - * Returns the preloaded Win95 error chime. - * @returns The cached error-chime audio element. + * Returns the preloaded audio element for the given system sound, building and + * caching it on first use. + * @param sound The sound to get the audio element for. + * @returns The cached audio element. */ -export function getErrorAudio(): HTMLAudioElement { - if (!errorAudio) { - errorAudio = new Audio(errorSound) - errorAudio.preload = "auto" - } +export function getSound(sound: AppSound): HTMLAudioElement { + const cached = cachedSounds.get(sound) + if (cached) return cached - return errorAudio + const audio = new Audio(CONSTANTS.SOUND_SOURCES[sound]) + audio.preload = "auto" + cachedSounds.set(sound, audio) + + return audio } diff --git a/src/preload/lib/constants.ts b/src/preload/lib/constants.ts new file mode 100644 index 0000000..9caf40b --- /dev/null +++ b/src/preload/lib/constants.ts @@ -0,0 +1,25 @@ +import chimesSound from "@main/assets/sounds/chimes.wav" +import chordSound from "@main/assets/sounds/chord.wav" +import dingSound from "@main/assets/sounds/ding.wav" +import startupSound from "@main/assets/sounds/startup.wav" +import tadaSound from "@main/assets/sounds/tada.wav" +import type { AppSound } from "@/main/types/audio" + +/** + * The constants for the preload script. + */ +const CONSTANTS = { + /** + * The bundled URL of every playable system sound, keyed by the name the + * renderer asks for it under. + */ + SOUND_SOURCES: { + chimes: chimesSound, + chord: chordSound, + ding: dingSound, + startup: startupSound, + tada: tadaSound, + } as Record, +} as const + +export default CONSTANTS diff --git a/src/preload/routes/app.ts b/src/preload/routes/app.ts index 2cf57fc..3c03b2b 100644 --- a/src/preload/routes/app.ts +++ b/src/preload/routes/app.ts @@ -4,6 +4,7 @@ import { ipcRenderer } from "electron" const appApiRoutes: GitgameApi["app"] = { version: ipcRenderer.sendSync(CONSTANTS.ipc.appGetVersion) as string, + isFirstLoad: ipcRenderer.sendSync(CONSTANTS.ipc.appConsumeFirstLoad) as boolean, } export default appApiRoutes diff --git a/src/preload/routes/audio.ts b/src/preload/routes/audio.ts index 53d0876..aec750a 100644 --- a/src/preload/routes/audio.ts +++ b/src/preload/routes/audio.ts @@ -1,9 +1,9 @@ import type { GitgameApi } from "@preload/index" -import { getErrorAudio } from "@preload/lib/audio" +import { getSound } from "@preload/lib/audio" const audioApiRoutes: GitgameApi["audio"] = { - playError: () => { - const audio = getErrorAudio() + play: sound => { + const audio = getSound(sound) audio.currentTime = 0 audio.play().catch(() => null) }, diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 2a5e13c..b278809 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -3,6 +3,7 @@ import FileTreeProvider from "@renderer/components/contexts/FileTree" import ProjectProvider, { useProjectContext } from "@renderer/components/contexts/Project" import StatusProvider from "@renderer/components/contexts/Status" import TreeViewProvider, { useTreeViewContext } from "@renderer/components/contexts/TreeView" +import useBoot from "@renderer/hooks/useBoot" import useMenuActions from "@renderer/hooks/useMenuActions" import useMenuShortcuts from "@renderer/hooks/useMenuShortcuts" import useUpdaterStatusTask from "@renderer/hooks/useUpdaterStatusTask" @@ -10,12 +11,15 @@ import { useEffect, useMemo } from "react" 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 StatusBarField from "@/renderer/components/fields/StatusBar" +import StatusMcpField from "@/renderer/components/fields/StatusMcp" import StatusTaskField from "@/renderer/components/fields/StatusTask" import StatusUpdateField from "@/renderer/components/fields/StatusUpdate" import AppRoot from "@/renderer/components/layouts/AppRoot" import MainLayout from "@/renderer/components/layouts/main" import Workspace from "@/renderer/components/spaces/Workspace" +import Loading from "@/renderer/components/views/Loading" import Welcome from "@/renderer/components/views/Welcome" import APP_CONFIG from "@/renderer/config/app" import { buildTopLevelMenus } from "@/renderer/config/menus" @@ -23,8 +27,9 @@ import { toBrowsableRemoteUrl } from "@/renderer/lib/utils/git" function AppShell() { const { isRegex, isAdvancedOpen, isShowingMyLocksOnly } = useTreeViewContext() - const { currentProject, recentProjects, remoteUrl } = useProjectContext() + const { currentProject, recentProjects, remoteUrl, isLoading } = useProjectContext() const { handleMenuAction } = useMenuActions() + const { isBooting, bootProgress } = useBoot() /** * The browsable HTTPS URL of the current project's `origin` remote, or @@ -40,6 +45,26 @@ function AppShell() { return currentProject ? `${currentProject.name} - ${title}` : title }, [currentProject]) + /** + * The status bar caption, naming the path of the open project, or the state the app + * sits in while there is none. + */ + const statusCaption = useMemo(() => { + if (currentProject) return currentProject.path + return isBooting || isLoading ? "Starting up..." : "No project open" + }, [currentProject, isBooting, isLoading]) + + /** + * The view filling the main area, the boot screen until the app is done starting up, the + * workspace of the open project, or the welcome view once the project context has settled, + * so a reload cannot flash it over the project it is about to reopen. + */ + const mainView = useMemo(() => { + if (isBooting) return + if (currentProject) return + return isLoading ? null : + }, [isBooting, bootProgress, currentProject, isLoading]) + /** * The menus of the application, rebuilt when the recent projects or any * checkable toggle state changes so the check indicators stay in sync. @@ -71,11 +96,14 @@ function AppShell() { -
{currentProject ? : }
+
{mainView}
- {currentProject ? currentProject.path : "No project open"} + {statusCaption} + + + diff --git a/src/renderer/components/dialogs/Message.tsx b/src/renderer/components/dialogs/Message.tsx index 1e8f48b..1db9d78 100644 --- a/src/renderer/components/dialogs/Message.tsx +++ b/src/renderer/components/dialogs/Message.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo } from "react" import { Button, Frame } from "react95" import type { ConfirmDialogOptions } from "@/main/types/dialogs" import TitleBar from "@/renderer/components/bars/Title" +import Icon from "@/renderer/components/ui/Icon" type MessageDialogProps = { options: ConfirmDialogOptions @@ -37,7 +38,8 @@ export default function MessageDialog({ options, variant }: MessageDialogProps) // Chime alongside the dialog rather than from the caller, so an error opened by // the main process (the updater) sounds the same as one raised by the renderer useEffect(() => { - if (variant === "error" || variant === "error-with-details") window.api.audio.playError() + const isError = variant === "error" || variant === "error-with-details" + window.api.audio.play(isError ? "chord" : "ding") }, [variant]) // Confirm on enter, cancel on escape @@ -61,15 +63,7 @@ export default function MessageDialog({ options, variant }: MessageDialogProps)
- {icon && ( - - )} + {icon && }

{options.message}

diff --git a/src/renderer/components/dialogs/Update.tsx b/src/renderer/components/dialogs/Update.tsx index b51e06b..1d8d382 100644 --- a/src/renderer/components/dialogs/Update.tsx +++ b/src/renderer/components/dialogs/Update.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from "react" import { Button, Frame, ProgressBar } from "react95" import type { UpdaterState } from "@/main/types/updater" import TitleBar from "@/renderer/components/bars/Title" +import Icon from "@/renderer/components/ui/Icon" import { reportError } from "@/renderer/lib/utils/errors" import { buildDownloadLabel, @@ -109,6 +110,14 @@ export default function UpdateDialog({ title }: UpdateDialogProps) { return unsubscribe }, []) + // Sound out the transitions worth hearing, keyed on the status alone so the + // progress pushes of a running download cannot retrigger anything + useEffect(() => { + if (state?.status === "available") window.api.audio.play("chimes") + if (state?.status === "downloaded") window.api.audio.play("tada") + if (state?.status === "error") window.api.audio.play("chord") + }, [state?.status]) + // Run the primary action on Enter, close on Escape useEffect(() => { /** @@ -130,13 +139,7 @@ export default function UpdateDialog({ title }: UpdateDialogProps) {
- +

{headline}

diff --git a/src/renderer/components/views/Loading.tsx b/src/renderer/components/views/Loading.tsx new file mode 100644 index 0000000..3bf587b --- /dev/null +++ b/src/renderer/components/views/Loading.tsx @@ -0,0 +1,31 @@ +import computerIcon from "@react95-icons/Computer3_16x16_4.png" +import Icon from "@renderer/components/ui/Icon" +import { ProgressBar } from "react95" +import APP_CONFIG from "@/renderer/config/app" + +type LoadingProps = { + progress: number +} + +/** + * Boot screen shown while the app starts up, standing in for the workspace and the welcome + * view until the project context is done with its initial load, so the Windows 95 startup + * sound plays over something rather than an empty window. + */ +export default function Loading({ progress }: LoadingProps) { + return ( +
+
+
+ + +

{APP_CONFIG.title}

+ +

Starting up...

+ + +
+
+
+ ) +} diff --git a/src/renderer/hooks/useBoot.tsx b/src/renderer/hooks/useBoot.tsx new file mode 100644 index 0000000..0728095 --- /dev/null +++ b/src/renderer/hooks/useBoot.tsx @@ -0,0 +1,52 @@ +import { useProjectContext } from "@renderer/components/contexts/Project" +import { useEffect, useState } from "react" +import CONSTANTS from "@/renderer/lib/constants" + +/** + * The result of the `useBoot` hook. + */ +export type UseBootResult = { + isBooting: boolean + bootProgress: number +} + +/** + * Tracks the startup of the app, which runs until the project context is done with its + * initial load and the boot screen has been up for its minimum duration, then sounds the + * startup chime, a reload boots through instantly and silently. + * @returns Whether the app is still booting, along with the boot progress as a percentage. + */ +export default function useBoot(): UseBootResult { + const { isLoading } = useProjectContext() + + const [bootProgress, setBootProgress] = useState(window.api.app.isFirstLoad ? 0 : 100) + + const isBooting = window.api.app.isFirstLoad && (isLoading || bootProgress < 100) + + // Ramp the progress from a single interval started on mount, so the bar lands on + // 100% exactly as the minimum duration runs out + useEffect(() => { + if (!window.api.app.isFirstLoad) return + + const startedAt = performance.now() + + const handle = window.setInterval(() => { + const elapsed = performance.now() - startedAt + const ratio = Math.min(1, elapsed / CONSTANTS.BOOT_SCREEN_MINIMUM_DURATION_MS) + + setBootProgress(Math.round(ratio * 100)) + if (ratio === 1) window.clearInterval(handle) + }, CONSTANTS.BOOT_SCREEN_PROGRESS_TICK_MS) + + return () => window.clearInterval(handle) + }, []) + + // Hand the boot screen over to the Win95 startup sound once it is done, from the main + // window only so the dialog windows stay silent apart from their own chimes + useEffect(() => { + if (!window.api.app.isFirstLoad || isBooting) return + window.api.audio.play("startup") + }, [isBooting]) + + return { isBooting, bootProgress } +} diff --git a/src/renderer/types/assets.d.ts b/src/renderer/types/assets.d.ts index 13fe9e8..f6ba5c8 100644 --- a/src/renderer/types/assets.d.ts +++ b/src/renderer/types/assets.d.ts @@ -22,6 +22,14 @@ declare module "*.mp3" { export default src } +/** + * Ambient declaration for static WAV asset imports resolved by Vite at build time. + */ +declare module "*.wav" { + const src: string + export default src +} + /** * Ambient declaration for direct PNG imports through the `@react95-icons/*` alias (for React95). */ From 6b42ac7b4b7a2dd4b808ba6ac88c71ebec8e7b0c Mon Sep 17 00:00:00 2001 From: yoratoni Date: Wed, 5 Aug 2026 00:05:16 +0200 Subject: [PATCH 04/12] feat: reworked the status bar update chip into an actionable field The chip used to be a label that only reopened the dialog, it now carries the download icon, a short label naming the version, and either a live tile progress bar while the installer is being fetched or an Update/Restart button that runs the matching action directly. Platforms that cannot auto-install still fall back to opening the dialog, and the task chip's padding was aligned with the other fields. --- src/renderer/components/fields/StatusTask.tsx | 4 +- .../components/fields/StatusUpdate.tsx | 67 ++++++++++++------- src/renderer/lib/utils/updater.ts | 8 +-- 3 files changed, 50 insertions(+), 29 deletions(-) diff --git a/src/renderer/components/fields/StatusTask.tsx b/src/renderer/components/fields/StatusTask.tsx index 44f86a8..ea06f9a 100644 --- a/src/renderer/components/fields/StatusTask.tsx +++ b/src/renderer/components/fields/StatusTask.tsx @@ -51,9 +51,9 @@ export default function StatusTaskField({ className }: StatusTaskFieldProps) { return ( -
+
{currentTask.label} diff --git a/src/renderer/components/fields/StatusUpdate.tsx b/src/renderer/components/fields/StatusUpdate.tsx index 1858e51..5d13287 100644 --- a/src/renderer/components/fields/StatusUpdate.tsx +++ b/src/renderer/components/fields/StatusUpdate.tsx @@ -1,9 +1,10 @@ import { cn } from "@cybearl/cypack/frontend" -import Tooltip from "@renderer/components/ui/Tooltip" +import downloadIcon from "@react95-icons/Download_16x16_4.png" +import Icon from "@renderer/components/ui/Icon" import useUpdaterState from "@renderer/hooks/useUpdaterState" +import { buildUpdaterStatusLabel } from "@renderer/lib/utils/updater" import { useCallback, useMemo } from "react" -import { Frame } from "react95" -import { buildUpdaterStatusLabel } from "@/renderer/lib/utils/updater" +import { Button, Frame, ProgressBar } from "react95" type StatusUpdateFieldProps = { className?: string @@ -13,37 +14,57 @@ export default function StatusUpdateField({ className }: StatusUpdateFieldProps) const state = useUpdaterState() /** - * The pending update advertised in the status bar, or `null` when there is - * nothing waiting on the user. + * The chip label matching the current state, `null` when there is nothing + * pending on the updater and the chip should stay hidden. */ const label = useMemo(() => (state ? buildUpdaterStatusLabel(state) : null), [state]) /** - * Brings the update dialog back up, without starting another check. + * The download progress as a rounded percentage, `0` before the first + * `download-progress` event lands so the bar starts empty. */ - const handleClick = useCallback(() => { + const downloadPercent = Math.round(state?.progress?.percent ?? 0) + + /** + * Runs the action that matches the current state, restart when the + * installer is staged, download when it is only available, falls back to + * opening the dialog so non-Windows platforms still have a path forward. + */ + const handleAction = useCallback(() => { + if (state?.status === "downloaded") { + window.api.updater.install() + return + } + + if (state?.canAutoInstall) { + window.api.updater.download() + return + } + window.api.updater.openDialog() - }, []) + }, [state?.canAutoInstall, state?.status]) - if (!label) return null + if (!state || !label) return null return ( - - - +
+ + {label} - - +
+ + {state.status === "downloading" ? ( + + ) : ( + + )} + ) } diff --git a/src/renderer/lib/utils/updater.ts b/src/renderer/lib/utils/updater.ts index 6d9a725..15dfb3a 100644 --- a/src/renderer/lib/utils/updater.ts +++ b/src/renderer/lib/utils/updater.ts @@ -91,14 +91,14 @@ export function buildUpdaterDetails(state: UpdaterState): string | null { } /** - * Builds the label of the status bar's update indicator, which stands in for the - * dialog once it has been dismissed. + * Builds the short label of the status bar's update chip, the button verb + * carries the action so the label only names the noun. * @param state The current updater state. * @returns The label, or `null` when there is nothing pending to advertise. */ export function buildUpdaterStatusLabel(state: UpdaterState): string | null { - if (state.status === "downloaded") return `Restart to update to ${state.version}` - if (state.status === "available") return `Update to ${state.version} available` + if (state.status === "downloading") return `Downloading: ${state.version}` + if (state.status === "downloaded" || state.status === "available") return `Update: ${state.version}` return null } From b8188d44d6b7e6044168682e244efe4f5f5dd792 Mon Sep 17 00:00:00 2001 From: yoratoni Date: Wed, 5 Aug 2026 00:05:22 +0200 Subject: [PATCH 05/12] refactor: split the details pane into sections and extracted the selection lock actions Broke the 260-line pane into a toolbar, a node section, a project section, and a row primitive, and moved the lockable/mine/others path sets together with the lock, unlock, and force-unlock actions into useSelectionLocks so the pane and the tree context menu derive them the same way. The project section now reports the project-wide lock counts through a new countLocksByOwnership helper, shown whenever nothing is selected. --- src/renderer/components/panes/Details.tsx | 260 ------------------ .../components/panes/details/NodeSection.tsx | 62 +++++ .../panes/details/ProjectSection.tsx | 32 +++ src/renderer/components/panes/details/Row.tsx | 16 ++ .../components/panes/details/Toolbar.tsx | 56 ++++ .../components/panes/details/index.tsx | 47 ++++ src/renderer/components/spaces/Workspace.tsx | 2 +- src/renderer/hooks/useSelectionLocks.tsx | 91 ++++++ src/renderer/lib/utils/lockStates.ts | 34 ++- 9 files changed, 334 insertions(+), 266 deletions(-) delete mode 100644 src/renderer/components/panes/Details.tsx create mode 100644 src/renderer/components/panes/details/NodeSection.tsx create mode 100644 src/renderer/components/panes/details/ProjectSection.tsx create mode 100644 src/renderer/components/panes/details/Row.tsx create mode 100644 src/renderer/components/panes/details/Toolbar.tsx create mode 100644 src/renderer/components/panes/details/index.tsx create mode 100644 src/renderer/hooks/useSelectionLocks.tsx diff --git a/src/renderer/components/panes/Details.tsx b/src/renderer/components/panes/Details.tsx deleted file mode 100644 index 2a7f3eb..0000000 --- a/src/renderer/components/panes/Details.tsx +++ /dev/null @@ -1,260 +0,0 @@ -import { cn } from "@cybearl/cypack/frontend" -import lockIcon from "@react95-icons/Lock_16x16_4.png" -import refreshIcon from "@react95-icons/Refresh_16x16_4.png" -import { useFileTreeContext } from "@renderer/components/contexts/FileTree" -import { useProjectContext } from "@renderer/components/contexts/Project" -import { useTreeViewContext } from "@renderer/components/contexts/TreeView" -import Tooltip from "@renderer/components/ui/Tooltip" -import { useCallback, useMemo } from "react" -import { Button, GroupBox, ScrollView, Separator, Toolbar } from "react95" -import { collectLockablePaths, collectLockedPaths } from "@/renderer/lib/utils/lockStates" -import { reportLockFailures } from "@/renderer/lib/utils/treeView" - -type DetailsPaneProps = { - className?: string -} - -export default function DetailsPane({ className }: DetailsPaneProps) { - const { currentProject } = useProjectContext() - const { locksByPath, refresh, lock, unlock } = useFileTreeContext() - const { selectedNode } = useTreeViewContext() - - /** - * The lockable file paths within the selected node's subtree, empty when - * nothing is selected. - */ - const lockablePaths = useMemo(() => (selectedNode ? collectLockablePaths(selectedNode) : []), [selectedNode]) - - /** - * The paths within the selected subtree locked by the current user. - */ - const minePaths = useMemo( - () => (selectedNode ? collectLockedPaths(selectedNode, locksByPath, true) : []), - [selectedNode, locksByPath], - ) - - /** - * The paths within the selected subtree locked by other users. - */ - const othersPaths = useMemo( - () => (selectedNode ? collectLockedPaths(selectedNode, locksByPath, false) : []), - [selectedNode, locksByPath], - ) - - /** - * The overall lock counts across the whole project, split between the - * current user and others. - */ - const overallCounts = useMemo(() => { - let mine = 0 - let others = 0 - - for (const entry of locksByPath.values()) { - if (entry.isMine) mine++ - else others++ - } - - return { mine, others } - }, [locksByPath]) - - /** - * The active lock on the selected node when it is a locked file, or - * `undefined` for folders and unlocked files. - */ - const selectedFileLock = useMemo(() => { - if (selectedNode?.type !== "file") return undefined - return locksByPath.get(selectedNode.path) - }, [selectedNode, locksByPath]) - - /** - * The formatted local timestamp for the selected file's lock, if any. - */ - const lockedAtLabel = useMemo(() => { - if (!selectedFileLock?.lockedAt) return null - return new Date(selectedFileLock.lockedAt).toLocaleString() - }, [selectedFileLock]) - - /** - * Refreshes the file tree and its lock state. - */ - const handleRefresh = useCallback(() => { - refresh() - }, [refresh]) - - /** - * Locks every lockable file within the selected subtree. - */ - const handleLock = useCallback(async () => { - reportLockFailures(await lock(lockablePaths)) - }, [lock, lockablePaths]) - - /** - * Unlocks every file within the selected subtree locked by the current user. - */ - const handleUnlock = useCallback(async () => { - reportLockFailures(await unlock(minePaths, false)) - }, [unlock, minePaths]) - - /** - * Force-unlocks every file within the selected subtree locked by other - * users, after native confirmation. - */ - const handleForceUnlock = useCallback(async () => { - const confirmed = await window.api.dialogs.confirm({ - title: "Force unlock", - message: `Force unlock ${othersPaths.length} file${othersPaths.length === 1 ? "" : "s"} locked by other users?`, - details: "Forcing may discard work the lock owner has not pushed yet.", - confirmLabel: "Force unlock", - isDestructive: true, - }) - - if (!confirmed) return - - reportLockFailures(await unlock(othersPaths, true)) - }, [unlock, othersPaths]) - - return ( -
- - - - - - - - - - - - - - - - - -
- {selectedNode ? ( - -
-
- Name: - {selectedNode.name} -
- -
- Path: - {selectedNode.path} -
- -
- Lockable: - {selectedNode.isLockable ? "Yes" : "No"} -
- - {selectedNode.type === "file" && selectedFileLock && ( - <> -
- Locked by: - - {selectedFileLock.isMine - ? `${selectedFileLock.owner} (you)` - : selectedFileLock.owner} - -
- - {lockedAtLabel && ( -
- Locked at: - {lockedAtLabel} -
- )} - - )} - - {selectedNode.type === "folder" && ( - <> -
- Lockable files: - {lockablePaths.length} -
- -
- Locked by you: - {minePaths.length} -
- -
- Locked by others: - {othersPaths.length} -
- - )} -
-
- ) : ( - -
-
- Name: - {currentProject?.name ?? "No project open"} -
- - {currentProject?.path && ( -
- Path: - {currentProject.path} -
- )} - -
- Locked by you: - {overallCounts.mine} -
- -
- Locked by others: - {overallCounts.others} -
-
- -
- Select a file or folder in the tree to see its lock details. -
-
- )} -
-
-
- ) -} diff --git a/src/renderer/components/panes/details/NodeSection.tsx b/src/renderer/components/panes/details/NodeSection.tsx new file mode 100644 index 0000000..a4c64a9 --- /dev/null +++ b/src/renderer/components/panes/details/NodeSection.tsx @@ -0,0 +1,62 @@ +import { useFileTreeContext } from "@renderer/components/contexts/FileTree" +import DetailsRow from "@renderer/components/panes/details/Row" +import { useMemo } from "react" +import { GroupBox } from "react95" +import type { FileTreeNode } from "@/main/types/fileTree" + +type NodeSectionProps = { + node: FileTreeNode + lockablePaths: string[] + minePaths: string[] + othersPaths: string[] +} + +export default function NodeSection({ node, lockablePaths, minePaths, othersPaths }: NodeSectionProps) { + const { locksByPath } = useFileTreeContext() + + /** + * The active lock on the node when it is a locked file, or `undefined` for + * folders and unlocked files. + */ + const nodeLock = useMemo(() => { + if (node.type !== "file") return undefined + return locksByPath.get(node.path) + }, [node, locksByPath]) + + /** + * The formatted local timestamp for the node's lock, if any. + */ + const lockedAtLabel = useMemo(() => { + if (!nodeLock?.lockedAt) return null + return new Date(nodeLock.lockedAt).toLocaleString() + }, [nodeLock]) + + return ( + +
+ + + + + {nodeLock && ( + <> + + + {lockedAtLabel && } + + )} + + {node.type === "folder" && ( + <> + + + + + )} +
+
+ ) +} diff --git a/src/renderer/components/panes/details/ProjectSection.tsx b/src/renderer/components/panes/details/ProjectSection.tsx new file mode 100644 index 0000000..e566956 --- /dev/null +++ b/src/renderer/components/panes/details/ProjectSection.tsx @@ -0,0 +1,32 @@ +import { useFileTreeContext } from "@renderer/components/contexts/FileTree" +import { useProjectContext } from "@renderer/components/contexts/Project" +import DetailsRow from "@renderer/components/panes/details/Row" +import { useMemo } from "react" +import { GroupBox } from "react95" +import { countLocksByOwnership } from "@/renderer/lib/utils/lockStates" + +export default function ProjectSection() { + const { currentProject } = useProjectContext() + const { locksByPath } = useFileTreeContext() + + /** + * The overall lock counts across the whole project, split between the + * current user and others. + */ + const { mine, others } = useMemo(() => countLocksByOwnership(locksByPath), [locksByPath]) + + return ( + +
+ + + {currentProject?.path && } + + + +
+ +
Select a file or folder in the tree to see its lock details.
+
+ ) +} diff --git a/src/renderer/components/panes/details/Row.tsx b/src/renderer/components/panes/details/Row.tsx new file mode 100644 index 0000000..8c40244 --- /dev/null +++ b/src/renderer/components/panes/details/Row.tsx @@ -0,0 +1,16 @@ +import type { ReactNode } from "react" + +type DetailsRowProps = { + label: string + value: ReactNode + className?: string +} + +export default function DetailsRow({ label, value, className }: DetailsRowProps) { + return ( +
+ {label}: + {value} +
+ ) +} diff --git a/src/renderer/components/panes/details/Toolbar.tsx b/src/renderer/components/panes/details/Toolbar.tsx new file mode 100644 index 0000000..4491e55 --- /dev/null +++ b/src/renderer/components/panes/details/Toolbar.tsx @@ -0,0 +1,56 @@ +import lockIcon from "@react95-icons/Lock_16x16_4.png" +import refreshIcon from "@react95-icons/Refresh_16x16_4.png" +import { useFileTreeContext } from "@renderer/components/contexts/FileTree" +import Icon from "@renderer/components/ui/Icon" +import Tooltip from "@renderer/components/ui/Tooltip" +import { Button, Separator, Toolbar } from "react95" + +type DetailsToolbarProps = { + lockablePaths: string[] + minePaths: string[] + othersPaths: string[] + onLock: () => void + onUnlock: () => void + onForceUnlock: () => void +} + +export default function DetailsToolbar({ + lockablePaths, + minePaths, + othersPaths, + onLock, + onUnlock, + onForceUnlock, +}: DetailsToolbarProps) { + const { refresh } = useFileTreeContext() + + return ( + + + + + + + + + + + + + + ) +} diff --git a/src/renderer/components/panes/details/index.tsx b/src/renderer/components/panes/details/index.tsx new file mode 100644 index 0000000..63c9207 --- /dev/null +++ b/src/renderer/components/panes/details/index.tsx @@ -0,0 +1,47 @@ +import { cn } from "@cybearl/cypack/frontend" +import { useTreeViewContext } from "@renderer/components/contexts/TreeView" +import NodeSection from "@renderer/components/panes/details/NodeSection" +import ProjectSection from "@renderer/components/panes/details/ProjectSection" +import DetailsToolbar from "@renderer/components/panes/details/Toolbar" +import useSelectionLocks from "@renderer/hooks/useSelectionLocks" +import { ScrollView, Separator } from "react95" + +type DetailsPaneProps = { + className?: string +} + +export default function DetailsPane({ className }: DetailsPaneProps) { + const { selectedNode } = useTreeViewContext() + const { lockablePaths, minePaths, othersPaths, lockSelection, unlockSelection, forceUnlockSelection } = + useSelectionLocks() + + return ( +
+ + + + + +
+ {selectedNode ? ( + + ) : ( + + )} +
+
+
+ ) +} diff --git a/src/renderer/components/spaces/Workspace.tsx b/src/renderer/components/spaces/Workspace.tsx index e2ce2ab..e205b28 100644 --- a/src/renderer/components/spaces/Workspace.tsx +++ b/src/renderer/components/spaces/Workspace.tsx @@ -1,6 +1,6 @@ import useResizablePaneWidth from "@renderer/hooks/useResizablePaneWidth" import { useCallback, useEffect, useMemo } from "react" -import DetailsPane from "@/renderer/components/panes/Details" +import DetailsPane from "@/renderer/components/panes/details" import FilesPane from "@/renderer/components/panes/Files" import WORKSPACE_CONFIG from "@/renderer/config/workspace" diff --git a/src/renderer/hooks/useSelectionLocks.tsx b/src/renderer/hooks/useSelectionLocks.tsx new file mode 100644 index 0000000..53792c2 --- /dev/null +++ b/src/renderer/hooks/useSelectionLocks.tsx @@ -0,0 +1,91 @@ +import { useFileTreeContext } from "@renderer/components/contexts/FileTree" +import { useTreeViewContext } from "@renderer/components/contexts/TreeView" +import { useCallback, useMemo } from "react" +import { collectLockablePaths, collectLockedPaths } from "@/renderer/lib/utils/lockStates" +import { reportLockFailures } from "@/renderer/lib/utils/treeView" + +/** + * The value returned by the `useSelectionLocks` hook. + */ +type UseSelectionLocksResult = { + lockablePaths: string[] + minePaths: string[] + othersPaths: string[] + lockSelection: () => Promise + unlockSelection: () => Promise + forceUnlockSelection: () => Promise +} + +/** + * Derives the lockable and locked paths within the selected node's subtree and + * exposes the lock actions operating on them, every path set is empty while + * nothing is selected, which leaves the actions inert. + * @returns The selection's path sets, and its lock, unlock and force-unlock actions. + */ +export default function useSelectionLocks(): UseSelectionLocksResult { + const { locksByPath, lock, unlock } = useFileTreeContext() + const { selectedNode } = useTreeViewContext() + + /** + * The lockable file paths within the selected node's subtree, empty when + * nothing is selected. + */ + const lockablePaths = useMemo(() => (selectedNode ? collectLockablePaths(selectedNode) : []), [selectedNode]) + + /** + * The paths within the selected subtree locked by the current user. + */ + const minePaths = useMemo( + () => (selectedNode ? collectLockedPaths(selectedNode, locksByPath, true) : []), + [selectedNode, locksByPath], + ) + + /** + * The paths within the selected subtree locked by other users. + */ + const othersPaths = useMemo( + () => (selectedNode ? collectLockedPaths(selectedNode, locksByPath, false) : []), + [selectedNode, locksByPath], + ) + + /** + * Locks every lockable file within the selected subtree. + */ + const lockSelection = useCallback(async () => { + reportLockFailures(await lock(lockablePaths)) + }, [lock, lockablePaths]) + + /** + * Unlocks every file within the selected subtree locked by the current user. + */ + const unlockSelection = useCallback(async () => { + reportLockFailures(await unlock(minePaths, false)) + }, [unlock, minePaths]) + + /** + * Force-unlocks every file within the selected subtree locked by other + * users, after native confirmation. + */ + const forceUnlockSelection = useCallback(async () => { + const confirmed = await window.api.dialogs.confirm({ + title: "Force unlock", + message: `Force unlock ${othersPaths.length} file${othersPaths.length === 1 ? "" : "s"} locked by other users?`, + details: "Forcing may discard work the lock owner has not pushed yet.", + confirmLabel: "Force unlock", + isDestructive: true, + }) + + if (!confirmed) return + + reportLockFailures(await unlock(othersPaths, true)) + }, [unlock, othersPaths]) + + return { + lockablePaths, + minePaths, + othersPaths, + lockSelection, + unlockSelection, + forceUnlockSelection, + } +} diff --git a/src/renderer/lib/utils/lockStates.ts b/src/renderer/lib/utils/lockStates.ts index f259f17..390d66c 100644 --- a/src/renderer/lib/utils/lockStates.ts +++ b/src/renderer/lib/utils/lockStates.ts @@ -2,11 +2,9 @@ import type { FileTreeNode } from "@/main/types/fileTree" import type { LfsLock } from "@/main/types/lfsCommands" /** - * The lock state of a tree node: - * - `unlockable`: the node has nothing lockable (no lock button). - * - `unlocked`: lockable, but nothing is locked. - * - `locked`: fully locked (a file, or a folder whose lockable descendants are all locked). - * - `partial`: a folder with some, but not all, lockable descendants locked. + * The lock state of a tree node, `unlockable` when nothing beneath it is + * lockable, `unlocked`/`locked` when all lockable files are in that state, + * `partial` when a folder has a mix of locked and unlocked descendants. */ export type NodeLockState = "unlockable" | "unlocked" | "locked" | "partial" @@ -153,6 +151,32 @@ export function collectLockablePaths(node: FileTreeNode): string[] { return paths } +/** + * The number of locked files held by the current user and by everyone else. + */ +export type LockOwnershipCounts = { + mine: number + others: number +} + +/** + * Counts the active locks of a whole project, split between the ones held by + * the current user and the ones held by others. + * @param locksByPath The active locks keyed by repository-relative path. + * @returns The lock counts split by ownership. + */ +export function countLocksByOwnership(locksByPath: Map): LockOwnershipCounts { + let mine = 0 + let others = 0 + + for (const lock of locksByPath.values()) { + if (lock.isMine) mine++ + else others++ + } + + return { mine, others } +} + /** * Collects the paths of every locked file within a node's subtree, filtered by * whether the current user owns the lock. From f879eb9545397d7b046c284c75a06c74d87ac945 Mon Sep 17 00:00:00 2001 From: yoratoni Date: Wed, 5 Aug 2026 00:05:28 +0200 Subject: [PATCH 06/12] refactor: extracted an Icon component for the pixel-art icons Every raw img tag repeated the same quartet of size, pixelated rendering, sync decoding, and high fetch priority, and drifted on the gutter between an icon and its label. Replaced them with one Icon component taking a size scale and an inline flag, with the sizing classes moved into the renderer constants, and gave the menu bar a right-hand slot for the fields that will sit next to the menus. --- src/renderer/components/bars/Menu.tsx | 51 +++++++++++--------- src/renderer/components/bars/Title.tsx | 13 +++-- src/renderer/components/dropdowns/Menu.tsx | 15 ++---- src/renderer/components/panes/Files.tsx | 9 +--- src/renderer/components/rows/FlatResults.tsx | 4 +- src/renderer/components/ui/EmptyState.tsx | 10 ++-- src/renderer/components/ui/Icon.tsx | 29 +++++++++++ src/renderer/components/views/Welcome.tsx | 13 +++-- src/renderer/lib/utils/treeView.tsx | 23 ++------- 9 files changed, 84 insertions(+), 83 deletions(-) create mode 100644 src/renderer/components/ui/Icon.tsx diff --git a/src/renderer/components/bars/Menu.tsx b/src/renderer/components/bars/Menu.tsx index d56e362..359afde 100644 --- a/src/renderer/components/bars/Menu.tsx +++ b/src/renderer/components/bars/Menu.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react" +import { type ReactNode, useCallback, useEffect, useRef, useState } from "react" import { Button, Separator } from "react95" import MenuDropdown from "@/renderer/components/dropdowns/Menu" import type { MenuAction, TopLevelMenu } from "@/renderer/config/menus" @@ -6,9 +6,10 @@ import type { MenuAction, TopLevelMenu } from "@/renderer/config/menus" type MenuBarProps = { menus: TopLevelMenu[] onAction: (action: MenuAction) => void + rightSlot?: ReactNode } -export default function MenuBar({ menus, onAction }: MenuBarProps) { +export default function MenuBar({ menus, onAction, rightSlot }: MenuBarProps) { const containerRef = useRef(null) const [openIndex, setOpenIndex] = useState(null) @@ -62,28 +63,32 @@ export default function MenuBar({ menus, onAction }: MenuBarProps) { return (
-
- {menus.map((menu, index) => { - return ( -
- +
+
+ {menus.map((menu, index) => { + return ( +
+ - {openIndex === index && ( - - )} -
- ) - })} + {openIndex === index && ( + + )} +
+ ) + })} +
+ + {rightSlot &&
{rightSlot}
}
diff --git a/src/renderer/components/bars/Title.tsx b/src/renderer/components/bars/Title.tsx index 493a885..9acdfcc 100644 --- a/src/renderer/components/bars/Title.tsx +++ b/src/renderer/components/bars/Title.tsx @@ -1,5 +1,6 @@ import { cn } from "@cybearl/cypack/frontend" import CONSTANTS from "@main/lib/constants" +import Icon from "@renderer/components/ui/Icon" import useWindowStates from "@renderer/hooks/useWindowStates" import type { CSSProperties } from "react" import { Button } from "react95" @@ -30,10 +31,8 @@ export default function TitleBar({ title, icon, mode = "full", onClose }: TitleB } onDoubleClick={() => window.api.windows.toggleMaximize()} > -
- {icon && !window.api.platform.isMacOS && ( - - )} +
+ {icon && !window.api.platform.isMacOS && } {title} @@ -51,7 +50,7 @@ export default function TitleBar({ title, icon, mode = "full", onClose }: TitleB onClick={() => window.api.windows.minimize()} className="w-6! h-5.5! min-w-0!" > - minimize + )} @@ -72,7 +71,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" > - close +
)} diff --git a/src/renderer/components/dropdowns/Menu.tsx b/src/renderer/components/dropdowns/Menu.tsx index aa3b413..a4b72f3 100644 --- a/src/renderer/components/dropdowns/Menu.tsx +++ b/src/renderer/components/dropdowns/Menu.tsx @@ -1,5 +1,6 @@ import { cn } from "@cybearl/cypack/frontend" import tickIcon from "@react95-icons/Tick_16x16_4.png" +import Icon from "@renderer/components/ui/Icon" import { useCallback, useState } from "react" import { MenuList, MenuListItem, Separator } from "react95" import type { MenuAction, TopLevelMenuEntry } from "@/renderer/config/menus" @@ -41,18 +42,8 @@ export default function MenuDropdown({ items, onAction, onDismiss, className }: onDismiss() }} > - - {entry.isChecked && ( - - - - )} + + {entry.isChecked && } {entry.label} diff --git a/src/renderer/components/panes/Files.tsx b/src/renderer/components/panes/Files.tsx index 047b82c..3c6faf9 100644 --- a/src/renderer/components/panes/Files.tsx +++ b/src/renderer/components/panes/Files.tsx @@ -1,6 +1,7 @@ import { cn } from "@cybearl/cypack/frontend" import lockIcon from "@react95-icons/Lock_16x16_4.png" import { useTreeViewContext } from "@renderer/components/contexts/TreeView" +import Icon from "@renderer/components/ui/Icon" import { type ChangeEvent, type CSSProperties, useCallback } from "react" import { Button, TextInput } from "react95" import TreeView from "@/renderer/components/views/TreeView" @@ -131,13 +132,7 @@ export default function FilesPane({ className, style }: FilesPaneProps) { aria-label="Show only files I have locked" title="Show only files I have locked" > - +
diff --git a/src/renderer/components/rows/FlatResults.tsx b/src/renderer/components/rows/FlatResults.tsx index 3e4a47a..0c1e2c6 100644 --- a/src/renderer/components/rows/FlatResults.tsx +++ b/src/renderer/components/rows/FlatResults.tsx @@ -73,8 +73,8 @@ export default function FlatResultsRow({ index, style, matches }: RowComponentPr onClick={() => select(node.path)} onContextMenu={event => openMenu(event, node)} > -
- {icon} +
+ {icon} {node.name}
{parentPath && {parentPath}} diff --git a/src/renderer/components/ui/EmptyState.tsx b/src/renderer/components/ui/EmptyState.tsx index c3e5940..994ad41 100644 --- a/src/renderer/components/ui/EmptyState.tsx +++ b/src/renderer/components/ui/EmptyState.tsx @@ -1,3 +1,5 @@ +import Icon from "@renderer/components/ui/Icon" + type EmptyStateProps = { icon: string title: string @@ -7,13 +9,7 @@ type EmptyStateProps = { export default function EmptyState({ icon, title, description }: EmptyStateProps) { return (
- +
{title}
{description}
diff --git a/src/renderer/components/ui/Icon.tsx b/src/renderer/components/ui/Icon.tsx new file mode 100644 index 0000000..b3b167f --- /dev/null +++ b/src/renderer/components/ui/Icon.tsx @@ -0,0 +1,29 @@ +import { cn } from "@cybearl/cypack/frontend" +import CONSTANTS from "@/renderer/lib/constants" + +type IconProps = { + src: string + alt?: string + size?: keyof typeof CONSTANTS.ICON_SIZE_CLASSES + isInline?: boolean + className?: string +} + +export default function Icon({ src, alt = "", size = "sm", isInline = false, className }: IconProps) { + return ( + {alt} + ) +} diff --git a/src/renderer/components/views/Welcome.tsx b/src/renderer/components/views/Welcome.tsx index 29a606e..280c67a 100644 --- a/src/renderer/components/views/Welcome.tsx +++ b/src/renderer/components/views/Welcome.tsx @@ -1,22 +1,21 @@ import computerIcon from "@react95-icons/Computer3_16x16_4.png" import { useProjectContext } from "@renderer/components/contexts/Project" +import Icon from "@renderer/components/ui/Icon" import { Button } from "react95" /** * Welcome / empty-state view shown when no project is open, prompts the user to open a UE - * project folder. Open failures (missing folder, non-UE folder, ...) surface through the - * native error-with-details dialog fired by `ProjectContext`, not inline here. + * project folder, open failures surface through the native error dialog fired by + * `ProjectContext`, not inline here. */ export default function Welcome() { - const projectContext = useProjectContext() - - if (projectContext.isLoading) return null + const { addLocalProject } = useProjectContext() return (
- +

Welcome to GitGame

@@ -25,7 +24,7 @@ export default function Welcome() { .uproject file at its root to get started.

-
diff --git a/src/renderer/lib/utils/treeView.tsx b/src/renderer/lib/utils/treeView.tsx index d3ce9fa..5c3681d 100644 --- a/src/renderer/lib/utils/treeView.tsx +++ b/src/renderer/lib/utils/treeView.tsx @@ -2,6 +2,7 @@ import { cn } from "@cybearl/cypack/frontend" import fileIcon from "@react95-icons/FileText_16x16_4.png" import folderIcon from "@react95-icons/Folder_16x16_4.png" import lockIcon from "@react95-icons/Lock_16x16_4.png" +import Icon from "@renderer/components/ui/Icon" import Tooltip from "@renderer/components/ui/Tooltip" import type { TreeLeaf } from "react95" import type { FileTreeNode } from "@/main/types/fileTree" @@ -64,16 +65,10 @@ export function renderLockIcon( return ( - Locked ) @@ -86,15 +81,7 @@ export function renderLockIcon( * @returns The type icon element. */ export function renderTypeIcon(node: FileTreeNode) { - return ( - - ) + return } /** @@ -216,7 +203,7 @@ export function resolveNode(target: HTMLElement, container: HTMLElement, roots: let item = target.closest('li[role="treeitem"]') while (item && container.contains(item)) { - const list = item.parentElement + const { parentElement: list } = item if (!list) break const siblings = Array.from(list.children).filter(child => child.getAttribute("role") === "treeitem") From 6b5eea71714188b3be87621e3b2e98795cb061d4 Mon Sep 17 00:00:00 2001 From: yoratoni Date: Wed, 5 Aug 2026 00:05:35 +0200 Subject: [PATCH 07/12] fix: made the file tree context menu open on any node Right-clicking a file with no lockable attribute or a folder with no lockable descendant did nothing at all, the menu now opens on every node and each entry disables itself when it has nothing to act on, force unlock included rather than appearing and disappearing with its separator. --- src/renderer/components/contexts/TreeView.tsx | 13 ++++++++----- .../components/menus/TreeViewContextMenu.tsx | 9 +++------ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/renderer/components/contexts/TreeView.tsx b/src/renderer/components/contexts/TreeView.tsx index 15cbcf4..d5a4bb1 100644 --- a/src/renderer/components/contexts/TreeView.tsx +++ b/src/renderer/components/contexts/TreeView.tsx @@ -305,21 +305,24 @@ export default function TreeViewProvider({ children }: TreeViewProviderProps) { }, [excludeText]) /** - * Opens the lock/unlock context menu for the given node, if it is lockable. + * Opens the lock/unlock context menu for the given node, opening on every node + * rather than lockable ones only so a right-click always answers, each entry + * disables itself when it has nothing to act on. * @param event The mouse event. * @param node The node the menu should target. */ const openMenu = useCallback( (event: MouseEvent, node: FileTreeNode) => { - if (!node.isLockable) return - event.preventDefault() + + const lockablePaths = collectLockablePaths(node) + setMenu({ x: event.clientX, y: event.clientY, node, - canLock: (lockStates.get(node.path) ?? "unlockable") !== "locked", - lockablePaths: collectLockablePaths(node), + canLock: lockablePaths.length > 0 && (lockStates.get(node.path) ?? "unlockable") !== "locked", + lockablePaths, minePaths: collectLockedPaths(node, locksByPath, true), othersPaths: collectLockedPaths(node, locksByPath, false), }) diff --git a/src/renderer/components/menus/TreeViewContextMenu.tsx b/src/renderer/components/menus/TreeViewContextMenu.tsx index d453892..b7555da 100644 --- a/src/renderer/components/menus/TreeViewContextMenu.tsx +++ b/src/renderer/components/menus/TreeViewContextMenu.tsx @@ -49,12 +49,9 @@ export default function TreeViewContextMenu() { Unlock - {menu.othersPaths.length > 0 && ( - <> - - Force unlock - - )} + + Force unlock + {matches !== null && ( <> From 4e84a91a4e8b67e2858c99bc4c098dd04b016db2 Mon Sep 17 00:00:00 2001 From: yoratoni Date: Wed, 5 Aug 2026 00:05:35 +0200 Subject: [PATCH 08/12] fix: kept the tooltip inside the viewport A long tooltip near the right or bottom edge ran off screen, it is now measured after layout and clamped inside the viewport, flipping above the cursor when the natural offset would overflow the bottom. Capped the width at min(80vw, 360px) and switched to pre-wrap so a multi-line tip wraps instead of stretching the whole way across. --- src/renderer/components/ui/Tooltip.tsx | 43 ++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/src/renderer/components/ui/Tooltip.tsx b/src/renderer/components/ui/Tooltip.tsx index ee2f98b..4589713 100644 --- a/src/renderer/components/ui/Tooltip.tsx +++ b/src/renderer/components/ui/Tooltip.tsx @@ -1,4 +1,4 @@ -import { type MouseEvent, type ReactNode, useCallback, useRef, useState } from "react" +import { type MouseEvent, type ReactNode, useCallback, useLayoutEffect, useRef, useState } from "react" import { createPortal } from "react-dom" import styled from "styled-components" @@ -9,6 +9,7 @@ import styled from "styled-components" const Tip = styled.div` position: fixed; z-index: 9999; + max-width: min(80vw, 360px); padding: 4px 6px; border: 2px solid ${({ theme }) => theme.borderDarkest}; background: ${({ theme }) => theme.tooltip}; @@ -16,10 +17,21 @@ const Tip = styled.div` box-shadow: 3px 3px 8px rgba(0, 0, 0, 0.35); font-size: 1rem; line-height: 1.35; - white-space: pre; + white-space: pre-wrap; pointer-events: none; ` +/** + * The gap kept between the tip and the viewport edge when the clamp kicks in. + */ +const VIEWPORT_MARGIN = 4 + +/** + * The vertical gap the tip flips above the cursor by when it would otherwise + * fall off the bottom edge. + */ +const FLIP_ABOVE_OFFSET = 24 + /** * The props for the `Tooltip` component. */ @@ -31,6 +43,7 @@ type TooltipProps = { export default function Tooltip({ text, enterDelay = 300, children }: TooltipProps) { const timer = useRef(undefined) + const tipRef = useRef(null) const [position, setPosition] = useState<{ x: number; y: number } | null>(null) @@ -65,11 +78,35 @@ export default function Tooltip({ text, enterDelay = 300, children }: TooltipPro setPosition(null) }, []) + // Clamp the tip inside the viewport after render, flipping it past the cursor + // whenever the natural cursor-offset position would overflow the right or bottom + useLayoutEffect(() => { + if (!tipRef.current || !position) return + + const rect = tipRef.current.getBoundingClientRect() + const overflowsRight = position.x + rect.width + VIEWPORT_MARGIN > window.innerWidth + const overflowsBottom = position.y + rect.height + VIEWPORT_MARGIN > window.innerHeight + + tipRef.current.style.left = overflowsRight + ? `${Math.max(VIEWPORT_MARGIN, window.innerWidth - rect.width - VIEWPORT_MARGIN)}px` + : `${position.x}px` + + tipRef.current.style.top = overflowsBottom + ? `${Math.max(VIEWPORT_MARGIN, position.y - rect.height - FLIP_ABOVE_OFFSET)}px` + : `${position.y}px` + }, [position]) + return ( {children} - {position && createPortal({text}, document.body)} + {position && + createPortal( + + {text} + , + document.body, + )} ) } From 6485118277c1c9844d2edcd765716711d77bf6d9 Mon Sep 17 00:00:00 2001 From: yoratoni Date: Wed, 5 Aug 2026 00:05:59 +0200 Subject: [PATCH 09/12] fix: stopped styled-components from forwarding non-DOM props Wrapped the app in a StyleSheetManager whose shouldForwardProp drops non-standard props on plain tags through @emotion/is-prop-valid, while leaving composite targets to filter their own, so react95's styling props no longer land on the DOM as unknown attributes. --- package.json | 1 + src/renderer/components/layouts/AppRoot.tsx | 13 ++++++++----- src/renderer/lib/utils/styled.ts | 12 ++++++++++++ 3 files changed, 21 insertions(+), 5 deletions(-) create mode 100644 src/renderer/lib/utils/styled.ts diff --git a/package.json b/package.json index eba73a7..a55d6b4 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ }, "dependencies": { "@cybearl/cypack": "^1.11.10", + "@emotion/is-prop-valid": "^1.4.0", "@react95/icons": "^2.5.0", "dedent": "^1.7.2", "electron-updater": "^6.3.9", diff --git a/src/renderer/components/layouts/AppRoot.tsx b/src/renderer/components/layouts/AppRoot.tsx index ee5ac47..e05ccc6 100644 --- a/src/renderer/components/layouts/AppRoot.tsx +++ b/src/renderer/components/layouts/AppRoot.tsx @@ -1,7 +1,8 @@ import type { ReactNode } from "react" import { createScrollbars } from "react95" import originalTheme from "react95/dist/themes/original" -import { createGlobalStyle, ThemeProvider } from "styled-components" +import { createGlobalStyle, StyleSheetManager, ThemeProvider } from "styled-components" +import { shouldForwardStyledProp } from "@/renderer/lib/utils/styled" /** * Applies react95's Win95 scrollbar styling globally. @@ -16,10 +17,12 @@ type AppRootProps = { export default function AppRoot({ children }: AppRootProps) { return ( - - + + + - {children} - + {children} + + ) } diff --git a/src/renderer/lib/utils/styled.ts b/src/renderer/lib/utils/styled.ts new file mode 100644 index 0000000..f3318c0 --- /dev/null +++ b/src/renderer/lib/utils/styled.ts @@ -0,0 +1,12 @@ +import isPropValid from "@emotion/is-prop-valid" + +/** + * Decides whether a styled-components prop reaches the rendered element, dropping the + * non-standard ones on plain tags, while leaving composite targets to filter their own. + * @param prop The name of the prop about to be forwarded. + * @param target The tag name or component the styled component renders. + * @returns Whether the prop should be forwarded. + */ +export function shouldForwardStyledProp(prop: string, target: unknown): boolean { + return typeof target === "string" ? isPropValid(prop) : true +} From 8aada09bf6cd97b374188b747b95a39e3aeffcac Mon Sep 17 00:00:00 2001 From: yoratoni Date: Wed, 5 Aug 2026 00:06:08 +0200 Subject: [PATCH 10/12] chore: added ESLint with the nitpicker plugin and applied its fixes Layered @alien_intelligence/eslint-plugin-nitpicker on top of Biome for nit-level enforcement, Biome still owns formatting and the broader rules, and wired both into the lint and lint:fix scripts. Swept what it flagged across the codebase: condensed the multi-line JSDoc blocks into dense prose, dropped the single-use locals in favour of destructuring, and tightened a few loops. Left no-british-english off for now, it crashes on any import type line and aborts the whole run. --- .vscode/settings.json | 10 +- eslint.config.mjs | 28 ++ package.json | 7 +- src/main/lib/gitCommands/exec.ts | 2 +- src/main/lib/lfsCommands/service.ts | 10 +- src/main/lib/utils/async.ts | 9 +- src/main/types/fileTree.d.ts | 7 +- src/main/types/gitCommands.d.ts | 9 +- src/main/types/lfsCommands.d.ts | 19 +- src/main/types/projects.d.ts | 8 +- src/main/types/updater.d.ts | 11 +- src/preload/routes/updater.ts | 2 - src/preload/routes/windows.ts | 2 - src/renderer/hooks/useResizablePaneWidth.tsx | 7 +- src/renderer/hooks/useWindowStates.tsx | 5 + src/renderer/lib/utils/git.ts | 5 +- yarn.lock | 434 ++++++++++++++++++- 17 files changed, 500 insertions(+), 75 deletions(-) create mode 100644 eslint.config.mjs diff --git a/.vscode/settings.json b/.vscode/settings.json index 8e90333..56360d1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -56,15 +56,20 @@ }, // CSpell "cSpell.words": [ + "Artur", "asar", "ASSETDATA", "asynciterable", + "Bień", "browsable", "CHUNKID", "CHUNKIDS", "EXPORTMAP", "fguid", "gatherable", + "konsole", + "Nwnp", + "OFPA", "Qfecheck", "scrollview", "SERIALSIZES", @@ -74,10 +79,11 @@ "uassets", "umap", "UPACKAGE", - "uproject" + "uproject", + "webfonts" ], "[json]": { - "editor.defaultFormatter": "biomejs.biome" + "editor.defaultFormatter": "vscode.json-language-features" }, "javascript.preferences.importModuleSpecifier": "shortest", "typescript.preferences.importModuleSpecifier": "non-relative" diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..fd9189d --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,28 @@ +import nitpicker from "@alien_intelligence/eslint-plugin-nitpicker" +import tsParser from "@typescript-eslint/parser" + +/** + * ESLint flat config, layers Nitpicker on top of Biome for nit-level enforcement, + * Biome still owns formatting and the broader lint rules. + */ +export default [ + { + files: ["src/**/*.ts", "src/**/*.tsx"], + languageOptions: { + parser: tsParser, + ecmaVersion: "latest", + sourceType: "module", + }, + plugins: { nitpicker }, + rules: { + ...nitpicker.configs.recommended.rules, + // TODO: re-enable once the upstream `e.charAt is not a function` crash is fixed, + // it currently trips on any TS `import type` line and aborts the whole lint run. + "nitpicker/no-british-english": "off", + }, + }, + { + files: ["src/renderer/**/*.tsx", "src/renderer/**/*.ts"], + ...nitpicker.configs.react, + }, +] diff --git a/package.json b/package.json index a55d6b4..4dfdf9f 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,8 @@ "typecheck": "tsc --noEmit", "test": "npm run test:unit", "test:unit": "vitest", - "lint": "biome check", - "lint:fix": "biome check --fix && biome format --write", + "lint": "biome check && eslint \"src/**/*.{ts,tsx}\"", + "lint:fix": "biome check --fix && biome format --write && eslint \"src/**/*.{ts,tsx}\" --fix", "dist": "electron-vite build && electron-builder", "release": "npx semantic-release", "hooks:install": "node -e \"if(require('fs').existsSync('.git'))require('child_process').execSync('git config core.hooksPath .githooks',{stdio:'inherit'})\"", @@ -45,16 +45,19 @@ "styled-components": "^6.4.3" }, "devDependencies": { + "@alien_intelligence/eslint-plugin-nitpicker": "^0.2.0", "@biomejs/biome": "^2.5.2", "@tailwindcss/vite": "^4.3.1", "@types/node": "^24.12.2", "@types/picomatch": "^4.0.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@typescript-eslint/parser": "^8.65.0", "@vitejs/plugin-react": "^5.0.0", "electron": "^42.5.0", "electron-builder": "^25.1.8", "electron-vite": "^5.0.0", + "eslint": "^10.8.0", "react": "^19.2.5", "react-dom": "^19.2.5", "tailwindcss": "^4.3.1", diff --git a/src/main/lib/gitCommands/exec.ts b/src/main/lib/gitCommands/exec.ts index 39182d2..fb3f229 100644 --- a/src/main/lib/gitCommands/exec.ts +++ b/src/main/lib/gitCommands/exec.ts @@ -47,7 +47,7 @@ export async function runGit(args: string[], options: RunGitOptions): Promise void, ): Promise { const files = await expandToLockableFiles(dir, paths) - const total = files.length - onProgress?.(0, total) + onProgress?.(0, files.length) let done = 0 return parallelLimit(files, GIT_CONFIG.lockConcurrency, async file => { const result = await runLockCommand(dir, ["lfs", "lock", file], file) done += 1 - onProgress?.(done, total) + onProgress?.(done, files.length) return result }) } @@ -173,16 +172,15 @@ export async function unlockPaths( onProgress?: (done: number, total: number) => void, ): Promise { const files = await expandToLockableFiles(dir, paths) - const total = files.length - onProgress?.(0, total) + onProgress?.(0, files.length) let done = 0 return parallelLimit(files, GIT_CONFIG.lockConcurrency, async file => { const args = force ? ["lfs", "unlock", "--force", file] : ["lfs", "unlock", file] const result = await runLockCommand(dir, args, file) done += 1 - onProgress?.(done, total) + onProgress?.(done, files.length) return result }) } diff --git a/src/main/lib/utils/async.ts b/src/main/lib/utils/async.ts index 7bc5c2a..4f644c7 100644 --- a/src/main/lib/utils/async.ts +++ b/src/main/lib/utils/async.ts @@ -19,12 +19,9 @@ export async function parallelLimit( * @returns A promise that resolves when the worker is done. */ const run = async (): Promise => { - while (true) { - const currentIndex = cursor - cursor += 1 - - if (currentIndex >= items.length) return - results[currentIndex] = await worker(items[currentIndex], currentIndex) + while (cursor < items.length) { + const index = cursor++ + results[index] = await worker(items[index], index) } } diff --git a/src/main/types/fileTree.d.ts b/src/main/types/fileTree.d.ts index 9923c31..c2c9ba8 100644 --- a/src/main/types/fileTree.d.ts +++ b/src/main/types/fileTree.d.ts @@ -4,11 +4,8 @@ export type FileTreeNodeType = "file" | "folder" /** - * A node in the repository file tree. - * - * For a file, `isLockable` reflects its `lockable` Git attribute, for a folder, - * `isLockable` is `true` when it contains at least one lockable descendant, so - * the UI knows whether a folder-level lock button is meaningful. + * A node in the repository file tree, `isLockable` reflects the `lockable` Git + * attribute on files, and is `true` on folders with at least one lockable descendant. */ export type FileTreeNode = { name: string diff --git a/src/main/types/gitCommands.d.ts b/src/main/types/gitCommands.d.ts index e3b6e22..f09fd11 100644 --- a/src/main/types/gitCommands.d.ts +++ b/src/main/types/gitCommands.d.ts @@ -8,12 +8,9 @@ export type GitResult = { } /** - * The staged (index) or unstaged (working tree) state of a single path, - * expressed with Git's porcelain status codes. - * - * Note: `.` means unmodified in that slot, other values follow `git status` - * conventions (`M` modified, `A` added, `D` deleted, `R` renamed, - * `C` copied, `U` unmerged, `?` untracked, `!` ignored). + * The state of a single path in the staged (index) or unstaged (working tree) + * slot, using Git's porcelain codes, `.` means unmodified, other letters follow + * `git status` conventions (`M`, `A`, `D`, `R`, `C`, `U`, `?`, `!`). */ export type GitStatusCode = "." | "M" | "A" | "D" | "R" | "C" | "U" | "?" | "!" diff --git a/src/main/types/lfsCommands.d.ts b/src/main/types/lfsCommands.d.ts index 2df9fb2..d6eb8bc 100644 --- a/src/main/types/lfsCommands.d.ts +++ b/src/main/types/lfsCommands.d.ts @@ -19,10 +19,8 @@ export type LfsLockResult = { } /** - * The payload streamed over the LFS lock progress channel, letting the renderer - * track a live `done/total` count while a lock or unlock batch is in flight. - * The `requestId` scopes each event to the originating invocation so multiple - * concurrent batches do not cross-contaminate. + * The payload streamed over the LFS lock progress channel, carrying a live + * `done/total` count, scoped by `requestId` so concurrent batches stay isolated. */ export type LfsLockProgress = { requestId: string @@ -38,17 +36,8 @@ export type LfsLockMigration = { from: string to: string /** - * The migration status, one of: - * - `migrated`: The old lock belonged to the current user, the new path was - * locked and the old lock released - * - `skipped-not-locked`: The old path had no active lock, nothing to do - * - `skipped-not-mine`: The old path is locked by another user and cannot - * be transferred without a force-unlock - * - `skipped-not-lockable`: The new path is not covered by the `lockable` - * attribute, so it cannot receive the lock - * - `failed-lock`: Locking the new path failed, the old lock was preserved - * - `failed-unlock`: The new path was locked but the old lock could not be - * released, both locks currently exist + * The migration status, `migrated` on success, `skipped-*` when the migration + * did not apply, `failed-*` when a step errored, self-describing suffixes. */ status: | "migrated" diff --git a/src/main/types/projects.d.ts b/src/main/types/projects.d.ts index cac701a..100ff21 100644 --- a/src/main/types/projects.d.ts +++ b/src/main/types/projects.d.ts @@ -11,11 +11,9 @@ export type Project = { } /** - * Why an attempt to open a project did not result in an open repository: - * - `cancelled`: the user dismissed the folder picker. - * - `not-found`: the path no longer exists on disk. - * - `not-a-repository`: the path exists but is not inside a Git repository. - * - `not-a-ue-project`: the repository has no `.uproject` file at its root. + * Why opening a project did not result in an open repository, `cancelled` when + * the picker was dismissed, `not-found`, `not-a-repository`, and `not-a-ue-project` + * cover the disk, git, and UE root-file checks respectively. */ export type OpenProjectFailureReason = "cancelled" | "not-found" | "not-a-repository" | "not-a-ue-project" diff --git a/src/main/types/updater.d.ts b/src/main/types/updater.d.ts index 30e0982..6d3311e 100644 --- a/src/main/types/updater.d.ts +++ b/src/main/types/updater.d.ts @@ -1,12 +1,7 @@ /** - * The lifecycle phase of the auto-updater: - * - `idle`: nothing has been checked yet, or the last result was dismissed. - * - `checking`: a check against the release feed is in flight. - * - `not-available`: the running version is the latest one published. - * - `available`: a newer version was found and is waiting on the user. - * - `downloading`: the installer is being fetched. - * - `downloaded`: the installer is staged and waiting for a restart. - * - `error`: the last check or download failed. + * The lifecycle phase of the auto-updater, moves from `idle` through `checking` + * to either `not-available`, `available` (then `downloading` and `downloaded`), + * or `error`, drives what the update dialog shows at each transition. */ export type UpdaterStatus = "idle" | "checking" | "not-available" | "available" | "downloading" | "downloaded" | "error" diff --git a/src/preload/routes/updater.ts b/src/preload/routes/updater.ts index 2089936..1ec283f 100644 --- a/src/preload/routes/updater.ts +++ b/src/preload/routes/updater.ts @@ -8,9 +8,7 @@ const updaterApiRoutes: GitgameApi["updater"] = { getState: () => safeInvoke(CONSTANTS.ipc.updaterGetState), onStateChange: callback => { const listener = (_: unknown, state: UpdaterState) => callback(state) - ipcRenderer.on(CONSTANTS.ipc.updaterStateChanged, listener) - return () => ipcRenderer.off(CONSTANTS.ipc.updaterStateChanged, listener) }, check: isManualCheck => safeInvoke(CONSTANTS.ipc.updaterCheck, isManualCheck), diff --git a/src/preload/routes/windows.ts b/src/preload/routes/windows.ts index 3c60f79..2a472c5 100644 --- a/src/preload/routes/windows.ts +++ b/src/preload/routes/windows.ts @@ -6,9 +6,7 @@ const windowsApiRoutes: GitgameApi["windows"] = { getState: () => ipcRenderer.invoke(CONSTANTS.ipc.windowsGetState), onStateChange: callback => { const listener = (_: unknown, state: WindowState) => callback(state) - ipcRenderer.on(CONSTANTS.ipc.windowsStateChanged, listener) - return () => ipcRenderer.off(CONSTANTS.ipc.windowsStateChanged, listener) }, minimize: () => ipcRenderer.send(CONSTANTS.ipc.windowsMinimize), diff --git a/src/renderer/hooks/useResizablePaneWidth.tsx b/src/renderer/hooks/useResizablePaneWidth.tsx index f5f6348..3acf2f1 100644 --- a/src/renderer/hooks/useResizablePaneWidth.tsx +++ b/src/renderer/hooks/useResizablePaneWidth.tsx @@ -72,10 +72,9 @@ export default function useResizablePaneWidth({ (event: ReactMouseEvent) => { event.preventDefault() - const startX = event.clientX - const startWidth = widthRef.current - const previousBodyCursor = document.body.style.cursor - const previousBodyUserSelect = document.body.style.userSelect + const { clientX: startX } = event + const { current: startWidth } = widthRef + const { cursor: previousBodyCursor, userSelect: previousBodyUserSelect } = document.body.style document.body.style.cursor = "col-resize" document.body.style.userSelect = "none" diff --git a/src/renderer/hooks/useWindowStates.tsx b/src/renderer/hooks/useWindowStates.tsx index 8a5ca36..22fc2ab 100644 --- a/src/renderer/hooks/useWindowStates.tsx +++ b/src/renderer/hooks/useWindowStates.tsx @@ -1,6 +1,11 @@ import { useEffect, useState } from "react" import type { WindowState } from "@/preload" +/** + * Subscribes to the main-process window state (focus, maximized, ...) and + * returns the latest snapshot, so title bars and window-scoped UI can stay in sync. + * @returns The current window state, or `undefined` before the first read lands. + */ export default function useWindowStates() { const [states, setStates] = useState() diff --git a/src/renderer/lib/utils/git.ts b/src/renderer/lib/utils/git.ts index d0d429b..fe298e1 100644 --- a/src/renderer/lib/utils/git.ts +++ b/src/renderer/lib/utils/git.ts @@ -1,7 +1,6 @@ /** - * Converts a Git remote URL (in either the `scp`-style SSH form git@host:owner/repo.git` - * or a standard URL form like `https://host/owner/repo.git`) into an HTTPS URL that - * can be opened in a browser, or returns `null` when the input cannot be normalized. + * Converts a Git remote URL (SSH `git@host:owner/repo.git` or HTTPS form) into a + * browsable HTTPS URL, or `null` when the input cannot be normalized. * @param remoteUrl The raw remote URL as reported by `git remote get-url`. * @returns The browsable HTTPS URL, or `null` if unparsable. */ diff --git a/yarn.lock b/yarn.lock index 244733a..a646341 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7,6 +7,13 @@ resolved "https://registry.yarnpkg.com/7zip-bin/-/7zip-bin-5.2.0.tgz#7a03314684dd6572b7dfa89e68ce31d60286854d" integrity sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A== +"@alien_intelligence/eslint-plugin-nitpicker@^0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@alien_intelligence/eslint-plugin-nitpicker/-/eslint-plugin-nitpicker-0.2.0.tgz#afe513228c7c1132b5420ed7265ac5c283e4edb6" + integrity sha512-d8kwa0JvSf3Fqiw22F+Ggffo7V90Z3zh8ezB89ANvAO95pJBEEs67HS8yz7ea2DCVonOr4748VqXIXIrOWt5Dg== + dependencies: + "@typescript-eslint/utils" "^8.18.0" + "@babel/code-frame@^7.29.7": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.7.tgz#f2fbbfea87c44a21590ec515b778b2c26d8866e7" @@ -355,7 +362,7 @@ dependencies: tslib "^2.4.0" -"@emotion/is-prop-valid@1.4.0": +"@emotion/is-prop-valid@1.4.0", "@emotion/is-prop-valid@^1.4.0": version "1.4.0" resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz#e9ad47adff0b5c94c72db3669ce46de33edf28c0" integrity sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw== @@ -627,11 +634,90 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz#10064ee44f4347b90c9a02b446bbf80a91632b12" integrity sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A== +"@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": + version "4.10.1" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz#8911bd72b2c3640a543609e0400b8c4d2e7e7cb6" + integrity sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg== + dependencies: + eslint-visitor-keys "^3.4.3" + +"@eslint-community/regexpp@^4.12.2": + version "4.12.2" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b" + integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== + +"@eslint/config-array@^0.23.5": + version "0.23.5" + resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.23.5.tgz#56e86d243049195d8acc0c06a1b3dfdc3fa3de95" + integrity sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA== + dependencies: + "@eslint/object-schema" "^3.0.5" + debug "^4.3.1" + minimatch "^10.2.4" + +"@eslint/config-helpers@^0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.7.0.tgz#09ee4aa07b73f059ec2d4c74bf4b2ff02b322377" + integrity sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw== + dependencies: + "@eslint/core" "^1.2.1" + +"@eslint/core@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.2.1.tgz#c1da7cd1b82fa8787f98b5629fb811848a1b63ce" + integrity sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ== + dependencies: + "@types/json-schema" "^7.0.15" + +"@eslint/object-schema@^3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-3.0.5.tgz#88e9bf4d11d2b19c082e78ebe7ce88724a5eb091" + integrity sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw== + +"@eslint/plugin-kit@^0.7.2": + version "0.7.2" + resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz#4b0962f3f2c7ce8bc98b3ecfe34525c09d2cb729" + integrity sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A== + dependencies: + "@eslint/core" "^1.2.1" + levn "^0.4.1" + "@gar/promisify@^1.1.3": version "1.1.3" resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" integrity sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw== +"@humanfs/core@^0.19.2": + version "0.19.2" + resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.2.tgz#a8272ca03b2acf492670222b2320b6c421bfde60" + integrity sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA== + dependencies: + "@humanfs/types" "^0.15.0" + +"@humanfs/node@^0.16.6": + version "0.16.8" + resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.8.tgz#8f800cccc13f4f8cd3116e2d9c0a94939da3e3ed" + integrity sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ== + dependencies: + "@humanfs/core" "^0.19.2" + "@humanfs/types" "^0.15.0" + "@humanwhocodes/retry" "^0.4.0" + +"@humanfs/types@^0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@humanfs/types/-/types-0.15.0.tgz#f2a09f62012390b2bff3fc6fb248ddec8c09a090" + integrity sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q== + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + +"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2": + version "0.4.3" + resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba" + integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== + "@isaacs/cliui@^8.0.2": version "8.0.2" resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" @@ -1057,7 +1143,12 @@ resolved "https://registry.yarnpkg.com/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd" integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== -"@types/estree@1.0.9", "@types/estree@^1.0.0": +"@types/esrecurse@^4.3.1": + version "4.3.1" + resolved "https://registry.yarnpkg.com/@types/esrecurse/-/esrecurse-4.3.1.tgz#6f636af962fbe6191b830bd676ba5986926bccec" + integrity sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw== + +"@types/estree@1.0.9", "@types/estree@^1.0.0", "@types/estree@^1.0.6", "@types/estree@^1.0.8": version "1.0.9" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24" integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg== @@ -1074,6 +1165,11 @@ resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz#f6a7788f438cbfde15f29acad46512b4c01913b3" integrity sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q== +"@types/json-schema@^7.0.15": + version "7.0.15" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + "@types/keyv@^3.1.4": version "3.1.4" resolved "https://registry.yarnpkg.com/@types/keyv/-/keyv-3.1.4.tgz#3ccdb1c6751b0c7e52300bcdacd5bcbf8faa75b6" @@ -1130,6 +1226,77 @@ resolved "https://registry.yarnpkg.com/@types/verror/-/verror-1.10.11.tgz#d3d6b418978c8aa202d41e5bb3483227b6ecc1bb" integrity sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg== +"@typescript-eslint/parser@^8.65.0": + version "8.65.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.65.0.tgz#5295c1058c0a1dd746ef28baaf9c0341dbdf03dc" + integrity sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA== + dependencies: + "@typescript-eslint/scope-manager" "8.65.0" + "@typescript-eslint/types" "8.65.0" + "@typescript-eslint/typescript-estree" "8.65.0" + "@typescript-eslint/visitor-keys" "8.65.0" + debug "^4.4.3" + +"@typescript-eslint/project-service@8.65.0": + version "8.65.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.65.0.tgz#65fbbc9a1591abffaeab5513200f848271cb0aa5" + integrity sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q== + dependencies: + "@typescript-eslint/tsconfig-utils" "^8.65.0" + "@typescript-eslint/types" "^8.65.0" + debug "^4.4.3" + +"@typescript-eslint/scope-manager@8.65.0": + version "8.65.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz#9547202ce7e608e7b6283df585703b980a0ea70d" + integrity sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg== + dependencies: + "@typescript-eslint/types" "8.65.0" + "@typescript-eslint/visitor-keys" "8.65.0" + +"@typescript-eslint/tsconfig-utils@8.65.0", "@typescript-eslint/tsconfig-utils@^8.65.0": + version "8.65.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz#36f168fcdbb1295f7446ff0379667f98c3cf1bf3" + integrity sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg== + +"@typescript-eslint/types@8.65.0", "@typescript-eslint/types@^8.65.0": + version "8.65.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.65.0.tgz#3e86738416a777c8b8925ab46745f48ecf904c9f" + integrity sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg== + +"@typescript-eslint/typescript-estree@8.65.0": + version "8.65.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz#f1f514808f6aa713e2d678ae8ff592a65e1632af" + integrity sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg== + dependencies: + "@typescript-eslint/project-service" "8.65.0" + "@typescript-eslint/tsconfig-utils" "8.65.0" + "@typescript-eslint/types" "8.65.0" + "@typescript-eslint/visitor-keys" "8.65.0" + debug "^4.4.3" + minimatch "^10.2.2" + semver "^7.7.3" + tinyglobby "^0.2.15" + ts-api-utils "^2.5.0" + +"@typescript-eslint/utils@^8.18.0": + version "8.65.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.65.0.tgz#afedd974a0c8deeef553b509df5800bafd615a72" + integrity sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA== + dependencies: + "@eslint-community/eslint-utils" "^4.9.1" + "@typescript-eslint/scope-manager" "8.65.0" + "@typescript-eslint/types" "8.65.0" + "@typescript-eslint/typescript-estree" "8.65.0" + +"@typescript-eslint/visitor-keys@8.65.0": + version "8.65.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz#e3704c13cb4a1c22454c1abf28ff4737e15018c6" + integrity sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A== + dependencies: + "@typescript-eslint/types" "8.65.0" + eslint-visitor-keys "^5.0.0" + "@vitejs/plugin-react@^5.0.0": version "5.2.0" resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz#108bd0f566f288ce3566982df4eff137ded7b15f" @@ -1213,6 +1380,16 @@ abbrev@^1.0.0: resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== +acorn-jsx@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" + integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== + +acorn@^8.16.0: + version "8.18.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.18.0.tgz#4faf01b2d6d326bfeed97aea1f52220b5f4c1940" + integrity sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ== + agent-base@6, agent-base@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" @@ -1245,7 +1422,7 @@ ajv-keywords@^3.4.1: resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ== -ajv@^6.10.0, ajv@^6.12.0: +ajv@^6.10.0, ajv@^6.12.0, ajv@^6.14.0: version "6.15.0" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.15.0.tgz#07e982c74626167aa7a2495c53817892d7139492" integrity sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw== @@ -1441,6 +1618,13 @@ brace-expansion@^5.0.5: dependencies: balanced-match "^4.0.2" +brace-expansion@^5.0.8: + version "5.0.9" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.9.tgz#7c72438809b5fa5babf54199a1f1c281a6984fcf" + integrity sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg== + dependencies: + balanced-match "^4.0.2" + browserslist@^4.24.0: version "4.28.5" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.5.tgz#438b7d38c0d4b47740bbb36778d5bdca01b37838" @@ -1770,7 +1954,7 @@ dateformat@^5.0.3: resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-5.0.3.tgz#fe2223eff3cc70ce716931cb3038b59a9280696e" integrity sha512-Kvr6HmPXUMerlLcLF+Pwq3K7apHpYmGDVqrxcDasBg86UcKeTSNWbEzU8bwdXnxnR44FtMhJAxI4Bov6Y/KUfA== -debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.3, debug@^4.3.4, debug@^4.4.1: +debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@^4.4.1, debug@^4.4.3: version "4.4.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== @@ -1799,6 +1983,11 @@ deep-eql@^5.0.1: resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-5.0.2.tgz#4b756d8d770a9257300825d52a2c2cff99c3a341" integrity sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q== +deep-is@^0.1.3: + version "0.1.4" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" + integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== + defaults@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/defaults/-/defaults-1.0.4.tgz#b0b02062c1e2aa62ff5d9528f0f98baa90978d7a" @@ -2112,6 +2301,95 @@ escalade@^3.1.1, escalade@^3.2.0: resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +eslint-scope@^9.1.2: + version "9.1.2" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-9.1.2.tgz#b9de6ace2fab1cff24d2e58d85b74c8fcea39802" + integrity sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ== + dependencies: + "@types/esrecurse" "^4.3.1" + "@types/estree" "^1.0.8" + esrecurse "^4.3.0" + estraverse "^5.2.0" + +eslint-visitor-keys@^3.4.3: + version "3.4.3" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" + integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== + +eslint-visitor-keys@^5.0.0, eslint-visitor-keys@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be" + integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA== + +eslint@^10.8.0: + version "10.8.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.8.0.tgz#e6d19907a3f090a53a022261ba34c5ff6d04908b" + integrity sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ== + dependencies: + "@eslint-community/eslint-utils" "^4.8.0" + "@eslint-community/regexpp" "^4.12.2" + "@eslint/config-array" "^0.23.5" + "@eslint/config-helpers" "^0.7.0" + "@eslint/core" "^1.2.1" + "@eslint/plugin-kit" "^0.7.2" + "@humanfs/node" "^0.16.6" + "@humanwhocodes/module-importer" "^1.0.1" + "@humanwhocodes/retry" "^0.4.2" + "@types/estree" "^1.0.6" + ajv "^6.14.0" + cross-spawn "^7.0.6" + debug "^4.3.2" + escape-string-regexp "^4.0.0" + eslint-scope "^9.1.2" + eslint-visitor-keys "^5.0.1" + espree "^11.2.0" + esquery "^1.7.0" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^8.0.0" + find-up "^5.0.0" + glob-parent "^6.0.2" + ignore "^5.2.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + json-stable-stringify-without-jsonify "^1.0.1" + minimatch "^10.2.5" + natural-compare "^1.4.0" + optionator "^0.9.3" + +espree@^11.2.0: + version "11.2.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-11.2.0.tgz#01d5e47dc332aaba3059008362454a8cc34ccaa5" + integrity sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw== + dependencies: + acorn "^8.16.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^5.0.1" + +esquery@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d" + integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== + dependencies: + estraverse "^5.1.0" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^5.1.0, estraverse@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + estree-walker@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" @@ -2119,6 +2397,11 @@ estree-walker@^3.0.3: dependencies: "@types/estree" "^1.0.0" +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + expect-type@^1.2.1: version "1.4.0" resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.4.0.tgz#24edf7f0cc69a44d008567ba4594ab96f3c3a3d6" @@ -2139,7 +2422,7 @@ fast-copy@^4.0.0: resolved "https://registry.yarnpkg.com/fast-copy/-/fast-copy-4.0.4.tgz#e569e2331ee6a9d67826c31aeb0e84b50bfb63e6" integrity sha512-eVAiWVNPSEGIzDl5yPuLrx8fNMogScXvD9xp1Kzd41FjRIz2I3sSIcxsFeM5EzFfHAfobdvs8ZySffUopljvIA== -fast-deep-equal@^3.1.1: +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== @@ -2149,6 +2432,11 @@ fast-json-stable-stringify@^2.0.0: resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== +fast-levenshtein@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== + fast-safe-stringify@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884" @@ -2159,6 +2447,13 @@ fdir@^6.5.0: resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== +file-entry-cache@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" + integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== + dependencies: + flat-cache "^4.0.0" + filelist@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.6.tgz#1e8870942a7c636c862f7c49b9394937b6a995a3" @@ -2166,6 +2461,27 @@ filelist@^1.0.4: dependencies: minimatch "^5.0.1" +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +flat-cache@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c" + integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== + dependencies: + flatted "^3.2.9" + keyv "^4.5.4" + +flatted@^3.2.9: + version "3.4.4" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.4.tgz#aeeca2a506303f0cee61c59e6c9f2a88d2f29fc6" + integrity sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q== + foreground-child@^3.1.0: version "3.3.1" resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" @@ -2290,6 +2606,13 @@ get-stream@^5.1.0: dependencies: pump "^3.0.0" +glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + glob@^10.3.12: version "10.5.0" resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" @@ -2471,6 +2794,11 @@ ieee754@^1.1.13: resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== +ignore@^5.2.0: + version "5.3.2" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" + integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== + imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" @@ -2511,11 +2839,23 @@ is-ci@^3.0.0: dependencies: ci-info "^3.2.0" +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + is-fullwidth-code-point@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== +is-glob@^4.0.0, is-glob@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + is-interactive@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-1.0.0.tgz#cea6e6ae5c870a7b0a0004070b7b587e0252912e" @@ -2606,6 +2946,11 @@ json-schema-traverse@^0.4.1: resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" + integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== + json5@^2.2.3: version "2.2.3" resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" @@ -2620,7 +2965,7 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" -keyv@^4.0.0: +keyv@^4.0.0, keyv@^4.5.4: version "4.5.4" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== @@ -2632,6 +2977,14 @@ lazy-val@^1.0.5: resolved "https://registry.yarnpkg.com/lazy-val/-/lazy-val-1.0.5.tgz#6cf3b9f5bc31cee7ee3e369c0832b7583dcd923d" integrity sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q== +levn@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" + integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + dependencies: + prelude-ls "^1.2.1" + type-check "~0.4.0" + lightningcss-android-arm64@1.32.0: version "1.32.0" resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz#f033885116dfefd9c6f54787523e3514b61e1968" @@ -2706,6 +3059,13 @@ lightningcss@1.32.0: lightningcss-win32-arm64-msvc "1.32.0" lightningcss-win32-x64-msvc "1.32.0" +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + lodash.escaperegexp@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz#64762c48618082518ac3df4ccf5d5886dae20347" @@ -2836,6 +3196,13 @@ minimatch@^10.0.0: dependencies: brace-expansion "^5.0.5" +minimatch@^10.2.2, minimatch@^10.2.4, minimatch@^10.2.5: + version "10.2.6" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.6.tgz#fd956bbe0b77241e9f15ac5dccb1c638060968ef" + integrity sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A== + dependencies: + brace-expansion "^5.0.8" + minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1: version "3.1.5" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e" @@ -2941,6 +3308,11 @@ nanoid@^3.3.12: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.15.tgz#36c490fad8c6e86c824c940dfdde999b69ed4316" integrity sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA== +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== + negotiator@^0.6.3: version "0.6.4" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.4.tgz#777948e2452651c570b712dd01c23e262713fff7" @@ -3028,6 +3400,18 @@ onetime@^5.1.0: dependencies: mimic-fn "^2.1.0" +optionator@^0.9.3: + version "0.9.4" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" + integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== + dependencies: + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + word-wrap "^1.2.5" + ora@^5.1.0: version "5.4.1" resolved "https://registry.yarnpkg.com/ora/-/ora-5.4.1.tgz#1b2678426af4ac4a509008e5e4ac9e9959db9e18" @@ -3048,13 +3432,20 @@ p-cancelable@^2.0.0: resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.1.1.tgz#aab7fbd416582fa32a3db49859c122487c5ed2cf" integrity sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg== -"p-limit@^3.1.0 ": +p-limit@^3.0.2, "p-limit@^3.1.0 ": version "3.1.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== dependencies: yocto-queue "^0.1.0" +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + p-map@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/p-map/-/p-map-4.0.0.tgz#bb2f95a5eda2ec168ec9274e06a747c3e2904d2b" @@ -3067,6 +3458,11 @@ package-json-from-dist@^1.0.0: resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + path-is-absolute@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" @@ -3181,6 +3577,11 @@ postcss@^8.5.6: picocolors "^1.1.1" source-map-js "^1.2.1" +prelude-ls@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" + integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + process-warning@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-5.0.0.tgz#566e0bf79d1dff30a72d8bbbe9e8ecefe8d378d7" @@ -3400,7 +3801,7 @@ semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.3.5, semver@^7.3.8, semver@^7.5.3, semver@^7.6.3: +semver@^7.3.5, semver@^7.3.8, semver@^7.5.3, semver@^7.6.3, semver@^7.7.3: version "7.8.5" resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69" integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA== @@ -3732,6 +4133,11 @@ truncate-utf8-bytes@^1.0.0: dependencies: utf8-byte-length "^1.0.1" +ts-api-utils@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz#4acd4a155e22734990a5ed1fe9e97f113bcb37c1" + integrity sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA== + tsconfck@^3.0.3: version "3.1.6" resolved "https://registry.yarnpkg.com/tsconfck/-/tsconfck-3.1.6.tgz#da1f0b10d82237ac23422374b3fce1edb23c3ead" @@ -3742,6 +4148,13 @@ tslib@^2.4.0, tslib@^2.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== +type-check@^0.4.0, type-check@~0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + dependencies: + prelude-ls "^1.2.1" + typescript@^5.4.3, typescript@^5.8.3: version "5.9.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" @@ -3902,6 +4315,11 @@ wide-align@^1.1.5: dependencies: string-width "^1.0.2 || 2 || 3 || 4" +word-wrap@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" + integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== + "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" From c31777573d582649ac38948fd70a6562f74587d5 Mon Sep 17 00:00:00 2001 From: yoratoni Date: Wed, 5 Aug 2026 00:06:08 +0200 Subject: [PATCH 11/12] docs: documented the third-party Windows 95 assets The MIT grant covers the source Cybearl authored but not the Windows 95 sounds, shell icons, and Microsoft Sans Serif webfonts the interface reproduces. Added NOTICE.md naming where each asset comes from and whether it is committed or resolved from node_modules at build time, the MIT react95 packages it arrives through, a trademark disclaimer, and a path for rights holders to ask for a replacement, then linked it from the README. --- NOTICE.md | 39 +++++++++++++++++++++++++++++++++++++++ README.md | 8 ++++++++ 2 files changed, 47 insertions(+) create mode 100644 NOTICE.md diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..bf7744c --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,39 @@ +# Notice +`@cybearl/gitgame` is released under the MIT License, as stated in [LICENSE](LICENSE). That grant covers the +source code authored by Cybearl but it does **not** cover the third-party assets listed below, which remain +the property of their respective owners and are used here to reproduce the Windows 95 visual style. + +If you fork this project or redistribute a build of it, these assets are yours to review. + +## Microsoft assets +The following assets originate from Microsoft software and are not licensed to Cybearl for +redistribution: +| Asset | Origin | Where it lives | +|--------------------------|-------------------------------------------------|---------------------------------------| +| Sound effects (`.wav`) | Windows 95 system sounds | `src/main/assets/sounds/` | +| Shell icons (`.png`) | Windows 95 resource icons, via `@react95/icons` | `node_modules`, bundled at build time | +| `ms_sans_serif` webfonts | Microsoft Sans Serif, via `react95` | `node_modules`, bundled at build time | + +The sound files are committed to this repository, the icons and fonts are not, they are resolved +from `node_modules` through the `@react95-icons` and `@react95-fonts` aliases declared in +[electron.vite.config.ts](electron.vite.config.ts). + +## Third-party packages +The interface is built on the following MIT-licensed packages, which are re-implementations of the +Windows 95 look rather than Microsoft code: +| Package | Author | License | +|------------------|------------|---------| +| `react95` | Artur Bień | MIT | +| `@react95/icons` | Artur Bień | MIT | + +Their MIT licenses cover the component and packaging code written by their author, they do **not** +extend to the underlying Microsoft artwork that those packages redistribute. + +## Trademarks +Cybearl is not affiliated with, endorsed by, or sponsored by Microsoft Corporation. Windows and +Windows 95 are trademarks of Microsoft Corporation, references to them in this project are +descriptive, made solely to identify the visual style that the interface imitates. + +## Requests +If you represent a rights holder and object to any asset used here, open an issue on +[the tracker](https://github.com/cybearl/gitgame/issues) and it will be replaced. diff --git a/README.md b/README.md index e96457f..ebc3e74 100644 --- a/README.md +++ b/README.md @@ -18,3 +18,11 @@ $ yarn install # Run the app in development mode $ yarn dev ``` + +## Third-party assets +This project reproduces the Windows 95 look and feel, some of the icons, fonts, and sounds it uses +originate from Microsoft software and are not covered by the MIT License in [LICENSE](LICENSE), read +[NOTICE.md](NOTICE.md) before forking this project or redistributing a build of it. + +Cybearl is not affiliated with, endorsed by, or sponsored by Microsoft Corporation. Windows and +Windows 95 are trademarks of Microsoft Corporation. From f626cd8a863922c7c3b9752c7a6b2f0159864cc1 Mon Sep 17 00:00:00 2001 From: yoratoni Date: Wed, 5 Aug 2026 00:11:25 +0200 Subject: [PATCH 12/12] fix: loading screen now takes the whole window instead of just the central view --- src/renderer/App.tsx | 37 +++++++++++++---------- src/renderer/components/views/Loading.tsx | 7 +---- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index b278809..b29c755 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -51,19 +51,18 @@ function AppShell() { */ const statusCaption = useMemo(() => { if (currentProject) return currentProject.path - return isBooting || isLoading ? "Starting up..." : "No project open" - }, [currentProject, isBooting, isLoading]) + return isLoading ? "Starting up..." : "No project open" + }, [currentProject, isLoading]) /** - * The view filling the main area, the boot screen until the app is done starting up, the - * workspace of the open project, or the welcome view once the project context has settled, - * so a reload cannot flash it over the project it is about to reopen. + * The view filling the main area, the workspace of the open project, or the welcome view + * once the project context has settled, so a reload cannot flash it over the project it + * is about to reopen. */ const mainView = useMemo(() => { - if (isBooting) return if (currentProject) return return isLoading ? null : - }, [isBooting, bootProgress, currentProject, isLoading]) + }, [currentProject, isLoading]) /** * The menus of the application, rebuilt when the recent projects or any @@ -94,18 +93,24 @@ function AppShell() { - + {isBooting ? ( + + ) : ( + <> + -
{mainView}
+
{mainView}
- - {statusCaption} + + {statusCaption} - - - - - + + + + + + + )}
) } diff --git a/src/renderer/components/views/Loading.tsx b/src/renderer/components/views/Loading.tsx index 3bf587b..03282c0 100644 --- a/src/renderer/components/views/Loading.tsx +++ b/src/renderer/components/views/Loading.tsx @@ -7,14 +7,9 @@ type LoadingProps = { progress: number } -/** - * Boot screen shown while the app starts up, standing in for the workspace and the welcome - * view until the project context is done with its initial load, so the Windows 95 startup - * sound plays over something rather than an empty window. - */ export default function Loading({ progress }: LoadingProps) { return ( -
+