diff --git a/src/common/utils/paths.ts b/src/common/utils/paths.ts index 86ada911e6..cf2c984646 100644 --- a/src/common/utils/paths.ts +++ b/src/common/utils/paths.ts @@ -23,8 +23,12 @@ import path from "path"; import os from "os"; export function resolveTilde(filePath: string) { - if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) { - return filePath.replace("~", os.homedir()); + 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 1f58903035..b171986d56 100644 --- a/src/main/shell-session/shell-session.ts +++ b/src/main/shell-session/shell-session.ts @@ -26,7 +26,8 @@ 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"; @@ -179,19 +180,42 @@ export abstract class ShellSession { } protected async getCwd(env: Record): Promise { - if (this.cwd) { + 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(this.cwd); + const stats = await stat(potentialCwd); if (stats.isDirectory()) { - return this.cwd; + return potentialCwd; } } catch {} } - return isWindows - ? env.USERPROFILE - : env.HOME; + return "."; // Always valid } protected async openShellProcess(shell: string, args: string[], env: Record) { 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-local-terminal-settings.tsx b/src/renderer/components/cluster-settings/components/cluster-local-terminal-settings.tsx index 99c993ab1f..22774a5af8 100644 --- a/src/renderer/components/cluster-settings/components/cluster-local-terminal-settings.tsx +++ b/src/renderer/components/cluster-settings/components/cluster-local-terminal-settings.tsx @@ -19,9 +19,8 @@ * 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 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"; @@ -31,155 +30,185 @@ 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; } -@observer -export class ClusterLocalTerminalSetting extends React.Component { - @observable directory = ""; - @observable defaultNamespace = ""; - - constructor(props: Props) { - super(props); - makeObservable(this); +function getUserReadableFileType(stats: Stats): string { + if (stats.isFile()) { + return "a file"; } - 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 || ""; - }), - ); + if (stats.isFIFO()) { + return "a pipe"; } - saveCWD = async () => { - if (!this.directory) { - this.props.cluster.preferences.terminalCWD = undefined; + if (stats.isSocket()) { + return "a socket"; + } - return; + 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; } - try { - const dir = resolveTilde(this.directory); - const stats = await stat(dir); + 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); - if (stats.isDirectory()) { - this.props.cluster.preferences.terminalCWD = dir; - } else { - Notifications.error( - <> - Shell Working Directory -

Provided path is not a directory, your changes were not saved.

- , - ); - } - } catch (error) { - if (error.code === "ENOENT") { - Notifications.error( - <> - Shell Working Directory -

Provided path does not exist, your changes were not saved.

- , - ); - } else { - Notifications.error( - <> - Shell Working Directory -

Your changes were not saved due to the error bellow

-

{String(error)}

- , - ); - } + return error ? lowerFirst(String(error)) : "of an unknown error, please try again."; } - }; + } +} - onChangeTerminalCWD = (value: string) => { - this.directory = value; - }; +export const ClusterLocalTerminalSetting = observer(({ cluster }: Props) => { + if (!cluster) { + return null; + } - saveDefaultNamespace = () => { - if (this.defaultNamespace) { - this.props.cluster.preferences.defaultNamespace = this.defaultNamespace; + 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 { - this.props.cluster.preferences.defaultNamespace = undefined; + 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); + } } }; - onChangeDefaultNamespace = (value: string) => { - this.defaultNamespace = value; + const commitDefaultNamespace = () => { + cluster.preferences ??= {}; + cluster.preferences.defaultNamespace = defaultNamespace || undefined; }; - openFilePicker = () => { + const setAndCommitDirectory = (newPath: string) => { + setDirectory(newPath); + commitDirectory(newPath); + }; + + const openFilePicker = () => { PathPicker.pick({ label: "Choose Working Directory", buttonLabel: "Pick", properties: ["openDirectory", "showHiddenFiles"], - onPick: ([directory]) => { - this.props.cluster.preferences.terminalCWD = directory; - }, + onPick: ([directory]) => setAndCommitDirectory(directory), }); }; - onClearCWD = () => { - this.props.cluster.preferences.terminalCWD = undefined; - }; - - render() { - return ( - <> -
- - - { - this.directory && ( - - ) - } - - - } - /> - - 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. - -
- - ); - } -} + 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. + +
+ + ); +});