From 0d4b083858d529ad3b4ce640b51a64884f6f44ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Sat, 4 Jul 2026 09:12:25 +0200 Subject: [PATCH 1/4] Support multiple window instances --- src/main/index.ts | 232 ++++++++---------- src/main/ipc/accounts.ts | 30 ++- src/main/ipc/app.ts | 22 +- src/main/ipc/clone.ts | 13 +- src/main/ipc/context.ts | 40 ++- src/main/ipc/index.ts | 7 +- src/main/ipc/repo.ts | 26 +- src/main/ipc/staging.ts | 4 +- src/main/ipc/sync.ts | 12 +- src/main/menu.ts | 25 +- src/main/updater.ts | 47 ++-- src/main/watcher.ts | 25 ++ src/main/window-state.test.ts | 54 +++- src/main/window-state.ts | 22 ++ src/main/windows.ts | 224 +++++++++++++++++ src/preload/index.ts | 1 + src/renderer/src/App.tsx | 15 +- .../src/components/toolbar/RepoSwitcher.tsx | 12 + src/renderer/src/lib/icons.tsx | 9 + src/renderer/src/lib/theme.ts | 11 + src/shared/ipc.ts | 13 +- 21 files changed, 626 insertions(+), 218 deletions(-) create mode 100644 src/main/windows.ts diff --git a/src/main/index.ts b/src/main/index.ts index 24dcaf5..d135b91 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,19 +1,10 @@ import { existsSync } from 'node:fs' import { basename, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { app, BrowserWindow, nativeImage, shell } from 'electron' - -// The main bundle is emitted as ESM (package.json "type": "module"), where -// __dirname is not defined — reconstruct it from the module URL. -const moduleDir = dirname(fileURLToPath(import.meta.url)) - -// In a packaged build the app's icon comes from the .app/.exe bundle. While -// developing we run inside the generic Electron binary, so point the dock / -// window icon at build/icon.png (sits two levels up from out/main) ourselves. -const devIconPath = join(moduleDir, '../../build/icon.png') +import { app, type BrowserWindow, Menu, nativeImage } from 'electron' import { IPC } from '@shared/ipc' -import type { GitAvailability, RepoOpenResult } from '@shared/types' +import type { GitAvailability, RepoOpenResult, UpdateStatus } from '@shared/types' import { REPO_URL } from './app-info' import { resolveStartupRepo } from './cli' import { gitVersion, locateGit, resetGitLocation } from './git/bin' @@ -27,18 +18,21 @@ import { import { registerIpc } from './ipc' import { buildMenu, type MenuContext } from './menu' import { getRecentRepo, rememberRepo } from './store' -import { initAutoUpdater } from './updater' +import { checkForUpdates, initAutoUpdater } from './updater' import { RepoWatcher } from './watcher' -import { - DEFAULT_WINDOW_HEIGHT, - DEFAULT_WINDOW_WIDTH, - MIN_WINDOW_HEIGHT, - MIN_WINDOW_WIDTH -} from './window-state' -import { loadWindowState, trackWindowState } from './window-state-store' +import { WindowManager } from './windows' const isDev = !app.isPackaged +// One process, many windows. A second launch (double-clicking the app again, +// `gitgrove --repo ` from a shell) must join the running instance: two +// processes would race each other on the recents store, the window-state file +// and the accounts cipher. The running instance reacts in 'second-instance' +// below — it opens the requested repo in a new window, or just raises a window. +if (!app.requestSingleInstanceLock()) { + app.quit() +} + // Chromium's OSCrypt encrypts its own on-disk data (cookies, storage) with a // key it keeps in the OS secret store — the macOS keychain entry "GitGrove // Safe Storage". Reaching that entry pops a "GitGrove wants to use your @@ -69,113 +63,55 @@ if (process.env.GITGROVE_DEBUG_PORT) { app.commandLine.appendSwitch('remote-debugging-port', process.env.GITGROVE_DEBUG_PORT) } -let mainWindow: BrowserWindow | null = null - // A repository named on the command line (`--repo `) or via // GITGROVE_OPEN_REPO, opened once the renderer mounts. Read exactly once (the -// renderer asks for it on startup) so a later reload returns to the welcome -// screen instead of reopening it. +// first window's renderer asks for it on startup) so a later reload returns to +// the welcome screen instead of reopening it. Windows created for a specific +// repo ("Open in New Window", second-instance `--repo`) carry their own +// pending repo inside the WindowManager instead. let startupRepoPath = resolveStartupRepo(process.argv, process.env) -function takeInitialRepoPath(): string | null { - const path = startupRepoPath - startupRepoPath = null - return path -} - -// Path of the repo currently open in the renderer, mirrored here so the -// application menu's repo actions (Reveal in Finder, Open in Terminal, …) know -// what to act on. Null until the first repo opens; the Repository menu items -// are disabled while it is. -let currentRepoPath: string | null = null - -const menuContext: MenuContext = { - getWindow: () => mainWindow, - getRepoPath: () => currentRepoPath -} const watcher = new RepoWatcher((repoPath) => { - mainWindow?.webContents.send(IPC.repoChanged, repoPath) + // Every window gets the ping; each renderer refreshes only when the path + // matches its own open repo (see useOsIntegration), so windows on other + // repos — and the welcome screen — stay untouched. + windows.broadcast(IPC.repoChanged, repoPath) }) -function createWindow(): void { - // Last session's geometry, already reconciled against the monitors attached - // right now (the display it was on may be gone — see window-state.ts). - const windowState = loadWindowState() - mainWindow = new BrowserWindow({ - ...(windowState.bounds ?? { width: DEFAULT_WINDOW_WIDTH, height: DEFAULT_WINDOW_HEIGHT }), - minWidth: MIN_WINDOW_WIDTH, - minHeight: MIN_WINDOW_HEIGHT, - show: false, - backgroundColor: '#0c0d10', - // Window icon is used on Windows/Linux (ignored on macOS); only needed in - // dev — packaged builds carry the icon in the executable. - ...(isDev ? { icon: devIconPath } : {}), - // macOS keeps its inset traffic lights. On Windows/Linux we hide the native - // title bar and menu bar so the app's toolbar acts as the title bar, with - // custom window controls (see WindowControls) painted into it — Alt still - // reveals the menu bar on demand. - titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'hidden', - trafficLightPosition: { x: 16, y: 18 }, - autoHideMenuBar: process.platform !== 'darwin', - webPreferences: { - preload: join(moduleDir, '../preload/index.js'), - sandbox: false, - contextIsolation: true, - nodeIntegration: false - } - }) - - mainWindow.on('ready-to-show', () => { - // Maximize/full-screen must wait until here: maximize() implicitly shows - // the window, which before ready-to-show would flash an unpainted frame. - if (windowState.isMaximized) mainWindow?.maximize() - if (windowState.isFullScreen) mainWindow?.setFullScreen(true) - mainWindow?.show() - }) - - trackWindowState(mainWindow) - - // Keep the renderer's custom window controls (Windows/Linux) in sync with the - // real maximize state so the maximize/restore glyph matches the window. - const emitMaximized = () => - mainWindow?.webContents.send(IPC.windowMaximized, mainWindow.isMaximized()) - mainWindow.on('maximize', emitMaximized) - mainWindow.on('unmaximize', emitMaximized) - - mainWindow.webContents.setWindowOpenHandler(({ url }) => { - shell.openExternal(url) - return { action: 'deny' } - }) +const windows = new WindowManager({ + onOpenReposChanged: (openRepos) => watcher.sync(openRepos), + onMenuTargetChanged: () => rebuildMenuIfTargetChanged() +}) - // A renderer reload (Ctrl/Cmd+R) drops back to the welcome screen with no repo - // open, but our mirrored currentRepoPath survives here in the main process. - // Clear it on every page load so the Repository menu's actions don't keep - // targeting the previously opened repo; openRepoAtPath re-sets it when the - // user opens one again. - mainWindow.webContents.on('did-start-loading', () => { - if (currentRepoPath !== null) { - currentRepoPath = null - buildMenu(menuContext) - } - }) +// The menu's only window-dependent state is the focused window's repo (its +// Repository actions capture the path at build time). Focus changes fire on +// every app switch, so rebuild only when that repo actually differs. +let menuRepoPath: string | null | undefined // undefined = menu never built +function rebuildMenuIfTargetChanged(): void { + const repoPath = windows.repoOfFocusedWindow() + if (repoPath === menuRepoPath) return + menuRepoPath = repoPath + buildMenu(menuContext) +} - if (isDev && process.env.ELECTRON_RENDERER_URL) { - mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL) - } else { - mainWindow.loadFile(join(moduleDir, '../renderer/index.html')) - } +// Update pushes go to every window: the "restart to update" banner is +// app-wide state, and whichever window the user answers from wins. +const pushUpdateStatus = (status: UpdateStatus) => windows.broadcast(IPC.updateStatus, status) - mainWindow.on('closed', () => { - mainWindow = null - }) +const menuContext: MenuContext = { + getWindow: () => windows.focusedWindow(), + getRepoPath: () => windows.repoOfFocusedWindow(), + newWindow: () => windows.createWindow(), + checkForUpdates: () => void checkForUpdates(pushUpdateStatus, true) } /** - * Open a folder, resolve it to a repo root, persist as recent, watch it. + * Open a folder, resolve it to a repo root, persist as recent, watch it, and + * associate it with the window that asked (menu targeting + watcher set). * Returns a cheap summary (current branch only) so the renderer can switch * instantly; branches and status are fetched separately by the renderer. */ -async function openRepoAtPath(rawPath: string): Promise { +async function openRepoAtPath(rawPath: string, win: BrowserWindow | null): Promise { // The folder is gone (a recent whose directory was deleted/moved): hand the // renderer the recovery screen with the last-known name + clone URL, rather // than failing as "not a git repository". git can't be queried on a path @@ -208,11 +144,8 @@ async function openRepoAtPath(rawPath: string): Promise { // vanishes — best-effort, never block opening on it. const remoteUrl = await getRemoteCloneUrl(root).catch(() => null) rememberRepo({ path: summary.path, name: summary.name, remoteUrl }) - watcher.watch(root) - // Point the application menu's repo actions at the now-open repo (and enable - // them if this is the first one). - currentRepoPath = summary.path - buildMenu(menuContext) + // Point the menu's repo actions and the watcher at the now-open repo. + if (win && !win.isDestroyed()) windows.setOpenRepo(win, summary.path) return { ok: true, summary } } @@ -222,7 +155,7 @@ async function openRepoAtPath(rawPath: string): Promise { * trust sticks across sessions and tools), and opens. If the folder is already * trusted by the time we get here, this just opens it. */ -async function trustRepo(rawPath: string): Promise { +async function trustRepo(rawPath: string, win: BrowserWindow | null): Promise { try { await resolveRepoRoot(rawPath) } catch (e) { @@ -232,7 +165,7 @@ async function trustRepo(rawPath: string): Promise { throw e } } - return openRepoAtPath(rawPath) + return openRepoAtPath(rawPath, win) } /** @@ -251,6 +184,27 @@ async function checkGit(force: boolean): Promise { } } +// A second launch routed its argv here (see requestSingleInstanceLock above). +// `--repo ` opens that repo in a new window — this is how "open two +// GitGroves" behaves on Windows/Linux; a bare relaunch raises a window (or +// recreates one if all were closed, e.g. on macOS with the app still running). +app.on('second-instance', (_e, argv) => { + const repoPath = resolveStartupRepo(argv, {}) + if (repoPath) { + windows.createWindow(repoPath) + return + } + if (windows.windowCount() === 0) { + windows.createWindow() + return + } + const win = windows.focusedWindow() + if (win) { + if (win.isMinimized()) win.restore() + win.focus() + } +}) + app.whenReady().then(() => { app.setAboutPanelOptions({ applicationName: app.getName(), @@ -260,26 +214,46 @@ app.whenReady().then(() => { website: REPO_URL }) - // macOS ignores the BrowserWindow icon and shows the bundle icon in the dock; - // in dev that's the generic Electron icon, so override it explicitly. - if (isDev && process.platform === 'darwin' && app.dock) { - const img = nativeImage.createFromPath(devIconPath) - if (!img.isEmpty()) app.dock.setIcon(img) + if (process.platform === 'darwin' && app.dock) { + // macOS ignores the BrowserWindow icon and shows the bundle icon in the + // dock; in dev that's the generic Electron icon, so override it explicitly. + if (isDev) { + // build/icon.png sits two levels up from out/main (ESM: no __dirname). + const devIconPath = join(dirname(fileURLToPath(import.meta.url)), '../../build/icon.png') + const img = nativeImage.createFromPath(devIconPath) + if (!img.isEmpty()) app.dock.setIcon(img) + } + // Finder-style dock menu: right-click the dock icon → "New Window". + app.dock.setMenu( + Menu.buildFromTemplate([{ label: 'New Window', click: () => windows.createWindow() }]) + ) } registerIpc({ - getWindow: () => mainWindow, + windowFrom: (sender) => windows.windowFrom(sender), + focusedWindow: () => windows.focusedWindow(), + broadcast: (channel, ...args) => windows.broadcast(channel, ...args), openRepoAtPath, - takeInitialRepoPath, + openRepoInNewWindow: (path) => void windows.createWindow(path), + // The CLI repo belongs to the first window; windows created for a repo + // ("Open in New Window") consume their own pending path instead. + takeInitialRepoPath: (win) => { + const pending = win ? windows.takePendingRepo(win) : null + if (pending) return pending + const path = startupRepoPath + startupRepoPath = null + return path + }, trustRepo, - checkGit + checkGit, + checkForUpdates: (manual) => checkForUpdates(pushUpdateStatus, manual) }) - buildMenu(menuContext) - createWindow() - initAutoUpdater(() => mainWindow) + rebuildMenuIfTargetChanged() + windows.createWindow() + initAutoUpdater(pushUpdateStatus) app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) createWindow() + if (windows.windowCount() === 0) windows.createWindow() }) }) diff --git a/src/main/ipc/accounts.ts b/src/main/ipc/accounts.ts index a05f42e..18f226b 100644 --- a/src/main/ipc/accounts.ts +++ b/src/main/ipc/accounts.ts @@ -21,7 +21,7 @@ import { getGlobalIdentity, getIdentity, setGlobalIdentity, setIdentity } from ' import type { HandlerDeps } from './context' export function registerAccountHandlers(deps: HandlerDeps): void { - const { getWindow } = deps + const { focusedWindow, broadcast } = deps // ── Commit identity ── ipcMain.handle(IPC.getIdentity, (_e, repoPath: string) => getIdentity(repoPath)) @@ -48,7 +48,12 @@ export function registerAccountHandlers(deps: HandlerDeps): void { setCredentialResponder((prompt, signal) => { const silent = answerFromAccounts(accountsStore(), prompt) if (silent !== null) return Promise.resolve(silent) - const window = getWindow() + // The prompt goes to the window the user is working in — the op that hit + // it almost always started there, and popping the same dialog in every + // window would leave orphaned copies (each window closes only the dialog + // it answered). Dismissals broadcast instead: focus may have moved since, + // and only the window showing that requestId reacts. + const window = focusedWindow() // No window to ask — cancel so the operation fails fast instead of hanging. if (!window) return Promise.resolve(null) const requestId = `credential-${++credentialSeq}` @@ -62,7 +67,7 @@ export function registerAccountHandlers(deps: HandlerDeps): void { // git operation has already failed. signal.addEventListener('abort', () => { settle(null) - getWindow()?.webContents.send(IPC.credentialDismiss, requestId) + broadcast(IPC.credentialDismiss, requestId) }) const request: CredentialPromptRequest = { requestId, ...prompt } window.webContents.send(IPC.credentialPrompt, request) @@ -86,7 +91,7 @@ export function registerAccountHandlers(deps: HandlerDeps): void { const answer = answerFromAccounts(accountsStore(), pending.prompt) if (answer !== null) { pending.settle(answer) - getWindow()?.webContents.send(IPC.credentialDismiss, requestId) + broadcast(IPC.credentialDismiss, requestId) } } } @@ -99,7 +104,8 @@ export function registerAccountHandlers(deps: HandlerDeps): void { const afterConnect = async (host: string) => { await rejectStoredCredential(host) answerPendingPromptsFor(host) - getWindow()?.webContents.send(IPC.accountsChanged) + // Every window's settings pane and clone dialog shows the accounts list. + broadcast(IPC.accountsChanged) } ipcMain.handle(IPC.accountsList, () => accountsStore().listAccounts()) @@ -110,26 +116,30 @@ export function registerAccountHandlers(deps: HandlerDeps): void { ipcMain.handle(IPC.accountsLookupAvatar, (_e, email: string) => lookupAvatarUrl(accountsStore(), email) ) - ipcMain.handle(IPC.accountRepos, (_e, accountId: string) => { + ipcMain.handle(IPC.accountRepos, (e, accountId: string) => { const store = accountsStore() const account = store.listAccounts().find((a) => a.id === accountId) const token = account && store.getTokenForHost(account.host) if (!account || !token) throw new Error('That account is no longer connected.') + // Pages stream back to the window whose picker asked for them. return fetchRepositories(account.host, token, fetch, (repos) => { - getWindow()?.webContents.send(IPC.accountReposPage, { accountId, repos }) + if (!e.sender.isDestroyed()) e.sender.send(IPC.accountReposPage, { accountId, repos }) }) }) // One sign-in at a time: a newly started flow supersedes (aborts) the old. let oauthInFlight: AbortController | null = null - ipcMain.handle(IPC.accountsBeginOAuth, async (_e, host: string, clientId?: string) => { + ipcMain.handle(IPC.accountsBeginOAuth, async (e, host: string, clientId?: string) => { oauthInFlight?.abort() const controller = new AbortController() oauthInFlight = controller const result = await connectViaOAuth(accountsStore(), host, { clientId, signal: controller.signal, - onDeviceCode: (info) => getWindow()?.webContents.send(IPC.accountsDeviceCode, info) + // The user code belongs in the window whose sign-in dialog is open. + onDeviceCode: (info) => { + if (!e.sender.isDestroyed()) e.sender.send(IPC.accountsDeviceCode, info) + } }) if (oauthInFlight === controller) oauthInFlight = null if (result.ok) await afterConnect(result.account.host) @@ -149,6 +159,6 @@ export function registerAccountHandlers(deps: HandlerDeps): void { // The OS helper still holds the token git stored on the last success — // sign-out must actually sign the machine out, not just forget metadata. if (removed) await rejectStoredCredential(removed.host) - getWindow()?.webContents.send(IPC.accountsChanged) + broadcast(IPC.accountsChanged) }) } diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 1517e91..5b211b9 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -4,26 +4,28 @@ import { IPC } from '@shared/ipc' import { clipboard, ipcMain, Menu, shell } from 'electron' import { appInfo } from '../app-info' -import { checkForUpdates, quitAndInstall } from '../updater' +import { quitAndInstall } from '../updater' import type { HandlerDeps } from './context' export function registerAppHandlers(deps: HandlerDeps): void { - const { getWindow, checkGit } = deps + const { windowFrom, checkGit, checkForUpdates } = deps ipcMain.handle(IPC.checkGit, (_e, force?: boolean) => checkGit(!!force)) ipcMain.handle(IPC.openExternal, (_e, url: string) => shell.openExternal(url)) ipcMain.handle(IPC.clipboardWrite, (_e, text: string) => clipboard.writeText(text)) // Window controls for the custom title bar (Windows/Linux; no-ops elsewhere). - ipcMain.handle(IPC.windowMinimize, () => getWindow()?.minimize()) - ipcMain.handle(IPC.windowMaximizeToggle, () => { - const window = getWindow() + // Always the *sender's* window: the control clicked must drive the window it + // sits in, never whichever window happens to be focused. + ipcMain.handle(IPC.windowMinimize, (e) => windowFrom(e.sender)?.minimize()) + ipcMain.handle(IPC.windowMaximizeToggle, (e) => { + const window = windowFrom(e.sender) if (!window) return if (window.isMaximized()) window.unmaximize() else window.maximize() }) - ipcMain.handle(IPC.windowClose, () => getWindow()?.close()) - ipcMain.handle(IPC.windowIsMaximized, () => getWindow()?.isMaximized() ?? false) + ipcMain.handle(IPC.windowClose, (e) => windowFrom(e.sender)?.close()) + ipcMain.handle(IPC.windowIsMaximized, (e) => windowFrom(e.sender)?.isMaximized() ?? false) // Custom always-visible menu bar (Windows/Linux): the renderer draws the // top-level labels and asks us to pop the corresponding native submenu, so all @@ -32,15 +34,15 @@ export function registerAppHandlers(deps: HandlerDeps): void { const menu = Menu.getApplicationMenu() return menu ? menu.items.filter((i) => i.submenu).map((i) => i.label) : [] }) - ipcMain.handle(IPC.menuPopup, (_e, label: string, x: number, y: number) => { + ipcMain.handle(IPC.menuPopup, (e, label: string, x: number, y: number) => { const item = Menu.getApplicationMenu()?.items.find((i) => i.label === label) - const window = getWindow() + const window = windowFrom(e.sender) if (item?.submenu && window) { item.submenu.popup({ window, x: Math.round(x), y: Math.round(y) }) } }) ipcMain.handle(IPC.appInfo, () => appInfo()) - ipcMain.handle(IPC.checkForUpdates, (_e, manual: boolean) => checkForUpdates(getWindow, manual)) + ipcMain.handle(IPC.checkForUpdates, (_e, manual: boolean) => checkForUpdates(manual)) ipcMain.handle(IPC.installUpdate, () => quitAndInstall()) } diff --git a/src/main/ipc/clone.ts b/src/main/ipc/clone.ts index cd771bb..17aa386 100644 --- a/src/main/ipc/clone.ts +++ b/src/main/ipc/clone.ts @@ -10,12 +10,15 @@ import * as gitSync from '../git/sync' import type { HandlerDeps } from './context' export function registerCloneHandlers(deps: HandlerDeps): void { - const { getWindow } = deps + const { windowFrom } = deps - ipcMain.handle(IPC.cloneRepo, async (_e, url: string, targetPath: string) => { + ipcMain.handle(IPC.cloneRepo, async (e, url: string, targetPath: string) => { const target = expandHome(targetPath) + // Progress goes to the window whose clone dialog is running, not to + // whichever window is focused — the user may be working elsewhere meanwhile. const dest = await gitSync.clone(url, target, (phase, percent) => { - getWindow()?.webContents.send(IPC.cloneProgress, { phase, percent, done: false }) + if (!e.sender.isDestroyed()) + e.sender.send(IPC.cloneProgress, { phase, percent, done: false }) }) // The next clone should land beside this one — remember the parent folder. rememberCloneBaseDir(dirname(target)) @@ -23,8 +26,8 @@ export function registerCloneHandlers(deps: HandlerDeps): void { }) ipcMain.handle(IPC.defaultCloneDir, () => getCloneBaseDir()) ipcMain.handle(IPC.checkCloneTarget, (_e, targetPath: string) => cloneTargetState(targetPath)) - ipcMain.handle(IPC.pickDirectory, async (_e, title?: string) => { - const window = getWindow() + ipcMain.handle(IPC.pickDirectory, async (e, title?: string) => { + const window = windowFrom(e.sender) if (!window) return null const result = await dialog.showOpenDialog(window, { title: title ?? 'Choose Folder', diff --git a/src/main/ipc/context.ts b/src/main/ipc/context.ts index 0167c4f..7016e41 100644 --- a/src/main/ipc/context.ts +++ b/src/main/ipc/context.ts @@ -1,18 +1,35 @@ // Shared dependencies threaded into every handler module. The app shell // (index.ts) supplies the IpcContext; registerIpc derives `opProgressTo` from // it and hands the combined HandlerDeps to each register*Handlers function. +// +// GitGrove is multi-window, so "the window" is never a global: a handler that +// needs one resolves it from the invoking renderer (`windowFrom(e.sender)` — +// dialogs, window controls, per-op progress), while pushes that aren't tied to +// a renderer call either go to every window (`broadcast`, filtered by repo +// path on the renderer side) or to the window the user is working in +// (`focusedWindow` — credential prompts). import type { GitAvailability, ProgressOpKind, RepoOpenResult } from '@shared/types' -import type { BrowserWindow } from 'electron' +import type { BrowserWindow, WebContents } from 'electron' /** What the handlers need from the app shell. */ export interface IpcContext { - getWindow(): BrowserWindow | null - openRepoAtPath(path: string): Promise - /** The repo requested on launch, returned once then forgotten (see cli.ts). */ - takeInitialRepoPath(): string | null - trustRepo(path: string): Promise + /** The window hosting the renderer that invoked a handler, or null if gone. */ + windowFrom(sender: WebContents): BrowserWindow | null + /** The window the user is working in (focused, or last focused). */ + focusedWindow(): BrowserWindow | null + /** Send to every open window; renderers filter by repo path where relevant. */ + broadcast(channel: string, ...args: unknown[]): void + /** Open a repo in `win` (associates it for the menu and the watcher). */ + openRepoAtPath(path: string, win: BrowserWindow | null): Promise + /** Open a repo in a brand-new window (repo switcher's "Open in New Window"). */ + openRepoInNewWindow(path: string): void + /** The repo `win` should open on boot, returned once then forgotten. */ + takeInitialRepoPath(win: BrowserWindow | null): string | null + trustRepo(path: string, win: BrowserWindow | null): Promise checkGit(force: boolean): Promise + /** Run an update check; results are pushed to every window as UpdateStatus. */ + checkForUpdates(manual: boolean): Promise } /** Pushes phase + percent for one long-running op to the renderer. */ @@ -20,10 +37,15 @@ export type ProgressReporter = (phase: string, percent: number) => void /** * Builds a progress forwarder for a long-running op: each call pushes phase + - * percent to the renderer so the matching button can fill determinately while - * git works. + * percent to the renderer that started the op — never to other windows, whose + * own busy state must not be disturbed — so the matching button can fill + * determinately while git works. */ -export type OpProgressFactory = (repoPath: string, kind: ProgressOpKind) => ProgressReporter +export type OpProgressFactory = ( + sender: WebContents, + repoPath: string, + kind: ProgressOpKind +) => ProgressReporter /** Everything a handler module receives: the app shell plus the progress factory. */ export interface HandlerDeps extends IpcContext { diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 7dde68d..cb9351f 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -27,9 +27,12 @@ import { registerWorktreeHandlers } from './worktrees' export type { IpcContext } from './context' export function registerIpc(ctx: IpcContext): void { - const opProgressTo: OpProgressFactory = (repoPath, kind) => (phase, percent) => { + // Progress goes back to the renderer that started the op — not broadcast: + // another window busy on the same repo must not see this op's fill. + const opProgressTo: OpProgressFactory = (sender, repoPath, kind) => (phase, percent) => { + if (sender.isDestroyed()) return const progress: OpProgress = { repoPath, kind, phase, percent } - ctx.getWindow()?.webContents.send(IPC.opProgress, progress) + sender.send(IPC.opProgress, progress) } const deps: HandlerDeps = { ...ctx, opProgressTo } diff --git a/src/main/ipc/repo.ts b/src/main/ipc/repo.ts index b31b430..8efe4be 100644 --- a/src/main/ipc/repo.ts +++ b/src/main/ipc/repo.ts @@ -15,10 +15,17 @@ import { getRecentRepos, removeRecentRepo } from '../store' import type { HandlerDeps } from './context' export function registerRepoHandlers(deps: HandlerDeps): void { - const { getWindow, openRepoAtPath, takeInitialRepoPath, trustRepo, opProgressTo } = deps + const { + windowFrom, + openRepoAtPath, + openRepoInNewWindow, + takeInitialRepoPath, + trustRepo, + opProgressTo + } = deps - ipcMain.handle(IPC.pickRepo, async () => { - const window = getWindow() + ipcMain.handle(IPC.pickRepo, async (e) => { + const window = windowFrom(e.sender) if (!window) return null const result = await dialog.showOpenDialog(window, { title: 'Open Git Repository', @@ -26,12 +33,13 @@ export function registerRepoHandlers(deps: HandlerDeps): void { buttonLabel: 'Open' }) if (result.canceled || result.filePaths.length === 0) return null - return openRepoAtPath(result.filePaths[0]) + return openRepoAtPath(result.filePaths[0], window) }) - ipcMain.handle(IPC.openRepo, (_e, path: string) => openRepoAtPath(path)) - ipcMain.handle(IPC.initialRepoPath, () => takeInitialRepoPath()) - ipcMain.handle(IPC.trustRepo, (_e, path: string) => trustRepo(path)) + ipcMain.handle(IPC.openRepo, (e, path: string) => openRepoAtPath(path, windowFrom(e.sender))) + ipcMain.handle(IPC.openRepoNewWindow, (_e, path: string) => openRepoInNewWindow(path)) + ipcMain.handle(IPC.initialRepoPath, (e) => takeInitialRepoPath(windowFrom(e.sender))) + ipcMain.handle(IPC.trustRepo, (e, path: string) => trustRepo(path, windowFrom(e.sender))) ipcMain.handle(IPC.recentRepos, () => getRecentRepos()) ipcMain.handle(IPC.removeRecent, (_e, path: string) => removeRecentRepo(path)) @@ -59,7 +67,7 @@ export function registerRepoHandlers(deps: HandlerDeps): void { ipcMain.handle( IPC.checkout, async ( - _e, + e, repoPath: string, branch: string, opts?: { changes?: BranchChangesAction } @@ -69,7 +77,7 @@ export function registerRepoHandlers(deps: HandlerDeps): void { repoPath, branch, opts, - opProgressTo(repoPath, 'checkout') + opProgressTo(e.sender, repoPath, 'checkout') ) return { branch: await getBranches(repoPath), outcome } } diff --git a/src/main/ipc/staging.ts b/src/main/ipc/staging.ts index 97ae890..d1caa9e 100644 --- a/src/main/ipc/staging.ts +++ b/src/main/ipc/staging.ts @@ -13,11 +13,11 @@ export function registerStagingHandlers(deps: HandlerDeps): void { ipcMain.handle( IPC.discardFiles, - async (_e, repoPath: string, files: DiscardItem[], untrackedPaths: string[]) => { + async (e, repoPath: string, files: DiscardItem[], untrackedPaths: string[]) => { const { trashPaths, resetPaths, checkoutPaths } = gitWrite.planDiscard(files, untrackedPaths) // Big discards take real time (one trash call per file, then the git // steps) — report determinate progress so the dialog can show a bar. - const progress = opProgressTo(repoPath, 'discard') + const progress = opProgressTo(e.sender, repoPath, 'discard') let lastPercent = -1 for (let i = 0; i < trashPaths.length; i++) { await shell.trashItem(join(repoPath, trashPaths[i])).catch(() => {}) diff --git a/src/main/ipc/sync.ts b/src/main/ipc/sync.ts index 27732ca..68dae69 100644 --- a/src/main/ipc/sync.ts +++ b/src/main/ipc/sync.ts @@ -8,18 +8,18 @@ import type { HandlerDeps } from './context' export function registerSyncHandlers(deps: HandlerDeps): void { const { opProgressTo } = deps - ipcMain.handle(IPC.fetch, (_e, repoPath: string, remote?: string, opts?: { quiet?: boolean }) => - gitSync.fetch(repoPath, remote, opProgressTo(repoPath, 'fetch'), opts) + ipcMain.handle(IPC.fetch, (e, repoPath: string, remote?: string, opts?: { quiet?: boolean }) => + gitSync.fetch(repoPath, remote, opProgressTo(e.sender, repoPath, 'fetch'), opts) ) - ipcMain.handle(IPC.pull, (_e, repoPath: string, opts?: { rebase?: boolean }) => - gitSync.pull(repoPath, opts, opProgressTo(repoPath, 'pull')) + ipcMain.handle(IPC.pull, (e, repoPath: string, opts?: { rebase?: boolean }) => + gitSync.pull(repoPath, opts, opProgressTo(e.sender, repoPath, 'pull')) ) ipcMain.handle( IPC.push, ( - _e, + e, repoPath: string, opts?: { setUpstream?: { remote: string; branch: string }; forceWithLease?: boolean } - ) => gitSync.push(repoPath, opts, opProgressTo(repoPath, 'push')) + ) => gitSync.push(repoPath, opts, opProgressTo(e.sender, repoPath, 'push')) ) } diff --git a/src/main/menu.ts b/src/main/menu.ts index 4e0b956..88fcb01 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -17,12 +17,20 @@ import { } from 'electron' import { REPO_URL } from './app-info' import { getRemoteWebUrl } from './git/read' -import { checkForUpdates } from './updater' -/** What the menu needs from the app: the live window and the open repo path. */ +/** + * What the menu needs from the app. GitGrove is multi-window: `getWindow` is + * the *focused* window and `getRepoPath` the repo open in it — the menu is + * rebuilt whenever focus moves or a window's repo changes, so its Repository + * actions always target the window the user is looking at. + */ export interface MenuContext { getWindow(): BrowserWindow | null getRepoPath(): string | null + /** Open a fresh window (File ▸ New Window / the macOS dock menu). */ + newWindow(): void + /** Run a manual update check; status is pushed to every window. */ + checkForUpdates(): void } export function buildMenu(ctx: MenuContext): void { @@ -45,7 +53,7 @@ export function buildMenu(ctx: MenuContext): void { }, { label: 'Check for Updates…', - click: () => checkForUpdates(getWindow, true) + click: () => ctx.checkForUpdates() }, { type: 'separator' as const }, { @@ -66,6 +74,15 @@ export function buildMenu(ctx: MenuContext): void { { label: 'File', submenu: [ + { + // Cmd/Ctrl+N, the platform convention (Finder, editors, browsers). + // The new window opens on the welcome screen; "Open in New Window" + // on a repo (repo switcher) is the one-gesture path to a second repo. + label: 'New Window', + accelerator: 'CmdOrCtrl+N', + click: () => ctx.newWindow() + }, + { type: 'separator' }, { label: 'Open Repository…', accelerator: 'CmdOrCtrl+O', @@ -216,7 +233,7 @@ export function buildMenu(ctx: MenuContext): void { { type: 'separator' as const }, { label: 'Check for Updates…', - click: () => checkForUpdates(getWindow, true) + click: () => ctx.checkForUpdates() }, { label: `About ${app.name}`, diff --git a/src/main/updater.ts b/src/main/updater.ts index d9411e5..2fe230f 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -26,9 +26,8 @@ import { Readable } from 'node:stream' import { pipeline } from 'node:stream/promises' import { promisify } from 'node:util' -import { IPC } from '@shared/ipc' import type { UpdateStatus } from '@shared/types' -import { app, type BrowserWindow, shell } from 'electron' +import { app, shell } from 'electron' import electronUpdater, { type UpdateInfo } from 'electron-updater' import { REPO_URL } from './app-info' @@ -55,16 +54,19 @@ let manualMode = false // Absolute path of a .dmg we've downloaded and are waiting for the user to open. let pendingInstallFile: string | null = null -/** Push an UpdateStatus to the renderer, stamped with version + manual flag. */ +/** + * Delivers an UpdateStatus to the renderers. The app shell implements this as + * a broadcast to every window: an available update is app-wide state, so the + * banner belongs in all of them. + */ +export type UpdateStatusPush = (status: UpdateStatus) => void + +/** Push an UpdateStatus to the renderers, stamped with version + manual flag. */ function pushStatus( - getWindow: () => BrowserWindow | null, + push: UpdateStatusPush, status: Omit ): void { - getWindow()?.webContents.send(IPC.updateStatus, { - ...status, - version, - manual: manualCheck - } satisfies UpdateStatus) + push({ ...status, version, manual: manualCheck } satisfies UpdateStatus) } /** Flatten electron-updater's string | {note}[] release notes to plain text. */ @@ -130,9 +132,9 @@ async function downloadFile( */ async function downloadForManualInstall( info: UpdateInfo, - getWindow: () => BrowserWindow | null + pushRaw: UpdateStatusPush ): Promise { - const push = pushStatus.bind(null, getWindow) + const push = pushStatus.bind(null, pushRaw) try { // latest-mac.yml lists every arch's artifacts and the update-available @@ -173,21 +175,21 @@ async function downloadForManualInstall( } } -export function initAutoUpdater(getWindow: () => BrowserWindow | null): void { +export function initAutoUpdater(pushRaw: UpdateStatusPush): void { if (initialized) return initialized = true autoUpdater.autoDownload = true autoUpdater.autoInstallOnAppQuit = true - const push = pushStatus.bind(null, getWindow) + const push = pushStatus.bind(null, pushRaw) autoUpdater.on('checking-for-update', () => push({ state: 'checking' })) autoUpdater.on('update-available', (info) => { // Unsigned macOS: take over the download so Squirrel never tries (and fails) // to validate. Everywhere else electron-updater downloads automatically. if (manualMode) { - void downloadForManualInstall(info, getWindow) + void downloadForManualInstall(info, pushRaw) } else { push({ state: 'available', newVersion: info.version, notes: notesToText(info.releaseNotes) }) } @@ -211,29 +213,22 @@ export function initAutoUpdater(getWindow: () => BrowserWindow | null): void { autoUpdater.autoInstallOnAppQuit = false } setTimeout(() => { - void checkForUpdates(getWindow, false) + void checkForUpdates(pushRaw, false) // Keep checking while the app is open; unref so a pending timer never // holds the process alive past quit. - setInterval(() => void checkForUpdates(getWindow, false), PERIODIC_CHECK_INTERVAL_MS).unref() + setInterval(() => void checkForUpdates(pushRaw, false), PERIODIC_CHECK_INTERVAL_MS).unref() }, INITIAL_CHECK_DELAY_MS) }) } -export async function checkForUpdates( - getWindow: () => BrowserWindow | null, - manual: boolean -): Promise { +export async function checkForUpdates(push: UpdateStatusPush, manual: boolean): Promise { manualCheck = manual // electron-updater throws ("update checking is disabled…") for unpackaged // builds. Tell the user plainly when they ask; stay silent on auto-checks. if (!app.isPackaged) { if (manual) { - getWindow()?.webContents.send(IPC.updateStatus, { - state: 'dev', - version, - manual: true - } satisfies UpdateStatus) + push({ state: 'dev', version, manual: true } satisfies UpdateStatus) } return } @@ -241,7 +236,7 @@ export async function checkForUpdates( try { await autoUpdater.checkForUpdates() } catch (err) { - getWindow()?.webContents.send(IPC.updateStatus, { + push({ state: 'error', version, manual, diff --git a/src/main/watcher.ts b/src/main/watcher.ts index ed9b6eb..81a213c 100644 --- a/src/main/watcher.ts +++ b/src/main/watcher.ts @@ -53,6 +53,31 @@ export class RepoWatcher { this.watchers.set(repoPath, handles) } + /** + * Reconcile the watched set with the repos currently open across all + * windows: start watching newly opened ones, stop watching repos no window + * shows anymore. Keeping orphaned watchers alive would burn fs events (and + * on Linux, inotify watches) on repos nobody is looking at. + */ + sync(openRepos: ReadonlySet): void { + for (const repoPath of openRepos) this.watch(repoPath) + for (const repoPath of [...this.watchers.keys()]) { + if (!openRepos.has(repoPath)) this.unwatch(repoPath) + } + } + + unwatch(repoPath: string): void { + const handles = this.watchers.get(repoPath) + if (!handles) return + for (const h of handles) h.close() + this.watchers.delete(repoPath) + const timer = this.timers.get(repoPath) + if (timer) { + clearTimeout(timer) + this.timers.delete(repoPath) + } + } + unwatchAll(): void { for (const handles of this.watchers.values()) { for (const h of handles) h.close() diff --git a/src/main/window-state.test.ts b/src/main/window-state.test.ts index a8c3c3f..8c84bf0 100644 --- a/src/main/window-state.test.ts +++ b/src/main/window-state.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from 'bun:test' -import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, type Rect, sanitizeWindowState } from './window-state' +import { + cascadeBounds, + MIN_WINDOW_HEIGHT, + MIN_WINDOW_WIDTH, + type Rect, + sanitizeWindowState +} from './window-state' // A 1920x1080 primary whose work area starts below a 40px menu/task bar. const primary: Rect = { x: 0, y: 40, width: 1920, height: 1040 } @@ -125,3 +131,49 @@ describe('sanitizeWindowState', () => { expect(state.bounds).toBeNull() }) }) + +describe('cascadeBounds', () => { + test('offsets down-right from the source window, keeping its size', () => { + const source = { x: 100, y: 140, width: 1200, height: 800 } + expect(cascadeBounds(source, primary)).toEqual({ x: 128, y: 168, width: 1200, height: 800 }) + }) + + test('wraps back to the work area edge when the offset would overflow', () => { + // Source sits flush against the bottom-right of the primary work area. + const source = { x: 720, y: 280, width: 1200, height: 800 } + const result = cascadeBounds(source, primary) + // x wraps (748 + 1200 > 1920) and y wraps (308 + 800 > 1080). + expect(result).toEqual({ x: primary.x, y: primary.y, width: 1200, height: 800 }) + }) + + test('wraps each axis independently', () => { + const source = { x: 720, y: 100, width: 1200, height: 800 } + const result = cascadeBounds(source, primary) + expect(result.x).toBe(primary.x) // horizontal overflow wraps… + expect(result.y).toBe(128) // …vertical still cascades + }) + + test('clamps an oversized source to the work area', () => { + const source = { x: 0, y: 38, width: 2560, height: 1440 } + const result = cascadeBounds(source, laptop) + expect(result.width).toBe(laptop.width) + expect(result.height).toBe(laptop.height) + }) + + test('never returns a window below the app minimum size', () => { + const source = { x: 10, y: 50, width: 200, height: 100 } + const result = cascadeBounds(source, primary) + expect(result.width).toBe(MIN_WINDOW_WIDTH) + expect(result.height).toBe(MIN_WINDOW_HEIGHT) + }) + + test('respects a secondary display work area origin', () => { + const source = { x: 2000, y: 60, width: 1600, height: 1200 } + expect(cascadeBounds(source, external)).toEqual({ + x: 2028, + y: 88, + width: 1600, + height: 1200 + }) + }) +}) diff --git a/src/main/window-state.ts b/src/main/window-state.ts index 55788f8..510156d 100644 --- a/src/main/window-state.ts +++ b/src/main/window-state.ts @@ -129,6 +129,28 @@ function centerIn(workArea: Rect, width: number, height: number): Rect { } } +// How far a new window is offset from the one it cascades from — enough to +// read both title bars, small enough to keep the windows visually related. +const CASCADE_OFFSET = 28 + +/** + * Bounds for a window opened *next to* an existing one (File ▸ New Window, + * "Open in New Window"): same size, offset down-right so both stay readable. + * When the offset would push an edge past the work area, that axis snaps back + * to the work area's origin — the classic cascade wrap — so any number of new + * windows stays fully on-screen. Size is clamped to the work area first, so + * the wrap test is against the size the window will actually get. + */ +export function cascadeBounds(source: Rect, workArea: Rect): Rect { + const width = Math.min(Math.max(source.width, MIN_WINDOW_WIDTH), workArea.width) + const height = Math.min(Math.max(source.height, MIN_WINDOW_HEIGHT), workArea.height) + let x = source.x + CASCADE_OFFSET + let y = source.y + CASCADE_OFFSET + if (x + width > workArea.x + workArea.width) x = workArea.x + if (y + height > workArea.y + workArea.height) y = workArea.y + return { x, y, width, height } +} + /** * Reconcile a previously saved window state (raw JSON, untrusted) with the * current display work areas. Guarantees of the result: diff --git a/src/main/windows.ts b/src/main/windows.ts new file mode 100644 index 0000000..63f9c80 --- /dev/null +++ b/src/main/windows.ts @@ -0,0 +1,224 @@ +// All BrowserWindow lifecycle in one place. GitGrove is multi-window: every +// window hosts one repository (or the welcome screen), all inside a *single* +// main process — so the per-repo write queue, the recents store and the +// window-state file keep exactly one writer, no matter how many windows are +// open. (index.ts enforces the single process with the single-instance lock.) +// +// The manager owns three pieces of per-window state: +// - which repo each window has open (drives the application menu and the +// watcher's ref-counted set of watched repos); +// - the repo a freshly created window should open on boot ("Open in New +// Window" / second-instance `--repo`), consumed once by initialRepoPath; +// - which window was focused last, so menu actions and credential prompts +// still have a sensible target while the app is in the background. + +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { IPC } from '@shared/ipc' +import { app, BrowserWindow, screen, shell, type WebContents } from 'electron' +import { + cascadeBounds, + DEFAULT_WINDOW_HEIGHT, + DEFAULT_WINDOW_WIDTH, + MIN_WINDOW_HEIGHT, + MIN_WINDOW_WIDTH, + type Rect +} from './window-state' +import { loadWindowState, trackWindowState } from './window-state-store' + +// The main bundle is emitted as ESM (package.json "type": "module"), where +// __dirname is not defined — reconstruct it from the module URL. +const moduleDir = dirname(fileURLToPath(import.meta.url)) + +// In a packaged build the app's icon comes from the .app/.exe bundle. While +// developing we run inside the generic Electron binary, so point the dock / +// window icon at build/icon.png (sits two levels up from out/main) ourselves. +const devIconPath = join(moduleDir, '../../build/icon.png') + +const isDev = !app.isPackaged + +/** What the app shell wires into the manager. */ +export interface WindowManagerHooks { + /** The set of repos open across all windows changed (open/close/reload). */ + onOpenReposChanged(openRepos: ReadonlySet): void + /** The window the application menu should target changed (focus/open/close). */ + onMenuTargetChanged(): void +} + +export class WindowManager { + private windows = new Set() + /** Repo open in each window, by window id. Absent → welcome screen. */ + private repoByWindow = new Map() + /** Repo a new window should open on boot, consumed once (see takePendingRepo). */ + private pendingRepoByWindow = new Map() + private lastFocused: BrowserWindow | null = null + + constructor(private hooks: WindowManagerHooks) {} + + /** + * Create a window. The first window restores last session's geometry; every + * additional one cascades down-right from the focused window so both stay + * readable. `repoPath` (when given) is what the new window opens on boot — + * its renderer picks it up through initialRepoPath. + */ + createWindow(repoPath?: string): BrowserWindow { + // Last session's geometry (already reconciled against the monitors attached + // right now — see window-state.ts) applies to the first window only; later + // windows cascade from the focused one and never open maximized, which + // would bury the window they came from. + const restored = this.windows.size === 0 ? loadWindowState() : null + const win = new BrowserWindow({ + ...(restored + ? (restored.bounds ?? { width: DEFAULT_WINDOW_WIDTH, height: DEFAULT_WINDOW_HEIGHT }) + : this.cascadeFromFocused()), + minWidth: MIN_WINDOW_WIDTH, + minHeight: MIN_WINDOW_HEIGHT, + show: false, + backgroundColor: '#0c0d10', + // Window icon is used on Windows/Linux (ignored on macOS); only needed in + // dev — packaged builds carry the icon in the executable. + ...(isDev ? { icon: devIconPath } : {}), + // macOS keeps its inset traffic lights. On Windows/Linux we hide the native + // title bar and menu bar so the app's toolbar acts as the title bar, with + // custom window controls (see WindowControls) painted into it — Alt still + // reveals the menu bar on demand. + titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'hidden', + trafficLightPosition: { x: 16, y: 18 }, + autoHideMenuBar: process.platform !== 'darwin', + webPreferences: { + preload: join(moduleDir, '../preload/index.js'), + sandbox: false, + contextIsolation: true, + nodeIntegration: false + } + }) + + this.windows.add(win) + // Captured now: BrowserWindow getters throw once the window is destroyed, + // and the 'closed' cleanup below runs exactly then. + const windowId = win.id + if (repoPath) this.pendingRepoByWindow.set(windowId, repoPath) + + win.on('ready-to-show', () => { + // Maximize/full-screen must wait until here: maximize() implicitly shows + // the window, which before ready-to-show would flash an unpainted frame. + if (restored?.isMaximized) win.maximize() + if (restored?.isFullScreen) win.setFullScreen(true) + win.show() + }) + + // Every window persists its geometry into the one state file; the last + // write wins, so the next launch opens where the user last worked. + trackWindowState(win) + + // Keep the renderer's custom window controls (Windows/Linux) in sync with + // the real maximize state so the maximize/restore glyph matches the window. + const emitMaximized = () => { + if (!win.isDestroyed()) win.webContents.send(IPC.windowMaximized, win.isMaximized()) + } + win.on('maximize', emitMaximized) + win.on('unmaximize', emitMaximized) + + win.on('focus', () => { + this.lastFocused = win + // The application menu's Repository actions target the focused window's + // repo — retarget them whenever focus moves between windows. + this.hooks.onMenuTargetChanged() + }) + + win.webContents.setWindowOpenHandler(({ url }) => { + shell.openExternal(url) + return { action: 'deny' } + }) + + // A renderer reload (Ctrl/Cmd+R) drops back to the welcome screen with no + // repo open, but the repo association survives here in the main process. + // Clear it on every page load so the menu and the watcher don't keep + // targeting the previously opened repo; setOpenRepo re-associates when the + // user opens one again. + win.webContents.on('did-start-loading', () => { + if (this.repoByWindow.has(win.id)) this.setOpenRepo(win, null) + }) + + win.on('closed', () => { + this.windows.delete(win) + this.repoByWindow.delete(windowId) + this.pendingRepoByWindow.delete(windowId) + if (this.lastFocused === win) this.lastFocused = null + this.hooks.onOpenReposChanged(this.openRepos()) + this.hooks.onMenuTargetChanged() + }) + + if (isDev && process.env.ELECTRON_RENDERER_URL) { + win.loadURL(process.env.ELECTRON_RENDERER_URL) + } else { + win.loadFile(join(moduleDir, '../renderer/index.html')) + } + + return win + } + + /** Bounds for an additional window: cascaded from the focused one. */ + private cascadeFromFocused(): Rect | { width: number; height: number } { + const source = this.focusedWindow() + if (!source) return { width: DEFAULT_WINDOW_WIDTH, height: DEFAULT_WINDOW_HEIGHT } + // getNormalBounds(): cascade from where the window *lives*, not the whole + // screen it happens to be maximized over. + const bounds = source.getNormalBounds() + return cascadeBounds(bounds, screen.getDisplayMatching(bounds).workArea) + } + + /** + * The window the user is working in: the focused one, or — while the app is + * in the background — the last one that had focus. Menu actions, credential + * prompts and update dialogs aim here. + */ + focusedWindow(): BrowserWindow | null { + const focused = BrowserWindow.getFocusedWindow() + if (focused) return focused + if (this.lastFocused && !this.lastFocused.isDestroyed()) return this.lastFocused + return this.windows.values().next().value ?? null + } + + /** The window hosting `sender`, or null when it's already gone. */ + windowFrom(sender: WebContents): BrowserWindow | null { + return BrowserWindow.fromWebContents(sender) + } + + /** Send to every open window; renderers filter by repo path where relevant. */ + broadcast(channel: string, ...args: unknown[]): void { + for (const win of this.windows) { + if (!win.isDestroyed()) win.webContents.send(channel, ...args) + } + } + + /** Record which repo `win` has open (null → welcome screen) and re-sync. */ + setOpenRepo(win: BrowserWindow, repoPath: string | null): void { + if (repoPath === null) this.repoByWindow.delete(win.id) + else this.repoByWindow.set(win.id, repoPath) + this.hooks.onOpenReposChanged(this.openRepos()) + this.hooks.onMenuTargetChanged() + } + + /** The repo the application menu should act on: the focused window's. */ + repoOfFocusedWindow(): string | null { + const win = this.focusedWindow() + return win ? (this.repoByWindow.get(win.id) ?? null) : null + } + + /** The repo `win` was created to open, handed over exactly once. */ + takePendingRepo(win: BrowserWindow): string | null { + const path = this.pendingRepoByWindow.get(win.id) ?? null + this.pendingRepoByWindow.delete(win.id) + return path + } + + /** Every repo open in some window — the watcher's ref-counted watch set. */ + openRepos(): ReadonlySet { + return new Set(this.repoByWindow.values()) + } + + windowCount(): number { + return this.windows.size + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 36cc7d2..9138373 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -17,6 +17,7 @@ const api: GitGroveApi = { platform: process.platform, pickRepo: () => ipcRenderer.invoke(IPC.pickRepo), openRepo: (path) => ipcRenderer.invoke(IPC.openRepo, path), + openRepoInNewWindow: (path) => ipcRenderer.invoke(IPC.openRepoNewWindow, path), initialRepoPath: () => ipcRenderer.invoke(IPC.initialRepoPath), trustRepo: (path) => ipcRenderer.invoke(IPC.trustRepo, path), recentRepos: () => ipcRenderer.invoke(IPC.recentRepos), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 3147d1c..23e4556 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1212,9 +1212,18 @@ export function App() { useEffect(() => window.gitgrove.onShowAbout(() => setAboutOpen(true)), []) - // Open the repository named on the command line / GITGROVE_OPEN_REPO at - // launch. Main hands it over once (then forgets it), so this fires only for - // the first mount — a reload drops back to the welcome screen as usual. + // Window title = the open repo, so multiple GitGrove windows stay tellable + // apart in the Window menu, Alt-Tab/Mission Control and the taskbar. (The + // in-window title bar is custom, so this never paints inside the app.) + useEffect(() => { + document.title = repo ? `${repo.name} — GitGrove` : 'GitGrove' + }, [repo]) + + // Open the repository this window was created for: "Open in New Window", a + // second-instance `--repo`, or (first window) the command line / + // GITGROVE_OPEN_REPO. Main hands it over once (then forgets it), so this + // fires only for the first mount — a reload drops back to the welcome + // screen as usual. useEffect(() => { window.gitgrove .initialRepoPath() diff --git a/src/renderer/src/components/toolbar/RepoSwitcher.tsx b/src/renderer/src/components/toolbar/RepoSwitcher.tsx index 6980158..de50046 100644 --- a/src/renderer/src/components/toolbar/RepoSwitcher.tsx +++ b/src/renderer/src/components/toolbar/RepoSwitcher.tsx @@ -160,6 +160,18 @@ export function RepoSwitcher({ repo, onOpenRepo, onPickRepo, onClone }: Props) { const buildItems = (m: MenuState): ContextMenuItem[] => { const { repo: target, isRecent, remote } = m const items: ContextMenuItem[] = [ + { + // A second repo side by side without touching this window. The new + // window boots straight into the repo; closing the popover mirrors the + // left-click open gesture, whose job is likewise done. + label: 'Open in New Window', + icon: , + onClick: () => { + close() + window.gitgrove.openRepoInNewWindow(target.path) + } + }, + {}, { label: 'Copy Repo Name', icon: , diff --git a/src/renderer/src/lib/icons.tsx b/src/renderer/src/lib/icons.tsx index 4b4ca44..d10d561 100644 --- a/src/renderer/src/lib/icons.tsx +++ b/src/renderer/src/lib/icons.tsx @@ -305,6 +305,15 @@ export const Icon = { ), + // Two cascaded app windows (title bar hinted by the inner line) — the repo + // switcher's "Open in New Window". + NewWindow: (p: IconProps) => ( + + + + + + ), // GitHub's PR-state octicons (fill, 16-grid like the octocat mark) so the PR // badge reads exactly as it does on github.com: a merged or closed glyph. PrMerged: ({ size = 16, ...p }: IconProps) => ( diff --git a/src/renderer/src/lib/theme.ts b/src/renderer/src/lib/theme.ts index 16f706a..ad64412 100644 --- a/src/renderer/src/lib/theme.ts +++ b/src/renderer/src/lib/theme.ts @@ -88,6 +88,17 @@ export function useTheme(): { return () => mq.removeEventListener('change', onChange) }, []) + // Follow theme changes made in *other* GitGrove windows: localStorage is + // shared across windows, and 'storage' fires only in the windows that didn't + // write — so every window flips together and this never loops. + useEffect(() => { + const onStorage = (e: StorageEvent) => { + if (e.key === STORAGE_KEY) setPrefState(readThemePref()) + } + window.addEventListener('storage', onStorage) + return () => window.removeEventListener('storage', onStorage) + }, []) + const resolved: ResolvedTheme = pref === 'system' ? system : pref useEffect(() => { diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index ec24504..18f419e 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -52,6 +52,7 @@ import type { export const IPC = { pickRepo: 'repo:pick', openRepo: 'repo:open', + openRepoNewWindow: 'repo:open-new-window', initialRepoPath: 'repo:initial-path', trustRepo: 'repo:trust', recentRepos: 'repo:recent', @@ -212,8 +213,16 @@ export interface GitGroveApi { /** Open a known path as a repository. */ openRepo(path: string): Promise /** - * The repository requested on launch (via `--repo` or GITGROVE_OPEN_REPO), or - * null. Consumed once: a later reload returns null so it doesn't reopen. + * Open a repository in a brand-new window, leaving this window untouched — + * the repo switcher's "Open in New Window". The new window boots straight + * into the repo (it arrives through initialRepoPath). + */ + openRepoInNewWindow(path: string): Promise + /** + * The repository this window should open on boot: what "Open in New Window" + * or a second-instance `--repo` created it for, or — first window only — + * the repo named on the command line (`--repo` / GITGROVE_OPEN_REPO). Null + * otherwise. Consumed once: a later reload returns null so it doesn't reopen. */ initialRepoPath(): Promise /** Trust a folder git flagged as untrusted (persist a safe.directory exception), then open it. */ From 8c1fd3f3fa32f980889258df96923e87c07f514c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Sat, 4 Jul 2026 09:41:23 +0200 Subject: [PATCH 2/4] Integrate recent repos into dock/explorer/AppImageLauncher --- build/installer.nsh | 24 ++++++++++ electron-builder.yml | 14 ++++++ src/main/app-info.ts | 8 ++++ src/main/app-shortcuts.ts | 82 +++++++++++++++++++++++++++++++++ src/main/cli.test.ts | 14 +++++- src/main/cli.ts | 11 +++++ src/main/index.ts | 95 ++++++++++++++++++++++++++++++--------- src/main/store.ts | 9 ++++ src/main/windows.ts | 19 ++++++++ src/preload/index.ts | 5 +++ src/renderer/src/App.tsx | 7 +++ src/shared/ipc.ts | 7 +++ 12 files changed, 272 insertions(+), 23 deletions(-) create mode 100644 build/installer.nsh create mode 100644 src/main/app-shortcuts.ts diff --git a/build/installer.nsh b/build/installer.nsh new file mode 100644 index 0000000..83942eb --- /dev/null +++ b/build/installer.nsh @@ -0,0 +1,24 @@ +; NSIS additions for the Windows installer (included via electron-builder.yml +; `nsis.include`). Adds the Explorer folder context menu — right-click a folder +; (or a folder window's background) → "Open in GitGrove", which launches +; `GitGrove.exe --repo ""`; the single-instance lock routes that into a +; running instance, which focuses or opens the repo in a window. +; +; Registered under HKCU to match the per-user install (`nsis.perMachine: false`) +; — no elevation needed, and the uninstaller can always remove what it wrote. +; `%V` is Explorer's verbatim selected-folder placeholder (works for both the +; selected folder and the window background, unlike %1). + +!macro customInstall + WriteRegStr HKCU "Software\Classes\Directory\shell\GitGrove" "" "Open in GitGrove" + WriteRegStr HKCU "Software\Classes\Directory\shell\GitGrove" "Icon" "$INSTDIR\${APP_EXECUTABLE_FILENAME}" + WriteRegStr HKCU "Software\Classes\Directory\shell\GitGrove\command" "" '"$INSTDIR\${APP_EXECUTABLE_FILENAME}" --repo "%V"' + WriteRegStr HKCU "Software\Classes\Directory\Background\shell\GitGrove" "" "Open in GitGrove" + WriteRegStr HKCU "Software\Classes\Directory\Background\shell\GitGrove" "Icon" "$INSTDIR\${APP_EXECUTABLE_FILENAME}" + WriteRegStr HKCU "Software\Classes\Directory\Background\shell\GitGrove\command" "" '"$INSTDIR\${APP_EXECUTABLE_FILENAME}" --repo "%V"' +!macroend + +!macro customUnInstall + DeleteRegKey HKCU "Software\Classes\Directory\shell\GitGrove" + DeleteRegKey HKCU "Software\Classes\Directory\Background\shell\GitGrove" +!macroend diff --git a/electron-builder.yml b/electron-builder.yml index aa3ea1d..2519f8f 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -78,10 +78,24 @@ nsis: allowToChangeInstallationDirectory: true createDesktopShortcut: true createStartMenuShortcut: true + # Explorer folder context menu ("Open in GitGrove" → `--repo ""`), + # registered per-user at install and removed at uninstall. + include: build/installer.nsh linux: icon: build/icon.png category: Development artifactName: ${productName}-${version}-Linux-${arch}.${ext} + # Launcher right-click → "New Window" (GNOME/KDE read [Desktop Action]s from + # the .desktop file — the platform has no runtime API, so unlike the macOS + # dock menu / Windows Jump List this stays static: no recents list here). + # `AppRun --no-sandbox` mirrors the main Exec electron-builder writes for + # AppImage; integrators (AppImageLauncher/appimaged) rewrite it to the + # AppImage's real path. `--new-window` is routed by the single-instance lock. + desktop: + desktopActions: + NewWindow: + Name: New Window + Exec: AppRun --no-sandbox --new-window target: - AppImage diff --git a/src/main/app-info.ts b/src/main/app-info.ts index 85628d4..bd649cc 100644 --- a/src/main/app-info.ts +++ b/src/main/app-info.ts @@ -3,6 +3,14 @@ import { app } from 'electron' export const REPO_URL = 'https://github.com/danipen/gitgrove' +/** + * Windows AppUserModelID — must equal electron-builder's `appId`, which the + * installer stamps onto the Start-menu/desktop shortcuts. Setting the same ID + * on the process makes the taskbar group our windows under those shortcuts, + * which is also what attaches the Jump List (app-shortcuts.ts) to them. + */ +export const APP_USER_MODEL_ID = 'software.gitgrove.app' + export function appInfo(): AppInfo { return { name: app.getName(), diff --git a/src/main/app-shortcuts.ts b/src/main/app-shortcuts.ts new file mode 100644 index 0000000..b56a31e --- /dev/null +++ b/src/main/app-shortcuts.ts @@ -0,0 +1,82 @@ +// OS launcher shortcuts: recent repositories plus a "New Window" entry, one +// right-click away on the app's dock / taskbar icon — even before the app is +// focused (or, on macOS and via pinned Jump Lists, before it is *running*). +// +// Per-platform reality check: +// - macOS: recents ride the OS-managed recent-documents list +// (`app.addRecentDocument` in index.ts, stored per bundle id by the system) +// — the Dock itself renders that section in the icon's menu whether or not +// GitGrove is running, and clicks come back as 'open-file' events. Only the +// "New Window" item needs the custom (running-only) dock menu here. +// - Windows: Jump List items can only *launch a program*, so each entry +// relaunches GitGrove with `--repo ` (or `--new-window`); the +// single-instance lock routes that into the running instance, which opens +// or focuses the repo (see 'second-instance' in index.ts). Rebuilt whenever +// the recents change; the pinned icon keeps it while the app is closed. +// - Linux: launchers only read static `[Desktop Action]`s from the .desktop +// file — no runtime API — so it gets a packaged "New Window" action only +// (electron-builder.yml) and nothing to do here. + +import { app, Menu } from 'electron' +import { getRecentRepos } from './store' + +// Windows renders Jump Lists as a tall dedicated flyout where ~8 entries is +// the platform's customary depth. +const JUMP_LIST_RECENTS = 8 + +/** What the shortcuts need from the app shell. */ +export interface AppShortcutActions { + /** Open a fresh window on the welcome screen. */ + newWindow(): void +} + +/** + * Rebuild the platform's launcher shortcuts from the current recents. Cheap + * (one small template) and idempotent — safe to call on every recents write. + */ +export function refreshAppShortcuts(actions: AppShortcutActions): void { + if (process.platform === 'darwin') { + // Recents are the OS's section (see module comment) — adding them here too + // would show every repo twice in the same menu. + app.dock?.setMenu( + Menu.buildFromTemplate([{ label: 'New Window', click: () => actions.newWindow() }]) + ) + } + if (process.platform === 'win32') refreshJumpList() +} + +function refreshJumpList(): void { + // A Jump List entry launches `program` with `args` — in dev that program is + // the generic Electron binary, which needs the app directory as its first + // argument to become GitGrove. + const devAppArg = app.isPackaged ? '' : `"${app.getAppPath()}" ` + const task = (args: string, title: string, description: string) => + ({ + type: 'task', + program: process.execPath, + args: `${devAppArg}${args}`, + title, + description, + iconPath: process.execPath, + iconIndex: 0 + }) as const + + // Folders that still exist, newest first — same filter as the repo switcher. + const recents = getRecentRepos() + .filter((repo) => !repo.missing) + .slice(0, JUMP_LIST_RECENTS) + + // setJumpList can fail (e.g. items the OS rejects) — a launcher shortcut is + // a convenience, never worth surfacing an error for, so ignore the outcome. + app.setJumpList([ + { + type: 'custom', + name: 'Recent Repositories', + items: recents.map((repo) => task(`--repo "${repo.path}"`, repo.name, repo.path)) + }, + { + type: 'tasks', + items: [task('--new-window', 'New Window', 'Open a new GitGrove window')] + } + ]) +} diff --git a/src/main/cli.test.ts b/src/main/cli.test.ts index 705e193..9f336e5 100644 --- a/src/main/cli.test.ts +++ b/src/main/cli.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { resolveStartupRepo } from './cli' +import { hasNewWindowFlag, resolveStartupRepo } from './cli' const noEnv: NodeJS.ProcessEnv = {} @@ -38,3 +38,15 @@ describe('resolveStartupRepo', () => { expect(resolveStartupRepo([], { GITGROVE_OPEN_REPO: ' ' })).toBeNull() }) }) + +describe('hasNewWindowFlag', () => { + test('spots --new-window anywhere in argv', () => { + expect(hasNewWindowFlag(['electron', '.', '--new-window'])).toBe(true) + expect(hasNewWindowFlag(['gitgrove', '--new-window', 'ignored'])).toBe(true) + }) + + test('requires the exact flag', () => { + expect(hasNewWindowFlag(['electron', '.'])).toBe(false) + expect(hasNewWindowFlag(['electron', '--new-window=1'])).toBe(false) + }) +}) diff --git a/src/main/cli.ts b/src/main/cli.ts index f6e9129..df0f61e 100644 --- a/src/main/cli.ts +++ b/src/main/cli.ts @@ -12,6 +12,7 @@ // open the wrong thing. An explicit flag keeps the intent unambiguous. const REPO_FLAG = '--repo' +const NEW_WINDOW_FLAG = '--new-window' /** * Resolve the repository to open on startup from the process argv and env, or @@ -34,3 +35,13 @@ export function resolveStartupRepo(argv: readonly string[], env: NodeJS.ProcessE const fromEnv = env.GITGROVE_OPEN_REPO return fromEnv && fromEnv.trim() !== '' ? fromEnv : null } + +/** + * Whether the invocation asks for a fresh window (`--new-window`) — used by + * launcher shortcuts (the Windows Jump List's "New Window" task, the Linux + * desktop action) whose relaunch is routed into the running instance by the + * single-instance lock. + */ +export function hasNewWindowFlag(argv: readonly string[]): boolean { + return argv.includes(NEW_WINDOW_FLAG) +} diff --git a/src/main/index.ts b/src/main/index.ts index d135b91..e457988 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,12 +1,13 @@ import { existsSync } from 'node:fs' import { basename, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { app, type BrowserWindow, Menu, nativeImage } from 'electron' +import { app, type BrowserWindow, nativeImage } from 'electron' import { IPC } from '@shared/ipc' import type { GitAvailability, RepoOpenResult, UpdateStatus } from '@shared/types' -import { REPO_URL } from './app-info' -import { resolveStartupRepo } from './cli' +import { APP_USER_MODEL_ID, REPO_URL } from './app-info' +import { refreshAppShortcuts } from './app-shortcuts' +import { hasNewWindowFlag, resolveStartupRepo } from './cli' import { gitVersion, locateGit, resetGitLocation } from './git/bin' import { addSafeDirectory, @@ -17,7 +18,7 @@ import { } from './git/read' import { registerIpc } from './ipc' import { buildMenu, type MenuContext } from './menu' -import { getRecentRepo, rememberRepo } from './store' +import { getRecentRepo, rememberRepo, setRecentsChangedListener } from './store' import { checkForUpdates, initAutoUpdater } from './updater' import { RepoWatcher } from './watcher' import { WindowManager } from './windows' @@ -55,6 +56,13 @@ if (process.platform === 'darwin') { app.commandLine.appendSwitch('use-mock-keychain') } +// Windows: adopt the installer's AppUserModelID so the taskbar groups our +// windows under the installed shortcut — required for the Jump List +// (app-shortcuts.ts) to appear on the pinned/Start-menu icon. +if (process.platform === 'win32') { + app.setAppUserModelId(APP_USER_MODEL_ID) +} + // Opt-in CDP debugging: when GITGROVE_DEBUG_PORT is set (e.g. `bun dev:debug`), // expose Chromium's remote-debugging endpoint so tools like the Playwright CLI // can attach to the renderer (`playwright-cli attach --cdp http://localhost:PORT`). @@ -105,6 +113,31 @@ const menuContext: MenuContext = { checkForUpdates: () => void checkForUpdates(pushUpdateStatus, true) } +/** + * Bring `path` to the front the least surprising way: focus the window that + * already shows it, else reuse a window idling on the welcome screen, else + * open a fresh window. Used by the launcher shortcuts (dock menu, Jump List + * via second-instance) — never steals a window that has another repo open. + */ +function focusOrOpenRepo(path: string): void { + const raise = (win: Electron.BrowserWindow) => { + if (win.isMinimized()) win.restore() + win.focus() + } + const showing = windows.windowShowing(path) + if (showing) { + raise(showing) + return + } + const idle = windows.welcomeWindow() + if (idle) { + raise(idle) + idle.webContents.send(IPC.openRepoRequest, path) + return + } + windows.createWindow(path) +} + /** * Open a folder, resolve it to a repo root, persist as recent, watch it, and * associate it with the window that asked (menu targeting + watcher set). @@ -144,6 +177,11 @@ async function openRepoAtPath(rawPath: string, win: BrowserWindow | null): Promi // vanishes — best-effort, never block opening on it. const remoteUrl = await getRemoteCloneUrl(root).catch(() => null) rememberRepo({ path: summary.path, name: summary.name, remoteUrl }) + // Feed the OS-managed recents (macOS): the Dock renders this list in the + // icon's menu even while GitGrove is *closed*; a click comes back as an + // 'open-file' event (handled below). The system owns pruning/ordering, so + // "Remove from Recents" in-app intentionally doesn't reach into it. + if (process.platform === 'darwin') app.addRecentDocument(summary.path) // Point the menu's repo actions and the watcher at the now-open repo. if (win && !win.isDestroyed()) windows.setOpenRepo(win, summary.path) return { ok: true, summary } @@ -184,17 +222,30 @@ async function checkGit(force: boolean): Promise { } } +// macOS hands us paths as 'open-file' Apple Events: a Dock-menu recent, a +// folder dragged onto the dock icon, or "Open With → GitGrove" in Finder. +// Before the app is ready the event beats window creation — stash the path as +// the startup repo for the first window; afterwards route it like any other +// launcher click. (Registered at top level: the event can fire pre-ready.) +app.on('open-file', (event, path) => { + event.preventDefault() + if (app.isReady()) focusOrOpenRepo(path) + else startupRepoPath = path +}) + // A second launch routed its argv here (see requestSingleInstanceLock above). -// `--repo ` opens that repo in a new window — this is how "open two -// GitGroves" behaves on Windows/Linux; a bare relaunch raises a window (or -// recreates one if all were closed, e.g. on macOS with the app still running). +// `--repo ` focuses or opens that repo (a Jump List recent, "open two +// GitGroves" on Windows/Linux, shell aliases); `--new-window` (the Jump List +// and Linux desktop-action task) opens a fresh window; a bare relaunch raises +// a window (or recreates one if all were closed, e.g. on macOS with the app +// still running). app.on('second-instance', (_e, argv) => { const repoPath = resolveStartupRepo(argv, {}) if (repoPath) { - windows.createWindow(repoPath) + focusOrOpenRepo(repoPath) return } - if (windows.windowCount() === 0) { + if (hasNewWindowFlag(argv) || windows.windowCount() === 0) { windows.createWindow() return } @@ -214,21 +265,21 @@ app.whenReady().then(() => { website: REPO_URL }) - if (process.platform === 'darwin' && app.dock) { - // macOS ignores the BrowserWindow icon and shows the bundle icon in the - // dock; in dev that's the generic Electron icon, so override it explicitly. - if (isDev) { - // build/icon.png sits two levels up from out/main (ESM: no __dirname). - const devIconPath = join(dirname(fileURLToPath(import.meta.url)), '../../build/icon.png') - const img = nativeImage.createFromPath(devIconPath) - if (!img.isEmpty()) app.dock.setIcon(img) - } - // Finder-style dock menu: right-click the dock icon → "New Window". - app.dock.setMenu( - Menu.buildFromTemplate([{ label: 'New Window', click: () => windows.createWindow() }]) - ) + // macOS ignores the BrowserWindow icon and shows the bundle icon in the + // dock; in dev that's the generic Electron icon, so override it explicitly. + if (isDev && process.platform === 'darwin' && app.dock) { + // build/icon.png sits two levels up from out/main (ESM: no __dirname). + const devIconPath = join(dirname(fileURLToPath(import.meta.url)), '../../build/icon.png') + const img = nativeImage.createFromPath(devIconPath) + if (!img.isEmpty()) app.dock.setIcon(img) } + // Launcher shortcuts (dock menu / Jump List), rebuilt whenever the recents + // store changes. macOS recents ride the OS list instead — see app-shortcuts. + const shortcutActions = { newWindow: () => void windows.createWindow() } + refreshAppShortcuts(shortcutActions) + setRecentsChangedListener(() => refreshAppShortcuts(shortcutActions)) + registerIpc({ windowFrom: (sender) => windows.windowFrom(sender), focusedWindow: () => windows.focusedWindow(), diff --git a/src/main/store.ts b/src/main/store.ts index 8360702..31bab60 100644 --- a/src/main/store.ts +++ b/src/main/store.ts @@ -31,11 +31,20 @@ function read(): StoredRepo[] { } } +// Fired after every successful write so OS surfaces built from the recents +// (the macOS dock menu, the Windows Jump List — see app-shortcuts.ts) can +// rebuild. One listener is all the app needs; a change bus would be ceremony. +let recentsChangedListener: (() => void) | null = null +export function setRecentsChangedListener(listener: () => void): void { + recentsChangedListener = listener +} + function write(repos: StoredRepo[]): void { try { const dir = app.getPath('userData') if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) writeFileSync(storePath(), JSON.stringify(repos, null, 2), 'utf8') + recentsChangedListener?.() } catch { // non-fatal: recents are a convenience only } diff --git a/src/main/windows.ts b/src/main/windows.ts index 63f9c80..f98986c 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -206,6 +206,25 @@ export class WindowManager { return win ? (this.repoByWindow.get(win.id) ?? null) : null } + /** The window that has `repoPath` open, or null. Paths compare exactly — + * both sides are the normalized repo root the recents store persisted. */ + windowShowing(repoPath: string): BrowserWindow | null { + for (const win of this.windows) { + if (!win.isDestroyed() && this.repoByWindow.get(win.id) === repoPath) return win + } + return null + } + + /** A window idling on the welcome screen (no repo open, none pending), or + * null — launcher shortcuts reuse it instead of spawning a sibling. */ + welcomeWindow(): BrowserWindow | null { + for (const win of this.windows) { + if (win.isDestroyed()) continue + if (!this.repoByWindow.has(win.id) && !this.pendingRepoByWindow.has(win.id)) return win + } + return null + } + /** The repo `win` was created to open, handed over exactly once. */ takePendingRepo(win: BrowserWindow): string | null { const path = this.pendingRepoByWindow.get(win.id) ?? null diff --git a/src/preload/index.ts b/src/preload/index.ts index 9138373..b23fd68 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -143,6 +143,11 @@ const api: GitGroveApi = { ipcRenderer.on(IPC.repoChanged, listener) return () => ipcRenderer.removeListener(IPC.repoChanged, listener) }, + onOpenRepoRequest: (handler) => { + const listener = (_e: unknown, path: string) => handler(path) + ipcRenderer.on(IPC.openRepoRequest, listener) + return () => ipcRenderer.removeListener(IPC.openRepoRequest, listener) + }, onMenuOpenRepo: (handler) => { const listener = () => handler() ipcRenderer.on(IPC.menuOpenRepo, listener) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 23e4556..8df7e7e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1233,6 +1233,13 @@ export function App() { .catch(() => {}) }, [openRepoByPath]) + // A dock-menu / Jump List recent aimed at this window (it was idling on the + // welcome screen, so main reused it instead of opening a sibling window). + useEffect( + () => window.gitgrove.onOpenRepoRequest((path) => openRepoByPath(path)), + [openRepoByPath] + ) + // Git LFS health of the open repo + the one-click enable — see useLfs. const { lfsHealth, lfsDismissed, dismissLfs, lfsEnabling, enableLfs, probeLfsHealth } = useLfs( repo?.path, diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 18f419e..1076efb 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -170,6 +170,8 @@ export const IPC = { menuPopup: 'menu:popup', // main -> renderer pushes repoChanged: 'repo:changed', + /** Open this repo path in this window (launcher shortcuts reusing a welcome-screen window). */ + openRepoRequest: 'repo:open-request', menuOpenRepo: 'menu:open-repo', menuShowAbout: 'menu:about', /** Generic application-menu command (payload: a MenuCommand string). */ @@ -526,6 +528,11 @@ export interface GitGroveApi { onWindowMaximized(handler: (maximized: boolean) => void): () => void /** Subscribe to filesystem-driven repo change notifications. Returns an unsubscribe fn. */ onRepoChanged(handler: (repoPath: string) => void): () => void + /** + * Subscribe to "open this repo here" requests — a dock-menu / Jump List + * recent landing in this window because it was idling on the welcome screen. + */ + onOpenRepoRequest(handler: (path: string) => void): () => void /** Subscribe to the application menu "Open Repository" command. */ onMenuOpenRepo(handler: () => void): () => void /** Subscribe to the "About GitGrove" menu command. */ From 3bc9b0bd0cf69ac0be096350954ea0f4fffa8569 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Sat, 4 Jul 2026 09:50:15 +0200 Subject: [PATCH 3/4] Restore the last opened repo when opening the app --- src/main/index.ts | 34 ++++++++++++++++++-- src/main/session-store.test.ts | 29 +++++++++++++++++ src/main/session-store.ts | 57 ++++++++++++++++++++++++++++++++++ src/main/windows.ts | 17 ++++++++++ 4 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 src/main/session-store.test.ts create mode 100644 src/main/session-store.ts diff --git a/src/main/index.ts b/src/main/index.ts index e457988..4b757b4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -18,6 +18,7 @@ import { } from './git/read' import { registerIpc } from './ipc' import { buildMenu, type MenuContext } from './menu' +import { loadSession, saveSession } from './session-store' import { getRecentRepo, rememberRepo, setRecentsChangedListener } from './store' import { checkForUpdates, initAutoUpdater } from './updater' import { RepoWatcher } from './watcher' @@ -86,9 +87,21 @@ const watcher = new RepoWatcher((repoPath) => { windows.broadcast(IPC.repoChanged, repoPath) }) +// True from the moment the app starts quitting. Quitting closes windows one +// by one; without this flag that cascade would whittle the persisted session +// down to nothing right before we want to restore it. +let quitting = false + const windows = new WindowManager({ onOpenReposChanged: (openRepos) => watcher.sync(openRepos), - onMenuTargetChanged: () => rebuildMenuIfTargetChanged() + onMenuTargetChanged: () => rebuildMenuIfTargetChanged(), + onSessionChanged: (session) => { + // The empty snapshot is never saved: the last window's close is how the + // app ends (it *is* the quit on Windows/Linux), so the state just before + // it — that window and its repo — is exactly what the next launch should + // bring back. Windows closed while others remain drop out as expected. + if (!quitting && session.length > 0) saveSession(session) + } }) // The menu's only window-dependent state is the focused window's repo (its @@ -300,7 +313,19 @@ app.whenReady().then(() => { checkForUpdates: (manual) => checkForUpdates(pushUpdateStatus, manual) }) rebuildMenuIfTargetChanged() - windows.createWindow() + + // Restore last session's windows — every window with the repo it had open — + // unless a specific repo was requested (CLI `--repo`, a Dock recent while + // closed): an explicit ask opens exactly that, nothing else. Repos whose + // folder vanished since come back as the recovery screen, welcome-screen + // windows (null) as themselves; an empty/missing session is a fresh start. + const session = startupRepoPath !== null ? [] : loadSession() + if (session.length === 0) { + windows.createWindow() + } else { + for (const repoPath of session) windows.createWindow(repoPath ?? undefined) + } + initAutoUpdater(pushUpdateStatus) app.on('activate', () => { @@ -313,4 +338,7 @@ app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() }) -app.on('before-quit', () => watcher.unwatchAll()) +app.on('before-quit', () => { + quitting = true + watcher.unwatchAll() +}) diff --git a/src/main/session-store.test.ts b/src/main/session-store.test.ts new file mode 100644 index 0000000..3500075 --- /dev/null +++ b/src/main/session-store.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'bun:test' +import { parseSession } from './session-store' + +describe('parseSession', () => { + test('keeps repo paths and welcome-screen markers in window order', () => { + expect(parseSession(['/repos/a', null, '/repos/b'])).toEqual(['/repos/a', null, '/repos/b']) + }) + + test('drops entries that are neither a path nor null', () => { + expect(parseSession(['/repos/a', 42, undefined, {}, '', '/repos/b'])).toEqual([ + '/repos/a', + '/repos/b' + ]) + }) + + test('caps a ballooned session', () => { + const huge = Array.from({ length: 50 }, (_, i) => `/repos/${i}`) + expect(parseSession(huge)).toHaveLength(20) + }) + + test.each([ + ['not an array', { windows: ['/repos/a'] }], + ['a string', '/repos/a'], + ['null', null], + ['a number', 7] + ])('degrades to an empty session on %s', (_label, raw) => { + expect(parseSession(raw)).toEqual([]) + }) +}) diff --git a/src/main/session-store.ts b/src/main/session-store.ts new file mode 100644 index 0000000..7771e98 --- /dev/null +++ b/src/main/session-store.ts @@ -0,0 +1,57 @@ +// Persists the window session — which repos were open, one entry per window +// in window order — so relaunching GitGrove reopens exactly where the user +// left off. Saved whenever windows open, close or change repo; index.ts stops +// saving the moment quitting starts, so the quit's cascade of window closes +// can't whittle the session down to nothing before it's read again. A missing +// or corrupt file simply yields an empty session (one welcome window) — +// restoring is a convenience, never a reason to fail startup. + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { app } from 'electron' + +/** One entry per window: its repo root, or null for a welcome-screen window. */ +export type SessionWindows = (string | null)[] + +// Safety valve against a corrupt file ballooning the restore; nobody works +// with anything near this many windows. +const MAX_SESSION_WINDOWS = 20 + +/** + * Validate raw JSON (untrusted: hand-edited, corrupt, or from a future + * version) into a usable session. Anything that isn't a non-empty repo path + * or a welcome-screen marker (null) is dropped. Pure, for direct unit tests. + */ +export function parseSession(raw: unknown): SessionWindows { + if (!Array.isArray(raw)) return [] + return raw + .filter( + (entry): entry is string | null => + entry === null || (typeof entry === 'string' && entry.length > 0) + ) + .slice(0, MAX_SESSION_WINDOWS) +} + +function sessionFile(): string { + return join(app.getPath('userData'), 'session.json') +} + +/** The last run's windows, oldest window first. Empty when there's nothing usable. */ +export function loadSession(): SessionWindows { + try { + if (!existsSync(sessionFile())) return [] + return parseSession(JSON.parse(readFileSync(sessionFile(), 'utf8'))) + } catch { + return [] + } +} + +export function saveSession(session: SessionWindows): void { + try { + const dir = app.getPath('userData') + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + writeFileSync(sessionFile(), JSON.stringify(session, null, 2), 'utf8') + } catch { + // non-fatal: worst case the next launch opens a welcome window + } +} diff --git a/src/main/windows.ts b/src/main/windows.ts index f98986c..8030a8a 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -43,6 +43,9 @@ export interface WindowManagerHooks { onOpenReposChanged(openRepos: ReadonlySet): void /** The window the application menu should target changed (focus/open/close). */ onMenuTargetChanged(): void + /** Windows opened/closed or changed repo — the snapshot to persist for + * session restore (one entry per window, null = welcome screen). */ + onSessionChanged(session: (string | null)[]): void } export class WindowManager { @@ -147,6 +150,7 @@ export class WindowManager { if (this.lastFocused === win) this.lastFocused = null this.hooks.onOpenReposChanged(this.openRepos()) this.hooks.onMenuTargetChanged() + this.hooks.onSessionChanged(this.sessionSnapshot()) }) if (isDev && process.env.ELECTRON_RENDERER_URL) { @@ -155,6 +159,7 @@ export class WindowManager { win.loadFile(join(moduleDir, '../renderer/index.html')) } + this.hooks.onSessionChanged(this.sessionSnapshot()) return win } @@ -198,6 +203,18 @@ export class WindowManager { else this.repoByWindow.set(win.id, repoPath) this.hooks.onOpenReposChanged(this.openRepos()) this.hooks.onMenuTargetChanged() + this.hooks.onSessionChanged(this.sessionSnapshot()) + } + + /** One entry per live window in creation order: the repo it shows — or was + * created to open (pending counts, so a session saved mid-boot doesn't + * read as a welcome window) — or null for the welcome screen. */ + private sessionSnapshot(): (string | null)[] { + return [...this.windows] + .filter((win) => !win.isDestroyed()) + .map( + (win) => this.repoByWindow.get(win.id) ?? this.pendingRepoByWindow.get(win.id) ?? null + ) } /** The repo the application menu should act on: the focused window's. */ From 54db686b0dcf219197a2bdadf1825c9006d3db4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Pen=CC=83alba?= Date: Sat, 4 Jul 2026 10:01:37 +0200 Subject: [PATCH 4/4] Keep session parsing electron-free so its unit test runs under bun MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session parse/validation lived in session-store.ts alongside the electron file I/O, so importing it from the unit test dragged in electron — which bun can't load, failing the whole suite (and CI lint tripped on formatting). Split the pure logic into session.ts (mirroring window-state.ts vs window-state-store.ts); session-store.ts keeps the file I/O. --- src/main/index.ts | 3 +-- src/main/ipc/clone.ts | 3 +-- src/main/session-store.ts | 26 +++---------------- ...{session-store.test.ts => session.test.ts} | 2 +- src/main/session.ts | 26 +++++++++++++++++++ src/main/windows.ts | 4 +-- 6 files changed, 33 insertions(+), 31 deletions(-) rename src/main/{session-store.test.ts => session.test.ts} (94%) create mode 100644 src/main/session.ts diff --git a/src/main/index.ts b/src/main/index.ts index 4b757b4..1e02153 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,10 +1,9 @@ import { existsSync } from 'node:fs' import { basename, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { app, type BrowserWindow, nativeImage } from 'electron' - import { IPC } from '@shared/ipc' import type { GitAvailability, RepoOpenResult, UpdateStatus } from '@shared/types' +import { app, type BrowserWindow, nativeImage } from 'electron' import { APP_USER_MODEL_ID, REPO_URL } from './app-info' import { refreshAppShortcuts } from './app-shortcuts' import { hasNewWindowFlag, resolveStartupRepo } from './cli' diff --git a/src/main/ipc/clone.ts b/src/main/ipc/clone.ts index 17aa386..1b40f61 100644 --- a/src/main/ipc/clone.ts +++ b/src/main/ipc/clone.ts @@ -17,8 +17,7 @@ export function registerCloneHandlers(deps: HandlerDeps): void { // Progress goes to the window whose clone dialog is running, not to // whichever window is focused — the user may be working elsewhere meanwhile. const dest = await gitSync.clone(url, target, (phase, percent) => { - if (!e.sender.isDestroyed()) - e.sender.send(IPC.cloneProgress, { phase, percent, done: false }) + if (!e.sender.isDestroyed()) e.sender.send(IPC.cloneProgress, { phase, percent, done: false }) }) // The next clone should land beside this one — remember the parent folder. rememberCloneBaseDir(dirname(target)) diff --git a/src/main/session-store.ts b/src/main/session-store.ts index 7771e98..4f1c0d2 100644 --- a/src/main/session-store.ts +++ b/src/main/session-store.ts @@ -4,33 +4,13 @@ // saving the moment quitting starts, so the quit's cascade of window closes // can't whittle the session down to nothing before it's read again. A missing // or corrupt file simply yields an empty session (one welcome window) — -// restoring is a convenience, never a reason to fail startup. +// restoring is a convenience, never a reason to fail startup. Parsing/shape +// validation is the pure, unit-tested session.ts. import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { app } from 'electron' - -/** One entry per window: its repo root, or null for a welcome-screen window. */ -export type SessionWindows = (string | null)[] - -// Safety valve against a corrupt file ballooning the restore; nobody works -// with anything near this many windows. -const MAX_SESSION_WINDOWS = 20 - -/** - * Validate raw JSON (untrusted: hand-edited, corrupt, or from a future - * version) into a usable session. Anything that isn't a non-empty repo path - * or a welcome-screen marker (null) is dropped. Pure, for direct unit tests. - */ -export function parseSession(raw: unknown): SessionWindows { - if (!Array.isArray(raw)) return [] - return raw - .filter( - (entry): entry is string | null => - entry === null || (typeof entry === 'string' && entry.length > 0) - ) - .slice(0, MAX_SESSION_WINDOWS) -} +import { parseSession, type SessionWindows } from './session' function sessionFile(): string { return join(app.getPath('userData'), 'session.json') diff --git a/src/main/session-store.test.ts b/src/main/session.test.ts similarity index 94% rename from src/main/session-store.test.ts rename to src/main/session.test.ts index 3500075..5a47249 100644 --- a/src/main/session-store.test.ts +++ b/src/main/session.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { parseSession } from './session-store' +import { parseSession } from './session' describe('parseSession', () => { test('keeps repo paths and welcome-screen markers in window order', () => { diff --git a/src/main/session.ts b/src/main/session.ts new file mode 100644 index 0000000..4071c7c --- /dev/null +++ b/src/main/session.ts @@ -0,0 +1,26 @@ +// The window session — which repos were open, one entry per window in window +// order — as pure data + validation, kept free of electron so it stays +// directly unit-testable (the file I/O lives in session-store.ts). Mirrors the +// window-state.ts / window-state-store.ts split. + +/** One entry per window: its repo root, or null for a welcome-screen window. */ +export type SessionWindows = (string | null)[] + +// Safety valve against a corrupt file ballooning the restore; nobody works +// with anything near this many windows. +export const MAX_SESSION_WINDOWS = 20 + +/** + * Validate raw JSON (untrusted: hand-edited, corrupt, or from a future + * version) into a usable session. Anything that isn't a non-empty repo path + * or a welcome-screen marker (null) is dropped. + */ +export function parseSession(raw: unknown): SessionWindows { + if (!Array.isArray(raw)) return [] + return raw + .filter( + (entry): entry is string | null => + entry === null || (typeof entry === 'string' && entry.length > 0) + ) + .slice(0, MAX_SESSION_WINDOWS) +} diff --git a/src/main/windows.ts b/src/main/windows.ts index 8030a8a..d6958a4 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -212,9 +212,7 @@ export class WindowManager { private sessionSnapshot(): (string | null)[] { return [...this.windows] .filter((win) => !win.isDestroyed()) - .map( - (win) => this.repoByWindow.get(win.id) ?? this.pendingRepoByWindow.get(win.id) ?? null - ) + .map((win) => this.repoByWindow.get(win.id) ?? this.pendingRepoByWindow.get(win.id) ?? null) } /** The repo the application menu should act on: the focused window's. */