From 7c5a0a9a6dc305c28642b2b5a66fa6afe9d7fc3f Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Tue, 23 Nov 2021 08:35:16 -0500 Subject: [PATCH] Improve local shell CWD setting (#4396) --- src/common/utils/paths.ts | 10 +- src/main/shell-session/shell-session.ts | 50 +++- .../cluster-settings/cluster-settings.tsx | 2 +- .../cluster-local-terminal-settings.test.tsx | 155 +++++++++++++ .../components/cluster-home-dir-setting.tsx | 109 --------- .../cluster-local-terminal-settings.tsx | 214 ++++++++++++++++++ .../cluster-settings/components/index.ts | 2 +- 7 files changed, 422 insertions(+), 120 deletions(-) create mode 100644 src/renderer/components/cluster-settings/components/__tests__/cluster-local-terminal-settings.test.tsx delete mode 100644 src/renderer/components/cluster-settings/components/cluster-home-dir-setting.tsx create mode 100644 src/renderer/components/cluster-settings/components/cluster-local-terminal-settings.tsx diff --git a/src/common/utils/paths.ts b/src/common/utils/paths.ts index 68d8f61ffa..cf2c984646 100644 --- a/src/common/utils/paths.ts +++ b/src/common/utils/paths.ts @@ -22,9 +22,13 @@ import path from "path"; import os from "os"; -function resolveTilde(filePath: string) { - if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) { - return filePath.replace("~", os.homedir()); +export function resolveTilde(filePath: string) { + if (filePath === "~") { + return os.homedir(); + } + + if (filePath.startsWith("~/")) { + return `${os.homedir()}${filePath.slice(1)}`; } return filePath; diff --git a/src/main/shell-session/shell-session.ts b/src/main/shell-session/shell-session.ts index 0fb9b3ef11..b171986d56 100644 --- a/src/main/shell-session/shell-session.ts +++ b/src/main/shell-session/shell-session.ts @@ -19,7 +19,6 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import fse from "fs-extra"; import type { Cluster } from "../cluster"; import { Kubectl } from "../kubectl"; import type WebSocket from "ws"; @@ -27,13 +26,15 @@ import { shellEnv } from "../utils/shell-env"; import { app } from "electron"; import { clearKubeconfigEnvVars } from "../utils/clear-kube-env-vars"; import path from "path"; -import { isWindows } from "../../common/vars"; +import os from "os"; +import { isMac, isWindows } from "../../common/vars"; import { UserStore } from "../../common/user-store"; import * as pty from "node-pty"; import { appEventBus } from "../../common/event-bus"; import logger from "../logger"; import { TerminalChannels, TerminalMessage } from "../../renderer/api/terminal-api"; import { deserialize, serialize } from "v8"; +import { stat } from "fs/promises"; export class ShellOpenError extends Error { constructor(message: string, public cause: Error) { @@ -178,10 +179,47 @@ export abstract class ShellSession { this.websocket.send(serialize(message)); } - protected async openShellProcess(shell: string, args: string[], env: Record) { - const cwd = (this.cwd && await fse.pathExists(this.cwd)) - ? this.cwd - : env.HOME; + protected async getCwd(env: Record): Promise { + const cwdOptions = [this.cwd]; + + if (isWindows) { + cwdOptions.push( + env.USERPROFILE, + os.homedir(), + "C:\\", + ); + } else { + cwdOptions.push( + env.HOME, + os.homedir(), + ); + + if (isMac) { + cwdOptions.push("/Users"); + } else { + cwdOptions.push("/home"); + } + } + + for (const potentialCwd of cwdOptions) { + if (!potentialCwd) { + continue; + } + + try { + const stats = await stat(potentialCwd); + + if (stats.isDirectory()) { + return potentialCwd; + } + } catch {} + } + + return "."; // Always valid + } + + protected async openShellProcess(shell: string, args: string[], env: Record) { + const cwd = await this.getCwd(env); const { shellProcess, resume } = this.ensureShellProcess(shell, args, env, cwd); if (resume) { diff --git a/src/renderer/components/cluster-settings/cluster-settings.tsx b/src/renderer/components/cluster-settings/cluster-settings.tsx index 6c35c1bbc8..762d0789fc 100644 --- a/src/renderer/components/cluster-settings/cluster-settings.tsx +++ b/src/renderer/components/cluster-settings/cluster-settings.tsx @@ -79,7 +79,7 @@ export function TerminalSettings({ entity }: EntitySettingViewProps) { return (
- +
); } diff --git a/src/renderer/components/cluster-settings/components/__tests__/cluster-local-terminal-settings.test.tsx b/src/renderer/components/cluster-settings/components/__tests__/cluster-local-terminal-settings.test.tsx new file mode 100644 index 0000000000..9c2db50ea9 --- /dev/null +++ b/src/renderer/components/cluster-settings/components/__tests__/cluster-local-terminal-settings.test.tsx @@ -0,0 +1,155 @@ +/** + * 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 { render, waitFor } from "@testing-library/react"; +import { ClusterLocalTerminalSetting } from "../cluster-local-terminal-settings"; +import userEvent from "@testing-library/user-event"; +import { stat } from "fs/promises"; +import { Notifications } from "../../../notifications"; + +const mockStat = stat as jest.MockedFunction; + +jest.mock("fs", () => { + const actual = jest.requireActual("fs"); + + actual.promises.stat = jest.fn(); + + return actual; +}); + +jest.mock("../../../notifications"); + +describe("ClusterLocalTerminalSettings", () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("should render without errors", () => { + const dom = render(); + + expect(dom.container).toBeInstanceOf(HTMLElement); + }); + + it("should render the current settings", async () => { + const cluster = { + preferences: { + terminalCWD: "/foobar", + defaultNamespace: "kube-system", + }, + getKubeconfig: jest.fn(() => ({ + getContextObject: jest.fn(() => ({})), + })), + } as any; + const dom = render(); + + expect(await dom.findByDisplayValue("/foobar")).toBeDefined(); + expect(await dom.findByDisplayValue("kube-system")).toBeDefined(); + }); + + it("should change placeholder for 'Default Namespace' to be the namespace from the kubeconfig", async () => { + const cluster = { + preferences: { + terminalCWD: "/foobar", + }, + getKubeconfig: jest.fn(() => ({ + getContextObject: jest.fn(() => ({ + namespace: "blat", + })), + })), + } as any; + const dom = render(); + + expect(await dom.findByDisplayValue("/foobar")).toBeDefined(); + expect(await dom.findByPlaceholderText("blat")).toBeDefined(); + }); + + it("should save the new default namespace after clicking away", async () => { + const cluster = { + preferences: { + terminalCWD: "/foobar", + }, + getKubeconfig: jest.fn(() => ({ + getContextObject: jest.fn(() => ({})), + })), + } as any; + + const dom = render(); + const dn = await dom.findByTestId("default-namespace"); + + userEvent.click(dn); + userEvent.type(dn, "kube-system"); + userEvent.click(dom.baseElement); + + await waitFor(() => expect(cluster.preferences.defaultNamespace).toBe("kube-system")); + }); + + it("should save the new CWD if path is a directory", async () => { + mockStat.mockImplementation(async (path: string) => { + expect(path).toBe("/foobar"); + + return { + isDirectory: () => true, + } as any; + }); + + const cluster = { + getKubeconfig: jest.fn(() => ({ + getContextObject: jest.fn(() => ({})), + })), + } as any; + + const dom = render(); + const dn = await dom.findByTestId("working-directory"); + + userEvent.click(dn); + userEvent.type(dn, "/foobar"); + userEvent.click(dom.baseElement); + + await waitFor(() => expect(cluster.preferences?.terminalCWD).toBe("/foobar")); + }); + + it("should not save the new CWD if path is a file", async () => { + mockStat.mockImplementation(async (path: string) => { + expect(path).toBe("/foobar"); + + return { + isDirectory: () => false, + isFile: () => true, + } as any; + }); + + const cluster = { + getKubeconfig: jest.fn(() => ({ + getContextObject: jest.fn(() => ({})), + })), + } as any; + + const dom = render(); + const dn = await dom.findByTestId("working-directory"); + + userEvent.click(dn); + userEvent.type(dn, "/foobar"); + userEvent.click(dom.baseElement); + + await waitFor(() => expect(Notifications.error).toBeCalled()); + }); +}); diff --git a/src/renderer/components/cluster-settings/components/cluster-home-dir-setting.tsx b/src/renderer/components/cluster-settings/components/cluster-home-dir-setting.tsx deleted file mode 100644 index 0af3238792..0000000000 --- a/src/renderer/components/cluster-settings/components/cluster-home-dir-setting.tsx +++ /dev/null @@ -1,109 +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 { observable, autorun, makeObservable } from "mobx"; -import { observer, disposeOnUnmount } from "mobx-react"; -import type { Cluster } from "../../../../main/cluster"; -import { Input } from "../../input"; -import { SubTitle } from "../../layout/sub-title"; - -interface Props { - cluster: Cluster; -} - -@observer -export class ClusterHomeDirSetting extends React.Component { - @observable directory = ""; - @observable defaultNamespace = ""; - - constructor(props: Props) { - super(props); - makeObservable(this); - } - - async componentDidMount() { - const kubeconfig = await this.props.cluster.getKubeconfig(); - - const defaultNamespace = this.props.cluster.preferences?.defaultNamespace || kubeconfig.getContextObject(this.props.cluster.contextName).namespace; - - disposeOnUnmount(this, - autorun(() => { - this.directory = this.props.cluster.preferences.terminalCWD || ""; - this.defaultNamespace = defaultNamespace || ""; - }), - ); - } - - saveCWD = () => { - this.props.cluster.preferences.terminalCWD = this.directory; - }; - - onChangeTerminalCWD = (value: string) => { - this.directory = value; - }; - - saveDefaultNamespace = () => { - if (this.defaultNamespace) { - this.props.cluster.preferences.defaultNamespace = this.defaultNamespace; - } else { - this.props.cluster.preferences.defaultNamespace = undefined; - } - }; - - onChangeDefaultNamespace = (value: string) => { - this.defaultNamespace = value; - }; - - render() { - return ( - <> -
- - - - An explicit start path where the terminal will be launched,{" "} - this is used as the current working directory (cwd) for the shell process. - -
-
- - - - Default namespace used for kubectl. - -
- - ); - } -} diff --git a/src/renderer/components/cluster-settings/components/cluster-local-terminal-settings.tsx b/src/renderer/components/cluster-settings/components/cluster-local-terminal-settings.tsx new file mode 100644 index 0000000000..22774a5af8 --- /dev/null +++ b/src/renderer/components/cluster-settings/components/cluster-local-terminal-settings.tsx @@ -0,0 +1,214 @@ +/** + * 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, { useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import type { Cluster } from "../../../../main/cluster"; +import { Input } from "../../input"; +import { SubTitle } from "../../layout/sub-title"; +import { stat } from "fs/promises"; +import { Notifications } from "../../notifications"; +import { resolveTilde } from "../../../utils"; +import { Icon } from "../../icon"; +import { PathPicker } from "../../path-picker"; +import { isWindows } from "../../../../common/vars"; +import type { Stats } from "fs"; +import logger from "../../../../common/logger"; +import { lowerFirst } from "lodash"; + +interface Props { + cluster: Cluster; +} + +function getUserReadableFileType(stats: Stats): string { + if (stats.isFile()) { + return "a file"; + } + + if (stats.isFIFO()) { + return "a pipe"; + } + + if (stats.isSocket()) { + return "a socket"; + } + + if (stats.isBlockDevice()) { + return "a block device"; + } + + if (stats.isCharacterDevice()) { + return "a character device"; + } + + return "an unknown file type"; +} + +/** + * Validate that `dir` currently points to a directory. If so return `false`. + * Otherwise, return a user readable error message string for displaying. + * @param dir The path to be validated + */ +async function validateDirectory(dir: string): Promise { + try { + const stats = await stat(dir); + + if (stats.isDirectory()) { + return false; + } + + return `the provided path is ${getUserReadableFileType(stats)} and not a directory.`; + } catch (error) { + switch (error?.code) { + case "ENOENT": + return `the provided path does not exist.`; + case "EACCES": + return `search permissions is denied for one of the directories in the prefix of the provided path.`; + case "ELOOP": + return `the provided path is a sym-link which points to a chain of sym-links that is too long to resolve. Perhaps it is cyclic.`; + case "ENAMETOOLONG": + return `the pathname is too long to be used.`; + case "ENOTDIR": + return `a prefix of the provided path is not a directory.`; + default: + logger.warn(`[CLUSTER-LOCAL-TERMINAL-SETTINGS]: unexpected error in validateDirectory for resolved path=${dir}`, error); + + return error ? lowerFirst(String(error)) : "of an unknown error, please try again."; + } + } +} + +export const ClusterLocalTerminalSetting = observer(({ cluster }: Props) => { + if (!cluster) { + return null; + } + + const [directory, setDirectory] = useState(cluster.preferences?.terminalCWD || ""); + const [defaultNamespace, setDefaultNamespaces] = useState(cluster.preferences?.defaultNamespace || ""); + const [placeholderDefaultNamespace, setPlaceholderDefaultNamespace] = useState("default"); + + useEffect(() => { + (async () => { + const kubeconfig = await cluster.getKubeconfig(); + const { namespace } = kubeconfig.getContextObject(cluster.contextName); + + if (namespace) { + setPlaceholderDefaultNamespace(namespace); + } + })(); + setDirectory(cluster.preferences?.terminalCWD || ""); + setDefaultNamespaces(cluster.preferences?.defaultNamespace || ""); + }, [cluster]); + + const commitDirectory = async (directory: string) => { + cluster.preferences ??= {}; + + if (!directory) { + cluster.preferences.terminalCWD = undefined; + } else { + const dir = resolveTilde(directory); + const errorMessage = await validateDirectory(dir); + + if (errorMessage) { + Notifications.error( + <> + Terminal Working Directory +

Your changes were not saved because {errorMessage}

+ , + ); + } else { + cluster.preferences.terminalCWD = dir; + setDirectory(dir); + } + } + }; + + const commitDefaultNamespace = () => { + cluster.preferences ??= {}; + cluster.preferences.defaultNamespace = defaultNamespace || undefined; + }; + + const setAndCommitDirectory = (newPath: string) => { + setDirectory(newPath); + commitDirectory(newPath); + }; + + const openFilePicker = () => { + PathPicker.pick({ + label: "Choose Working Directory", + buttonLabel: "Pick", + properties: ["openDirectory", "showHiddenFiles"], + onPick: ([directory]) => setAndCommitDirectory(directory), + }); + }; + + return ( + <> +
+ + commitDirectory(directory)} + placeholder={isWindows ? "$USERPROFILE" : "$HOME"} + iconRight={ + <> + { + directory && ( + setAndCommitDirectory("")} + /> + ) + } + + + } + /> + + An explicit start path where the terminal will be launched,{" "} + this is used as the current working directory (cwd) for the shell process. + +
+
+ + + + Default namespace used for kubectl. + +
+ + ); +}); diff --git a/src/renderer/components/cluster-settings/components/index.ts b/src/renderer/components/cluster-settings/components/index.ts index beb80269b7..55a1340ced 100644 --- a/src/renderer/components/cluster-settings/components/index.ts +++ b/src/renderer/components/cluster-settings/components/index.ts @@ -20,7 +20,7 @@ */ export * from "./cluster-accessible-namespaces"; -export * from "./cluster-home-dir-setting"; +export * from "./cluster-local-terminal-settings"; export * from "./cluster-kubeconfig"; export * from "./cluster-metrics-setting"; export * from "./cluster-name-setting";