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

Merge remote-tracking branch 'origin/master' into eliminate-gst-from-app-paths

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>
This commit is contained in:
Janne Savolainen 2022-01-11 13:37:18 +02:00
commit 8d87bd0985
No known key found for this signature in database
GPG Key ID: 5F465B5672372402
80 changed files with 1823 additions and 1272 deletions

View File

@ -1,6 +1,7 @@
<component name="InspectionProjectProfileManager"> <component name="InspectionProjectProfileManager">
<profile version="1.0"> <profile version="1.0">
<option name="myName" value="Project Default" /> <option name="myName" value="Project Default" />
<inspection_tool class="ES6PreferShortImport" enabled="true" level="INFORMATION" enabled_by_default="true" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" /> <inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile> </profile>
</component> </component>

View File

@ -110,6 +110,10 @@ describe("cluster-store", () => {
createCluster = mainDi.inject(createClusterInjectionToken); createCluster = mainDi.inject(createClusterInjectionToken);
}); });
afterEach(() => {
mockFs.restore();
});
describe("empty config", () => { describe("empty config", () => {
let getCustomKubeConfigDirectory: (directoryName: string) => string; let getCustomKubeConfigDirectory: (directoryName: string) => string;

View File

@ -239,7 +239,7 @@ describe("HotbarStore", () => {
const hotbarStore = HotbarStore.getInstance(); const hotbarStore = HotbarStore.getInstance();
hotbarStore.add({ name: "hottest", id: "hottest" }); hotbarStore.add({ name: "hottest", id: "hottest" });
hotbarStore.activeHotbarId = "hottest"; hotbarStore.setActiveHotbar("hottest");
const { error } = logger; const { error } = logger;
const mocked = jest.fn(); const mocked = jest.fn();

View File

@ -19,7 +19,7 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { CatalogCategory, CatalogEntity, CatalogEntityAddMenuContext, CatalogEntityContextMenuContext, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog"; import { CatalogCategory, CatalogEntity, CatalogEntityContextMenuContext, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog";
import { catalogCategoryRegistry } from "../catalog/catalog-category-registry"; import { catalogCategoryRegistry } from "../catalog/catalog-category-registry";
import { productName } from "../vars"; import { productName } from "../vars";
import { WeblinkStore } from "../weblink-store"; import { WeblinkStore } from "../weblink-store";
@ -86,21 +86,6 @@ export class WebLinkCategory extends CatalogCategory {
kind: "WebLink", kind: "WebLink",
}, },
}; };
public static onAdd?: () => void;
constructor() {
super();
this.on("catalogAddMenu", (ctx: CatalogEntityAddMenuContext) => {
ctx.menuItems.push({
icon: "public",
title: "Add web link",
onClick: () => {
WebLinkCategory.onAdd();
},
});
});
}
} }
catalogCategoryRegistry.add(new WebLinkCategory()); catalogCategoryRegistry.add(new WebLinkCategory());

View File

@ -19,12 +19,11 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import portForwardStoreInjectable from "../port-forward-store.injectable"; import { HotbarStore } from "./hotbar-store";
const addPortForwardInjectable = getInjectable({
instantiate: (di) => di.inject(portForwardStoreInjectable).add,
const hotbarManagerInjectable = getInjectable({
instantiate: () => HotbarStore.getInstance(),
lifecycle: lifecycleEnum.singleton, lifecycle: lifecycleEnum.singleton,
}); });
export default addPortForwardInjectable; export default hotbarManagerInjectable;

View File

@ -19,7 +19,7 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { action, comparer, observable, makeObservable } from "mobx"; import { action, comparer, observable, makeObservable, computed } from "mobx";
import { BaseStore } from "./base-store"; import { BaseStore } from "./base-store";
import migrations from "../migrations/hotbar-store"; import migrations from "../migrations/hotbar-store";
import { toJS } from "./utils"; import { toJS } from "./utils";
@ -27,7 +27,7 @@ import { CatalogEntity } from "./catalog";
import { catalogEntity } from "../main/catalog-sources/general"; import { catalogEntity } from "../main/catalog-sources/general";
import logger from "../main/logger"; import logger from "../main/logger";
import { broadcastMessage, HotbarTooManyItems } from "./ipc"; import { broadcastMessage, HotbarTooManyItems } from "./ipc";
import { defaultHotbarCells, getEmptyHotbar, Hotbar, HotbarCreateOptions } from "./hotbar-types"; import { defaultHotbarCells, getEmptyHotbar, Hotbar, CreateHotbarData, CreateHotbarOptions } from "./hotbar-types";
export interface HotbarStoreModel { export interface HotbarStoreModel {
hotbars: Hotbar[]; hotbars: Hotbar[];
@ -52,22 +52,40 @@ export class HotbarStore extends BaseStore<HotbarStoreModel> {
this.load(); this.load();
} }
get activeHotbarId() { @computed get activeHotbarId() {
return this._activeHotbarId; return this._activeHotbarId;
} }
set activeHotbarId(id: string) { /**
if (this.getById(id)) { * If `hotbar` points to a known hotbar, make it active. Otherwise, ignore
this._activeHotbarId = id; * @param hotbar The hotbar instance, or the index, or its ID
*/
setActiveHotbar(hotbar: Hotbar | number | string) {
if (typeof hotbar === "number") {
if (hotbar >= 0 && hotbar < this.hotbars.length) {
this._activeHotbarId = this.hotbars[hotbar].id;
}
} else if (typeof hotbar === "string") {
if (this.getById(hotbar)) {
this._activeHotbarId = hotbar;
}
} else {
if (this.hotbars.indexOf(hotbar) >= 0) {
this._activeHotbarId = hotbar.id;
}
} }
} }
hotbarIndex(id: string) { private hotbarIndexById(id: string) {
return this.hotbars.findIndex((hotbar) => hotbar.id === id); return this.hotbars.findIndex((hotbar) => hotbar.id === id);
} }
get activeHotbarIndex() { private hotbarIndex(hotbar: Hotbar) {
return this.hotbarIndex(this.activeHotbarId); return this.hotbars.indexOf(hotbar);
}
@computed get activeHotbarIndex() {
return this.hotbarIndexById(this.activeHotbarId);
} }
@action @action
@ -87,13 +105,11 @@ export class HotbarStore extends BaseStore<HotbarStoreModel> {
this.hotbars.forEach(ensureExactHotbarItemLength); this.hotbars.forEach(ensureExactHotbarItemLength);
if (data.activeHotbarId) { if (data.activeHotbarId) {
if (this.getById(data.activeHotbarId)) { this.setActiveHotbar(data.activeHotbarId);
this.activeHotbarId = data.activeHotbarId;
}
} }
if (!this.activeHotbarId) { if (!this.activeHotbarId) {
this.activeHotbarId = this.hotbars[0].id; this.setActiveHotbar(0);
} }
} }
@ -118,8 +134,7 @@ export class HotbarStore extends BaseStore<HotbarStoreModel> {
return this.hotbars.find((hotbar) => hotbar.id === id); return this.hotbars.find((hotbar) => hotbar.id === id);
} }
@action add = action((data: CreateHotbarData, { setActive = false }: CreateHotbarOptions = {}) => {
add(data: HotbarCreateOptions, { setActive = false } = {}) {
const hotbar = getEmptyHotbar(data.name, data.id); const hotbar = getEmptyHotbar(data.name, data.id);
this.hotbars.push(hotbar); this.hotbars.push(hotbar);
@ -127,29 +142,29 @@ export class HotbarStore extends BaseStore<HotbarStoreModel> {
if (setActive) { if (setActive) {
this._activeHotbarId = hotbar.id; this._activeHotbarId = hotbar.id;
} }
} });
@action setHotbarName = action((id: string, name: string) => {
setHotbarName(id: string, name: string) {
const index = this.hotbars.findIndex((hotbar) => hotbar.id === id); const index = this.hotbars.findIndex((hotbar) => hotbar.id === id);
if(index < 0) { if (index < 0) {
console.warn(`[HOTBAR-STORE]: cannot setHotbarName: unknown id`, { id }); return void console.warn(`[HOTBAR-STORE]: cannot setHotbarName: unknown id`, { id });
return;
} }
this.hotbars[index].name = name; this.hotbars[index].name = name;
} });
remove = action((hotbar: Hotbar) => {
if (this.hotbars.length <= 1) {
throw new Error("Cannot remove the last hotbar");
}
@action
remove(hotbar: Hotbar) {
this.hotbars = this.hotbars.filter((h) => h !== hotbar); this.hotbars = this.hotbars.filter((h) => h !== hotbar);
if (this.activeHotbarId === hotbar.id) { if (this.activeHotbarId === hotbar.id) {
this.activeHotbarId = this.hotbars[0].id; this.setActiveHotbar(0);
} }
} });
@action @action
addToHotbar(item: CatalogEntity, cellIndex?: number) { addToHotbar(item: CatalogEntity, cellIndex?: number) {
@ -263,7 +278,7 @@ export class HotbarStore extends BaseStore<HotbarStoreModel> {
index = hotbarStore.hotbars.length - 1; index = hotbarStore.hotbars.length - 1;
} }
hotbarStore.activeHotbarId = hotbarStore.hotbars[index].id; hotbarStore.setActiveHotbar(index);
} }
switchToNext() { switchToNext() {
@ -274,7 +289,7 @@ export class HotbarStore extends BaseStore<HotbarStoreModel> {
index = 0; index = 0;
} }
hotbarStore.activeHotbarId = hotbarStore.hotbars[index].id; hotbarStore.setActiveHotbar(index);
} }
/** /**
@ -284,6 +299,20 @@ export class HotbarStore extends BaseStore<HotbarStoreModel> {
isAddedToActive(entity: CatalogEntity) { isAddedToActive(entity: CatalogEntity) {
return !!this.getActive().items.find(item => item?.entity.uid === entity.metadata.uid); return !!this.getActive().items.find(item => item?.entity.uid === entity.metadata.uid);
} }
getDisplayLabel(hotbar: Hotbar): string {
return `${this.getDisplayIndex(hotbar)}: ${hotbar.name}`;
}
getDisplayIndex(hotbar: Hotbar): string {
const index = this.hotbarIndex(hotbar);
if (index < 0) {
return "??";
}
return `${index + 1}`;
}
} }
/** /**
@ -292,12 +321,7 @@ export class HotbarStore extends BaseStore<HotbarStoreModel> {
* @param hotbar The hotbar to modify * @param hotbar The hotbar to modify
*/ */
function ensureExactHotbarItemLength(hotbar: Hotbar) { function ensureExactHotbarItemLength(hotbar: Hotbar) {
if (hotbar.items.length === defaultHotbarCells) { // if there are not enough items
// if we already have `defaultHotbarCells` then we are good to stop
return;
}
// otherwise, keep adding empty entries until full
while (hotbar.items.length < defaultHotbarCells) { while (hotbar.items.length < defaultHotbarCells) {
hotbar.items.push(null); hotbar.items.push(null);
} }

View File

@ -33,14 +33,18 @@ export interface HotbarItem {
} }
} }
export type Hotbar = Required<HotbarCreateOptions>; export type Hotbar = Required<CreateHotbarData>;
export interface HotbarCreateOptions { export interface CreateHotbarData {
id?: string; id?: string;
name: string; name: string;
items?: Tuple<HotbarItem | null, typeof defaultHotbarCells>; items?: Tuple<HotbarItem | null, typeof defaultHotbarCells>;
} }
export interface CreateHotbarOptions {
setActive?: boolean;
}
export const defaultHotbarCells = 12; // Number is chosen to easy hit any item with keyboard export const defaultHotbarCells = 12; // Number is chosen to easy hit any item with keyboard
export function getEmptyHotbar(name: string, id: string = uuid.v4()): Hotbar { export function getEmptyHotbar(name: string, id: string = uuid.v4()): Hotbar {

View File

@ -20,6 +20,7 @@
*/ */
export const dialogShowOpenDialogHandler = "dialog:show-open-dialog"; export const dialogShowOpenDialogHandler = "dialog:show-open-dialog";
export const catalogEntityRunListener = "catalog-entity:run";
export * from "./ipc"; export * from "./ipc";
export * from "./invalid-kubeconfig"; export * from "./invalid-kubeconfig";

View File

@ -34,7 +34,9 @@ const electronRemote = (() => {
if (ipcRenderer) { if (ipcRenderer) {
try { try {
return require("@electron/remote"); return require("@electron/remote");
} catch {} } catch {
// ignore temp
}
} }
return null; return null;

View File

@ -19,16 +19,19 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import mockFs from "mock-fs";
import { watch } from "chokidar"; import { watch } from "chokidar";
import path from "path"; import path from "path";
import type { ExtensionDiscovery } from "./extension-discovery";
import os from "os"; import os from "os";
import { Console } from "console"; import { Console } from "console";
import extensionDiscoveryInjectable from "./extension-discovery.injectable"; import * as fse from "fs-extra";
import extensionPackageRootDirectoryInjectable from "../extension-installer/extension-package-root-directory/extension-package-root-directory.injectable";
import installExtensionInjectable from "../extension-installer/install-extension/install-extension.injectable";
import { getDiForUnitTesting } from "../../main/getDiForUnitTesting"; import { getDiForUnitTesting } from "../../main/getDiForUnitTesting";
import extensionDiscoveryInjectable from "../extension-discovery/extension-discovery.injectable";
import type { ExtensionDiscovery } from "../extension-discovery/extension-discovery";
import installExtensionInjectable
from "../extension-installer/install-extension/install-extension.injectable";
import directoryForUserDataInjectable
from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
import mockFs from "mock-fs";
jest.setTimeout(60_000); jest.setTimeout(60_000);
@ -36,6 +39,8 @@ jest.mock("../../common/ipc");
jest.mock("chokidar", () => ({ jest.mock("chokidar", () => ({
watch: jest.fn(), watch: jest.fn(),
})); }));
jest.mock("fs-extra");
jest.mock("electron", () => ({ jest.mock("electron", () => ({
app: { app: {
getVersion: () => "99.99.99", getVersion: () => "99.99.99",
@ -54,6 +59,7 @@ jest.mock("electron", () => ({
console = new Console(process.stdout, process.stderr); // fix mockFS console = new Console(process.stdout, process.stderr); // fix mockFS
const mockedWatch = watch as jest.MockedFunction<typeof watch>; const mockedWatch = watch as jest.MockedFunction<typeof watch>;
const mockedFse = fse as jest.Mocked<typeof fse>;
describe("ExtensionDiscovery", () => { describe("ExtensionDiscovery", () => {
let extensionDiscovery: ExtensionDiscovery; let extensionDiscovery: ExtensionDiscovery;
@ -61,20 +67,10 @@ describe("ExtensionDiscovery", () => {
beforeEach(async () => { beforeEach(async () => {
const di = getDiForUnitTesting({ doGeneralOverrides: true }); const di = getDiForUnitTesting({ doGeneralOverrides: true });
mockFs({ di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data");
[`${os.homedir()}/.k8slens/extensions/my-extension/package.json`]:
JSON.stringify({
name: "my-extension",
}),
});
di.override(installExtensionInjectable, () => () => Promise.resolve()); di.override(installExtensionInjectable, () => () => Promise.resolve());
di.override( mockFs();
extensionPackageRootDirectoryInjectable,
() => "some-extension-packages-root",
);
await di.runSetups(); await di.runSetups();
@ -85,10 +81,20 @@ describe("ExtensionDiscovery", () => {
mockFs.restore(); mockFs.restore();
}); });
it("emits add for added extension", async (done) => { it("emits add for added extension", async (done) => {
let addHandler: (filePath: string) => void; let addHandler: (filePath: string) => void;
mockedFse.readJson.mockImplementation((p) => {
expect(p).toBe(path.join(os.homedir(), ".k8slens/extensions/my-extension/package.json"));
return {
name: "my-extension",
version: "1.0.0",
};
});
mockedFse.pathExists.mockImplementation(() => true);
const mockWatchInstance: any = { const mockWatchInstance: any = {
on: jest.fn((event: string, handler: typeof addHandler) => { on: jest.fn((event: string, handler: typeof addHandler) => {
if (event === "add") { if (event === "add") {
@ -99,42 +105,35 @@ describe("ExtensionDiscovery", () => {
}), }),
}; };
mockedWatch.mockImplementationOnce(() => mockWatchInstance as any); mockedWatch.mockImplementationOnce(() =>
(mockWatchInstance) as any,
);
// Need to force isLoaded to be true so that the file watching is started // Need to force isLoaded to be true so that the file watching is started
extensionDiscovery.isLoaded = true; extensionDiscovery.isLoaded = true;
await extensionDiscovery.watchExtensions(); await extensionDiscovery.watchExtensions();
extensionDiscovery.events.on("add", (extension) => { extensionDiscovery.events.on("add", extension => {
expect(extension).toEqual({ expect(extension).toEqual({
absolutePath: expect.any(String), absolutePath: expect.any(String),
id: path.normalize( id: path.normalize("some-directory-for-user-data/node_modules/my-extension/package.json"),
"some-extension-packages-root/node_modules/my-extension/package.json",
),
isBundled: false, isBundled: false,
isEnabled: false, isEnabled: false,
isCompatible: false, isCompatible: false,
manifest: { manifest: {
name: "my-extension", name: "my-extension",
version: "1.0.0",
}, },
manifestPath: path.normalize( manifestPath: path.normalize("some-directory-for-user-data/node_modules/my-extension/package.json"),
"some-extension-packages-root/node_modules/my-extension/package.json",
),
}); });
done(); done();
}); });
addHandler( addHandler(path.join(extensionDiscovery.localFolderPath, "/my-extension/package.json"));
path.join(
extensionDiscovery.localFolderPath,
"/my-extension/package.json",
),
);
}); });
it("doesn't emit add for added file under extension", async (done) => { it("doesn't emit add for added file under extension", async done => {
let addHandler: (filePath: string) => void; let addHandler: (filePath: string) => void;
const mockWatchInstance: any = { const mockWatchInstance: any = {
@ -147,7 +146,9 @@ describe("ExtensionDiscovery", () => {
}), }),
}; };
mockedWatch.mockImplementationOnce(() => mockWatchInstance as any); mockedWatch.mockImplementationOnce(() =>
(mockWatchInstance) as any,
);
// Need to force isLoaded to be true so that the file watching is started // Need to force isLoaded to be true so that the file watching is started
extensionDiscovery.isLoaded = true; extensionDiscovery.isLoaded = true;
@ -158,12 +159,7 @@ describe("ExtensionDiscovery", () => {
extensionDiscovery.events.on("add", onAdd); extensionDiscovery.events.on("add", onAdd);
addHandler( addHandler(path.join(extensionDiscovery.localFolderPath, "/my-extension/node_modules/dep/package.json"));
path.join(
extensionDiscovery.localFolderPath,
"/my-extension/node_modules/dep/package.json",
),
);
setTimeout(() => { setTimeout(() => {
expect(onAdd).not.toHaveBeenCalled(); expect(onAdd).not.toHaveBeenCalled();
@ -171,3 +167,4 @@ describe("ExtensionDiscovery", () => {
}, 10); }, 10);
}); });
}); });

View File

@ -271,7 +271,6 @@ export class ExtensionLoader {
registries.AppPreferenceRegistry.getInstance().add(extension.appPreferences), registries.AppPreferenceRegistry.getInstance().add(extension.appPreferences),
registries.EntitySettingRegistry.getInstance().add(extension.entitySettings), registries.EntitySettingRegistry.getInstance().add(extension.entitySettings),
registries.StatusBarRegistry.getInstance().add(extension.statusBarItems), registries.StatusBarRegistry.getInstance().add(extension.statusBarItems),
registries.CommandRegistry.getInstance().add(extension.commands),
registries.CatalogEntityDetailRegistry.getInstance().add(extension.catalogEntityDetailItems), registries.CatalogEntityDetailRegistry.getInstance().add(extension.catalogEntityDetailItems),
]; ];
@ -302,7 +301,6 @@ export class ExtensionLoader {
registries.KubeObjectDetailRegistry.getInstance().add(extension.kubeObjectDetailItems), registries.KubeObjectDetailRegistry.getInstance().add(extension.kubeObjectDetailItems),
registries.KubeObjectStatusRegistry.getInstance().add(extension.kubeObjectStatusTexts), registries.KubeObjectStatusRegistry.getInstance().add(extension.kubeObjectStatusTexts),
registries.WorkloadsOverviewDetailRegistry.getInstance().add(extension.kubeWorkloadsOverviewItems), registries.WorkloadsOverviewDetailRegistry.getInstance().add(extension.kubeWorkloadsOverviewItems),
registries.CommandRegistry.getInstance().add(extension.commands),
]; ];
this.events.on("remove", (removedExtension: LensRendererExtension) => { this.events.on("remove", (removedExtension: LensRendererExtension) => {

View File

@ -30,6 +30,7 @@ import type { TopBarRegistration } from "../renderer/components/layout/top-bar/t
import type { KubernetesCluster } from "../common/catalog-entities"; import type { KubernetesCluster } from "../common/catalog-entities";
import type { WelcomeMenuRegistration } from "../renderer/components/+welcome/welcome-menu-items/welcome-menu-registration"; import type { WelcomeMenuRegistration } from "../renderer/components/+welcome/welcome-menu-items/welcome-menu-registration";
import type { WelcomeBannerRegistration } from "../renderer/components/+welcome/welcome-banner-items/welcome-banner-registration"; import type { WelcomeBannerRegistration } from "../renderer/components/+welcome/welcome-banner-items/welcome-banner-registration";
import type { CommandRegistration } from "../renderer/components/command-palette/registered-commands/commands";
export class LensRendererExtension extends LensExtension { export class LensRendererExtension extends LensExtension {
globalPages: registries.PageRegistration[] = []; globalPages: registries.PageRegistration[] = [];
@ -42,7 +43,7 @@ export class LensRendererExtension extends LensExtension {
kubeObjectDetailItems: registries.KubeObjectDetailRegistration[] = []; kubeObjectDetailItems: registries.KubeObjectDetailRegistration[] = [];
kubeObjectMenuItems: registries.KubeObjectMenuRegistration[] = []; kubeObjectMenuItems: registries.KubeObjectMenuRegistration[] = [];
kubeWorkloadsOverviewItems: registries.WorkloadsOverviewDetailRegistration[] = []; kubeWorkloadsOverviewItems: registries.WorkloadsOverviewDetailRegistration[] = [];
commands: registries.CommandRegistration[] = []; commands: CommandRegistration[] = [];
welcomeMenus: WelcomeMenuRegistration[] = []; welcomeMenus: WelcomeMenuRegistration[] = [];
welcomeBanners: WelcomeBannerRegistration[] = []; welcomeBanners: WelcomeBannerRegistration[] = [];
catalogEntityDetailItems: registries.CatalogEntityDetailRegistration<CatalogEntity>[] = []; catalogEntityDetailItems: registries.CatalogEntityDetailRegistration<CatalogEntity>[] = [];

View File

@ -54,7 +54,7 @@ export class EntitySettingRegistry extends BaseRegistry<EntitySettingRegistratio
}; };
} }
getItemsForKind(kind: string, apiVersion: string, source?: string) { getItemsForKind = (kind: string, apiVersion: string, source?: string) => {
let items = this.getItems().filter((item) => { let items = this.getItems().filter((item) => {
return item.kind === kind && item.apiVersions.includes(apiVersion); return item.kind === kind && item.apiVersions.includes(apiVersion);
}); });
@ -66,5 +66,5 @@ export class EntitySettingRegistry extends BaseRegistry<EntitySettingRegistratio
} }
return items.sort((a, b) => (b.priority ?? 50) - (a.priority ?? 50)); return items.sort((a, b) => (b.priority ?? 50) - (a.priority ?? 50));
} };
} }

View File

@ -28,7 +28,6 @@ export * from "./status-bar-registry";
export * from "./kube-object-detail-registry"; export * from "./kube-object-detail-registry";
export * from "./kube-object-menu-registry"; export * from "./kube-object-menu-registry";
export * from "./kube-object-status-registry"; export * from "./kube-object-status-registry";
export * from "./command-registry";
export * from "./entity-setting-registry"; export * from "./entity-setting-registry";
export * from "./catalog-entity-detail-registry"; export * from "./catalog-entity-detail-registry";
export * from "./workloads-overview-detail-registry"; export * from "./workloads-overview-detail-registry";

View File

@ -18,8 +18,6 @@
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { getDiForUnitTesting } from "../getDiForUnitTesting";
const logger = { const logger = {
silly: jest.fn(), silly: jest.fn(),
debug: jest.fn(), debug: jest.fn(),
@ -47,6 +45,7 @@ jest.mock("winston", () => ({
}, },
})); }));
import { getDiForUnitTesting } from "../getDiForUnitTesting";
import { KubeconfigManager } from "../kubeconfig-manager/kubeconfig-manager"; import { KubeconfigManager } from "../kubeconfig-manager/kubeconfig-manager";
import mockFs from "mock-fs"; import mockFs from "mock-fs";
import type { Cluster } from "../../common/cluster/cluster"; import type { Cluster } from "../../common/cluster/cluster";
@ -69,7 +68,7 @@ describe("kubeconfig manager tests", () => {
di.override(directoryForTempInjectable, () => "some-directory-for-temp"); di.override(directoryForTempInjectable, () => "some-directory-for-temp");
const mockOpts = { mockFs({
"minikube-config.yml": JSON.stringify({ "minikube-config.yml": JSON.stringify({
apiVersion: "v1", apiVersion: "v1",
clusters: [{ clusters: [{
@ -91,9 +90,7 @@ describe("kubeconfig manager tests", () => {
kind: "Config", kind: "Config",
preferences: {}, preferences: {},
}), }),
}; });
mockFs(mockOpts);
await di.runSetups(); await di.runSetups();
@ -110,7 +107,7 @@ describe("kubeconfig manager tests", () => {
cluster.contextHandler = { cluster.contextHandler = {
ensureServer: () => Promise.resolve(), ensureServer: () => Promise.resolve(),
} as any; } as any;
jest.spyOn(KubeconfigManager.prototype, "resolveProxyUrl", "get").mockReturnValue("http://127.0.0.1:9191/foo"); jest.spyOn(KubeconfigManager.prototype, "resolveProxyUrl", "get").mockReturnValue("http://127.0.0.1:9191/foo");
}); });
@ -135,7 +132,7 @@ describe("kubeconfig manager tests", () => {
it("should remove 'temp' kube config on unlink and remove reference from inside class", async () => { it("should remove 'temp' kube config on unlink and remove reference from inside class", async () => {
const kubeConfManager = createKubeconfigManager(cluster); const kubeConfManager = createKubeconfigManager(cluster);
const configPath = await kubeConfManager.getPath(); const configPath = await kubeConfManager.getPath();
expect(await fse.pathExists(configPath)).toBe(true); expect(await fse.pathExists(configPath)).toBe(true);

View File

@ -22,25 +22,33 @@ import electronAppInjectable from "./electron-app/electron-app.injectable";
import getElectronAppPathInjectable from "./get-electron-app-path.injectable"; import getElectronAppPathInjectable from "./get-electron-app-path.injectable";
import { getDiForUnitTesting } from "../../getDiForUnitTesting"; import { getDiForUnitTesting } from "../../getDiForUnitTesting";
import type { App } from "electron"; import type { App } from "electron";
import registerChannelInjectable from "../register-channel/register-channel.injectable";
describe("get-electron-app-path", () => { describe("get-electron-app-path", () => {
let getElectronAppPath: (name: string) => string | null; let getElectronAppPath: (name: string) => string | null;
beforeEach(() => { beforeEach(async () => {
const di = getDiForUnitTesting(); const di = getDiForUnitTesting({ doGeneralOverrides: false });
const appStub = { const appStub = {
name: "some-app-name",
getPath: (name: string) => { getPath: (name: string) => {
if (name !== "some-existing-name") { if (name !== "some-existing-name") {
throw new Error("irrelevant"); throw new Error("irrelevant");
} }
return "some-existing-app-path"; return "some-existing-app-path";
}, },
// eslint-disable-next-line unused-imports/no-unused-vars-ts
setPath: (_, __) => undefined,
} as App; } as App;
di.override(electronAppInjectable, () => appStub); di.override(electronAppInjectable, () => appStub);
di.override(registerChannelInjectable, () => () => undefined);
await di.runSetups();
getElectronAppPath = di.inject(getElectronAppPathInjectable); getElectronAppPath = di.inject(getElectronAppPathInjectable);
}); });
@ -54,6 +62,6 @@ describe("get-electron-app-path", () => {
it("given app path does not exist, when called, returns null", () => { it("given app path does not exist, when called, returns null", () => {
const actual = getElectronAppPath("some-non-existing-name"); const actual = getElectronAppPath("some-non-existing-name");
expect(actual).toBe(null); expect(actual).toBe("");
}); });
}); });

View File

@ -31,6 +31,6 @@ export const getElectronAppPath =
try { try {
return app.getPath(name); return app.getPath(name);
} catch (e) { } catch (e) {
return null; return "";
} }
}; };

View File

@ -26,11 +26,13 @@ import logger from "../logger";
import { ensureDir, pathExists } from "fs-extra"; import { ensureDir, pathExists } from "fs-extra";
import * as lockFile from "proper-lockfile"; import * as lockFile from "proper-lockfile";
import { helmCli } from "../helm/helm-cli"; import { helmCli } from "../helm/helm-cli";
import { customRequest } from "../../common/request";
import { getBundledKubectlVersion } from "../../common/utils/app-version"; import { getBundledKubectlVersion } from "../../common/utils/app-version";
import { isDevelopment, isWindows, isTestEnv } from "../../common/vars"; import { isDevelopment, isWindows, isTestEnv } from "../../common/vars";
import { SemVer } from "semver"; import { SemVer } from "semver";
import { defaultPackageMirror, packageMirrors } from "../../common/user-store/preferences-helpers"; import { defaultPackageMirror, packageMirrors } from "../../common/user-store/preferences-helpers";
import got from "got/dist/source";
import { promisify } from "util";
import stream from "stream";
const bundledVersion = getBundledKubectlVersion(); const bundledVersion = getBundledKubectlVersion();
const kubectlMap: Map<string, string> = new Map([ const kubectlMap: Map<string, string> = new Map([
@ -51,7 +53,7 @@ const kubectlMap: Map<string, string> = new Map([
["1.21", bundledVersion], ["1.21", bundledVersion],
]); ]);
let bundledPath: string; let bundledPath: string;
const initScriptVersionString = "# lens-initscript v3\n"; const initScriptVersionString = "# lens-initscript v3";
export function bundledKubectlPath(): string { export function bundledKubectlPath(): string {
if (bundledPath) { return bundledPath; } if (bundledPath) { return bundledPath; }
@ -308,99 +310,87 @@ export class Kubectl {
logger.info(`Downloading kubectl ${this.kubectlVersion} from ${this.url} to ${this.path}`); logger.info(`Downloading kubectl ${this.kubectlVersion} from ${this.url} to ${this.path}`);
return new Promise<void>((resolve, reject) => { const downloadStream = got.stream({ url: this.url, decompress: true });
const stream = customRequest({ const fileWriteStream = fs.createWriteStream(this.path, { mode: 0o755 });
url: this.url, const pipeline = promisify(stream.pipeline);
gzip: true,
});
const file = fs.createWriteStream(this.path);
stream.on("complete", () => { await pipeline(downloadStream, fileWriteStream);
logger.debug("kubectl binary download finished"); logger.debug("kubectl binary download finished");
file.end();
});
stream.on("error", (error) => {
logger.error(error);
fs.unlink(this.path, () => {
// do nothing
});
reject(error);
});
file.on("close", () => {
logger.debug("kubectl binary download closed");
fs.chmod(this.path, 0o755, (err) => {
if (err) reject(err);
});
resolve();
});
stream.pipe(file);
});
} }
protected async writeInitScripts() { protected async writeInitScripts() {
const kubectlPath = this.dependencies.userStore.downloadKubectlBinaries ? this.dirname : path.dirname(this.getPathFromPreferences()); const kubectlPath = this.dependencies.userStore.downloadKubectlBinaries
? this.dirname
: path.dirname(this.getPathFromPreferences());
const helmPath = helmCli.getBinaryDir(); const helmPath = helmCli.getBinaryDir();
const fsPromises = fs.promises;
const bashScriptPath = path.join(this.dirname, ".bash_set_path"); const bashScriptPath = path.join(this.dirname, ".bash_set_path");
let bashScript = `${initScriptVersionString}`; const bashScript = [
initScriptVersionString,
bashScript += "tempkubeconfig=\"$KUBECONFIG\"\n"; "tempkubeconfig=\"$KUBECONFIG\"",
bashScript += "test -f \"/etc/profile\" && . \"/etc/profile\"\n"; "test -f \"/etc/profile\" && . \"/etc/profile\"",
bashScript += "if test -f \"$HOME/.bash_profile\"; then\n"; "if test -f \"$HOME/.bash_profile\"; then",
bashScript += " . \"$HOME/.bash_profile\"\n"; " . \"$HOME/.bash_profile\"",
bashScript += "elif test -f \"$HOME/.bash_login\"; then\n"; "elif test -f \"$HOME/.bash_login\"; then",
bashScript += " . \"$HOME/.bash_login\"\n"; " . \"$HOME/.bash_login\"",
bashScript += "elif test -f \"$HOME/.profile\"; then\n"; "elif test -f \"$HOME/.profile\"; then",
bashScript += " . \"$HOME/.profile\"\n"; " . \"$HOME/.profile\"",
bashScript += "fi\n"; "fi",
bashScript += `export PATH="${helmPath}:${kubectlPath}:$PATH"\n`; `export PATH="${helmPath}:${kubectlPath}:$PATH"`,
bashScript += "export KUBECONFIG=\"$tempkubeconfig\"\n"; 'export KUBECONFIG="$tempkubeconfig"',
`NO_PROXY=",\${NO_PROXY:-localhost},"`,
bashScript += `NO_PROXY=",\${NO_PROXY:-localhost},"\n`; `NO_PROXY="\${NO_PROXY//,localhost,/,}"`,
bashScript += `NO_PROXY="\${NO_PROXY//,localhost,/,}"\n`; `NO_PROXY="\${NO_PROXY//,127.0.0.1,/,}"`,
bashScript += `NO_PROXY="\${NO_PROXY//,127.0.0.1,/,}"\n`; `NO_PROXY="localhost,127.0.0.1\${NO_PROXY%,}"`,
bashScript += `NO_PROXY="localhost,127.0.0.1\${NO_PROXY%,}"\n`; "export NO_PROXY",
bashScript += "export NO_PROXY\n"; "unset tempkubeconfig",
bashScript += "unset tempkubeconfig\n"; ].join("\n");
await fsPromises.writeFile(bashScriptPath, bashScript.toString(), { mode: 0o644 });
const zshScriptPath = path.join(this.dirname, ".zlogin"); const zshScriptPath = path.join(this.dirname, ".zlogin");
let zshScript = `${initScriptVersionString}`; const zshScript = [
initScriptVersionString,
"tempkubeconfig=\"$KUBECONFIG\"",
zshScript += "tempkubeconfig=\"$KUBECONFIG\"\n"; // restore previous ZDOTDIR
// restore previous ZDOTDIR "export ZDOTDIR=\"$OLD_ZDOTDIR\"",
zshScript += "export ZDOTDIR=\"$OLD_ZDOTDIR\"\n";
// source all the files
zshScript += "test -f \"$OLD_ZDOTDIR/.zshenv\" && . \"$OLD_ZDOTDIR/.zshenv\"\n";
zshScript += "test -f \"$OLD_ZDOTDIR/.zprofile\" && . \"$OLD_ZDOTDIR/.zprofile\"\n";
zshScript += "test -f \"$OLD_ZDOTDIR/.zlogin\" && . \"$OLD_ZDOTDIR/.zlogin\"\n";
zshScript += "test -f \"$OLD_ZDOTDIR/.zshrc\" && . \"$OLD_ZDOTDIR/.zshrc\"\n";
// voodoo to replace any previous occurrences of kubectl path in the PATH // source all the files
zshScript += `kubectlpath="${kubectlPath}"\n`; "test -f \"$OLD_ZDOTDIR/.zshenv\" && . \"$OLD_ZDOTDIR/.zshenv\"",
zshScript += `helmpath="${helmPath}"\n`; "test -f \"$OLD_ZDOTDIR/.zprofile\" && . \"$OLD_ZDOTDIR/.zprofile\"",
zshScript += "p=\":$kubectlpath:\"\n"; "test -f \"$OLD_ZDOTDIR/.zlogin\" && . \"$OLD_ZDOTDIR/.zlogin\"",
zshScript += "d=\":$PATH:\"\n"; "test -f \"$OLD_ZDOTDIR/.zshrc\" && . \"$OLD_ZDOTDIR/.zshrc\"",
zshScript += `d=\${d//$p/:}\n`;
zshScript += `d=\${d/#:/}\n`; // voodoo to replace any previous occurrences of kubectl path in the PATH
zshScript += `export PATH="$helmpath:$kubectlpath:\${d/%:/}"\n`; `kubectlpath="${kubectlPath}"`,
zshScript += "export KUBECONFIG=\"$tempkubeconfig\"\n"; `helmpath="${helmPath}"`,
zshScript += `NO_PROXY=",\${NO_PROXY:-localhost},"\n`; "p=\":$kubectlpath:\"",
zshScript += `NO_PROXY="\${NO_PROXY//,localhost,/,}"\n`; "d=\":$PATH:\"",
zshScript += `NO_PROXY="\${NO_PROXY//,127.0.0.1,/,}"\n`; `d=\${d//$p/:}`,
zshScript += `NO_PROXY="localhost,127.0.0.1\${NO_PROXY%,}"\n`; `d=\${d/#:/}`,
zshScript += "export NO_PROXY\n"; `export PATH="$helmpath:$kubectlpath:\${d/%:/}"`,
zshScript += "unset tempkubeconfig\n"; "export KUBECONFIG=\"$tempkubeconfig\"",
zshScript += "unset OLD_ZDOTDIR\n"; `NO_PROXY=",\${NO_PROXY:-localhost},"`,
await fsPromises.writeFile(zshScriptPath, zshScript.toString(), { mode: 0o644 }); `NO_PROXY="\${NO_PROXY//,localhost,/,}"`,
`NO_PROXY="\${NO_PROXY//,127.0.0.1,/,}"`,
`NO_PROXY="localhost,127.0.0.1\${NO_PROXY%,}"`,
"export NO_PROXY",
"unset tempkubeconfig",
"unset OLD_ZDOTDIR",
].join("\n");
await Promise.all([
fs.promises.writeFile(bashScriptPath, bashScript, { mode: 0o644 }),
fs.promises.writeFile(zshScriptPath, zshScript, { mode: 0o644 }),
]);
} }
protected getDownloadMirror(): string { protected getDownloadMirror(): string {
// MacOS packages are only available from default // MacOS packages are only available from default
const mirror = packageMirrors.get(this.dependencies.userStore.downloadMirror) const { url } = packageMirrors.get(this.dependencies.userStore.downloadMirror)
?? packageMirrors.get(defaultPackageMirror); ?? packageMirrors.get(defaultPackageMirror);
return mirror.url; return url;
} }
} }

View File

@ -216,8 +216,16 @@ export function getAppMenu(
label: "Command Palette...", label: "Command Palette...",
accelerator: "Shift+CmdOrCtrl+P", accelerator: "Shift+CmdOrCtrl+P",
id: "command-palette", id: "command-palette",
click() { click(_m, _b, event) {
broadcastMessage("command-palette:open"); /**
* Don't broadcast unless it was triggered by menu iteration so that
* there aren't double events in renderer
*
* NOTE: this `?` is required because of a bug in playwright. https://github.com/microsoft/playwright/issues/10554
*/
if (!event?.triggeredByAccelerator) {
broadcastMessage("command-palette:open");
}
}, },
}, },
{ type: "separator" }, { type: "separator" },

View File

@ -186,7 +186,6 @@ export class Router {
// Port-forward API (the container port and local forwarding port are obtained from the query parameters) // Port-forward API (the container port and local forwarding port are obtained from the query parameters)
this.router.add({ method: "post", path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}` }, this.dependencies.routePortForward); this.router.add({ method: "post", path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}` }, this.dependencies.routePortForward);
this.router.add({ method: "get", path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}` }, PortForwardRoute.routeCurrentPortForward); this.router.add({ method: "get", path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}` }, PortForwardRoute.routeCurrentPortForward);
this.router.add({ method: "get", path: `${apiPrefix}/pods/port-forwards` }, PortForwardRoute.routeAllPortForwards);
this.router.add({ method: "delete", path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}` }, PortForwardRoute.routeCurrentPortForwardStop); this.router.add({ method: "delete", path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}` }, PortForwardRoute.routeCurrentPortForwardStop);
// Helm API // Helm API

View File

@ -22,7 +22,7 @@
import type { LensApiRequest } from "../router"; import type { LensApiRequest } from "../router";
import logger from "../logger"; import logger from "../logger";
import { respondJson } from "../utils/http-responses"; import { respondJson } from "../utils/http-responses";
import { PortForward, PortForwardArgs } from "./port-forward/port-forward"; import { PortForward } from "./port-forward/port-forward";
export class PortForwardRoute { export class PortForwardRoute {
static async routeCurrentPortForward(request: LensApiRequest) { static async routeCurrentPortForward(request: LensApiRequest) {
@ -40,31 +40,6 @@ export class PortForwardRoute {
respondJson(response, { port: portForward?.forwardPort ?? null }); respondJson(response, { port: portForward?.forwardPort ?? null });
} }
static async routeAllPortForwards(request: LensApiRequest) {
const { query, response } = request;
const clusterId = query.get("clusterId");
let portForwards: PortForwardArgs[] = PortForward.portForwards.map(f => (
{
clusterId: f.clusterId,
kind: f.kind,
namespace: f.namespace,
name: f.name,
port: f.port,
forwardPort: f.forwardPort,
protocol: f.protocol,
}),
);
if (clusterId) {
// filter out any not for this cluster
portForwards = portForwards.filter(pf => pf.clusterId == clusterId);
}
respondJson(response, { portForwards });
}
static async routeCurrentPortForwardStop(request: LensApiRequest) { static async routeCurrentPortForwardStop(request: LensApiRequest) {
const { params, query, response, cluster } = request; const { params, query, response, cluster } = request;
const { namespace, resourceType, resourceName } = params; const { namespace, resourceType, resourceName } = params;

View File

@ -32,8 +32,10 @@ describe("tray-menu-items", () => {
let trayMenuItems: IComputedValue<TrayMenuRegistration[]>; let trayMenuItems: IComputedValue<TrayMenuRegistration[]>;
let extensionsStub: ObservableMap<string, LensMainExtension>; let extensionsStub: ObservableMap<string, LensMainExtension>;
beforeEach(() => { beforeEach(async () => {
di = getDiForUnitTesting(); di = getDiForUnitTesting({ doGeneralOverrides: true });
await di.runSetups();
extensionsStub = new ObservableMap(); extensionsStub = new ObservableMap();

View File

@ -20,7 +20,7 @@
*/ */
import { computed, observable, makeObservable, action } from "mobx"; import { computed, observable, makeObservable, action } from "mobx";
import { ipcRendererOn } from "../../common/ipc"; import { catalogEntityRunListener, ipcRendererOn } from "../../common/ipc";
import { CatalogCategory, CatalogEntity, CatalogEntityData, catalogCategoryRegistry, CatalogCategoryRegistry, CatalogEntityKindData } from "../../common/catalog"; import { CatalogCategory, CatalogEntity, CatalogEntityData, catalogCategoryRegistry, CatalogCategoryRegistry, CatalogEntityKindData } from "../../common/catalog";
import "../../common/catalog-entities"; import "../../common/catalog-entities";
import type { Cluster } from "../../common/cluster/cluster"; import type { Cluster } from "../../common/cluster/cluster";
@ -32,6 +32,7 @@ import { CatalogRunEvent } from "../../common/catalog/catalog-run-event";
import { ipcRenderer } from "electron"; import { ipcRenderer } from "electron";
import { CatalogIpcEvents } from "../../common/ipc/catalog"; import { CatalogIpcEvents } from "../../common/ipc/catalog";
import { navigate } from "../navigation"; import { navigate } from "../navigation";
import { isMainFrame } from "process";
export type EntityFilter = (entity: CatalogEntity) => any; export type EntityFilter = (entity: CatalogEntity) => any;
export type CatalogEntityOnBeforeRun = (event: CatalogRunEvent) => void | Promise<void>; export type CatalogEntityOnBeforeRun = (event: CatalogRunEvent) => void | Promise<void>;
@ -85,6 +86,16 @@ export class CatalogEntityRegistry {
// Make sure that we get items ASAP and not the next time one of them changes // Make sure that we get items ASAP and not the next time one of them changes
ipcRenderer.send(CatalogIpcEvents.INIT); ipcRenderer.send(CatalogIpcEvents.INIT);
if (isMainFrame) {
ipcRendererOn(catalogEntityRunListener, (event, id: string) => {
const entity = this.getById(id);
if (entity) {
this.onRun(entity);
}
});
}
} }
@action updateItems(items: (CatalogEntityData & CatalogEntityKindData)[]) { @action updateItems(items: (CatalogEntityData & CatalogEntityKindData)[]) {

View File

@ -21,13 +21,14 @@
import { when } from "mobx"; import { when } from "mobx";
import { catalogCategoryRegistry } from "../../../common/catalog"; import { catalogCategoryRegistry } from "../../../common/catalog";
import { catalogEntityRegistry } from "../../../renderer/api/catalog-entity-registry"; import { catalogEntityRegistry } from "../catalog-entity-registry";
import { isActiveRoute } from "../../../renderer/navigation"; import { isActiveRoute } from "../../navigation";
import type { GeneralEntity } from "../../../common/catalog-entities";
export async function setEntityOnRouteMatch() { export async function setEntityOnRouteMatch() {
await when(() => catalogEntityRegistry.entities.size > 0); await when(() => catalogEntityRegistry.entities.size > 0);
const entities = catalogEntityRegistry.getItemsForCategory(catalogCategoryRegistry.getByName("General")); const entities: GeneralEntity[] = catalogEntityRegistry.getItemsForCategory(catalogCategoryRegistry.getByName("General"));
const activeEntity = entities.find(entity => isActiveRoute(entity.spec.path)); const activeEntity = entities.find(entity => isActiveRoute(entity.spec.path));
if (activeEntity) { if (activeEntity) {

View File

@ -41,24 +41,17 @@ import { WeblinkStore } from "../common/weblink-store";
import { ThemeStore } from "./theme.store"; import { ThemeStore } from "./theme.store";
import { SentryInit } from "../common/sentry"; import { SentryInit } from "../common/sentry";
import { registerCustomThemes } from "./components/monaco-editor"; import { registerCustomThemes } from "./components/monaco-editor";
import { getDi } from "./components/getDi"; import { getDi } from "./getDi";
import { DiContextProvider } from "@ogre-tools/injectable-react"; import { DiContextProvider } from "@ogre-tools/injectable-react";
import type { DependencyInjectionContainer } from "@ogre-tools/injectable"; import type { DependencyInjectionContainer } from "@ogre-tools/injectable";
import extensionLoaderInjectable from "../extensions/extension-loader/extension-loader.injectable"; import extensionLoaderInjectable from "../extensions/extension-loader/extension-loader.injectable";
import extensionDiscoveryInjectable from "../extensions/extension-discovery/extension-discovery.injectable";
import extensionInstallationStateStoreInjectable from "../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
import extensionDiscoveryInjectable
from "../extensions/extension-discovery/extension-discovery.injectable";
import extensionInstallationStateStoreInjectable
from "../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
import clusterStoreInjectable from "../common/cluster-store/cluster-store.injectable"; import clusterStoreInjectable from "../common/cluster-store/cluster-store.injectable";
import userStoreInjectable from "../common/user-store/user-store.injectable"; import userStoreInjectable from "../common/user-store/user-store.injectable";
import initRootFrameInjectable from "./frames/root-frame/init-root-frame/init-root-frame.injectable"; import initRootFrameInjectable from "./frames/root-frame/init-root-frame/init-root-frame.injectable";
import initClusterFrameInjectable import initClusterFrameInjectable from "./frames/cluster-frame/init-cluster-frame/init-cluster-frame.injectable";
from "./frames/cluster-frame/init-cluster-frame/init-cluster-frame.injectable"; import commandOverlayInjectable from "./components/command-palette/command-overlay.injectable";
import createTerminalTabInjectable
from "./components/dock/create-terminal-tab/create-terminal-tab.injectable";
if (process.isMainFrame) { if (process.isMainFrame) {
SentryInit(); SentryInit();
@ -80,7 +73,7 @@ async function attachChromeDebugger() {
export async function bootstrap(di: DependencyInjectionContainer) { export async function bootstrap(di: DependencyInjectionContainer) {
await di.runSetups(); await di.runSetups();
const rootElem = document.getElementById("app"); const rootElem = document.getElementById("app");
const logPrefix = `[BOOTSTRAP-${process.isMainFrame ? "ROOT" : "CLUSTER"}-FRAME]:`; const logPrefix = `[BOOTSTRAP-${process.isMainFrame ? "ROOT" : "CLUSTER"}-FRAME]:`;
@ -93,11 +86,6 @@ export async function bootstrap(di: DependencyInjectionContainer) {
logger.info(`${logPrefix} initializing Registries`); logger.info(`${logPrefix} initializing Registries`);
initializers.initRegistries(); initializers.initRegistries();
const createTerminalTab = di.inject(createTerminalTabInjectable);
logger.info(`${logPrefix} initializing CommandRegistry`);
initializers.initCommandRegistry(createTerminalTab);
logger.info(`${logPrefix} initializing EntitySettingsRegistry`); logger.info(`${logPrefix} initializing EntitySettingsRegistry`);
initializers.initEntitySettingsRegistry(); initializers.initEntitySettingsRegistry();
@ -117,7 +105,9 @@ export async function bootstrap(di: DependencyInjectionContainer) {
initializers.initCatalogCategoryRegistryEntries(); initializers.initCatalogCategoryRegistryEntries();
logger.info(`${logPrefix} initializing Catalog`); logger.info(`${logPrefix} initializing Catalog`);
initializers.initCatalog(); initializers.initCatalog({
openCommandDialog: di.inject(commandOverlayInjectable).open,
});
const extensionLoader = di.inject(extensionLoaderInjectable); const extensionLoader = di.inject(extensionLoaderInjectable);

View File

@ -29,7 +29,7 @@ import { CatalogCategoryRegistry, CatalogEntity, CatalogEntityActionContext, Cat
import { CatalogEntityRegistry } from "../../api/catalog-entity-registry"; import { CatalogEntityRegistry } from "../../api/catalog-entity-registry";
import { CatalogEntityDetailRegistry } from "../../../extensions/registries"; import { CatalogEntityDetailRegistry } from "../../../extensions/registries";
import type { CatalogEntityStore } from "./catalog-entity-store/catalog-entity.store"; import type { CatalogEntityStore } from "./catalog-entity-store/catalog-entity.store";
import { getDiForUnitTesting } from "../getDiForUnitTesting"; import { getDiForUnitTesting } from "../../getDiForUnitTesting";
import type { DependencyInjectionContainer } from "@ogre-tools/injectable"; import type { DependencyInjectionContainer } from "@ogre-tools/injectable";
import catalogEntityStoreInjectable from "./catalog-entity-store/catalog-entity-store.injectable"; import catalogEntityStoreInjectable from "./catalog-entity-store/catalog-entity-store.injectable";
import catalogEntityRegistryInjectable import catalogEntityRegistryInjectable

View File

@ -66,22 +66,15 @@ export class CRDStore extends KubeObjectStore<CustomResourceDefinition> {
@computed get groups() { @computed get groups() {
const groups: Record<string, CustomResourceDefinition[]> = {}; const groups: Record<string, CustomResourceDefinition[]> = {};
return this.items.reduce((groups, crd) => { for (const crd of this.items) {
const group = crd.getGroup(); (groups[crd.getGroup()] ??= []).push(crd);
}
if (!groups[group]) groups[group] = []; return groups;
groups[group].push(crd);
return groups;
}, groups);
} }
getByGroup(group: string, pluralName: string) { getByGroup(group: string, pluralName: string) {
const crdInGroup = this.groups[group]; return this.groups[group]?.find(crd => crd.getPluralName() === pluralName);
if (!crdInGroup) return null;
return crdInGroup.find(crd => crd.getPluralName() === pluralName);
} }
getByObject(obj: KubeObject) { getByObject(obj: KubeObject) {

View File

@ -18,13 +18,15 @@
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import portForwardStoreInjectable from "../port-forward-store.injectable";
const modifyPortForwardInjectable = getInjectable({ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
instantiate: (di) => di.inject(portForwardStoreInjectable).modify, import { computed } from "mobx";
import { crdStore } from "./crd.store";
const customResourceDefinitionsInjectable = getInjectable({
instantiate: () => computed(() => [...crdStore.items]),
lifecycle: lifecycleEnum.singleton, lifecycle: lifecycleEnum.singleton,
}); });
export default modifyPortForwardInjectable; export default customResourceDefinitionsInjectable;

View File

@ -30,13 +30,12 @@ import { ConfirmDialog } from "../../confirm-dialog";
import { Extensions } from "../extensions"; import { Extensions } from "../extensions";
import mockFs from "mock-fs"; import mockFs from "mock-fs";
import { mockWindow } from "../../../../../__mocks__/windowMock"; import { mockWindow } from "../../../../../__mocks__/windowMock";
import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
import extensionLoaderInjectable from "../../../../extensions/extension-loader/extension-loader.injectable"; import extensionLoaderInjectable from "../../../../extensions/extension-loader/extension-loader.injectable";
import { getDiForUnitTesting } from "../../getDiForUnitTesting";
import { DiRender, renderFor } from "../../test-utils/renderFor"; import { DiRender, renderFor } from "../../test-utils/renderFor";
import extensionDiscoveryInjectable from "../../../../extensions/extension-discovery/extension-discovery.injectable"; import extensionDiscoveryInjectable from "../../../../extensions/extension-discovery/extension-discovery.injectable";
import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
import directoryForDownloadsInjectable import directoryForDownloadsInjectable from "../../../../common/app-paths/directory-for-downloads/directory-for-downloads.injectable";
from "../../../../common/app-paths/directory-for-downloads/directory-for-downloads.injectable";
mockWindow(); mockWindow();

View File

@ -21,16 +21,14 @@
import React from "react"; import React from "react";
import { boundMethod, cssNames } from "../../utils"; import { boundMethod, cssNames } from "../../utils";
import { openPortForward, PortForwardItem } from "../../port-forward"; import { openPortForward, PortForwardItem, PortForwardStore } from "../../port-forward";
import { MenuActions, MenuActionsProps } from "../menu/menu-actions"; import { MenuActions, MenuActionsProps } from "../menu/menu-actions";
import { MenuItem } from "../menu"; import { MenuItem } from "../menu";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { Notifications } from "../notifications"; import { Notifications } from "../notifications";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
import removePortForwardInjectable import portForwardDialogModelInjectable from "../../port-forward/port-forward-dialog-model/port-forward-dialog-model.injectable";
from "../../port-forward/port-forward-store/remove-port-forward/remove-port-forward.injectable"; import portForwardStoreInjectable from "../../port-forward/port-forward-store/port-forward-store.injectable";
import portForwardDialogModelInjectable
from "../../port-forward/port-forward-dialog-model/port-forward-dialog-model.injectable";
interface Props extends MenuActionsProps { interface Props extends MenuActionsProps {
portForward: PortForwardItem; portForward: PortForwardItem;
@ -38,7 +36,7 @@ interface Props extends MenuActionsProps {
} }
interface Dependencies { interface Dependencies {
removePortForward: (item: PortForwardItem) => Promise<void>, portForwardStore: PortForwardStore,
openPortForwardDialog: (item: PortForwardItem) => void openPortForwardDialog: (item: PortForwardItem) => void
} }
@ -48,12 +46,48 @@ class NonInjectedPortForwardMenu extends React.Component<Props & Dependencies> {
const { portForward } = this.props; const { portForward } = this.props;
try { try {
this.props.removePortForward(portForward); this.portForwardStore.remove(portForward);
} catch (error) { } catch (error) {
Notifications.error(`Error occurred stopping the port-forward from port ${portForward.forwardPort}. The port-forward may still be active.`); Notifications.error(`Error occurred stopping the port-forward from port ${portForward.forwardPort}. The port-forward may still be active.`);
} }
} }
get portForwardStore() {
return this.props.portForwardStore;
}
private startPortForwarding = async () => {
const { portForward } = this.props;
const pf = await this.portForwardStore.start(portForward);
if (pf.status === "Disabled") {
const { name, kind, forwardPort } = portForward;
Notifications.error(`Error occurred starting port-forward, the local port ${forwardPort} may not be available or the ${kind} ${name} may not be reachable`);
}
};
renderStartStopMenuItem() {
const { portForward, toolbar } = this.props;
if (portForward.status === "Active") {
return (
<MenuItem onClick={() => this.portForwardStore.stop(portForward)}>
<Icon material="stop" tooltip="Stop port-forward" interactive={toolbar} />
<span className="title">Stop</span>
</MenuItem>
);
}
return (
<MenuItem onClick={this.startPortForwarding}>
<Icon material="play_arrow" tooltip="Start port-forward" interactive={toolbar} />
<span className="title">Start</span>
</MenuItem>
);
}
renderContent() { renderContent() {
const { portForward, toolbar } = this.props; const { portForward, toolbar } = this.props;
@ -61,14 +95,17 @@ class NonInjectedPortForwardMenu extends React.Component<Props & Dependencies> {
return ( return (
<> <>
<MenuItem onClick={() => openPortForward(this.props.portForward)}> { portForward.status === "Active" &&
<Icon material="open_in_browser" interactive={toolbar} tooltip="Open in browser" /> <MenuItem onClick={() => openPortForward(portForward)}>
<span className="title">Open</span> <Icon material="open_in_browser" interactive={toolbar} tooltip="Open in browser" />
</MenuItem> <span className="title">Open</span>
</MenuItem>
}
<MenuItem onClick={() => this.props.openPortForwardDialog(portForward)}> <MenuItem onClick={() => this.props.openPortForwardDialog(portForward)}>
<Icon material="edit" tooltip="Change port or protocol" interactive={toolbar} /> <Icon material="edit" tooltip="Change port or protocol" interactive={toolbar} />
<span className="title">Edit</span> <span className="title">Edit</span>
</MenuItem> </MenuItem>
{this.renderStartStopMenuItem()}
</> </>
); );
} }
@ -93,7 +130,7 @@ export const PortForwardMenu = withInjectables<Dependencies, Props>(
{ {
getProps: (di, props) => ({ getProps: (di, props) => ({
removePortForward: di.inject(removePortForwardInjectable), portForwardStore: di.inject(portForwardStoreInjectable),
openPortForwardDialog: di.inject(portForwardDialogModelInjectable).open, openPortForwardDialog: di.inject(portForwardDialogModelInjectable).open,
...props, ...props,
}), }),

View File

@ -76,7 +76,7 @@ class NonInjectedPortForwards extends React.Component<Props & Dependencies> {
showDetails = (item: PortForwardItem) => { showDetails = (item: PortForwardItem) => {
navigation.push(portForwardsURL({ navigation.push(portForwardsURL({
params: { params: {
forwardport: String(item.getForwardPort()), forwardport: item.getId(),
}, },
})); }));
}; };

View File

@ -24,20 +24,22 @@ import "./service-port-component.scss";
import React from "react"; import React from "react";
import { disposeOnUnmount, observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import type { Service, ServicePort } from "../../../common/k8s-api/endpoints"; import type { Service, ServicePort } from "../../../common/k8s-api/endpoints";
import { observable, makeObservable, reaction } from "mobx"; import { action, makeObservable, observable, reaction } from "mobx";
import { cssNames } from "../../utils"; import { cssNames } from "../../utils";
import { Notifications } from "../notifications"; import { Notifications } from "../notifications";
import { Button } from "../button"; import { Button } from "../button";
import { aboutPortForwarding, getPortForward, getPortForwards, openPortForward, PortForwardStore, predictProtocol } from "../../port-forward";
import type { ForwardedPort } from "../../port-forward"; import type { ForwardedPort } from "../../port-forward";
import {
aboutPortForwarding,
notifyErrorPortForwarding, openPortForward,
PortForwardStore,
predictProtocol,
} from "../../port-forward";
import { Spinner } from "../spinner"; import { Spinner } from "../spinner";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
import portForwardStoreInjectable from "../../port-forward/port-forward-store/port-forward-store.injectable"; import portForwardStoreInjectable from "../../port-forward/port-forward-store/port-forward-store.injectable";
import removePortForwardInjectable from "../../port-forward/port-forward-store/remove-port-forward/remove-port-forward.injectable"; import portForwardDialogModelInjectable from "../../port-forward/port-forward-dialog-model/port-forward-dialog-model.injectable";
import portForwardDialogModelInjectable import logger from "../../../common/logger";
from "../../port-forward/port-forward-dialog-model/port-forward-dialog-model.injectable";
import addPortForwardInjectable
from "../../port-forward/port-forward-store/add-port-forward/add-port-forward.injectable";
interface Props { interface Props {
service: Service; service: Service;
@ -46,9 +48,7 @@ interface Props {
interface Dependencies { interface Dependencies {
portForwardStore: PortForwardStore portForwardStore: PortForwardStore
removePortForward: (item: ForwardedPort) => Promise<void> openPortForwardDialog: (item: ForwardedPort, options: { openInBrowser: boolean, onClose: () => void }) => void
addPortForward: (item: ForwardedPort) => Promise<number>
openPortForwardDialog: (item: ForwardedPort, options: { openInBrowser: boolean }) => void
} }
@observer @observer
@ -56,6 +56,7 @@ class NonInjectedServicePortComponent extends React.Component<Props & Dependenci
@observable waiting = false; @observable waiting = false;
@observable forwardPort = 0; @observable forwardPort = 0;
@observable isPortForwarded = false; @observable isPortForwarded = false;
@observable isActive = false;
constructor(props: Props & Dependencies) { constructor(props: Props & Dependencies) {
super(props); super(props);
@ -65,13 +66,18 @@ class NonInjectedServicePortComponent extends React.Component<Props & Dependenci
componentDidMount() { componentDidMount() {
disposeOnUnmount(this, [ disposeOnUnmount(this, [
reaction(() => [this.props.portForwardStore.portForwards, this.props.service], () => this.checkExistingPortForwarding()), reaction(() => this.props.service, () => this.checkExistingPortForwarding()),
]); ]);
} }
get portForwardStore() {
return this.props.portForwardStore;
}
@action
async checkExistingPortForwarding() { async checkExistingPortForwarding() {
const { service, port } = this.props; const { service, port } = this.props;
const portForward: ForwardedPort = { let portForward: ForwardedPort = {
kind: "service", kind: "service",
name: service.getName(), name: service.getName(),
namespace: service.getNs(), namespace: service.getNs(),
@ -79,57 +85,66 @@ class NonInjectedServicePortComponent extends React.Component<Props & Dependenci
forwardPort: this.forwardPort, forwardPort: this.forwardPort,
}; };
let activePort: number;
try { try {
activePort = await getPortForward(portForward) ?? 0; portForward = await this.portForwardStore.getPortForward(portForward);
} catch (error) { } catch (error) {
this.isPortForwarded = false; this.isPortForwarded = false;
this.isActive = false;
return; return;
} }
this.forwardPort = activePort; this.forwardPort = portForward.forwardPort;
this.isPortForwarded = activePort ? true : false; this.isPortForwarded = true;
this.isActive = portForward.status === "Active";
} }
@action
async portForward() { async portForward() {
const { service, port } = this.props; const { service, port } = this.props;
const portForward: ForwardedPort = { let portForward: ForwardedPort = {
kind: "service", kind: "service",
name: service.getName(), name: service.getName(),
namespace: service.getNs(), namespace: service.getNs(),
port: port.port, port: port.port,
forwardPort: this.forwardPort, forwardPort: this.forwardPort,
protocol: predictProtocol(port.name), protocol: predictProtocol(port.name),
status: "Active",
}; };
this.waiting = true; this.waiting = true;
try { try {
// determine how many port-forwards are already active // determine how many port-forwards already exist
const { length } = await getPortForwards(); const { length } = this.portForwardStore.getPortForwards();
this.forwardPort = await this.props.addPortForward(portForward); if (!this.isPortForwarded) {
portForward = await this.portForwardStore.add(portForward);
} else if (!this.isActive) {
portForward = await this.portForwardStore.start(portForward);
}
if (this.forwardPort) { this.forwardPort = portForward.forwardPort;
portForward.forwardPort = this.forwardPort;
if (portForward.status === "Active") {
openPortForward(portForward); openPortForward(portForward);
this.isPortForwarded = true;
// if this is the first port-forward show the about notification // if this is the first port-forward show the about notification
if (!length) { if (!length) {
aboutPortForwarding(); aboutPortForwarding();
} }
} else {
notifyErrorPortForwarding(`Error occurred starting port-forward, the local port may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`);
} }
} catch (error) { } catch (error) {
Notifications.error(`Error occurred starting port-forward, the local port may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`); logger.error("[SERVICE-PORT-COMPONENT]:", error, portForward);
this.checkExistingPortForwarding();
} finally { } finally {
this.checkExistingPortForwarding();
this.waiting = false; this.waiting = false;
} }
} }
@action
async stopPortForward() { async stopPortForward() {
const { service, port } = this.props; const { service, port } = this.props;
const portForward: ForwardedPort = { const portForward: ForwardedPort = {
@ -143,12 +158,12 @@ class NonInjectedServicePortComponent extends React.Component<Props & Dependenci
this.waiting = true; this.waiting = true;
try { try {
await this.props.removePortForward(portForward); await this.portForwardStore.remove(portForward);
this.isPortForwarded = false;
} catch (error) { } catch (error) {
Notifications.error(`Error occurred stopping the port-forward from port ${portForward.forwardPort}.`); Notifications.error(`Error occurred stopping the port-forward from port ${portForward.forwardPort}.`);
this.checkExistingPortForwarding();
} finally { } finally {
this.checkExistingPortForwarding();
this.forwardPort = 0;
this.waiting = false; this.waiting = false;
} }
} }
@ -156,7 +171,7 @@ class NonInjectedServicePortComponent extends React.Component<Props & Dependenci
render() { render() {
const { port, service } = this.props; const { port, service } = this.props;
const portForwardAction = async () => { const portForwardAction = action(async () => {
if (this.isPortForwarded) { if (this.isPortForwarded) {
await this.stopPortForward(); await this.stopPortForward();
} else { } else {
@ -169,16 +184,16 @@ class NonInjectedServicePortComponent extends React.Component<Props & Dependenci
protocol: predictProtocol(port.name), protocol: predictProtocol(port.name),
}; };
this.props.openPortForwardDialog(portForward, { openInBrowser: true }); this.props.openPortForwardDialog(portForward, { openInBrowser: true, onClose: () => this.checkExistingPortForwarding() });
} }
}; });
return ( return (
<div className={cssNames("ServicePortComponent", { waiting: this.waiting })}> <div className={cssNames("ServicePortComponent", { waiting: this.waiting })}>
<span title="Open in a browser" onClick={() => this.portForward()}> <span title="Open in a browser" onClick={() => this.portForward()}>
{port.toString()} {port.toString()}
</span> </span>
<Button primary onClick={() => portForwardAction()}> {this.isPortForwarded ? "Stop" : "Forward..."} </Button> <Button primary onClick={portForwardAction}> {this.isPortForwarded ? (this.isActive ? "Stop/Remove" : "Remove") : "Forward..."} </Button>
{this.waiting && ( {this.waiting && (
<Spinner /> <Spinner />
)} )}
@ -193,8 +208,6 @@ export const ServicePortComponent = withInjectables<Dependencies, Props>(
{ {
getProps: (di, props) => ({ getProps: (di, props) => ({
portForwardStore: di.inject(portForwardStoreInjectable), portForwardStore: di.inject(portForwardStoreInjectable),
removePortForward: di.inject(removePortForwardInjectable),
addPortForward: di.inject(addPortForwardInjectable),
openPortForwardDialog: di.inject(portForwardDialogModelInjectable).open, openPortForwardDialog: di.inject(portForwardDialogModelInjectable).open,
...props, ...props,
}), }),

View File

@ -34,6 +34,7 @@ $service-status-color-list: (
$port-forward-status-color-list: ( $port-forward-status-color-list: (
active: var(--colorOk), active: var(--colorOk),
disabled: var(--colorSoftError)
); );
@mixin port-forward-status-colors { @mixin port-forward-status-colors {

View File

@ -24,7 +24,7 @@ import { ClusterRoleBindingDialog } from "../dialog";
import { clusterRolesStore } from "../../+cluster-roles/store"; import { clusterRolesStore } from "../../+cluster-roles/store";
import { ClusterRole } from "../../../../../common/k8s-api/endpoints"; import { ClusterRole } from "../../../../../common/k8s-api/endpoints";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; import { getDiForUnitTesting } from "../../../../getDiForUnitTesting";
import { DiRender, renderFor } from "../../../test-utils/renderFor"; import { DiRender, renderFor } from "../../../test-utils/renderFor";
jest.mock("../../+cluster-roles/store"); jest.mock("../../+cluster-roles/store");

View File

@ -24,7 +24,7 @@ import React from "react";
import { clusterRolesStore } from "../../+cluster-roles/store"; import { clusterRolesStore } from "../../+cluster-roles/store";
import { ClusterRole } from "../../../../../common/k8s-api/endpoints"; import { ClusterRole } from "../../../../../common/k8s-api/endpoints";
import { RoleBindingDialog } from "../dialog"; import { RoleBindingDialog } from "../dialog";
import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; import { getDiForUnitTesting } from "../../../../getDiForUnitTesting";
import type { DiRender } from "../../../test-utils/renderFor"; import type { DiRender } from "../../../test-utils/renderFor";
import { renderFor } from "../../../test-utils/renderFor"; import { renderFor } from "../../../test-utils/renderFor";
import directoryForUserDataInjectable import directoryForUserDataInjectable

View File

@ -24,7 +24,7 @@ import { screen } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect"; import "@testing-library/jest-dom/extend-expect";
import { defaultWidth, Welcome } from "../welcome"; import { defaultWidth, Welcome } from "../welcome";
import { computed } from "mobx"; import { computed } from "mobx";
import { getDiForUnitTesting } from "../../getDiForUnitTesting"; import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
import type { DiRender } from "../../test-utils/renderFor"; import type { DiRender } from "../../test-utils/renderFor";
import { renderFor } from "../../test-utils/renderFor"; import { renderFor } from "../../test-utils/renderFor";
import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable";
@ -46,8 +46,10 @@ describe("<Welcome/>", () => {
let di: ConfigurableDependencyInjectionContainer; let di: ConfigurableDependencyInjectionContainer;
let welcomeBannersStub: WelcomeBannerRegistration[]; let welcomeBannersStub: WelcomeBannerRegistration[];
beforeEach(() => { beforeEach(async () => {
di = getDiForUnitTesting(); di = getDiForUnitTesting({ doGeneralOverrides: true });
await di.runSetups();
render = renderFor(di); render = renderFor(di);

View File

@ -24,7 +24,7 @@ import "@testing-library/jest-dom/extend-expect";
import { fireEvent } from "@testing-library/react"; import { fireEvent } from "@testing-library/react";
import type { IToleration } from "../../../../common/k8s-api/workload-kube-object"; import type { IToleration } from "../../../../common/k8s-api/workload-kube-object";
import { PodTolerations } from "../pod-tolerations"; import { PodTolerations } from "../pod-tolerations";
import { getDiForUnitTesting } from "../../getDiForUnitTesting"; import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
import { DiRender, renderFor } from "../../test-utils/renderFor"; import { DiRender, renderFor } from "../../test-utils/renderFor";
import directoryForLensLocalStorageInjectable import directoryForLensLocalStorageInjectable
from "../../../../common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable"; from "../../../../common/directory-for-lens-local-storage/directory-for-lens-local-storage.injectable";

View File

@ -24,20 +24,24 @@ import "./pod-container-port.scss";
import React from "react"; import React from "react";
import { disposeOnUnmount, observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import type { Pod } from "../../../common/k8s-api/endpoints"; import type { Pod } from "../../../common/k8s-api/endpoints";
import { observable, makeObservable, reaction } from "mobx"; import { action, makeObservable, observable, reaction } from "mobx";
import { cssNames } from "../../utils"; import { cssNames } from "../../utils";
import { Notifications } from "../notifications"; import { Notifications } from "../notifications";
import { Button } from "../button"; import { Button } from "../button";
import { aboutPortForwarding, getPortForward, getPortForwards, openPortForward, PortForwardStore, predictProtocol } from "../../port-forward";
import type { ForwardedPort } from "../../port-forward"; import type { ForwardedPort } from "../../port-forward";
import {
aboutPortForwarding,
notifyErrorPortForwarding,
openPortForward,
PortForwardStore,
predictProtocol,
} from "../../port-forward";
import { Spinner } from "../spinner"; import { Spinner } from "../spinner";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
import portForwardStoreInjectable from "../../port-forward/port-forward-store/port-forward-store.injectable"; import portForwardStoreInjectable from "../../port-forward/port-forward-store/port-forward-store.injectable";
import removePortForwardInjectable from "../../port-forward/port-forward-store/remove-port-forward/remove-port-forward.injectable"; import portForwardDialogModelInjectable from "../../port-forward/port-forward-dialog-model/port-forward-dialog-model.injectable";
import portForwardDialogModelInjectable import logger from "../../../common/logger";
from "../../port-forward/port-forward-dialog-model/port-forward-dialog-model.injectable";
import addPortForwardInjectable
from "../../port-forward/port-forward-store/add-port-forward/add-port-forward.injectable";
interface Props { interface Props {
pod: Pod; pod: Pod;
@ -45,14 +49,12 @@ interface Props {
name?: string; name?: string;
containerPort: number; containerPort: number;
protocol: string; protocol: string;
} };
} }
interface Dependencies { interface Dependencies {
portForwardStore: PortForwardStore; portForwardStore: PortForwardStore;
removePortForward: (item: ForwardedPort) => Promise<void>; openPortForwardDialog: (item: ForwardedPort, options: { openInBrowser: boolean, onClose: () => void }) => void;
addPortForward: (item: ForwardedPort) => Promise<number>;
openPortForwardDialog: (item: ForwardedPort, options: { openInBrowser: boolean }) => void;
} }
@observer @observer
@ -60,6 +62,7 @@ class NonInjectedPodContainerPort extends React.Component<Props & Dependencies>
@observable waiting = false; @observable waiting = false;
@observable forwardPort = 0; @observable forwardPort = 0;
@observable isPortForwarded = false; @observable isPortForwarded = false;
@observable isActive = false;
constructor(props: Props & Dependencies) { constructor(props: Props & Dependencies) {
super(props); super(props);
@ -69,13 +72,18 @@ class NonInjectedPodContainerPort extends React.Component<Props & Dependencies>
componentDidMount() { componentDidMount() {
disposeOnUnmount(this, [ disposeOnUnmount(this, [
reaction(() => [this.props.portForwardStore.portForwards, this.props.pod], () => this.checkExistingPortForwarding()), reaction(() => this.props.pod, () => this.checkExistingPortForwarding()),
]); ]);
} }
get portForwardStore() {
return this.props.portForwardStore;
}
@action
async checkExistingPortForwarding() { async checkExistingPortForwarding() {
const { pod, port } = this.props; const { pod, port } = this.props;
const portForward: ForwardedPort = { let portForward: ForwardedPort = {
kind: "pod", kind: "pod",
name: pod.getName(), name: pod.getName(),
namespace: pod.getNs(), namespace: pod.getNs(),
@ -83,57 +91,64 @@ class NonInjectedPodContainerPort extends React.Component<Props & Dependencies>
forwardPort: this.forwardPort, forwardPort: this.forwardPort,
}; };
let activePort: number;
try { try {
activePort = await getPortForward(portForward) ?? 0; portForward = await this.portForwardStore.getPortForward(portForward);
} catch (error) { } catch (error) {
this.isPortForwarded = false; this.isPortForwarded = false;
this.isActive = false;
return; return;
} }
this.forwardPort = activePort; this.forwardPort = portForward.forwardPort;
this.isPortForwarded = activePort ? true : false; this.isPortForwarded = true;
this.isActive = portForward.status === "Active";
} }
@action
async portForward() { async portForward() {
const { pod, port } = this.props; const { pod, port } = this.props;
const portForward: ForwardedPort = { let portForward: ForwardedPort = {
kind: "pod", kind: "pod",
name: pod.getName(), name: pod.getName(),
namespace: pod.getNs(), namespace: pod.getNs(),
port: port.containerPort, port: port.containerPort,
forwardPort: this.forwardPort, forwardPort: this.forwardPort,
protocol: predictProtocol(port.name), protocol: predictProtocol(port.name),
status: "Active",
}; };
this.waiting = true; this.waiting = true;
try { try {
// determine how many port-forwards are already active // determine how many port-forwards already exist
const { length } = await getPortForwards(); const { length } = this.portForwardStore.getPortForwards();
this.forwardPort = await this.props.addPortForward(portForward); if (!this.isPortForwarded) {
portForward = await this.portForwardStore.add(portForward);
} else if (!this.isActive) {
portForward = await this.portForwardStore.start(portForward);
}
if (this.forwardPort) { if (portForward.status === "Active") {
portForward.forwardPort = this.forwardPort;
openPortForward(portForward); openPortForward(portForward);
this.isPortForwarded = true;
// if this is the first port-forward show the about notification // if this is the first port-forward show the about notification
if (!length) { if (!length) {
aboutPortForwarding(); aboutPortForwarding();
} }
} else {
notifyErrorPortForwarding(`Error occurred starting port-forward, the local port may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`);
} }
} catch (error) { } catch (error) {
Notifications.error(`Error occurred starting port-forward, the local port may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`); logger.error("[POD-CONTAINER-PORT]:", error, portForward);
this.checkExistingPortForwarding();
} finally { } finally {
this.checkExistingPortForwarding();
this.waiting = false; this.waiting = false;
} }
} }
@action
async stopPortForward() { async stopPortForward() {
const { pod, port } = this.props; const { pod, port } = this.props;
const portForward: ForwardedPort = { const portForward: ForwardedPort = {
@ -147,12 +162,12 @@ class NonInjectedPodContainerPort extends React.Component<Props & Dependencies>
this.waiting = true; this.waiting = true;
try { try {
await this.props.removePortForward(portForward); await this.portForwardStore.remove(portForward);
this.isPortForwarded = false;
} catch (error) { } catch (error) {
Notifications.error(`Error occurred stopping the port-forward from port ${portForward.forwardPort}.`); Notifications.error(`Error occurred stopping the port-forward from port ${portForward.forwardPort}.`);
this.checkExistingPortForwarding();
} finally { } finally {
this.checkExistingPortForwarding();
this.forwardPort = 0;
this.waiting = false; this.waiting = false;
} }
} }
@ -162,7 +177,7 @@ class NonInjectedPodContainerPort extends React.Component<Props & Dependencies>
const { name, containerPort, protocol } = port; const { name, containerPort, protocol } = port;
const text = `${name ? `${name}: ` : ""}${containerPort}/${protocol}`; const text = `${name ? `${name}: ` : ""}${containerPort}/${protocol}`;
const portForwardAction = async () => { const portForwardAction = action(async () => {
if (this.isPortForwarded) { if (this.isPortForwarded) {
await this.stopPortForward(); await this.stopPortForward();
} else { } else {
@ -175,16 +190,16 @@ class NonInjectedPodContainerPort extends React.Component<Props & Dependencies>
protocol: predictProtocol(port.name), protocol: predictProtocol(port.name),
}; };
this.props.openPortForwardDialog(portForward, { openInBrowser: true }); this.props.openPortForwardDialog(portForward, { openInBrowser: true, onClose: () => this.checkExistingPortForwarding() });
} }
}; });
return ( return (
<div className={cssNames("PodContainerPort", { waiting: this.waiting })}> <div className={cssNames("PodContainerPort", { waiting: this.waiting })}>
<span title="Open in a browser" onClick={() => this.portForward()}> <span title="Open in a browser" onClick={() => this.portForward()}>
{text} {text}
</span> </span>
<Button primary onClick={() => portForwardAction()}> {this.isPortForwarded ? "Stop" : "Forward..."} </Button> <Button primary onClick={portForwardAction}> {this.isPortForwarded ? (this.isActive ? "Stop/Remove" : "Remove") : "Forward..."} </Button>
{this.waiting && ( {this.waiting && (
<Spinner /> <Spinner />
)} )}
@ -199,8 +214,6 @@ export const PodContainerPort = withInjectables<Dependencies, Props>(
{ {
getProps: (di, props) => ({ getProps: (di, props) => ({
portForwardStore: di.inject(portForwardStoreInjectable), portForwardStore: di.inject(portForwardStoreInjectable),
removePortForward: di.inject(removePortForwardInjectable),
addPortForward: di.inject(addPortForwardInjectable),
openPortForwardDialog: di.inject(portForwardDialogModelInjectable).open, openPortForwardDialog: di.inject(portForwardDialogModelInjectable).open,
...props, ...props,
}), }),

View File

@ -19,40 +19,49 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { computed } from "mobx"; import { withInjectables } from "@ogre-tools/injectable-react";
import { computed, IComputedValue } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import React from "react"; import React from "react";
import { broadcastMessage, catalogEntityRunListener } from "../../../common/ipc";
import type { CatalogEntity } from "../../api/catalog-entity"; import type { CatalogEntity } from "../../api/catalog-entity";
import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
import { CommandOverlay } from "../command-palette"; import commandOverlayInjectable from "../command-palette/command-overlay.injectable";
import { Select } from "../select"; import { Select } from "../select";
@observer interface Dependencies {
export class ActivateEntityCommand extends React.Component { closeCommandOverlay: () => void;
@computed get options() { entities: IComputedValue<CatalogEntity[]>;
return catalogEntityRegistry.items.map(entity => ({
label: `${entity.kind}: ${entity.getName()}`,
value: entity,
}));
}
onSelect(entity: CatalogEntity): void {
catalogEntityRegistry.onRun(entity);
CommandOverlay.close();
}
render() {
return (
<Select
menuPortalTarget={null}
onChange={(v) => this.onSelect(v.value)}
components={{ DropdownIndicator: null, IndicatorSeparator: null }}
menuIsOpen={true}
options={this.options}
autoFocus={true}
escapeClearsValue={false}
placeholder="Activate entity ..."
/>
);
}
} }
const NonInjectedActivateEntityCommand = observer(({ closeCommandOverlay, entities }: Dependencies) => {
const options = entities.get().map(entity => ({
label: `${entity.kind}: ${entity.getName()}`,
value: entity,
}));
const onSelect = (entity: CatalogEntity): void => {
broadcastMessage(catalogEntityRunListener, entity.getId());
closeCommandOverlay();
};
return (
<Select
menuPortalTarget={null}
onChange={(v) => onSelect(v.value)}
components={{ DropdownIndicator: null, IndicatorSeparator: null }}
menuIsOpen={true}
options={options}
autoFocus={true}
escapeClearsValue={false}
placeholder="Activate entity ..."
/>
);
});
export const ActivateEntityCommand = withInjectables<Dependencies>(NonInjectedActivateEntityCommand, {
getProps: di => ({
closeCommandOverlay: di.inject(commandOverlayInjectable).close,
entities: computed(() => [...catalogEntityRegistry.items]),
}),
});

View File

@ -21,21 +21,26 @@
import React from "react"; import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { CommandOverlay } from "../command-palette";
import { Input } from "../input"; import { Input } from "../input";
import { isUrl } from "../input/input_validators"; import { isUrl } from "../input/input_validators";
import { WeblinkStore } from "../../../common/weblink-store"; import { WeblinkStore } from "../../../common/weblink-store";
import { computed, makeObservable, observable } from "mobx"; import { computed, makeObservable, observable } from "mobx";
import { withInjectables } from "@ogre-tools/injectable-react";
import commandOverlayInjectable from "../command-palette/command-overlay.injectable";
interface Dependencies {
closeCommandOverlay: () => void;
}
@observer @observer
export class WeblinkAddCommand extends React.Component { class NonInjectedWeblinkAddCommand extends React.Component<Dependencies> {
@observable url = ""; @observable url = "";
@observable nameHidden = true; @observable nameHidden = true;
@observable dirty = false; @observable dirty = false;
constructor(props: {}) { constructor(props: Dependencies) {
super(props); super(props);
makeObservable(this); makeObservable(this);
} }
@ -55,8 +60,7 @@ export class WeblinkAddCommand extends React.Component {
name: name || this.url, name: name || this.url,
url: this.url, url: this.url,
}); });
this.props.closeCommandOverlay();
CommandOverlay.close();
} }
@computed get showValidation() { @computed get showValidation() {
@ -100,3 +104,10 @@ export class WeblinkAddCommand extends React.Component {
); );
} }
} }
export const WeblinkAddCommand = withInjectables<Dependencies>(NonInjectedWeblinkAddCommand, {
getProps: (di, props) => ({
closeCommandOverlay: di.inject(commandOverlayInjectable).close,
...props,
}),
});

View File

@ -22,19 +22,31 @@
import React from "react"; import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { HotbarStore } from "../../../common/hotbar-store"; import hotbarManagerInjectable from "../../../common/hotbar-store.injectable";
import { CommandOverlay } from "../command-palette";
import { HotbarSwitchCommand } from "../hotbar/hotbar-switch-command"; import { HotbarSwitchCommand } from "../hotbar/hotbar-switch-command";
import { withInjectables } from "@ogre-tools/injectable-react";
import commandOverlayInjectable from "../command-palette/command-overlay.injectable";
export const ActiveHotbarName = observer(() => { interface Dependencies {
return ( openCommandOverlay: (component: React.ReactElement) => void;
<div activeHotbarName: () => string | undefined;
className="flex items-center" }
data-testid="current-hotbar-name"
onClick={() => CommandOverlay.open(<HotbarSwitchCommand />)} const NonInjectedActiveHotbarName = observer(({ openCommandOverlay, activeHotbarName }: Dependencies) => (
> <div
<Icon material="bookmarks" className="mr-2" size={14}/> className="flex items-center"
{HotbarStore.getInstance().getActive()?.name} data-testid="current-hotbar-name"
</div> onClick={() => openCommandOverlay(<HotbarSwitchCommand />)}
); >
<Icon material="bookmarks" className="mr-2" size={14} />
{activeHotbarName()}
</div>
));
export const ActiveHotbarName = withInjectables<Dependencies>(NonInjectedActiveHotbarName, {
getProps: (di, props) => ({
activeHotbarName: () => di.inject(hotbarManagerInjectable).getActive()?.name,
openCommandOverlay: di.inject(commandOverlayInjectable).open,
...props,
}),
}); });

View File

@ -21,21 +21,19 @@
import React from "react"; import React from "react";
import mockFs from "mock-fs"; import mockFs from "mock-fs";
import { render, fireEvent } from "@testing-library/react"; import { fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect"; import "@testing-library/jest-dom/extend-expect";
import { BottomBar } from "./bottom-bar"; import { BottomBar } from "./bottom-bar";
import { StatusBarRegistry } from "../../../extensions/registries"; import { StatusBarRegistry } from "../../../extensions/registries";
import { HotbarStore } from "../../../common/hotbar-store"; import hotbarManagerInjectable from "../../../common/hotbar-store.injectable";
import { CommandOverlay } from "../command-palette";
import { HotbarSwitchCommand } from "../hotbar/hotbar-switch-command"; import { HotbarSwitchCommand } from "../hotbar/hotbar-switch-command";
import { ActiveHotbarName } from "./active-hotbar-name"; import { ActiveHotbarName } from "./active-hotbar-name";
import { getDisForUnitTesting } from "../../../test-utils/get-dis-for-unit-testing"; import { getDiForUnitTesting } from "../../getDiForUnitTesting";
import { DiRender, renderFor } from "../test-utils/renderFor";
import type { DependencyInjectionContainer } from "@ogre-tools/injectable";
import commandOverlayInjectable from "../command-palette/command-overlay.injectable";
import { getEmptyHotbar } from "../../../common/hotbar-types";
jest.mock("../command-palette", () => ({
CommandOverlay: {
open: jest.fn(),
},
}));
jest.mock("electron", () => ({ jest.mock("electron", () => ({
app: { app: {
@ -53,26 +51,36 @@ jest.mock("electron", () => ({
}, },
})); }));
const foobarHotbar = getEmptyHotbar("foobar");
describe("<BottomBar />", () => { describe("<BottomBar />", () => {
let di: DependencyInjectionContainer;
let render: DiRender;
beforeEach(async () => { beforeEach(async () => {
const dis = getDisForUnitTesting({ doGeneralOverrides: true });
await dis.runSetups();
const mockOpts = { const mockOpts = {
"tmp": { "tmp": {
"test-store.json": JSON.stringify({}), "test-store.json": JSON.stringify({}),
}, },
}; };
di = getDiForUnitTesting({ doGeneralOverrides: true });
mockFs(mockOpts); mockFs(mockOpts);
render = renderFor(di);
di.override(hotbarManagerInjectable, () => ({
getActive: () => foobarHotbar,
} as any));
await di.runSetups();
StatusBarRegistry.createInstance(); StatusBarRegistry.createInstance();
HotbarStore.createInstance();
}); });
afterEach(() => { afterEach(() => {
StatusBarRegistry.resetInstance(); StatusBarRegistry.resetInstance();
HotbarStore.resetInstance();
mockFs.restore(); mockFs.restore();
}); });
@ -82,24 +90,20 @@ describe("<BottomBar />", () => {
expect(container).toBeInstanceOf(HTMLElement); expect(container).toBeInstanceOf(HTMLElement);
}); });
it("renders w/o errors when .getItems() returns unexpected (not type compliant) data", async () => { it.each([
StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => undefined); undefined,
expect(() => render(<BottomBar />)).not.toThrow(); "hello",
StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => "hello"); 6,
expect(() => render(<BottomBar />)).not.toThrow(); null,
StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => 6); [],
expect(() => render(<BottomBar />)).not.toThrow(); [{}],
StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => null); {},
expect(() => render(<BottomBar />)).not.toThrow(); ])("renders w/o errors when .getItems() returns not type compliant (%p)", val => {
StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => []); StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => val);
expect(() => render(<BottomBar />)).not.toThrow();
StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [{}]);
expect(() => render(<BottomBar />)).not.toThrow();
StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => { return {};});
expect(() => render(<BottomBar />)).not.toThrow(); expect(() => render(<BottomBar />)).not.toThrow();
}); });
it("renders items [{item: React.ReactNode}] (4.0.0-rc.1)", async () => { it("renders items [{item: React.ReactNode}] (4.0.0-rc.1)", () => {
const testId = "testId"; const testId = "testId";
const text = "heee"; const text = "heee";
@ -108,10 +112,10 @@ describe("<BottomBar />", () => {
]); ]);
const { getByTestId } = render(<BottomBar />); const { getByTestId } = render(<BottomBar />);
expect(await getByTestId(testId)).toHaveTextContent(text); expect(getByTestId(testId)).toHaveTextContent(text);
}); });
it("renders items [{item: () => React.ReactNode}] (4.0.0-rc.1+)", async () => { it("renders items [{item: () => React.ReactNode}] (4.0.0-rc.1+)", () => {
const testId = "testId"; const testId = "testId";
const text = "heee"; const text = "heee";
@ -120,33 +124,25 @@ describe("<BottomBar />", () => {
]); ]);
const { getByTestId } = render(<BottomBar />); const { getByTestId } = render(<BottomBar />);
expect(await getByTestId(testId)).toHaveTextContent(text); expect(getByTestId(testId)).toHaveTextContent(text);
}); });
it("show default hotbar name", () => { it("shows active hotbar name", () => {
StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [ StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [
{ item: () => <ActiveHotbarName/> }, { item: () => <ActiveHotbarName/> },
]); ]);
const { getByTestId } = render(<BottomBar />); const { getByTestId } = render(<BottomBar />);
expect(getByTestId("current-hotbar-name")).toHaveTextContent("default"); expect(getByTestId("current-hotbar-name")).toHaveTextContent("foobar");
});
it("show active hotbar name", () => {
StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [
{ item: () => <ActiveHotbarName/> },
]);
const { getByTestId } = render(<BottomBar />);
HotbarStore.getInstance().add({
id: "new",
name: "new",
}, { setActive: true });
expect(getByTestId("current-hotbar-name")).toHaveTextContent("new");
}); });
it("opens command palette on click", () => { it("opens command palette on click", () => {
const mockOpen = jest.fn();
di.override(commandOverlayInjectable, () => ({
open: mockOpen,
}) as any);
StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [ StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [
{ item: () => <ActiveHotbarName/> }, { item: () => <ActiveHotbarName/> },
]); ]);
@ -155,7 +151,8 @@ describe("<BottomBar />", () => {
fireEvent.click(activeHotbar); fireEvent.click(activeHotbar);
expect(CommandOverlay.open).toHaveBeenCalledWith(<HotbarSwitchCommand />);
expect(mockOpen).toHaveBeenCalledWith(<HotbarSwitchCommand />);
}); });
it("sort positioned items properly", () => { it("sort positioned items properly", () => {

View File

@ -38,7 +38,7 @@ import * as routes from "../../../common/routes";
import { DeleteClusterDialog } from "../delete-cluster-dialog"; import { DeleteClusterDialog } from "../delete-cluster-dialog";
import { reaction } from "mobx"; import { reaction } from "mobx";
import { navigation } from "../../navigation"; import { navigation } from "../../navigation";
import { setEntityOnRouteMatch } from "../../../main/catalog-sources/helpers/general-active-sync"; import { setEntityOnRouteMatch } from "../../api/helpers/general-active-sync";
import { catalogURL, getPreviousTabUrl } from "../../../common/routes"; import { catalogURL, getPreviousTabUrl } from "../../../common/routes";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
import { TopBar } from "../layout/top-bar/top-bar"; import { TopBar } from "../layout/top-bar/top-bar";

View File

@ -21,68 +21,100 @@
import "./command-container.scss"; import "./command-container.scss";
import { observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import React from "react"; import React from "react";
import { Dialog } from "../dialog"; import { Dialog } from "../dialog";
import { ipcRendererOn } from "../../../common/ipc";
import { CommandDialog } from "./command-dialog"; import { CommandDialog } from "./command-dialog";
import type { ClusterId } from "../../../common/cluster-types"; import type { ClusterId } from "../../../common/cluster-types";
import commandOverlayInjectable, { CommandOverlay } from "./command-overlay.injectable";
import { isMac } from "../../../common/vars";
import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
import { CommandRegistration, CommandRegistry } from "../../../extensions/registries/command-registry"; import { broadcastMessage, ipcRendererOn } from "../../../common/ipc";
import { CommandOverlay } from "./command-overlay"; import { getMatchedClusterId } from "../../navigation";
import type { Disposer } from "../../utils";
import { withInjectables } from "@ogre-tools/injectable-react";
import windowAddEventListenerInjectable from "../../window/event-listener.injectable";
export interface CommandContainerProps { export interface CommandContainerProps {
clusterId?: ClusterId; clusterId?: ClusterId;
} }
interface Dependencies {
addWindowEventListener: <K extends keyof WindowEventMap>(type: K, listener: (this: Window, ev: WindowEventMap[K]) => any, options?: boolean | AddEventListenerOptions) => Disposer;
commandOverlay: CommandOverlay,
}
@observer @observer
export class CommandContainer extends React.Component<CommandContainerProps> { class NonInjectedCommandContainer extends React.Component<CommandContainerProps & Dependencies> {
private escHandler(event: KeyboardEvent) { private escHandler(event: KeyboardEvent) {
const { commandOverlay } = this.props;
if (event.key === "Escape") { if (event.key === "Escape") {
event.stopPropagation(); event.stopPropagation();
CommandOverlay.close(); commandOverlay.close();
} }
} }
private findCommandById(commandId: string) { handleCommandPalette = () => {
return CommandRegistry.getInstance().getItems().find((command) => command.id === commandId); const { commandOverlay } = this.props;
} const clusterIsActive = getMatchedClusterId() !== undefined;
private runCommand(command: CommandRegistration) { if (clusterIsActive) {
command.action({ broadcastMessage(`command-palette:${catalogEntityRegistry.activeEntity.getId()}:open`);
entity: catalogEntityRegistry.activeEntity, } else {
}); commandOverlay.open(<CommandDialog />);
}
};
onKeyboardShortcut(action: () => void) {
return ({ key, shiftKey, ctrlKey, altKey, metaKey }: KeyboardEvent) => {
const ctrlOrCmd = isMac ? metaKey && !ctrlKey : !metaKey && ctrlKey;
if (key === "p" && shiftKey && ctrlOrCmd && !altKey) {
action();
}
};
} }
componentDidMount() { componentDidMount() {
if (this.props.clusterId) { const { clusterId, addWindowEventListener, commandOverlay } = this.props;
ipcRendererOn(`command-palette:run-action:${this.props.clusterId}`, (event, commandId: string) => {
const command = this.findCommandById(commandId);
if (command) { const action = clusterId
this.runCommand(command); ? () => commandOverlay.open(<CommandDialog />)
} : this.handleCommandPalette;
}); const ipcChannel = clusterId
} else { ? `command-palette:${clusterId}:open`
ipcRendererOn("command-palette:open", () => { : "command-palette:open";
CommandOverlay.open(<CommandDialog />);
}); disposeOnUnmount(this, [
} ipcRendererOn(ipcChannel, action),
window.addEventListener("keyup", (e) => this.escHandler(e), true); addWindowEventListener("keydown", this.onKeyboardShortcut(action)),
addWindowEventListener("keyup", (e) => this.escHandler(e), true),
]);
} }
render() { render() {
const { commandOverlay } = this.props;
return ( return (
<Dialog <Dialog
isOpen={CommandOverlay.isOpen} isOpen={commandOverlay.isOpen}
animated={true} animated={true}
onClose={CommandOverlay.close} onClose={commandOverlay.close}
modal={false} modal={false}
> >
<div id="command-container"> <div id="command-container">
{CommandOverlay.component} {commandOverlay.component}
</div> </div>
</Dialog> </Dialog>
); );
} }
} }
export const CommandContainer = withInjectables<Dependencies, CommandContainerProps>(NonInjectedCommandContainer, {
getProps: (di, props) => ({
addWindowEventListener: di.inject(windowAddEventListenerInjectable),
commandOverlay: di.inject(commandOverlayInjectable),
...props,
}),
});

View File

@ -21,108 +21,107 @@
import { Select } from "../select"; import { Select } from "../select";
import { computed, makeObservable, observable } from "mobx"; import type { IComputedValue } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import React from "react"; import React, { useState } from "react";
import { CommandRegistry } from "../../../extensions/registries/command-registry"; import commandOverlayInjectable from "./command-overlay.injectable";
import { CommandOverlay } from "./command-overlay";
import { broadcastMessage } from "../../../common/ipc";
import { navigate } from "../../navigation";
import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
import type { CatalogEntity } from "../../../common/catalog"; import type { CatalogEntity } from "../../../common/catalog";
import { clusterViewURL } from "../../../common/routes"; import { navigate } from "../../navigation";
import { broadcastMessage } from "../../../common/ipc";
import { IpcRendererNavigationEvents } from "../../navigation/events";
import type { RegisteredCommand } from "./registered-commands/commands";
import { iter } from "../../utils";
import { orderBy } from "lodash";
import { withInjectables } from "@ogre-tools/injectable-react";
import registeredCommandsInjectable from "./registered-commands/registered-commands.injectable";
import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
@observer interface Dependencies {
export class CommandDialog extends React.Component { commands: IComputedValue<Map<string, RegisteredCommand>>;
@observable menuIsOpen = true; activeEntity?: CatalogEntity;
@observable searchValue: any = undefined; closeCommandOverlay: () => void;
}
constructor(props: {}) { const NonInjectedCommandDialog = observer(({ commands, activeEntity, closeCommandOverlay }: Dependencies) => {
super(props); const [searchValue, setSearchValue] = useState("");
makeObservable(this);
}
@computed get activeEntity(): CatalogEntity | undefined { const executeAction = (commandId: string) => {
return catalogEntityRegistry.activeEntity; const command = commands.get().get(commandId);
}
@computed get options() {
const registry = CommandRegistry.getInstance();
const context = {
entity: this.activeEntity,
};
return registry.getItems().filter((command) => {
if (command.scope === "entity" && !this.activeEntity) {
return false;
}
try {
return command.isActive?.(context) ?? true;
} catch(e) {
console.error(e);
}
return false;
})
.map((command) => ({
value: command.id,
label: command.title,
}))
.sort((a, b) => a.label > b.label ? 1 : -1);
}
private onChange(value: string) {
const registry = CommandRegistry.getInstance();
const command = registry.getItems().find((cmd) => cmd.id === value);
if (!command) { if (!command) {
return; return;
} }
try { try {
CommandOverlay.close(); closeCommandOverlay();
command.action({
entity: activeEntity,
navigate: (url, opts = {}) => {
const { forceRootFrame = false } = opts;
if (command.scope === "global") { if (forceRootFrame) {
command.action({ broadcastMessage(IpcRendererNavigationEvents.NAVIGATE_IN_APP, url);
entity: this.activeEntity, } else {
}); navigate(url);
} else if(this.activeEntity) { }
navigate(clusterViewURL({ },
params: { });
clusterId: this.activeEntity.metadata.uid, } catch (error) {
},
}));
broadcastMessage(`command-palette:run-action:${this.activeEntity.metadata.uid}`, command.id);
}
} catch(error) {
console.error("[COMMAND-DIALOG] failed to execute command", command.id, error); console.error("[COMMAND-DIALOG] failed to execute command", command.id, error);
} }
} };
render() { const context = {
return ( entity: activeEntity,
<Select };
menuPortalTarget={null} const activeCommands = iter.filter(commands.get().values(), command => {
onChange={v => this.onChange(v.value)} try {
components={{ return command.isActive(context);
DropdownIndicator: null, } catch (error) {
IndicatorSeparator: null, console.error(`[COMMAND-DIALOG]: isActive for ${command.id} threw an error, defaulting to false`, error);
}} }
menuIsOpen={this.menuIsOpen}
options={this.options} return false;
autoFocus={true} });
escapeClearsValue={false} const options = Array.from(activeCommands, ({ id, title }) => ({
data-test-id="command-palette-search" value: id,
placeholder="Type a command or search&hellip;" label: typeof title === "function"
onInputChange={(newValue, { action }) => { ? title(context)
if (action === "input-change") { : title,
this.searchValue = newValue; }));
}
}} // Make sure the options are in the correct order
inputValue={this.searchValue} orderBy(options, "label", "asc");
/>
); return (
} <Select
} menuPortalTarget={null}
onChange={v => executeAction(v.value)}
components={{
DropdownIndicator: null,
IndicatorSeparator: null,
}}
menuIsOpen
options={options}
autoFocus={true}
escapeClearsValue={false}
data-test-id="command-palette-search"
placeholder="Type a command or search&hellip;"
onInputChange={(newValue, { action }) => {
if (action === "input-change") {
setSearchValue(newValue);
}
}}
inputValue={searchValue}
/>
);
});
export const CommandDialog = withInjectables<Dependencies>(NonInjectedCommandDialog, {
getProps: di => ({
commands: di.inject(registeredCommandsInjectable),
// TODO: replace with injection
activeEntity: catalogEntityRegistry.activeEntity,
closeCommandOverlay: di.inject(commandOverlayInjectable).close,
}),
});

View File

@ -19,29 +19,37 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { observable } from "mobx"; import { observable } from "mobx";
import React from "react"; import React from "react";
export class CommandOverlay { export class CommandOverlay {
static #component = observable.box<React.ReactElement | null>(null, { deep: false }); #component = observable.box<React.ReactElement | null>(null, { deep: false });
static get isOpen(): boolean { get isOpen(): boolean {
return Boolean(CommandOverlay.#component.get()); return Boolean(this.#component.get());
} }
static open(component: React.ReactElement) { open = (component: React.ReactElement) => {
if (!React.isValidElement(component)) { if (!React.isValidElement(component)) {
throw new TypeError("CommandOverlay.open must be passed a valid ReactElement"); throw new TypeError("CommandOverlay.open must be passed a valid ReactElement");
} }
CommandOverlay.#component.set(component); this.#component.set(component);
} };
static close() { close = () => {
CommandOverlay.#component.set(null); this.#component.set(null);
} };
static get component(): React.ReactElement | null { get component(): React.ReactElement | null {
return CommandOverlay.#component.get(); return this.#component.get();
} }
} }
const commandOverlayInjectable = getInjectable({
instantiate: () => new CommandOverlay(),
lifecycle: lifecycleEnum.singleton,
});
export default commandOverlayInjectable;

View File

@ -21,4 +21,4 @@
export * from "./command-container"; export * from "./command-container";
export * from "./command-dialog"; export * from "./command-dialog";
export * from "./command-overlay"; export * from "./command-overlay.injectable";

View File

@ -19,34 +19,55 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
// Extensions API -> Commands import type { CatalogEntity } from "../../../../common/catalog";
import { BaseRegistry } from "./base-registry";
import type { LensExtension } from "../lens-extension";
import type { CatalogEntity } from "../../common/catalog";
/**
* The context given to commands when executed
*/
export interface CommandContext { export interface CommandContext {
entity?: CatalogEntity; entity?: CatalogEntity;
} }
export interface CommandActionNavigateOptions {
/**
* If `true` then the navigate will only navigate on the root frame and not
* within a cluster
* @default false
*/
forceRootFrame?: boolean;
}
export interface CommandActionContext extends CommandContext {
navigate: (url: string, opts?: CommandActionNavigateOptions) => void;
}
export interface CommandRegistration { export interface CommandRegistration {
/**
* The ID of the command, must be globally unique
*/
id: string; id: string;
title: string;
scope: "entity" | "global"; /**
action: (context: CommandContext) => void; * The display name of the command in the command pallet
*/
title: string | ((context: CommandContext) => string);
/**
* @deprecated use `isActive` instead since there is always an entity active
*/
scope?: "global" | "entity";
/**
* The function to run when this command is selected
*/
action: (context: CommandActionContext) => void;
/**
* A function that determines if the command is active.
*
* @default () => true
*/
isActive?: (context: CommandContext) => boolean; isActive?: (context: CommandContext) => boolean;
} }
export class CommandRegistry extends BaseRegistry<CommandRegistration> { export type RegisteredCommand = Required<Omit<CommandRegistration, "scope">>;
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);
}
}

View File

@ -0,0 +1,235 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import React from "react";
import * as routes from "../../../../common/routes";
import { EntitySettingRegistry, RegisteredEntitySetting } from "../../../../extensions/registries";
import { HotbarAddCommand } from "../../hotbar/hotbar-add-command";
import { HotbarRemoveCommand } from "../../hotbar/hotbar-remove-command";
import { HotbarSwitchCommand } from "../../hotbar/hotbar-switch-command";
import { HotbarRenameCommand } from "../../hotbar/hotbar-rename-command";
import { ActivateEntityCommand } from "../../activate-entity-command";
import type { CommandContext, CommandRegistration } from "./commands";
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import commandOverlayInjectable from "../command-overlay.injectable";
import createTerminalTabInjectable
from "../../dock/create-terminal-tab/create-terminal-tab.injectable";
import type { DockTabCreate } from "../../dock/dock-store/dock.store";
export function isKubernetesClusterActive(context: CommandContext): boolean {
return context.entity?.kind === "KubernetesCluster";
}
interface Dependencies {
openCommandDialog: (component: React.ReactElement) => void;
getEntitySettingItems: (kind: string, apiVersion: string, source?: string) => RegisteredEntitySetting[];
createTerminalTab: () => DockTabCreate
}
function getInternalCommands({ openCommandDialog, getEntitySettingItems, createTerminalTab }: Dependencies): CommandRegistration[] {
return [
{
id: "app.showPreferences",
title: "Preferences: Open",
action: ({ navigate }) => navigate(routes.preferencesURL(), {
forceRootFrame: true,
}),
},
{
id: "cluster.viewHelmCharts",
title: "Cluster: View Helm Charts",
isActive: isKubernetesClusterActive,
action: ({ navigate }) => navigate(routes.helmChartsURL()),
},
{
id: "cluster.viewHelmReleases",
title: "Cluster: View Helm Releases",
isActive: isKubernetesClusterActive,
action: ({ navigate }) => navigate(routes.releaseURL()),
},
{
id: "cluster.viewConfigMaps",
title: "Cluster: View ConfigMaps",
isActive: isKubernetesClusterActive,
action: ({ navigate }) => navigate(routes.configMapsURL()),
},
{
id: "cluster.viewSecrets",
title: "Cluster: View Secrets",
isActive: isKubernetesClusterActive,
action: ({ navigate }) => navigate(routes.secretsURL()),
},
{
id: "cluster.viewResourceQuotas",
title: "Cluster: View ResourceQuotas",
isActive: isKubernetesClusterActive,
action: ({ navigate }) => navigate(routes.resourceQuotaURL()),
},
{
id: "cluster.viewLimitRanges",
title: "Cluster: View LimitRanges",
isActive: isKubernetesClusterActive,
action: ({ navigate }) => navigate(routes.limitRangeURL()),
},
{
id: "cluster.viewHorizontalPodAutoscalers",
title: "Cluster: View HorizontalPodAutoscalers (HPA)",
isActive: isKubernetesClusterActive,
action: ({ navigate }) => navigate(routes.hpaURL()),
},
{
id: "cluster.viewPodDisruptionBudget",
title: "Cluster: View PodDisruptionBudgets",
isActive: isKubernetesClusterActive,
action: ({ navigate }) => navigate(routes.pdbURL()),
},
{
id: "cluster.viewServices",
title: "Cluster: View Services",
isActive: isKubernetesClusterActive,
action: ({ navigate }) => navigate(routes.servicesURL()),
},
{
id: "cluster.viewEndpoints",
title: "Cluster: View Endpoints",
isActive: isKubernetesClusterActive,
action: ({ navigate }) => navigate(routes.endpointURL()),
},
{
id: "cluster.viewIngresses",
title: "Cluster: View Ingresses",
isActive: isKubernetesClusterActive,
action: ({ navigate }) => navigate(routes.ingressURL()),
},
{
id: "cluster.viewNetworkPolicies",
title: "Cluster: View NetworkPolicies",
isActive: isKubernetesClusterActive,
action: ({ navigate }) => navigate(routes.networkPoliciesURL()),
},
{
id: "cluster.viewNodes",
title: "Cluster: View Nodes",
isActive: isKubernetesClusterActive,
action: ({ navigate }) => navigate(routes.nodesURL()),
},
{
id: "cluster.viewPods",
title: "Cluster: View Pods",
isActive: isKubernetesClusterActive,
action: ({ navigate }) => navigate(routes.podsURL()),
},
{
id: "cluster.viewDeployments",
title: "Cluster: View Deployments",
isActive: isKubernetesClusterActive,
action: ({ navigate }) => navigate(routes.deploymentsURL()),
},
{
id: "cluster.viewDaemonSets",
title: "Cluster: View DaemonSets",
isActive: isKubernetesClusterActive,
action: ({ navigate }) => navigate(routes.daemonSetsURL()),
},
{
id: "cluster.viewStatefulSets",
title: "Cluster: View StatefulSets",
isActive: isKubernetesClusterActive,
action: ({ navigate }) => navigate(routes.statefulSetsURL()),
},
{
id: "cluster.viewJobs",
title: "Cluster: View Jobs",
isActive: isKubernetesClusterActive,
action: ({ navigate }) => navigate(routes.jobsURL()),
},
{
id: "cluster.viewCronJobs",
title: "Cluster: View CronJobs",
isActive: isKubernetesClusterActive,
action: ({ navigate }) => navigate(routes.cronJobsURL()),
},
{
id: "cluster.viewCustomResourceDefinitions",
title: "Cluster: View Custom Resource Definitions",
isActive: isKubernetesClusterActive,
action: ({ navigate }) => navigate(routes.crdURL()),
},
{
id: "entity.viewSettings",
title: ({ entity }) => `${entity.kind}/${entity.getName()}: View Settings`,
action: ({ entity, navigate }) => navigate(`/entity/${entity.getId()}/settings`, {
forceRootFrame: true,
}),
isActive: ({ entity }) => {
if (!entity) {
return false;
}
return getEntitySettingItems(entity.kind, entity.apiVersion, entity.metadata.source).length > 0;
},
},
{
id: "cluster.openTerminal",
title: "Cluster: Open terminal",
action: () => createTerminalTab(),
isActive: isKubernetesClusterActive,
},
{
id: "hotbar.switchHotbar",
title: "Hotbar: Switch ...",
action: () => openCommandDialog(<HotbarSwitchCommand />),
},
{
id: "hotbar.addHotbar",
title: "Hotbar: Add Hotbar ...",
action: () => openCommandDialog(<HotbarAddCommand />),
},
{
id: "hotbar.removeHotbar",
title: "Hotbar: Remove Hotbar ...",
action: () => openCommandDialog(<HotbarRemoveCommand />),
},
{
id: "hotbar.renameHotbar",
title: "Hotbar: Rename Hotbar ...",
action: () => openCommandDialog(<HotbarRenameCommand />),
},
{
id: "catalog.searchEntities",
title: "Catalog: Activate Entity ...",
action: () => openCommandDialog(<ActivateEntityCommand />),
},
];
}
const internalCommandsInjectable = getInjectable({
instantiate: (di) => getInternalCommands({
openCommandDialog: di.inject(commandOverlayInjectable).open,
getEntitySettingItems: EntitySettingRegistry
.getInstance()
.getItemsForKind,
createTerminalTab: di.inject(createTerminalTabInjectable),
}),
lifecycle: lifecycleEnum.singleton,
});
export default internalCommandsInjectable;

View File

@ -0,0 +1,69 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { computed, IComputedValue } from "mobx";
import type { CustomResourceDefinition } from "../../../../common/k8s-api/endpoints";
import customResourceDefinitionsInjectable from "../../+custom-resources/custom-resources.injectable";
import type { LensRendererExtension } from "../../../../extensions/lens-renderer-extension";
import rendererExtensionsInjectable from "../../../../extensions/renderer-extensions.injectable";
import type { CommandRegistration, RegisteredCommand } from "./commands";
import internalCommandsInjectable, { isKubernetesClusterActive } from "./internal-commands.injectable";
interface Dependencies {
extensions: IComputedValue<LensRendererExtension[]>;
customResourceDefinitions: IComputedValue<CustomResourceDefinition[]>;
internalCommands: CommandRegistration[];
}
const instantiateRegisteredCommands = ({ extensions, customResourceDefinitions, internalCommands }: Dependencies) => computed(() => {
const result = new Map<string, RegisteredCommand>();
const commands = [
...internalCommands,
...extensions.get().flatMap(e => e.commands),
...customResourceDefinitions.get().map((command): CommandRegistration => ({
id: `cluster.view.${command.getResourceKind()}`,
title: `Cluster: View ${command.getResourceKind()}`,
isActive: isKubernetesClusterActive,
action: ({ navigate }) => navigate(command.getResourceUrl()),
})),
];
for (const { scope, isActive = () => true, ...command } of commands) {
if (!result.has(command.id)) {
result.set(command.id, { ...command, isActive });
}
}
return result;
});
const registeredCommandsInjectable = getInjectable({
instantiate: (di) => instantiateRegisteredCommands({
extensions: di.inject(rendererExtensionsInjectable),
customResourceDefinitions: di.inject(customResourceDefinitionsInjectable),
internalCommands: di.inject(internalCommandsInjectable),
}),
lifecycle: lifecycleEnum.singleton,
});
export default registeredCommandsInjectable;

View File

@ -28,7 +28,7 @@ import { DockStore, DockTab, TabKind } from "../dock-store/dock.store";
import { noop } from "../../../utils"; import { noop } from "../../../utils";
import { ThemeStore } from "../../../theme.store"; import { ThemeStore } from "../../../theme.store";
import { UserStore } from "../../../../common/user-store"; import { UserStore } from "../../../../common/user-store";
import { getDiForUnitTesting } from "../../getDiForUnitTesting"; import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
import dockStoreInjectable from "../dock-store/dock-store.injectable"; import dockStoreInjectable from "../dock-store/dock-store.injectable";
import type { DiRender } from "../../test-utils/renderFor"; import type { DiRender } from "../../test-utils/renderFor";
import { renderFor } from "../../test-utils/renderFor"; import { renderFor } from "../../test-utils/renderFor";

View File

@ -29,7 +29,7 @@ import { dockerPod, deploymentPod1 } from "./pod.mock";
import { ThemeStore } from "../../../theme.store"; import { ThemeStore } from "../../../theme.store";
import { UserStore } from "../../../../common/user-store"; import { UserStore } from "../../../../common/user-store";
import mockFs from "mock-fs"; import mockFs from "mock-fs";
import { getDiForUnitTesting } from "../../getDiForUnitTesting"; import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
import type { DiRender } from "../../test-utils/renderFor"; import type { DiRender } from "../../test-utils/renderFor";
import { renderFor } from "../../test-utils/renderFor"; import { renderFor } from "../../test-utils/renderFor";
import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";

View File

@ -25,7 +25,7 @@ import { Pod } from "../../../../common/k8s-api/endpoints";
import { ThemeStore } from "../../../theme.store"; import { ThemeStore } from "../../../theme.store";
import { deploymentPod1, deploymentPod2, deploymentPod3, dockerPod } from "./pod.mock"; import { deploymentPod1, deploymentPod2, deploymentPod3, dockerPod } from "./pod.mock";
import { mockWindow } from "../../../../../__mocks__/windowMock"; import { mockWindow } from "../../../../../__mocks__/windowMock";
import { getDiForUnitTesting } from "../../getDiForUnitTesting"; import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
import logTabStoreInjectable from "../log-tab-store/log-tab-store.injectable"; import logTabStoreInjectable from "../log-tab-store/log-tab-store.injectable";
import type { LogTabStore } from "../log-tab-store/log-tab.store"; import type { LogTabStore } from "../log-tab-store/log-tab.store";
import dockStoreInjectable from "../dock-store/dock-store.injectable"; import dockStoreInjectable from "../dock-store/dock-store.injectable";

View File

@ -21,13 +21,17 @@
import "@testing-library/jest-dom/extend-expect"; import "@testing-library/jest-dom/extend-expect";
import { HotbarRemoveCommand } from "../hotbar-remove-command"; import { HotbarRemoveCommand } from "../hotbar-remove-command";
import { render, fireEvent } from "@testing-library/react"; import { fireEvent } from "@testing-library/react";
import React from "react"; import React from "react";
import type { DependencyInjectionContainer } from "@ogre-tools/injectable";
import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
import { DiRender, renderFor } from "../../test-utils/renderFor";
import hotbarManagerInjectable from "../../../../common/hotbar-store.injectable";
import { ThemeStore } from "../../../theme.store"; import { ThemeStore } from "../../../theme.store";
import { ConfirmDialog } from "../../confirm-dialog";
import type { HotbarStore } from "../../../../common/hotbar-store";
import { UserStore } from "../../../../common/user-store"; import { UserStore } from "../../../../common/user-store";
import { Notifications } from "../../notifications";
import mockFs from "mock-fs"; import mockFs from "mock-fs";
import { getDiForUnitTesting } from "../../getDiForUnitTesting";
import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
const mockHotbars: { [id: string]: any } = { const mockHotbars: { [id: string]: any } = {
@ -38,50 +42,69 @@ const mockHotbars: { [id: string]: any } = {
}, },
}; };
jest.mock("../../../../common/hotbar-store", () => ({
HotbarStore: {
getInstance: () => ({
hotbars: [mockHotbars["1"]],
getById: (id: string) => mockHotbars[id],
remove: () => {},
hotbarIndex: () => 0,
}),
},
}));
describe("<HotbarRemoveCommand />", () => { describe("<HotbarRemoveCommand />", () => {
beforeEach(async () => { let di: DependencyInjectionContainer;
const di = getDiForUnitTesting({ doGeneralOverrides: true }); let render: DiRender;
beforeEach(() => {
di = getDiForUnitTesting({ doGeneralOverrides: true });
mockFs(); mockFs();
di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data");
await di.runSetups(); render = renderFor(di);
UserStore.createInstance(); UserStore.createInstance();
ThemeStore.createInstance(); ThemeStore.createInstance();
}); });
afterEach(() => { afterEach(() => {
UserStore.resetInstance();
ThemeStore.resetInstance();
mockFs.restore(); mockFs.restore();
ThemeStore.resetInstance();
UserStore.resetInstance();
}); });
it("renders w/o errors", () => { it("renders w/o errors", async () => {
const { container } = render(<HotbarRemoveCommand/>); di.override(hotbarManagerInjectable, () => ({
hotbars: [mockHotbars["1"]],
getById: (id: string) => mockHotbars[id],
remove: () => {
},
hotbarIndex: () => 0,
getDisplayLabel: () => "1: Default",
}) as any as HotbarStore);
await di.runSetups();
const { container } = render(<HotbarRemoveCommand />);
expect(container).toBeInstanceOf(HTMLElement); expect(container).toBeInstanceOf(HTMLElement);
}); });
it("displays error notification if user tries to remove last hotbar", () => { it("calls remove if you click on the entry", async () => {
const spy = jest.spyOn(Notifications, "error"); const removeMock = jest.fn();
const { getByText } = render(<HotbarRemoveCommand/>);
di.override(hotbarManagerInjectable, () => ({
hotbars: [mockHotbars["1"]],
getById: (id: string) => mockHotbars[id],
remove: removeMock,
hotbarIndex: () => 0,
getDisplayLabel: () => "1: Default",
}) as any as HotbarStore);
await di.runSetups();
const { getByText } = render(
<>
<HotbarRemoveCommand />
<ConfirmDialog />
</>,
);
fireEvent.click(getByText("1: Default")); fireEvent.click(getByText("1: Default"));
fireEvent.click(getByText("Remove Hotbar"));
expect(spy).toHaveBeenCalled(); expect(removeMock).toHaveBeenCalled();
spy.mockRestore();
}); });
}); });

View File

@ -21,44 +21,53 @@
import React from "react"; import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { HotbarStore } from "../../../common/hotbar-store";
import { CommandOverlay } from "../command-palette";
import { Input, InputValidator } from "../input"; import { Input, InputValidator } from "../input";
import type { CreateHotbarData, CreateHotbarOptions } from "../../../common/hotbar-types";
import { withInjectables } from "@ogre-tools/injectable-react";
import commandOverlayInjectable from "../command-palette/command-overlay.injectable";
import hotbarManagerInjectable from "../../../common/hotbar-store.injectable";
import uniqueHotbarNameInjectable from "../input/validators/unique-hotbar-name.injectable";
export const uniqueHotbarName: InputValidator = { interface Dependencies {
condition: ({ required }) => required, closeCommandOverlay: () => void;
message: () => "Hotbar with this name already exists", addHotbar: (data: CreateHotbarData, { setActive }?: CreateHotbarOptions) => void;
validate: value => !HotbarStore.getInstance().getByName(value), uniqueHotbarName: InputValidator;
}; }
@observer const NonInjectedHotbarAddCommand = observer(({ closeCommandOverlay, addHotbar, uniqueHotbarName }: Dependencies) => {
export class HotbarAddCommand extends React.Component { const onSubmit = (name: string) => {
onSubmit = (name: string) => {
if (!name.trim()) { if (!name.trim()) {
return; return;
} }
HotbarStore.getInstance().add({ name }, { setActive: true }); addHotbar({ name }, { setActive: true });
CommandOverlay.close(); closeCommandOverlay();
}; };
render() { return (
return ( <>
<> <Input
<Input placeholder="Hotbar name"
placeholder="Hotbar name" autoFocus={true}
autoFocus={true} theme="round-black"
theme="round-black" data-test-id="command-palette-hotbar-add-name"
data-test-id="command-palette-hotbar-add-name" validators={uniqueHotbarName}
validators={uniqueHotbarName} onSubmit={onSubmit}
onSubmit={this.onSubmit} dirty={true}
dirty={true} showValidationLine={true}
showValidationLine={true} />
/> <small className="hint">
<small className="hint"> Please provide a new hotbar name (Press &quot;Enter&quot; to confirm or &quot;Escape&quot; to cancel)
Please provide a new hotbar name (Press &quot;Enter&quot; to confirm or &quot;Escape&quot; to cancel) </small>
</small> </>
</> );
); });
}
} export const HotbarAddCommand = withInjectables<Dependencies>(NonInjectedHotbarAddCommand, {
getProps: (di, props) => ({
closeCommandOverlay: di.inject(commandOverlayInjectable).close,
addHotbar: di.inject(hotbarManagerInjectable).add,
uniqueHotbarName: di.inject(uniqueHotbarNameInjectable),
...props,
}),
});

View File

@ -22,51 +22,44 @@
import React from "react"; import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Select } from "../select"; import { Select } from "../select";
import { computed, makeObservable } from "mobx"; import hotbarManagerInjectable from "../../../common/hotbar-store.injectable";
import { HotbarStore } from "../../../common/hotbar-store";
import { hotbarDisplayLabel } from "./hotbar-display-label";
import { CommandOverlay } from "../command-palette";
import { ConfirmDialog } from "../confirm-dialog"; import { ConfirmDialog } from "../confirm-dialog";
import { Notifications } from "../notifications"; import { withInjectables } from "@ogre-tools/injectable-react";
import commandOverlayInjectable from "../command-palette/command-overlay.injectable";
import type { Hotbar } from "../../../common/hotbar-types";
@observer interface Dependencies {
export class HotbarRemoveCommand extends React.Component { closeCommandOverlay: () => void;
constructor(props: {}) { hotbarManager: {
super(props); hotbars: Hotbar[];
makeObservable(this); getById: (id: string) => Hotbar | undefined;
} remove: (hotbar: Hotbar) => void;
getDisplayLabel: (hotbar: Hotbar) => string;
};
}
@computed get options() { const NonInjectedHotbarRemoveCommand = observer(({ closeCommandOverlay, hotbarManager }: Dependencies) => {
return HotbarStore.getInstance().hotbars.map((hotbar) => { const options = hotbarManager.hotbars.map(hotbar => ({
return { value: hotbar.id, label: hotbarDisplayLabel(hotbar.id) }; value: hotbar.id,
}); label: hotbarManager.getDisplayLabel(hotbar),
} }));
onChange(id: string): void { const onChange = (id: string): void => {
const hotbarStore = HotbarStore.getInstance(); const hotbar = hotbarManager.getById(id);
const hotbar = hotbarStore.getById(id);
CommandOverlay.close();
if (!hotbar) { if (!hotbar) {
return; return;
} }
if (hotbarStore.hotbars.length === 1) { closeCommandOverlay();
Notifications.error("Can't remove the last hotbar"); // TODO: make confirm dialog injectable
return;
}
ConfirmDialog.open({ ConfirmDialog.open({
okButtonProps: { okButtonProps: {
label: `Remove Hotbar`, label: "Remove Hotbar",
primary: false, primary: false,
accent: true, accent: true,
}, },
ok: () => { ok: () => hotbarManager.remove(hotbar),
hotbarStore.remove(hotbar);
},
message: ( message: (
<div className="confirm flex column gaps"> <div className="confirm flex column gaps">
<p> <p>
@ -75,19 +68,26 @@ export class HotbarRemoveCommand extends React.Component {
</div> </div>
), ),
}); });
} };
render() { return (
return ( <Select
<Select menuPortalTarget={null}
menuPortalTarget={null} onChange={(v) => onChange(v.value)}
onChange={(v) => this.onChange(v.value)} components={{ DropdownIndicator: null, IndicatorSeparator: null }}
components={{ DropdownIndicator: null, IndicatorSeparator: null }} menuIsOpen={true}
menuIsOpen={true} options={options}
options={this.options} autoFocus={true}
autoFocus={true} escapeClearsValue={false}
escapeClearsValue={false} placeholder="Remove hotbar"
placeholder="Remove hotbar" /> />
); );
} });
}
export const HotbarRemoveCommand = withInjectables<Dependencies>(NonInjectedHotbarRemoveCommand, {
getProps: (di, props) => ({
closeCommandOverlay: di.inject(commandOverlayInjectable).close,
hotbarManager: di.inject(hotbarManagerInjectable),
...props,
}),
});

View File

@ -19,81 +19,61 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import React from "react"; import React, { useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Select } from "../select"; import { Select } from "../select";
import { action, computed, makeObservable, observable } from "mobx"; import hotbarManagerInjectable from "../../../common/hotbar-store.injectable";
import { HotbarStore } from "../../../common/hotbar-store"; import { Input, InputValidator } from "../input";
import { hotbarDisplayLabel } from "./hotbar-display-label"; import type { Hotbar } from "../../../common/hotbar-types";
import { Input } from "../input"; import { withInjectables } from "@ogre-tools/injectable-react";
import { uniqueHotbarName } from "./hotbar-add-command"; import commandOverlayInjectable from "../command-palette/command-overlay.injectable";
import { CommandOverlay } from "../command-palette"; import uniqueHotbarNameInjectable from "../input/validators/unique-hotbar-name.injectable";
@observer interface Dependencies {
export class HotbarRenameCommand extends React.Component { closeCommandOverlay: () => void;
@observable hotbarId = ""; hotbarManager: {
@observable hotbarName = ""; hotbars: Hotbar[];
getById: (id: string) => Hotbar | undefined;
constructor(props: {}) { setHotbarName: (id: string, name: string) => void;
super(props); getDisplayLabel: (hotbar: Hotbar) => string;
makeObservable(this);
}
@computed get options() {
return HotbarStore.getInstance().hotbars.map((hotbar) => {
return { value: hotbar.id, label: hotbarDisplayLabel(hotbar.id) };
});
}
@action onSelect = (id: string) => {
this.hotbarId = id;
this.hotbarName = HotbarStore.getInstance().getById(this.hotbarId).name;
}; };
uniqueHotbarName: InputValidator;
}
onSubmit = (name: string) => { const NonInjectedHotbarRenameCommand = observer(({ closeCommandOverlay, hotbarManager, uniqueHotbarName }: Dependencies) => {
const [hotbarId, setHotbarId] = useState("");
const [hotbarName, setHotbarName] = useState("");
const options = hotbarManager.hotbars.map(hotbar => ({
value: hotbar.id,
label: hotbarManager.getDisplayLabel(hotbar),
}));
const onSelect = (id: string) => {
setHotbarId(id);
setHotbarName(hotbarManager.getById(id).name);
};
const onSubmit = (name: string) => {
if (!name.trim()) { if (!name.trim()) {
return; return;
} }
const hotbarStore = HotbarStore.getInstance(); hotbarManager.setHotbarName(hotbarId, name);
const hotbar = HotbarStore.getInstance().getById(this.hotbarId); closeCommandOverlay();
if (!hotbar) {
return;
}
hotbarStore.setHotbarName(this.hotbarId, name);
CommandOverlay.close();
}; };
renderHotbarList() { if (hotbarId) {
return (
<>
<Select
menuPortalTarget={null}
onChange={(v) => this.onSelect(v.value)}
components={{ DropdownIndicator: null, IndicatorSeparator: null }}
menuIsOpen={true}
options={this.options}
autoFocus={true}
escapeClearsValue={false}
placeholder="Rename hotbar"/>
</>
);
}
renderNameInput() {
return ( return (
<> <>
<Input <Input
trim={true} trim={true}
value={this.hotbarName} value={hotbarName}
onChange={v => this.hotbarName = v} onChange={setHotbarName}
placeholder="New hotbar name" placeholder="New hotbar name"
autoFocus={true} autoFocus={true}
theme="round-black" theme="round-black"
validators={uniqueHotbarName} validators={uniqueHotbarName}
onSubmit={this.onSubmit} onSubmit={onSubmit}
showValidationLine={true} showValidationLine={true}
/> />
<small className="hint"> <small className="hint">
@ -103,12 +83,25 @@ export class HotbarRenameCommand extends React.Component {
); );
} }
render() { return (
<Select
menuPortalTarget={null}
onChange={(v) => onSelect(v.value)}
components={{ DropdownIndicator: null, IndicatorSeparator: null }}
menuIsOpen={true}
options={options}
autoFocus={true}
escapeClearsValue={false}
placeholder="Rename hotbar"
/>
);
});
return ( export const HotbarRenameCommand = withInjectables<Dependencies>(NonInjectedHotbarRenameCommand, {
<> getProps: (di, props) => ({
{!this.hotbarId ? this.renderHotbarList() : this.renderNameInput()} closeCommandOverlay: di.inject(commandOverlayInjectable).close,
</> hotbarManager: di.inject(hotbarManagerInjectable),
); uniqueHotbarName: di.inject(uniqueHotbarNameInjectable),
} ...props,
} }),
});

View File

@ -23,38 +23,52 @@ import "./hotbar-selector.scss";
import React from "react"; import React from "react";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { Badge } from "../badge"; import { Badge } from "../badge";
import { HotbarStore } from "../../../common/hotbar-store"; import hotbarManagerInjectable from "../../../common/hotbar-store.injectable";
import { CommandOverlay } from "../command-palette";
import { HotbarSwitchCommand } from "./hotbar-switch-command"; import { HotbarSwitchCommand } from "./hotbar-switch-command";
import { hotbarDisplayIndex } from "./hotbar-display-label";
import { TooltipPosition } from "../tooltip"; import { TooltipPosition } from "../tooltip";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import type { Hotbar } from "../../../common/hotbar-types"; import type { Hotbar } from "../../../common/hotbar-types";
import { withInjectables } from "@ogre-tools/injectable-react";
import commandOverlayInjectable from "../command-palette/command-overlay.injectable";
interface Props { export interface HotbarSelectorProps {
hotbar: Hotbar; hotbar: Hotbar;
} }
export const HotbarSelector = observer(({ hotbar }: Props) => { interface Dependencies {
const store = HotbarStore.getInstance(); hotbarManager: {
switchToPrevious: () => void;
switchToNext: () => void;
getActive: () => Hotbar;
getDisplayIndex: (hotbar: Hotbar) => string;
};
openCommandOverlay: (component: React.ReactElement) => void;
}
return ( const NonInjectedHotbarSelector = observer(({ hotbar, hotbarManager, openCommandOverlay }: HotbarSelectorProps & Dependencies) => (
<div className="HotbarSelector flex align-center"> <div className="HotbarSelector flex align-center">
<Icon material="play_arrow" className="previous box" onClick={() => store.switchToPrevious()} /> <Icon material="play_arrow" className="previous box" onClick={() => hotbarManager.switchToPrevious()} />
<div className="box grow flex align-center"> <div className="box grow flex align-center">
<Badge <Badge
id="hotbarIndex" id="hotbarIndex"
small small
label={hotbarDisplayIndex(store.activeHotbarId)} label={hotbarManager.getDisplayIndex(hotbarManager.getActive())}
onClick={() => CommandOverlay.open(<HotbarSwitchCommand />)} onClick={() => openCommandOverlay(<HotbarSwitchCommand />)}
tooltip={{ tooltip={{
preferredPositions: [TooltipPosition.TOP, TooltipPosition.TOP_LEFT], preferredPositions: [TooltipPosition.TOP, TooltipPosition.TOP_LEFT],
children: hotbar.name, children: hotbar.name,
}} }}
className="SelectorIndex" className="SelectorIndex"
/> />
</div>
<Icon material="play_arrow" className="next box" onClick={() => store.switchToNext()} />
</div> </div>
); <Icon material="play_arrow" className="next box" onClick={() => hotbarManager.switchToNext()} />
</div>
));
export const HotbarSelector = withInjectables<Dependencies, HotbarSelectorProps>(NonInjectedHotbarSelector, {
getProps: (di, props) => ({
hotbarManager: di.inject(hotbarManagerInjectable),
openCommandOverlay: di.inject(commandOverlayInjectable).open,
...props,
}),
}); });

View File

@ -22,73 +22,82 @@
import React from "react"; import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Select } from "../select"; import { Select } from "../select";
import { computed, makeObservable } from "mobx"; import hotbarManagerInjectable from "../../../common/hotbar-store.injectable";
import { HotbarStore } from "../../../common/hotbar-store"; import type { CommandOverlay } from "../command-palette";
import { CommandOverlay } from "../command-palette";
import { HotbarAddCommand } from "./hotbar-add-command"; import { HotbarAddCommand } from "./hotbar-add-command";
import { HotbarRemoveCommand } from "./hotbar-remove-command"; import { HotbarRemoveCommand } from "./hotbar-remove-command";
import { hotbarDisplayLabel } from "./hotbar-display-label";
import { HotbarRenameCommand } from "./hotbar-rename-command"; import { HotbarRenameCommand } from "./hotbar-rename-command";
import type { Hotbar } from "../../../common/hotbar-types";
import { withInjectables } from "@ogre-tools/injectable-react";
import commandOverlayInjectable from "../command-palette/command-overlay.injectable";
@observer const addActionId = "__add__";
export class HotbarSwitchCommand extends React.Component { const removeActionId = "__remove__";
private static addActionId = "__add__"; const renameActionId = "__rename__";
private static removeActionId = "__remove__";
private static renameActionId = "__rename__";
constructor(props: {}) { interface HotbarManager {
super(props); hotbars: Hotbar[];
makeObservable(this); setActiveHotbar: (id: string) => void;
} getDisplayLabel: (hotbar: Hotbar) => string;
@computed get options() {
const hotbarStore = HotbarStore.getInstance();
const options = hotbarStore.hotbars.map((hotbar) => {
return { value: hotbar.id, label: hotbarDisplayLabel(hotbar.id) };
});
options.push({ value: HotbarSwitchCommand.addActionId, label: "Add hotbar ..." });
if (hotbarStore.hotbars.length > 1) {
options.push({ value: HotbarSwitchCommand.removeActionId, label: "Remove hotbar ..." });
}
options.push({ value: HotbarSwitchCommand.renameActionId, label: "Rename hotbar ..." });
return options;
}
onChange(idOrAction: string): void {
switch (idOrAction) {
case HotbarSwitchCommand.addActionId:
CommandOverlay.open(<HotbarAddCommand />);
return;
case HotbarSwitchCommand.removeActionId:
CommandOverlay.open(<HotbarRemoveCommand />);
return;
case HotbarSwitchCommand.renameActionId:
CommandOverlay.open(<HotbarRenameCommand />);
return;
default:
HotbarStore.getInstance().activeHotbarId = idOrAction;
CommandOverlay.close();
}
}
render() {
return (
<Select
menuPortalTarget={null}
onChange={(v) => this.onChange(v.value)}
components={{ DropdownIndicator: null, IndicatorSeparator: null }}
menuIsOpen={true}
options={this.options}
autoFocus={true}
escapeClearsValue={false}
placeholder="Switch to hotbar" />
);
}
} }
interface Dependencies {
hotbarManager: HotbarManager
commandOverlay: CommandOverlay;
}
function getHotbarSwitchOptions(hotbarManager: HotbarManager) {
const options = hotbarManager.hotbars.map(hotbar => ({
value: hotbar.id,
label: hotbarManager.getDisplayLabel(hotbar),
}));
options.push({ value: addActionId, label: "Add hotbar ..." });
if (hotbarManager.hotbars.length > 1) {
options.push({ value: removeActionId, label: "Remove hotbar ..." });
}
options.push({ value: renameActionId, label: "Rename hotbar ..." });
return options;
}
const NonInjectedHotbarSwitchCommand = observer(({ hotbarManager, commandOverlay }: Dependencies) => {
const options = getHotbarSwitchOptions(hotbarManager);
const onChange = (idOrAction: string): void => {
switch (idOrAction) {
case addActionId:
return commandOverlay.open(<HotbarAddCommand />);
case removeActionId:
return commandOverlay.open(<HotbarRemoveCommand />);
case renameActionId:
return commandOverlay.open(<HotbarRenameCommand />);
default:
hotbarManager.setActiveHotbar(idOrAction);
commandOverlay.close();
}
};
return (
<Select
menuPortalTarget={null}
onChange={(v) => onChange(v.value)}
components={{ DropdownIndicator: null, IndicatorSeparator: null }}
menuIsOpen={true}
options={options}
autoFocus={true}
escapeClearsValue={false}
placeholder="Switch to hotbar"
/>
);
});
export const HotbarSwitchCommand = withInjectables<Dependencies>(NonInjectedHotbarSwitchCommand, {
getProps: (di, props) => ({
hotbarManager: di.inject(hotbarManagerInjectable),
commandOverlay: di.inject(commandOverlayInjectable),
...props,
}),
});

View File

@ -103,6 +103,10 @@ export class Input extends React.Component<InputProps, State> {
submitted: false, submitted: false,
}; };
componentWillUnmount(): void {
this.setDirtyOnChange.cancel();
}
setValue(value = "") { setValue(value = "") {
if (value !== this.getValue()) { if (value !== this.getValue()) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(this.input.constructor.prototype, "value").set; const nativeInputValueSetter = Object.getOwnPropertyDescriptor(this.input.constructor.prototype, "value").set;

View File

@ -18,13 +18,18 @@
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import portForwardStoreInjectable from "../port-forward-store.injectable"; import hotbarManagerInjectable from "../../../../common/hotbar-store.injectable";
import type { InputValidator } from "../input_validators";
const removePortForwardInjectable = getInjectable({
instantiate: (di) => di.inject(portForwardStoreInjectable).remove,
const uniqueHotbarNameInjectable = getInjectable({
instantiate: di => ({
condition: ({ required }) => required,
message: () => "Hotbar with this name already exists",
validate: value => !di.inject(hotbarManagerInjectable).getByName(value),
} as InputValidator),
lifecycle: lifecycleEnum.singleton, lifecycle: lifecycleEnum.singleton,
}); });
export default removePortForwardInjectable; export default uniqueHotbarNameInjectable;

View File

@ -29,7 +29,7 @@ import type { KubeObjectMenuRegistration } from "../../../extensions/registries"
import { KubeObjectMenuRegistry } from "../../../extensions/registries"; import { KubeObjectMenuRegistry } from "../../../extensions/registries";
import { ConfirmDialog } from "../confirm-dialog"; import { ConfirmDialog } from "../confirm-dialog";
import asyncFn, { AsyncFnMock } from "@async-fn/jest"; import asyncFn, { AsyncFnMock } from "@async-fn/jest";
import { getDiForUnitTesting } from "../getDiForUnitTesting"; import { getDiForUnitTesting } from "../../getDiForUnitTesting";
import clusterInjectable from "./dependencies/cluster.injectable"; import clusterInjectable from "./dependencies/cluster.injectable";
import hideDetailsInjectable from "./dependencies/hide-details.injectable"; import hideDetailsInjectable from "./dependencies/hide-details.injectable";
@ -65,8 +65,7 @@ describe("kube-object-menu", () => {
}) as Cluster); }) as Cluster);
di.override(apiManagerInjectable, () => ({ di.override(apiManagerInjectable, () => ({
// eslint-disable-next-line unused-imports/no-unused-vars-ts getStore: api => void api,
getStore: api => undefined,
}) as ApiManager); }) as ApiManager);
di.override(hideDetailsInjectable, () => () => {}); di.override(hideDetailsInjectable, () => () => {});
@ -285,5 +284,5 @@ const addDynamicMenuItem = ({
const kubeObjectMenuRegistry = di.inject(kubeObjectMenuRegistryInjectable); const kubeObjectMenuRegistry = di.inject(kubeObjectMenuRegistryInjectable);
kubeObjectMenuRegistry.add(dynamicMenuItemStub); kubeObjectMenuRegistry.add([dynamicMenuItemStub]);
}; };

View File

@ -26,7 +26,7 @@ import { TopBar } from "./top-bar";
import { IpcMainWindowEvents } from "../../../../main/window-manager"; import { IpcMainWindowEvents } from "../../../../main/window-manager";
import { broadcastMessage } from "../../../../common/ipc"; import { broadcastMessage } from "../../../../common/ipc";
import * as vars from "../../../../common/vars"; import * as vars from "../../../../common/vars";
import { getDiForUnitTesting } from "../../getDiForUnitTesting"; import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
import { DiRender, renderFor } from "../../test-utils/renderFor"; import { DiRender, renderFor } from "../../test-utils/renderFor";
import directoryForUserDataInjectable import directoryForUserDataInjectable
from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";

View File

@ -23,7 +23,7 @@ import React from "react";
import { fireEvent } from "@testing-library/react"; import { fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect"; import "@testing-library/jest-dom/extend-expect";
import { TopBar } from "./top-bar"; import { TopBar } from "./top-bar";
import { getDiForUnitTesting } from "../../getDiForUnitTesting"; import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable"; import type { ConfigurableDependencyInjectionContainer } from "@ogre-tools/injectable";
import { DiRender, renderFor } from "../../test-utils/renderFor"; import { DiRender, renderFor } from "../../test-utils/renderFor";
import topBarItemsInjectable from "./top-bar-items/top-bar-items.injectable"; import topBarItemsInjectable from "./top-bar-items/top-bar-items.injectable";

View File

@ -20,7 +20,7 @@
*/ */
import { createContainer } from "@ogre-tools/injectable"; import { createContainer } from "@ogre-tools/injectable";
import { setLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api"; import { setLegacyGlobalDiForExtensionApi } from "../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api";
export const getDi = () => { export const getDi = () => {
const di = createContainer( const di = createContainer(
@ -35,10 +35,10 @@ export const getDi = () => {
}; };
const getRequireContextForRendererCode = () => const getRequireContextForRendererCode = () =>
require.context("../", true, /\.injectable\.(ts|tsx)$/); require.context("./", true, /\.injectable\.(ts|tsx)$/);
const getRequireContextForCommonExtensionCode = () =>
require.context("../../extensions", true, /\.injectable\.(ts|tsx)$/);
const getRequireContextForCommonCode = () => const getRequireContextForCommonCode = () =>
require.context("../../common", true, /\.injectable\.(ts|tsx)$/); require.context("../common", true, /\.injectable\.(ts|tsx)$/);
const getRequireContextForCommonExtensionCode = () =>
require.context("../extensions", true, /\.injectable\.(ts|tsx)$/);

View File

@ -21,36 +21,29 @@
import glob from "glob"; import glob from "glob";
import { memoize } from "lodash/fp"; import { memoize } from "lodash/fp";
import { createContainer } from "@ogre-tools/injectable";
import { import { setLegacyGlobalDiForExtensionApi } from "../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api";
createContainer, import getValueFromRegisteredChannelInjectable from "./components/app-paths/get-value-from-registered-channel/get-value-from-registered-channel.injectable";
ConfigurableDependencyInjectionContainer, import writeJsonFileInjectable from "../common/fs/write-json-file/write-json-file.injectable";
} from "@ogre-tools/injectable"; import readJsonFileInjectable from "../common/fs/read-json-file/read-json-file.injectable";
import { setLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api";
import getValueFromRegisteredChannelInjectable from "./app-paths/get-value-from-registered-channel/get-value-from-registered-channel.injectable";
import writeJsonFileInjectable from "../../common/fs/write-json-file/write-json-file.injectable";
import readJsonFileInjectable from "../../common/fs/read-json-file/read-json-file.injectable";
export const getDiForUnitTesting = ({ doGeneralOverrides } = { doGeneralOverrides: false }) => { export const getDiForUnitTesting = ({ doGeneralOverrides } = { doGeneralOverrides: false }) => {
const di: ConfigurableDependencyInjectionContainer = createContainer(); const di = createContainer();
setLegacyGlobalDiForExtensionApi(di); setLegacyGlobalDiForExtensionApi(di);
getInjectableFilePaths() for (const filePath of getInjectableFilePaths()) {
.map(key => { const injectableInstance = require(filePath).default;
const injectable = require(key).default;
return { di.register({
id: key, id: filePath,
...injectable, ...injectableInstance,
aliases: [injectable, ...(injectable.aliases || [])], aliases: [injectableInstance, ...(injectableInstance.aliases || [])],
}; });
}) }
.forEach(injectable => di.register(injectable));
di.preventSideEffects(); di.preventSideEffects();
if (doGeneralOverrides) { if (doGeneralOverrides) {
di.override(getValueFromRegisteredChannelInjectable, () => () => undefined); di.override(getValueFromRegisteredChannelInjectable, () => () => undefined);
@ -67,7 +60,7 @@ export const getDiForUnitTesting = ({ doGeneralOverrides } = { doGeneralOverride
}; };
const getInjectableFilePaths = memoize(() => [ const getInjectableFilePaths = memoize(() => [
...glob.sync("../**/*.injectable.{ts,tsx}", { cwd: __dirname }), ...glob.sync("./**/*.injectable.{ts,tsx}", { cwd: __dirname }),
...glob.sync("../../extensions/**/*.injectable.{ts,tsx}", { cwd: __dirname }), ...glob.sync("../common/**/*.injectable.{ts,tsx}", { cwd: __dirname }),
...glob.sync("../../common/**/*.injectable.{ts,tsx}", { cwd: __dirname }), ...glob.sync("../extensions/**/*.injectable.{ts,tsx}", { cwd: __dirname }),
]); ]);

View File

@ -22,32 +22,12 @@
import React from "react"; import React from "react";
import fs from "fs"; import fs from "fs";
import "../../common/catalog-entities/kubernetes-cluster"; import "../../common/catalog-entities/kubernetes-cluster";
import { WebLinkCategory } from "../../common/catalog-entities";
import { ClusterStore } from "../../common/cluster-store/cluster-store"; import { ClusterStore } from "../../common/cluster-store/cluster-store";
import { catalogCategoryRegistry } from "../api/catalog-category-registry"; import { catalogCategoryRegistry } from "../api/catalog-category-registry";
import { WeblinkAddCommand } from "../components/catalog-entities/weblink-add-command"; import { WeblinkAddCommand } from "../components/catalog-entities/weblink-add-command";
import { CommandOverlay } from "../components/command-palette";
import { loadConfigFromString } from "../../common/kube-helpers"; import { loadConfigFromString } from "../../common/kube-helpers";
import { DeleteClusterDialog } from "../components/delete-cluster-dialog"; import { DeleteClusterDialog } from "../components/delete-cluster-dialog";
function initWebLinks() {
WebLinkCategory.onAdd = () => CommandOverlay.open(<WeblinkAddCommand />);
}
function initKubernetesClusters() {
catalogCategoryRegistry
.getForGroupKind("entity.k8slens.dev", "KubernetesCluster")
.on("contextMenuOpen", (entity, context) => {
if (entity.metadata?.source == "local") {
context.menuItems.push({
title: "Delete",
icon: "delete",
onClick: () => onClusterDelete(entity.metadata.uid),
});
}
});
}
async function onClusterDelete(clusterId: string) { async function onClusterDelete(clusterId: string) {
const cluster = ClusterStore.getInstance().getById(clusterId); const cluster = ClusterStore.getInstance().getById(clusterId);
@ -64,7 +44,30 @@ async function onClusterDelete(clusterId: string) {
DeleteClusterDialog.open({ cluster, config }); DeleteClusterDialog.open({ cluster, config });
} }
export function initCatalog() { interface Dependencies {
initWebLinks(); openCommandDialog: (component: React.ReactElement) => void;
initKubernetesClusters(); }
export function initCatalog({ openCommandDialog }: Dependencies) {
catalogCategoryRegistry
.getForGroupKind("entity.k8slens.dev", "WebLink")
.on("catalogAddMenu", ctx => {
ctx.menuItems.push({
title: "Add web link",
icon: "public",
onClick: () => openCommandDialog(<WeblinkAddCommand />),
});
});
catalogCategoryRegistry
.getForGroupKind("entity.k8slens.dev", "KubernetesCluster")
.on("contextMenuOpen", (entity, context) => {
if (entity.metadata?.source == "local") {
context.menuItems.push({
title: "Delete",
icon: "delete",
onClick: () => onClusterDelete(entity.metadata.uid),
});
}
});
} }

View File

@ -1,206 +0,0 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import React from "react";
import * as routes from "../../common/routes";
import { CommandRegistry } from "../../extensions/registries";
import { getActiveClusterEntity } from "../api/catalog-entity-registry";
import { CommandOverlay } from "../components/command-palette";
import { HotbarAddCommand } from "../components/hotbar/hotbar-add-command";
import { HotbarRemoveCommand } from "../components/hotbar/hotbar-remove-command";
import { HotbarSwitchCommand } from "../components/hotbar/hotbar-switch-command";
import { navigate } from "../navigation";
import { HotbarRenameCommand } from "../components/hotbar/hotbar-rename-command";
import { ActivateEntityCommand } from "../components/activate-entity-command";
export function initCommandRegistry(createTerminalTab: () => void) {
CommandRegistry.getInstance()
.add([
{
id: "app.showPreferences",
title: "Preferences: Open",
scope: "global",
action: () => navigate(routes.preferencesURL()),
},
{
id: "cluster.viewHelmCharts",
title: "Cluster: View Helm Charts",
scope: "entity",
action: () => navigate(routes.helmChartsURL()),
},
{
id: "cluster.viewHelmReleases",
title: "Cluster: View Helm Releases",
scope: "entity",
action: () => navigate(routes.releaseURL()),
},
{
id: "cluster.viewConfigMaps",
title: "Cluster: View ConfigMaps",
scope: "entity",
action: () => navigate(routes.configMapsURL()),
},
{
id: "cluster.viewSecrets",
title: "Cluster: View Secrets",
scope: "entity",
action: () => navigate(routes.secretsURL()),
},
{
id: "cluster.viewResourceQuotas",
title: "Cluster: View ResourceQuotas",
scope: "entity",
action: () => navigate(routes.resourceQuotaURL()),
},
{
id: "cluster.viewLimitRanges",
title: "Cluster: View LimitRanges",
scope: "entity",
action: () => navigate(routes.limitRangeURL()),
},
{
id: "cluster.viewHorizontalPodAutoscalers",
title: "Cluster: View HorizontalPodAutoscalers (HPA)",
scope: "entity",
action: () => navigate(routes.hpaURL()),
},
{
id: "cluster.viewPodDisruptionBudget",
title: "Cluster: View PodDisruptionBudgets",
scope: "entity",
action: () => navigate(routes.pdbURL()),
},
{
id: "cluster.viewServices",
title: "Cluster: View Services",
scope: "entity",
action: () => navigate(routes.servicesURL()),
},
{
id: "cluster.viewEndpoints",
title: "Cluster: View Endpoints",
scope: "entity",
action: () => navigate(routes.endpointURL()),
},
{
id: "cluster.viewIngresses",
title: "Cluster: View Ingresses",
scope: "entity",
action: () => navigate(routes.ingressURL()),
},
{
id: "cluster.viewNetworkPolicies",
title: "Cluster: View NetworkPolicies",
scope: "entity",
action: () => navigate(routes.networkPoliciesURL()),
},
{
id: "cluster.viewNodes",
title: "Cluster: View Nodes",
scope: "entity",
action: () => navigate(routes.nodesURL()),
},
{
id: "cluster.viewPods",
title: "Cluster: View Pods",
scope: "entity",
action: () => navigate(routes.podsURL()),
},
{
id: "cluster.viewDeployments",
title: "Cluster: View Deployments",
scope: "entity",
action: () => navigate(routes.deploymentsURL()),
},
{
id: "cluster.viewDaemonSets",
title: "Cluster: View DaemonSets",
scope: "entity",
action: () => navigate(routes.daemonSetsURL()),
},
{
id: "cluster.viewStatefulSets",
title: "Cluster: View StatefulSets",
scope: "entity",
action: () => navigate(routes.statefulSetsURL()),
},
{
id: "cluster.viewJobs",
title: "Cluster: View Jobs",
scope: "entity",
action: () => navigate(routes.jobsURL()),
},
{
id: "cluster.viewCronJobs",
title: "Cluster: View CronJobs",
scope: "entity",
action: () => navigate(routes.cronJobsURL()),
},
{
id: "cluster.viewCurrentClusterSettings",
title: "Cluster: View Settings",
scope: "global",
action: () => navigate(routes.entitySettingsURL({
params: {
entityId: getActiveClusterEntity()?.id,
},
})),
isActive: (context) => !!context.entity,
},
{
id: "cluster.openTerminal",
title: "Cluster: Open terminal",
scope: "entity",
action: () => createTerminalTab(),
isActive: (context) => !!context.entity,
},
{
id: "hotbar.switchHotbar",
title: "Hotbar: Switch ...",
scope: "global",
action: () => CommandOverlay.open(<HotbarSwitchCommand />),
},
{
id: "hotbar.addHotbar",
title: "Hotbar: Add Hotbar ...",
scope: "global",
action: () => CommandOverlay.open(<HotbarAddCommand />),
},
{
id: "hotbar.removeHotbar",
title: "Hotbar: Remove Hotbar ...",
scope: "global",
action: () => CommandOverlay.open(<HotbarRemoveCommand />),
},
{
id: "hotbar.renameHotbar",
title: "Hotbar: Rename Hotbar ...",
scope: "global",
action: () => CommandOverlay.open(<HotbarRenameCommand />),
},
{
id: "catalog.searchEntities",
title: "Catalog: Activate Entity ...",
scope: "global",
action: () => CommandOverlay.open(<ActivateEntityCommand />),
},
]);
}

View File

@ -21,7 +21,6 @@
export * from "./catalog-entity-detail-registry"; export * from "./catalog-entity-detail-registry";
export * from "./catalog"; export * from "./catalog";
export * from "./command-registry";
export * from "./entity-settings-registry"; export * from "./entity-settings-registry";
export * from "./ipc"; export * from "./ipc";
export * from "./kube-object-detail-registry"; export * from "./kube-object-detail-registry";

View File

@ -26,7 +26,6 @@ export function initRegistries() {
registries.CatalogEntityDetailRegistry.createInstance(); registries.CatalogEntityDetailRegistry.createInstance();
registries.ClusterPageMenuRegistry.createInstance(); registries.ClusterPageMenuRegistry.createInstance();
registries.ClusterPageRegistry.createInstance(); registries.ClusterPageRegistry.createInstance();
registries.CommandRegistry.createInstance();
registries.EntitySettingRegistry.createInstance(); registries.EntitySettingRegistry.createInstance();
registries.GlobalPageRegistry.createInstance(); registries.GlobalPageRegistry.createInstance();
registries.KubeObjectDetailRegistry.createInstance(); registries.KubeObjectDetailRegistry.createInstance();

View File

@ -54,7 +54,7 @@ export function isActiveRoute(route: string | string[] | RouteProps): boolean {
return !!matchRoute(route); return !!matchRoute(route);
} }
export function getMatchedClusterId(): string { export function getMatchedClusterId(): string | undefined {
const matched = matchPath<ClusterViewRouteParams>(navigation.location.pathname, { const matched = matchPath<ClusterViewRouteParams>(navigation.location.pathname, {
exact: true, exact: true,
path: clusterViewRoute.path, path: clusterViewRoute.path,

View File

@ -18,17 +18,20 @@
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { noop } from "lodash/fp";
import { action, computed, observable, makeObservable } from "mobx"; import { action, computed, observable, makeObservable } from "mobx";
import type { ForwardedPort } from "../port-forward-item"; import type { ForwardedPort } from "../port-forward-item";
interface PortForwardDialogOpenOptions { interface PortForwardDialogOpenOptions {
openInBrowser: boolean openInBrowser: boolean
onClose: () => void
} }
export class PortForwardDialogModel { export class PortForwardDialogModel {
portForward: ForwardedPort = null; portForward: ForwardedPort = null;
useHttps = false; useHttps = false;
openInBrowser = false; openInBrowser = false;
onClose = noop;
constructor() { constructor() {
makeObservable(this, { makeObservable(this, {
@ -46,10 +49,11 @@ export class PortForwardDialogModel {
return !!this.portForward; return !!this.portForward;
} }
open = (portForward: ForwardedPort, options: PortForwardDialogOpenOptions = { openInBrowser: false }) => { open = (portForward: ForwardedPort, options: PortForwardDialogOpenOptions = { openInBrowser: false, onClose: noop }) => {
this.portForward = portForward; this.portForward = portForward;
this.useHttps = portForward.protocol === "https"; this.useHttps = portForward.protocol === "https";
this.openInBrowser = options.openInBrowser; this.openInBrowser = options.openInBrowser;
this.onClose = options.onClose;
}; };
close = () => { close = () => {

View File

@ -27,25 +27,21 @@ import { observer } from "mobx-react";
import { Dialog, DialogProps } from "../components/dialog"; import { Dialog, DialogProps } from "../components/dialog";
import { Wizard, WizardStep } from "../components/wizard"; import { Wizard, WizardStep } from "../components/wizard";
import { Input } from "../components/input"; import { Input } from "../components/input";
import { Notifications } from "../components/notifications";
import { cssNames } from "../utils"; import { cssNames } from "../utils";
import { getPortForwards } from "./port-forward-store/port-forward-store"; import type { PortForwardStore } from "./port-forward-store/port-forward-store";
import type { ForwardedPort } from "./port-forward-item";
import { openPortForward } from "./port-forward-utils"; import { openPortForward } from "./port-forward-utils";
import { aboutPortForwarding } from "./port-forward-notify"; import { aboutPortForwarding, notifyErrorPortForwarding } from "./port-forward-notify";
import { Checkbox } from "../components/checkbox"; import { Checkbox } from "../components/checkbox";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
import modifyPortForwardInjectable from "./port-forward-store/modify-port-forward/modify-port-forward.injectable";
import type { PortForwardDialogModel } from "./port-forward-dialog-model/port-forward-dialog-model"; import type { PortForwardDialogModel } from "./port-forward-dialog-model/port-forward-dialog-model";
import portForwardDialogModelInjectable from "./port-forward-dialog-model/port-forward-dialog-model.injectable"; import portForwardDialogModelInjectable from "./port-forward-dialog-model/port-forward-dialog-model.injectable";
import addPortForwardInjectable from "./port-forward-store/add-port-forward/add-port-forward.injectable"; import logger from "../../common/logger";
import portForwardStoreInjectable from "./port-forward-store/port-forward-store.injectable";
interface Props extends Partial<DialogProps> { interface Props extends Partial<DialogProps> {}
}
interface Dependencies { interface Dependencies {
modifyPortForward: (item: ForwardedPort, desiredPort: number) => Promise<number>, portForwardStore: PortForwardStore,
addPortForward: (item: ForwardedPort) => Promise<number>,
model: PortForwardDialogModel model: PortForwardDialogModel
} }
@ -59,50 +55,58 @@ class NonInjectedPortForwardDialog extends Component<Props & Dependencies> {
makeObservable(this); makeObservable(this);
} }
get portForwardStore() {
return this.props.portForwardStore;
}
onOpen = async () => { onOpen = async () => {
this.currentPort = +this.props.model.portForward.forwardPort; this.currentPort = +this.props.model.portForward.forwardPort;
this.desiredPort = this.currentPort; this.desiredPort = this.currentPort;
}; };
onClose = () => {
};
changePort = (value: string) => { changePort = (value: string) => {
this.desiredPort = Number(value); this.desiredPort = Number(value);
}; };
startPortForward = async () => { startPortForward = async () => {
const portForward = this.props.model.portForward; let { portForward } = this.props.model;
const { currentPort, desiredPort } = this; const { currentPort, desiredPort } = this;
try { try {
// determine how many port-forwards are already active // determine how many port-forwards already exist
const { length } = await getPortForwards(); const { length } = this.portForwardStore.getPortForwards();
let port: number;
portForward.protocol = this.props.model.useHttps ? "https" : "http"; portForward.protocol = this.props.model.useHttps ? "https" : "http";
if (currentPort) { if (currentPort) {
port = await this.props.modifyPortForward(portForward, desiredPort); const wasRunning = portForward.status === "Active";
portForward = await this.portForwardStore.modify(portForward, desiredPort);
if (wasRunning && portForward.status === "Disabled") {
notifyErrorPortForwarding(`Error occurred starting port-forward, the local port ${portForward.forwardPort} may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`);
}
} else { } else {
portForward.forwardPort = desiredPort; portForward.forwardPort = desiredPort;
port = await this.props.addPortForward(portForward); portForward = await this.portForwardStore.add(portForward);
// if this is the first port-forward show the about notification if (portForward.status === "Disabled") {
if (!length) { notifyErrorPortForwarding(`Error occurred starting port-forward, the local port ${portForward.forwardPort} may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`);
aboutPortForwarding(); } else {
// if this is the first port-forward show the about notification
if (!length) {
aboutPortForwarding();
}
} }
} }
if (this.props.model.openInBrowser) { if (portForward.status === "Active" && this.props.model.openInBrowser) {
portForward.forwardPort = port;
openPortForward(portForward); openPortForward(portForward);
} }
} catch (err) { } catch (error) {
Notifications.error(`Error occurred starting port-forward, the local port may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`); logger.error(`[PORT-FORWARD-DIALOG]: ${error}`, portForward);
} finally { } finally {
close(); this.props.model.close();
} }
}; };
@ -141,7 +145,7 @@ class NonInjectedPortForwardDialog extends Component<Props & Dependencies> {
} }
render() { render() {
const { className, modifyPortForward, model, ...dialogProps } = this.props; const { className, portForwardStore, model, ...dialogProps } = this.props;
const resourceName = this.props.model.portForward?.name ?? ""; const resourceName = this.props.model.portForward?.name ?? "";
const header = ( const header = (
<h5> <h5>
@ -155,14 +159,14 @@ class NonInjectedPortForwardDialog extends Component<Props & Dependencies> {
isOpen={this.props.model.isOpen} isOpen={this.props.model.isOpen}
className={cssNames("PortForwardDialog", className)} className={cssNames("PortForwardDialog", className)}
onOpen={this.onOpen} onOpen={this.onOpen}
onClose={this.onClose} onClose={model.onClose}
close={this.props.model.close} close={this.props.model.close}
> >
<Wizard header={header} done={this.props.model.close}> <Wizard header={header} done={this.props.model.close}>
<WizardStep <WizardStep
contentClass="flex gaps column" contentClass="flex gaps column"
next={this.startPortForward} next={this.startPortForward}
nextLabel={this.currentPort === 0 ? "Start" : "Restart"} nextLabel={this.currentPort === 0 ? "Start" : "Modify"}
> >
{this.renderContents()} {this.renderContents()}
</WizardStep> </WizardStep>
@ -177,8 +181,7 @@ export const PortForwardDialog = withInjectables<Dependencies, Props>(
{ {
getProps: (di, props) => ({ getProps: (di, props) => ({
modifyPortForward: di.inject(modifyPortForwardInjectable), portForwardStore: di.inject(portForwardStoreInjectable),
addPortForward: di.inject(addPortForwardInjectable),
model: di.inject(portForwardDialogModelInjectable), model: di.inject(portForwardDialogModelInjectable),
...props, ...props,
}), }),

View File

@ -23,33 +23,34 @@
import type { ItemObject } from "../../common/item.store"; import type { ItemObject } from "../../common/item.store";
import { autoBind } from "../../common/utils"; import { autoBind } from "../../common/utils";
export type ForwardedPortStatus = "Active" | "Disabled";
export interface ForwardedPort { export interface ForwardedPort {
clusterId?: string;
kind: string; kind: string;
namespace: string; namespace: string;
name: string; name: string;
port: number; port: number;
forwardPort: number; forwardPort: number;
protocol?: string; protocol?: string;
status?: ForwardedPortStatus;
} }
export class PortForwardItem implements ItemObject { export class PortForwardItem implements ItemObject {
clusterId: string;
kind: string; kind: string;
namespace: string; namespace: string;
name: string; name: string;
port: number; port: number;
forwardPort: number; forwardPort: number;
protocol: string; protocol: string;
status: ForwardedPortStatus;
constructor(pf: ForwardedPort) { constructor(pf: ForwardedPort) {
this.clusterId = pf.clusterId;
this.kind = pf.kind; this.kind = pf.kind;
this.namespace = pf.namespace; this.namespace = pf.namespace;
this.name = pf.name; this.name = pf.name;
this.port = pf.port; this.port = pf.port;
this.forwardPort = pf.forwardPort; this.forwardPort = pf.forwardPort;
this.protocol = pf.protocol ?? "http"; this.protocol = pf.protocol ?? "http";
this.status = pf.status ?? "Active";
autoBind(this); autoBind(this);
} }
@ -62,12 +63,8 @@ export class PortForwardItem implements ItemObject {
return this.namespace; return this.namespace;
} }
get id() {
return this.forwardPort;
}
getId() { getId() {
return String(this.forwardPort); return `${this.namespace}-${this.kind}-${this.name}:${this.port}`;
} }
getKind() { getKind() {
@ -87,16 +84,17 @@ export class PortForwardItem implements ItemObject {
} }
getStatus() { getStatus() {
return "Active"; // to-do allow port-forward-items to be stopped (without removing them) return this.status;
} }
getSearchFields() { getSearchFields() {
return [ return [
this.name, this.name,
this.id, this.namespace,
this.kind, this.kind,
this.port, this.port,
this.forwardPort, this.forwardPort,
this.status,
]; ];
} }
} }

View File

@ -56,3 +56,34 @@ export function aboutPortForwarding() {
}, },
); );
} }
export function notifyErrorPortForwarding(msg: string) {
const notificationId = `port-forward-error-notification-${getHostedClusterId()}`;
Notifications.error(
(
<div className="flex column gaps">
<b>Port Forwarding</b>
<p>
{msg}
</p>
<div className="flex gaps row align-left box grow">
<Button
active
outlined
label="Check Port Forwarding"
onClick={() => {
navigate(portForwardsURL());
notificationsStore.remove(notificationId);
}}
/>
</div>
</div>
),
{
id: notificationId,
timeout: 10_000,
},
);
}

View File

@ -19,21 +19,21 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { action, makeObservable, observable, reaction } from "mobx";
import { makeObservable, observable, reaction } from "mobx";
import { ItemStore } from "../../../common/item.store"; import { ItemStore } from "../../../common/item.store";
import { autoBind, disposer, getHostedClusterId, StorageHelper } from "../../utils"; import { autoBind, disposer, StorageHelper } from "../../utils";
import { ForwardedPort, PortForwardItem } from "../port-forward-item"; import { ForwardedPort, PortForwardItem } from "../port-forward-item";
import { notifyErrorPortForwarding } from "../port-forward-notify";
import { apiBase } from "../../api"; import { apiBase } from "../../api";
import logger from "../../../common/logger";
import { waitUntilFree } from "tcp-port-used"; import { waitUntilFree } from "tcp-port-used";
import logger from "../../../common/logger";
interface Dependencies { interface Dependencies {
storage: StorageHelper<ForwardedPort[] | undefined> storage: StorageHelper<ForwardedPort[] | undefined>
} }
export class PortForwardStore extends ItemStore<PortForwardItem> { export class PortForwardStore extends ItemStore<PortForwardItem> {
@observable portForwards: PortForwardItem[]; @observable portForwards: PortForwardItem[] = [];
constructor(private dependencies: Dependencies) { constructor(private dependencies: Dependencies) {
super(); super();
@ -50,33 +50,58 @@ export class PortForwardStore extends ItemStore<PortForwardItem> {
if (Array.isArray(savedPortForwards)) { if (Array.isArray(savedPortForwards)) {
logger.info("[PORT-FORWARD-STORE] starting saved port-forwards"); logger.info("[PORT-FORWARD-STORE] starting saved port-forwards");
await Promise.all(savedPortForwards.map(this.add));
// add the disabled ones
await Promise.all(
savedPortForwards
.filter((pf) => pf.status === "Disabled")
.map(this.add),
);
// add the active ones and check if they started successfully
const results = await Promise.allSettled(
savedPortForwards
.filter((pf) => pf.status === "Active")
.map(this.add),
);
for (const result of results) {
if (
result.status === "rejected" ||
result.value.status === "Disabled"
) {
notifyErrorPortForwarding(
"One or more port-forwards could not be started",
);
return;
}
}
} }
} }
watch() { watch() {
return disposer( return disposer(
reaction(() => this.portForwards, () => this.loadAll()), reaction(
() => this.portForwards.slice(),
() => this.loadAll(),
),
); );
} }
loadAll() { loadAll() {
return this.loadItems(async () => { return this.loadItems(() => {
const portForwards = await getPortForwards(getHostedClusterId()); const portForwards = this.getPortForwards();
this.dependencies.storage.set(portForwards); this.dependencies.storage.set(portForwards);
this.reset(); this.portForwards = [];
portForwards.map(pf => this.portForwards.push(new PortForwardItem(pf))); portForwards.map((pf) => this.portForwards.push(new PortForwardItem(pf)));
return this.portForwards; return this.portForwards;
}); });
} }
reset = () => {
this.portForwards = [];
};
async removeSelectedItems() { async removeSelectedItems() {
return Promise.all(this.selectedItems.map(this.remove)); return Promise.all(this.selectedItems.map(this.remove));
} }
@ -91,87 +116,285 @@ export class PortForwardStore extends ItemStore<PortForwardItem> {
return this.getItems()[index]; return this.getItems()[index];
} }
add = async (portForward: ForwardedPort): Promise<number> => { /**
* add a port-forward to the store and optionally start it
* @param portForward the port-forward to add. If the port-forward already exists in the store it will be
* returned with its current state. If the forwardPort field is 0 then an arbitrary port will be
* used. If the status field is "Active" or not present then an attempt is made to start the port-forward.
*
* @returns the port-forward with updated status ("Active" if successfully started, "Disabled" otherwise) and
* forwardPort
*/
add = action(async (portForward: ForwardedPort): Promise<ForwardedPort> => {
const pf = this.findPortForward(portForward);
if (pf) {
return pf;
}
this.portForwards.push(new PortForwardItem(portForward));
if (!portForward.status) {
portForward.status = "Active";
}
if (portForward.status === "Active") {
portForward = await this.start(portForward);
}
return portForward;
});
/**
* modifies a port-forward in the store, including the forwardPort and protocol
* @param portForward the port-forward to modify.
*
* @returns the port-forward after being modified.
*/
modify = action(
async (
portForward: ForwardedPort,
desiredPort: number,
): Promise<ForwardedPort> => {
const pf = this.findPortForward(portForward);
if (!pf) {
throw new Error("port-forward not found");
}
if (pf.status === "Active") {
try {
await this.stop(pf);
} catch {
// ignore, assume it is stopped and proceed to restart it
}
pf.forwardPort = desiredPort;
pf.protocol = portForward.protocol ?? "http";
this.setPortForward(pf);
return await this.start(pf);
}
pf.forwardPort = desiredPort;
this.setPortForward(pf);
return pf as ForwardedPort;
},
);
/**
* remove and stop an existing port-forward.
* @param portForward the port-forward to remove.
*/
remove = action(async (portForward: ForwardedPort) => {
const pf = this.findPortForward(portForward);
if (!pf) {
const error = new Error("port-forward not found");
logger.warn(
`[PORT-FORWARD-STORE] Error getting port-forward: ${error}`,
portForward,
);
return;
}
try {
await this.stop(portForward);
} catch (error) {
if (pf.status === "Active") {
logger.warn(
`[PORT-FORWARD-STORE] Error removing port-forward: ${error}`,
portForward,
);
}
}
const index = this.portForwards.findIndex(portForwardsEqual(portForward));
if (index >= 0) {
this.portForwards.splice(index, 1);
}
});
/**
* gets the list of port-forwards in the store
*
* @returns the port-forwards
*/
getPortForwards = (): ForwardedPort[] => {
return this.portForwards;
};
/**
* stop an existing port-forward. Its status is set to "Disabled" after successfully stopped.
* @param portForward the port-forward to stop.
*
* @throws if the port-forward could not be stopped. Its status is unchanged
*/
stop = action(async (portForward: ForwardedPort) => {
const pf = this.findPortForward(portForward);
if (!pf) {
logger.warn(
"[PORT-FORWARD-STORE] Error getting port-forward: port-forward not found",
portForward,
);
return;
}
const { port, forwardPort } = portForward; const { port, forwardPort } = portForward;
try {
await apiBase.del(
`/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}`,
{ query: { port, forwardPort }},
);
await waitUntilFree(+forwardPort, 200, 1000);
} catch (error) {
logger.warn(
`[PORT-FORWARD-STORE] Error stopping active port-forward: ${error}`,
portForward,
);
throw error;
}
pf.status = "Disabled";
this.setPortForward(pf);
});
private findPortForward = (portForward: ForwardedPort) => {
return this.portForwards.find(portForwardsEqual(portForward));
};
private setPortForward = action((portForward: ForwardedPort) => {
const index = this.portForwards.findIndex(portForwardsEqual(portForward));
if (index < 0) {
return;
}
this.portForwards[index] = new PortForwardItem(portForward);
});
/**
* start an existing port-forward
* @param portForward the port-forward to start. If the forwardPort field is 0 then an arbitrary port will be
* used
*
* @returns the port-forward with updated status ("Active" if successfully started, "Disabled" otherwise) and
* forwardPort
*
* @throws if the port-forward does not already exist in the store
*/
start = action(async (portForward: ForwardedPort): Promise<ForwardedPort> => {
const pf = this.findPortForward(portForward);
if (!pf) {
throw new Error("cannot start non-existent port-forward");
}
const { port, forwardPort } = pf;
let response: PortForwardResult; let response: PortForwardResult;
try { try {
const protocol = portForward.protocol ?? "http"; const protocol = pf.protocol ?? "http";
response = await apiBase.post<PortForwardResult>(`/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}`, { query: { port, forwardPort, protocol }}); response = await apiBase.post<PortForwardResult>(
`/pods/port-forward/${pf.namespace}/${pf.kind}/${pf.name}`,
{ query: { port, forwardPort, protocol }},
);
// expecting the received port to be the specified port, unless the specified port is 0, which indicates any available port is suitable // expecting the received port to be the specified port, unless the specified port is 0, which indicates any available port is suitable
if (portForward.forwardPort && response?.port && response.port != +portForward.forwardPort) { if (
logger.warn(`[PORT-FORWARD-STORE] specified ${portForward.forwardPort} got ${response.port}`); pf.forwardPort &&
response?.port &&
response.port != +pf.forwardPort
) {
logger.warn(
`[PORT-FORWARD-STORE] specified ${pf.forwardPort}, got ${response.port}`,
);
} }
pf.forwardPort = response.port;
pf.status = "Active";
} catch (error) { } catch (error) {
logger.warn("[PORT-FORWARD-STORE] Error adding port-forward:", error, portForward); logger.warn(
throw (error); `[PORT-FORWARD-STORE] Error starting port-forward: ${error}`,
pf,
);
pf.status = "Disabled";
} }
this.reset(); this.setPortForward(pf);
return response?.port; return pf as ForwardedPort;
}; });
remove = async (portForward: ForwardedPort) => { /**
const { port, forwardPort } = portForward; * get a port-forward from the store, with up-to-date status
* @param portForward the port-forward to get.
*
* @returns the port-forward with updated status ("Active" if running, "Disabled" if not) and
* forwardPort used.
*
* @throws if the port-forward does not exist in the store
*/
getPortForward = async (
portForward: ForwardedPort,
): Promise<ForwardedPort> => {
if (!this.findPortForward(portForward)) {
throw new Error("port-forward not found");
}
let pf: ForwardedPort;
try { try {
await apiBase.del(`/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}`, { query: { port, forwardPort }}); // check if the port-forward is active, and if so check if it has the same local port
await waitUntilFree(+forwardPort, 200, 1000); pf = await getActivePortForward(portForward);
if (pf.forwardPort && pf.forwardPort !== portForward.forwardPort) {
logger.warn(
`[PORT-FORWARD-STORE] local port, expected ${pf.forwardPort}, got ${portForward.forwardPort}`,
);
}
} catch (error) { } catch (error) {
logger.warn("[PORT-FORWARD-STORE] Error removing port-forward:", error, portForward); // port is not active
throw (error);
} }
this.reset(); return pf;
};
modify = async (portForward: ForwardedPort, desiredPort: number): Promise<number> => {
await this.remove(portForward);
portForward.forwardPort = desiredPort;
const port = await this.add(portForward);
this.reset();
return port;
}; };
} }
export interface PortForwardResult { interface PortForwardResult {
port: number; port: number;
} }
interface PortForwardsResult { function portForwardsEqual(portForward: ForwardedPort) {
portForwards: ForwardedPort[]; return (pf: ForwardedPort) => (
pf.kind == portForward.kind &&
pf.name == portForward.name &&
pf.namespace == portForward.namespace &&
pf.port == portForward.port
);
} }
export async function getPortForward(portForward: ForwardedPort): Promise<number> { async function getActivePortForward(portForward: ForwardedPort): Promise<ForwardedPort> {
const { port, forwardPort, protocol } = portForward; const { port, forwardPort, protocol } = portForward;
let response: PortForwardResult; let response: PortForwardResult;
try { try {
response = await apiBase.get<PortForwardResult>(`/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}`, { query: { port, forwardPort, protocol }}); response = await apiBase.get<PortForwardResult>(`/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}`, { query: { port, forwardPort, protocol }});
} catch (error) { } catch (error) {
logger.warn("[PORT-FORWARD-STORE] Error getting port-forward:", error, portForward); logger.warn(`[PORT-FORWARD-STORE] Error getting active port-forward: ${error}`, portForward);
throw (error);
} }
return response?.port; portForward.status = response?.port ? "Active" : "Disabled";
} portForward.forwardPort = response?.port;
return portForward;
export async function getPortForwards(clusterId?: string): Promise<ForwardedPort[]> {
try {
const response = await apiBase.get<PortForwardsResult>("/pods/port-forwards", { query: { clusterId }});
return response.portForwards;
} catch (error) {
logger.warn("[PORT-FORWARD-STORE] Error getting all port-forwards:", error);
return [];
}
} }

View File

@ -35,7 +35,6 @@ export function openPortForward(portForward: ForwardedPort) {
openExternal(browseTo) openExternal(browseTo)
.catch(error => { .catch(error => {
logger.error(`failed to open in browser: ${error}`, { logger.error(`failed to open in browser: ${error}`, {
clusterId: portForward.clusterId,
port: portForward.port, port: portForward.port,
kind: portForward.kind, kind: portForward.kind,
namespace: portForward.namespace, namespace: portForward.namespace,

View File

@ -22,7 +22,7 @@
import { SearchStore } from "./search-store"; import { SearchStore } from "./search-store";
import { Console } from "console"; import { Console } from "console";
import { stdout, stderr } from "process"; import { stdout, stderr } from "process";
import { getDiForUnitTesting } from "../components/getDiForUnitTesting"; import { getDiForUnitTesting } from "../getDiForUnitTesting";
import searchStoreInjectable from "./search-store.injectable"; import searchStoreInjectable from "./search-store.injectable";
import directoryForUserDataInjectable import directoryForUserDataInjectable
from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; from "../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";

View File

@ -19,18 +19,18 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { HotbarStore } from "../../../common/hotbar-store"; import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import type { Disposer } from "../utils";
function hotbarIndex(id: string) { function addWindowEventListener<K extends keyof WindowEventMap>(type: K, listener: (this: Window, ev: WindowEventMap[K]) => any, options?: boolean | AddEventListenerOptions): Disposer {
return HotbarStore.getInstance().hotbarIndex(id) + 1; window.addEventListener(type, listener, options);
return () => void window.removeEventListener(type, listener);
} }
export function hotbarDisplayLabel(id: string) : string { const windowAddEventListenerInjectable = getInjectable({
const hotbar = HotbarStore.getInstance().getById(id); instantiate: () => addWindowEventListener,
lifecycle: lifecycleEnum.singleton,
});
return `${hotbarIndex(id)}: ${hotbar.name}`; export default windowAddEventListenerInjectable;
}
export function hotbarDisplayIndex(id: string) : string {
return hotbarIndex(id).toString();
}

View File

@ -18,7 +18,7 @@
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { getDiForUnitTesting as getRendererDi } from "../renderer/components/getDiForUnitTesting"; import { getDiForUnitTesting as getRendererDi } from "../renderer/getDiForUnitTesting";
import { getDiForUnitTesting as getMainDi } from "../main/getDiForUnitTesting"; import { getDiForUnitTesting as getMainDi } from "../main/getDiForUnitTesting";
import { overrideIpcBridge } from "./override-ipc-bridge"; import { overrideIpcBridge } from "./override-ipc-bridge";