1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

hotbar context menu & command palette

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
Jari Kolehmainen 2021-04-06 13:50:21 +03:00
parent 92c1d73700
commit 1f8b388bd4
24 changed files with 143 additions and 71 deletions

View File

@ -38,6 +38,7 @@ export type CatalogEntityStatus = {
export interface CatalogEntityActionContext {
navigate: (url: string) => void;
setCommandPaletteContext: (context?: CatalogEntity) => void;
}
export type CatalogEntityContextMenu = {
@ -46,7 +47,8 @@ export type CatalogEntityContextMenu = {
onClick: () => Promise<void>;
};
export interface CatalogEntityContextMenuContext extends CatalogEntityActionContext {
export interface CatalogEntityContextMenuContext {
navigate: (url: string) => void;
menuItems: CatalogEntityContextMenu[];
}

View File

@ -213,7 +213,6 @@ export class ExtensionLoader {
registries.globalPageRegistry.add(extension.globalPages, extension),
registries.globalPageMenuRegistry.add(extension.globalPageMenus, extension),
registries.appPreferenceRegistry.add(extension.appPreferences),
registries.clusterFeatureRegistry.add(extension.clusterFeatures),
registries.statusBarRegistry.add(extension.statusBarItems),
registries.commandRegistry.add(extension.commands),
];

View File

@ -1,5 +1,4 @@
export type { AppPreferenceRegistration, AppPreferenceComponents } from "../registries/app-preference-registry";
export type { ClusterFeatureRegistration, ClusterFeatureComponents } from "../registries/cluster-feature-registry";
export type { KubeObjectDetailRegistration, KubeObjectDetailComponents } from "../registries/kube-object-detail-registry";
export type { KubeObjectMenuRegistration, KubeObjectMenuComponents } from "../registries/kube-object-menu-registry";
export type { KubeObjectStatusRegistration } from "../registries/kube-object-status-registry";

View File

@ -1,4 +1,4 @@
import type { AppPreferenceRegistration, ClusterFeatureRegistration, ClusterPageMenuRegistration, KubeObjectDetailRegistration, KubeObjectMenuRegistration, KubeObjectStatusRegistration, PageMenuRegistration, PageRegistration, StatusBarRegistration, } from "./registries";
import type { AppPreferenceRegistration, ClusterPageMenuRegistration, KubeObjectDetailRegistration, KubeObjectMenuRegistration, KubeObjectStatusRegistration, PageMenuRegistration, PageRegistration, StatusBarRegistration, } from "./registries";
import type { Cluster } from "../main/cluster";
import { LensExtension } from "./lens-extension";
import { getExtensionPageUrl } from "./registries/page-registry";
@ -11,7 +11,6 @@ export class LensRendererExtension extends LensExtension {
clusterPageMenus: ClusterPageMenuRegistration[] = [];
kubeObjectStatusTexts: KubeObjectStatusRegistration[] = [];
appPreferences: AppPreferenceRegistration[] = [];
clusterFeatures: ClusterFeatureRegistration[] = [];
statusBarItems: StatusBarRegistration[] = [];
kubeObjectDetailItems: KubeObjectDetailRegistration[] = [];
kubeObjectMenuItems: KubeObjectMenuRegistration[] = [];

View File

@ -1,18 +0,0 @@
import type React from "react";
import { BaseRegistry } from "./base-registry";
import { ClusterFeature } from "../cluster-feature";
export interface ClusterFeatureComponents {
Description: React.ComponentType<any>;
}
export interface ClusterFeatureRegistration {
title: string;
components: ClusterFeatureComponents
feature: ClusterFeature
}
export class ClusterFeatureRegistry extends BaseRegistry<ClusterFeatureRegistration> {
}
export const clusterFeatureRegistry = new ClusterFeatureRegistry();

View File

@ -1,23 +1,26 @@
// Extensions API -> Commands
import type { Cluster } from "../../main/cluster";
import { BaseRegistry } from "./base-registry";
import { action } from "mobx";
import { action, observable, reaction } from "mobx";
import { LensExtension } from "../lens-extension";
import { CatalogEntity } from "../../common/catalog-entity";
import { catalogEntityRegistry } from "../../common/catalog-entity-registry";
export type CommandContext = {
cluster?: Cluster;
entity?: CatalogEntity;
};
export interface CommandRegistration {
id: string;
title: string;
scope: "cluster" | "global";
scope: "entity" | "global";
action: (context: CommandContext) => void;
isActive?: (context: CommandContext) => boolean;
}
export class CommandRegistry extends BaseRegistry<CommandRegistration> {
@observable activeEntity: CatalogEntity;
@action
add(items: CommandRegistration | CommandRegistration[], extension?: LensExtension) {
const itemArray = [items].flat();
@ -32,4 +35,16 @@ export class CommandRegistry extends BaseRegistry<CommandRegistration> {
}
}
export function syncCommandRegistryWithCatalog() {
reaction(() => catalogEntityRegistry.items, (items) => {
if (!commandRegistry.activeEntity) {
return;
}
if (!items.includes(commandRegistry.activeEntity)) {
commandRegistry.activeEntity = null;
}
});
}
export const commandRegistry = new CommandRegistry();

View File

@ -7,6 +7,5 @@ export * from "./app-preference-registry";
export * from "./status-bar-registry";
export * from "./kube-object-detail-registry";
export * from "./kube-object-menu-registry";
export * from "./cluster-feature-registry";
export * from "./kube-object-status-registry";
export * from "./command-registry";

View File

@ -4,8 +4,6 @@ import { CatalogEntity, CatalogEntityData } from "../../common/catalog-entity";
import { catalogCategoryRegistry, CatalogCategoryRegistry } from "../../common/catalog-category-registry";
import "../../common/catalog-entities";
export { CatalogEntity, CatalogEntityData, CatalogEntityContextMenuContext } from "../../common/catalog-entity";
export class CatalogEntityRegistry {
@observable protected _items: CatalogEntity[] = observable.array([], { deep: true });

View File

@ -0,0 +1,12 @@
import { navigate } from "../navigation";
import { commandRegistry } from "../../extensions/registries";
import { CatalogEntity } from "../../common/catalog-entity";
export { CatalogEntity, CatalogEntityData, CatalogEntityActionContext, CatalogEntityContextMenuContext } from "../../common/catalog-entity";
export const catalogEntityRunContext = {
navigate: (url: string) => navigate(url),
setCommandPaletteContext: (entity?: CatalogEntity) => {
commandRegistry.activeEntity = entity;
}
};

View File

@ -6,13 +6,13 @@ import { releaseURL } from "../+apps-releases";
commandRegistry.add({
id: "cluster.viewHelmCharts",
title: "Cluster: View Helm Charts",
scope: "cluster",
scope: "entity",
action: () => navigate(helmChartsURL())
});
commandRegistry.add({
id: "cluster.viewHelmReleases",
title: "Cluster: View Helm Releases",
scope: "cluster",
scope: "entity",
action: () => navigate(releaseURL())
});

View File

@ -1,5 +1,6 @@
import { action, computed, reaction } from "mobx";
import { CatalogEntity, catalogEntityRegistry } from "../../api/catalog-entity-registry";
import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
import { CatalogEntity, CatalogEntityActionContext } from "../../api/catalog-entity";
import { ItemObject, ItemStore } from "../../item.store";
import { autobind } from "../../utils";
@ -42,7 +43,7 @@ export class CatalogEntityItem implements ItemObject {
return this.entity.metadata.source || "unknown";
}
onRun(ctx: any) {
onRun(ctx: CatalogEntityActionContext) {
this.entity.onRun(ctx);
}

View File

@ -9,7 +9,7 @@ import { kebabCase } from "lodash";
import { PageLayout } from "../layout/page-layout";
import { MenuItem, MenuActions } from "../menu";
import { Icon } from "../icon";
import { CatalogEntityContextMenuContext } from "../../api/catalog-entity-registry";
import { CatalogEntityContextMenuContext, catalogEntityRunContext } from "../../api/catalog-entity";
import { Badge } from "../badge";
import { hotbarStore } from "../../../common/hotbar-store";
import { addClusterURL } from "../+add-cluster";
@ -68,6 +68,10 @@ export class Catalog extends React.Component {
hotbar.items = hotbar.items.filter((i) => i.entity.uid !== item.id);
}
onDetails(item: CatalogEntityItem) {
item.onRun(catalogEntityRunContext);
}
@autobind()
renderItemMenu(item: CatalogEntityItem) {
const onOpen = async () => {
@ -124,7 +128,7 @@ export class Catalog extends React.Component {
item.labels.map((label) => <Badge key={label} label={label} title={label} />),
{ title: item.phase, className: kebabCase(item.phase) }
]}
onDetails={(item: CatalogEntityItem) => item.onRun({ navigate: (url: string) => navigate(url)})}
onDetails={(item: CatalogEntityItem) => this.onDetails(item) }
renderItemMenu={this.renderItemMenu}
addRemoveButtons={{
addTooltip: "Add Kubernetes Cluster",

View File

@ -12,5 +12,5 @@ commandRegistry.add({
clusterId: clusterStore.active.id
}
})),
isActive: (context) => !!context.cluster
isActive: (context) => !!context.entity
});

View File

@ -10,41 +10,41 @@ import { pdbURL } from "../+config-pod-disruption-budgets";
commandRegistry.add({
id: "cluster.viewConfigMaps",
title: "Cluster: View ConfigMaps",
scope: "cluster",
scope: "entity",
action: () => navigate(configMapsURL())
});
commandRegistry.add({
id: "cluster.viewSecrets",
title: "Cluster: View Secrets",
scope: "cluster",
scope: "entity",
action: () => navigate(secretsURL())
});
commandRegistry.add({
id: "cluster.viewResourceQuotas",
title: "Cluster: View ResourceQuotas",
scope: "cluster",
scope: "entity",
action: () => navigate(resourceQuotaURL())
});
commandRegistry.add({
id: "cluster.viewLimitRanges",
title: "Cluster: View LimitRanges",
scope: "cluster",
scope: "entity",
action: () => navigate(limitRangeURL())
});
commandRegistry.add({
id: "cluster.viewHorizontalPodAutoscalers",
title: "Cluster: View HorizontalPodAutoscalers (HPA)",
scope: "cluster",
scope: "entity",
action: () => navigate(hpaURL())
});
commandRegistry.add({
id: "cluster.viewPodDisruptionBudget",
title: "Cluster: View PodDisruptionBudgets",
scope: "cluster",
scope: "entity",
action: () => navigate(pdbURL())
});

View File

@ -8,27 +8,27 @@ import { networkPoliciesURL } from "../+network-policies";
commandRegistry.add({
id: "cluster.viewServices",
title: "Cluster: View Services",
scope: "cluster",
scope: "entity",
action: () => navigate(servicesURL())
});
commandRegistry.add({
id: "cluster.viewEndpoints",
title: "Cluster: View Endpoints",
scope: "cluster",
scope: "entity",
action: () => navigate(endpointURL())
});
commandRegistry.add({
id: "cluster.viewIngresses",
title: "Cluster: View Ingresses",
scope: "cluster",
scope: "entity",
action: () => navigate(ingressURL())
});
commandRegistry.add({
id: "cluster.viewNetworkPolicies",
title: "Cluster: View NetworkPolicies",
scope: "cluster",
scope: "entity",
action: () => navigate(networkPoliciesURL())
});

View File

@ -5,6 +5,6 @@ import { nodesURL } from "./nodes.route";
commandRegistry.add({
id: "cluster.viewNodes",
title: "Cluster: View Nodes",
scope: "cluster",
scope: "entity",
action: () => navigate(nodesURL())
});

View File

@ -5,41 +5,41 @@ import { cronJobsURL, daemonSetsURL, deploymentsURL, jobsURL, podsURL, statefulS
commandRegistry.add({
id: "cluster.viewPods",
title: "Cluster: View Pods",
scope: "cluster",
scope: "entity",
action: () => navigate(podsURL())
});
commandRegistry.add({
id: "cluster.viewDeployments",
title: "Cluster: View Deployments",
scope: "cluster",
scope: "entity",
action: () => navigate(deploymentsURL())
});
commandRegistry.add({
id: "cluster.viewDaemonSets",
title: "Cluster: View DaemonSets",
scope: "cluster",
scope: "entity",
action: () => navigate(daemonSetsURL())
});
commandRegistry.add({
id: "cluster.viewStatefulSets",
title: "Cluster: View StatefulSets",
scope: "cluster",
scope: "entity",
action: () => navigate(statefulSetsURL())
});
commandRegistry.add({
id: "cluster.viewJobs",
title: "Cluster: View Jobs",
scope: "cluster",
scope: "entity",
action: () => navigate(jobsURL())
});
commandRegistry.add({
id: "cluster.viewCronJobs",
title: "Cluster: View CronJobs",
scope: "cluster",
scope: "entity",
action: () => navigate(cronJobsURL())
});

View File

@ -8,7 +8,6 @@ import { EventEmitter } from "../../../common/event-emitter";
import { subscribeToBroadcast } from "../../../common/ipc";
import { CommandDialog } from "./command-dialog";
import { CommandRegistration, commandRegistry } from "../../../extensions/registries/command-registry";
import { clusterStore } from "../../../common/cluster-store";
export type CommandDialogEvent = {
component: React.ReactElement
@ -48,7 +47,7 @@ export class CommandContainer extends React.Component<{ clusterId?: string }> {
private runCommand(command: CommandRegistration) {
command.action({
cluster: clusterStore.active
entity: commandRegistry.activeEntity
});
}

View File

@ -16,11 +16,11 @@ export class CommandDialog extends React.Component {
@computed get options() {
const context = {
cluster: clusterStore.active
entity: commandRegistry.activeEntity
};
return commandRegistry.getItems().filter((command) => {
if (command.scope === "cluster" && !clusterStore.active) {
if (command.scope === "entity" && !clusterStore.active) {
return false;
}
@ -54,15 +54,15 @@ export class CommandDialog extends React.Component {
if (command.scope === "global") {
action({
cluster: clusterStore.active
entity: commandRegistry.activeEntity
});
} else if(clusterStore.active) {
} else if(commandRegistry.activeEntity) {
navigate(clusterViewURL({
params: {
clusterId: clusterStore.active.id
clusterId: commandRegistry.activeEntity.metadata.uid
}
}));
broadcastMessage(`command-palette:run-action:${clusterStore.active.id}`, command.id);
broadcastMessage(`command-palette:run-action:${commandRegistry.activeEntity.metadata.uid}`, command.id);
}
} catch(error) {
console.error("[COMMAND-DIALOG] failed to execute command", command.id, error);

View File

@ -136,7 +136,7 @@ export class Dock extends React.Component<Props> {
commandRegistry.add({
id: "cluster.openTerminal",
title: "Cluster: Open terminal",
scope: "cluster",
scope: "entity",
action: () => createTerminalTab(),
isActive: (context) => !!context.cluster
isActive: (context) => !!context.entity
});

View File

@ -39,7 +39,7 @@ export class ErrorBoundary extends React.Component<Props, State> {
if (error) {
const slackLink = <a href={slackUrl} rel="noreferrer" target="_blank">Slack</a>;
const githubLink = <a href={issuesTrackerUrl} rel="noreferrer" target="_blank">Github</a>;
const pageUrl = location.href;
const pageUrl = location.pathname;
return (
<div className="ErrorBoundary flex column gaps">

View File

@ -5,7 +5,12 @@ import { observer } from "mobx-react";
import { cssNames, IClassName } from "../../utils";
import { Tooltip } from "../tooltip";
import { Avatar } from "@material-ui/core";
import { CatalogEntity } from "../../../common/catalog-entity";
import { CatalogEntity, CatalogEntityContextMenuContext } from "../../../common/catalog-entity";
import { Menu, MenuItem } from "../menu";
import { Icon } from "../icon";
import { observable } from "mobx";
import { navigate } from "../../navigation";
import { hotbarStore } from "../../../common/hotbar-store";
interface Props extends DOMAttributes<HTMLElement> {
entity: CatalogEntity;
@ -16,6 +21,16 @@ interface Props extends DOMAttributes<HTMLElement> {
@observer
export class HotbarIcon extends React.Component<Props> {
@observable.deep private contextMenu: CatalogEntityContextMenuContext;
@observable menuOpen = false;
componentDidMount() {
this.contextMenu = {
menuItems: [],
navigate: (url: string) => navigate(url)
};
}
get iconString() {
let splittedName = this.props.entity.metadata.name.split(" ");
@ -36,6 +51,20 @@ export class HotbarIcon extends React.Component<Props> {
}
}
toggleMenu() {
this.menuOpen = !this.menuOpen;
}
removeFromHotbar(item: CatalogEntity) {
const hotbar = hotbarStore.getByName("default"); // FIXME
if (!hotbar) {
return;
}
hotbar.items = hotbar.items.filter((i) => i.entity.uid !== item.metadata.uid);
}
render() {
const {
entity, errorClass, isActive,
@ -46,11 +75,34 @@ export class HotbarIcon extends React.Component<Props> {
interactive: true,
active: isActive,
});
const onOpen = async () => {
await entity.onContextMenuOpen(this.contextMenu);
this.toggleMenu();
};
return (
<div {...elemProps} className={className} id={entityIconId}>
<Tooltip targetId={entityIconId}>{entity.metadata.name}</Tooltip>
<Avatar variant="square" className={isActive ? "active" : "default"}>{this.iconString}</Avatar>
<Menu
usePortal={true}
htmlFor={entityIconId}
isOpen={this.menuOpen}
toggleEvent="contextmenu"
position={{right: true, top: true}} // FIXME: position does not work
open={() => onOpen()}
close={() => this.toggleMenu()}>
<MenuItem key="remove-from-hotbar" onClick={() => this.removeFromHotbar(entity) }>
<Icon material="clear" interactive={true} title="Remove from hotbar"/> Remove from Hotbar
</MenuItem>
{ this.contextMenu && this.contextMenu.menuItems.map((menuItem) => {
return (
<MenuItem key={menuItem.title} onClick={() => menuItem.onClick()}>
<Icon material={menuItem.icon} interactive={true} title={menuItem.title}/> {menuItem.title}
</MenuItem>
);
})}
</Menu>
{children}
</div>
);

View File

@ -5,8 +5,8 @@ import { observer } from "mobx-react";
import { HotbarIcon } from "./hotbar-icon";
import { cssNames, IClassName } from "../../utils";
import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
import { navigate } from "../../navigation";
import { hotbarStore } from "../../../common/hotbar-store";
import { catalogEntityRunContext } from "../../api/catalog-entity";
interface Props {
className?: IClassName;
@ -23,9 +23,6 @@ export class HotbarMenu extends React.Component<Props> {
}
const items = hotbar.items.map((item) => catalogEntityRegistry.items.find((entity) => entity.metadata.uid === item.entity.uid)).filter(Boolean);
const runContext = {
navigate: (url: string) => navigate(url)
};
return (
<div className={cssNames("HotbarMenu flex column", className)}>
@ -36,7 +33,7 @@ export class HotbarMenu extends React.Component<Props> {
key={entity.metadata.uid}
entity={entity}
isActive={entity.status.active}
onClick={() => entity.onRun(runContext)}
onClick={() => entity.onRun(catalogEntityRunContext)}
/>
);
})}

View File

@ -17,6 +17,8 @@ import { registerIpcHandlers } from "./ipc";
import { ipcRenderer } from "electron";
import { IpcRendererNavigationEvents } from "./navigation/events";
import { catalogEntityRegistry } from "./api/catalog-entity-registry";
import { commandRegistry } from "../extensions/registries";
import { reaction } from "mobx";
@observer
export class LensApp extends React.Component {
@ -36,6 +38,18 @@ export class LensApp extends React.Component {
ipcRenderer.send(IpcRendererNavigationEvents.LOADED);
}
componentDidMount() {
reaction(() => catalogEntityRegistry.items, (items) => {
if (!commandRegistry.activeEntity) {
return;
}
if (!items.includes(commandRegistry.activeEntity)) {
commandRegistry.activeEntity = null;
}
});
}
render() {
return (
<Router history={history}>