waveterm/frontend/app/tab/workspaceswitcher.tsx
Evan Simkowitz 4070abadde
Squash some leftover bugs (#1495)
Only create new tab in `CheckAndFixWindow` if no tabs or pinned tabs
exist

Update `resolvers.resolveTabNum` to account for pinned tabs

Remove obsolete and unused `wstore.DeleteTab`

Only show accelerators for first 9 workspaces in workspace app menu to
be consistent with other keybindings

Fix tabbar spacing to remove min size for drag right spacer, account for
workspace switcher button size

Fix updatebanner size calculations
2024-12-11 12:52:15 -08:00

376 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, forwardRef, 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 = forwardRef<HTMLDivElement>((_, ref) => {
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)}
ref={ref}
>
<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 };