update widgets bar to be MUCH more responsive (#2311)

This commit is contained in:
Mike Sawka 2025-08-29 13:04:22 -07:00 committed by GitHub
parent 0f7159858e
commit 76afdc65ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 294 additions and 222 deletions

View file

@ -1,6 +1,7 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { cn } from "@/util/util";
import {
FloatingPortal,
autoUpdate,
@ -11,7 +12,6 @@ import {
useHover,
useInteractions,
} from "@floating-ui/react";
import { cn } from "@/util/util";
import { useEffect, useRef, useState } from "react";
interface TooltipProps {
@ -19,12 +19,13 @@ interface TooltipProps {
content: React.ReactNode;
placement?: "top" | "bottom" | "left" | "right";
forceOpen?: boolean;
disable?: boolean;
divClassName?: string;
divStyle?: React.CSSProperties;
divOnClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
}
export function Tooltip({
function TooltipInner({
children,
content,
placement = "top",
@ -32,7 +33,7 @@ export function Tooltip({
divClassName,
divStyle,
divOnClick,
}: TooltipProps) {
}: Omit<TooltipProps, 'disable'>) {
const [isOpen, setIsOpen] = useState(forceOpen);
const [isVisible, setIsVisible] = useState(false);
const timeoutRef = useRef<number | null>(null);
@ -63,11 +64,7 @@ export function Tooltip({
}
},
placement,
middleware: [
offset(10),
flip(),
shift({ padding: 12 }),
],
middleware: [offset(10), flip(), shift({ padding: 12 })],
whileElementsMounted: autoUpdate,
});
@ -132,7 +129,7 @@ export function Tooltip({
}}
{...getFloatingProps()}
className={cn(
"bg-white dark:bg-panel border border-border rounded-md px-2 py-1 text-xs text-foreground shadow-xl z-50"
"bg-gray-800 border border-border rounded-md px-2 py-1 text-xs text-foreground shadow-xl z-50"
)}
>
{content}
@ -141,4 +138,39 @@ export function Tooltip({
)}
</>
);
}
}
export function Tooltip({
children,
content,
placement = "top",
forceOpen = false,
disable = false,
divClassName,
divStyle,
divOnClick,
}: TooltipProps) {
if (disable) {
return (
<div
className={divClassName}
style={divStyle}
onClick={divOnClick}
>
{children}
</div>
);
}
return (
<TooltipInner
children={children}
content={content}
placement={placement}
forceOpen={forceOpen}
divClassName={divClassName}
divStyle={divStyle}
divOnClick={divOnClick}
/>
);
}

View file

@ -1,64 +0,0 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
.notification-popover {
padding: 8px 2px 8px 0px;
display: flex;
align-items: center;
justify-content: center;
button {
width: 27px;
height: 26px;
display: flex;
justify-content: center;
i {
font-size: 17px;
}
}
}
.notification-content {
display: flex;
width: 380px;
padding: 10px 0 0;
flex-direction: column;
align-items: flex-start;
column-gap: 8px;
border-radius: 8px;
border: 0.5px solid rgba(255, 255, 255, 0.12);
background: #232323;
box-shadow: 0px 8px 32px 0px rgba(0, 0, 0, 0.25);
.divider {
background: rgba(255, 255, 255, 0.08);
height: 1px;
width: 100%;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0 10px 8px 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
span {
color: var(--main-text-color);
font-size: 14px;
font-style: normal;
font-weight: 600;
line-height: 16px;
}
}
.close-all-btn {
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 16px;
color: rgba(255, 255, 255, 0.4);
}
}

View file

@ -13,8 +13,6 @@ import { NotificationItem } from "./notificationitem";
import { useUpdateNotifier } from "./updatenotifier";
import { useNotification } from "./usenotification";
import "./notificationpopover.scss";
const NotificationPopover = () => {
useUpdateNotifier();
const {
@ -51,14 +49,14 @@ const NotificationPopover = () => {
return (
<Popover
className="notification-popover"
className="w-full pb-2 pt-1 pl-0 pr-0.5 flex items-center justify-center"
placement="left-end"
offset={{ mainAxis: 20, crossAxis: 2 }}
onDismiss={handleTogglePopover}
>
<PopoverButton
className={clsx(
"notification-trigger-button horizontal-padding-6 vertical-padding-4 border-radius-",
"w-[27px] h-[26px] flex justify-center [&>i]:text-[17px] horizontal-padding-6 vertical-padding-4 border-radius-",
addOnClassNames
)}
disabled={notifications.length === 0}
@ -67,11 +65,11 @@ const NotificationPopover = () => {
{getIcon()}
</PopoverButton>
{notifications.length > 0 && (
<PopoverContent className="notification-content">
<div className="header">
<span>Notifications</span>
<PopoverContent className="flex w-[380px] pt-2.5 pb-0 px-0 flex-col items-start gap-x-2 rounded-lg border-[0.5px] border-white/12 bg-[#232323] shadow-[0px_8px_32px_0px_rgba(0,0,0,0.25)]">
<div className="flex items-center justify-between w-full px-2.5 pb-2 border-b border-white/8">
<span className="text-foreground text-sm font-semibold leading-4">Notifications</span>
<Button
className="ghost grey close-all-btn horizontal-padding-3 vertical-padding-3"
className="ghost grey text-[13px] font-normal leading-4 text-white/40 horizontal-padding-3 vertical-padding-3"
onClick={(e) => {
e.stopPropagation();
removeAllNotifications();
@ -98,7 +96,7 @@ const NotificationPopover = () => {
onMouseEnter={() => setHoveredId(notif.id)}
onMouseLeave={() => setHoveredId(null)}
/>
{index !== notifications.length - 1 && <div className="divider"></div>}
{index !== notifications.length - 1 && <div className="bg-white/8 h-px w-full"></div>}
</Fragment>
))}
</OverlayScrollbarsComponent>

View file

@ -0,0 +1,243 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { Tooltip } from "@/app/element/tooltip";
import { NotificationPopover } from "@/app/notification/notificationpopover";
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 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>
);
});
const Widgets = memo(() => {
const fullConfig = useAtomValue(atoms.fullConfigAtom);
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",
},
},
};
const showHelp = fullConfig?.settings?.["widget:showhelp"] ?? true;
const widgets = sortByDisplayOrder(fullConfig?.widgets);
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" />
{showHelp ? (
<div className="grid grid-cols-2 gap-0 w-full">
<Widget key="tips" widget={tipsWidget} mode={mode} />
<Widget key="help" widget={helpWidget} mode={mode} />
</div>
) : null}
</>
) : (
<>
{widgets?.map((data, idx) => <Widget key={`widget-${idx}`} widget={data} mode={mode} />)}
<div className="flex-grow" />
{showHelp ? (
<>
<Widget key="tips" widget={tipsWidget} mode={mode} />
<Widget key="help" widget={helpWidget} mode={mode} />
</>
) : null}
</>
)}
{isDev() ? <NotificationPopover /> : null}
</div>
<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() ? <NotificationPopover /> : null}
</div>
</>
);
});
export { Widgets };

View file

@ -4,150 +4,13 @@
import { ErrorBoundary } from "@/app/element/errorboundary";
import { CenteredDiv } from "@/app/element/quickelems";
import { ModalsRenderer } from "@/app/modals/modalsrenderer";
import { NotificationPopover } from "@/app/notification/notificationpopover";
import { ContextMenuModel } from "@/app/store/contextmenu";
import { RpcApi } from "@/app/store/wshclientapi";
import { TabRpcClient } from "@/app/store/wshrpcutil";
import { TabBar } from "@/app/tab/tabbar";
import { TabContent } from "@/app/tab/tabcontent";
import { atoms, createBlock, getApi, isDev } from "@/store/global";
import { fireAndForget, isBlank, makeIconClass } from "@/util/util";
import clsx from "clsx";
import { Widgets } from "@/app/workspace/widgets";
import { atoms } from "@/store/global";
import { useAtomValue } from "jotai";
import { memo } from "react";
const iconRegex = /^[a-z0-9-]+$/;
function keyLen(obj: Object): number {
if (obj == null) {
return 0;
}
return Object.keys(obj).length;
}
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;
}
const Widgets = memo(() => {
const fullConfig = useAtomValue(atoms.fullConfigAtom);
const helpWidget: WidgetConfigType = {
icon: "circle-question",
label: "help",
blockdef: {
meta: {
view: "help",
},
},
};
const tipsWidget: WidgetConfigType = {
icon: "lightbulb",
label: "tips",
blockdef: {
meta: {
view: "tips",
},
},
};
const showHelp = fullConfig?.settings?.["widget:showhelp"] ?? true;
const widgets = sortByDisplayOrder(fullConfig?.widgets);
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
className="flex flex-col w-12 overflow-hidden py-1 -ml-1 select-none"
onContextMenu={handleWidgetsBarContextMenu}
>
{widgets?.map((data, idx) => <Widget key={`widget-${idx}`} widget={data} />)}
<div className="flex-grow" />
{showHelp ? (
<>
<Widget key="tips" widget={tipsWidget} />
<Widget key="help" widget={helpWidget} />
</>
) : null}
{isDev() ? <NotificationPopover /> : null}
</div>
);
});
async function handleWidgetSelect(widget: WidgetConfigType) {
const blockDef = widget.blockdef;
createBlock(blockDef, widget.magnified);
}
const Widget = memo(({ widget }: { widget: WidgetConfigType }) => {
return (
<div
className={clsx(
"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",
widget["display:hidden"] && "hidden"
)}
onClick={() => handleWidgetSelect(widget)}
title={widget.description || widget.label}
>
<div style={{ color: widget.color }}>
<i className={makeIconClass(widget.icon, true, { defaultIcon: "browser" })}></i>
</div>
{!isBlank(widget.label) ? (
<div className="text-xxs mt-0.5 w-full px-0.5 text-center whitespace-nowrap overflow-hidden">
{widget.label}
</div>
) : null}
</div>
);
});
const WorkspaceElem = memo(() => {
const tabId = useAtomValue(atoms.staticTabId);
const ws = useAtomValue(atoms.workspace);