diff --git a/.roo/rules/rules.md b/.roo/rules/rules.md index 7efa154ea7..341d328f9e 100644 --- a/.roo/rules/rules.md +++ b/.roo/rules/rules.md @@ -84,8 +84,7 @@ The full API is defined in custom.d.ts as type ElectronApi. - **CRITICAL: Completion format MUST be: "Done: [one-line description]"** - **Keep your Task Completed summaries VERY short** -- **No lengthy pre-completion summaries** - Do not provide detailed explanations of implementation before using attempt_completion -- **No recaps of changes** - Skip explaining what was done before completion +- **No double-summarization** - Put your summary ONLY inside attempt_completion. Do not write a summary in the message body AND then repeat it in attempt_completion. One summary, one place. - **Go directly to completion** - After making changes, proceed directly to attempt_completion without summarizing - The project is currently an un-released POC / MVP. Do not worry about backward compatibility when making changes - With React hooks, always complete all hook calls at the top level before any conditional returns (including jotai hook calls useAtom and useAtomValue); when a user explicitly tells you a function handles null inputs, trust them and stop trying to "protect" it with unnecessary checks or workarounds. diff --git a/cmd/generatego/main-generatego.go b/cmd/generatego/main-generatego.go index 74767a5bc6..ab7e338439 100644 --- a/cmd/generatego/main-generatego.go +++ b/cmd/generatego/main-generatego.go @@ -24,14 +24,15 @@ func GenerateWshClient() error { fmt.Fprintf(os.Stderr, "generating wshclient file to %s\n", WshClientFileName) var buf strings.Builder gogen.GenerateBoilerplate(&buf, "wshclient", []string{ + "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes", + "github.com/wavetermdev/waveterm/pkg/baseds", "github.com/wavetermdev/waveterm/pkg/telemetry/telemetrydata", - "github.com/wavetermdev/waveterm/pkg/wshutil", - "github.com/wavetermdev/waveterm/pkg/wshrpc", - "github.com/wavetermdev/waveterm/pkg/wconfig", + "github.com/wavetermdev/waveterm/pkg/vdom", "github.com/wavetermdev/waveterm/pkg/waveobj", + "github.com/wavetermdev/waveterm/pkg/wconfig", "github.com/wavetermdev/waveterm/pkg/wps", - "github.com/wavetermdev/waveterm/pkg/vdom", - "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes", + "github.com/wavetermdev/waveterm/pkg/wshrpc", + "github.com/wavetermdev/waveterm/pkg/wshutil", }) wshDeclMap := wshrpc.GenerateWshCommandDeclMap() for _, key := range utilfn.GetOrderedMapKeys(wshDeclMap) { diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go index ddbd16889f..70c8b3a005 100644 --- a/cmd/server/main-server.go +++ b/cmd/server/main-server.go @@ -573,7 +573,11 @@ func main() { blocklogger.InitBlockLogger() jobcontroller.InitJobController() blockcontroller.InitBlockController() - wcore.InitTabIndicatorStore() + err = wcore.InitBadgeStore() + if err != nil { + log.Printf("error initializing badge store: %v\n", err) + return + } go func() { defer func() { panichandler.PanicHandler("GetSystemSummary", recover()) diff --git a/cmd/wsh/cmd/wshcmd-badge.go b/cmd/wsh/cmd/wshcmd-badge.go new file mode 100644 index 0000000000..590ed1e40b --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-badge.go @@ -0,0 +1,129 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "runtime" + + "github.com/google/uuid" + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/baseds" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wps" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" + "github.com/wavetermdev/waveterm/pkg/wshutil" +) + +var badgeCmd = &cobra.Command{ + Use: "badge [icon]", + Short: "set or clear a block badge", + Args: cobra.MaximumNArgs(1), + RunE: badgeRun, + PreRunE: preRunSetupRpcClient, +} + +var ( + badgeColor string + badgePriority float64 + badgeClear bool + badgeBeep bool + badgePid int +) + +func init() { + rootCmd.AddCommand(badgeCmd) + badgeCmd.Flags().StringVar(&badgeColor, "color", "", "badge color") + badgeCmd.Flags().Float64Var(&badgePriority, "priority", 10, "badge priority") + badgeCmd.Flags().BoolVar(&badgeClear, "clear", false, "clear the badge") + badgeCmd.Flags().BoolVar(&badgeBeep, "beep", false, "play system bell sound") + badgeCmd.Flags().IntVar(&badgePid, "pid", 0, "watch a pid and automatically clear the badge when it exits (default priority 5)") +} + +func badgeRun(cmd *cobra.Command, args []string) (rtnErr error) { + defer func() { + sendActivity("badge", rtnErr == nil) + }() + + if badgePid > 0 && runtime.GOOS == "windows" { + return fmt.Errorf("--pid flag is not supported on Windows") + } + if badgePid > 0 && !cmd.Flags().Changed("priority") { + badgePriority = 5 + } + + oref, err := resolveBlockArg() + if err != nil { + return fmt.Errorf("resolving block: %v", err) + } + if oref.OType != waveobj.OType_Block && oref.OType != waveobj.OType_Tab { + return fmt.Errorf("badge oref must be a block or tab (got %q)", oref.OType) + } + + var eventData baseds.BadgeEvent + eventData.ORef = oref.String() + + if badgeClear { + eventData.Clear = true + } else { + icon := "circle-small" + if len(args) > 0 { + icon = args[0] + } + badgeId, err := uuid.NewV7() + if err != nil { + return fmt.Errorf("generating badge id: %v", err) + } + eventData.Badge = &baseds.Badge{ + BadgeId: badgeId.String(), + Icon: icon, + Color: badgeColor, + Priority: badgePriority, + PidLinked: badgePid > 0, + } + } + + event := wps.WaveEvent{ + Event: wps.Event_Badge, + Scopes: []string{oref.String()}, + Data: eventData, + } + + err = wshclient.EventPublishCommand(RpcClient, event, &wshrpc.RpcOpts{NoResponse: true}) + if err != nil { + return fmt.Errorf("publishing badge event: %v", err) + } + + if badgeBeep { + err = wshclient.ElectronSystemBellCommand(RpcClient, &wshrpc.RpcOpts{Route: "electron"}) + if err != nil { + return fmt.Errorf("playing system bell: %v", err) + } + } + + if badgePid > 0 && eventData.Badge != nil { + conn := RpcContext.Conn + if conn == "" { + conn = wshrpc.LocalConnName + } + connRoute := wshutil.MakeConnectionRouteId(conn) + watchData := wshrpc.CommandBadgeWatchPidData{ + Pid: badgePid, + ORef: *oref, + BadgeId: eventData.Badge.BadgeId, + } + err = wshclient.BadgeWatchPidCommand(RpcClient, watchData, &wshrpc.RpcOpts{Route: connRoute}) + if err != nil { + return fmt.Errorf("watching pid: %v", err) + } + } + + if badgeClear { + fmt.Printf("badge cleared\n") + } else { + fmt.Printf("badge set\n") + } + return nil +} diff --git a/cmd/wsh/cmd/wshcmd-tabindicator.go b/cmd/wsh/cmd/wshcmd-tabindicator.go index f103ee9437..c3fa499cf9 100644 --- a/cmd/wsh/cmd/wshcmd-tabindicator.go +++ b/cmd/wsh/cmd/wshcmd-tabindicator.go @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd @@ -7,7 +7,9 @@ import ( "fmt" "os" + "github.com/google/uuid" "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/baseds" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wps" "github.com/wavetermdev/waveterm/pkg/wshrpc" @@ -16,28 +18,26 @@ import ( var tabIndicatorCmd = &cobra.Command{ Use: "tabindicator [icon]", - Short: "set or clear a tab indicator", + Short: "set or clear a tab indicator (deprecated: use 'wsh badge')", Args: cobra.MaximumNArgs(1), RunE: tabIndicatorRun, PreRunE: preRunSetupRpcClient, } var ( - tabIndicatorTabId string - tabIndicatorColor string - tabIndicatorPriority float64 - tabIndicatorClear bool - tabIndicatorPersistent bool - tabIndicatorBeep bool + tabIndicatorTabId string + tabIndicatorColor string + tabIndicatorPriority float64 + tabIndicatorClear bool + tabIndicatorBeep bool ) func init() { rootCmd.AddCommand(tabIndicatorCmd) tabIndicatorCmd.Flags().StringVar(&tabIndicatorTabId, "tabid", "", "tab id (defaults to WAVETERM_TABID)") tabIndicatorCmd.Flags().StringVar(&tabIndicatorColor, "color", "", "indicator color") - tabIndicatorCmd.Flags().Float64Var(&tabIndicatorPriority, "priority", 0, "indicator priority") + tabIndicatorCmd.Flags().Float64Var(&tabIndicatorPriority, "priority", 10, "indicator priority") tabIndicatorCmd.Flags().BoolVar(&tabIndicatorClear, "clear", false, "clear the indicator") - tabIndicatorCmd.Flags().BoolVar(&tabIndicatorPersistent, "persistent", false, "make indicator persistent (don't clear on focus)") tabIndicatorCmd.Flags().BoolVar(&tabIndicatorBeep, "beep", false, "play system bell sound") } @@ -46,6 +46,8 @@ func tabIndicatorRun(cmd *cobra.Command, args []string) (rtnErr error) { sendActivity("tabindicator", rtnErr == nil) }() + fmt.Fprintf(os.Stderr, "tabindicator is deprecated, use 'wsh badge' instead\n") + tabId := tabIndicatorTabId if tabId == "" { tabId = os.Getenv("WAVETERM_TABID") @@ -54,34 +56,39 @@ func tabIndicatorRun(cmd *cobra.Command, args []string) (rtnErr error) { return fmt.Errorf("no tab id specified (use --tabid or set WAVETERM_TABID)") } - var indicator *wshrpc.TabIndicator - if !tabIndicatorClear { + oref := waveobj.MakeORef(waveobj.OType_Tab, tabId) + + var eventData baseds.BadgeEvent + eventData.ORef = oref.String() + + if tabIndicatorClear { + eventData.Clear = true + } else { icon := "bell" if len(args) > 0 { icon = args[0] } - indicator = &wshrpc.TabIndicator{ - Icon: icon, - Color: tabIndicatorColor, - Priority: tabIndicatorPriority, - ClearOnFocus: !tabIndicatorPersistent, + badgeId, err := uuid.NewV7() + if err != nil { + return fmt.Errorf("generating badge id: %v", err) + } + eventData.Badge = &baseds.Badge{ + BadgeId: badgeId.String(), + Icon: icon, + Color: tabIndicatorColor, + Priority: tabIndicatorPriority, } - } - - eventData := wshrpc.TabIndicatorEventData{ - TabId: tabId, - Indicator: indicator, } event := wps.WaveEvent{ - Event: wps.Event_TabIndicator, - Scopes: []string{waveobj.MakeORef(waveobj.OType_Tab, tabId).String()}, + Event: wps.Event_Badge, + Scopes: []string{oref.String()}, Data: eventData, } err := wshclient.EventPublishCommand(RpcClient, event, &wshrpc.RpcOpts{NoResponse: true}) if err != nil { - return fmt.Errorf("publishing tab indicator event: %v", err) + return fmt.Errorf("publishing badge event: %v", err) } if tabIndicatorBeep { diff --git a/emain/emain-menu.ts b/emain/emain-menu.ts index e3de818f80..691e475443 100644 --- a/emain/emain-menu.ts +++ b/emain/emain-menu.ts @@ -227,7 +227,7 @@ function makeViewMenu( label: "Toggle DevTools", accelerator: devToolsAccel, click: (_, window) => { - let wc = getWindowWebContents(window) ?? webContents; + const wc = getWindowWebContents(window) ?? webContents; wc?.toggleDevTools(); }, }, diff --git a/eslint.config.js b/eslint.config.js index d4844a8b64..50fe7ef7c3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -76,8 +76,9 @@ export default [ "@typescript-eslint/no-unused-vars": [ "warn", { - argsIgnorePattern: "^_[a-z0-9]*$", - varsIgnorePattern: "^_[a-z0-9]*$", + argsIgnorePattern: "^(_[a-zA-Z0-9_]*|e|get)$", + varsIgnorePattern: "^(_[a-zA-Z0-9_]*|dlog|e)$", + caughtErrorsIgnorePattern: "^(_[a-zA-Z0-9_]*|e)$", }, ], "prefer-const": "warn", diff --git a/frontend/app/app.tsx b/frontend/app/app.tsx index 0970b476a1..7e5613d346 100644 --- a/frontend/app/app.tsx +++ b/frontend/app/app.tsx @@ -1,19 +1,19 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { + clearBadgesForBlockOnFocus, + clearBadgesForTabOnFocus, + getBadgeAtom, + getBlockBadgeAtom, +} from "@/app/store/badge"; import { ClientModel } from "@/app/store/client-model"; import { GlobalModel } from "@/app/store/global-model"; import { getTabModelByTabId, TabModelContext } from "@/app/store/tab-model"; import { Workspace } from "@/app/workspace/workspace"; +import { getLayoutModelForStaticTab } from "@/layout/index"; import { ContextMenuModel } from "@/store/contextmenu"; -import { - atoms, - clearTabIndicatorFromFocus, - createBlock, - getSettingsPrefixAtom, - getTabIndicatorAtom, - globalStore, -} from "@/store/global"; +import { atoms, createBlock, getSettingsPrefixAtom, globalStore } from "@/store/global"; import { appHandleKeyDown, keyboardMouseDownHandler } from "@/store/keymodel"; import { getElemAsStr } from "@/util/focusutil"; import * as keyutil from "@/util/keyutil"; @@ -23,7 +23,7 @@ import clsx from "clsx"; import debug from "debug"; import { Provider, useAtomValue } from "jotai"; import "overlayscrollbars/overlayscrollbars.css"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { DndProvider } from "react-dnd"; import { HTML5Backend } from "react-dnd-html5-backend"; import { AppBackground } from "./app-bg"; @@ -103,7 +103,7 @@ async function handleContextMenu(e: React.MouseEvent) { if (!canPaste && !canCopy && !canCut && !clipboardURL) { return; } - let menu: ContextMenuItem[] = []; + const menu: ContextMenuItem[] = []; if (canCut) { menu.push({ label: "Cut", role: "cut" }); } @@ -215,25 +215,57 @@ const AppKeyHandlers = () => { return null; }; -const TabIndicatorAutoClearing = () => { +const BadgeAutoClearing = () => { const tabId = useAtomValue(atoms.staticTabId); - const indicator = useAtomValue(getTabIndicatorAtom(tabId)); const documentHasFocus = useAtomValue(atoms.documentHasFocus); + const layoutModel = getLayoutModelForStaticTab(); + const focusedNode = useAtomValue(layoutModel.focusedNode); + const focusedBlockId = focusedNode?.data?.blockId; + const badge = useAtomValue(getBlockBadgeAtom(focusedBlockId)); + const tabTransientBadge = useAtomValue(getBadgeAtom(tabId != null ? `tab:${tabId}` : null)); + const prevFocusedBlockIdRef = useRef(null); + const prevDocHasFocusRef = useRef(false); + const prevTabDocHasFocusRef = useRef(false); useEffect(() => { - if (!indicator || !documentHasFocus || !indicator.clearonfocus) { + if (!focusedBlockId || !badge || !documentHasFocus) { + prevFocusedBlockIdRef.current = focusedBlockId; + prevDocHasFocusRef.current = documentHasFocus; return; } - + const focusSwitched = + prevFocusedBlockIdRef.current !== focusedBlockId || prevDocHasFocusRef.current !== documentHasFocus; + prevFocusedBlockIdRef.current = focusedBlockId; + prevDocHasFocusRef.current = documentHasFocus; + const delay = focusSwitched ? 500 : 3000; const timeoutId = setTimeout(() => { - const currentIndicator = globalStore.get(getTabIndicatorAtom(tabId)); - if (globalStore.get(atoms.documentHasFocus) && currentIndicator?.clearonfocus) { - clearTabIndicatorFromFocus(tabId); + if (!document.hasFocus()) { + return; } - }, 3000); + const currentFocusedNode = globalStore.get(layoutModel.focusedNode); + if (currentFocusedNode?.data?.blockId === focusedBlockId) { + clearBadgesForBlockOnFocus(focusedBlockId); + } + }, delay); + return () => clearTimeout(timeoutId); + }, [focusedBlockId, badge, documentHasFocus]); + useEffect(() => { + if (!tabId || !tabTransientBadge || !documentHasFocus) { + prevTabDocHasFocusRef.current = documentHasFocus; + return; + } + const focusSwitched = prevTabDocHasFocusRef.current !== documentHasFocus; + prevTabDocHasFocusRef.current = documentHasFocus; + const delay = focusSwitched ? 500 : 3000; + const timeoutId = setTimeout(() => { + if (!document.hasFocus()) { + return; + } + clearBadgesForTabOnFocus(tabId); + }, delay); return () => clearTimeout(timeoutId); - }, [tabId, indicator, documentHasFocus]); + }, [tabId, tabTransientBadge, documentHasFocus]); return null; }; @@ -265,7 +297,7 @@ const AppInner = () => { - + diff --git a/frontend/app/block/blockframe-header.tsx b/frontend/app/block/blockframe-header.tsx index 70f28ff2fe..420a6889c8 100644 --- a/frontend/app/block/blockframe-header.tsx +++ b/frontend/app/block/blockframe-header.tsx @@ -10,6 +10,7 @@ import { } from "@/app/block/blockutil"; import { ConnectionButton } from "@/app/block/connectionbutton"; import { DurableSessionFlyover } from "@/app/block/durable-session-flyover"; +import { getBlockBadgeAtom } from "@/app/store/badge"; import { ContextMenuModel } from "@/app/store/contextmenu"; import { recordTEvent, refocusNode, WOS } from "@/app/store/global"; import { globalStore } from "@/app/store/jotaiStore"; @@ -19,7 +20,7 @@ import { TabRpcClient } from "@/app/store/wshrpcutil"; import { IconButton } from "@/element/iconbutton"; import { NodeModel } from "@/layout/index"; import * as util from "@/util/util"; -import { cn } from "@/util/util"; +import { cn, makeIconClass } from "@/util/util"; import * as jotai from "jotai"; import * as React from "react"; import { BlockFrameProps } from "./blocktypes"; @@ -177,6 +178,7 @@ const BlockFrame_Header = ({ const useTermHeader = util.useAtomValueSafe(viewModel?.useTermHeader); const termConfigedDurable = util.useAtomValueSafe(viewModel?.termConfigedDurable); const hideViewName = util.useAtomValueSafe(viewModel?.hideViewName); + const badge = jotai.useAtomValue(getBlockBadgeAtom(useTermHeader ? nodeModel.blockId : null)); const magnified = jotai.useAtomValue(nodeModel.isMagnified); const prevMagifiedState = React.useRef(magnified); const manageConnection = util.useAtomValueSafe(viewModel?.manageConnection); @@ -229,6 +231,11 @@ const BlockFrame_Header = ({ divClassName="iconbutton disabled text-[13px] ml-[-4px]" /> )} + {useTermHeader && badge && ( +
+ +
+ )} diff --git a/frontend/app/store/badge.ts b/frontend/app/store/badge.ts new file mode 100644 index 0000000000..e3edb82103 --- /dev/null +++ b/frontend/app/store/badge.ts @@ -0,0 +1,226 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { fireAndForget, NullAtom } from "@/util/util"; +import { atom, Atom, PrimitiveAtom } from "jotai"; +import { v7 as uuidv7, version as uuidVersion } from "uuid"; +import { globalStore } from "./jotaiStore"; +import * as WOS from "./wos"; +import { waveEventSubscribeSingle } from "./wps"; + +const BadgeMap = new Map>(); +const TabBadgeAtomCache = new Map>(); + +function clearBadgeInternal(oref: string) { + const eventData: WaveEvent = { + event: "badge", + scopes: [oref], + data: { + oref: oref, + clear: true, + } as BadgeEvent, + }; + fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); +} + +function clearBadgesForBlockOnFocus(blockId: string) { + const oref = WOS.makeORef("block", blockId); + const badgeAtom = BadgeMap.get(oref); + const badge = badgeAtom != null ? globalStore.get(badgeAtom) : null; + if (badge != null && !badge.pidlinked) { + clearBadgeInternal(oref); + } +} + +function clearBadgesForTabOnFocus(tabId: string) { + const oref = WOS.makeORef("tab", tabId); + const badgeAtom = BadgeMap.get(oref); + const badge = badgeAtom != null ? globalStore.get(badgeAtom) : null; + if (badge != null && !badge.pidlinked) { + clearBadgeInternal(oref); + } +} + +function clearAllBadges() { + const eventData: WaveEvent = { + event: "badge", + scopes: [], + data: { + oref: "", + clearall: true, + } as BadgeEvent, + }; + fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); +} + +function clearBadgesForTab(tabId: string) { + const tabAtom = WOS.getWaveObjectAtom(WOS.makeORef("tab", tabId)); + const tab = globalStore.get(tabAtom); + const blockIds = (tab as Tab)?.blockids ?? []; + for (const blockId of blockIds) { + const oref = WOS.makeORef("block", blockId); + const badgeAtom = BadgeMap.get(oref); + if (badgeAtom != null && globalStore.get(badgeAtom) != null) { + clearBadgeInternal(oref); + } + } +} + +function getBadgeAtom(oref: string): PrimitiveAtom { + if (oref == null) { + return NullAtom as PrimitiveAtom; + } + let rtn = BadgeMap.get(oref); + if (rtn == null) { + rtn = atom(null) as PrimitiveAtom; + BadgeMap.set(oref, rtn); + } + return rtn; +} + +function getBlockBadgeAtom(blockId: string): Atom { + if (blockId == null) { + return NullAtom as Atom; + } + const oref = WOS.makeORef("block", blockId); + return getBadgeAtom(oref); +} + +function getTabBadgeAtom(tabId: string): Atom { + if (tabId == null) { + return NullAtom as Atom; + } + let rtn = TabBadgeAtomCache.get(tabId); + if (rtn != null) { + return rtn; + } + const tabOref = WOS.makeORef("tab", tabId); + const tabBadgeAtom = getBadgeAtom(tabOref); + const tabAtom = atom((get) => WOS.getObjectValue(tabOref, get)); + rtn = atom((get) => { + const tab = get(tabAtom); + const blockIds = tab?.blockids ?? []; + const badges: Badge[] = []; + for (const blockId of blockIds) { + const badge = get(getBadgeAtom(WOS.makeORef("block", blockId))); + if (badge != null) { + badges.push(badge); + } + } + const tabBadge = get(tabBadgeAtom); + if (tabBadge != null) { + badges.push(tabBadge); + } + return sortBadgesForTab(badges); + }); + TabBadgeAtomCache.set(tabId, rtn); + return rtn; +} + +async function loadBadges() { + const badges = await RpcApi.GetAllBadgesCommand(TabRpcClient); + if (badges == null) { + return; + } + for (const badgeEvent of badges) { + if (badgeEvent.oref == null) { + continue; + } + const curAtom = getBadgeAtom(badgeEvent.oref); + globalStore.set(curAtom, badgeEvent.badge ?? null); + } +} + +function setBadge(blockId: string, badge: Omit & { badgeid?: string }) { + if (!badge.badgeid) { + badge = { ...badge, badgeid: uuidv7() }; + } else if (uuidVersion(badge.badgeid) !== 7) { + throw new Error(`setBadge: badgeid must be a v7 UUID, got version ${uuidVersion(badge.badgeid)}`); + } + const oref = WOS.makeORef("block", blockId); + const eventData: WaveEvent = { + event: "badge", + scopes: [oref], + data: { + oref: oref, + badge: badge, + } as BadgeEvent, + }; + fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); +} + +function clearBadgeById(blockId: string, badgeId: string) { + const oref = WOS.makeORef("block", blockId); + const eventData: WaveEvent = { + event: "badge", + scopes: [oref], + data: { + oref: oref, + clearbyid: badgeId, + } as BadgeEvent, + }; + fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); +} + +function setupBadgesSubscription() { + waveEventSubscribeSingle({ + eventType: "badge", + handler: (event) => { + const data = event.data; + if (data?.clearall) { + for (const atom of BadgeMap.values()) { + globalStore.set(atom, null); + } + return; + } + if (data?.oref == null) { + return; + } + const curAtom = getBadgeAtom(data.oref); + if (data.clearbyid) { + const existing = globalStore.get(curAtom); + if (existing?.badgeid === data.clearbyid) { + globalStore.set(curAtom, null); + } + return; + } + globalStore.set(curAtom, data.clear ? null : (data.badge ?? null)); + }, + }); +} + +function sortBadges(badges: Badge[]): Badge[] { + return [...badges].sort((a, b) => { + if (a.priority !== b.priority) { + return b.priority - a.priority; + } + return b.badgeid < a.badgeid ? -1 : b.badgeid > a.badgeid ? 1 : 0; + }); +} + +function sortBadgesForTab(badges: Badge[]): Badge[] { + return [...badges].sort((a, b) => { + if (a.priority !== b.priority) { + return b.priority - a.priority; + } + return a.badgeid < b.badgeid ? -1 : a.badgeid > b.badgeid ? 1 : 0; + }); +} + +export { + clearAllBadges, + clearBadgeById, + clearBadgesForBlockOnFocus, + clearBadgesForTab, + clearBadgesForTabOnFocus, + getBadgeAtom, + getBlockBadgeAtom, + getTabBadgeAtom, + loadBadges, + setBadge, + setupBadgesSubscription, + sortBadges, + sortBadgesForTab, +}; diff --git a/frontend/app/store/global-atoms.ts b/frontend/app/store/global-atoms.ts index 18f072070f..6d24666ff0 100644 --- a/frontend/app/store/global-atoms.ts +++ b/frontend/app/store/global-atoms.ts @@ -9,7 +9,6 @@ import * as WOS from "./wos"; let atoms!: GlobalAtomsType; const blockComponentModelMap = new Map(); const ConnStatusMapAtom = atom(new Map>()); -const TabIndicatorMap = new Map>(); const orefAtomCache = new Map>>(); function initGlobalAtoms(initOpts: GlobalInitOptions) { @@ -154,4 +153,4 @@ function getApi(): ElectronApi { return (window as any).api; } -export { atoms, blockComponentModelMap, ConnStatusMapAtom, getAtoms, initGlobalAtoms, orefAtomCache, TabIndicatorMap }; +export { atoms, blockComponentModelMap, ConnStatusMapAtom, getAtoms, initGlobalAtoms, orefAtomCache }; diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 628ae03626..88b679fb57 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -27,27 +27,19 @@ import { isWslConnName, NullAtom, } from "@/util/util"; -import { isPreviewWindow } from "./windowtype"; import { atom, Atom, PrimitiveAtom, useAtomValue } from "jotai"; -import { - atoms, - blockComponentModelMap, - ConnStatusMapAtom, - initGlobalAtoms, - orefAtomCache, - TabIndicatorMap, -} from "./global-atoms"; +import { setupBadgesSubscription } from "./badge"; +import { atoms, blockComponentModelMap, ConnStatusMapAtom, initGlobalAtoms, orefAtomCache } from "./global-atoms"; import { globalStore } from "./jotaiStore"; import { modalsModel } from "./modalmodel"; import { ClientService, ObjectService } from "./services"; +import { isPreviewWindow } from "./windowtype"; import * as WOS from "./wos"; import { getFileSubject, waveEventSubscribeSingle } from "./wps"; -let globalEnvironment: "electron" | "renderer"; let globalPrimaryTabStartup: boolean = false; function initGlobal(initOpts: GlobalInitOptions) { - globalEnvironment = initOpts.environment; globalPrimaryTabStartup = initOpts.primaryTabStartup ?? false; setPlatform(initOpts.platform); initGlobalAtoms(initOpts); @@ -105,12 +97,7 @@ function initGlobalWaveEventSubs(initOpts: WaveInitOpts) { globalStore.set(atoms.waveAIRateLimitInfoAtom, event.data); }, }); - waveEventSubscribeSingle({ - eventType: "tab:indicator", - handler: (event) => { - setTabIndicatorInternal(event.data.tabid, event.data.indicator); - }, - }); + setupBadgesSubscription(); } const blockCache = new Map>(); @@ -137,8 +124,8 @@ function getBlockMetaKeyAtom(blockId: string, key: T): return metaAtom; } metaAtom = atom((get) => { - let blockAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", blockId)); - let blockData = get(blockAtom); + const blockAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", blockId)); + const blockData = get(blockAtom); return blockData?.meta?.[key]; }); blockCache.set(metaAtomName, metaAtom); @@ -157,8 +144,8 @@ function getOrefMetaKeyAtom(oref: string, key: T): Ato return metaAtom; } metaAtom = atom((get) => { - let objAtom = WOS.getWaveObjectAtom(oref); - let objData = get(objAtom); + const objAtom = WOS.getWaveObjectAtom(oref); + const objData = get(objAtom); return objData?.meta?.[key]; }); orefCache.set(metaAtomName, metaAtom); @@ -171,14 +158,14 @@ function useOrefMetaKeyAtom(oref: string, key: T): Met function getConnConfigKeyAtom(connName: string, key: T): Atom { if (isPreviewWindow()) return NullAtom as Atom; - let connCache = getSingleConnAtomCache(connName); + const connCache = getSingleConnAtomCache(connName); const keyAtomName = "#conn-" + key; let keyAtom = connCache.get(keyAtomName); if (keyAtom != null) { return keyAtom; } keyAtom = atom((get) => { - let fullConfig = get(atoms.fullConfigAtom); + const fullConfig = get(atoms.fullConfigAtom); return fullConfig.connections?.[connName]?.[key]; }); connCache.set(keyAtomName, keyAtom); @@ -608,17 +595,6 @@ async function loadConnStatus() { } } -async function loadTabIndicators() { - const tabIndicators = await RpcApi.GetAllTabIndicatorsCommand(TabRpcClient); - if (tabIndicators == null) { - return; - } - for (const [tabId, indicator] of Object.entries(tabIndicators)) { - const curAtom = getTabIndicatorAtom(tabId); - globalStore.set(curAtom, indicator); - } -} - function subscribeToConnEvents() { waveEventSubscribeSingle({ eventType: "connchange", @@ -629,7 +605,7 @@ function subscribeToConnEvents() { return; } console.log("connstatus update", connStatus); - let curAtom = getConnStatusAtom(connStatus.connection); + const curAtom = getConnStatusAtom(connStatus.connection); globalStore.set(curAtom, connStatus); } catch (e) { console.log("connchange error", e); @@ -672,76 +648,6 @@ function getConnStatusAtom(conn: string): PrimitiveAtom { return rtn; } -function getTabIndicatorAtom(tabId: string): PrimitiveAtom { - let rtn = TabIndicatorMap.get(tabId); - if (rtn == null) { - rtn = atom(null) as PrimitiveAtom; - TabIndicatorMap.set(tabId, rtn); - } - return rtn; -} - -function setTabIndicatorInternal(tabId: string, indicator: TabIndicator) { - if (indicator == null) { - const indicatorAtom = getTabIndicatorAtom(tabId); - globalStore.set(indicatorAtom, null); - return; - } - const indicatorAtom = getTabIndicatorAtom(tabId); - const currentIndicator = globalStore.get(indicatorAtom); - if (currentIndicator == null) { - globalStore.set(indicatorAtom, indicator); - return; - } - if (indicator.priority >= currentIndicator.priority) { - if (indicator.clearonfocus && !currentIndicator.clearonfocus) { - indicator.persistentindicator = currentIndicator; - } - globalStore.set(indicatorAtom, indicator); - } -} - -function setTabIndicator(tabId: string, indicator: TabIndicator) { - setTabIndicatorInternal(tabId, indicator); - - const eventData: WaveEvent = { - event: "tab:indicator", - scopes: [WOS.makeORef("tab", tabId)], - data: { - tabid: tabId, - indicator: indicator, - }, - }; - fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); -} - -function clearTabIndicatorFromFocus(tabId: string) { - const indicatorAtom = getTabIndicatorAtom(tabId); - const currentIndicator = globalStore.get(indicatorAtom); - if (currentIndicator == null) { - return; - } - const persistentIndicator = currentIndicator.persistentindicator; - const eventData: WaveEvent = { - event: "tab:indicator", - scopes: [WOS.makeORef("tab", tabId)], - data: { - tabid: tabId, - indicator: persistentIndicator ?? null, - } as TabIndicatorEventData, - }; - fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData)); -} - -function clearAllTabIndicators() { - for (const [tabId, indicatorAtom] of TabIndicatorMap.entries()) { - const indicator = globalStore.get(indicatorAtom); - if (indicator != null) { - setTabIndicator(tabId, null); - } - } -} - function createTab() { getApi().createTab(); } @@ -758,12 +664,8 @@ function recordTEvent(event: string, props?: TEventProps) { RpcApi.RecordTEventCommand(TabRpcClient, { event, props }, { noresponse: true }); } -export { ConnStatusMapAtom, getAtoms, initGlobalAtoms, orefAtomCache, TabIndicatorMap, blockComponentModelMap } from "./global-atoms"; - export { atoms, - clearAllTabIndicators, - clearTabIndicatorFromFocus, createBlock, createBlockSplitHorizontally, createBlockSplitVertically, @@ -783,7 +685,6 @@ export { getOverrideConfigAtom, getSettingsKeyAtom, getSettingsPrefixAtom, - getTabIndicatorAtom, getUserName, globalPrimaryTabStartup, globalStore, @@ -791,7 +692,6 @@ export { initGlobalWaveEventSubs, isDev, loadConnStatus, - loadTabIndicators, openLink, readAtom, recordTEvent, @@ -801,7 +701,6 @@ export { setActiveTab, setNodeFocus, setPlatform, - setTabIndicator, subscribeToConnEvents, unregisterBlockComponentModel, useBlockAtom, diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index aa25448a0a..ac4ab94c42 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -65,7 +65,7 @@ export function keyboardMouseDownHandler(e: MouseEvent) { } } -function getFocusedBlockInStaticTab() { +function getFocusedBlockInStaticTab(): string { const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); return focusedNode.data?.blockId; diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 670b660cb0..dd8e20e5c7 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -37,35 +37,61 @@ class RpcApiType { } // command "authenticatejobmanager" [call] - AuthenticateJobManagerCommand(client: WshClient, data: CommandAuthenticateJobManagerData, opts?: RpcOpts): Promise { + AuthenticateJobManagerCommand( + client: WshClient, + data: CommandAuthenticateJobManagerData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "authenticatejobmanager", data, opts); return client.wshRpcCall("authenticatejobmanager", data, opts); } // command "authenticatejobmanagerverify" [call] - AuthenticateJobManagerVerifyCommand(client: WshClient, data: CommandAuthenticateJobManagerData, opts?: RpcOpts): Promise { + AuthenticateJobManagerVerifyCommand( + client: WshClient, + data: CommandAuthenticateJobManagerData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "authenticatejobmanagerverify", data, opts); return client.wshRpcCall("authenticatejobmanagerverify", data, opts); } // command "authenticatetojobmanager" [call] - AuthenticateToJobManagerCommand(client: WshClient, data: CommandAuthenticateToJobData, opts?: RpcOpts): Promise { + AuthenticateToJobManagerCommand( + client: WshClient, + data: CommandAuthenticateToJobData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "authenticatetojobmanager", data, opts); return client.wshRpcCall("authenticatetojobmanager", data, opts); } // command "authenticatetoken" [call] - AuthenticateTokenCommand(client: WshClient, data: CommandAuthenticateTokenData, opts?: RpcOpts): Promise { + AuthenticateTokenCommand( + client: WshClient, + data: CommandAuthenticateTokenData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "authenticatetoken", data, opts); return client.wshRpcCall("authenticatetoken", data, opts); } // command "authenticatetokenverify" [call] - AuthenticateTokenVerifyCommand(client: WshClient, data: CommandAuthenticateTokenData, opts?: RpcOpts): Promise { + AuthenticateTokenVerifyCommand( + client: WshClient, + data: CommandAuthenticateTokenData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "authenticatetokenverify", data, opts); return client.wshRpcCall("authenticatetokenverify", data, opts); } + // command "badgewatchpid" [call] + BadgeWatchPidCommand(client: WshClient, data: CommandBadgeWatchPidData, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "badgewatchpid", data, opts); + return client.wshRpcCall("badgewatchpid", data, opts); + } + // command "blockinfo" [call] BlockInfoCommand(client: WshClient, data: string, opts?: RpcOpts): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "blockinfo", data, opts); @@ -85,7 +111,11 @@ class RpcApiType { } // command "captureblockscreenshot" [call] - CaptureBlockScreenshotCommand(client: WshClient, data: CommandCaptureBlockScreenshotData, opts?: RpcOpts): Promise { + CaptureBlockScreenshotCommand( + client: WshClient, + data: CommandCaptureBlockScreenshotData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "captureblockscreenshot", data, opts); return client.wshRpcCall("captureblockscreenshot", data, opts); } @@ -151,7 +181,11 @@ class RpcApiType { } // command "controllerappendoutput" [call] - ControllerAppendOutputCommand(client: WshClient, data: CommandControllerAppendOutputData, opts?: RpcOpts): Promise { + ControllerAppendOutputCommand( + client: WshClient, + data: CommandControllerAppendOutputData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "controllerappendoutput", data, opts); return client.wshRpcCall("controllerappendoutput", data, opts); } @@ -235,13 +269,21 @@ class RpcApiType { } // command "electrondecrypt" [call] - ElectronDecryptCommand(client: WshClient, data: CommandElectronDecryptData, opts?: RpcOpts): Promise { + ElectronDecryptCommand( + client: WshClient, + data: CommandElectronDecryptData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "electrondecrypt", data, opts); return client.wshRpcCall("electrondecrypt", data, opts); } // command "electronencrypt" [call] - ElectronEncryptCommand(client: WshClient, data: CommandElectronEncryptData, opts?: RpcOpts): Promise { + ElectronEncryptCommand( + client: WshClient, + data: CommandElectronEncryptData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "electronencrypt", data, opts); return client.wshRpcCall("electronencrypt", data, opts); } @@ -259,7 +301,11 @@ class RpcApiType { } // command "eventreadhistory" [call] - EventReadHistoryCommand(client: WshClient, data: CommandEventReadHistoryData, opts?: RpcOpts): Promise { + EventReadHistoryCommand( + client: WshClient, + data: CommandEventReadHistoryData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "eventreadhistory", data, opts); return client.wshRpcCall("eventreadhistory", data, opts); } @@ -289,7 +335,11 @@ class RpcApiType { } // command "fetchsuggestions" [call] - FetchSuggestionsCommand(client: WshClient, data: FetchSuggestionsData, opts?: RpcOpts): Promise { + FetchSuggestionsCommand( + client: WshClient, + data: FetchSuggestionsData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "fetchsuggestions", data, opts); return client.wshRpcCall("fetchsuggestions", data, opts); } @@ -337,7 +387,11 @@ class RpcApiType { } // command "fileliststream" [responsestream] - FileListStreamCommand(client: WshClient, data: FileListData, opts?: RpcOpts): AsyncGenerator { + FileListStreamCommand( + client: WshClient, + data: FileListData, + opts?: RpcOpts + ): AsyncGenerator { if (mockClient) return mockClient.mockWshRpcStream(client, "fileliststream", data, opts); return client.wshRpcStream("fileliststream", data, opts); } @@ -361,7 +415,7 @@ class RpcApiType { } // command "filereadstream" [responsestream] - FileReadStreamCommand(client: WshClient, data: FileData, opts?: RpcOpts): AsyncGenerator { + FileReadStreamCommand(client: WshClient, data: FileData, opts?: RpcOpts): AsyncGenerator { if (mockClient) return mockClient.mockWshRpcStream(client, "filereadstream", data, opts); return client.wshRpcStream("filereadstream", data, opts); } @@ -390,10 +444,10 @@ class RpcApiType { return client.wshRpcCall("focuswindow", data, opts); } - // command "getalltabindicators" [call] - GetAllTabIndicatorsCommand(client: WshClient, opts?: RpcOpts): Promise<{[key: string]: TabIndicator}> { - if (mockClient) return mockClient.mockWshRpcCall(client, "getalltabindicators", null, opts); - return client.wshRpcCall("getalltabindicators", null, opts); + // command "getallbadges" [call] + GetAllBadgesCommand(client: WshClient, opts?: RpcOpts): Promise { + if (mockClient) return mockClient.mockWshRpcCall(client, "getallbadges", null, opts); + return client.wshRpcCall("getallbadges", null, opts); } // command "getallvars" [call] @@ -445,7 +499,7 @@ class RpcApiType { } // command "getsecrets" [call] - GetSecretsCommand(client: WshClient, data: string[], opts?: RpcOpts): Promise<{[key: string]: string}> { + GetSecretsCommand(client: WshClient, data: string[], opts?: RpcOpts): Promise<{ [key: string]: string }> { if (mockClient) return mockClient.mockWshRpcCall(client, "getsecrets", data, opts); return client.wshRpcCall("getsecrets", data, opts); } @@ -511,7 +565,11 @@ class RpcApiType { } // command "jobcontrollerattachjob" [call] - JobControllerAttachJobCommand(client: WshClient, data: CommandJobControllerAttachJobData, opts?: RpcOpts): Promise { + JobControllerAttachJobCommand( + client: WshClient, + data: CommandJobControllerAttachJobData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "jobcontrollerattachjob", data, opts); return client.wshRpcCall("jobcontrollerattachjob", data, opts); } @@ -571,7 +629,11 @@ class RpcApiType { } // command "jobcontrollerstartjob" [call] - JobControllerStartJobCommand(client: WshClient, data: CommandJobControllerStartJobData, opts?: RpcOpts): Promise { + JobControllerStartJobCommand( + client: WshClient, + data: CommandJobControllerStartJobData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "jobcontrollerstartjob", data, opts); return client.wshRpcCall("jobcontrollerstartjob", data, opts); } @@ -583,7 +645,11 @@ class RpcApiType { } // command "jobprepareconnect" [call] - JobPrepareConnectCommand(client: WshClient, data: CommandJobPrepareConnectData, opts?: RpcOpts): Promise { + JobPrepareConnectCommand( + client: WshClient, + data: CommandJobPrepareConnectData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "jobprepareconnect", data, opts); return client.wshRpcCall("jobprepareconnect", data, opts); } @@ -595,7 +661,11 @@ class RpcApiType { } // command "listallappfiles" [call] - ListAllAppFilesCommand(client: WshClient, data: CommandListAllAppFilesData, opts?: RpcOpts): Promise { + ListAllAppFilesCommand( + client: WshClient, + data: CommandListAllAppFilesData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "listallappfiles", data, opts); return client.wshRpcCall("listallappfiles", data, opts); } @@ -613,7 +683,11 @@ class RpcApiType { } // command "makedraftfromlocal" [call] - MakeDraftFromLocalCommand(client: WshClient, data: CommandMakeDraftFromLocalData, opts?: RpcOpts): Promise { + MakeDraftFromLocalCommand( + client: WshClient, + data: CommandMakeDraftFromLocalData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "makedraftfromlocal", data, opts); return client.wshRpcCall("makedraftfromlocal", data, opts); } @@ -649,13 +723,21 @@ class RpcApiType { } // command "publishapp" [call] - PublishAppCommand(client: WshClient, data: CommandPublishAppData, opts?: RpcOpts): Promise { + PublishAppCommand( + client: WshClient, + data: CommandPublishAppData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "publishapp", data, opts); return client.wshRpcCall("publishapp", data, opts); } // command "readappfile" [call] - ReadAppFileCommand(client: WshClient, data: CommandReadAppFileData, opts?: RpcOpts): Promise { + ReadAppFileCommand( + client: WshClient, + data: CommandReadAppFileData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "readappfile", data, opts); return client.wshRpcCall("readappfile", data, opts); } @@ -667,7 +749,11 @@ class RpcApiType { } // command "remotedisconnectfromjobmanager" [call] - RemoteDisconnectFromJobManagerCommand(client: WshClient, data: CommandRemoteDisconnectFromJobManagerData, opts?: RpcOpts): Promise { + RemoteDisconnectFromJobManagerCommand( + client: WshClient, + data: CommandRemoteDisconnectFromJobManagerData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "remotedisconnectfromjobmanager", data, opts); return client.wshRpcCall("remotedisconnectfromjobmanager", data, opts); } @@ -703,7 +789,11 @@ class RpcApiType { } // command "remotefilemultiinfo" [call] - RemoteFileMultiInfoCommand(client: WshClient, data: CommandRemoteFileMultiInfoData, opts?: RpcOpts): Promise<{[key: string]: FileInfo}> { + RemoteFileMultiInfoCommand( + client: WshClient, + data: CommandRemoteFileMultiInfoData, + opts?: RpcOpts + ): Promise<{ [key: string]: FileInfo }> { if (mockClient) return mockClient.mockWshRpcCall(client, "remotefilemultiinfo", data, opts); return client.wshRpcCall("remotefilemultiinfo", data, opts); } @@ -727,7 +817,11 @@ class RpcApiType { } // command "remotelistentries" [responsestream] - RemoteListEntriesCommand(client: WshClient, data: CommandRemoteListEntriesData, opts?: RpcOpts): AsyncGenerator { + RemoteListEntriesCommand( + client: WshClient, + data: CommandRemoteListEntriesData, + opts?: RpcOpts + ): AsyncGenerator { if (mockClient) return mockClient.mockWshRpcStream(client, "remotelistentries", data, opts); return client.wshRpcStream("remotelistentries", data, opts); } @@ -739,31 +833,47 @@ class RpcApiType { } // command "remotereconnecttojobmanager" [call] - RemoteReconnectToJobManagerCommand(client: WshClient, data: CommandRemoteReconnectToJobManagerData, opts?: RpcOpts): Promise { + RemoteReconnectToJobManagerCommand( + client: WshClient, + data: CommandRemoteReconnectToJobManagerData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "remotereconnecttojobmanager", data, opts); return client.wshRpcCall("remotereconnecttojobmanager", data, opts); } // command "remotestartjob" [call] - RemoteStartJobCommand(client: WshClient, data: CommandRemoteStartJobData, opts?: RpcOpts): Promise { + RemoteStartJobCommand( + client: WshClient, + data: CommandRemoteStartJobData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "remotestartjob", data, opts); return client.wshRpcCall("remotestartjob", data, opts); } // command "remotestreamcpudata" [responsestream] - RemoteStreamCpuDataCommand(client: WshClient, opts?: RpcOpts): AsyncGenerator { + RemoteStreamCpuDataCommand(client: WshClient, opts?: RpcOpts): AsyncGenerator { if (mockClient) return mockClient.mockWshRpcStream(client, "remotestreamcpudata", null, opts); return client.wshRpcStream("remotestreamcpudata", null, opts); } // command "remotestreamfile" [responsestream] - RemoteStreamFileCommand(client: WshClient, data: CommandRemoteStreamFileData, opts?: RpcOpts): AsyncGenerator { + RemoteStreamFileCommand( + client: WshClient, + data: CommandRemoteStreamFileData, + opts?: RpcOpts + ): AsyncGenerator { if (mockClient) return mockClient.mockWshRpcStream(client, "remotestreamfile", data, opts); return client.wshRpcStream("remotestreamfile", data, opts); } // command "remoteterminatejobmanager" [call] - RemoteTerminateJobManagerCommand(client: WshClient, data: CommandRemoteTerminateJobManagerData, opts?: RpcOpts): Promise { + RemoteTerminateJobManagerCommand( + client: WshClient, + data: CommandRemoteTerminateJobManagerData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "remoteterminatejobmanager", data, opts); return client.wshRpcCall("remoteterminatejobmanager", data, opts); } @@ -781,13 +891,21 @@ class RpcApiType { } // command "resolveids" [call] - ResolveIdsCommand(client: WshClient, data: CommandResolveIdsData, opts?: RpcOpts): Promise { + ResolveIdsCommand( + client: WshClient, + data: CommandResolveIdsData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "resolveids", data, opts); return client.wshRpcCall("resolveids", data, opts); } // command "restartbuilderandwait" [call] - RestartBuilderAndWaitCommand(client: WshClient, data: CommandRestartBuilderAndWaitData, opts?: RpcOpts): Promise { + RestartBuilderAndWaitCommand( + client: WshClient, + data: CommandRestartBuilderAndWaitData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "restartbuilderandwait", data, opts); return client.wshRpcCall("restartbuilderandwait", data, opts); } @@ -847,7 +965,7 @@ class RpcApiType { } // command "setsecrets" [call] - SetSecretsCommand(client: WshClient, data: {[key: string]: string}, opts?: RpcOpts): Promise { + SetSecretsCommand(client: WshClient, data: { [key: string]: string }, opts?: RpcOpts): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "setsecrets", data, opts); return client.wshRpcCall("setsecrets", data, opts); } @@ -877,7 +995,11 @@ class RpcApiType { } // command "streamcpudata" [responsestream] - StreamCpuDataCommand(client: WshClient, data: CpuDataRequest, opts?: RpcOpts): AsyncGenerator { + StreamCpuDataCommand( + client: WshClient, + data: CpuDataRequest, + opts?: RpcOpts + ): AsyncGenerator { if (mockClient) return mockClient.mockWshRpcStream(client, "streamcpudata", data, opts); return client.wshRpcStream("streamcpudata", data, opts); } @@ -895,19 +1017,27 @@ class RpcApiType { } // command "streamtest" [responsestream] - StreamTestCommand(client: WshClient, opts?: RpcOpts): AsyncGenerator { + StreamTestCommand(client: WshClient, opts?: RpcOpts): AsyncGenerator { if (mockClient) return mockClient.mockWshRpcStream(client, "streamtest", null, opts); return client.wshRpcStream("streamtest", null, opts); } // command "streamwaveai" [responsestream] - StreamWaveAiCommand(client: WshClient, data: WaveAIStreamRequest, opts?: RpcOpts): AsyncGenerator { + StreamWaveAiCommand( + client: WshClient, + data: WaveAIStreamRequest, + opts?: RpcOpts + ): AsyncGenerator { if (mockClient) return mockClient.mockWshRpcStream(client, "streamwaveai", data, opts); return client.wshRpcStream("streamwaveai", data, opts); } // command "termgetscrollbacklines" [call] - TermGetScrollbackLinesCommand(client: WshClient, data: CommandTermGetScrollbackLinesData, opts?: RpcOpts): Promise { + TermGetScrollbackLinesCommand( + client: WshClient, + data: CommandTermGetScrollbackLinesData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "termgetscrollbacklines", data, opts); return client.wshRpcCall("termgetscrollbacklines", data, opts); } @@ -937,13 +1067,21 @@ class RpcApiType { } // command "vdomrender" [responsestream] - VDomRenderCommand(client: WshClient, data: VDomFrontendUpdate, opts?: RpcOpts): AsyncGenerator { + VDomRenderCommand( + client: WshClient, + data: VDomFrontendUpdate, + opts?: RpcOpts + ): AsyncGenerator { if (mockClient) return mockClient.mockWshRpcStream(client, "vdomrender", data, opts); return client.wshRpcStream("vdomrender", data, opts); } // command "vdomurlrequest" [responsestream] - VDomUrlRequestCommand(client: WshClient, data: VDomUrlRequestData, opts?: RpcOpts): AsyncGenerator { + VDomUrlRequestCommand( + client: WshClient, + data: VDomUrlRequestData, + opts?: RpcOpts + ): AsyncGenerator { if (mockClient) return mockClient.mockWshRpcStream(client, "vdomurlrequest", data, opts); return client.wshRpcStream("vdomurlrequest", data, opts); } @@ -967,7 +1105,11 @@ class RpcApiType { } // command "waveaigettooldiff" [call] - WaveAIGetToolDiffCommand(client: WshClient, data: CommandWaveAIGetToolDiffData, opts?: RpcOpts): Promise { + WaveAIGetToolDiffCommand( + client: WshClient, + data: CommandWaveAIGetToolDiffData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "waveaigettooldiff", data, opts); return client.wshRpcCall("waveaigettooldiff", data, opts); } @@ -979,7 +1121,11 @@ class RpcApiType { } // command "wavefilereadstream" [call] - WaveFileReadStreamCommand(client: WshClient, data: CommandWaveFileReadStreamData, opts?: RpcOpts): Promise { + WaveFileReadStreamCommand( + client: WshClient, + data: CommandWaveFileReadStreamData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "wavefilereadstream", data, opts); return client.wshRpcCall("wavefilereadstream", data, opts); } @@ -1009,13 +1155,21 @@ class RpcApiType { } // command "writeappgofile" [call] - WriteAppGoFileCommand(client: WshClient, data: CommandWriteAppGoFileData, opts?: RpcOpts): Promise { + WriteAppGoFileCommand( + client: WshClient, + data: CommandWriteAppGoFileData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "writeappgofile", data, opts); return client.wshRpcCall("writeappgofile", data, opts); } // command "writeappsecretbindings" [call] - WriteAppSecretBindingsCommand(client: WshClient, data: CommandWriteAppSecretBindingsData, opts?: RpcOpts): Promise { + WriteAppSecretBindingsCommand( + client: WshClient, + data: CommandWriteAppSecretBindingsData, + opts?: RpcOpts + ): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "writeappsecretbindings", data, opts); return client.wshRpcCall("writeappsecretbindings", data, opts); } @@ -1027,7 +1181,7 @@ class RpcApiType { } // command "wshactivity" [call] - WshActivityCommand(client: WshClient, data: {[key: string]: number}, opts?: RpcOpts): Promise { + WshActivityCommand(client: WshClient, data: { [key: string]: number }, opts?: RpcOpts): Promise { if (mockClient) return mockClient.mockWshRpcCall(client, "wshactivity", data, opts); return client.wshRpcCall("wshactivity", data, opts); } @@ -1049,7 +1203,6 @@ class RpcApiType { if (mockClient) return mockClient.mockWshRpcCall(client, "wslstatus", null, opts); return client.wshRpcCall("wslstatus", null, opts); } - } export const RpcApi = new RpcApiType(); diff --git a/frontend/app/tab/tab.scss b/frontend/app/tab/tab.scss index 3739752eee..ad10fc814e 100644 --- a/frontend/app/tab/tab.scss +++ b/frontend/app/tab/tab.scss @@ -1,10 +1,10 @@ -// Copyright 2024, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 .tab { position: absolute; width: 130px; - height: calc(100% - 1px); + height: calc(100% - 3px); padding: 0 0 0 0; box-sizing: border-box; font-weight: bold; @@ -14,13 +14,12 @@ align-items: center; justify-content: center; - &::after { - content: ""; + .tab-divider { position: absolute; left: 0; width: 1px; height: 14px; - border-right: 1px solid rgb(from var(--main-text-color) r g b / 0.2); + background: rgb(from var(--main-text-color) r g b / 0.2); } .tab-inner { @@ -45,19 +44,11 @@ } .name { - color: var(--main-text-color); - } - - & + .tab::after, - &::after { - content: none; + color: rgba(255, 255, 255, 1); + font-weight: 600; } } - &:first-child::after { - content: none; - } - .name { position: absolute; top: 50%; @@ -81,21 +72,6 @@ } } - .tab-indicator { - position: absolute; - top: 50%; - left: 4px; - transform: translate3d(0, -50%, 0); - width: 20px; - height: 20px; - display: flex; - align-items: center; - justify-content: center; - z-index: var(--zindex-tab-name); - padding: 1px 2px; - transition: none !important; - } - .wave-button { position: absolute; top: 50%; @@ -118,11 +94,17 @@ } // Only apply hover effects when not in nohover mode. This prevents the previously-hovered tab from remaining hovered while a tab view is not mounted. +body:not(.nohover) .tab:hover + .tab, +body:not(.nohover) .tab.dragging + .tab { + .tab-divider { + display: none; + } +} + body:not(.nohover) .tab:hover, body:not(.nohover) .tab.dragging { - & + .tab::after, - &::after { - content: none; + .tab-divider { + display: none; } .tab-inner { @@ -157,53 +139,3 @@ body.nohover .tab.active .close { animation: expandWidthAndFadeIn 0.1s forwards; } -@keyframes jigglePinIcon { - 0% { - transform: rotate(0deg); - color: inherit; - } - 10% { - transform: rotate(-30deg); - color: rgb(255, 193, 7); - } - 20% { - transform: rotate(30deg); - color: rgb(255, 193, 7); - } - 30% { - transform: rotate(-30deg); - color: rgb(255, 193, 7); - } - 40% { - transform: rotate(30deg); - color: rgb(255, 193, 7); - } - 50% { - transform: rotate(-15deg); - color: rgb(255, 193, 7); - } - 60% { - transform: rotate(15deg); - color: rgb(255, 193, 7); - } - 70% { - transform: rotate(-15deg); - color: rgb(255, 193, 7); - } - 80% { - transform: rotate(15deg); - color: rgb(255, 193, 7); - } - 90% { - transform: rotate(0deg); - color: rgb(255, 193, 7); - } - 100% { - transform: rotate(0deg); - color: inherit; - } -} - -.pin.jiggling i { - animation: jigglePinIcon 0.5s ease-in-out; -} diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index 37a96ca525..01a13bf13e 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -1,24 +1,18 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { - atoms, - clearAllTabIndicators, - clearTabIndicatorFromFocus, - getTabIndicatorAtom, - globalStore, - recordTEvent, - refocusNode, - setTabIndicator, -} from "@/app/store/global"; +import { getTabBadgeAtom, sortBadgesForTab } from "@/app/store/badge"; +import { atoms, getOrefMetaKeyAtom, globalStore, recordTEvent, refocusNode } from "@/app/store/global"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { Button } from "@/element/button"; import { ContextMenuModel } from "@/store/contextmenu"; +import { validateCssColor } from "@/util/color-validator"; import { fireAndForget, makeIconClass } from "@/util/util"; import clsx from "clsx"; import { useAtomValue } from "jotai"; -import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; +import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"; +import { v7 as uuidv7 } from "uuid"; import { ObjectService } from "../store/services"; import { makeORef, useWaveObjectValue } from "../store/wos"; import "./tab.scss"; @@ -27,11 +21,12 @@ interface TabVProps { tabId: string; tabName: string; active: boolean; - isBeforeActive: boolean; + showDivider: boolean; isDragging: boolean; tabWidth: number; isNew: boolean; - indicator?: TabIndicator | null; + badges?: Badge[] | null; + flagColor?: string | null; onClick: () => void; onClose: (event: React.MouseEvent | null) => void; onDragStart: (event: React.MouseEvent) => void; @@ -41,16 +36,58 @@ interface TabVProps { renameRef?: React.RefObject<(() => void) | null>; } +interface TabBadgesProps { + badges?: Badge[] | null; + flagColor?: string | null; +} + +function TabBadges({ badges, flagColor }: TabBadgesProps) { + const flagBadgeId = useMemo(() => uuidv7(), []); + const allBadges = useMemo(() => { + const base = badges ?? []; + if (!flagColor) { + return base; + } + const flagBadge: Badge = { icon: "flag", color: flagColor, priority: 0, badgeid: flagBadgeId }; + return sortBadgesForTab([...base, flagBadge]); + }, [badges, flagColor, flagBadgeId]); + if (!allBadges[0]) { + return null; + } + const firstBadge = allBadges[0]; + const extraBadges = allBadges.slice(1, 3); + return ( +
+ + {extraBadges.length > 0 && ( +
+ {extraBadges.map((badge, idx) => ( +
+ ))} +
+ )} +
+ ); +} + const TabV = forwardRef((props, ref) => { const { tabId, tabName, active, - isBeforeActive, + showDivider, isDragging, tabWidth, isNew, - indicator, + badges, + flagColor, onClick, onClose, onDragStart, @@ -167,7 +204,6 @@ const TabV = forwardRef((props, ref) => { className={clsx("tab", { active, dragging: isDragging, - "before-active": isBeforeActive, "new-tab": isNew, })} onMouseDown={onDragStart} @@ -175,6 +211,7 @@ const TabV = forwardRef((props, ref) => { onContextMenu={onContextMenu} data-tab-id={tabId} > + {showDivider &&
}
((props, ref) => { > {tabName}
- {indicator && ( -
- -
- )} +