mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-11-28 05:00:26 +08:00
454 lines
18 KiB
TypeScript
454 lines
18 KiB
TypeScript
// Copyright 2025, Command Line Inc.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
import { Tooltip } from "@/app/element/tooltip";
|
|
import { ContextMenuModel } from "@/app/store/contextmenu";
|
|
import { RpcApi } from "@/app/store/wshclientapi";
|
|
import { TabRpcClient } from "@/app/store/wshrpcutil";
|
|
import { atoms, createBlock, getApi, isDev } from "@/store/global";
|
|
import { fireAndForget, isBlank, makeIconClass } from "@/util/util";
|
|
import {
|
|
FloatingPortal,
|
|
autoUpdate,
|
|
offset,
|
|
shift,
|
|
useDismiss,
|
|
useFloating,
|
|
useInteractions,
|
|
} from "@floating-ui/react";
|
|
import clsx from "clsx";
|
|
import { useAtomValue } from "jotai";
|
|
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
|
|
|
function sortByDisplayOrder(wmap: { [key: string]: WidgetConfigType }): WidgetConfigType[] {
|
|
if (wmap == null) {
|
|
return [];
|
|
}
|
|
const wlist = Object.values(wmap);
|
|
wlist.sort((a, b) => {
|
|
return (a["display:order"] ?? 0) - (b["display:order"] ?? 0);
|
|
});
|
|
return wlist;
|
|
}
|
|
|
|
async function handleWidgetSelect(widget: WidgetConfigType) {
|
|
const blockDef = widget.blockdef;
|
|
createBlock(blockDef, widget.magnified);
|
|
}
|
|
|
|
const Widget = memo(({ widget, mode }: { widget: WidgetConfigType; mode: "normal" | "compact" | "supercompact" }) => {
|
|
const [isTruncated, setIsTruncated] = useState(false);
|
|
const labelRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (mode === "normal" && labelRef.current) {
|
|
const element = labelRef.current;
|
|
setIsTruncated(element.scrollWidth > element.clientWidth);
|
|
}
|
|
}, [mode, widget.label]);
|
|
|
|
const shouldDisableTooltip = mode !== "normal" ? false : !isTruncated;
|
|
|
|
return (
|
|
<Tooltip
|
|
content={widget.description || widget.label}
|
|
placement="left"
|
|
disable={shouldDisableTooltip}
|
|
divClassName={clsx(
|
|
"flex flex-col justify-center items-center w-full py-1.5 pr-0.5 text-secondary overflow-hidden rounded-sm hover:bg-hoverbg hover:text-white cursor-pointer",
|
|
mode === "supercompact" ? "text-sm" : "text-lg",
|
|
widget["display:hidden"] && "hidden"
|
|
)}
|
|
divOnClick={() => handleWidgetSelect(widget)}
|
|
>
|
|
<div style={{ color: widget.color }}>
|
|
<i className={makeIconClass(widget.icon, true, { defaultIcon: "browser" })}></i>
|
|
</div>
|
|
{mode === "normal" && !isBlank(widget.label) ? (
|
|
<div
|
|
ref={labelRef}
|
|
className="text-xxs mt-0.5 w-full px-0.5 text-center whitespace-nowrap overflow-hidden text-ellipsis"
|
|
>
|
|
{widget.label}
|
|
</div>
|
|
) : null}
|
|
</Tooltip>
|
|
);
|
|
});
|
|
|
|
function calculateGridSize(appCount: number): number {
|
|
if (appCount <= 4) return 2;
|
|
if (appCount <= 9) return 3;
|
|
if (appCount <= 16) return 4;
|
|
if (appCount <= 25) return 5;
|
|
return 6;
|
|
}
|
|
|
|
const AppsFloatingWindow = memo(
|
|
({
|
|
isOpen,
|
|
onClose,
|
|
referenceElement,
|
|
}: {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
referenceElement: HTMLElement;
|
|
}) => {
|
|
const [apps, setApps] = useState<AppInfo[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const { refs, floatingStyles, context } = useFloating({
|
|
open: isOpen,
|
|
onOpenChange: onClose,
|
|
placement: "left-start",
|
|
middleware: [offset(-2), shift({ padding: 12 })],
|
|
whileElementsMounted: autoUpdate,
|
|
elements: {
|
|
reference: referenceElement,
|
|
},
|
|
});
|
|
|
|
const dismiss = useDismiss(context);
|
|
const { getFloatingProps } = useInteractions([dismiss]);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
|
|
const fetchApps = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const allApps = await RpcApi.ListAllAppsCommand(TabRpcClient);
|
|
const localApps = allApps
|
|
.filter((app) => !app.appid.startsWith("draft/"))
|
|
.sort((a, b) => {
|
|
const aName = a.appid.replace(/^local\//, "");
|
|
const bName = b.appid.replace(/^local\//, "");
|
|
return aName.localeCompare(bName);
|
|
});
|
|
setApps(localApps);
|
|
} catch (error) {
|
|
console.error("Failed to fetch apps:", error);
|
|
setApps([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchApps();
|
|
}, [isOpen]);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
const gridSize = calculateGridSize(apps.length);
|
|
|
|
return (
|
|
<FloatingPortal>
|
|
<div
|
|
ref={refs.setFloating}
|
|
style={floatingStyles}
|
|
{...getFloatingProps()}
|
|
className="bg-modalbg border border-border rounded-lg shadow-xl p-4 z-50"
|
|
>
|
|
{loading ? (
|
|
<div className="flex items-center justify-center p-8">
|
|
<i className="fa fa-solid fa-spinner fa-spin text-2xl text-muted"></i>
|
|
</div>
|
|
) : apps.length === 0 ? (
|
|
<div className="text-muted text-sm p-4 text-center">No local apps found</div>
|
|
) : (
|
|
<div
|
|
className="grid gap-3"
|
|
style={{
|
|
gridTemplateColumns: `repeat(${gridSize}, minmax(0, 1fr))`,
|
|
maxWidth: `${gridSize * 80}px`,
|
|
}}
|
|
>
|
|
{apps.map((app) => {
|
|
const appMeta = app.manifest?.appmeta;
|
|
const displayName = app.appid.replace(/^local\//, "");
|
|
const icon = appMeta?.icon || "cube";
|
|
const iconColor = appMeta?.iconcolor || "white";
|
|
|
|
return (
|
|
<div
|
|
key={app.appid}
|
|
className="flex flex-col items-center justify-center p-2 rounded hover:bg-hoverbg cursor-pointer transition-colors"
|
|
onClick={() => {
|
|
const blockDef: BlockDef = {
|
|
meta: {
|
|
view: "tsunami",
|
|
controller: "tsunami",
|
|
"tsunami:appid": app.appid,
|
|
},
|
|
};
|
|
createBlock(blockDef);
|
|
onClose();
|
|
}}
|
|
>
|
|
<div style={{ color: iconColor }} className="text-3xl mb-1">
|
|
<i className={makeIconClass(icon, false)}></i>
|
|
</div>
|
|
<div className="text-xxs text-center text-secondary break-words w-full px-1">
|
|
{displayName}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</FloatingPortal>
|
|
);
|
|
}
|
|
);
|
|
|
|
const Widgets = memo(() => {
|
|
const fullConfig = useAtomValue(atoms.fullConfigAtom);
|
|
const hasCustomAIPresets = useAtomValue(atoms.hasCustomAIPresetsAtom);
|
|
const [mode, setMode] = useState<"normal" | "compact" | "supercompact">("normal");
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const measurementRef = useRef<HTMLDivElement>(null);
|
|
|
|
const helpWidget: WidgetConfigType = {
|
|
icon: "circle-question",
|
|
label: "help",
|
|
blockdef: {
|
|
meta: {
|
|
view: "help",
|
|
},
|
|
},
|
|
};
|
|
const tipsWidget: WidgetConfigType = {
|
|
icon: "lightbulb",
|
|
label: "tips",
|
|
blockdef: {
|
|
meta: {
|
|
view: "tips",
|
|
},
|
|
},
|
|
magnified: true,
|
|
};
|
|
const showHelp = fullConfig?.settings?.["widget:showhelp"] ?? true;
|
|
const featureWaveAppBuilder = fullConfig?.settings?.["feature:waveappbuilder"] ?? false;
|
|
const widgetsMap = fullConfig?.widgets ?? {};
|
|
const filteredWidgets = hasCustomAIPresets
|
|
? widgetsMap
|
|
: Object.fromEntries(Object.entries(widgetsMap).filter(([key]) => key !== "defwidget@ai"));
|
|
const widgets = sortByDisplayOrder(filteredWidgets);
|
|
|
|
const [isAppsOpen, setIsAppsOpen] = useState(false);
|
|
const appsButtonRef = useRef<HTMLDivElement>(null);
|
|
|
|
const checkModeNeeded = useCallback(() => {
|
|
if (!containerRef.current || !measurementRef.current) return;
|
|
|
|
const containerHeight = containerRef.current.clientHeight;
|
|
const normalHeight = measurementRef.current.scrollHeight;
|
|
const gracePeriod = 10;
|
|
|
|
let newMode: "normal" | "compact" | "supercompact" = "normal";
|
|
|
|
if (normalHeight > containerHeight - gracePeriod) {
|
|
newMode = "compact";
|
|
|
|
// Calculate total widget count for supercompact check
|
|
const totalWidgets = (widgets?.length || 0) + (showHelp ? 2 : 0);
|
|
const minHeightPerWidget = 32;
|
|
const requiredHeight = totalWidgets * minHeightPerWidget;
|
|
|
|
if (requiredHeight > containerHeight) {
|
|
newMode = "supercompact";
|
|
}
|
|
}
|
|
|
|
if (newMode !== mode) {
|
|
setMode(newMode);
|
|
}
|
|
}, [mode, widgets, showHelp]);
|
|
|
|
useEffect(() => {
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
checkModeNeeded();
|
|
});
|
|
|
|
if (containerRef.current) {
|
|
resizeObserver.observe(containerRef.current);
|
|
}
|
|
|
|
return () => {
|
|
resizeObserver.disconnect();
|
|
};
|
|
}, [checkModeNeeded]);
|
|
|
|
useEffect(() => {
|
|
checkModeNeeded();
|
|
}, [widgets, showHelp, checkModeNeeded]);
|
|
|
|
const handleWidgetsBarContextMenu = (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
const menu: ContextMenuItem[] = [
|
|
{
|
|
label: "Edit widgets.json",
|
|
click: () => {
|
|
fireAndForget(async () => {
|
|
const path = `${getApi().getConfigDir()}/widgets.json`;
|
|
const blockDef: BlockDef = {
|
|
meta: { view: "preview", file: path },
|
|
};
|
|
await createBlock(blockDef, false, true);
|
|
});
|
|
},
|
|
},
|
|
{
|
|
label: "Show Help Widgets",
|
|
submenu: [
|
|
{
|
|
label: "On",
|
|
type: "checkbox",
|
|
checked: showHelp,
|
|
click: () => {
|
|
fireAndForget(async () => {
|
|
await RpcApi.SetConfigCommand(TabRpcClient, { "widget:showhelp": true });
|
|
});
|
|
},
|
|
},
|
|
{
|
|
label: "Off",
|
|
type: "checkbox",
|
|
checked: !showHelp,
|
|
click: () => {
|
|
fireAndForget(async () => {
|
|
await RpcApi.SetConfigCommand(TabRpcClient, { "widget:showhelp": false });
|
|
});
|
|
},
|
|
},
|
|
],
|
|
},
|
|
];
|
|
ContextMenuModel.showContextMenu(menu, e);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
ref={containerRef}
|
|
className="flex flex-col w-12 overflow-hidden py-1 -ml-1 select-none"
|
|
onContextMenu={handleWidgetsBarContextMenu}
|
|
>
|
|
{mode === "supercompact" ? (
|
|
<>
|
|
<div className="grid grid-cols-2 gap-0 w-full">
|
|
{widgets?.map((data, idx) => (
|
|
<Widget key={`widget-${idx}`} widget={data} mode={mode} />
|
|
))}
|
|
</div>
|
|
<div className="flex-grow" />
|
|
{isDev() || featureWaveAppBuilder || showHelp ? (
|
|
<div className="grid grid-cols-2 gap-0 w-full">
|
|
{isDev() || featureWaveAppBuilder ? (
|
|
<div
|
|
ref={appsButtonRef}
|
|
className="flex flex-col justify-center items-center w-full py-1.5 pr-0.5 text-secondary text-sm overflow-hidden rounded-sm hover:bg-hoverbg hover:text-white cursor-pointer"
|
|
onClick={() => setIsAppsOpen(!isAppsOpen)}
|
|
>
|
|
<Tooltip content="Local WaveApps" placement="left" disable={isAppsOpen}>
|
|
<div>
|
|
<i className={makeIconClass("cube", true)}></i>
|
|
</div>
|
|
</Tooltip>
|
|
</div>
|
|
) : null}
|
|
{showHelp ? (
|
|
<>
|
|
<Widget key="tips" widget={tipsWidget} mode={mode} />
|
|
<Widget key="help" widget={helpWidget} mode={mode} />
|
|
</>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
<>
|
|
{widgets?.map((data, idx) => (
|
|
<Widget key={`widget-${idx}`} widget={data} mode={mode} />
|
|
))}
|
|
<div className="flex-grow" />
|
|
{isDev() || featureWaveAppBuilder ? (
|
|
<div
|
|
ref={appsButtonRef}
|
|
className="flex flex-col justify-center items-center w-full py-1.5 pr-0.5 text-secondary text-lg overflow-hidden rounded-sm hover:bg-hoverbg hover:text-white cursor-pointer"
|
|
onClick={() => setIsAppsOpen(!isAppsOpen)}
|
|
>
|
|
<Tooltip content="Local WaveApps" placement="left" disable={isAppsOpen}>
|
|
<div>
|
|
<i className={makeIconClass("cube", true)}></i>
|
|
</div>
|
|
{mode === "normal" && (
|
|
<div className="text-xxs mt-0.5 w-full px-0.5 text-center whitespace-nowrap overflow-hidden text-ellipsis">
|
|
apps
|
|
</div>
|
|
)}
|
|
</Tooltip>
|
|
</div>
|
|
) : null}
|
|
{showHelp ? (
|
|
<>
|
|
<Widget key="tips" widget={tipsWidget} mode={mode} />
|
|
<Widget key="help" widget={helpWidget} mode={mode} />
|
|
</>
|
|
) : null}
|
|
</>
|
|
)}
|
|
{isDev() ? (
|
|
<div
|
|
className="dev-label flex justify-center items-center w-full py-1 text-accent text-[30px]"
|
|
title="Running Wave Dev Build"
|
|
>
|
|
<i className="fa fa-brands fa-dev fa-fw" />
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
{(isDev() || featureWaveAppBuilder) && appsButtonRef.current && (
|
|
<AppsFloatingWindow
|
|
isOpen={isAppsOpen}
|
|
onClose={() => setIsAppsOpen(false)}
|
|
referenceElement={appsButtonRef.current}
|
|
/>
|
|
)}
|
|
|
|
<div
|
|
ref={measurementRef}
|
|
className="flex flex-col w-12 py-1 -ml-1 select-none absolute -z-10 opacity-0 pointer-events-none"
|
|
>
|
|
{widgets?.map((data, idx) => (
|
|
<Widget key={`measurement-widget-${idx}`} widget={data} mode="normal" />
|
|
))}
|
|
<div className="flex-grow" />
|
|
{showHelp ? (
|
|
<>
|
|
<Widget key="measurement-tips" widget={tipsWidget} mode="normal" />
|
|
<Widget key="measurement-help" widget={helpWidget} mode="normal" />
|
|
</>
|
|
) : null}
|
|
{isDev() ? (
|
|
<div className="flex flex-col justify-center items-center w-full py-1.5 pr-0.5 text-lg">
|
|
<div>
|
|
<i className={makeIconClass("cube", true)}></i>
|
|
</div>
|
|
<div className="text-xxs mt-0.5 w-full px-0.5 text-center">apps</div>
|
|
</div>
|
|
) : null}
|
|
{isDev() ? (
|
|
<div
|
|
className="dev-label flex justify-center items-center w-full py-1 text-accent text-[30px]"
|
|
title="Running Wave Dev Build"
|
|
>
|
|
<i className="fa fa-brands fa-dev fa-fw" />
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</>
|
|
);
|
|
});
|
|
|
|
export { Widgets };
|