waveterm/frontend/app/tab/workspaceswitcher.tsx
Copilot 195277de45
Generate WaveEvent as a typed discriminated union with explicit null payloads for no-data events (#2899)
This updates WaveEvent typing to be event-aware instead of `data?: any`,
while keeping safe fallback behavior for unmapped events. It also
codifies known no-payload events as `null` payloads and documents event
payload expectations alongside the Go event constants.

- **Event registry + payload documentation (Go)**
- Added `AllEvents` in `pkg/wps/wpstypes.go` as the canonical list of
Wave event names.
  - Added/updated inline payload annotations on `Event_*` constants.
- Marked confirmed no-payload events with `// type: none` (e.g.
`route:up`, `route:down`, `workspace:update`, `waveapp:appgoupdated`).

- **Dedicated WaveEvent TS generation path**
- Added `pkg/tsgen/tsgenevent.go` with `event -> reflect.Type` metadata
(`WaveEventDataTypes`).
  - Supports three cases:
    - mapped concrete type → strong TS payload type
    - mapped `nil` → `data?: null` (explicit no-data contract)
    - unmapped event → `data?: any` (non-breaking fallback)

- **Custom WaveEvent output and default suppression**
- Suppressed default struct-based `WaveEvent` emission in
`gotypes.d.ts`.
  - Added generated `frontend/types/waveevent.d.ts` containing:
    - `WaveEventName` string-literal union from `AllEvents`
    - discriminated `WaveEvent` union keyed by `event`.

- **Generator wiring + focused coverage**
- Hooked custom event generation into
`cmd/generatets/main-generatets.go`.
  - Added `pkg/tsgen/tsgenevent_test.go` assertions for:
    - typed mapped events
    - explicit `null` for known no-data events
    - `any` fallback for unmapped events.

```ts
type WaveEvent = {
  event: WaveEventName;
  scopes?: string[];
  sender?: string;
  persist?: number;
  data?: any;
} & (
  { event: "block:jobstatus"; data?: BlockJobStatusData } |
  { event: "route:up"; data?: null } |
  { event: "workspace:update"; data?: null } |
  { event: "some:future:event"; data?: any } // fallback if unmapped
);
```

<!-- START COPILOT CODING AGENT TIPS -->
---

💬 We'd love your input! Share your thoughts on Copilot coding agent in
our [2 minute survey](https://gh.io/copilot-coding-agent-survey).

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
Co-authored-by: sawka <mike@commandline.dev>
2026-02-20 17:04:03 -08:00

247 lines
9.5 KiB
TypeScript

// Copyright 2025, Command Line
// SPDX-License-Identifier: Apache-2.0
import {
ExpandableMenu,
ExpandableMenuItem,
ExpandableMenuItemGroup,
ExpandableMenuItemGroupTitle,
ExpandableMenuItemLeftElement,
ExpandableMenuItemRightElement,
} from "@/element/expandablemenu";
import { Popover, PopoverButton, PopoverContent } from "@/element/popover";
import { fireAndForget, makeIconClass, useAtomValueSafe } from "@/util/util";
import clsx from "clsx";
import { atom, PrimitiveAtom, useAtom, useAtomValue, useSetAtom } from "jotai";
import { splitAtom } from "jotai/utils";
import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
import { CSSProperties, forwardRef, useCallback, useEffect } from "react";
import WorkspaceSVG from "../asset/workspace.svg";
import { IconButton } from "../element/iconbutton";
import { atoms, getApi } from "../store/global";
import { WorkspaceService } from "../store/services";
import { getObjectValue, makeORef } from "../store/wos";
import { waveEventSubscribeSingle } from "../store/wps";
import { WorkspaceEditor } from "./workspaceeditor";
import "./workspaceswitcher.scss";
type WorkspaceListEntry = {
windowId: string;
workspace: Workspace;
};
type WorkspaceList = WorkspaceListEntry[];
const workspaceMapAtom = atom<WorkspaceList>([]);
const workspaceSplitAtom = splitAtom(workspaceMapAtom);
const editingWorkspaceAtom = atom<string>();
const WorkspaceSwitcher = forwardRef<HTMLDivElement>((_, ref) => {
const setWorkspaceList = useSetAtom(workspaceMapAtom);
const activeWorkspace = useAtomValueSafe(atoms.workspace);
const workspaceList = useAtomValue(workspaceSplitAtom);
const setEditingWorkspace = useSetAtom(editingWorkspaceAtom);
const updateWorkspaceList = useCallback(async () => {
const workspaceList = await WorkspaceService.ListWorkspaces();
if (!workspaceList) {
return;
}
const newList: WorkspaceList = [];
for (const entry of workspaceList) {
// This just ensures that the atom exists for easier setting of the object
getObjectValue(makeORef("workspace", entry.workspaceid));
newList.push({
windowId: entry.windowid,
workspace: await WorkspaceService.GetWorkspace(entry.workspaceid),
});
}
setWorkspaceList(newList);
}, []);
useEffect(
() =>
waveEventSubscribeSingle({
eventType: "workspace:update",
handler: () => fireAndForget(updateWorkspaceList),
}),
[]
);
useEffect(() => {
fireAndForget(updateWorkspaceList);
}, []);
const onDeleteWorkspace = useCallback((workspaceId: string) => {
getApi().deleteWorkspace(workspaceId);
}, []);
const isActiveWorkspaceSaved = !!(activeWorkspace.name && activeWorkspace.icon);
const workspaceIcon = isActiveWorkspaceSaved ? (
<i className={makeIconClass(activeWorkspace.icon, false)} style={{ color: activeWorkspace.color }}></i>
) : (
<WorkspaceSVG />
);
const saveWorkspace = () => {
fireAndForget(async () => {
await WorkspaceService.UpdateWorkspace(activeWorkspace.oid, "", "", "", true);
await updateWorkspaceList();
setEditingWorkspace(activeWorkspace.oid);
});
};
return (
<Popover
className="workspace-switcher-popover"
placement="bottom-start"
onDismiss={() => setEditingWorkspace(null)}
ref={ref}
>
<PopoverButton
className="workspace-switcher-button grey"
as="div"
onClick={() => {
fireAndForget(updateWorkspaceList);
}}
>
<span className="workspace-icon">{workspaceIcon}</span>
</PopoverButton>
<PopoverContent className="workspace-switcher-content">
<div className="title">{isActiveWorkspaceSaved ? "Switch workspace" : "Open workspace"}</div>
<OverlayScrollbarsComponent className={"scrollable"} options={{ scrollbars: { autoHide: "leave" } }}>
<ExpandableMenu noIndent singleOpen>
{workspaceList.map((entry, i) => (
<WorkspaceSwitcherItem key={i} entryAtom={entry} onDeleteWorkspace={onDeleteWorkspace} />
))}
</ExpandableMenu>
</OverlayScrollbarsComponent>
<div className="actions">
{isActiveWorkspaceSaved ? (
<ExpandableMenuItem onClick={() => getApi().createWorkspace()}>
<ExpandableMenuItemLeftElement>
<i className="fa-sharp fa-solid fa-plus"></i>
</ExpandableMenuItemLeftElement>
<div className="content">Create new workspace</div>
</ExpandableMenuItem>
) : (
<ExpandableMenuItem onClick={() => saveWorkspace()}>
<ExpandableMenuItemLeftElement>
<i className="fa-sharp fa-solid fa-floppy-disk"></i>
</ExpandableMenuItemLeftElement>
<div className="content">Save workspace</div>
</ExpandableMenuItem>
)}
</div>
</PopoverContent>
</Popover>
);
});
const WorkspaceSwitcherItem = ({
entryAtom,
onDeleteWorkspace,
}: {
entryAtom: PrimitiveAtom<WorkspaceListEntry>;
onDeleteWorkspace: (workspaceId: string) => void;
}) => {
const activeWorkspace = useAtomValueSafe(atoms.workspace);
const [workspaceEntry, setWorkspaceEntry] = useAtom(entryAtom);
const [editingWorkspace, setEditingWorkspace] = useAtom(editingWorkspaceAtom);
const workspace = workspaceEntry.workspace;
const isCurrentWorkspace = activeWorkspace.oid === workspace.oid;
const setWorkspace = useCallback((newWorkspace: Workspace) => {
setWorkspaceEntry({ ...workspaceEntry, workspace: newWorkspace });
if (newWorkspace.name != "") {
fireAndForget(() =>
WorkspaceService.UpdateWorkspace(
workspace.oid,
newWorkspace.name,
newWorkspace.icon,
newWorkspace.color,
false
)
);
}
}, []);
const isActive = !!workspaceEntry.windowId;
const editIconDecl: IconButtonDecl = {
elemtype: "iconbutton",
className: "edit",
icon: "pencil",
title: "Edit workspace",
click: (e) => {
e.stopPropagation();
if (editingWorkspace === workspace.oid) {
setEditingWorkspace(null);
} else {
setEditingWorkspace(workspace.oid);
}
},
};
const windowIconDecl: IconButtonDecl = {
elemtype: "iconbutton",
className: "window",
noAction: true,
icon: isCurrentWorkspace ? "check" : "window",
title: isCurrentWorkspace ? "This is your current workspace" : "This workspace is open",
};
const isEditing = editingWorkspace === workspace.oid;
return (
<ExpandableMenuItemGroup
key={workspace.oid}
isOpen={isEditing}
className={clsx({ "is-current": isCurrentWorkspace })}
>
<ExpandableMenuItemGroupTitle
onClick={() => {
getApi().switchWorkspace(workspace.oid);
// Create a fake escape key event to close the popover
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
}}
>
<div
className="menu-group-title-wrapper"
style={
{
"--workspace-color": workspace.color,
} as CSSProperties
}
>
<ExpandableMenuItemLeftElement>
<i
className={clsx("left-icon", makeIconClass(workspace.icon, true))}
style={{ color: workspace.color }}
/>
</ExpandableMenuItemLeftElement>
<div className="label">{workspace.name}</div>
<ExpandableMenuItemRightElement>
<div className="icons">
<IconButton decl={editIconDecl} />
{isActive && <IconButton decl={windowIconDecl} />}
</div>
</ExpandableMenuItemRightElement>
</div>
</ExpandableMenuItemGroupTitle>
<ExpandableMenuItem>
<WorkspaceEditor
title={workspace.name}
icon={workspace.icon}
color={workspace.color}
focusInput={isEditing}
onTitleChange={(title) => setWorkspace({ ...workspace, name: title })}
onColorChange={(color) => setWorkspace({ ...workspace, color })}
onIconChange={(icon) => setWorkspace({ ...workspace, icon })}
onDeleteWorkspace={() => onDeleteWorkspace(workspace.oid)}
/>
</ExpandableMenuItem>
</ExpandableMenuItemGroup>
);
};
export { WorkspaceSwitcher };