mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-01-06 19:18:22 +01:00
4070abadde
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
376 lines
13 KiB
TypeScript
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 };
|