Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,20 @@
},
// CSpell
"cSpell.words": [
"Artur",
"asar",
"ASSETDATA",
"asynciterable",
"Bień",
"browsable",
"CHUNKID",
"CHUNKIDS",
"EXPORTMAP",
"fguid",
"gatherable",
"konsole",
"Nwnp",
"OFPA",
"Qfecheck",
"scrollview",
"SERIALSIZES",
Expand All @@ -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"
Expand Down
39 changes: 39 additions & 0 deletions NOTICE.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
28 changes: 28 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -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,
},
]
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,16 @@
"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'})\"",
"prepare": "npm run hooks:install"
},
"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",
Expand All @@ -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",
Expand Down
Binary file added src/main/assets/sounds/chimes.wav
Binary file not shown.
Binary file added src/main/assets/sounds/chord.wav
Binary file not shown.
Binary file added src/main/assets/sounds/ding.wav
Binary file not shown.
Binary file removed src/main/assets/sounds/error.mp3
Binary file not shown.
Binary file added src/main/assets/sounds/startup.wav
Binary file not shown.
Binary file added src/main/assets/sounds/tada.wav
Binary file not shown.
8 changes: 8 additions & 0 deletions src/main/config/autoLock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* The configuration for the auto-lock loop.
*/
const AUTO_LOCK_CONFIG = {
tickIntervalMs: 10 * 1000,
}

export default AUTO_LOCK_CONFIG
13 changes: 13 additions & 0 deletions src/main/config/mcp.ts
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -48,6 +52,7 @@ function createMainWindow(): BrowserWindow {

// Create the main application window when Electron is ready
app.whenReady().then(() => {
// Handlers
registerAppHandlers()
registerWindowsControlHandlers()
registerDialogsHandlers()
Expand All @@ -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", () => {
Expand Down
21 changes: 14 additions & 7 deletions src/main/lib/app/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
}
27 changes: 27 additions & 0 deletions src/main/lib/autoLock/handlers.ts
Original file line number Diff line number Diff line change
@@ -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())
}
28 changes: 28 additions & 0 deletions src/main/lib/autoLock/index.ts
Original file line number Diff line number Diff line change
@@ -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 }
34 changes: 34 additions & 0 deletions src/main/lib/autoLock/paths.ts
Original file line number Diff line number Diff line change
@@ -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)
}
54 changes: 54 additions & 0 deletions src/main/lib/autoLock/reconcile.ts
Original file line number Diff line number Diff line change
@@ -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<string>, mineLocked: Set<string>): { 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<AutoLockReconcileResult> {
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,
}
}
Loading
Loading