ing
This commit is contained in:
parent
66bb90dda6
commit
7c915f6d5f
10
@overflow/core/merge.ts
Normal file
10
@overflow/core/merge.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
/** Create a copy of an object by merging it with a subset of its properties. */
|
||||||
|
export function merge<T, K extends keyof T>(obj: T, subset: Pick<T, K>): T {
|
||||||
|
const copy = Object.assign({}, obj);
|
||||||
|
for (const k in subset) {
|
||||||
|
if (subset[k]) {
|
||||||
|
copy[k] = subset[k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
|
@ -39,9 +39,10 @@
|
||||||
"@ngrx/schematics": "^6.1.0",
|
"@ngrx/schematics": "^6.1.0",
|
||||||
"@ngrx/store": "^6.1.0",
|
"@ngrx/store": "^6.1.0",
|
||||||
"@ngrx/store-devtools": "^6.1.0",
|
"@ngrx/store-devtools": "^6.1.0",
|
||||||
|
"@types/fs-extra": "^5.0.4",
|
||||||
"@types/jasmine": "~2.8.6",
|
"@types/jasmine": "~2.8.6",
|
||||||
"@types/jasminewd2": "~2.0.3",
|
"@types/jasminewd2": "~2.0.3",
|
||||||
"@types/node": "~8.9.4",
|
"@types/node": "^8.10.4",
|
||||||
"awesome-typescript-loader": "^5.2.0",
|
"awesome-typescript-loader": "^5.2.0",
|
||||||
"codelyzer": "~4.2.1",
|
"codelyzer": "~4.2.1",
|
||||||
"core-js": "^2.5.4",
|
"core-js": "^2.5.4",
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
<div class="ui-g">
|
<div class="ui-g">
|
||||||
<div class="ui-g-12 ui-nopad">
|
<div class="ui-g-12 ui-nopad">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>Home works!!</h1>
|
<h1>Home works!!!!</h1>
|
||||||
<p-panel #content [showHeader]="false" class="block-panel">
|
<p-panel #content [showHeader]="false" class="block-panel">
|
||||||
<div class="ui-g" dir="rtl">
|
<div class="ui-g" dir="rtl">
|
||||||
<button class="ui-button-width-fit" type="button" label="Discovery" icon="ui-icon-search" pButton></button>
|
<button class="ui-button-width-fit" type="button" label="Discovery" icon="ui-icon-search" pButton></button>
|
||||||
|
|
665
src/commons/model/app-menu.ts
Normal file
665
src/commons/model/app-menu.ts
Normal file
|
@ -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<MenuItem>;
|
||||||
|
|
||||||
|
/** 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<string>();
|
||||||
|
|
||||||
|
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<string, MenuItem>()
|
||||||
|
): Map<string, MenuItem> {
|
||||||
|
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<MenuItem>
|
||||||
|
): 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<IMenu>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, MenuItem>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<IMenu>,
|
||||||
|
menuItemById: Map<string, MenuItem>
|
||||||
|
) {
|
||||||
|
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<IMenu>();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
135
src/commons/model/app-state.ts
Normal file
135
src/commons/model/app-state.ts
Normal file
|
@ -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<IMenu>;
|
||||||
|
|
||||||
|
readonly errors: ReadonlyArray<Error>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
;
|
54
src/commons/model/application-theme.ts
Normal file
54
src/commons/model/application-theme.ts
Normal file
|
@ -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));
|
||||||
|
}
|
174
src/commons/model/menu-update.ts
Normal file
174
src/commons/model/menu-update.ts
Normal file
|
@ -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<MenuIDs, IMenuItemState>;
|
||||||
|
|
||||||
|
public constructor(state: Map<MenuIDs, IMenuItemState> = 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<MenuIDs, IMenuItemState>(this._state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateMenuItem<K extends keyof IMenuItemState>(
|
||||||
|
id: MenuIDs,
|
||||||
|
state: Pick<IMenuItemState, K>
|
||||||
|
) {
|
||||||
|
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<MenuIDs, IMenuItemState>(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<MenuIDs> = [
|
||||||
|
'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<MenuIDs, IMenuItemState> {
|
||||||
|
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<MenuIDs> = [
|
||||||
|
'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);
|
||||||
|
}
|
4
src/commons/model/preferences.ts
Normal file
4
src/commons/model/preferences.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
export enum PreferencesTab {
|
||||||
|
Appearance = 0,
|
||||||
|
Advanced = 1,
|
||||||
|
}
|
37
src/commons/model/tip.ts
Normal file
37
src/commons/model/tip.ts
Normal file
|
@ -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
|
||||||
|
;
|
|
@ -17,10 +17,10 @@ export class ElectronProxyService {
|
||||||
private store: Store<any>,
|
private store: Store<any>,
|
||||||
private launchService: LaunchService,
|
private launchService: LaunchService,
|
||||||
) {
|
) {
|
||||||
this.bindIPCRenderer();
|
this.bindIpcEventHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
private bindIPCRenderer(): void {
|
private bindIpcEventHandlers(): void {
|
||||||
ipcRenderer.on('menu-event',
|
ipcRenderer.on('menu-event',
|
||||||
(event: Electron.IpcMessageEvent, { name }: { name: MenuEvent }) => {
|
(event: Electron.IpcMessageEvent, { name }: { name: MenuEvent }) => {
|
||||||
|
|
||||||
|
@ -29,9 +29,9 @@ export class ElectronProxyService {
|
||||||
|
|
||||||
ipcRenderer.on('launch-timing-stats',
|
ipcRenderer.on('launch-timing-stats',
|
||||||
(event: Electron.IpcMessageEvent, { state }: { state: LaunchState }) => {
|
(event: Electron.IpcMessageEvent, { state }: { state: LaunchState }) => {
|
||||||
console.info(`App ready time: ${state.mainReadyTime}ms`)
|
console.info(`App ready time: ${state.mainReadyTime}ms`);
|
||||||
console.info(`Load time: ${state.loadTime}ms`)
|
console.info(`Load time: ${state.loadTime}ms`);
|
||||||
console.info(`Renderer ready time: ${state.rendererReadyTime}ms`)
|
console.info(`Renderer ready time: ${state.rendererReadyTime}ms`);
|
||||||
|
|
||||||
this.launchService.save(state).pipe(
|
this.launchService.save(state).pipe(
|
||||||
map((id: number) => {
|
map((id: number) => {
|
||||||
|
@ -42,11 +42,15 @@ export class ElectronProxyService {
|
||||||
take(1),
|
take(1),
|
||||||
).subscribe();
|
).subscribe();
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sendReady(time: number): void {
|
public sendReady(time: number): void {
|
||||||
ipcRenderer.send('renderer-ready', time);
|
ipcRenderer.send('renderer-ready', time);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getAppMenu(): void {
|
||||||
|
ipcRenderer.send('get-app-menu');
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
export type MenuEvent =
|
export type MenuEvent =
|
||||||
|
| 'save'
|
||||||
|
| 'save-as'
|
||||||
|
| 'show-preferences'
|
||||||
| 'show-about'
|
| 'show-about'
|
||||||
| 'open-external-editor'
|
| 'open-external-editor'
|
||||||
| 'select-all';
|
| 'select-all';
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
export type MenuIDs =
|
export type MenuIDs =
|
||||||
|
| 'save'
|
||||||
|
| 'save-as'
|
||||||
| 'preferences'
|
| 'preferences'
|
||||||
| 'open-in-shell'
|
| 'open-in-shell'
|
||||||
| 'open-external-editor'
|
| 'open-external-editor'
|
||||||
|
|
|
@ -7,12 +7,13 @@ import { Subscription, PartialObserver } from 'rxjs';
|
||||||
import { Emitter } from '@overflow/core/emitter';
|
import { Emitter } from '@overflow/core/emitter';
|
||||||
|
|
||||||
import { encodePathAsUrl } from '@overflow/core/path';
|
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 { URLActionType } from '@overflow/core/parse-app-url';
|
||||||
import { now } from '@overflow/core/now';
|
import { now } from '@overflow/core/now';
|
||||||
|
|
||||||
import { MenuEvent } from '../commons/type';
|
import { MenuEvent } from '../commons/type';
|
||||||
import { LaunchState } from '../commons/model';
|
import { LaunchState } from '../commons/model';
|
||||||
|
import { menuFromElectronMenu } from '../commons/model/app-menu';
|
||||||
|
|
||||||
let windowStateKeeper: any | null = null;
|
let windowStateKeeper: any | null = null;
|
||||||
|
|
||||||
|
@ -223,13 +224,13 @@ export class AppWindow {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Send the app menu to the renderer. */
|
/** Send the app menu to the renderer. */
|
||||||
// public sendAppMenu() {
|
public sendAppMenu() {
|
||||||
// const appMenu = Menu.getApplicationMenu();
|
const appMenu = Menu.getApplicationMenu();
|
||||||
// if (appMenu) {
|
if (appMenu) {
|
||||||
// const menu = menuFromElectronMenu(appMenu);
|
const menu = menuFromElectronMenu(appMenu);
|
||||||
// this.window.webContents.send('app-menu', { menu });
|
this.window.webContents.send('app-menu', { menu });
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
|
|
||||||
/** Send a certificate error to the renderer. */
|
/** Send a certificate error to the renderer. */
|
||||||
public sendCertificateError(
|
public sendCertificateError(
|
||||||
|
|
|
@ -15,6 +15,10 @@ import { AppWindow } from './app-window';
|
||||||
import { handleSquirrelEvent } from './squirrel-updater';
|
import { handleSquirrelEvent } from './squirrel-updater';
|
||||||
|
|
||||||
import { openDirectorySafe } from './shell';
|
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();
|
enableSourceMaps();
|
||||||
|
|
||||||
|
@ -193,83 +197,81 @@ app.on('ready', () => {
|
||||||
|
|
||||||
createWindow();
|
createWindow();
|
||||||
|
|
||||||
// let menu = buildDefaultMenu();
|
let menu = buildDefaultMenu();
|
||||||
// Menu.setApplicationMenu(menu);
|
Menu.setApplicationMenu(menu);
|
||||||
|
|
||||||
// ipcMain.on(
|
ipcMain.on(
|
||||||
// 'update-preferred-app-menu-item-labels',
|
'update-preferred-app-menu-item-labels',
|
||||||
// (
|
(
|
||||||
// event: Electron.IpcMessageEvent,
|
event: Electron.IpcMessageEvent,
|
||||||
// labels: { editor?: string; pullRequestLabel?: string; shell: string }
|
labels: { editor?: string; pullRequestLabel?: string; shell: string }
|
||||||
// ) => {
|
) => {
|
||||||
// menu = buildDefaultMenu(
|
menu = buildDefaultMenu(
|
||||||
// labels.editor,
|
labels.shell,
|
||||||
// labels.shell,
|
);
|
||||||
// labels.pullRequestLabel
|
Menu.setApplicationMenu(menu);
|
||||||
// );
|
if (mainWindow) {
|
||||||
// Menu.setApplicationMenu(menu);
|
mainWindow.sendAppMenu();
|
||||||
// if (mainWindow) {
|
}
|
||||||
// mainWindow.sendAppMenu();
|
}
|
||||||
// }
|
);
|
||||||
// }
|
|
||||||
// );
|
|
||||||
|
|
||||||
// ipcMain.on('menu-event', (event: Electron.IpcMessageEvent, args: any[]) => {
|
ipcMain.on('menu-event', (event: Electron.IpcMessageEvent, args: any[]) => {
|
||||||
// const { name }: { name: MenuEvent } = event as any;
|
const { name }: { name: MenuEvent } = event as any;
|
||||||
// if (mainWindow) {
|
if (mainWindow) {
|
||||||
// mainWindow.sendMenuEvent(name);
|
mainWindow.sendMenuEvent(name);
|
||||||
// }
|
}
|
||||||
// });
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An event sent by the renderer asking that the menu item with the given id
|
* An event sent by the renderer asking that the menu item with the given id
|
||||||
* is executed (ie clicked).
|
* is executed (ie clicked).
|
||||||
*/
|
*/
|
||||||
// ipcMain.on(
|
ipcMain.on(
|
||||||
// 'execute-menu-item',
|
'execute-menu-item',
|
||||||
// (event: Electron.IpcMessageEvent, { id }: { id: string }) => {
|
(event: Electron.IpcMessageEvent, { id }: { id: string }) => {
|
||||||
// const menuItem = findMenuItemByID(menu, id);
|
const menuItem = findMenuItemByID(menu, id);
|
||||||
// if (menuItem) {
|
if (menuItem) {
|
||||||
// const window = BrowserWindow.fromWebContents(event.sender);
|
const window = BrowserWindow.fromWebContents(event.sender);
|
||||||
// const fakeEvent = { preventDefault: () => { }, sender: event.sender };
|
const fakeEvent = { preventDefault: () => { }, sender: event.sender };
|
||||||
// menuItem.click(fakeEvent, window, event.sender);
|
menuItem.click(fakeEvent, window, event.sender);
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
// );
|
);
|
||||||
|
|
||||||
// ipcMain.on(
|
ipcMain.on(
|
||||||
// 'update-menu-state',
|
'update-menu-state',
|
||||||
// (
|
(
|
||||||
// event: Electron.IpcMessageEvent,
|
event: Electron.IpcMessageEvent,
|
||||||
// items: Array<{ id: string; state: IMenuItemState }>
|
items: Array<{ id: string; state: IMenuItemState }>
|
||||||
// ) => {
|
) => {
|
||||||
// let sendMenuChangedEvent = false;
|
let sendMenuChangedEvent = false;
|
||||||
|
|
||||||
// for (const item of items) {
|
for (const item of items) {
|
||||||
// const { id, state } = item;
|
const { id, state } = item;
|
||||||
// const menuItem = findMenuItemByID(menu, id);
|
const menuItem = findMenuItemByID(menu, id);
|
||||||
|
|
||||||
// if (menuItem) {
|
if (menuItem) {
|
||||||
// // Only send the updated app menu when the state actually changes
|
// Only send the updated app menu when the state actually changes
|
||||||
// // or we might end up introducing a never ending loop between
|
// or we might end up introducing a never ending loop between
|
||||||
// // the renderer and the main process
|
// the renderer and the main process
|
||||||
// if (
|
if (
|
||||||
// state.enabled !== undefined &&
|
state.enabled !== undefined &&
|
||||||
// menuItem.enabled !== state.enabled
|
menuItem.enabled !== state.enabled
|
||||||
// ) {
|
) {
|
||||||
// menuItem.enabled = state.enabled;
|
menuItem.enabled = state.enabled;
|
||||||
// sendMenuChangedEvent = true;
|
sendMenuChangedEvent = true;
|
||||||
// }
|
}
|
||||||
// } else {
|
} else {
|
||||||
// fatalError(`Unknown menu id: ${id}`);
|
// fatalError(`Unknown menu id: ${id}`);
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
|
|
||||||
// if (sendMenuChangedEvent && mainWindow) {
|
if (sendMenuChangedEvent && mainWindow) {
|
||||||
// mainWindow.sendAppMenu();
|
mainWindow.sendAppMenu();
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
// );
|
);
|
||||||
|
|
||||||
// ipcMain.on(
|
// ipcMain.on(
|
||||||
// 'show-contextual-menu',
|
// 'show-contextual-menu',
|
||||||
|
@ -287,11 +289,11 @@ app.on('ready', () => {
|
||||||
* An event sent by the renderer asking for a copy of the current
|
* An event sent by the renderer asking for a copy of the current
|
||||||
* application menu.
|
* application menu.
|
||||||
*/
|
*/
|
||||||
// ipcMain.on('get-app-menu', () => {
|
ipcMain.on('get-app-menu', () => {
|
||||||
// if (mainWindow) {
|
if (mainWindow) {
|
||||||
// mainWindow.sendAppMenu();
|
mainWindow.sendAppMenu();
|
||||||
// }
|
}
|
||||||
// });
|
});
|
||||||
|
|
||||||
// ipcMain.on(
|
// ipcMain.on(
|
||||||
// 'show-certificate-trust-dialog',
|
// 'show-certificate-trust-dialog',
|
||||||
|
|
310
src/electron/menu/build-default.ts
Normal file
310
src/electron/menu/build-default.ts
Normal file
|
@ -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<Electron.MenuItemConstructorOptions>();
|
||||||
|
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<number>, 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
44
src/electron/menu/ensure-item-ids.ts
Normal file
44
src/electron/menu/ensure-item-ids.ts
Normal file
|
@ -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<Electron.MenuItemConstructorOptions>,
|
||||||
|
prefix = '@',
|
||||||
|
seenIds = new Set<string>()
|
||||||
|
) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
24
src/electron/menu/find-menu-item.ts
Normal file
24
src/electron/menu/find-menu-item.ts
Normal file
|
@ -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;
|
||||||
|
}
|
|
@ -143,7 +143,7 @@ async function updateShortcut(): Promise<void> {
|
||||||
const desktopShortcutPath = Path.join(
|
const desktopShortcutPath = Path.join(
|
||||||
homeDirectory,
|
homeDirectory,
|
||||||
'Desktop',
|
'Desktop',
|
||||||
'GitHub Desktop.lnk'
|
'overFlow Scanner.lnk'
|
||||||
);
|
);
|
||||||
const exists = await pathExists(desktopShortcutPath);
|
const exists = await pathExists(desktopShortcutPath);
|
||||||
const locations: ShortcutLocations = exists
|
const locations: ShortcutLocations = exists
|
||||||
|
|
|
@ -1,11 +1,10 @@
|
||||||
{
|
{
|
||||||
"extends": "../tsconfig.json",
|
"extends": "../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "../out-tsc/app",
|
"outDir": "../out-tsc/app"
|
||||||
"types": ["node"]
|
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"src/test.ts",
|
"src/test.ts",
|
||||||
"**/*.spec.ts"
|
"**/*.spec.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
|
@ -13,6 +13,9 @@
|
||||||
"typeRoots": [
|
"typeRoots": [
|
||||||
"node_modules/@types"
|
"node_modules/@types"
|
||||||
],
|
],
|
||||||
|
"types": [
|
||||||
|
"node"
|
||||||
|
],
|
||||||
"lib": [
|
"lib": [
|
||||||
"es2015",
|
"es2015",
|
||||||
"es2017",
|
"es2017",
|
||||||
|
|
|
@ -46,7 +46,7 @@
|
||||||
"no-arg": true,
|
"no-arg": true,
|
||||||
"no-bitwise": true,
|
"no-bitwise": true,
|
||||||
"no-console": [
|
"no-console": [
|
||||||
true,
|
false,
|
||||||
"debug",
|
"debug",
|
||||||
"info",
|
"info",
|
||||||
"time",
|
"time",
|
||||||
|
@ -127,4 +127,4 @@
|
||||||
"component-class-suffix": true,
|
"component-class-suffix": true,
|
||||||
"directive-class-suffix": true
|
"directive-class-suffix": true
|
||||||
}
|
}
|
||||||
}
|
}
|
16
yarn.lock
16
yarn.lock
|
@ -243,6 +243,12 @@
|
||||||
semver "^5.3.0"
|
semver "^5.3.0"
|
||||||
semver-intersect "^1.1.2"
|
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":
|
"@types/jasmine@*", "@types/jasmine@~2.8.6":
|
||||||
version "2.8.8"
|
version "2.8.8"
|
||||||
resolved "https://nexus.loafle.net/repository/npm-all/@types/jasmine/-/jasmine-2.8.8.tgz#bf53a7d193ea8b03867a38bfdb4fbb0e0bf066c9"
|
resolved "https://nexus.loafle.net/repository/npm-all/@types/jasmine/-/jasmine-2.8.8.tgz#bf53a7d193ea8b03867a38bfdb4fbb0e0bf066c9"
|
||||||
|
@ -257,6 +263,10 @@
|
||||||
version "4.14.116"
|
version "4.14.116"
|
||||||
resolved "https://nexus.loafle.net/repository/npm-all/@types/lodash/-/lodash-4.14.116.tgz#5ccf215653e3e8c786a58390751033a9adca0eb9"
|
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":
|
"@types/node@^6.0.46":
|
||||||
version "6.0.116"
|
version "6.0.116"
|
||||||
resolved "https://nexus.loafle.net/repository/npm-all/@types/node/-/node-6.0.116.tgz#2f9cd62b4ecc4927e3942e2655c182eecf5b45f1"
|
resolved "https://nexus.loafle.net/repository/npm-all/@types/node/-/node-6.0.116.tgz#2f9cd62b4ecc4927e3942e2655c182eecf5b45f1"
|
||||||
|
@ -265,9 +275,9 @@
|
||||||
version "8.10.25"
|
version "8.10.25"
|
||||||
resolved "https://nexus.loafle.net/repository/npm-all/@types/node/-/node-8.10.25.tgz#801fe4e39372cef18f268db880a5fbfcf71adc7e"
|
resolved "https://nexus.loafle.net/repository/npm-all/@types/node/-/node-8.10.25.tgz#801fe4e39372cef18f268db880a5fbfcf71adc7e"
|
||||||
|
|
||||||
"@types/node@~8.9.4":
|
"@types/node@^8.10.4":
|
||||||
version "8.9.5"
|
version "8.10.26"
|
||||||
resolved "https://nexus.loafle.net/repository/npm-all/@types/node/-/node-8.9.5.tgz#162b864bc70be077e6db212b322754917929e976"
|
resolved "https://nexus.loafle.net/repository/npm-all/@types/node/-/node-8.10.26.tgz#950e3d4e6b316ba6e1ae4e84d9155aba67f88c2f"
|
||||||
|
|
||||||
"@types/q@^0.0.32":
|
"@types/q@^0.0.32":
|
||||||
version "0.0.32"
|
version "0.0.32"
|
||||||
|
|
Loading…
Reference in New Issue
Block a user