From 7c915f6d5fc9334b41484abffd7f6d05faf41cca Mon Sep 17 00:00:00 2001 From: crusader Date: Fri, 17 Aug 2018 15:11:55 +0900 Subject: [PATCH] ing --- @overflow/core/merge.ts | 10 + package.json | 3 +- src/app/pages/home/home-page.component.html | 2 +- src/commons/model/app-menu.ts | 665 ++++++++++++++++++ src/commons/model/app-state.ts | 135 ++++ src/commons/model/application-theme.ts | 54 ++ src/commons/model/menu-update.ts | 174 +++++ src/commons/model/preferences.ts | 4 + src/commons/model/tip.ts | 37 + .../commons/model}/window-state.ts | 0 src/commons/service/electron-proxy.service.ts | 16 +- src/commons/type/menu-event.ts | 3 + src/commons/type/menu-ids.ts | 2 + src/electron/app-window.ts | 17 +- src/electron/main.ts | 144 ++-- src/electron/menu/build-default.ts | 310 ++++++++ src/electron/menu/ensure-item-ids.ts | 44 ++ src/electron/menu/find-menu-item.ts | 24 + src/electron/squirrel-updater.ts | 2 +- src/tsconfig.app.json | 5 +- tsconfig.json | 3 + tslint.json | 4 +- yarn.lock | 16 +- 23 files changed, 1578 insertions(+), 96 deletions(-) create mode 100644 @overflow/core/merge.ts create mode 100644 src/commons/model/app-menu.ts create mode 100644 src/commons/model/app-state.ts create mode 100644 src/commons/model/application-theme.ts create mode 100644 src/commons/model/menu-update.ts create mode 100644 src/commons/model/preferences.ts create mode 100644 src/commons/model/tip.ts rename {@overflow/core => src/commons/model}/window-state.ts (100%) create mode 100644 src/electron/menu/build-default.ts create mode 100644 src/electron/menu/ensure-item-ids.ts create mode 100644 src/electron/menu/find-menu-item.ts diff --git a/@overflow/core/merge.ts b/@overflow/core/merge.ts new file mode 100644 index 0000000..4b64efd --- /dev/null +++ b/@overflow/core/merge.ts @@ -0,0 +1,10 @@ +/** Create a copy of an object by merging it with a subset of its properties. */ +export function merge(obj: T, subset: Pick): T { + const copy = Object.assign({}, obj); + for (const k in subset) { + if (subset[k]) { + copy[k] = subset[k]; + } + } + return copy; +} diff --git a/package.json b/package.json index c4be124..d947750 100644 --- a/package.json +++ b/package.json @@ -39,9 +39,10 @@ "@ngrx/schematics": "^6.1.0", "@ngrx/store": "^6.1.0", "@ngrx/store-devtools": "^6.1.0", + "@types/fs-extra": "^5.0.4", "@types/jasmine": "~2.8.6", "@types/jasminewd2": "~2.0.3", - "@types/node": "~8.9.4", + "@types/node": "^8.10.4", "awesome-typescript-loader": "^5.2.0", "codelyzer": "~4.2.1", "core-js": "^2.5.4", diff --git a/src/app/pages/home/home-page.component.html b/src/app/pages/home/home-page.component.html index 62e1f62..01ecb09 100644 --- a/src/app/pages/home/home-page.component.html +++ b/src/app/pages/home/home-page.component.html @@ -2,7 +2,7 @@
-

Home works!!

+

Home works!!!!

diff --git a/src/commons/model/app-menu.ts b/src/commons/model/app-menu.ts new file mode 100644 index 0000000..8134cf9 --- /dev/null +++ b/src/commons/model/app-menu.ts @@ -0,0 +1,665 @@ +/** A type union of all possible types of menu items */ +export type MenuItem = + | IMenuItem + | ISubmenuItem + | ISeparatorMenuItem + | ICheckboxMenuItem + | IRadioMenuItem; + +/** A type union of all types of menu items which can be executed */ +export type ExecutableMenuItem = IMenuItem | ICheckboxMenuItem | IRadioMenuItem; + +/** + * Common properties for all item types except separator. + * Only useful for declaring the types, not for consumption + */ +interface IBaseMenuItem { + readonly id: string; + readonly enabled: boolean; + readonly visible: boolean; + readonly label: string; +} + +/** + * An interface describing the properties of a 'normal' + * menu item, i.e. a clickable item with a label but no + * other special properties. + */ +export interface IMenuItem extends IBaseMenuItem { + readonly type: 'menuItem'; + readonly accelerator: string | null; + readonly accessKey: string | null; +} + +/** + * An interface describing the properties of a + * submenu menu item, i.e. an item which has an associated + * submenu which can be expanded to reveal more menu + * item. Not in itself executable, only a container. + */ +export interface ISubmenuItem extends IBaseMenuItem { + readonly type: 'submenuItem'; + readonly menu: IMenu; + readonly accessKey: string | null; +} + +/** + * An interface describing the properties of a checkbox + * menu item, i.e. an item which has an associated checked + * state that can be toggled by executing it. + */ +export interface ICheckboxMenuItem extends IBaseMenuItem { + readonly type: 'checkbox'; + readonly accelerator: string | null; + readonly accessKey: string | null; + readonly checked: boolean; +} + +/** + * An interface describing the properties of a checkbox + * menu item, i.e. an item which has an associated checked + * state that is checked or unchecked based on application logic. + * + * The radio menu item is probably going to be used in a collection + * of more radio menu items where the checked item is assigned + * based on the last executed item in that group. + */ +export interface IRadioMenuItem extends IBaseMenuItem { + readonly type: 'radio'; + readonly accelerator: string | null; + readonly accessKey: string | null; + readonly checked: boolean; +} + +/** + * An interface describing the properties of a separator menu + * item, i.e. an item which sole purpose is to create separation + * between menu items. It has no other semantics and is purely + * a visual hint. + */ +export interface ISeparatorMenuItem { + readonly id: string; + readonly type: 'separator'; + readonly visible: boolean; +} + +/** + * An interface describing a menu. + * + * Holds collection of menu items and an indication of which item (if any) + * in the menu is selected. + */ +export interface IMenu { + /** + * The id of this menu. For the root menu this will be undefined. For all + * other menus it will be the same as the id of the submenu item which + * owns this menu. + * + * +---------------------------+ + * | Root menu (id: undefined) | + * +---------------------------+ +--------------------------+ + * | File (id File) +--> File menu (id: File) | + * +---------------------------+ +--------------------------+ + * | Edit (id Edit) | | Open (id File.Open) | + * +---------------------------+ +--------------------------+ + * | Close (id File.Close) | + * +--------------------------+ + */ + readonly id?: string; + + /** Type identifier, used for type narrowing */ + readonly type: 'menu'; + + /** A collection of zero or more menu items */ + readonly items: ReadonlyArray; + + /** The selected item in the menu or undefined if no item is selected */ + readonly selectedItem?: MenuItem; +} + +/** + * Gets the accelerator for a given menu item. If the menu item doesn't + * have an explicitly defined accelerator but does have a defined role + * the default accelerator (if any) for that particular role will be + * returned. + */ +function getAccelerator(menuItem: Electron.MenuItem): string | null { + if (menuItem.accelerator) { + return menuItem.accelerator as string; + } + + if (menuItem.role) { + const unsafeItem = menuItem as any; + // https://github.com/electron/electron/blob/d4a8a64ba/lib/browser/api/menu-item.js#L62 + const getDefaultRoleAccelerator = unsafeItem.getDefaultRoleAccelerator; + + if (typeof getDefaultRoleAccelerator === 'function') { + try { + const defaultRoleAccelerator = getDefaultRoleAccelerator.call(menuItem); + if (typeof defaultRoleAccelerator === 'string') { + return defaultRoleAccelerator; + } + } catch (err) { + console.error('Could not retrieve default accelerator', err); + } + } + } + + return null; +} + +/** + * Return the access key (applicable on Windows) from a menu item label. + * + * An access key is a letter or symbol preceded by an ampersand, i.e. in + * the string "Check for &updates" the access key is 'u'. Access keys are + * case insensitive and are unique per menu. + */ +function getAccessKey(text: string): string | null { + const m = text.match(/&([^&])/); + return m ? m[1] : null; +} + +/** + * Creates an instance of one of the types in the MenuItem type union based + * on an Electron MenuItem instance. Will recurse through all sub menus and + * convert each item. + */ +function menuItemFromElectronMenuItem(menuItem: Electron.MenuItem): MenuItem { + // Our menu items always have ids and Electron.MenuItem takes on whatever + // properties was defined on the MenuItemOptions template used to create it + // but doesn't surface those in the type declaration. + const id: string | undefined = (menuItem as any).id; + if (!id) { + throw new Error(`menuItem must specify id: ${menuItem.label}`); + } + const enabled = menuItem.enabled; + const visible = menuItem.visible; + const label = menuItem.label; + const checked = menuItem.checked; + const accelerator = getAccelerator(menuItem); + const accessKey = getAccessKey(menuItem.label); + + // normal, separator, submenu, checkbox or radio. + switch (menuItem.type) { + case 'normal': + return { + id, + type: 'menuItem', + label, + enabled, + visible, + accelerator, + accessKey, + }; + case 'separator': + return { id, type: 'separator', visible }; + case 'submenu': + const menu = menuFromElectronMenu(menuItem.submenu as Electron.Menu, id); + return { + id, + type: 'submenuItem', + label, + enabled, + visible, + menu, + accessKey, + }; + case 'checkbox': + return { + id, + type: 'checkbox', + label, + enabled, + visible, + accelerator, + checked, + accessKey, + }; + case 'radio': + return { + id, + type: 'radio', + label, + enabled, + visible, + accelerator, + checked, + accessKey, + }; + default: + // return assertNever( + // menuItem.type, + // `Unknown menu item type ${menuItem.type}` + // ); + } +} +/** + * Creates a IMenu instance based on an Electron Menu instance. + * Will recurse through all sub menus and convert each item using + * menuItemFromElectronMenuItem. + * + * @param menu - The electron menu instance to convert into an + * IMenu instance + * + * @param id - The id of the menu. Menus share their id with + * their parent item. The root menu id is undefined. + */ +export function menuFromElectronMenu(menu: Electron.Menu, id?: string): IMenu { + const items = menu.items.map(menuItemFromElectronMenuItem); + + if (__DEV__) { + const seenAccessKeys = new Set(); + + for (const item of items) { + if (item.visible) { + if (itemMayHaveAccessKey(item) && item.accessKey) { + if (seenAccessKeys.has(item.accessKey.toLowerCase())) { + throw new Error( + `Duplicate access key '${item.accessKey}' for item ${item.label}` + ); + } else { + seenAccessKeys.add(item.accessKey.toLowerCase()); + } + } + } + } + } + + return { id, type: 'menu', items }; +} + +/** + * Creates a map between MenuItem ids and MenuItems by recursing + * through all items and all submenus. + */ +function buildIdMap( + menu: IMenu, + map = new Map() +): Map { + for (const item of menu.items) { + map.set(item.id, item); + if (item.type === 'submenuItem') { + buildIdMap(item.menu, map); + } + } + + return map; +} + +/** Type guard which narrows a MenuItem to one which supports access keys */ +export function itemMayHaveAccessKey( + item: MenuItem +): item is IMenuItem | ISubmenuItem | ICheckboxMenuItem | IRadioMenuItem { + return ( + item.type === 'menuItem' || + item.type === 'submenuItem' || + item.type === 'checkbox' || + item.type === 'radio' + ); +} + +/** + * Returns a value indicating whether or not the given menu item can be + * selected. Selectable items are non-separator items which are enabled + * and visible. + */ +export function itemIsSelectable(item: MenuItem) { + return item.type !== 'separator' && item.enabled && item.visible; +} + +/** + * Attempts to locate a menu item matching the provided access key in a + * given list of items. The access key comparison is case-insensitive. + * + * Note that this function does not take into account whether or not the + * item is selectable, consumers of this function need to perform that + * check themselves when applicable. + */ +export function findItemByAccessKey( + accessKey: string, + items: ReadonlyArray +): IMenuItem | ISubmenuItem | ICheckboxMenuItem | IRadioMenuItem | null { + const lowerCaseAccessKey = accessKey.toLowerCase(); + + for (const item of items) { + if (itemMayHaveAccessKey(item)) { + if ( + item.accessKey && + item.accessKey.toLowerCase() === lowerCaseAccessKey + ) { + return item; + } + } + } + + return null; +} + +/** + * An immutable, transformable object which represents an application menu + * and its current state (which menus are open, which items are selected). + * + * The primary use case for this is for rendering a custom application menu + * on non-macOS systems. As such some interactions are explicitly made to + * conform to Windows menu interactions. This includes things like selecting + * the entire path up until the last selected item. This is necessary since, + * on Windows, the parent menu item of a menu might not be selected even + * though the submenu is. This is in order to allow for some delay when + * moving the cursor from one menu pane to another. + * + * In general, however, this object is not platform specific and much of + * the interactions are defined by the component using it. + */ +export class AppMenu { + /** + * A list of currently open menus with their selected items + * in the application menu. + * + * The semantics around what constitutes an open menu and how + * selection works is defined within this class class as well as + * in the individual components transforming that state. + */ + public readonly openMenus: ReadonlyArray; + + /** + * The menu that this instance operates on, taken from an + * electron Menu instance and converted into an IMenu model + * by menuFromElectronMenu. + */ + private readonly menu: IMenu; + + /** + * A map between menu item ids and their corresponding MenuItem + */ + private readonly menuItemById: Map; + + /** + * Static constructor for the initial creation of an AppMenu instance + * from an IMenu instance. + */ + public static fromMenu(menu: IMenu): AppMenu { + const map = buildIdMap(menu); + const openMenus = [menu]; + + return new AppMenu(menu, openMenus, map); + } + + // Used by static constructors and transformers. + private constructor( + menu: IMenu, + openMenus: ReadonlyArray, + menuItemById: Map + ) { + this.menu = menu; + this.openMenus = openMenus; + this.menuItemById = menuItemById; + } + + /** + * Retrieves a menu item by its id. + */ + public getItemById(id: string): MenuItem | undefined { + return this.menuItemById.get(id); + } + + /** + * Merges the current AppMenu state with a new menu while + * attempting to maintain selection state. + */ + public withMenu(newMenu: IMenu): AppMenu { + const newMap = buildIdMap(newMenu); + const newOpenMenus = new Array(); + + // Enumerate all currently open menus and attempt to recreate + // the openMenus array with the new menu instances + for (const openMenu of this.openMenus) { + let newOpenMenu: IMenu; + + // No id means it's the root menu, simple enough. + if (!openMenu.id) { + newOpenMenu = newMenu; + } else { + // Menus share id with their parent item + const item = newMap.get(openMenu.id); + + if (item && item.type === 'submenuItem') { + newOpenMenu = item.menu; + } else { + // This particular menu can't be found in the new menu + // structure, we have no choice but to bail here and + // not open this particular menu. + break; + } + } + + let newSelectedItem: MenuItem | undefined; + + if (openMenu.selectedItem) { + newSelectedItem = newMap.get(openMenu.selectedItem.id); + } + + newOpenMenus.push({ + id: newOpenMenu.id, + type: 'menu', + items: newOpenMenu.items, + selectedItem: newSelectedItem, + }); + } + + return new AppMenu(newMenu, newOpenMenus, newMap); + } + + /** + * Creates a new copy of this AppMenu instance with the given submenu open. + * + * @param submenuItem - The item which submenu should be appended + * to the list of open menus. + * + * @param selectFirstItem - A convenience item for automatically selecting + * the first item in the newly opened menu. + * + * If false the new menu is opened without a selection. + * + * Defaults to false. + */ + public withOpenedMenu( + submenuItem: ISubmenuItem, + selectFirstItem = false + ): AppMenu { + const ourMenuItem = this.menuItemById.get(submenuItem.id); + + if (!ourMenuItem) { + return this; + } + + if (ourMenuItem.type !== 'submenuItem') { + throw new Error( + `Attempt to open a submenu from an item of wrong type: ${ + ourMenuItem.type + }` + ); + } + + const parentMenuIndex = this.openMenus.findIndex( + m => m.items.indexOf(ourMenuItem) !== -1 + ); + + // The parent menu has apparently been closed in between, we could go and + // recreate it but it's probably not worth it. + if (parentMenuIndex === -1) { + return this; + } + + const newOpenMenus = this.openMenus.slice(0, parentMenuIndex + 1); + + if (selectFirstItem) { + // First selectable item. + const selectedItem = ourMenuItem.menu.items.find(itemIsSelectable); + newOpenMenus.push({ ...ourMenuItem.menu, selectedItem }); + } else { + newOpenMenus.push(ourMenuItem.menu); + } + + return new AppMenu(this.menu, newOpenMenus, this.menuItemById); + } + + /** + * Creates a new copy of this AppMenu instance with the given menu removed from + * the list of open menus. + * + * @param menu - The menu which is to be closed, i.e. removed from the + * list of open menus. + */ + public withClosedMenu(menu: IMenu) { + // Root menu is always open and can't be closed + if (!menu.id) { + return this; + } + + const ourMenuIndex = this.openMenus.findIndex(m => m.id === menu.id); + + if (ourMenuIndex === -1) { + return this; + } + + const newOpenMenus = this.openMenus.slice(0, ourMenuIndex); + + return new AppMenu(this.menu, newOpenMenus, this.menuItemById); + } + + /** + * Creates a new copy of this AppMenu instance with the list of open menus trimmed + * to not include any menus below the given menu. + * + * @param menu - The last menu which is to remain in the list of open + * menus, all menus below this level will be pruned from + * the list of open menus. + */ + public withLastMenu(menu: IMenu) { + const ourMenuIndex = this.openMenus.findIndex(m => m.id === menu.id); + + if (ourMenuIndex === -1) { + return this; + } + + const newOpenMenus = this.openMenus.slice(0, ourMenuIndex + 1); + + return new AppMenu(this.menu, newOpenMenus, this.menuItemById); + } + + /** + * Creates a new copy of this AppMenu instance in which the given menu item + * is selected. + * + * Additional semantics: + * + * All menus leading up to the given menu item will have their + * selection reset in such a fashion that the selection path + * points to the given menu item. + * + * All menus after the menu in which the given item resides + * will have their selections cleared. + * + * @param menuItem - The menu item which is to be selected. + */ + public withSelectedItem(menuItem: MenuItem) { + const ourMenuItem = this.menuItemById.get(menuItem.id); + + // The item that someone is trying to select no longer + // exists, not much we can do about that. + if (!ourMenuItem) { + return this; + } + + const parentMenuIndex = this.openMenus.findIndex( + m => m.items.indexOf(ourMenuItem) !== -1 + ); + + // The menu which the selected item belongs to is no longer open, + // not much we can do about that. + if (parentMenuIndex === -1) { + return this; + } + + const newOpenMenus = this.openMenus.slice(); + + const parentMenu = newOpenMenus[parentMenuIndex]; + + newOpenMenus[parentMenuIndex] = { ...parentMenu, selectedItem: ourMenuItem }; + + // All submenus below the active menu should have their selection cleared + for (let i = parentMenuIndex + 1; i < newOpenMenus.length; i++) { + newOpenMenus[i] = { ...newOpenMenus[i], selectedItem: undefined }; + } + + // Ensure that the path that lead us to the currently selected menu is + // selected. i.e. all menus above the currently active menu should have + // their selection reset to point to the currently active menu. + for (let i = parentMenuIndex - 1; i >= 0; i--) { + const menu = newOpenMenus[i]; + const childMenu = newOpenMenus[i + 1]; + + const selectedItem = menu.items.find( + item => item.type === 'submenuItem' && item.id === childMenu.id + ); + + newOpenMenus[i] = { ...menu, selectedItem }; + } + + return new AppMenu(this.menu, newOpenMenus, this.menuItemById); + } + + /** + * Creates a new copy of this AppMenu instance in which the given menu has had + * its selection state cleared. + * + * Additional semantics: + * + * All menus leading up to the given menu item will have their + * selection reset in such a fashion that the selection path + * points to the given menu. + * + * @param menu - The menu which is to have its selection state + * cleared. + */ + public withDeselectedMenu(menu: IMenu) { + const ourMenuIndex = this.openMenus.findIndex(m => m.id === menu.id); + + // The menu that someone is trying to deselect is no longer open + // so no need to worry about selection + if (ourMenuIndex === -1) { + return this; + } + + const ourMenu = this.openMenus[ourMenuIndex]; + const newOpenMenus = this.openMenus.slice(); + + newOpenMenus[ourMenuIndex] = { ...ourMenu, selectedItem: undefined }; + + // Ensure that the path to the menu without an active selection is + // selected. i.e. all menus above should have their selection reset + // to point to the menu which no longer has an active selection. + for (let i = ourMenuIndex - 1; i >= 0; i--) { + const _menu = newOpenMenus[i]; + const childMenu = newOpenMenus[i + 1]; + + const selectedItem = _menu.items.find( + item => item.type === 'submenuItem' && item.id === childMenu.id + ); + + newOpenMenus[i] = { ..._menu, selectedItem }; + } + + return new AppMenu(this.menu, newOpenMenus, this.menuItemById); + } + + /** + * Creates a new copy of this AppMenu instance in which all state + * is reset. Resetting means that only the root menu is open and + * all selection state is cleared. + */ + public withReset() { + return new AppMenu(this.menu, [this.menu], this.menuItemById); + } +} diff --git a/src/commons/model/app-state.ts b/src/commons/model/app-state.ts new file mode 100644 index 0000000..c8c7e38 --- /dev/null +++ b/src/commons/model/app-state.ts @@ -0,0 +1,135 @@ +import { Tip } from './tip'; +import { IMenu } from './app-menu'; +import { WindowState } from './window-state'; +import { PreferencesTab } from './preferences'; + +import { ApplicationTheme } from './application-theme'; + + +/** All of the shared app state. */ +export interface IAppState { + /** + * The current state of the window, ie maximized, minimized full-screen etc. + */ + readonly windowState: WindowState; + + /** + * The current zoom factor of the window represented as a fractional number + * where 1 equals 100% (ie actual size) and 2 represents 200%. + */ + readonly windowZoomFactor: number; + + /** + * A value indicating whether or not the current application + * window has focus. + */ + readonly appIsFocused: boolean; + + readonly showWelcomeFlow: boolean; + readonly currentPopup: Popup | null; + readonly currentFoldout: Foldout | null; + + /** + * A list of currently open menus with their selected items + * in the application menu. + * + * The semantics around what constitues an open menu and how + * selection works is defined by the AppMenu class and the + * individual components transforming that state. + * + * Note that as long as the renderer has received an application + * menu from the main process there will always be one menu + * "open", that is the root menu which can't be closed. In other + * words, a non-zero length appMenuState does not imply that the + * application menu should be visible. Currently thats defined by + * whether the app menu is open as a foldout (see currentFoldout). + * + * Not applicable on macOS unless the in-app application menu has + * been explicitly enabled for testing purposes. + */ + readonly appMenuState: ReadonlyArray; + + readonly errors: ReadonlyArray; + + /** + * The width of the repository sidebar. + * + * This affects the changes and history sidebar + * as well as the first toolbar section which contains + * repo selection on all platforms and repo selection and + * app menu on Windows. + * + * Lives on IAppState as opposed to IRepositoryState + * because it's used in the toolbar as well as the + * repository. + */ + readonly sidebarWidth: number; + + /** The width of the commit summary column in the history view */ + readonly commitSummaryWidth: number; + + /** Whether we should hide the toolbar (and show inverted window controls) */ + readonly titleBarStyle: 'light' | 'dark'; + + /** + * Used to highlight access keys throughout the app when the + * Alt key is pressed. Only applicable on non-macOS platforms. + */ + readonly highlightAccessKeys: boolean; + + /** Whether we should show the update banner */ + readonly isUpdateAvailableBannerVisible: boolean; + + /** Whether we should show a confirmation dialog */ + readonly askForConfirmationOnRepositoryRemoval: boolean; + + /** Whether we should show a confirmation dialog */ + readonly askForConfirmationOnDiscardChanges: boolean; + + /** The currently selected appearance (aka theme) */ + readonly selectedTheme: ApplicationTheme; +} + +export enum PopupType { + Preferences = 1, + About, + Acknowledgements, + TermsAndConditions, +} + +export type Popup = + | { type: PopupType.Preferences; initialSelectedTab?: PreferencesTab } + | { type: PopupType.About } + | { type: PopupType.Acknowledgements } + | { type: PopupType.TermsAndConditions } + ; + +export enum FoldoutType { + AppMenu, + AddMenu, +} + +export interface AppMenuFoldout { + type: FoldoutType.AppMenu; + + /** + * Whether or not the application menu was opened with the Alt key, this + * enables access key highlighting for applicable menu items as well as + * keyboard navigation by pressing access keys. + */ + enableAccessKeyNavigation: boolean; + + /** + * Whether the menu was opened by pressing Alt (or Alt+X where X is an + * access key for one of the top level menu items). This is used as a + * one-time signal to the AppMenu to use some special semantics for + * selection and focus. Specifically it will ensure that the last opened + * menu will receive focus. + */ + openedWithAccessKey?: boolean; +} + +export type Foldout = + | { type: FoldoutType.AddMenu } + | AppMenuFoldout + ; diff --git a/src/commons/model/application-theme.ts b/src/commons/model/application-theme.ts new file mode 100644 index 0000000..8887d0b --- /dev/null +++ b/src/commons/model/application-theme.ts @@ -0,0 +1,54 @@ +/** + * A set of the user-selectable appearances (aka themes) + */ +export enum ApplicationTheme { + Light, + Dark, +} + +/** + * Gets the friendly name of an application theme for use + * in persisting to storage and/or calculating the required + * body class name to set in order to apply the theme. + */ +export function getThemeName(theme: ApplicationTheme): string { + switch (theme) { + case ApplicationTheme.Light: + return 'light'; + case ApplicationTheme.Dark: + return 'dark'; + default: + // return assertNever(theme, `Unknown theme ${theme}`); + } +} + +// The key under which the currently selected theme is persisted +// in localStorage. +const applicationThemeKey = 'theme'; + +/** + * Load the currently selected theme from the persistent + * store (localStorage). If no theme is selected the default + * theme will be returned. + */ +export function getPersistedTheme(): ApplicationTheme { + return localStorage.getItem(applicationThemeKey) === 'dark' + ? ApplicationTheme.Dark + : ApplicationTheme.Light; +} + +/** + * Load the name of the currently selected theme from the persistent + * store (localStorage). If no theme is selected the default + * theme name will be returned. + */ +export function getPersistedThemeName(): string { + return getThemeName(getPersistedTheme()); +} + +/** + * Store the given theme in the persistent store (localStorage). + */ +export function setPersistedTheme(theme: ApplicationTheme) { + localStorage.setItem(applicationThemeKey, getThemeName(theme)); +} diff --git a/src/commons/model/menu-update.ts b/src/commons/model/menu-update.ts new file mode 100644 index 0000000..55de6f2 --- /dev/null +++ b/src/commons/model/menu-update.ts @@ -0,0 +1,174 @@ +import { merge } from '@overflow/core/merge'; + +import { MenuIDs } from '../type'; +import { IAppState } from './app-state'; +import { TipState } from './tip'; +import { AppMenu, MenuItem } from './app-menu'; + +export interface IMenuItemState { + readonly enabled?: boolean; +} + +/** + * Utility class for coalescing updates to menu items + */ +class MenuStateBuilder { + private readonly _state: Map; + + public constructor(state: Map = new Map()) { + this._state = state; + } + + /** + * Returns an Map where each key is a MenuID and the values + * are IMenuItemState instances containing information about + * whether a particular menu item should be enabled/disabled or + * visible/hidden. + */ + public get state() { + return new Map(this._state); + } + + private updateMenuItem( + id: MenuIDs, + state: Pick + ) { + const currentState = this._state.get(id) || {}; + this._state.set(id, merge(currentState, state)); + } + + /** Set the state of the given menu item id to enabled */ + public enable(id: MenuIDs): this { + this.updateMenuItem(id, { enabled: true }); + return this; + } + + /** Set the state of the given menu item id to disabled */ + public disable(id: MenuIDs): this { + this.updateMenuItem(id, { enabled: false }); + return this; + } + + /** Set the enabledness of the given menu item id */ + public setEnabled(id: MenuIDs, enabled: boolean): this { + this.updateMenuItem(id, { enabled }); + return this; + } + + /** + * Create a new state builder by merging the current state with the state from + * the other state builder. This will replace values in `this` with values + * from `other`. + */ + public merge(other: MenuStateBuilder): MenuStateBuilder { + const merged = new Map(this._state); + for (const [key, value] of other._state) { + merged.set(key, value); + } + return new MenuStateBuilder(merged); + } +} + +function menuItemStateEqual(state: IMenuItemState, menuItem: MenuItem) { + if ( + state.enabled !== undefined && + menuItem.type !== 'separator' && + menuItem.enabled !== state.enabled + ) { + return false; + } + + return true; +} + +const allMenuIds: ReadonlyArray = [ + 'save', + 'save-as', + 'preferences', + 'open-in-shell', + 'open-external-editor', + 'about', +]; + +function getAllMenusDisabledBuilder(): MenuStateBuilder { + const menuStateBuilder = new MenuStateBuilder(); + + for (const menuId of allMenuIds) { + menuStateBuilder.disable(menuId); + } + + return menuStateBuilder; +} + +function getMenuState(state: IAppState): Map { + if (state.currentPopup) { + return getAllMenusDisabledBuilder().state; + } + + return getAllMenusEnabledBuilder() + .merge(getInWelcomeFlowBuilder(state.showWelcomeFlow)).state; +} + +function getAllMenusEnabledBuilder(): MenuStateBuilder { + const menuStateBuilder = new MenuStateBuilder(); + for (const menuId of allMenuIds) { + menuStateBuilder.enable(menuId); + } + return menuStateBuilder; +} + +function getInWelcomeFlowBuilder(inWelcomeFlow: boolean): MenuStateBuilder { + const welcomeScopedIds: ReadonlyArray = [ + 'preferences', + 'about', + ]; + + const menuStateBuilder = new MenuStateBuilder(); + if (inWelcomeFlow) { + for (const id of welcomeScopedIds) { + menuStateBuilder.disable(id); + } + } else { + for (const id of welcomeScopedIds) { + menuStateBuilder.enable(id); + } + } + + return menuStateBuilder; +} + +/** + * Update the menu state in the main process. + * + * This function will set the enabledness and visibility of menu items + * in the main process based on the AppState. All changes will be + * batched together into one ipc message. + */ +export function updateMenuState( + state: IAppState, + currentAppMenu: AppMenu | null +) { + const menuState = getMenuState(state); + + // Try to avoid updating sending the IPC message at all + // if we have a current app menu that we can compare against. + if (currentAppMenu) { + for (const [id, menuItemState] of menuState.entries()) { + const appMenuItem = currentAppMenu.getItemById(id); + + if (appMenuItem && menuItemStateEqual(menuItemState, appMenuItem)) { + menuState.delete(id); + } + } + } + + if (menuState.size === 0) { + return; + } + + // because we can't send Map over the wire, we need to convert + // the remaining entries into an array that can be serialized + const array = new Array<{ id: MenuIDs; state: IMenuItemState }>(); + menuState.forEach((value, key) => array.push({ id: key, state: value })); + // ipcUpdateMenuState(array); +} diff --git a/src/commons/model/preferences.ts b/src/commons/model/preferences.ts new file mode 100644 index 0000000..82978d2 --- /dev/null +++ b/src/commons/model/preferences.ts @@ -0,0 +1,4 @@ +export enum PreferencesTab { + Appearance = 0, + Advanced = 1, +} diff --git a/src/commons/model/tip.ts b/src/commons/model/tip.ts new file mode 100644 index 0000000..e89ec00 --- /dev/null +++ b/src/commons/model/tip.ts @@ -0,0 +1,37 @@ +export enum TipState { + Unknown, + Unborn, + Detached, + Valid, +} + +export interface IUnknownRepository { + readonly kind: TipState.Unknown; +} + +export interface IUnbornRepository { + readonly kind: TipState.Unborn; + + /** + * The symbolic reference that the unborn repository points to currently. + * + * Typically this will be "master" but a user can easily create orphaned + * branches externally. + */ + readonly ref: string; +} + +export interface IDetachedHead { + readonly kind: TipState.Detached; + /** + * The commit identifier of the current tip of the repository. + */ + readonly currentSha: string; +} + + +export type Tip = + | IUnknownRepository + | IUnbornRepository + | IDetachedHead + ; diff --git a/@overflow/core/window-state.ts b/src/commons/model/window-state.ts similarity index 100% rename from @overflow/core/window-state.ts rename to src/commons/model/window-state.ts diff --git a/src/commons/service/electron-proxy.service.ts b/src/commons/service/electron-proxy.service.ts index b6ddc6b..eb6d1e8 100644 --- a/src/commons/service/electron-proxy.service.ts +++ b/src/commons/service/electron-proxy.service.ts @@ -17,10 +17,10 @@ export class ElectronProxyService { private store: Store, private launchService: LaunchService, ) { - this.bindIPCRenderer(); + this.bindIpcEventHandlers(); } - private bindIPCRenderer(): void { + private bindIpcEventHandlers(): void { ipcRenderer.on('menu-event', (event: Electron.IpcMessageEvent, { name }: { name: MenuEvent }) => { @@ -29,9 +29,9 @@ export class ElectronProxyService { ipcRenderer.on('launch-timing-stats', (event: Electron.IpcMessageEvent, { state }: { state: LaunchState }) => { - console.info(`App ready time: ${state.mainReadyTime}ms`) - console.info(`Load time: ${state.loadTime}ms`) - console.info(`Renderer ready time: ${state.rendererReadyTime}ms`) + console.info(`App ready time: ${state.mainReadyTime}ms`); + console.info(`Load time: ${state.loadTime}ms`); + console.info(`Renderer ready time: ${state.rendererReadyTime}ms`); this.launchService.save(state).pipe( map((id: number) => { @@ -42,11 +42,15 @@ export class ElectronProxyService { take(1), ).subscribe(); } - ) + ); } public sendReady(time: number): void { ipcRenderer.send('renderer-ready', time); } + public getAppMenu(): void { + ipcRenderer.send('get-app-menu'); + } + } diff --git a/src/commons/type/menu-event.ts b/src/commons/type/menu-event.ts index 591adf5..9c52929 100644 --- a/src/commons/type/menu-event.ts +++ b/src/commons/type/menu-event.ts @@ -1,4 +1,7 @@ export type MenuEvent = + | 'save' + | 'save-as' + | 'show-preferences' | 'show-about' | 'open-external-editor' | 'select-all'; diff --git a/src/commons/type/menu-ids.ts b/src/commons/type/menu-ids.ts index fe97435..9953eca 100644 --- a/src/commons/type/menu-ids.ts +++ b/src/commons/type/menu-ids.ts @@ -1,4 +1,6 @@ export type MenuIDs = + | 'save' + | 'save-as' | 'preferences' | 'open-in-shell' | 'open-external-editor' diff --git a/src/electron/app-window.ts b/src/electron/app-window.ts index df67fa0..419bb04 100644 --- a/src/electron/app-window.ts +++ b/src/electron/app-window.ts @@ -7,12 +7,13 @@ import { Subscription, PartialObserver } from 'rxjs'; import { Emitter } from '@overflow/core/emitter'; import { encodePathAsUrl } from '@overflow/core/path'; -import { registerWindowStateChangedEvents } from '@overflow/core/window-state'; +import { registerWindowStateChangedEvents } from '../commons/model/window-state'; import { URLActionType } from '@overflow/core/parse-app-url'; import { now } from '@overflow/core/now'; import { MenuEvent } from '../commons/type'; import { LaunchState } from '../commons/model'; +import { menuFromElectronMenu } from '../commons/model/app-menu'; let windowStateKeeper: any | null = null; @@ -223,13 +224,13 @@ export class AppWindow { } /** Send the app menu to the renderer. */ - // public sendAppMenu() { - // const appMenu = Menu.getApplicationMenu(); - // if (appMenu) { - // const menu = menuFromElectronMenu(appMenu); - // this.window.webContents.send('app-menu', { menu }); - // } - // } + public sendAppMenu() { + const appMenu = Menu.getApplicationMenu(); + if (appMenu) { + const menu = menuFromElectronMenu(appMenu); + this.window.webContents.send('app-menu', { menu }); + } + } /** Send a certificate error to the renderer. */ public sendCertificateError( diff --git a/src/electron/main.ts b/src/electron/main.ts index 6bbd9fa..5331900 100644 --- a/src/electron/main.ts +++ b/src/electron/main.ts @@ -15,6 +15,10 @@ import { AppWindow } from './app-window'; import { handleSquirrelEvent } from './squirrel-updater'; import { openDirectorySafe } from './shell'; +import { buildDefaultMenu } from './menu/build-default'; +import { MenuEvent } from '../commons/type'; +import { findMenuItemByID } from './menu/find-menu-item'; +import { IMenuItemState } from '../commons/model/menu-update'; enableSourceMaps(); @@ -193,83 +197,81 @@ app.on('ready', () => { createWindow(); - // let menu = buildDefaultMenu(); - // Menu.setApplicationMenu(menu); + let menu = buildDefaultMenu(); + Menu.setApplicationMenu(menu); - // ipcMain.on( - // 'update-preferred-app-menu-item-labels', - // ( - // event: Electron.IpcMessageEvent, - // labels: { editor?: string; pullRequestLabel?: string; shell: string } - // ) => { - // menu = buildDefaultMenu( - // labels.editor, - // labels.shell, - // labels.pullRequestLabel - // ); - // Menu.setApplicationMenu(menu); - // if (mainWindow) { - // mainWindow.sendAppMenu(); - // } - // } - // ); + ipcMain.on( + 'update-preferred-app-menu-item-labels', + ( + event: Electron.IpcMessageEvent, + labels: { editor?: string; pullRequestLabel?: string; shell: string } + ) => { + menu = buildDefaultMenu( + labels.shell, + ); + Menu.setApplicationMenu(menu); + if (mainWindow) { + mainWindow.sendAppMenu(); + } + } + ); - // ipcMain.on('menu-event', (event: Electron.IpcMessageEvent, args: any[]) => { - // const { name }: { name: MenuEvent } = event as any; - // if (mainWindow) { - // mainWindow.sendMenuEvent(name); - // } - // }); + ipcMain.on('menu-event', (event: Electron.IpcMessageEvent, args: any[]) => { + const { name }: { name: MenuEvent } = event as any; + if (mainWindow) { + mainWindow.sendMenuEvent(name); + } + }); /** * An event sent by the renderer asking that the menu item with the given id * is executed (ie clicked). */ - // ipcMain.on( - // 'execute-menu-item', - // (event: Electron.IpcMessageEvent, { id }: { id: string }) => { - // const menuItem = findMenuItemByID(menu, id); - // if (menuItem) { - // const window = BrowserWindow.fromWebContents(event.sender); - // const fakeEvent = { preventDefault: () => { }, sender: event.sender }; - // menuItem.click(fakeEvent, window, event.sender); - // } - // } - // ); + ipcMain.on( + 'execute-menu-item', + (event: Electron.IpcMessageEvent, { id }: { id: string }) => { + const menuItem = findMenuItemByID(menu, id); + if (menuItem) { + const window = BrowserWindow.fromWebContents(event.sender); + const fakeEvent = { preventDefault: () => { }, sender: event.sender }; + menuItem.click(fakeEvent, window, event.sender); + } + } + ); - // ipcMain.on( - // 'update-menu-state', - // ( - // event: Electron.IpcMessageEvent, - // items: Array<{ id: string; state: IMenuItemState }> - // ) => { - // let sendMenuChangedEvent = false; + ipcMain.on( + 'update-menu-state', + ( + event: Electron.IpcMessageEvent, + items: Array<{ id: string; state: IMenuItemState }> + ) => { + let sendMenuChangedEvent = false; - // for (const item of items) { - // const { id, state } = item; - // const menuItem = findMenuItemByID(menu, id); + for (const item of items) { + const { id, state } = item; + const menuItem = findMenuItemByID(menu, id); - // if (menuItem) { - // // Only send the updated app menu when the state actually changes - // // or we might end up introducing a never ending loop between - // // the renderer and the main process - // if ( - // state.enabled !== undefined && - // menuItem.enabled !== state.enabled - // ) { - // menuItem.enabled = state.enabled; - // sendMenuChangedEvent = true; - // } - // } else { - // fatalError(`Unknown menu id: ${id}`); - // } - // } + if (menuItem) { + // Only send the updated app menu when the state actually changes + // or we might end up introducing a never ending loop between + // the renderer and the main process + if ( + state.enabled !== undefined && + menuItem.enabled !== state.enabled + ) { + menuItem.enabled = state.enabled; + sendMenuChangedEvent = true; + } + } else { + // fatalError(`Unknown menu id: ${id}`); + } + } - // if (sendMenuChangedEvent && mainWindow) { - // mainWindow.sendAppMenu(); - // } - // } - // ); + if (sendMenuChangedEvent && mainWindow) { + mainWindow.sendAppMenu(); + } + } + ); // ipcMain.on( // 'show-contextual-menu', @@ -287,11 +289,11 @@ app.on('ready', () => { * An event sent by the renderer asking for a copy of the current * application menu. */ - // ipcMain.on('get-app-menu', () => { - // if (mainWindow) { - // mainWindow.sendAppMenu(); - // } - // }); + ipcMain.on('get-app-menu', () => { + if (mainWindow) { + mainWindow.sendAppMenu(); + } + }); // ipcMain.on( // 'show-certificate-trust-dialog', diff --git a/src/electron/menu/build-default.ts b/src/electron/menu/build-default.ts new file mode 100644 index 0000000..bf14f5d --- /dev/null +++ b/src/electron/menu/build-default.ts @@ -0,0 +1,310 @@ +import { Menu, ipcMain, shell, app } from 'electron'; +import { ensureDir } from 'fs-extra'; + +import { ensureItemIds } from './ensure-item-ids'; +import { MenuEvent } from '../../commons/type/menu-event'; +import { openDirectorySafe } from '../shell'; + +const defaultShellLabel = __DARWIN__ + ? 'Open in Terminal' + : 'Open in Command Prompt'; + +export function buildDefaultMenu( + shellLabel: string = defaultShellLabel, +): Electron.Menu { + const template = new Array(); + const separator: Electron.MenuItemConstructorOptions = { type: 'separator' }; + + if (__DARWIN__) { + template.push({ + label: 'overFlow Scanner', + submenu: [ + { + label: 'About overFlow Scanner', + click: emit('show-about'), + id: 'about', + }, + separator, + { + label: 'Preferences…', + id: 'preferences', + accelerator: 'CmdOrCtrl+,', + click: emit('show-preferences'), + }, + separator, + { + role: 'services', + submenu: [], + }, + separator, + { role: 'hide' }, + { role: 'hideothers' }, + { role: 'unhide' }, + separator, + { role: 'quit' }, + ], + }); + } + + const fileMenu: Electron.MenuItemConstructorOptions = { + label: __DARWIN__ ? 'File' : '&File', + submenu: [ + { + label: __DARWIN__ ? 'Save…' : 'Save…', + id: 'new-repository', + click: emit('save'), + accelerator: 'CmdOrCtrl+S', + }, + { + label: __DARWIN__ ? 'Save as…' : 'Save as…', + id: 'add-local-repository', + accelerator: 'CmdOrCtrl+Shift+S', + click: emit('save-as'), + } + ], + }; + + if (!__DARWIN__) { + const fileItems = fileMenu.submenu as Electron.MenuItemConstructorOptions[]; + + fileItems.push( + separator, + { + label: '&Options…', + id: 'preferences', + accelerator: 'CmdOrCtrl+,', + click: emit('show-preferences'), + }, + separator, + { role: 'quit' } + ); + } + + template.push(fileMenu); + + template.push({ + label: __DARWIN__ ? 'Edit' : '&Edit', + submenu: [ + { role: 'undo', label: __DARWIN__ ? 'Undo' : '&Undo' }, + { role: 'redo', label: __DARWIN__ ? 'Redo' : '&Redo' }, + separator, + { role: 'cut', label: __DARWIN__ ? 'Cut' : 'Cu&t' }, + { role: 'copy', label: __DARWIN__ ? 'Copy' : '&Copy' }, + { role: 'paste', label: __DARWIN__ ? 'Paste' : '&Paste' }, + { + label: __DARWIN__ ? 'Select All' : 'Select &all', + accelerator: 'CmdOrCtrl+A', + click: emit('select-all'), + }, + ], + }); + + template.push({ + label: __DARWIN__ ? 'View' : '&View', + submenu: [ + { + label: __DARWIN__ ? 'Toggle Full Screen' : 'Toggle &full screen', + role: 'togglefullscreen', + }, + separator, + { + label: __DARWIN__ ? 'Reset Zoom' : 'Reset zoom', + accelerator: 'CmdOrCtrl+0', + click: zoom(ZoomDirection.Reset), + }, + { + label: __DARWIN__ ? 'Zoom In' : 'Zoom in', + accelerator: 'CmdOrCtrl+=', + click: zoom(ZoomDirection.In), + }, + { + label: __DARWIN__ ? 'Zoom Out' : 'Zoom out', + accelerator: 'CmdOrCtrl+-', + click: zoom(ZoomDirection.Out), + }, + separator, + { + label: '&Reload', + id: 'reload-window', + // Ctrl+Alt is interpreted as AltGr on international keyboards and this + // can clash with other shortcuts. We should always use Ctrl+Shift for + // chorded shortcuts, but this menu item is not a user-facing feature + // so we are going to keep this one around and save Ctrl+Shift+R for + // a different shortcut in the future... + accelerator: 'CmdOrCtrl+Alt+R', + click(item: any, focusedWindow: Electron.BrowserWindow) { + if (focusedWindow) { + focusedWindow.reload(); + } + }, + visible: __RELEASE_CHANNEL__ === 'development', + }, + { + id: 'show-devtools', + label: __DARWIN__ + ? 'Toggle Developer Tools' + : '&Toggle developer tools', + accelerator: (() => { + return __DARWIN__ ? 'Alt+Command+I' : 'Ctrl+Shift+I'; + })(), + click(item: any, focusedWindow: Electron.BrowserWindow) { + if (focusedWindow) { + focusedWindow.webContents.toggleDevTools(); + } + }, + }, + ], + }); + + if (__DARWIN__) { + template.push({ + role: 'window', + submenu: [ + { role: 'minimize' }, + { role: 'zoom' }, + { role: 'close' }, + separator, + { role: 'front' }, + ], + }); + } + + const submitIssueItem: Electron.MenuItemConstructorOptions = { + label: __DARWIN__ ? 'Report Issue…' : 'Report issue…', + click() { + shell.openExternal('https://github.com/desktop/desktop/issues/new/choose'); + }, + }; + + const contactSupportItem: Electron.MenuItemConstructorOptions = { + label: __DARWIN__ ? 'Contact overFlow Scanner Support…' : '&Contact overFlow Scanner support…', + click() { + shell.openExternal( + `https://github.com/contact?from_desktop_app=1&app_version=${app.getVersion()}` + ); + }, + }; + + const showUserGuides: Electron.MenuItemConstructorOptions = { + label: 'Show User Guides', + click() { + shell.openExternal('https://help.github.com/desktop/guides/'); + }, + }; + + const helpItems = [ + submitIssueItem, + contactSupportItem, + showUserGuides, + ]; + + if (__DARWIN__) { + template.push({ + role: 'help', + submenu: helpItems, + }); + } else { + template.push({ + label: '&Help', + submenu: [ + ...helpItems, + separator, + { + label: '&About overFlow Scanner', + click: emit('show-about'), + id: 'about', + }, + ], + }); + } + + ensureItemIds(template); + + return Menu.buildFromTemplate(template); +} + +type ClickHandler = ( + menuItem: Electron.MenuItem, + browserWindow: Electron.BrowserWindow, + event: Electron.Event +) => void; + +/** + * Utility function returning a Click event handler which, when invoked, emits + * the provided menu event over IPC. + */ +function emit(name: MenuEvent): ClickHandler { + return (menuItem, window) => { + if (window) { + window.webContents.send('menu-event', { name }); + } else { + ipcMain.emit('menu-event', { name }); + } + }; +} + +enum ZoomDirection { + Reset, + In, + Out, +} + +/** The zoom steps that we support, these factors must sorted */ +const ZoomInFactors = [1, 1.1, 1.25, 1.5, 1.75, 2]; +const ZoomOutFactors = ZoomInFactors.slice().reverse(); + +/** + * Returns the element in the array that's closest to the value parameter. Note + * that this function will throw if passed an empty array. + */ +function findClosestValue(arr: Array, value: number) { + return arr.reduce((previous, current) => { + return Math.abs(current - value) < Math.abs(previous - value) + ? current + : previous; + }); +} + +/** + * Figure out the next zoom level for the given direction and alert the renderer + * about a change in zoom factor if necessary. + */ +function zoom(direction: ZoomDirection): ClickHandler { + return (menuItem, window) => { + if (!window) { + return; + } + + const { webContents } = window; + + if (direction === ZoomDirection.Reset) { + webContents.setZoomFactor(1); + webContents.send('zoom-factor-changed', 1); + } else { + webContents.getZoomFactor(rawZoom => { + const zoomFactors = + direction === ZoomDirection.In ? ZoomInFactors : ZoomOutFactors; + + // So the values that we get from getZoomFactor are floating point + // precision numbers from chromium that don't always round nicely so + // we'll have to do a little trick to figure out which of our supported + // zoom factors the value is referring to. + const currentZoom = findClosestValue(zoomFactors, rawZoom); + + const nextZoomLevel = zoomFactors.find( + f => + direction === ZoomDirection.In ? f > currentZoom : f < currentZoom + ); + + // If we couldn't find a zoom level (likely due to manual manipulation + // of the zoom factor in devtools) we'll just snap to the closest valid + // factor we've got. + const newZoom = + nextZoomLevel === undefined ? currentZoom : nextZoomLevel; + + webContents.setZoomFactor(newZoom); + webContents.send('zoom-factor-changed', newZoom); + }); + } + }; +} diff --git a/src/electron/menu/ensure-item-ids.ts b/src/electron/menu/ensure-item-ids.ts new file mode 100644 index 0000000..eb6a1e1 --- /dev/null +++ b/src/electron/menu/ensure-item-ids.ts @@ -0,0 +1,44 @@ +function getItemId(template: Electron.MenuItemConstructorOptions) { + return template.id || template.label || template.role || 'unknown'; +} + +/** + * Ensures that all menu items in the given template are assigned an id + * by recursively traversing the template and mutating items in place. + * + * Items which already have an id are left alone, the other get a unique, + * but consistent id based on their label or role and their position in + * the menu hierarchy. + * + * Note that this does not do anything to prevent the case where items have + * explicitly been given duplicate ids. + */ +export function ensureItemIds( + template: ReadonlyArray, + prefix = '@', + seenIds = new Set() +) { + for (const item of template) { + let counter = 0; + let id = item.id; + + // Automatically generate an id if one hasn't been explicitly provided + if (!id) { + // Ensure that multiple items with the same key gets suffixed with a number + // i.e. @.separator, @.separator1 @.separator2 etc + do { + id = `${prefix}.${getItemId(item)}${counter++ || ''}`; + } while (seenIds.has(id)); + } + + item.id = id; + seenIds.add(id); + + if (item.submenu) { + const subMenuTemplate = item.submenu as ReadonlyArray< + Electron.MenuItemConstructorOptions + >; + ensureItemIds(subMenuTemplate, item.id, seenIds); + } + } +} diff --git a/src/electron/menu/find-menu-item.ts b/src/electron/menu/find-menu-item.ts new file mode 100644 index 0000000..606f74e --- /dev/null +++ b/src/electron/menu/find-menu-item.ts @@ -0,0 +1,24 @@ +/** Find the menu item with the given ID. */ +export function findMenuItemByID( + menu: Electron.Menu, + id: string +): Electron.MenuItem | null { + const items = menu.items; + for (const item of items) { + // The electron type definition doesn't include the `id` field :( + if ((item as any).id === id) { + return item; + } + + // We're assuming we're working with an already created menu. + const submenu = item.submenu as Electron.Menu; + if (submenu) { + const found = findMenuItemByID(submenu, id); + if (found) { + return found; + } + } + } + + return null; +} diff --git a/src/electron/squirrel-updater.ts b/src/electron/squirrel-updater.ts index 11d7b43..e5ab982 100644 --- a/src/electron/squirrel-updater.ts +++ b/src/electron/squirrel-updater.ts @@ -143,7 +143,7 @@ async function updateShortcut(): Promise { const desktopShortcutPath = Path.join( homeDirectory, 'Desktop', - 'GitHub Desktop.lnk' + 'overFlow Scanner.lnk' ); const exists = await pathExists(desktopShortcutPath); const locations: ShortcutLocations = exists diff --git a/src/tsconfig.app.json b/src/tsconfig.app.json index 632c154..4a9f936 100644 --- a/src/tsconfig.app.json +++ b/src/tsconfig.app.json @@ -1,11 +1,10 @@ { "extends": "../tsconfig.json", "compilerOptions": { - "outDir": "../out-tsc/app", - "types": ["node"] + "outDir": "../out-tsc/app" }, "exclude": [ "src/test.ts", "**/*.spec.ts" ] -} +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index ef8675d..7197fac 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,9 @@ "typeRoots": [ "node_modules/@types" ], + "types": [ + "node" + ], "lib": [ "es2015", "es2017", diff --git a/tslint.json b/tslint.json index 3ea984c..9c5e830 100644 --- a/tslint.json +++ b/tslint.json @@ -46,7 +46,7 @@ "no-arg": true, "no-bitwise": true, "no-console": [ - true, + false, "debug", "info", "time", @@ -127,4 +127,4 @@ "component-class-suffix": true, "directive-class-suffix": true } -} +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index b5f95bd..b7bdfea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -243,6 +243,12 @@ semver "^5.3.0" semver-intersect "^1.1.2" +"@types/fs-extra@^5.0.4": + version "5.0.4" + resolved "https://nexus.loafle.net/repository/npm-all/@types/fs-extra/-/fs-extra-5.0.4.tgz#b971134d162cc0497d221adde3dbb67502225599" + dependencies: + "@types/node" "*" + "@types/jasmine@*", "@types/jasmine@~2.8.6": version "2.8.8" resolved "https://nexus.loafle.net/repository/npm-all/@types/jasmine/-/jasmine-2.8.8.tgz#bf53a7d193ea8b03867a38bfdb4fbb0e0bf066c9" @@ -257,6 +263,10 @@ version "4.14.116" resolved "https://nexus.loafle.net/repository/npm-all/@types/lodash/-/lodash-4.14.116.tgz#5ccf215653e3e8c786a58390751033a9adca0eb9" +"@types/node@*": + version "10.7.1" + resolved "https://nexus.loafle.net/repository/npm-all/@types/node/-/node-10.7.1.tgz#b704d7c259aa40ee052eec678758a68d07132a2e" + "@types/node@^6.0.46": version "6.0.116" resolved "https://nexus.loafle.net/repository/npm-all/@types/node/-/node-6.0.116.tgz#2f9cd62b4ecc4927e3942e2655c182eecf5b45f1" @@ -265,9 +275,9 @@ version "8.10.25" resolved "https://nexus.loafle.net/repository/npm-all/@types/node/-/node-8.10.25.tgz#801fe4e39372cef18f268db880a5fbfcf71adc7e" -"@types/node@~8.9.4": - version "8.9.5" - resolved "https://nexus.loafle.net/repository/npm-all/@types/node/-/node-8.9.5.tgz#162b864bc70be077e6db212b322754917929e976" +"@types/node@^8.10.4": + version "8.10.26" + resolved "https://nexus.loafle.net/repository/npm-all/@types/node/-/node-8.10.26.tgz#950e3d4e6b316ba6e1ae4e84d9155aba67f88c2f" "@types/q@^0.0.32": version "0.0.32"