mirror of
https://github.com/wavetermdev/waveterm.git
synced 2024-12-22 16:48:23 +01:00
c2312f1c7f
I made our logo a custom icon in Font Awesome so we can add it to our workspace switcher as the default icon
375 lines
13 KiB
TypeScript
375 lines
13 KiB
TypeScript
// Copyright 2024, Command Line
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
import { Button } from "@/element/button";
|
|
import {
|
|
ExpandableMenu,
|
|
ExpandableMenuItem,
|
|
ExpandableMenuItemGroup,
|
|
ExpandableMenuItemGroupTitle,
|
|
ExpandableMenuItemLeftElement,
|
|
ExpandableMenuItemRightElement,
|
|
} from "@/element/expandablemenu";
|
|
import { Input } from "@/element/input";
|
|
import { Popover, PopoverButton, PopoverContent } from "@/element/popover";
|
|
import { fireAndForget, makeIconClass, useAtomValueSafe } from "@/util/util";
|
|
import clsx from "clsx";
|
|
import { atom, PrimitiveAtom, useAtom, useAtomValue, useSetAtom } from "jotai";
|
|
import { splitAtom } from "jotai/utils";
|
|
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
|
|
import { CSSProperties, memo, useCallback, useEffect, useRef } from "react";
|
|
import WorkspaceSVG from "../asset/workspace.svg";
|
|
import { IconButton } from "../element/iconbutton";
|
|
import { atoms, getApi } from "../store/global";
|
|
import { WorkspaceService } from "../store/services";
|
|
import { getObjectValue, makeORef, setObjectValue } from "../store/wos";
|
|
import "./workspaceswitcher.scss";
|
|
|
|
interface ColorSelectorProps {
|
|
colors: string[];
|
|
selectedColor?: string;
|
|
onSelect: (color: string) => void;
|
|
className?: string;
|
|
}
|
|
|
|
const colors = [
|
|
"#58C142", // Green (accent)
|
|
"#00FFDB", // Teal
|
|
"#429DFF", // Blue
|
|
"#BF55EC", // Purple
|
|
"#FF453A", // Red
|
|
"#FF9500", // Orange
|
|
"#FFE900", // Yellow
|
|
];
|
|
|
|
const icons = [
|
|
"custom@wave-logo-solid",
|
|
"triangle",
|
|
"star",
|
|
"heart",
|
|
"bolt",
|
|
"solid@cloud",
|
|
"moon",
|
|
"layer-group",
|
|
"rocket",
|
|
"flask",
|
|
"paperclip",
|
|
"chart-line",
|
|
"graduation-cap",
|
|
"mug-hot",
|
|
];
|
|
|
|
const ColorSelector = memo(({ colors, selectedColor, onSelect, className }: ColorSelectorProps) => {
|
|
const handleColorClick = (color: string) => {
|
|
onSelect(color);
|
|
};
|
|
|
|
return (
|
|
<div className={clsx("color-selector", className)}>
|
|
{colors.map((color) => (
|
|
<div
|
|
key={color}
|
|
className={clsx("color-circle", { selected: selectedColor === color })}
|
|
style={{ backgroundColor: color }}
|
|
onClick={() => handleColorClick(color)}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
interface IconSelectorProps {
|
|
icons: string[];
|
|
selectedIcon?: string;
|
|
onSelect: (icon: string) => void;
|
|
className?: string;
|
|
}
|
|
|
|
const IconSelector = memo(({ icons, selectedIcon, onSelect, className }: IconSelectorProps) => {
|
|
const handleIconClick = (icon: string) => {
|
|
onSelect(icon);
|
|
};
|
|
|
|
return (
|
|
<div className={clsx("icon-selector", className)}>
|
|
{icons.map((icon) => {
|
|
const iconClass = makeIconClass(icon, true);
|
|
return (
|
|
<i
|
|
key={icon}
|
|
className={clsx(iconClass, "icon-item", { selected: selectedIcon === icon })}
|
|
onClick={() => handleIconClick(icon)}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
interface WorkspaceEditorProps {
|
|
title: string;
|
|
icon: string;
|
|
color: string;
|
|
focusInput: boolean;
|
|
onTitleChange: (newTitle: string) => void;
|
|
onColorChange: (newColor: string) => void;
|
|
onIconChange: (newIcon: string) => void;
|
|
onDeleteWorkspace: () => void;
|
|
}
|
|
const WorkspaceEditor = memo(
|
|
({
|
|
title,
|
|
icon,
|
|
color,
|
|
focusInput,
|
|
onTitleChange,
|
|
onColorChange,
|
|
onIconChange,
|
|
onDeleteWorkspace,
|
|
}: WorkspaceEditorProps) => {
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (focusInput && inputRef.current) {
|
|
inputRef.current.focus();
|
|
inputRef.current.select();
|
|
}
|
|
}, [focusInput]);
|
|
|
|
return (
|
|
<div className="workspace-editor">
|
|
<Input
|
|
ref={inputRef}
|
|
className={clsx("vertical-padding-3", { error: title === "" })}
|
|
onChange={onTitleChange}
|
|
value={title}
|
|
autoFocus
|
|
autoSelect
|
|
/>
|
|
<ColorSelector selectedColor={color} colors={colors} onSelect={onColorChange} />
|
|
<IconSelector selectedIcon={icon} icons={icons} onSelect={onIconChange} />
|
|
<div className="delete-ws-btn-wrapper">
|
|
<Button className="ghost red font-size-12 bold" onClick={onDeleteWorkspace}>
|
|
Delete workspace
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
|
|
type WorkspaceListEntry = {
|
|
windowId: string;
|
|
workspace: Workspace;
|
|
};
|
|
|
|
type WorkspaceList = WorkspaceListEntry[];
|
|
const workspaceMapAtom = atom<WorkspaceList>([]);
|
|
const workspaceSplitAtom = splitAtom(workspaceMapAtom);
|
|
const editingWorkspaceAtom = atom<string>();
|
|
const WorkspaceSwitcher = () => {
|
|
const setWorkspaceList = useSetAtom(workspaceMapAtom);
|
|
const activeWorkspace = useAtomValueSafe(atoms.workspace);
|
|
const workspaceList = useAtomValue(workspaceSplitAtom);
|
|
const setEditingWorkspace = useSetAtom(editingWorkspaceAtom);
|
|
|
|
const updateWorkspaceList = useCallback(async () => {
|
|
const workspaceList = await WorkspaceService.ListWorkspaces();
|
|
if (!workspaceList) {
|
|
return;
|
|
}
|
|
const newList: WorkspaceList = [];
|
|
for (const entry of workspaceList) {
|
|
// This just ensures that the atom exists for easier setting of the object
|
|
getObjectValue(makeORef("workspace", entry.workspaceid));
|
|
newList.push({
|
|
windowId: entry.windowid,
|
|
workspace: await WorkspaceService.GetWorkspace(entry.workspaceid),
|
|
});
|
|
}
|
|
setWorkspaceList(newList);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fireAndForget(updateWorkspaceList);
|
|
}, []);
|
|
|
|
const onDeleteWorkspace = useCallback((workspaceId: string) => {
|
|
getApi().deleteWorkspace(workspaceId);
|
|
setTimeout(() => {
|
|
fireAndForget(updateWorkspaceList);
|
|
}, 10);
|
|
}, []);
|
|
|
|
const isActiveWorkspaceSaved = !!(activeWorkspace.name && activeWorkspace.icon);
|
|
|
|
const workspaceIcon = isActiveWorkspaceSaved ? (
|
|
<i className={makeIconClass(activeWorkspace.icon, false)} style={{ color: activeWorkspace.color }}></i>
|
|
) : (
|
|
<WorkspaceSVG />
|
|
);
|
|
|
|
const saveWorkspace = () => {
|
|
setObjectValue(
|
|
{
|
|
...activeWorkspace,
|
|
name: `New Workspace (${activeWorkspace.oid.slice(0, 5)})`,
|
|
icon: icons[0],
|
|
color: colors[0],
|
|
},
|
|
undefined,
|
|
true
|
|
);
|
|
setTimeout(() => {
|
|
fireAndForget(updateWorkspaceList);
|
|
}, 10);
|
|
setEditingWorkspace(activeWorkspace.oid);
|
|
};
|
|
|
|
return (
|
|
<Popover
|
|
className="workspace-switcher-popover"
|
|
placement="bottom-start"
|
|
onDismiss={() => setEditingWorkspace(null)}
|
|
>
|
|
<PopoverButton
|
|
className="workspace-switcher-button grey"
|
|
as="div"
|
|
onClick={() => {
|
|
fireAndForget(updateWorkspaceList);
|
|
}}
|
|
>
|
|
<span className="workspace-icon">{workspaceIcon}</span>
|
|
</PopoverButton>
|
|
<PopoverContent className="workspace-switcher-content">
|
|
<div className="title">{isActiveWorkspaceSaved ? "Switch workspace" : "Open workspace"}</div>
|
|
<OverlayScrollbarsComponent className={"scrollable"} options={{ scrollbars: { autoHide: "leave" } }}>
|
|
<ExpandableMenu noIndent singleOpen>
|
|
{workspaceList.map((entry, i) => (
|
|
<WorkspaceSwitcherItem key={i} entryAtom={entry} onDeleteWorkspace={onDeleteWorkspace} />
|
|
))}
|
|
</ExpandableMenu>
|
|
</OverlayScrollbarsComponent>
|
|
|
|
<div className="actions">
|
|
{isActiveWorkspaceSaved ? (
|
|
<ExpandableMenuItem onClick={() => getApi().createWorkspace()}>
|
|
<ExpandableMenuItemLeftElement>
|
|
<i className="fa-sharp fa-solid fa-plus"></i>
|
|
</ExpandableMenuItemLeftElement>
|
|
<div className="content">Create new workspace</div>
|
|
</ExpandableMenuItem>
|
|
) : (
|
|
<ExpandableMenuItem onClick={() => saveWorkspace()}>
|
|
<ExpandableMenuItemLeftElement>
|
|
<i className="fa-sharp fa-solid fa-floppy-disk"></i>
|
|
</ExpandableMenuItemLeftElement>
|
|
<div className="content">Save workspace</div>
|
|
</ExpandableMenuItem>
|
|
)}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
};
|
|
|
|
const WorkspaceSwitcherItem = ({
|
|
entryAtom,
|
|
onDeleteWorkspace,
|
|
}: {
|
|
entryAtom: PrimitiveAtom<WorkspaceListEntry>;
|
|
onDeleteWorkspace: (workspaceId: string) => void;
|
|
}) => {
|
|
const activeWorkspace = useAtomValueSafe(atoms.workspace);
|
|
const [workspaceEntry, setWorkspaceEntry] = useAtom(entryAtom);
|
|
const [editingWorkspace, setEditingWorkspace] = useAtom(editingWorkspaceAtom);
|
|
|
|
const workspace = workspaceEntry.workspace;
|
|
const isCurrentWorkspace = activeWorkspace.oid === workspace.oid;
|
|
|
|
const setWorkspace = useCallback((newWorkspace: Workspace) => {
|
|
if (newWorkspace.name != "") {
|
|
setObjectValue({ ...newWorkspace, otype: "workspace" }, undefined, true);
|
|
}
|
|
setWorkspaceEntry({ ...workspaceEntry, workspace: newWorkspace });
|
|
}, []);
|
|
|
|
const isActive = !!workspaceEntry.windowId;
|
|
const editIconDecl: IconButtonDecl = {
|
|
elemtype: "iconbutton",
|
|
className: "edit",
|
|
icon: "pencil",
|
|
title: "Edit workspace",
|
|
click: (e) => {
|
|
e.stopPropagation();
|
|
if (editingWorkspace === workspace.oid) {
|
|
setEditingWorkspace(null);
|
|
} else {
|
|
setEditingWorkspace(workspace.oid);
|
|
}
|
|
},
|
|
};
|
|
const windowIconDecl: IconButtonDecl = {
|
|
elemtype: "iconbutton",
|
|
className: "window",
|
|
noAction: true,
|
|
icon: isCurrentWorkspace ? "check" : "window",
|
|
title: isCurrentWorkspace ? "This is your current workspace" : "This workspace is open",
|
|
};
|
|
|
|
const isEditing = editingWorkspace === workspace.oid;
|
|
|
|
return (
|
|
<ExpandableMenuItemGroup
|
|
key={workspace.oid}
|
|
isOpen={isEditing}
|
|
className={clsx({ "is-current": isCurrentWorkspace })}
|
|
>
|
|
<ExpandableMenuItemGroupTitle
|
|
onClick={() => {
|
|
getApi().switchWorkspace(workspace.oid);
|
|
// Create a fake escape key event to close the popover
|
|
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
|
}}
|
|
>
|
|
<div
|
|
className="menu-group-title-wrapper"
|
|
style={
|
|
{
|
|
"--workspace-color": workspace.color,
|
|
} as CSSProperties
|
|
}
|
|
>
|
|
<ExpandableMenuItemLeftElement>
|
|
<i
|
|
className={clsx("left-icon", makeIconClass(workspace.icon, true))}
|
|
style={{ color: workspace.color }}
|
|
/>
|
|
</ExpandableMenuItemLeftElement>
|
|
<div className="label">{workspace.name}</div>
|
|
<ExpandableMenuItemRightElement>
|
|
<div className="icons">
|
|
<IconButton decl={editIconDecl} />
|
|
{isActive && <IconButton decl={windowIconDecl} />}
|
|
</div>
|
|
</ExpandableMenuItemRightElement>
|
|
</div>
|
|
</ExpandableMenuItemGroupTitle>
|
|
<ExpandableMenuItem>
|
|
<WorkspaceEditor
|
|
title={workspace.name}
|
|
icon={workspace.icon}
|
|
color={workspace.color}
|
|
focusInput={isEditing}
|
|
onTitleChange={(title) => setWorkspace({ ...workspace, name: title })}
|
|
onColorChange={(color) => setWorkspace({ ...workspace, color })}
|
|
onIconChange={(icon) => setWorkspace({ ...workspace, icon })}
|
|
onDeleteWorkspace={() => onDeleteWorkspace(workspace.oid)}
|
|
/>
|
|
</ExpandableMenuItem>
|
|
</ExpandableMenuItemGroup>
|
|
);
|
|
};
|
|
|
|
export { WorkspaceSwitcher };
|