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/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. 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 eba73a7..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'})\"", @@ -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", @@ -44,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/assets/sounds/chimes.wav b/src/main/assets/sounds/chimes.wav new file mode 100644 index 0000000..e509be3 Binary files /dev/null and b/src/main/assets/sounds/chimes.wav differ diff --git a/src/main/assets/sounds/chord.wav b/src/main/assets/sounds/chord.wav new file mode 100644 index 0000000..c2ab12f Binary files /dev/null and b/src/main/assets/sounds/chord.wav differ diff --git a/src/main/assets/sounds/ding.wav b/src/main/assets/sounds/ding.wav new file mode 100644 index 0000000..cac253d Binary files /dev/null and b/src/main/assets/sounds/ding.wav differ diff --git a/src/main/assets/sounds/error.mp3 b/src/main/assets/sounds/error.mp3 deleted file mode 100644 index cbf30a0..0000000 Binary files a/src/main/assets/sounds/error.mp3 and /dev/null differ diff --git a/src/main/assets/sounds/startup.wav b/src/main/assets/sounds/startup.wav new file mode 100644 index 0000000..0e3f936 Binary files /dev/null and b/src/main/assets/sounds/startup.wav differ diff --git a/src/main/assets/sounds/tada.wav b/src/main/assets/sounds/tada.wav new file mode 100644 index 0000000..b5ca819 Binary files /dev/null and b/src/main/assets/sounds/tada.wav differ 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/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/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/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/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 { 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/lfsCommands/service.ts b/src/main/lib/lfsCommands/service.ts index b51b1c8..e712c75 100644 --- a/src/main/lib/lfsCommands/service.ts +++ b/src/main/lib/lfsCommands/service.ts @@ -142,15 +142,14 @@ export async function lockPaths( 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 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/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/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/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/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() 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/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/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/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/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/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/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/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/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/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/App.tsx b/src/renderer/App.tsx index 2a5e13c..b29c755 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,25 @@ 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 isLoading ? "Starting up..." : "No project open" + }, [currentProject, isLoading]) + + /** + * 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 (currentProject) return + return isLoading ? null : + }, [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. @@ -69,15 +93,24 @@ function AppShell() { - + {isBooting ? ( + + ) : ( + <> + + +
{mainView}
-
{currentProject ? : }
+ + {statusCaption} - - {currentProject ? currentProject.path : "No project open"} - - - + + + + + + + )}
) } 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/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/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/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/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/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/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/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/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 && ( <> 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/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/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/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/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/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/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/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, + )} ) } diff --git a/src/renderer/components/views/Loading.tsx b/src/renderer/components/views/Loading.tsx new file mode 100644 index 0000000..03282c0 --- /dev/null +++ b/src/renderer/components/views/Loading.tsx @@ -0,0 +1,26 @@ +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 +} + +export default function Loading({ progress }: LoadingProps) { + return ( +
+
+
+ + +

{APP_CONFIG.title}

+ +

Starting up...

+ + +
+
+
+ ) +} 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/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/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/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/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/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/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/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/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/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. 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" + } +} 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 +} 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") 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 } 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). */ 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"