Sublime-style Build-n versioning + About box

Build n = commit count, baked at build time into a gitignored
version.gen.ts (postinstall + all build scripts regenerate it). About
lives in the macOS app menu and as a footer row in the options popover
(the cross-platform path), showing build, commit, date and engines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jokin
2026-07-13 23:55:39 +02:00
co-authored by Claude Fable 5
parent 14b92b33a4
commit d80d5c23dc
9 changed files with 112 additions and 6 deletions
+2
View File
@@ -6,3 +6,5 @@ artifacts/
.DS_Store .DS_Store
ui/dist/ ui/dist/
ui/.angular/ ui/.angular/
src/shared/version.gen.ts
release/
+11 -5
View File
@@ -3,13 +3,17 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"workspaces": ["ui"], "workspaces": [
"ui"
],
"scripts": { "scripts": {
"dev": "bun scripts/dev.ts", "dev": "bun scripts/gen-version.ts && bun scripts/dev.ts",
"dev:ui": "bun run --cwd ui start", "dev:ui": "bun run --cwd ui start",
"build:ui": "bun run --cwd ui build", "build:ui": "bun run --cwd ui build",
"build": "bun run build:ui && bunx electrobun build", "build": "bun scripts/gen-version.ts && bun run build:ui && bunx electrobun build",
"start": "bun run build:ui && bunx electrobun dev" "start": "bun scripts/gen-version.ts && bun run build:ui && bunx electrobun dev",
"postinstall": "bun scripts/gen-version.ts",
"release": "bun scripts/release.ts"
}, },
"dependencies": { "dependencies": {
"electrobun": "1.18.1", "electrobun": "1.18.1",
@@ -22,5 +26,7 @@
"@types/ssh2": "^1.15.5", "@types/ssh2": "^1.15.5",
"typescript": "~5.9.3" "typescript": "~5.9.3"
}, },
"trustedDependencies": ["@parcel/watcher"] "trustedDependencies": [
"@parcel/watcher"
]
} }
+46
View File
@@ -0,0 +1,46 @@
// Writes src/shared/version.gen.ts (gitignored) with the "Build n" version:
// n = commit count since the repo root, Sublime Text style. Regenerated by
// postinstall and by every dev/build/release script — never committed, since
// committing it would bump the very count it records.
import path from "node:path";
function git(args: string[]): string | null {
try {
const proc = Bun.spawnSync(["git", ...args], {
cwd: import.meta.dir,
stdout: "pipe",
stderr: "ignore",
});
if (!proc.success) return null;
return proc.stdout.toString().trim() || null;
} catch {
return null;
}
}
const number = Number(git(["rev-list", "--count", "HEAD"]) ?? 0);
const commit = git(["rev-parse", "--short", "HEAD"]) ?? "dev";
const date = new Date().toISOString().slice(0, 10);
let electrobun = "unknown";
try {
const pkg = await Bun.file(
path.join(import.meta.dir, "..", "node_modules", "electrobun", "package.json"),
).json();
electrobun = pkg.version;
} catch {
// not installed yet (postinstall ordering) — fine
}
const out = `// GENERATED by scripts/gen-version.ts — do not edit, do not commit.
export const BUILD = {
number: ${number},
commit: ${JSON.stringify(commit)},
date: ${JSON.stringify(date)},
electrobun: ${JSON.stringify(electrobun)},
} as const;
`;
const target = path.join(import.meta.dir, "..", "src", "shared", "version.gen.ts");
await Bun.write(target, out);
console.log(`[version] Build ${number} (${commit}) -> ${path.relative(process.cwd(), target)}`);
+19
View File
@@ -51,6 +51,7 @@ const events = {
openEditor: (_ctx: EditorOpenContext) => {}, openEditor: (_ctx: EditorOpenContext) => {},
settingsChanged: (_settings: AppSettings) => {}, settingsChanged: (_settings: AppSettings) => {},
openConnectionManager: () => {}, openConnectionManager: () => {},
showAbout: () => {},
}; };
const rpc = createAppRPC({ registry, engine, store, settings, edits, contextMenu, events }); const rpc = createAppRPC({ registry, engine, store, settings, edits, contextMenu, events });
const url = await getMainViewUrl(); const url = await getMainViewUrl();
@@ -104,6 +105,20 @@ events.settingsChanged = (s) => {
manager.broadcastSettings(s); manager.broadcastSettings(s);
}; };
events.openConnectionManager = () => manager.open(); events.openConnectionManager = () => manager.open();
function showAbout(): void {
const { build } = BRANDING;
void Utils.showMessageBox({
type: "info",
title: BRANDING.appName,
message: BRANDING.appName,
detail: `${BRANDING.buildLabel} (${build.commit}) · ${build.date}\n\nElectrobun ${build.electrobun} · Bun ${Bun.version}`,
buttons: ["OK"],
defaultId: 0,
cancelId: 0,
});
}
events.showAbout = showAbout;
// clean editors close with the main window; dirty ones get to ask first, // clean editors close with the main window; dirty ones get to ask first,
// and the modal manager has nothing to guard // and the modal manager has nothing to guard
win.on("close", () => { win.on("close", () => {
@@ -204,6 +219,10 @@ ApplicationMenu.on("application-menu-clicked", (event) => {
manager.open(); manager.open();
return; return;
} }
if (action === "about") {
showAbout();
return;
}
// the manager is modal to the main window — don't drive main behind it // the manager is modal to the main window — don't drive main behind it
if (manager.isOpen()) return; if (manager.isOpen()) return;
rpc.send.menuAction({ action }); rpc.send.menuAction({ action });
+3
View File
@@ -1,4 +1,5 @@
import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/bun"; import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/bun";
import { BRANDING } from "../shared/branding";
import type { SavedConnection } from "../shared/types"; import type { SavedConnection } from "../shared/types";
import type { ConnectionStore } from "./store/connections"; import type { ConnectionStore } from "./store/connections";
@@ -32,6 +33,8 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
ApplicationMenu.setApplicationMenu([ ApplicationMenu.setApplicationMenu([
{ {
submenu: [ submenu: [
{ label: `About ${BRANDING.appName}`, action: "about" },
{ type: "separator" },
{ role: "hide" }, { role: "hide" },
{ role: "hideOthers" }, { role: "hideOthers" },
{ role: "showAll" }, { role: "showAll" },
+7
View File
@@ -30,6 +30,7 @@ export interface AppDeps {
openEditor: (ctx: EditorOpenContext) => void; openEditor: (ctx: EditorOpenContext) => void;
settingsChanged: (settings: AppSettings) => void; settingsChanged: (settings: AppSettings) => void;
openConnectionManager: () => void; openConnectionManager: () => void;
showAbout: () => void;
}; };
} }
@@ -294,6 +295,12 @@ export function createAppRPC({
events.openConnectionManager(); events.openConnectionManager();
return {}; return {};
}), }),
showAbout: () =>
wrap(async () => {
events.showAbout();
return {};
}),
}, },
}, },
}); });
+7 -1
View File
@@ -1,12 +1,18 @@
// The single place to rebrand the app. Everything (window title, bundle name, // The single place to rebrand the app. Everything (window title, bundle name,
// app-data directory, UI header, icon) reads from here. // app-data directory, UI header, icon) reads from here.
import { BUILD } from "./version.gen";
export const BRANDING = { export const BRANDING = {
appName: "miniSCP", appName: "miniSCP",
identifier: "es.teknes.miniscp", identifier: "es.teknes.miniscp",
// Previous app names: their app-data folders are migrated on boot // Previous app names: their app-data folders are migrated on boot
// (see migrateLegacyDataDir in src/bun/store/paths.ts). // (see migrateLegacyDataDir in src/bun/store/paths.ts).
legacyNames: ["BunSCP"], legacyNames: ["BunSCP"],
version: "0.1.0", // Sublime-style versioning: n = commit count (scripts/gen-version.ts).
// `version` stays semver-shaped for electrobun's config/updater.
version: `0.1.${BUILD.number}`,
buildLabel: `Build ${BUILD.number}`,
build: BUILD,
// macOS build converts this .iconset folder into AppIcon.icns. // macOS build converts this .iconset folder into AppIcon.icns.
// Regenerate from assets/icon.png with scripts/gen-iconset.sh. // Regenerate from assets/icon.png with scripts/gen-iconset.sh.
icon: "assets/icon.iconset", icon: "assets/icon.iconset",
+2
View File
@@ -137,6 +137,8 @@ export type AppRPC = {
moveToTrash: { params: { path: string }; response: Result }; moveToTrash: { params: { path: string }; response: Result };
/** Native column-picker menu (right-click on the list headers). */ /** Native column-picker menu (right-click on the list headers). */
showColumnsMenu: { params: { visible: ColumnKey[] }; response: Result }; showColumnsMenu: { params: { visible: ColumnKey[] }; response: Result };
/** Native About box (name, Build n, commit, engine versions). */
showAbout: { params: Record<string, never>; response: Result };
// editor windows // editor windows
/** Main window only: open (or focus) a native editor window. */ /** Main window only: open (or focus) a native editor window. */
openEditor: { params: EditorOpenContext; response: Result }; openEditor: { params: EditorOpenContext; response: Result };
+15
View File
@@ -1,5 +1,6 @@
import { Component, ElementRef, effect, inject, signal, viewChild } from '@angular/core'; import { Component, ElementRef, effect, inject, signal, viewChild } from '@angular/core';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { BRANDING } from '@shared/branding';
import type { AccentTone } from '@shared/types'; import type { AccentTone } from '@shared/types';
import { PanelsService } from '../panels.service'; import { PanelsService } from '../panels.service';
import { IS_MAC_CHROME } from '../platform'; import { IS_MAC_CHROME } from '../platform';
@@ -339,6 +340,14 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
</button> </button>
</div> </div>
</div> </div>
<!-- about footer -->
<button
(click)="showAbout()"
class="mt-3 w-full border-t border-border pt-2 text-center text-[11px] text-muted-foreground hover:text-foreground"
>
{{ appName }} · {{ buildLabel }}
</button>
</div> </div>
} }
</div> </div>
@@ -350,6 +359,8 @@ export class TabBar {
private readonly rpc = inject(RpcService); private readonly rpc = inject(RpcService);
readonly settingsOpen = signal(false); readonly settingsOpen = signal(false);
readonly isMacChrome = IS_MAC_CHROME; readonly isMacChrome = IS_MAC_CHROME;
readonly appName = BRANDING.appName;
readonly buildLabel = BRANDING.buildLabel;
readonly themes = THEMES; readonly themes = THEMES;
readonly darkTones = DARK_TONES; readonly darkTones = DARK_TONES;
readonly accents = ACCENTS; readonly accents = ACCENTS;
@@ -392,6 +403,10 @@ export class TabBar {
void this.rpc.request.openConnectionManager({}); void this.rpc.request.openConnectionManager({});
} }
showAbout(): void {
void this.rpc.request.showAbout({});
}
async pickEditorApp(): Promise<void> { async pickEditorApp(): Promise<void> {
const res = await this.rpc.request.pickEditorApp({}); const res = await this.rpc.request.pickEditorApp({});
if (res.ok && res.path) this.settings.setEditorApp(res.path); if (res.ok && res.path) this.settings.setEditorApp(res.path);