Initial command palette feature (#1957)

* wip: command palette

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* register shortcut to global menu

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* introduce openCommandDialog & closeCommandDialog

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fix ipc broadcast to frames from renderer

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* tweak

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* add more commands

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* cleanup

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* ipc fix

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* add integration tests

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* ipc fix

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* implement workspace edit

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* workspace edit fixes

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* make tests green

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fixes from code review

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* cleanup ipc

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* cleanup CommandRegistry

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* ipc fix

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fix ClusterManager cluster auto-init

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* ensure cluster view is active before sending a command

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* switch to last active cluster when workspace change

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* tweak integration tests

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* run integration tests serially

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* cleanup

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* fixes based on code review

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* cleanup

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* cleanup more

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* cleanup

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* add workspace fixes

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* cleanup

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
Jari Kolehmainen authored and GitHub committed 2021-02-02 12:34:13 +02:00
1 parent 9e1487685c
commit b5e7be7591
48 files changed
+1446 -884

No files matched your search

+31 -21
View File
@@ -3,9 +3,12 @@
// https://www.electronjs.org/docs/api/ipc-renderer
import { ipcMain, ipcRenderer, webContents, remote } from "electron";
import { toJS } from "mobx";
import logger from "../main/logger";
import { ClusterFrameInfo, clusterFrameMap } from "./cluster-frames";
const subFramesChannel = "ipc:get-sub-frames";
export function handleRequest(channel: string, listener: (...args: any[]) => any) {
ipcMain.handle(channel, listener);
}
@@ -14,38 +17,39 @@ export async function requestMain(channel: string, ...args: any[]) {
return ipcRenderer.invoke(channel, ...args);
}
async function getSubFrames(): Promise<ClusterFrameInfo[]> {
const subFrames: ClusterFrameInfo[] = [];
clusterFrameMap.forEach(frameInfo => {
subFrames.push(frameInfo);
});
return subFrames;
function getSubFrames(): ClusterFrameInfo[] {
return toJS(Array.from(clusterFrameMap.values()), { recurseEverything: true });
}
export function broadcastMessage(channel: string, ...args: any[]) {
export async function broadcastMessage(channel: string, ...args: any[]) {
const views = (webContents || remote?.webContents)?.getAllWebContents();
if (!views) return;
views.forEach(webContent => {
const type = webContent.getType();
logger.silly(`[IPC]: broadcasting "${channel}" to ${type}=${webContent.id}`, { args });
webContent.send(channel, ...args);
getSubFrames().then((frames) => {
frames.map((frameInfo) => {
webContent.sendToFrame([frameInfo.processId, frameInfo.frameId], channel, ...args);
});
}).catch((e) => e);
});
if (ipcRenderer) {
ipcRenderer.send(channel, ...args);
} else {
ipcMain.emit(channel, ...args);
}
for (const view of views) {
const type = view.getType();
logger.silly(`[IPC]: broadcasting "${channel}" to ${type}=${view.id}`, { args });
view.send(channel, ...args);
try {
const subFrames: ClusterFrameInfo[] = ipcRenderer
? await requestMain(subFramesChannel)
: getSubFrames();
for (const frameInfo of subFrames) {
view.sendToFrame([frameInfo.processId, frameInfo.frameId], channel, ...args);
}
} catch (error) {
logger.error("[IPC]: failed to send IPC message", { error });
}
}
}
export function subscribeToBroadcast(channel: string, listener: (...args: any[]) => any) {
@@ -73,3 +77,9 @@ export function unsubscribeAllFromBroadcast(channel: string) {
ipcMain.removeAllListeners(channel);
}
}
export function bindBroadcastHandlers() {
handleRequest(subFramesChannel, () => {
return getSubFrames();
});
}
@@ -2,6 +2,7 @@ import type { AppPreferenceRegistration, ClusterFeatureRegistration, ClusterPage
import type { Cluster } from "../main/cluster";
import { LensExtension } from "./lens-extension";
import { getExtensionPageUrl } from "./registries/page-registry";
import { CommandRegistration } from "./registries/command-registry";
export class LensRendererExtension extends LensExtension {
globalPages: PageRegistration[] = [];
@@ -14,6 +15,7 @@ export class LensRendererExtension extends LensExtension {
statusBarItems: StatusBarRegistration[] = [];
kubeObjectDetailItems: KubeObjectDetailRegistration[] = [];
kubeObjectMenuItems: KubeObjectMenuRegistration[] = [];
commands: CommandRegistration[] = [];
async navigate<P extends object>(pageId?: string, params?: P) {
const { navigate } = await import("../renderer/navigation");
@@ -0,0 +1,37 @@
// Extensions API -> Commands
import type { Cluster } from "../../main/cluster";
import type { Workspace } from "../../common/workspace-store";
import { BaseRegistry } from "./base-registry";
import { action } from "mobx";
import { LensExtension } from "../lens-extension";
export type CommandContext = {
cluster?: Cluster;
workspace?: Workspace;
};
export interface CommandRegistration {
id: string;
title: string;
scope: "cluster" | "global";
action: (context: CommandContext) => void;
isActive?: (context: CommandContext) => boolean;
}
export class CommandRegistry extends BaseRegistry<CommandRegistration> {
@action
add(items: CommandRegistration | CommandRegistration[], extension?: LensExtension) {
const itemArray = [items].flat();
const newIds = itemArray.map((item) => item.id);
const currentIds = this.getItems().map((item) => item.id);
const filteredIds = newIds.filter((id) => !currentIds.includes(id));
const filteredItems = itemArray.filter((item) => filteredIds.includes(item.id));
return super.add(filteredItems, extension);
}
}
export const commandRegistry = new CommandRegistry();
@@ -13,6 +13,9 @@ export * from "../../renderer/components/select";
export * from "../../renderer/components/slider";
export * from "../../renderer/components/input/input";
// command-overlay
export { CommandOverlay } from "../../renderer/components/command-palette";
// other components
export * from "../../renderer/components/icon";
export * from "../../renderer/components/tooltip";
+4 -4
View File
@@ -1,7 +1,7 @@
import "../common/cluster-ipc";
import type http from "http";
import { ipcMain } from "electron";
import { autorun } from "mobx";
import { autorun, reaction } from "mobx";
import { clusterStore, getClusterIdFromHost } from "../common/cluster-store";
import { Cluster } from "./cluster";
import logger from "./logger";
@@ -12,14 +12,14 @@ export class ClusterManager extends Singleton {
constructor(public readonly port: number) {
super();
// auto-init clusters
autorun(() => {
clusterStore.enabledClustersList.forEach(cluster => {
reaction(() => clusterStore.enabledClustersList, (clusters) => {
clusters.forEach((cluster) => {
if (!cluster.initialized && !cluster.initializing) {
logger.info(`[CLUSTER-MANAGER]: init cluster`, cluster.getMeta());
cluster.init(port);
}
});
});
}, { fireImmediately: true });
// auto-stop removed clusters
autorun(() => {
+3
View File
@@ -26,6 +26,7 @@ import { InstalledExtension, extensionDiscovery } from "../extensions/extension-
import type { LensExtensionId } from "../extensions/lens-extension";
import { installDeveloperTools } from "./developer-tools";
import { filesystemProvisionerStore } from "./extension-filesystem";
import { bindBroadcastHandlers } from "../common/ipc";
const workingDir = path.join(app.getPath("appData"), appName);
let proxyPort: number;
@@ -63,6 +64,8 @@ app.on("ready", async () => {
logger.info(`🚀 Starting Lens from "${workingDir}"`);
await shellSync();
bindBroadcastHandlers();
powerMonitor.on("shutdown", () => {
app.exit();
});
+9
View File
@@ -10,6 +10,7 @@ import { extensionsURL } from "../renderer/components/+extensions/extensions.rou
import { menuRegistry } from "../extensions/registries/menu-registry";
import logger from "./logger";
import { exitApp } from "./exit-app";
import { broadcastMessage } from "../common/ipc";
export type MenuTopId = "mac" | "file" | "edit" | "view" | "help";
@@ -173,6 +174,14 @@ export function buildMenu(windowManager: WindowManager) {
const viewMenu: MenuItemConstructorOptions = {
label: "View",
submenu: [
{
label: "Command Palette...",
accelerator: "Shift+CmdOrCtrl+P",
click() {
broadcastMessage("command-palette:open");
}
},
{ type: "separator" },
{
label: "Back",
accelerator: "CmdOrCtrl+[",
@@ -0,0 +1,18 @@
import { navigate } from "../../navigation";
import { commandRegistry } from "../../../extensions/registries/command-registry";
import { helmChartsURL } from "../+apps-helm-charts";
import { releaseURL } from "../+apps-releases";
commandRegistry.add({
id: "cluster.viewHelmCharts",
title: "Cluster: View Helm Charts",
scope: "cluster",
action: () => navigate(helmChartsURL())
});
commandRegistry.add({
id: "cluster.viewHelmReleases",
title: "Cluster: View Helm Releases",
scope: "cluster",
action: () => navigate(releaseURL())
});
+1
View File
@@ -1,2 +1,3 @@
export * from "./apps";
export * from "./apps.route";
export * from "./apps.command";
@@ -0,0 +1,16 @@
import { navigate } from "../../navigation";
import { commandRegistry } from "../../../extensions/registries/command-registry";
import { clusterSettingsURL } from "./cluster-settings.route";
import { clusterStore } from "../../../common/cluster-store";
commandRegistry.add({
id: "cluster.viewCurrentClusterSettings",
title: "Cluster: View Settings",
scope: "global",
action: () => navigate(clusterSettingsURL({
params: {
clusterId: clusterStore.active.id
}
})),
isActive: (context) => !!context.cluster
});
@@ -1,7 +1,5 @@
import React from "react";
import { observer } from "mobx-react";
import { Link } from "react-router-dom";
import { workspacesURL } from "../../+workspaces";
import { workspaceStore } from "../../../../common/workspace-store";
import { Cluster } from "../../../../main/cluster";
import { Select } from "../../../components/select";
@@ -18,10 +16,7 @@ export class ClusterWorkspaceSetting extends React.Component<Props> {
<>
<SubTitle title="Cluster Workspace"/>
<p>
Define cluster{" "}
<Link to={workspacesURL()}>
workspace
</Link>.
Define cluster workspace.
</p>
<Select
value={this.props.cluster.workspace}
@@ -1,2 +1,3 @@
export * from "./cluster-settings.route";
export * from "./cluster-settings";
export * from "./cluster-settings.command";
@@ -0,0 +1,50 @@
import { navigate } from "../../navigation";
import { commandRegistry } from "../../../extensions/registries/command-registry";
import { configMapsURL } from "../+config-maps";
import { secretsURL } from "../+config-secrets";
import { resourceQuotaURL } from "../+config-resource-quotas";
import { limitRangeURL } from "../+config-limit-ranges";
import { hpaURL } from "../+config-autoscalers";
import { pdbURL } from "../+config-pod-disruption-budgets";
commandRegistry.add({
id: "cluster.viewConfigMaps",
title: "Cluster: View ConfigMaps",
scope: "cluster",
action: () => navigate(configMapsURL())
});
commandRegistry.add({
id: "cluster.viewSecrets",
title: "Cluster: View Secrets",
scope: "cluster",
action: () => navigate(secretsURL())
});
commandRegistry.add({
id: "cluster.viewResourceQuotas",
title: "Cluster: View ResourceQuotas",
scope: "cluster",
action: () => navigate(resourceQuotaURL())
});
commandRegistry.add({
id: "cluster.viewLimitRanges",
title: "Cluster: View LimitRanges",
scope: "cluster",
action: () => navigate(limitRangeURL())
});
commandRegistry.add({
id: "cluster.viewHorizontalPodAutoscalers",
title: "Cluster: View HorizontalPodAutoscalers (HPA)",
scope: "cluster",
action: () => navigate(hpaURL())
});
commandRegistry.add({
id: "cluster.viewPodDisruptionBudget",
title: "Cluster: View PodDisruptionBudgets",
scope: "cluster",
action: () => navigate(pdbURL())
});
+1
View File
@@ -1,2 +1,3 @@
export * from "./config.route";
export * from "./config";
export * from "./config.command";
@@ -1,2 +1,3 @@
export * from "./network.route";
export * from "./network";
export * from "./network.command";
@@ -0,0 +1,34 @@
import { navigate } from "../../navigation";
import { commandRegistry } from "../../../extensions/registries/command-registry";
import { servicesURL } from "../+network-services";
import { endpointURL } from "../+network-endpoints";
import { ingressURL } from "../+network-ingresses";
import { networkPoliciesURL } from "../+network-policies";
commandRegistry.add({
id: "cluster.viewServices",
title: "Cluster: View Services",
scope: "cluster",
action: () => navigate(servicesURL())
});
commandRegistry.add({
id: "cluster.viewEndpoints",
title: "Cluster: View Endpoints",
scope: "cluster",
action: () => navigate(endpointURL())
});
commandRegistry.add({
id: "cluster.viewIngresses",
title: "Cluster: View Ingresses",
scope: "cluster",
action: () => navigate(ingressURL())
});
commandRegistry.add({
id: "cluster.viewNetworkPolicies",
title: "Cluster: View NetworkPolicies",
scope: "cluster",
action: () => navigate(networkPoliciesURL())
});
+1
View File
@@ -1,3 +1,4 @@
export * from "./nodes";
export * from "./nodes.route";
export * from "./node-details";
export * from "./node.command";
@@ -0,0 +1,10 @@
import { navigate } from "../../navigation";
import { commandRegistry } from "../../../extensions/registries/command-registry";
import { nodesURL } from "./nodes.route";
commandRegistry.add({
id: "cluster.viewNodes",
title: "Cluster: View Nodes",
scope: "cluster",
action: () => navigate(nodesURL())
});
@@ -1,8 +1,17 @@
import type { RouteProps } from "react-router";
import { commandRegistry } from "../../../extensions/registries/command-registry";
import { buildURL } from "../../../common/utils/buildUrl";
import { navigate } from "../../navigation";
export const preferencesRoute: RouteProps = {
path: "/preferences"
};
export const preferencesURL = buildURL(preferencesRoute.path);
commandRegistry.add({
id: "app.showPreferences",
title: "Preferences: Open",
scope: "global",
action: () => navigate(preferencesURL())
});
@@ -1,3 +1,4 @@
export * from "./workloads.route";
export * from "./workloads";
export * from "./workloads.stores";
export * from "./workloads.command";
@@ -0,0 +1,45 @@
import { navigate } from "../../navigation";
import { commandRegistry } from "../../../extensions/registries/command-registry";
import { cronJobsURL, daemonSetsURL, deploymentsURL, jobsURL, podsURL, statefulSetsURL } from "./workloads.route";
commandRegistry.add({
id: "cluster.viewPods",
title: "Cluster: View Pods",
scope: "cluster",
action: () => navigate(podsURL())
});
commandRegistry.add({
id: "cluster.viewDeployments",
title: "Cluster: View Deployments",
scope: "cluster",
action: () => navigate(deploymentsURL())
});
commandRegistry.add({
id: "cluster.viewDaemonSets",
title: "Cluster: View DaemonSets",
scope: "cluster",
action: () => navigate(daemonSetsURL())
});
commandRegistry.add({
id: "cluster.viewStatefulSets",
title: "Cluster: View StatefulSets",
scope: "cluster",
action: () => navigate(statefulSetsURL())
});
commandRegistry.add({
id: "cluster.viewJobs",
title: "Cluster: View Jobs",
scope: "cluster",
action: () => navigate(jobsURL())
});
commandRegistry.add({
id: "cluster.viewCronJobs",
title: "Cluster: View CronJobs",
scope: "cluster",
action: () => navigate(cronJobsURL())
});
@@ -0,0 +1,64 @@
import React from "react";
import { observer } from "mobx-react";
import { Workspace, workspaceStore } from "../../../common/workspace-store";
import { v4 as uuid } from "uuid";
import { commandRegistry } from "../../../extensions/registries/command-registry";
import { Input, InputValidator } from "../input";
import { navigate } from "../../navigation";
import { CommandOverlay } from "../command-palette/command-container";
import { landingURL } from "../+landing-page";
import { clusterStore } from "../../../common/cluster-store";
const uniqueWorkspaceName: InputValidator = {
condition: ({ required }) => required,
message: () => `Workspace with this name already exists`,
validate: value => !workspaceStore.getByName(value),
};
@observer
export class AddWorkspace extends React.Component {
onSubmit(name: string) {
if (!name.trim()) {
return;
}
const workspace = workspaceStore.addWorkspace(new Workspace({
id: uuid(),
name
}));
if (!workspace) {
return;
}
workspaceStore.setActive(workspace.id);
clusterStore.setActive(null);
navigate(landingURL());
CommandOverlay.close();
}
render() {
return (
<>
<Input
placeholder="Workspace name"
autoFocus={true}
theme="round-black"
data-test-id="command-palette-workspace-add-name"
validators={[uniqueWorkspaceName]}
onSubmit={(v) => this.onSubmit(v)}
dirty={true}
showValidationLine={true} />
<small className="hint">
Please provide a new workspace name (Press &quot;Enter&quot; to confirm or &quot;Escape&quot; to cancel)
</small>
</>
);
}
}
commandRegistry.add({
id: "workspace.addWorkspace",
title: "Workspace: Add workspace ...",
scope: "global",
action: () => CommandOverlay.open(<AddWorkspace />)
});
@@ -0,0 +1,82 @@
import React from "react";
import { observer } from "mobx-react";
import { WorkspaceStore, workspaceStore } from "../../../common/workspace-store";
import { commandRegistry } from "../../../extensions/registries/command-registry";
import { Input, InputValidator } from "../input";
import { CommandOverlay } from "../command-palette/command-container";
const validateWorkspaceName: InputValidator = {
condition: ({ required }) => required,
message: () => `Workspace with this name already exists`,
validate: (value) => {
const current = workspaceStore.currentWorkspace;
if (current.name === value.trim()) {
return true;
}
return !workspaceStore.enabledWorkspacesList.find((workspace) => workspace.name === value);
}
};
interface EditWorkspaceState {
name: string;
}
@observer
export class EditWorkspace extends React.Component<{}, EditWorkspaceState> {
state: EditWorkspaceState = {
name: ""
};
componentDidMount() {
this.setState({name: workspaceStore.currentWorkspace.name});
}
onSubmit(name: string) {
if (name.trim() === "") {
return;
}
workspaceStore.currentWorkspace.name = name;
CommandOverlay.close();
}
onChange(name: string) {
this.setState({name});
}
get name() {
return this.state.name;
}
render() {
return (
<>
<Input
placeholder="Workspace name"
autoFocus={true}
theme="round-black"
data-test-id="command-palette-workspace-add-name"
validators={[validateWorkspaceName]}
onChange={(v) => this.onChange(v)}
onSubmit={(v) => this.onSubmit(v)}
dirty={true}
value={this.name}
showValidationLine={true} />
<small className="hint">
Please provide a new workspace name (Press &quot;Enter&quot; to confirm or &quot;Escape&quot; to cancel)
</small>
</>
);
}
}
commandRegistry.add({
id: "workspace.editCurrentWorkspace",
title: "Workspace: Edit current workspace ...",
scope: "global",
action: () => CommandOverlay.open(<EditWorkspace />),
isActive: (context) => context.workspace?.id !== WorkspaceStore.defaultId
});
@@ -1,2 +1 @@
export * from "./workspaces.route";
export * from "./workspaces";
@@ -0,0 +1,68 @@
import React from "react";
import { observer } from "mobx-react";
import { computed} from "mobx";
import { WorkspaceStore, workspaceStore } from "../../../common/workspace-store";
import { ConfirmDialog } from "../confirm-dialog";
import { commandRegistry } from "../../../extensions/registries/command-registry";
import { Select } from "../select";
import { CommandOverlay } from "../command-palette/command-container";
@observer
export class RemoveWorkspace extends React.Component {
@computed get options() {
return workspaceStore.enabledWorkspacesList.filter((workspace) => workspace.id !== WorkspaceStore.defaultId).map((workspace) => {
return { value: workspace.id, label: workspace.name };
});
}
onChange(id: string) {
const workspace = workspaceStore.enabledWorkspacesList.find((workspace) => workspace.id === id);
if (!workspace ) {
return;
}
CommandOverlay.close();
ConfirmDialog.open({
okButtonProps: {
label: `Remove Workspace`,
primary: false,
accent: true,
},
ok: () => {
workspaceStore.removeWorkspace(workspace);
},
message: (
<div className="confirm flex column gaps">
<p>
Are you sure you want remove workspace <b>{workspace.name}</b>?
</p>
<p className="info">
All clusters within workspace will be cleared as well
</p>
</div>
),
});
}
render() {
return (
<Select
onChange={(v) => this.onChange(v.value)}
components={{ DropdownIndicator: null, IndicatorSeparator: null }}
menuIsOpen={true}
options={this.options}
autoFocus={true}
escapeClearsValue={false}
data-test-id="command-palette-workspace-remove-select"
placeholder="Remove workspace" />
);
}
}
commandRegistry.add({
id: "workspace.removeWorkspace",
title: "Workspace: Remove workspace ...",
scope: "global",
action: () => CommandOverlay.open(<RemoveWorkspace />)
});
@@ -1,7 +0,0 @@
.WorkspaceMenu {
border-radius: $radius;
.workspaces-title {
padding: $padding;
}
}
@@ -1,66 +0,0 @@
import "./workspace-menu.scss";
import React from "react";
import { observer } from "mobx-react";
import { Link } from "react-router-dom";
import { workspacesURL } from "./workspaces.route";
import { Menu, MenuItem, MenuProps } from "../menu";
import { Icon } from "../icon";
import { observable } from "mobx";
import { WorkspaceId, workspaceStore } from "../../../common/workspace-store";
import { cssNames } from "../../utils";
import { navigate } from "../../navigation";
import { clusterViewURL } from "../cluster-manager/cluster-view.route";
import { landingURL } from "../+landing-page";
interface Props extends Partial<MenuProps> {
}
@observer
export class WorkspaceMenu extends React.Component<Props> {
@observable menuVisible = false;
activateWorkspace = (id: WorkspaceId) => {
const clusterId = workspaceStore.getById(id).lastActiveClusterId;
workspaceStore.setActive(id);
if (clusterId) {
navigate(clusterViewURL({ params: { clusterId } }));
} else {
navigate(landingURL());
}
};
render() {
const { className, ...menuProps } = this.props;
const { enabledWorkspacesList, currentWorkspace } = workspaceStore;
return (
<Menu
{...menuProps}
usePortal
className={cssNames("WorkspaceMenu", className)}
isOpen={this.menuVisible}
open={() => this.menuVisible = true}
close={() => this.menuVisible = false}
>
<Link className="workspaces-title" to={workspacesURL()}>
Workspaces
</Link>
{enabledWorkspacesList.map(({ id: workspaceId, name, description }) => {
return (
<MenuItem
key={workspaceId}
title={description}
active={workspaceId === currentWorkspace.id}
onClick={() => this.activateWorkspace(workspaceId)}
>
<Icon small material="layers"/>
<span className="workspace">{name}</span>
</MenuItem>
);
})}
</Menu>
);
}
}
@@ -1,8 +0,0 @@
import type { RouteProps } from "react-router";
import { buildURL } from "../../../common/utils/buildUrl";
export const workspacesRoute: RouteProps = {
path: "/workspaces"
};
export const workspacesURL = buildURL(workspacesRoute.path);
@@ -1,14 +0,0 @@
.Workspaces {
.workspace {
--flex-gap: #{$padding};
padding: $padding / 2;
&.default {
font-style: italic;
}
> .description {
flex: 1;
}
}
}
@@ -1,216 +1,89 @@
import "./workspaces.scss";
import React, { Fragment } from "react";
import React from "react";
import { observer } from "mobx-react";
import { computed, observable, toJS } from "mobx";
import { WizardLayout } from "../layout/wizard-layout";
import { Workspace, WorkspaceId, workspaceStore } from "../../../common/workspace-store";
import { v4 as uuid } from "uuid";
import { ConfirmDialog } from "../confirm-dialog";
import { Icon } from "../icon";
import { Input } from "../input";
import { cssNames, prevDefault } from "../../utils";
import { Button } from "../button";
import { isRequired, InputValidator } from "../input/input_validators";
import { clusterStore } from "../../../common/cluster-store";
import { computed} from "mobx";
import { WorkspaceStore, workspaceStore } from "../../../common/workspace-store";
import { commandRegistry } from "../../../extensions/registries/command-registry";
import { Select } from "../select";
import { navigate } from "../../navigation";
import { CommandOverlay } from "../command-palette/command-container";
import { AddWorkspace } from "./add-workspace";
import { RemoveWorkspace } from "./remove-workspace";
import { EditWorkspace } from "./edit-workspace";
import { landingURL } from "../+landing-page";
import { clusterViewURL } from "../cluster-manager/cluster-view.route";
@observer
export class Workspaces extends React.Component {
@observable editingWorkspaces = observable.map<WorkspaceId, Workspace>();
export class ChooseWorkspace extends React.Component {
private static addActionId = "__add__";
private static removeActionId = "__remove__";
private static editActionId = "__edit__";
@computed get workspaces(): Workspace[] {
const currentWorkspaces: Map<WorkspaceId, Workspace> = new Map();
workspaceStore.enabledWorkspacesList.forEach((w) => {
currentWorkspaces.set(w.id, w);
@computed get options() {
const options = workspaceStore.enabledWorkspacesList.map((workspace) => {
return { value: workspace.id, label: workspace.name };
});
const allWorkspaces = new Map([
...currentWorkspaces,
...this.editingWorkspaces,
]);
return Array.from(allWorkspaces.values());
options.push({ value: ChooseWorkspace.addActionId, label: "Add workspace ..." });
if (options.length > 1) {
options.push({ value: ChooseWorkspace.removeActionId, label: "Remove workspace ..." });
if (workspaceStore.currentWorkspace.id !== WorkspaceStore.defaultId) {
options.push({ value: ChooseWorkspace.editActionId, label: "Edit current workspace ..." });
}
}
return options;
}
renderInfo() {
return (
<Fragment>
<h2>What is a Workspace?</h2>
<p className="info">
Workspaces are used to organize number of clusters into logical groups.
</p>
<p>
A single workspaces contains a list of clusters and their full configuration.
</p>
</Fragment>
);
}
saveWorkspace = (id: WorkspaceId) => {
const workspace = new Workspace(this.editingWorkspaces.get(id));
if (workspaceStore.getById(id)) {
workspaceStore.updateWorkspace(workspace);
this.clearEditing(id);
onChange(id: string) {
if (id === ChooseWorkspace.addActionId) {
CommandOverlay.open(<AddWorkspace />);
return;
}
if (workspaceStore.addWorkspace(workspace)) {
this.clearEditing(id);
if (id === ChooseWorkspace.removeActionId) {
CommandOverlay.open(<RemoveWorkspace />);
return;
}
};
addWorkspace = () => {
const workspaceId = uuid();
if (id === ChooseWorkspace.editActionId) {
CommandOverlay.open(<EditWorkspace />);
this.editingWorkspaces.set(workspaceId, new Workspace({
id: workspaceId,
name: "",
description: ""
}));
};
editWorkspace = (id: WorkspaceId) => {
const workspace = workspaceStore.getById(id);
this.editingWorkspaces.set(id, toJS(workspace));
};
activateWorkspace = (id: WorkspaceId) => {
const clusterId = workspaceStore.getById(id).lastActiveClusterId;
return;
}
workspaceStore.setActive(id);
clusterStore.setActive(clusterId);
};
const clusterId = workspaceStore.getById(id).lastActiveClusterId;
clearEditing = (id: WorkspaceId) => {
this.editingWorkspaces.delete(id);
};
removeWorkspace = (id: WorkspaceId) => {
const workspace = workspaceStore.getById(id);
ConfirmDialog.open({
okButtonProps: {
label: `Remove Workspace`,
primary: false,
accent: true,
},
ok: () => {
this.clearEditing(id);
workspaceStore.removeWorkspace(workspace);
},
message: (
<div className="confirm flex column gaps">
<p>
Are you sure you want remove workspace <b>{workspace.name}</b>?
</p>
<p className="info">
All clusters within workspace will be cleared as well
</p>
</div>
),
});
};
onInputKeypress = (evt: React.KeyboardEvent<any>, workspaceId: WorkspaceId) => {
if (evt.key == "Enter") {
// Trigget input validation
evt.currentTarget.blur();
evt.currentTarget.focus();
this.saveWorkspace(workspaceId);
if (clusterId) {
navigate(clusterViewURL({ params: { clusterId } }));
} else {
navigate(landingURL());
}
};
CommandOverlay.close();
}
render() {
return (
<WizardLayout className="Workspaces" infoPanel={this.renderInfo()}>
<h2>
Workspaces
</h2>
<div className="items flex column gaps">
{this.workspaces.map(({ id: workspaceId, name, description, ownerRef }) => {
const isActive = workspaceStore.currentWorkspaceId === workspaceId;
const isDefault = workspaceStore.isDefault(workspaceId);
const isEditing = this.editingWorkspaces.has(workspaceId);
const editingWorkspace = this.editingWorkspaces.get(workspaceId);
const managed = !!ownerRef;
const className = cssNames("workspace flex gaps align-center", {
active: isActive,
editing: isEditing,
default: isDefault,
});
const existenceValidator: InputValidator = {
message: () => `Workspace '${name}' already exists`,
validate: value => !workspaceStore.getByName(value.trim())
};
return (
<div key={workspaceId} className={cssNames(className)}>
{!isEditing && (
<Fragment>
<span className="name flex gaps align-center">
<a href="#" onClick={prevDefault(() => this.activateWorkspace(workspaceId))}>{name}</a>
{isActive && <span> (current)</span>}
</span>
<span className="description">{description}</span>
{!isDefault && !managed && (
<Fragment>
<Icon
material="edit"
tooltip="Edit"
onClick={() => this.editWorkspace(workspaceId)}
/>
<Icon
material="delete"
tooltip="Delete"
onClick={() => this.removeWorkspace(workspaceId)}
/>
</Fragment>
)}
</Fragment>
)}
{isEditing && (
<Fragment>
<Input
className="name"
placeholder={`Name`}
value={editingWorkspace.name}
onChange={v => editingWorkspace.name = v}
onKeyPress={(e) => this.onInputKeypress(e, workspaceId)}
validators={[isRequired, existenceValidator]}
autoFocus
/>
<Input
className="description"
placeholder={`Description`}
value={editingWorkspace.description}
onChange={v => editingWorkspace.description = v}
onKeyPress={(e) => this.onInputKeypress(e, workspaceId)}
/>
<Icon
material="save"
tooltip="Save"
onClick={() => this.saveWorkspace(workspaceId)}
/>
<Icon
material="cancel"
tooltip="Cancel"
onClick={() => this.clearEditing(workspaceId)}
/>
</Fragment>
)}
</div>
);
})}
</div>
<Button
primary
className="box left"
label="Add Workspace"
onClick={this.addWorkspace}
/>
</WizardLayout>
<Select
onChange={(v) => this.onChange(v.value)}
components={{ DropdownIndicator: null, IndicatorSeparator: null }}
menuIsOpen={true}
options={this.options}
autoFocus={true}
escapeClearsValue={false}
placeholder="Switch to workspace" />
);
}
}
commandRegistry.add({
id: "workspace.chooseWorkspace",
title: "Workspace: Switch to workspace ...",
scope: "global",
action: () => CommandOverlay.open(<ChooseWorkspace />)
});
+4
View File
@@ -47,6 +47,7 @@ import { nodesStore } from "./+nodes/nodes.store";
import { podsStore } from "./+workloads-pods/pods.store";
import { kubeWatchApi } from "../api/kube-watch-api";
import { ReplicaSetScaleDialog } from "./+workloads-replicasets/replicaset-scale-dialog";
import { CommandContainer } from "./command-palette/command-container";
@observer
export class App extends React.Component {
@@ -188,6 +189,8 @@ export class App extends React.Component {
}
render() {
const cluster = getHostedCluster();
return (
<Router history={history}>
<ErrorBoundary>
@@ -219,6 +222,7 @@ export class App extends React.Component {
<StatefulSetScaleDialog/>
<ReplicaSetScaleDialog/>
<CronJobTriggerDialog/>
<CommandContainer cluster={cluster} />
</ErrorBoundary>
</Router>
);
@@ -3,9 +3,10 @@ import "./bottom-bar.scss";
import React from "react";
import { observer } from "mobx-react";
import { Icon } from "../icon";
import { WorkspaceMenu } from "../+workspaces/workspace-menu";
import { workspaceStore } from "../../../common/workspace-store";
import { statusBarRegistry } from "../../../extensions/registries";
import { CommandOverlay } from "../command-palette/command-container";
import { ChooseWorkspace } from "../+workspaces";
@observer
export class BottomBar extends React.Component {
@@ -16,13 +17,10 @@ export class BottomBar extends React.Component {
return (
<div className="BottomBar flex gaps">
<div id="current-workspace" className="flex gaps align-center">
<div id="current-workspace" data-test-id="current-workspace" className="flex gaps align-center" onClick={() => CommandOverlay.open(<ChooseWorkspace />)}>
<Icon smallest material="layers"/>
<span className="workspace-name">{currentWorkspace.name}</span>
<span className="workspace-name" data-test-id="current-workspace-name">{currentWorkspace.name}</span>
</div>
<WorkspaceMenu
htmlFor="current-workspace"
/>
<div className="extensions box grow flex gaps justify-flex-end">
{Array.isArray(items) && items.map(({ item }, index) => {
if (!item) return;
@@ -8,7 +8,6 @@ import { ClustersMenu } from "./clusters-menu";
import { BottomBar } from "./bottom-bar";
import { LandingPage, landingRoute, landingURL } from "../+landing-page";
import { Preferences, preferencesRoute } from "../+preferences";
import { Workspaces, workspacesRoute } from "../+workspaces";
import { AddCluster, addClusterRoute } from "../+add-cluster";
import { ClusterView } from "./cluster-view";
import { ClusterSettings, clusterSettingsRoute } from "../+cluster-settings";
@@ -67,7 +66,6 @@ export class ClusterManager extends React.Component {
<Route component={LandingPage} {...landingRoute} />
<Route component={Preferences} {...preferencesRoute} />
<Route component={Extensions} {...extensionsRoute} />
<Route component={Workspaces} {...workspacesRoute} />
<Route component={AddCluster} {...addClusterRoute} />
<Route component={ClusterView} {...clusterViewRoute} />
<Route component={ClusterSettings} {...clusterSettingsRoute} />
@@ -22,6 +22,10 @@ import { ConfirmDialog } from "../confirm-dialog";
import { clusterViewURL } from "./cluster-view.route";
import { getExtensionPageUrl, globalPageMenuRegistry, globalPageRegistry } from "../../../extensions/registries";
import { clusterDisconnectHandler } from "../../../common/cluster-ipc";
import { commandRegistry } from "../../../extensions/registries/command-registry";
import { CommandOverlay } from "../command-palette/command-container";
import { computed } from "mobx";
import { Select } from "../select";
interface Props {
className?: IClassName;
@@ -178,3 +182,41 @@ export class ClustersMenu extends React.Component<Props> {
);
}
}
@observer
export class ChooseCluster extends React.Component {
@computed get options() {
const clusters = clusterStore.getByWorkspaceId(workspaceStore.currentWorkspaceId).filter(cluster => cluster.enabled);
const options = clusters.map((cluster) => {
return { value: cluster.id, label: cluster.name };
});
return options;
}
onChange(clusterId: string) {
navigate(clusterViewURL({ params: { clusterId } }));
CommandOverlay.close();
}
render() {
return (
<Select
onChange={(v) => this.onChange(v.value)}
components={{ DropdownIndicator: null, IndicatorSeparator: null }}
menuIsOpen={true}
options={this.options}
autoFocus={true}
escapeClearsValue={false}
placeholder="Switch to cluster" />
);
}
}
commandRegistry.add({
id: "workspace.chooseCluster",
title: "Workspace: Switch to cluster ...",
scope: "global",
action: () => CommandOverlay.open(<ChooseCluster />)
});
@@ -0,0 +1,11 @@
#command-container {
position: absolute;
top: 20px;
width: 40%;
left: 0;
right: 0;
margin-left: auto;
margin-right: auto;
padding: 10px;
background-color: var(--dockInfoBackground);
}
@@ -0,0 +1,87 @@
import "./command-container.scss";
import { action, observable } from "mobx";
import { observer } from "mobx-react";
import React from "react";
import { Dialog } from "../dialog";
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";
import { workspaceStore } from "../../../common/workspace-store";
import { Cluster } from "../../../main/cluster";
export type CommandDialogEvent = {
component: React.ReactElement
};
const commandDialogBus = new EventEmitter<[CommandDialogEvent]>();
export class CommandOverlay {
static open(component: React.ReactElement) {
commandDialogBus.emit({ component });
}
static close() {
commandDialogBus.emit({ component: null });
}
}
@observer
export class CommandContainer extends React.Component<{cluster?: Cluster}> {
@observable.ref commandComponent: React.ReactElement;
private escHandler(event: KeyboardEvent) {
if (event.key === "Escape") {
event.stopPropagation();
this.closeDialog();
}
}
@action
private closeDialog() {
this.commandComponent = null;
}
private findCommandById(commandId: string) {
return commandRegistry.getItems().find((command) => command.id === commandId);
}
private runCommand(command: CommandRegistration) {
command.action({
cluster: clusterStore.active,
workspace: workspaceStore.currentWorkspace
});
}
componentDidMount() {
if (this.props.cluster) {
subscribeToBroadcast(`command-palette:run-action:${this.props.cluster.id}`, (event, commandId: string) => {
const command = this.findCommandById(commandId);
if (command) {
this.runCommand(command);
}
});
} else {
subscribeToBroadcast("command-palette:open", () => {
CommandOverlay.open(<CommandDialog />);
});
}
window.addEventListener("keyup", (e) => this.escHandler(e), true);
commandDialogBus.addListener((event) => {
this.commandComponent = event.component;
});
}
render() {
return (
<Dialog isOpen={!!this.commandComponent} animated={false} onClose={() => this.commandComponent = null}>
<div id="command-container">
{this.commandComponent}
</div>
</Dialog>
);
}
}
@@ -0,0 +1,88 @@
import { Select } from "../select";
import { computed, observable, toJS } from "mobx";
import { observer } from "mobx-react";
import React from "react";
import { commandRegistry } from "../../../extensions/registries/command-registry";
import { clusterStore } from "../../../common/cluster-store";
import { workspaceStore } from "../../../common/workspace-store";
import { CommandOverlay } from "./command-container";
import { broadcastMessage } from "../../../common/ipc";
import { navigate } from "../../navigation";
import { clusterViewURL } from "../cluster-manager/cluster-view.route";
@observer
export class CommandDialog extends React.Component {
@observable menuIsOpen = true;
@computed get options() {
const context = {
cluster: clusterStore.active,
workspace: workspaceStore.currentWorkspace
};
return commandRegistry.getItems().filter((command) => {
if (command.scope === "cluster" && !clusterStore.active) {
return false;
}
if (!command.isActive) {
return true;
}
try {
return command.isActive(context);
} catch(e) {
console.error(e);
return false;
}
}).map((command) => {
return { value: command.id, label: command.title };
}).sort((a, b) => a.label > b.label ? 1 : -1);
}
private onChange(value: string) {
const command = commandRegistry.getItems().find((cmd) => cmd.id === value);
if (!command) {
return;
}
const action = toJS(command.action);
try {
CommandOverlay.close();
if (command.scope === "global") {
action({
cluster: clusterStore.active,
workspace: workspaceStore.currentWorkspace
});
} else if(clusterStore.active) {
navigate(clusterViewURL({
params: {
clusterId: clusterStore.active.id
}
}));
broadcastMessage(`command-palette:run-action:${clusterStore.active.id}`, command.id);
}
} catch(error) {
console.error("[COMMAND-DIALOG] failed to execute command", command.id, error);
}
}
render() {
return (
<Select
onChange={(v) => this.onChange(v.value)}
components={{ DropdownIndicator: null, IndicatorSeparator: null }}
menuIsOpen={this.menuIsOpen}
options={this.options}
autoFocus={true}
escapeClearsValue={false}
data-test-id="command-palette-search"
placeholder="" />
);
}
}
@@ -0,0 +1,2 @@
export * from "./command-container";
export * from "./command-dialog";
+9
View File
@@ -22,6 +22,7 @@ import { TerminalWindow } from "./terminal-window";
import { createTerminalTab, isTerminalTab } from "./terminal.store";
import { UpgradeChart } from "./upgrade-chart";
import { isUpgradeChartTab } from "./upgrade-chart.store";
import { commandRegistry } from "../../../extensions/registries/command-registry";
interface Props {
className?: string;
@@ -131,3 +132,11 @@ export class Dock extends React.Component<Props> {
);
}
}
commandRegistry.add({
id: "cluster.openTerminal",
title: "Cluster: Open terminal",
scope: "cluster",
action: () => createTerminalTab(),
isActive: (context) => !!context.cluster
});
+1 -1
View File
@@ -242,7 +242,7 @@ export class Input extends React.Component<InputProps, State> {
switch (evt.key) {
case "Enter":
if (this.props.onSubmit && !modified && !evt.repeat) {
if (this.props.onSubmit && !modified && !evt.repeat && this.isValid) {
this.props.onSubmit(this.getValue());
}
break;
+2
View File
@@ -11,6 +11,7 @@ import { Notifications } from "./components/notifications";
import { ConfirmDialog } from "./components/confirm-dialog";
import { extensionLoader } from "../extensions/extension-loader";
import { broadcastMessage } from "../common/ipc";
import { CommandContainer } from "./components/command-palette/command-container";
@observer
export class LensApp extends React.Component {
@@ -36,6 +37,7 @@ export class LensApp extends React.Component {
</ErrorBoundary>
<Notifications/>
<ConfirmDialog/>
<CommandContainer />
</Router>
);
}