Skip to content

Commit 71e651a

Browse files
committed
open-multiple-repos-in-one-window (squashed)
1 parent 3827062 commit 71e651a

15 files changed

Lines changed: 1460 additions & 355 deletions

package-lock.json

Lines changed: 367 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
"build": "electron-vite build",
1717
"preview": "electron-vite preview",
1818
"typecheck": "tsc --noEmit",
19+
"test": "vitest run",
20+
"test:watch": "vitest",
1921
"postinstall": "electron-builder install-app-deps",
2022
"rebuild:dev": "electron-rebuild -f -w node-pty",
2123
"pack": "npm run build && CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --dir --arm64 --config.mac.identity=null",
@@ -117,6 +119,7 @@
117119
"electron-vite": "^5.0.0",
118120
"tailwindcss": "^4.2.2",
119121
"typescript": "^6.0.2",
120-
"vite": "^8.0.8"
122+
"vite": "^8.0.8",
123+
"vitest": "^4.1.4"
121124
}
122125
}

src/main/index.ts

Lines changed: 54 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,7 @@ const ptyManager = new PtyManager()
4141
let config = loadConfig()
4242
let stopWatchingStatus: (() => void) | null = null
4343

44-
// Track repo root per window
45-
const windowRepoRoots = new Map<number, string>()
46-
47-
function getWindowFromEvent(event: Electron.IpcMainEvent | Electron.IpcMainInvokeEvent): BrowserWindow | null {
48-
return BrowserWindow.fromWebContents(event.sender)
49-
}
50-
51-
function createWindow(repoRoot?: string): BrowserWindow {
44+
function createWindow(): BrowserWindow {
5245
const bounds = config.windowBounds || { width: 1400, height: 900, x: undefined!, y: undefined! }
5346

5447
const win = new BrowserWindow({
@@ -68,10 +61,6 @@ function createWindow(repoRoot?: string): BrowserWindow {
6861
}
6962
})
7063

71-
if (repoRoot) {
72-
windowRepoRoots.set(win.id, repoRoot)
73-
}
74-
7564
// Forward renderer console logs to debug log
7665
win.webContents.on('console-message', (_event, level, message) => {
7766
const levelName = ['verbose', 'info', 'warn', 'error'][level] || 'log'
@@ -87,10 +76,6 @@ function createWindow(repoRoot?: string): BrowserWindow {
8776
win.on('resize', saveBounds)
8877
win.on('move', saveBounds)
8978

90-
win.on('closed', () => {
91-
windowRepoRoots.delete(win.id)
92-
})
93-
9479
// Load renderer
9580
if (process.env['ELECTRON_RENDERER_URL']) {
9681
win.loadURL(process.env['ELECTRON_RENDERER_URL'])
@@ -102,31 +87,24 @@ function createWindow(repoRoot?: string): BrowserWindow {
10287
}
10388

10489
function registerIpcHandlers(): void {
105-
// Worktree handlers — scoped to the calling window's repo root
106-
ipcMain.handle('worktree:list', async (event) => {
107-
const win = getWindowFromEvent(event)
108-
const repoRoot = win ? windowRepoRoots.get(win.id) : null
90+
// Worktree handlers — every call takes an explicit repoRoot, since a single
91+
// window now shows worktrees from multiple repos at once.
92+
ipcMain.handle('worktree:list', async (_, repoRoot: string) => {
10993
if (!repoRoot) return []
11094
const trees = await listWorktrees(repoRoot)
111-
// Keep activity records' branch/repoRoot in sync with current git state so
112-
// historical reporting still identifies the worktree after it's removed.
11395
for (const wt of trees) {
11496
touchActivityMeta(wt.path, { branch: wt.branch, repoRoot })
11597
}
11698
return trees
11799
})
118100

119-
ipcMain.handle('worktree:branches', async (event) => {
120-
const win = getWindowFromEvent(event)
121-
const repoRoot = win ? windowRepoRoots.get(win.id) : null
101+
ipcMain.handle('worktree:branches', async (_, repoRoot: string) => {
122102
if (!repoRoot) return []
123103
return listBranches(repoRoot)
124104
})
125105

126-
ipcMain.handle('worktree:add', async (event, branchName: string, baseBranch?: string) => {
127-
const win = getWindowFromEvent(event)
128-
const repoRoot = win ? windowRepoRoots.get(win.id) : null
129-
if (!repoRoot) throw new Error('No repo root configured')
106+
ipcMain.handle('worktree:add', async (_, repoRoot: string, branchName: string, baseBranch?: string) => {
107+
if (!repoRoot) throw new Error('No repo root provided')
130108
const wtDir = defaultWorktreeDir(repoRoot)
131109
const mode = config.worktreeBase || DEFAULT_WORKTREE_BASE
132110
return addWorktree(repoRoot, wtDir, branchName, {
@@ -137,10 +115,8 @@ function registerIpcHandlers(): void {
137115

138116
ipcMain.handle(
139117
'worktree:continue',
140-
async (event, worktreePath: string, newBranchName: string, baseBranch?: string) => {
141-
const win = getWindowFromEvent(event)
142-
const repoRoot = win ? windowRepoRoots.get(win.id) : null
143-
if (!repoRoot) throw new Error('No repo root configured')
118+
async (_, repoRoot: string, worktreePath: string, newBranchName: string, baseBranch?: string) => {
119+
if (!repoRoot) throw new Error('No repo root provided')
144120
const mode = config.worktreeBase || DEFAULT_WORKTREE_BASE
145121
return continueWorktree(repoRoot, worktreePath, newBranchName, {
146122
baseBranch,
@@ -154,14 +130,13 @@ function registerIpcHandlers(): void {
154130
})
155131

156132
ipcMain.handle('worktree:remove', async (
157-
event,
133+
_,
134+
repoRoot: string,
158135
path: string,
159136
force?: boolean,
160137
removeMeta?: { prNumber?: number; prState?: PRState }
161138
) => {
162-
const win = getWindowFromEvent(event)
163-
const repoRoot = win ? windowRepoRoots.get(win.id) : null
164-
if (!repoRoot) throw new Error('No repo root configured')
139+
if (!repoRoot) throw new Error('No repo root provided')
165140
// Drop any locally-merged flag for the branch at this path
166141
const trees = await listWorktrees(repoRoot)
167142
const wt = trees.find((t) => t.path === path)
@@ -180,34 +155,43 @@ function registerIpcHandlers(): void {
180155
return removeWorktree(repoRoot, path, force)
181156
})
182157

183-
ipcMain.handle('worktree:dir', async (event) => {
184-
const win = getWindowFromEvent(event)
185-
const repoRoot = win ? windowRepoRoots.get(win.id) : null
158+
ipcMain.handle('worktree:dir', async (_, repoRoot: string) => {
186159
if (!repoRoot) return ''
187160
return defaultWorktreeDir(repoRoot)
188161
})
189162

190-
ipcMain.handle('repo:select', async (event) => {
191-
const win = getWindowFromEvent(event)
192-
if (!win) return null
193-
const result = await dialog.showOpenDialog(win, {
163+
ipcMain.handle('repo:list', () => {
164+
return config.repoRoots
165+
})
166+
167+
ipcMain.handle('repo:add', async (event) => {
168+
const win = BrowserWindow.fromWebContents(event.sender)
169+
const result = await dialog.showOpenDialog(win!, {
194170
properties: ['openDirectory'],
195-
title: 'Select Git Repository Root'
171+
title: 'Open Git Repository'
196172
})
197173
if (result.canceled || result.filePaths.length === 0) return null
198174
const repoRoot = result.filePaths[0]
199-
windowRepoRoots.set(win.id, repoRoot)
200-
// Track in config for reopening
201175
if (!config.repoRoots.includes(repoRoot)) {
202176
config.repoRoots.push(repoRoot)
203177
saveConfig(config)
178+
broadcastToAllWindows('repo:listChanged', config.repoRoots)
204179
}
205180
return repoRoot
206181
})
207182

208-
ipcMain.handle('repo:getRoot', (event) => {
209-
const win = getWindowFromEvent(event)
210-
return win ? windowRepoRoots.get(win.id) || null : null
183+
ipcMain.handle('repo:remove', (_, repoRoot: string) => {
184+
const idx = config.repoRoots.indexOf(repoRoot)
185+
if (idx === -1) return false
186+
config.repoRoots.splice(idx, 1)
187+
// Also drop any persisted panes for the removed repo so they don't
188+
// linger as orphans.
189+
if (config.panes && config.panes[repoRoot]) {
190+
delete config.panes[repoRoot]
191+
}
192+
saveConfig(config)
193+
broadcastToAllWindows('repo:listChanged', config.repoRoots)
194+
return true
211195
})
212196

213197
// Changed files
@@ -248,34 +232,26 @@ function registerIpcHandlers(): void {
248232
return getPRStatus(worktreePath)
249233
})
250234

251-
ipcMain.handle('worktree:mainStatus', async (event) => {
252-
const win = getWindowFromEvent(event)
253-
const repoRoot = win ? windowRepoRoots.get(win.id) : null
254-
if (!repoRoot) throw new Error('No repo root configured')
235+
ipcMain.handle('worktree:mainStatus', async (_, repoRoot: string) => {
236+
if (!repoRoot) throw new Error('No repo root provided')
255237
return getMainWorktreeStatus(repoRoot)
256238
})
257239

258-
ipcMain.handle('worktree:previewMerge', async (event, sourceBranch: string) => {
259-
const win = getWindowFromEvent(event)
260-
const repoRoot = win ? windowRepoRoots.get(win.id) : null
261-
if (!repoRoot) throw new Error('No repo root configured')
240+
ipcMain.handle('worktree:previewMerge', async (_, repoRoot: string, sourceBranch: string) => {
241+
if (!repoRoot) throw new Error('No repo root provided')
262242
const status = await getMainWorktreeStatus(repoRoot)
263243
return previewMergeConflicts(repoRoot, sourceBranch, status.baseBranch)
264244
})
265245

266-
ipcMain.handle('worktree:prepareMain', async (event) => {
267-
const win = getWindowFromEvent(event)
268-
const repoRoot = win ? windowRepoRoots.get(win.id) : null
269-
if (!repoRoot) throw new Error('No repo root configured')
246+
ipcMain.handle('worktree:prepareMain', async (_, repoRoot: string) => {
247+
if (!repoRoot) throw new Error('No repo root provided')
270248
return prepareMainForMerge(repoRoot)
271249
})
272250

273251
ipcMain.handle(
274252
'worktree:mergeLocal',
275-
async (event, sourceBranch: string, strategy: MergeStrategy) => {
276-
const win = getWindowFromEvent(event)
277-
const repoRoot = win ? windowRepoRoots.get(win.id) : null
278-
if (!repoRoot) throw new Error('No repo root configured')
253+
async (_, repoRoot: string, sourceBranch: string, strategy: MergeStrategy) => {
254+
if (!repoRoot) throw new Error('No repo root provided')
279255
const result = await mergeWorktreeLocally(repoRoot, sourceBranch, strategy)
280256
// Record the branch as locally merged at its current tip sha. If new
281257
// commits are pushed to the branch later, the flag becomes stale and
@@ -296,9 +272,7 @@ function registerIpcHandlers(): void {
296272
* check can't tell "fork point on trunk" from "trunk position after merge",
297273
* so we don't try to detect external merges automatically. The flag is
298274
* auto-cleared if the branch later gains new commits. */
299-
ipcMain.handle('worktree:mergedStatus', async (event) => {
300-
const win = getWindowFromEvent(event)
301-
const repoRoot = win ? windowRepoRoots.get(win.id) : null
275+
ipcMain.handle('worktree:mergedStatus', async (_, repoRoot: string) => {
302276
if (!repoRoot) return {}
303277
const trees = await listWorktrees(repoRoot)
304278
const result: Record<string, boolean> = {}
@@ -515,14 +489,14 @@ function registerIpcHandlers(): void {
515489
return true
516490
})
517491

518-
// Persisted workspace panes (tabs per pane, per worktree)
492+
// Persisted workspace panes (tabs per pane, per worktree, per repo).
519493
ipcMain.handle('config:getPanes', () => {
520494
return config.panes || {}
521495
})
522496

523497
ipcMain.handle(
524498
'config:setPanes',
525-
(_, panes: Record<string, PersistedPane[]>) => {
499+
(_, panes: Record<string, Record<string, PersistedPane[]>>) => {
526500
config.panes = panes
527501
saveConfig(config)
528502
return true
@@ -683,7 +657,7 @@ function registerIpcHandlers(): void {
683657

684658
// PTY handlers — route to the calling window
685659
ipcMain.on('pty:create', (event, id: string, cwd: string, cmd: string, args: string[]) => {
686-
const win = getWindowFromEvent(event)
660+
const win = BrowserWindow.fromWebContents(event.sender)
687661
if (win) ptyManager.create(id, cwd, cmd, args, win)
688662
})
689663

@@ -837,24 +811,21 @@ app.whenReady().then(() => {
837811

838812
// Prune terminal history files not referenced by any persisted tab
839813
const keepIds = new Set<string>()
840-
for (const panes of Object.values(config.panes || {})) {
841-
for (const pane of panes) {
842-
for (const tab of pane.tabs) keepIds.add(tab.id)
814+
for (const byRepo of Object.values(config.panes || {})) {
815+
for (const panes of Object.values(byRepo)) {
816+
for (const pane of panes) {
817+
for (const tab of pane.tabs) keepIds.add(tab.id)
818+
}
843819
}
844820
}
845821
pruneTerminalHistory(keepIds)
846822

847823
// Watch status dir globally — route to correct window via ptyManager
848824
stopWatchingStatus = watchStatusDir((id) => ptyManager.getWindowForTerminal(id))
849825

850-
// Open a window for each saved repo root, or one empty window
851-
if (config.repoRoots.length > 0) {
852-
for (const root of config.repoRoots) {
853-
createWindow(root)
854-
}
855-
} else {
856-
createWindow()
857-
}
826+
// One window shows all repos. The renderer reads `config.repoRoots` via
827+
// `repo:list` and opens each one on mount.
828+
createWindow()
858829

859830
setupAutoUpdater()
860831

0 commit comments

Comments
 (0)