waveterm/frontend/app/workspace/widgets.tsx
2025-11-14 16:35:37 -08:00

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 };