Skip to content

Commit 9451243

Browse files
committed
Send cli open command to correct window if multiple windows are open
Closes #177
1 parent 3e01e8e commit 9451243

6 files changed

Lines changed: 116 additions & 16 deletions

File tree

app/src/lib/ipc-shared.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export type RequestChannels = {
7373
'quit-app': () => void
7474
'open-repository-in-new-window': (path: string) => void
7575
'set-window-title': (title: string) => void
76+
'set-window-selected-repository': (path: string | null) => void
7677
'minimize-window': () => void
7778
'maximize-window': () => void
7879
'unmaximize-window': () => void

app/src/main-process/app-window.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ export class AppWindow {
3737
private _rendererReadyTime: number | null = null
3838
private isDownloadingUpdate: boolean = false
3939

40+
/**
41+
* The path of the repository currently selected in this window, as reported
42+
* by the renderer, or null if no repository is selected. Used to route
43+
* `open-repository` actions to the window that already has the repository
44+
* open.
45+
*/
46+
private _selectedRepositoryPath: string | null = null
47+
4048
private minWidth = 960
4149
private minHeight = 660
4250

@@ -331,6 +339,19 @@ export class AppWindow {
331339
this.window.setTitle(title)
332340
}
333341

342+
/** The path of the repository currently selected in this window, if any. */
343+
public get selectedRepositoryPath(): string | null {
344+
return this._selectedRepositoryPath
345+
}
346+
347+
public setSelectedRepositoryPath(path: string | null) {
348+
this._selectedRepositoryPath = path
349+
}
350+
351+
public hasSelectedRepositoryPath(): this is AppWindowWithSelectedRepository {
352+
return this.isLoaded && this.selectedRepositoryPath !== null
353+
}
354+
334355
/** Selects all the windows web contents */
335356
public selectAllWindowContents() {
336357
this.window.webContents.selectAll()
@@ -609,6 +630,10 @@ export class AppWindow {
609630
}
610631
}
611632

633+
export type AppWindowWithSelectedRepository = AppWindow & {
634+
selectedRepositoryPath: string
635+
}
636+
612637
const trySetUpdaterGuid = async (url: string) => {
613638
try {
614639
const id = await getUpdaterGUID()

app/src/main-process/main.ts

Lines changed: 60 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
WebContents,
1212
} from 'electron'
1313
import * as Fs from 'fs'
14+
import * as Path from 'path'
1415

1516
import { AppWindow } from './app-window'
1617
import { buildDefaultMenu, getAllMenuItems } from './menu'
@@ -189,7 +190,34 @@ function getTargetWindow() {
189190
return focusedAppWindow
190191
}
191192

192-
return getAppWindows()[0] ?? null
193+
return getAppWindows().at(0) ?? null
194+
}
195+
196+
function normalizeRepositoryPath(path: string) {
197+
// Strip trailing separator
198+
const normalized = Path.normalize(path).replace(/[\\/]+$/, '')
199+
// Windows paths are case-insensitive
200+
return __WIN32__ ? normalized.toLowerCase() : normalized
201+
}
202+
203+
function findWindowForRepositoryPath(rawTargetPath: string): AppWindow | null {
204+
const targetPath = normalizeRepositoryPath(rawTargetPath)
205+
const allWindows = getAppWindows().filter(w => w.hasSelectedRepositoryPath())
206+
const windowsSortedFromMostSpecificToLeast = allWindows.sort(
207+
(a, b) => b.selectedRepositoryPath.length - a.selectedRepositoryPath.length
208+
)
209+
210+
for (const window of windowsSortedFromMostSpecificToLeast) {
211+
const candidatePath = normalizeRepositoryPath(window.selectedRepositoryPath)
212+
if (
213+
targetPath === candidatePath ||
214+
targetPath.startsWith(candidatePath + Path.sep)
215+
) {
216+
return window
217+
}
218+
}
219+
220+
return null // fallback to getLoadedTargetWindow()
193221
}
194222

195223
function getLoadedTargetWindow() {
@@ -250,22 +278,23 @@ if (!handlingSquirrelEvent) {
250278
const gotSingleInstanceLock = app.requestSingleInstanceLock()
251279
isDuplicateInstance = !gotSingleInstanceLock
252280

253-
app.on('second-instance', (event, args, workingDirectory) => {
254-
// Someone tried to run a second instance, we should focus our window.
255-
const targetWindow = getTargetWindow()
256-
if (targetWindow) {
257-
if (targetWindow.isMinimized()) {
258-
targetWindow.restore()
281+
app.on('second-instance', async (event, args, workingDirectory) => {
282+
const handledAction = await handleCommandLineArguments(args)
283+
if (handledAction) {
284+
return
285+
}
286+
const mainWindow = getTargetWindow()
287+
if (mainWindow) {
288+
if (mainWindow.isMinimized()) {
289+
mainWindow.restore()
259290
}
260291

261-
if (!targetWindow.isVisible()) {
262-
targetWindow.show()
292+
if (!mainWindow.isVisible()) {
293+
mainWindow.show()
263294
}
264295

265-
targetWindow.focus()
296+
mainWindow.focus()
266297
}
267-
268-
handleCommandLineArguments(args)
269298
})
270299

271300
if (isDuplicateInstance) {
@@ -311,7 +340,7 @@ if (__DARWIN__) {
311340
})
312341
}
313342

314-
async function handleCommandLineArguments(argv: string[]) {
343+
async function handleCommandLineArguments(argv: string[]): Promise<boolean> {
315344
const args = parseCommandLineArgs(argv, {
316345
boolean: ['protocol-launcher'],
317346
})
@@ -347,30 +376,41 @@ async function handleCommandLineArguments(argv: string[]) {
347376

348377
if (matchingUrl) {
349378
handleAppURL(matchingUrl)
350-
return
379+
return true
351380
} else if (__WIN32__) {
352381
log.error(`Encountered --protocol-launcher without app url`)
353-
return
382+
return false
354383
}
355384
// If --protocol-launcher is present we always want to bail and not
356385
// risk a smuggled cli switch
357386
}
358387

359388
if (typeof args['cli-open'] === 'string') {
360389
handleCLIAction({ kind: 'open-repository', path: args['cli-open'] })
390+
return true
361391
} else if (typeof args['cli-clone'] === 'string') {
362392
handleCLIAction({
363393
kind: 'clone-url',
364394
url: args['cli-clone'],
365395
branch:
366396
typeof args['cli-branch'] === 'string' ? args['cli-branch'] : undefined,
367397
})
398+
return true
368399
}
369400

370-
return
401+
return false
371402
}
372403

373404
function handleCLIAction(action: CLIAction) {
405+
if (action.kind === 'open-repository') {
406+
const existingWindow = findWindowForRepositoryPath(action.path)
407+
if (existingWindow !== null) {
408+
existingWindow.revealAndFocus()
409+
existingWindow.sendCLIAction(action)
410+
return
411+
}
412+
}
413+
374414
onDidLoad(window => {
375415
// This manual focus call _shouldn't_ be necessary, but is for Chrome on
376416
// macOS. See https://github.com/desktop/desktop/issues/973.
@@ -623,6 +663,10 @@ app.on('ready', () => {
623663
getAppWindowFromWebContents(event.sender)?.setTitle(title)
624664
)
625665

666+
ipcMain.on('set-window-selected-repository', (event, path: string | null) =>
667+
getAppWindowFromWebContents(event.sender)?.setSelectedRepositoryPath(path)
668+
)
669+
626670
ipcMain.on('minimize-window', event =>
627671
getAppWindowFromWebContents(event.sender)?.minimizeWindow()
628672
)

app/src/ui/app.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import {
7373
uninstallWindowsCLI,
7474
openRepositoryInNewWindow,
7575
setWindowTitle,
76+
setWindowSelectedRepository,
7677
} from './main-process-proxy'
7778
import { DiscardChanges } from './discard-changes'
7879
import { Welcome } from './welcome'
@@ -1120,12 +1121,20 @@ export class App extends React.Component<IAppProps, IAppState> {
11201121
})
11211122

11221123
this.updateWindowTitle()
1124+
this.updateSelectedRepository()
11231125
}
11241126

11251127
public componentDidUpdate(prevProps: IAppProps, prevState: IAppState): void {
11261128
if (this.getWindowTitle(prevState) !== this.getWindowTitle()) {
11271129
this.updateWindowTitle()
11281130
}
1131+
1132+
if (
1133+
this.getSelectedRepositoryPath(prevState) !==
1134+
this.getSelectedRepositoryPath()
1135+
) {
1136+
this.updateSelectedRepository()
1137+
}
11291138
}
11301139

11311140
private onDocumentFocus = (event: FocusEvent) => {
@@ -1149,6 +1158,16 @@ export class App extends React.Component<IAppProps, IAppState> {
11491158
setWindowTitle(this.getWindowTitle())
11501159
}
11511160

1161+
private getSelectedRepositoryPath(
1162+
state: IAppState = this.state
1163+
): string | null {
1164+
return state.selectedState?.repository.path ?? null
1165+
}
1166+
1167+
private updateSelectedRepository() {
1168+
setWindowSelectedRepository(this.getSelectedRepositoryPath())
1169+
}
1170+
11521171
/**
11531172
* Manages keyboard shortcuts specific to macOS.
11541173
* - adds Shift+F10 to open the context menus (like on Windows so macOS

app/src/ui/main-process-proxy.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,16 @@ export const openRepositoryInNewWindow = sendProxy(
182182
/** Tell the main process to update the current window title */
183183
export const setWindowTitle = sendProxy('set-window-title', 1)
184184

185+
/**
186+
* Tell the main process which repository (if any) is currently selected in this
187+
* window. This allows the main process to route CLI/second-instance
188+
* `open-repository` actions to the window that already has the repository open.
189+
*/
190+
export const setWindowSelectedRepository = sendProxy(
191+
'set-window-selected-repository',
192+
1
193+
)
194+
185195
/** Subscribes to auto updater error events originating from the main process */
186196
export function onAutoUpdaterError(
187197
errorHandler: (evt: Electron.IpcRendererEvent, error: Error) => void

app/test/unit/ipc-contract-test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ describe('IPC channel contract', () => {
7171
'uninstall-windows-cli',
7272
'open-repository-in-new-window',
7373
'set-window-title',
74+
'set-window-selected-repository',
7475
'restart-app',
7576
] as const
7677

0 commit comments

Comments
 (0)