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/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",
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<div class="ui-g">
|
||||
<div class="ui-g-12 ui-nopad">
|
||||
<div class="card">
|
||||
<h1>Home works!!</h1>
|
||||
<h1>Home works!!!!</h1>
|
||||
<p-panel #content [showHeader]="false" class="block-panel">
|
||||
<div class="ui-g" dir="rtl">
|
||||
<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 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');
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
export type MenuEvent =
|
||||
| 'save'
|
||||
| 'save-as'
|
||||
| 'show-preferences'
|
||||
| 'show-about'
|
||||
| 'open-external-editor'
|
||||
| 'select-all';
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
export type MenuIDs =
|
||||
| 'save'
|
||||
| 'save-as'
|
||||
| 'preferences'
|
||||
| 'open-in-shell'
|
||||
| 'open-external-editor'
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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',
|
||||
|
|
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(
|
||||
homeDirectory,
|
||||
'Desktop',
|
||||
'GitHub Desktop.lnk'
|
||||
'overFlow Scanner.lnk'
|
||||
);
|
||||
const exists = await pathExists(desktopShortcutPath);
|
||||
const locations: ShortcutLocations = exists
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../out-tsc/app",
|
||||
"types": ["node"]
|
||||
"outDir": "../out-tsc/app"
|
||||
},
|
||||
"exclude": [
|
||||
"src/test.ts",
|
||||
"**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -13,6 +13,9 @@
|
|||
"typeRoots": [
|
||||
"node_modules/@types"
|
||||
],
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"lib": [
|
||||
"es2015",
|
||||
"es2017",
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
16
yarn.lock
16
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"
|
||||
|
|
Loading…
Reference in New Issue
Block a user