diff --git a/emain/emain-ipc.ts b/emain/emain-ipc.ts index 09ba69d1eb..176184371a 100644 --- a/emain/emain-ipc.ts +++ b/emain/emain-ipc.ts @@ -258,6 +258,21 @@ export function initIpcHandlers() { event.returnValue = getWaveVersion() as AboutModalDetails; }); + electron.ipcMain.handle("show-open-folder-dialog", async () => { + const ww = focusedWaveWindow; + if (ww == null) { + return null; + } + const result = await electron.dialog.showOpenDialog(ww, { + title: "Select Workspace Directory", + properties: ["openDirectory", "createDirectory"], + }); + if (result.canceled || result.filePaths.length === 0) { + return null; + } + return result.filePaths[0]; + }); + electron.ipcMain.on("get-zoom-factor", (event) => { event.returnValue = event.sender.getZoomFactor(); }); diff --git a/emain/preload.ts b/emain/preload.ts index c6bdf14988..4f26006542 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -68,6 +68,7 @@ contextBridge.exposeInMainWorld("api", { openBuilder: (appId?: string) => ipcRenderer.send("open-builder", appId), setBuilderWindowAppId: (appId: string) => ipcRenderer.send("set-builder-window-appid", appId), doRefresh: () => ipcRenderer.send("do-refresh"), + showOpenFolderDialog: () => ipcRenderer.invoke("show-open-folder-dialog"), }); // Custom event for "new-window" diff --git a/frontend/app/store/services.ts b/frontend/app/store/services.ts index 7a36718c37..93df4193fd 100644 --- a/frontend/app/store/services.ts +++ b/frontend/app/store/services.ts @@ -182,7 +182,7 @@ class WorkspaceServiceType { } // @returns object updates - UpdateWorkspace(workspaceId: string, name: string, icon: string, color: string, applyDefaults: boolean): Promise { + UpdateWorkspace(workspaceId: string, name: string, icon: string, color: string, directory: string, applyDefaults: boolean): Promise { return WOS.callBackendService("workspace", "UpdateWorkspace", Array.from(arguments)) } } diff --git a/frontend/app/tab/workspaceeditor.scss b/frontend/app/tab/workspaceeditor.scss index d850d0a948..579b25422c 100644 --- a/frontend/app/tab/workspaceeditor.scss +++ b/frontend/app/tab/workspaceeditor.scss @@ -61,6 +61,33 @@ } } + .directory-selector { + margin-top: 15px; + padding-top: 15px; + border-top: 1px solid var(--modal-border-color); + + .directory-label { + display: block; + font-size: 12px; + color: var(--secondary-text-color); + margin-bottom: 5px; + } + + .directory-input-row { + display: flex; + gap: 8px; + align-items: center; + + .directory-input { + flex: 1; + } + + .browse-btn { + flex-shrink: 0; + } + } + } + .delete-ws-btn-wrapper { display: flex; align-items: center; diff --git a/frontend/app/tab/workspaceeditor.tsx b/frontend/app/tab/workspaceeditor.tsx index dee2ccb6e1..47cf6bb44b 100644 --- a/frontend/app/tab/workspaceeditor.tsx +++ b/frontend/app/tab/workspaceeditor.tsx @@ -1,3 +1,4 @@ +import { getApi } from "@/app/store/global"; import { fireAndForget, makeIconClass } from "@/util/util"; import clsx from "clsx"; import { memo, useEffect, useRef, useState } from "react"; @@ -13,11 +14,12 @@ interface ColorSelectorProps { className?: string; } -const ColorSelector = memo(({ colors, selectedColor, onSelect, className }: ColorSelectorProps) => { - const handleColorClick = (color: string) => { - onSelect(color); - }; - +const ColorSelector = memo(function ColorSelector({ + colors, + selectedColor, + onSelect, + className, +}: ColorSelectorProps) { return (
{colors.map((color) => ( @@ -25,7 +27,7 @@ const ColorSelector = memo(({ colors, selectedColor, onSelect, className }: Colo key={color} className={clsx("color-circle", { selected: selectedColor === color })} style={{ backgroundColor: color }} - onClick={() => handleColorClick(color)} + onClick={() => onSelect(color)} /> ))}
@@ -39,11 +41,12 @@ interface IconSelectorProps { className?: string; } -const IconSelector = memo(({ icons, selectedIcon, onSelect, className }: IconSelectorProps) => { - const handleIconClick = (icon: string) => { - onSelect(icon); - }; - +const IconSelector = memo(function IconSelector({ + icons, + selectedIcon, + onSelect, + className, +}: IconSelectorProps) { return (
{icons.map((icon) => { @@ -52,7 +55,7 @@ const IconSelector = memo(({ icons, selectedIcon, onSelect, className }: IconSel handleIconClick(icon)} + onClick={() => onSelect(icon)} /> ); })} @@ -64,22 +67,26 @@ interface WorkspaceEditorProps { title: string; icon: string; color: string; + directory: string; focusInput: boolean; onTitleChange: (newTitle: string) => void; onColorChange: (newColor: string) => void; onIconChange: (newIcon: string) => void; + onDirectoryChange: (newDirectory: string) => void; onDeleteWorkspace: () => void; } -const WorkspaceEditorComponent = ({ +export const WorkspaceEditor = memo(function WorkspaceEditor({ title, icon, color, + directory, focusInput, onTitleChange, onColorChange, onIconChange, + onDirectoryChange, onDeleteWorkspace, -}: WorkspaceEditorProps) => { +}: WorkspaceEditorProps) { const inputRef = useRef(null); const [colors, setColors] = useState([]); @@ -87,10 +94,10 @@ const WorkspaceEditorComponent = ({ useEffect(() => { fireAndForget(async () => { - const colors = await WorkspaceService.GetColors(); - const icons = await WorkspaceService.GetIcons(); - setColors(colors); - setIcons(icons); + const fetchedColors = await WorkspaceService.GetColors(); + const fetchedIcons = await WorkspaceService.GetIcons(); + setColors(fetchedColors); + setIcons(fetchedIcons); }); }, []); @@ -113,6 +120,32 @@ const WorkspaceEditorComponent = ({ /> +
+ +
+ + +
+
); -}; - -export const WorkspaceEditor = memo(WorkspaceEditorComponent) as typeof WorkspaceEditorComponent; +}); diff --git a/frontend/app/tab/workspaceswitcher.tsx b/frontend/app/tab/workspaceswitcher.tsx index f303f3253a..f2d476a423 100644 --- a/frontend/app/tab/workspaceswitcher.tsx +++ b/frontend/app/tab/workspaceswitcher.tsx @@ -10,15 +10,18 @@ import { ExpandableMenuItemRightElement, } from "@/element/expandablemenu"; import { Popover, PopoverButton, PopoverContent } from "@/element/popover"; -import { fireAndForget, makeIconClass, useAtomValueSafe } from "@/util/util"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { fireAndForget, isLocalConnName, makeIconClass, shellQuoteForShellType, stringToBase64, useAtomValueSafe } from "@/util/util"; import clsx from "clsx"; -import { atom, PrimitiveAtom, useAtom, useAtomValue, useSetAtom } from "jotai"; +import { Atom, atom, PrimitiveAtom, useAtom, useAtomValue, useSetAtom } from "jotai"; import { splitAtom } from "jotai/utils"; import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; -import { CSSProperties, forwardRef, useCallback, useEffect } from "react"; +import { CSSProperties, forwardRef, useCallback, useEffect, useMemo, useRef } from "react"; +import { debounce } from "throttle-debounce"; import WorkspaceSVG from "../asset/workspace.svg"; import { IconButton } from "../element/iconbutton"; -import { atoms, getApi } from "../store/global"; +import { atoms, getAllBlockComponentModels, getApi, globalStore, pushFlashError } from "../store/global"; import { WorkspaceService } from "../store/services"; import { getObjectValue, makeORef } from "../store/wos"; import { waveEventSubscribe } from "../store/wps"; @@ -84,7 +87,7 @@ const WorkspaceSwitcher = forwardRef((_, ref) => { const saveWorkspace = () => { fireAndForget(async () => { - await WorkspaceService.UpdateWorkspace(activeWorkspace.oid, "", "", "", true); + await WorkspaceService.UpdateWorkspace(activeWorkspace.oid, "", "", "", "", true); await updateWorkspaceList(); setEditingWorkspace(activeWorkspace.oid); }); @@ -138,6 +141,112 @@ const WorkspaceSwitcher = forwardRef((_, ref) => { ); }); +/** + * A ViewModel that has access to its block's ID and atom. + */ +interface BlockAwareViewModel extends ViewModel { + blockId: string; + blockAtom: Atom; +} + +/** + * A preview ViewModel with directory navigation capabilities. + */ +interface PreviewViewModel extends BlockAwareViewModel { + goHistory: (path: string) => Promise; +} + +/** + * Type guard that checks if a ViewModel has block awareness (blockId and blockAtom properties). + */ +function isBlockAwareViewModel(viewModel: ViewModel): viewModel is BlockAwareViewModel { + return "blockId" in viewModel && "blockAtom" in viewModel; +} + +/** + * Type guard that checks if a ViewModel is a preview view with navigation capabilities. + */ +function isPreviewViewModel(viewModel: ViewModel): viewModel is PreviewViewModel { + return viewModel.viewType === "preview" && isBlockAwareViewModel(viewModel) && "goHistory" in viewModel; +} + +/** + * Updates all local blocks to use a new workspace directory. + * For preview blocks, navigates to the new directory. + * For terminal blocks, sends a cd command to change to the new directory. + * Skips blocks that have a remote connection. + */ +async function updateBlocksWithNewDirectory(newDirectory: string): Promise { + const allModels = getAllBlockComponentModels(); + for (const model of allModels) { + if (model?.viewModel == null) { + continue; + } + const viewModel = model.viewModel; + if (!isBlockAwareViewModel(viewModel)) { + continue; + } + const blockData = globalStore.get(viewModel.blockAtom); + const connection = blockData?.meta?.connection; + if (connection && !isLocalConnName(connection)) { + continue; + } + if (isPreviewViewModel(viewModel)) { + try { + await viewModel.goHistory(newDirectory); + } catch (e) { + console.error("Failed to navigate preview block to new directory:", e); + pushFlashError({ + id: null, + icon: "triangle-exclamation", + title: "Directory Change Failed", + message: `Could not navigate preview to ${newDirectory}`, + expiration: null, + }); + } + } else if (viewModel.viewType === "term") { + try { + const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, { + oref: makeORef("block", viewModel.blockId), + }); + const shellType = rtInfo?.["shell:type"]; + const quotedDir = shellQuoteForShellType(newDirectory, shellType); + const cdPrefix = + shellType === "bash" || shellType === "zsh" || shellType === "sh" || shellType === "fish" + ? "cd -- " + : "cd "; + fireAndForget(async () => { + try { + await RpcApi.ControllerInputCommand(TabRpcClient, { + blockid: viewModel.blockId, + inputdata64: stringToBase64(`${cdPrefix}${quotedDir}\n`), + }); + } catch (e) { + console.error("Failed to send cd command to terminal:", e); + // Optional: align UX with preview block failures + pushFlashError({ + id: null, + icon: "triangle-exclamation", + title: "Directory Change Failed", + message: `Could not change terminal directory to ${newDirectory}`, + expiration: null, + }); + } + }); + } catch (e) { + console.error("Failed to get shell type for terminal block:", e); + pushFlashError({ + id: null, + icon: "triangle-exclamation", + title: "Directory Change Failed", + message: `Could not change terminal directory to ${newDirectory}`, + expiration: null, + }); + } + } + } +} + const WorkspaceSwitcherItem = ({ entryAtom, onDeleteWorkspace, @@ -152,20 +261,62 @@ const WorkspaceSwitcherItem = ({ const workspace = workspaceEntry.workspace; const isCurrentWorkspace = activeWorkspace.oid === workspace.oid; - const setWorkspace = useCallback((newWorkspace: Workspace) => { - setWorkspaceEntry({ ...workspaceEntry, workspace: newWorkspace }); - if (newWorkspace.name != "") { - fireAndForget(() => - WorkspaceService.UpdateWorkspace( - workspace.oid, - newWorkspace.name, - newWorkspace.icon, - newWorkspace.color, - false - ) - ); - } - }, []); + const pendingDirectoryRef = useRef(null); + + const debouncedBlockUpdate = useMemo( + () => + debounce(300, (newDirectory: string) => { + pendingDirectoryRef.current = null; + fireAndForget(async () => { + await updateBlocksWithNewDirectory(newDirectory); + }); + }), + [] + ); + + const debouncedWorkspaceUpdate = useMemo( + () => + debounce(300, (oid: string, name: string, icon: string, color: string, directory: string) => { + fireAndForget(async () => { + await WorkspaceService.UpdateWorkspace(oid, name, icon, color, directory, false); + }); + }), + [] + ); + + useEffect(() => { + return () => { + debouncedBlockUpdate.cancel(); + debouncedWorkspaceUpdate.cancel(); + pendingDirectoryRef.current = null; + }; + }, [debouncedBlockUpdate, debouncedWorkspaceUpdate]); + + const setWorkspace = useCallback( + (newWorkspace: Workspace) => { + setWorkspaceEntry((prev) => { + const oldDirectory = prev.workspace.directory; + const newDirectory = newWorkspace.directory; + const directoryChanged = newDirectory !== oldDirectory; + + if (newWorkspace.name !== "") { + debouncedWorkspaceUpdate( + prev.workspace.oid, + newWorkspace.name, + newWorkspace.icon, + newWorkspace.color, + newWorkspace.directory ?? "" + ); + if (directoryChanged && isCurrentWorkspace && newDirectory) { + pendingDirectoryRef.current = newDirectory; + debouncedBlockUpdate(newDirectory); + } + } + return { ...prev, workspace: newWorkspace }; + }); + }, + [debouncedBlockUpdate, debouncedWorkspaceUpdate, isCurrentWorkspace, setWorkspaceEntry] + ); const isActive = !!workspaceEntry.windowId; const editIconDecl: IconButtonDecl = { @@ -233,10 +384,12 @@ const WorkspaceSwitcherItem = ({ title={workspace.name} icon={workspace.icon} color={workspace.color} + directory={workspace.directory ?? ""} focusInput={isEditing} onTitleChange={(title) => setWorkspace({ ...workspace, name: title })} onColorChange={(color) => setWorkspace({ ...workspace, color })} onIconChange={(icon) => setWorkspace({ ...workspace, icon })} + onDirectoryChange={(directory) => setWorkspace({ ...workspace, directory })} onDeleteWorkspace={() => onDeleteWorkspace(workspace.oid)} /> diff --git a/frontend/app/view/preview/preview-model.tsx b/frontend/app/view/preview/preview-model.tsx index 3431a9663f..22ae53f65e 100644 --- a/frontend/app/view/preview/preview-model.tsx +++ b/frontend/app/view/preview/preview-model.tsx @@ -6,7 +6,7 @@ import type { TabModel } from "@/app/store/tab-model"; import { ContextMenuModel } from "@/app/store/contextmenu"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; -import { getConnStatusAtom, getOverrideConfigAtom, getSettingsKeyAtom, globalStore, refocusNode } from "@/store/global"; +import { atoms, getConnStatusAtom, getOverrideConfigAtom, getSettingsKeyAtom, globalStore, refocusNode } from "@/store/global"; import * as services from "@/store/services"; import * as WOS from "@/store/wos"; import { goHistory, goHistoryBack, goHistoryForward } from "@/util/historyutil"; @@ -375,10 +375,17 @@ export class PreviewModel implements ViewModel { }); this.metaFilePath = atom((get) => { const file = get(this.blockAtom)?.meta?.file; - if (isBlank(file)) { - return "~"; + if (!isBlank(file)) { + return file; + } + const connName = get(this.blockAtom)?.meta?.connection; + if (!connName) { + const workspace = get(atoms.workspace); + if (workspace?.directory) { + return workspace.directory; + } } - return file; + return "~"; }); this.statFilePath = atom>(async (get) => { const fileInfo = await get(this.statFile); diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index db2999a500..2df75416c1 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -134,6 +134,7 @@ declare global { openBuilder: (appId?: string) => void; // open-builder setBuilderWindowAppId: (appId: string) => void; // set-builder-window-appid doRefresh: () => void; // do-refresh + showOpenFolderDialog: () => Promise; // show-open-folder-dialog }; type ElectronContextMenuItem = { diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 4658bc1af2..ee5d2fbccc 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -1866,6 +1866,7 @@ declare global { name?: string; icon?: string; color?: string; + directory?: string; tabids: string[]; activetabid: string; }; diff --git a/frontend/util/util.test.ts b/frontend/util/util.test.ts new file mode 100644 index 0000000000..f625c0572a --- /dev/null +++ b/frontend/util/util.test.ts @@ -0,0 +1,234 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { assert, describe, test } from "vitest"; +import { cmdQuote, powershellQuote, shellQuote, shellQuoteForShellType } from "./util"; + +describe("shellQuote", () => { + test("empty string returns empty quoted string", () => { + assert.equal(shellQuote(""), "''"); + }); + + test("simple alphanumeric string is returned as-is", () => { + assert.equal(shellQuote("simple"), "simple"); + assert.equal(shellQuote("test123"), "test123"); + }); + + test("safe path characters are returned as-is", () => { + assert.equal(shellQuote("path/to/file.txt"), "path/to/file.txt"); + assert.equal(shellQuote("/usr/local/bin"), "/usr/local/bin"); + assert.equal(shellQuote("file-name_v2.0"), "file-name_v2.0"); + }); + + test("tilde paths are returned as-is", () => { + assert.equal(shellQuote("~"), "~"); + assert.equal(shellQuote("~/Documents"), "~/Documents"); + assert.equal(shellQuote("~/.config/wave"), "~/.config/wave"); + }); + + test("strings with spaces are single-quoted", () => { + assert.equal(shellQuote("path with spaces"), "'path with spaces'"); + assert.equal(shellQuote("my file.txt"), "'my file.txt'"); + }); + + test("strings with special shell characters are single-quoted", () => { + assert.equal(shellQuote("$HOME"), "'$HOME'"); + assert.equal(shellQuote("foo;bar"), "'foo;bar'"); + assert.equal(shellQuote("cmd && other"), "'cmd && other'"); + assert.equal(shellQuote("file|pipe"), "'file|pipe'"); + assert.equal(shellQuote("`command`"), "'`command`'"); + assert.equal(shellQuote("$(subshell)"), "'$(subshell)'"); + }); + + test("strings with embedded single quotes are properly escaped", () => { + assert.equal(shellQuote("it's"), "'it'\\''s'"); + assert.equal(shellQuote("don't stop"), "'don'\\''t stop'"); + assert.equal(shellQuote("'quoted'"), "''\\''quoted'\\'''"); + }); + + test("strings with double quotes are single-quoted", () => { + assert.equal(shellQuote('say "hello"'), "'say \"hello\"'"); + }); + + test("strings with backslashes are single-quoted", () => { + assert.equal(shellQuote("back\\slash"), "'back\\slash'"); + }); + + test("strings with newlines are single-quoted", () => { + assert.equal(shellQuote("line1\nline2"), "'line1\nline2'"); + }); + + test("strings with tabs are single-quoted", () => { + assert.equal(shellQuote("col1\tcol2"), "'col1\tcol2'"); + }); + + test("complex paths with special characters", () => { + assert.equal(shellQuote("/path/to/my file (1).txt"), "'/path/to/my file (1).txt'"); + assert.equal(shellQuote("~/My Documents/file.txt"), "'~/My Documents/file.txt'"); + }); + + test("prevents command injection", () => { + // These should all be safely quoted, preventing execution + assert.equal(shellQuote("; rm -rf /"), "'; rm -rf /'"); + assert.equal(shellQuote("$(whoami)"), "'$(whoami)'"); + assert.equal(shellQuote("`id`"), "'`id`'"); + assert.equal(shellQuote("foo\nbar"), "'foo\nbar'"); + assert.equal(shellQuote("a; echo pwned"), "'a; echo pwned'"); + }); +}); + +describe("powershellQuote", () => { + test("empty string returns empty quoted string", () => { + assert.equal(powershellQuote(""), "''"); + }); + + test("simple alphanumeric string is returned as-is", () => { + assert.equal(powershellQuote("simple"), "simple"); + assert.equal(powershellQuote("test123"), "test123"); + }); + + test("safe path characters including backslashes and colons are returned as-is", () => { + assert.equal(powershellQuote("C:\\Users\\test"), "C:\\Users\\test"); + assert.equal(powershellQuote("D:/path/to/file"), "D:/path/to/file"); + assert.equal(powershellQuote("file-name_v2.0"), "file-name_v2.0"); + }); + + test("tilde paths are returned as-is", () => { + assert.equal(powershellQuote("~"), "~"); + assert.equal(powershellQuote("~/Documents"), "~/Documents"); + }); + + test("strings with spaces are single-quoted", () => { + assert.equal(powershellQuote("path with spaces"), "'path with spaces'"); + assert.equal(powershellQuote("C:\\Program Files"), "'C:\\Program Files'"); + }); + + test("strings with special PowerShell characters are single-quoted", () => { + assert.equal(powershellQuote("$HOME"), "'$HOME'"); + assert.equal(powershellQuote("foo;bar"), "'foo;bar'"); + assert.equal(powershellQuote("cmd | other"), "'cmd | other'"); + }); + + test("strings with embedded single quotes are escaped by doubling", () => { + assert.equal(powershellQuote("it's"), "'it''s'"); + assert.equal(powershellQuote("don't stop"), "'don''t stop'"); + assert.equal(powershellQuote("'quoted'"), "'''quoted'''"); + }); + + test("strings with double quotes are single-quoted without escaping", () => { + assert.equal(powershellQuote('say "hello"'), "'say \"hello\"'"); + }); + + test("prevents command injection", () => { + assert.equal(powershellQuote("; Remove-Item -Recurse"), "'; Remove-Item -Recurse'"); + assert.equal(powershellQuote("$(whoami)"), "'$(whoami)'"); + assert.equal(powershellQuote("& cmd"), "'& cmd'"); + }); +}); + +describe("cmdQuote", () => { + test("empty string returns empty quoted string", () => { + assert.equal(cmdQuote(""), '""'); + }); + + test("simple alphanumeric string is returned as-is", () => { + assert.equal(cmdQuote("simple"), "simple"); + assert.equal(cmdQuote("test123"), "test123"); + }); + + test("safe path characters including backslashes and colons are returned as-is", () => { + assert.equal(cmdQuote("C:\\Users\\test"), "C:\\Users\\test"); + assert.equal(cmdQuote("D:/path/to/file"), "D:/path/to/file"); + assert.equal(cmdQuote("file-name_v2.0"), "file-name_v2.0"); + }); + + test("tilde paths are returned as-is", () => { + assert.equal(cmdQuote("~"), "~"); + assert.equal(cmdQuote("~/Documents"), "~/Documents"); + }); + + test("strings with spaces are double-quoted", () => { + assert.equal(cmdQuote("path with spaces"), '"path with spaces"'); + assert.equal(cmdQuote("C:\\Program Files"), '"C:\\Program Files"'); + }); + + test("strings with special cmd characters are double-quoted", () => { + assert.equal(cmdQuote("foo&bar"), '"foo&bar"'); + assert.equal(cmdQuote("cmd | other"), '"cmd | other"'); + assert.equal(cmdQuote("ac"), '"ac"'); + }); + + test("strings with embedded double quotes are escaped by doubling", () => { + assert.equal(cmdQuote('say "hello"'), '"say ""hello"""'); + assert.equal(cmdQuote('"quoted"'), '"""quoted"""'); + }); + + test("strings with single quotes are double-quoted without escaping", () => { + assert.equal(cmdQuote("it's"), "\"it's\""); + }); + + test("prevents command injection", () => { + assert.equal(cmdQuote("& del /s"), '"& del /s"'); + assert.equal(cmdQuote("foo | bar"), '"foo | bar"'); + assert.equal(cmdQuote("a && b"), '"a && b"'); + }); +}); + +describe("shellQuoteForShellType", () => { + test("uses POSIX quoting for bash", () => { + assert.equal(shellQuoteForShellType("it's", "bash"), "'it'\\''s'"); + }); + + test("uses POSIX quoting for zsh", () => { + assert.equal(shellQuoteForShellType("it's", "zsh"), "'it'\\''s'"); + }); + + test("uses POSIX quoting for sh", () => { + assert.equal(shellQuoteForShellType("it's", "sh"), "'it'\\''s'"); + }); + + test("uses POSIX quoting for fish", () => { + assert.equal(shellQuoteForShellType("it's", "fish"), "'it'\\''s'"); + }); + + test("uses PowerShell quoting for pwsh", () => { + assert.equal(shellQuoteForShellType("it's", "pwsh"), "'it''s'"); + }); + + test("uses PowerShell quoting for powershell", () => { + assert.equal(shellQuoteForShellType("it's", "powershell"), "'it''s'"); + }); + + test("uses PowerShell quoting case-insensitively", () => { + assert.equal(shellQuoteForShellType("it's", "PowerShell"), "'it''s'"); + assert.equal(shellQuoteForShellType("it's", "PWSH"), "'it''s'"); + }); + + test("uses cmd quoting for cmd", () => { + assert.equal(shellQuoteForShellType('say "hi"', "cmd"), '"say ""hi"""'); + }); + + test("uses cmd quoting case-insensitively", () => { + assert.equal(shellQuoteForShellType('say "hi"', "CMD"), '"say ""hi"""'); + }); + + test("uses POSIX quoting for null shell type", () => { + assert.equal(shellQuoteForShellType("it's", null), "'it'\\''s'"); + }); + + test("uses POSIX quoting for undefined shell type", () => { + assert.equal(shellQuoteForShellType("it's", undefined), "'it'\\''s'"); + }); + + test("uses POSIX quoting for unknown shell types", () => { + assert.equal(shellQuoteForShellType("it's", "unknown"), "'it'\\''s'"); + assert.equal(shellQuoteForShellType("it's", ""), "'it'\\''s'"); + }); + + test("handles Windows paths correctly per shell type", () => { + const windowsPath = "C:\\Program Files\\App"; + assert.equal(shellQuoteForShellType(windowsPath, "pwsh"), "'C:\\Program Files\\App'"); + assert.equal(shellQuoteForShellType(windowsPath, "cmd"), '"C:\\Program Files\\App"'); + assert.equal(shellQuoteForShellType(windowsPath, "bash"), "'C:\\Program Files\\App'"); + }); +}); diff --git a/frontend/util/util.ts b/frontend/util/util.ts index 2fdc793bee..491d0df4e8 100644 --- a/frontend/util/util.ts +++ b/frontend/util/util.ts @@ -463,6 +463,83 @@ function parseDataUrl(dataUrl: string): ParsedDataUrl { return { mimeType, buffer }; } +/** + * Quotes a string for safe use in POSIX shell commands. + * Uses single quotes and escapes embedded single quotes. + * @param s The string to quote + * @returns A shell-safe quoted string + */ +function shellQuote(s: string): string { + if (s === "") { + return "''"; + } + // If the string contains only safe characters, return as-is + if (/^[a-zA-Z0-9._\-/~]+$/.test(s)) { + return s; + } + // Single-quote the string and escape any embedded single quotes + // 'foo'bar' becomes 'foo'\''bar' + return "'" + s.replace(/'/g, "'\\''") + "'"; +} + +/** + * Quotes a string for safe use in PowerShell commands. + * Uses single quotes and escapes embedded single quotes by doubling them. + * @param s The string to quote + * @returns A PowerShell-safe quoted string + */ +function powershellQuote(s: string): string { + if (s === "") { + return "''"; + } + // If the string contains only safe characters, return as-is + if (/^[a-zA-Z0-9._\-/\\:~]+$/.test(s)) { + return s; + } + // Single-quote the string and escape embedded single quotes by doubling them + return "'" + s.replace(/'/g, "''") + "'"; +} + +/** + * Quotes a string for safe use in Windows cmd.exe commands. + * Uses double quotes and escapes special characters. + * @param s The string to quote + * @returns A cmd.exe-safe quoted string + */ +function cmdQuote(s: string): string { + if (s === "") { + return '""'; + } + // If the string contains only safe characters, return as-is + if (/^[a-zA-Z0-9._\-/\\:~]+$/.test(s)) { + return s; + } + // Double-quote the string and escape embedded double quotes and special chars + // In cmd, ^ is the escape character for special chars inside quotes + // Double quotes inside are escaped as "" + return '"' + s.replace(/"/g, '""') + '"'; +} + +const POWERSHELL_SHELLS = new Set(["pwsh", "powershell"]); +const CMD_SHELLS = new Set(["cmd"]); + +/** + * Quotes a string for safe use in shell commands based on the shell type. + * @param s The string to quote + * @param shellType The shell type (e.g., "bash", "zsh", "pwsh", "cmd") + * @returns A properly quoted string for the target shell + */ +function shellQuoteForShellType(s: string, shellType: string | null | undefined): string { + const normalizedShell = shellType?.toLowerCase() ?? ""; + if (POWERSHELL_SHELLS.has(normalizedShell)) { + return powershellQuote(s); + } + if (CMD_SHELLS.has(normalizedShell)) { + return cmdQuote(s); + } + return shellQuote(s); +} + function formatRelativeTime(timestamp: number): string { if (!timestamp) { return "never"; @@ -525,6 +602,10 @@ export { makeIconClass, mergeMeta, parseDataUrl, + powershellQuote, + cmdQuote, + shellQuote, + shellQuoteForShellType, sleep, sortByDisplayOrder, stringToBase64, diff --git a/package-lock.json b/package-lock.json index bc0bbdb1dc..cfda16a848 100644 --- a/package-lock.json +++ b/package-lock.json @@ -189,6 +189,7 @@ "integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -209,6 +210,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -468,6 +470,7 @@ "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.37.0.tgz", "integrity": "sha512-DAFVUvEg+u7jUs6BZiVz9zdaUebYULPiQ4LM2R4n8Nujzyj7BZzGr2DCd85ip4p/cx7nAZWKM8pLcGtkTRTdsg==", "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.37.0", "@algolia/requester-browser-xhr": "5.37.0", @@ -621,6 +624,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2479,6 +2483,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -2501,6 +2506,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2610,6 +2616,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -3031,6 +3038,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -4082,6 +4090,7 @@ "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.9.2.tgz", "integrity": "sha512-C5wZsGuKTY8jEYsqdxhhFOe1ZDjH0uIYJ9T/jebHwkyxqnr4wW0jTkB72OMqNjsoQRcb0JN3PcSeTwFlVgzCZg==", "license": "MIT", + "peer": true, "dependencies": { "@docusaurus/core": "3.9.2", "@docusaurus/logger": "3.9.2", @@ -5022,7 +5031,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -5037,18 +5045,6 @@ "node": ">=14.14" } }, - "node_modules/@emnapi/runtime": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", - "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", @@ -5727,468 +5723,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.3.tgz", - "integrity": "sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.0" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.3.tgz", - "integrity": "sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.0" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.0.tgz", - "integrity": "sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.0.tgz", - "integrity": "sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.0.tgz", - "integrity": "sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.0.tgz", - "integrity": "sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.0.tgz", - "integrity": "sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.0.tgz", - "integrity": "sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.0.tgz", - "integrity": "sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.0.tgz", - "integrity": "sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.0.tgz", - "integrity": "sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.3.tgz", - "integrity": "sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.0" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.3.tgz", - "integrity": "sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.0" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.3.tgz", - "integrity": "sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.0" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.3.tgz", - "integrity": "sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.0" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.3.tgz", - "integrity": "sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.0" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.3.tgz", - "integrity": "sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.0" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.3.tgz", - "integrity": "sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.0" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.3.tgz", - "integrity": "sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/runtime": "^1.4.4" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.3.tgz", - "integrity": "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.3.tgz", - "integrity": "sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.3.tgz", - "integrity": "sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/@isaacs/balanced-match": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", @@ -6568,6 +6102,7 @@ "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", "license": "MIT", + "peer": true, "dependencies": { "@types/mdx": "^2.0.0" }, @@ -8345,6 +7880,7 @@ "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -8739,7 +8275,8 @@ "version": "0.0.7", "resolved": "https://registry.npmjs.org/@table-nav/core/-/core-0.0.7.tgz", "integrity": "sha512-pCh18jHDRe3tw9sJZXfKi4cSD6VjHbn40CYdqhp5X91SIX7rakDEQAsTx6F7Fv9TUv265l+5rUDcYNaJ0N0cqQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@table-nav/react": { "version": "0.0.7", @@ -9774,6 +9311,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.2.tgz", "integrity": "sha512-lif9hF9afNk39jMUVYk5eyYEojLZQqaYX61LfuwUJJ1+qiQbh7jVaZXskYgzyjAIFDFQRf5Sd6MVM7EyXkfiRw==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -9849,6 +9387,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz", "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -10144,6 +9683,7 @@ "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", @@ -10818,7 +10358,8 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", @@ -10873,6 +10414,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -10981,6 +10523,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -11045,6 +10588,7 @@ "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.37.0.tgz", "integrity": "sha512-y7gau/ZOQDqoInTQp0IwTOjkrHc4Aq4R8JgpmCleFwiLl+PbN2DMWoDUWZnrK8AhNJwT++dn28Bt4NZYNLAmuA==", "license": "MIT", + "peer": true, "dependencies": { "@algolia/abtesting": "1.3.0", "@algolia/client-abtesting": "5.37.0", @@ -11875,6 +11419,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -12458,6 +12003,7 @@ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -13340,8 +12886,7 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -13509,6 +13054,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -13839,6 +13385,7 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -14248,6 +13795,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -14693,6 +14241,7 @@ "integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.0.12", "builder-util": "26.0.11", @@ -15168,7 +14717,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -15189,7 +14737,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -15205,7 +14752,6 @@ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, "license": "MIT", - "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -15216,7 +14762,6 @@ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 4.0.0" } @@ -15530,6 +15075,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -20454,6 +20000,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", @@ -23553,7 +23100,8 @@ "version": "2.12.0", "resolved": "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-2.12.0.tgz", "integrity": "sha512-mWJ5MOkcZ/ljHwfLw8+bN0V9ziGCoNoqULcp994j5DTGNQvnkWKWkA7rnO29Kyew5AoHxUnJ4Ndqfcl0HSQjXg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/overlayscrollbars-react": { "version": "0.5.6", @@ -24333,6 +23881,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -25236,6 +24785,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -25835,7 +25385,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -25853,7 +25402,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -25954,6 +25502,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -26105,6 +25654,7 @@ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -26335,6 +25885,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -26383,6 +25934,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -26472,6 +26024,7 @@ "resolved": "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-6.0.0.tgz", "integrity": "sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/react": "*" }, @@ -26537,6 +26090,7 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -28361,6 +27915,7 @@ "integrity": "sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -28539,6 +28094,7 @@ "resolved": "https://registry.npmjs.org/sass/-/sass-1.91.0.tgz", "integrity": "sha512-aFOZHGf+ur+bp1bCHZ+u8otKGh77ZtmFyXDo4tlYvT7PWql41Kwd8wdkPqhhT+h2879IVblcHFglIMofsFd1EA==", "license": "MIT", + "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -28657,6 +28213,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -29048,61 +28605,6 @@ "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", "license": "MIT" }, - "node_modules/sharp": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.3.tgz", - "integrity": "sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==", - "hasInstallScript": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.4", - "semver": "^7.7.2" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.3", - "@img/sharp-darwin-x64": "0.34.3", - "@img/sharp-libvips-darwin-arm64": "1.2.0", - "@img/sharp-libvips-darwin-x64": "1.2.0", - "@img/sharp-libvips-linux-arm": "1.2.0", - "@img/sharp-libvips-linux-arm64": "1.2.0", - "@img/sharp-libvips-linux-ppc64": "1.2.0", - "@img/sharp-libvips-linux-s390x": "1.2.0", - "@img/sharp-libvips-linux-x64": "1.2.0", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.0", - "@img/sharp-libvips-linuxmusl-x64": "1.2.0", - "@img/sharp-linux-arm": "0.34.3", - "@img/sharp-linux-arm64": "0.34.3", - "@img/sharp-linux-ppc64": "0.34.3", - "@img/sharp-linux-s390x": "0.34.3", - "@img/sharp-linux-x64": "0.34.3", - "@img/sharp-linuxmusl-arm64": "0.34.3", - "@img/sharp-linuxmusl-x64": "0.34.3", - "@img/sharp-wasm32": "0.34.3", - "@img/sharp-win32-arm64": "0.34.3", - "@img/sharp-win32-ia32": "0.34.3", - "@img/sharp-win32-x64": "0.34.3" - } - }, - "node_modules/sharp/node_modules/detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -30010,7 +29512,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", @@ -30038,7 +29539,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=16" } @@ -30093,7 +29593,8 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -30149,7 +29650,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -30191,7 +29691,6 @@ "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -30213,7 +29712,6 @@ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -30227,7 +29725,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -30242,7 +29739,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -30740,7 +30236,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsunami-frontend": { "resolved": "tsunami/frontend", @@ -31321,6 +30818,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -31419,6 +30917,7 @@ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "license": "MIT", + "peer": true, "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", @@ -32336,6 +31835,7 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -32496,6 +31996,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -32738,6 +32239,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.3.tgz", "integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==", "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -33763,6 +33265,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -33812,7 +33315,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "tsunami/frontend/node_modules/redux-thunk": { "version": "3.1.0", diff --git a/pkg/blockcontroller/shellcontroller.go b/pkg/blockcontroller/shellcontroller.go index 040a245745..f406a6b2ac 100644 --- a/pkg/blockcontroller/shellcontroller.go +++ b/pkg/blockcontroller/shellcontroller.go @@ -386,10 +386,16 @@ func (bc *ShellController) setupAndStartShellProcess(logCtx context.Context, rc blocklogger.Infof(logCtx, "[conndebug] remoteName: %q, connType: %s, wshEnabled: %v, shell: %q, shellType: %s\n", remoteName, connUnion.ConnType, connUnion.WshEnabled, connUnion.ShellPath, connUnion.ShellType) var cmdStr string var cmdOpts shellexec.CommandOptsType + wsCtx, wsCancel := context.WithTimeout(context.Background(), 2*time.Second) + wsId, wsDir := getWorkspaceInfo(wsCtx, bc.TabId) + wsCancel() if bc.ControllerType == BlockController_Shell { cmdOpts.Interactive = true cmdOpts.Login = true cmdOpts.Cwd = blockMeta.GetString(waveobj.MetaKey_CmdCwd, "") + if cmdOpts.Cwd == "" && connUnion.ConnType == ConnType_Local { + cmdOpts.Cwd = wsDir + } if cmdOpts.Cwd != "" { cwdPath, err := wavebase.ExpandHomeDir(cmdOpts.Cwd) if err != nil { @@ -408,7 +414,12 @@ func (bc *ShellController) setupAndStartShellProcess(logCtx context.Context, rc return nil, fmt.Errorf("unknown controller type %q", bc.ControllerType) } var shellProc *shellexec.ShellProc - swapToken := bc.makeSwapToken(ctx, logCtx, blockMeta, remoteName, connUnion.ShellType) + // Only pass wsDir for local connections to avoid leaking local paths to remote shells + wsDirForToken := "" + if connUnion.ConnType == ConnType_Local { + wsDirForToken = wsDir + } + swapToken := bc.makeSwapToken(ctx, logCtx, blockMeta, remoteName, connUnion.ShellType, wsId, wsDirForToken) cmdOpts.SwapToken = swapToken blocklogger.Debugf(logCtx, "[conndebug] created swaptoken: %s\n", swapToken.Token) if connUnion.ConnType == ConnType_Wsl { @@ -715,7 +726,31 @@ func createCmdStrAndOpts(blockId string, blockMeta waveobj.MetaMapType, connName return cmdStr, &cmdOpts, nil } -func (bc *ShellController) makeSwapToken(ctx context.Context, logCtx context.Context, blockMeta waveobj.MetaMapType, remoteName string, shellType string) *shellutil.TokenSwapEntry { +// getWorkspaceInfo retrieves the workspace ID and directory for a given tab. +// Returns empty strings if the tab ID is empty or if the workspace cannot be found. +func getWorkspaceInfo(ctx context.Context, tabId string) (wsId string, wsDir string) { + if tabId == "" { + return "", "" + } + wsId, err := wstore.DBFindWorkspaceForTabId(ctx, tabId) + if err != nil { + log.Printf("error finding workspace for tab %s: %v\n", tabId, err) + return "", "" + } + ws, err := wstore.DBGet[*waveobj.Workspace](ctx, wsId) + if err != nil { + log.Printf("error getting workspace %s: %v\n", wsId, err) + return wsId, "" + } + if ws == nil { + return wsId, "" + } + return wsId, ws.Directory +} + +// makeSwapToken creates a token swap entry with environment variables for shell initialization. +// The token is used to securely pass environment configuration to new shell processes. +func (bc *ShellController) makeSwapToken(ctx context.Context, logCtx context.Context, blockMeta waveobj.MetaMapType, remoteName string, shellType string, wsId string, wsDir string) *shellutil.TokenSwapEntry { token := &shellutil.TokenSwapEntry{ Token: uuid.New().String(), Env: make(map[string]string), @@ -731,13 +766,11 @@ func (bc *ShellController) makeSwapToken(ctx context.Context, logCtx context.Con } else { token.Env["WAVETERM_TABID"] = tabId } - if tabId != "" { - wsId, err := wstore.DBFindWorkspaceForTabId(ctx, tabId) - if err != nil { - log.Printf("error finding workspace for tab: %v\n", err) - } else { - token.Env["WAVETERM_WORKSPACEID"] = wsId - } + if wsId != "" { + token.Env["WAVETERM_WORKSPACEID"] = wsId + } + if wsDir != "" { + token.Env["WAVETERM_WORKSPACE_DIR"] = wsDir } clientData, err := wstore.DBGetSingleton[*waveobj.Client](ctx) if err != nil { diff --git a/pkg/service/workspaceservice/workspaceservice.go b/pkg/service/workspaceservice/workspaceservice.go index 152aa09eb6..e827f2b79d 100644 --- a/pkg/service/workspaceservice/workspaceservice.go +++ b/pkg/service/workspaceservice/workspaceservice.go @@ -29,6 +29,7 @@ func (svc *WorkspaceService) CreateWorkspace_Meta() tsgenmeta.MethodMeta { } } +// CreateWorkspace creates a new workspace and returns its ID. func (svc *WorkspaceService) CreateWorkspace(ctx context.Context, name string, icon string, color string, applyDefaults bool) (string, error) { newWS, err := wcore.CreateWorkspace(ctx, name, icon, color, applyDefaults, false) if err != nil { @@ -39,13 +40,15 @@ func (svc *WorkspaceService) CreateWorkspace(ctx context.Context, name string, i func (svc *WorkspaceService) UpdateWorkspace_Meta() tsgenmeta.MethodMeta { return tsgenmeta.MethodMeta{ - ArgNames: []string{"ctx", "workspaceId", "name", "icon", "color", "applyDefaults"}, + ArgNames: []string{"ctx", "workspaceId", "name", "icon", "color", "directory", "applyDefaults"}, } } -func (svc *WorkspaceService) UpdateWorkspace(ctx context.Context, workspaceId string, name string, icon string, color string, applyDefaults bool) (waveobj.UpdatesRtnType, error) { +// UpdateWorkspace updates a workspace's properties and publishes a workspace update event. +// Returns the updates or an error if the workspace could not be updated. +func (svc *WorkspaceService) UpdateWorkspace(ctx context.Context, workspaceId string, name string, icon string, color string, directory string, applyDefaults bool) (waveobj.UpdatesRtnType, error) { ctx = waveobj.ContextWithUpdates(ctx) - _, updated, err := wcore.UpdateWorkspace(ctx, workspaceId, name, icon, color, applyDefaults) + _, updated, err := wcore.UpdateWorkspace(ctx, workspaceId, name, icon, color, directory, applyDefaults) if err != nil { return nil, fmt.Errorf("error updating workspace: %w", err) } @@ -74,6 +77,7 @@ func (svc *WorkspaceService) GetWorkspace_Meta() tsgenmeta.MethodMeta { } } +// GetWorkspace retrieves a workspace by its ID. func (svc *WorkspaceService) GetWorkspace(workspaceId string) (*waveobj.Workspace, error) { ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() @@ -90,6 +94,7 @@ func (svc *WorkspaceService) DeleteWorkspace_Meta() tsgenmeta.MethodMeta { } } +// DeleteWorkspace deletes a workspace and returns any claimable workspace ID. func (svc *WorkspaceService) DeleteWorkspace(workspaceId string) (waveobj.UpdatesRtnType, string, error) { ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() @@ -114,6 +119,7 @@ func (svc *WorkspaceService) DeleteWorkspace(workspaceId string) (waveobj.Update return updates, claimableWorkspace, nil } +// ListWorkspaces returns a list of all workspaces. func (svc *WorkspaceService) ListWorkspaces() (waveobj.WorkspaceList, error) { ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() @@ -133,6 +139,7 @@ func (svc *WorkspaceService) GetColors_Meta() tsgenmeta.MethodMeta { } } +// GetColors returns the available workspace colors. func (svc *WorkspaceService) GetColors() []string { return wcore.WorkspaceColors[:] } @@ -143,10 +150,12 @@ func (svc *WorkspaceService) GetIcons_Meta() tsgenmeta.MethodMeta { } } +// GetIcons returns the available workspace icons. func (svc *WorkspaceService) GetIcons() []string { return wcore.WorkspaceIcons[:] } +// CreateTab creates a new tab in the specified workspace. func (svc *WorkspaceService) CreateTab(workspaceId string, tabName string, activateTab bool) (string, waveobj.UpdatesRtnType, error) { ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout) defer cancelFn() diff --git a/pkg/waveobj/wtype.go b/pkg/waveobj/wtype.go index 2f7e7e0a1f..3530ad953e 100644 --- a/pkg/waveobj/wtype.go +++ b/pkg/waveobj/wtype.go @@ -174,6 +174,7 @@ type Workspace struct { Name string `json:"name,omitempty"` Icon string `json:"icon,omitempty"` Color string `json:"color,omitempty"` + Directory string `json:"directory,omitempty"` TabIds []string `json:"tabids"` ActiveTabId string `json:"activetabid"` Meta MetaMapType `json:"meta"` diff --git a/pkg/wconfig/defaultconfig/widgets.json b/pkg/wconfig/defaultconfig/widgets.json index 97a3d26c10..230253cb94 100644 --- a/pkg/wconfig/defaultconfig/widgets.json +++ b/pkg/wconfig/defaultconfig/widgets.json @@ -16,8 +16,7 @@ "label": "files", "blockdef": { "meta": { - "view": "preview", - "file": "~" + "view": "preview" } } }, diff --git a/pkg/wcore/workspace.go b/pkg/wcore/workspace.go index f61306d897..4522ef3a51 100644 --- a/pkg/wcore/workspace.go +++ b/pkg/wcore/workspace.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "log" + "strings" "time" "github.com/google/uuid" @@ -21,6 +22,18 @@ import ( "github.com/wavetermdev/waveterm/pkg/wstore" ) +// ValidateWorkspaceDirectory validates a workspace directory path. +// Returns an error if the directory path is invalid. +func ValidateWorkspaceDirectory(directory string) error { + if directory == "" { + return nil + } + if strings.ContainsRune(directory, 0) { + return fmt.Errorf("invalid directory path: contains null byte") + } + return nil +} + var WorkspaceColors = [...]string{ "#58C142", // Green (accent) "#00FFDB", // Teal @@ -48,6 +61,8 @@ var WorkspaceIcons = [...]string{ "mug-hot", } +// CreateWorkspace creates a new workspace with the specified name, icon, and color. +// If applyDefaults is true, default values will be applied for empty fields. func CreateWorkspace(ctx context.Context, name string, icon string, color string, applyDefaults bool, isInitialLaunch bool) (*waveobj.Workspace, error) { ws := &waveobj.Workspace{ OID: uuid.NewString(), @@ -69,12 +84,13 @@ func CreateWorkspace(ctx context.Context, name string, icon string, color string Event: wps.Event_WorkspaceUpdate, }) - ws, _, err = UpdateWorkspace(ctx, ws.OID, name, icon, color, applyDefaults) + ws, _, err = UpdateWorkspace(ctx, ws.OID, name, icon, color, "", applyDefaults) return ws, err } -// Returns updated workspace, whether it was updated, error. -func UpdateWorkspace(ctx context.Context, workspaceId string, name string, icon string, color string, applyDefaults bool) (*waveobj.Workspace, bool, error) { +// UpdateWorkspace updates workspace properties such as name, icon, color, and directory. +// Returns the updated workspace, a boolean indicating if changes were made, and any error. +func UpdateWorkspace(ctx context.Context, workspaceId string, name string, icon string, color string, directory string, applyDefaults bool) (*waveobj.Workspace, bool, error) { ws, err := GetWorkspace(ctx, workspaceId) updated := false if err != nil { @@ -106,6 +122,13 @@ func UpdateWorkspace(ctx context.Context, workspaceId string, name string, icon ws.Color = WorkspaceColors[len(wsList)%len(WorkspaceColors)] updated = true } + if directory != ws.Directory { + if err := ValidateWorkspaceDirectory(directory); err != nil { + return nil, false, err + } + ws.Directory = directory + updated = true + } if updated { wstore.DBUpdate(ctx, ws) } @@ -181,6 +204,7 @@ func DeleteWorkspace(ctx context.Context, workspaceId string, force bool) (bool, return true, "", nil } +// GetWorkspace retrieves a workspace by its ID from the database. func GetWorkspace(ctx context.Context, wsID string) (*waveobj.Workspace, error) { return wstore.DBMustGet[*waveobj.Workspace](ctx, wsID) } @@ -320,6 +344,7 @@ func DeleteTab(ctx context.Context, workspaceId string, tabId string, recursive return newActiveTabId, nil } +// SetActiveTab sets the active tab for a workspace. func SetActiveTab(ctx context.Context, workspaceId string, tabId string) error { if tabId != "" && workspaceId != "" { workspace, err := GetWorkspace(ctx, workspaceId) @@ -336,6 +361,7 @@ func SetActiveTab(ctx context.Context, workspaceId string, tabId string) error { return nil } +// SendActiveTabUpdate sends an event to Electron to update the active tab. func SendActiveTabUpdate(ctx context.Context, workspaceId string, newActiveTabId string) { eventbus.SendEventToElectron(eventbus.WSEventType{ EventType: eventbus.WSEvent_ElectronUpdateActiveTab, @@ -343,6 +369,7 @@ func SendActiveTabUpdate(ctx context.Context, workspaceId string, newActiveTabId }) } +// UpdateWorkspaceTabIds updates the list of tab IDs for a workspace. func UpdateWorkspaceTabIds(ctx context.Context, workspaceId string, tabIds []string) error { ws, _ := wstore.DBGet[*waveobj.Workspace](ctx, workspaceId) if ws == nil { @@ -353,6 +380,7 @@ func UpdateWorkspaceTabIds(ctx context.Context, workspaceId string, tabIds []str return nil } +// ListWorkspaces returns a list of all workspaces with their associated window IDs. func ListWorkspaces(ctx context.Context) (waveobj.WorkspaceList, error) { workspaces, err := wstore.DBGetAllObjsByType[*waveobj.Workspace](ctx, waveobj.OType_Workspace) if err != nil { @@ -384,6 +412,7 @@ func ListWorkspaces(ctx context.Context) (waveobj.WorkspaceList, error) { return wl, nil } +// SetIcon sets the icon for a workspace. func SetIcon(workspaceId string, icon string) error { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -399,6 +428,7 @@ func SetIcon(workspaceId string, icon string) error { return nil } +// SetColor sets the color for a workspace. func SetColor(workspaceId string, color string) error { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -414,6 +444,7 @@ func SetColor(workspaceId string, color string) error { return nil } +// SetName sets the name for a workspace. func SetName(workspaceId string, name string) error { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() diff --git a/pkg/wcore/workspace_test.go b/pkg/wcore/workspace_test.go new file mode 100644 index 0000000000..d50f2c20ce --- /dev/null +++ b/pkg/wcore/workspace_test.go @@ -0,0 +1,103 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package wcore + +import ( + "testing" +) + +func TestValidateWorkspaceDirectory(t *testing.T) { + tests := []struct { + name string + directory string + expectError bool + errorMsg string + }{ + { + name: "empty directory is valid", + directory: "", + expectError: false, + }, + { + name: "valid absolute path", + directory: "/home/user/projects", + expectError: false, + }, + { + name: "valid home directory path", + directory: "~/projects", + expectError: false, + }, + { + name: "valid path with spaces", + directory: "/home/user/my projects", + expectError: false, + }, + { + name: "valid path with special characters", + directory: "/home/user/project-v1.0_final", + expectError: false, + }, + { + name: "valid Windows-style path", + directory: "C:\\Users\\user\\projects", + expectError: false, + }, + { + name: "directory with null byte at end is rejected", + directory: "/home/user\x00", + expectError: true, + errorMsg: "invalid directory path: contains null byte", + }, + { + name: "directory with null byte in middle is rejected", + directory: "/home/user\x00/malicious", + expectError: true, + errorMsg: "invalid directory path: contains null byte", + }, + { + name: "directory with embedded null byte", + directory: "valid\x00path", + expectError: true, + errorMsg: "invalid directory path: contains null byte", + }, + { + name: "directory with multiple null bytes", + directory: "\x00\x00\x00", + expectError: true, + errorMsg: "invalid directory path: contains null byte", + }, + { + name: "valid unicode path", + directory: "/home/用户/项目", + expectError: false, + }, + { + name: "valid path with emoji", + directory: "/home/user/📁projects", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateWorkspaceDirectory(tt.directory) + + if tt.expectError { + if err == nil { + t.Errorf("expected error but got none") + return + } + if err.Error() != tt.errorMsg { + t.Errorf("expected error %q, got %q", tt.errorMsg, err.Error()) + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +}