mirror of
https://github.com/wavetermdev/waveterm
synced 2026-04-21 14:37:16 +00:00
TabBar full preview + much more FE mocking via WaveEnv to enable it (#3028)
Large PR that extends WaveEnv mocking to fully cover the (complicated) TabBar implementation. Also includes a full preview of the tab bar in the preview server with lots of controls to simulate different scenarios. As a result of this mocking, also fixed a bunch of dependencies, and layout errors, random bugs, and visual UX bugs in the tab bar, making it more robust. --------- 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>
This commit is contained in:
parent
568027df21
commit
ecccad6ea1
38 changed files with 1056 additions and 373 deletions
|
|
@ -26,7 +26,7 @@ RPC commands in Wave Terminal follow these conventions:
|
|||
|
||||
- **Method names** must end with `Command`
|
||||
- **First parameter** must be `context.Context`
|
||||
- **Second parameter** (optional) is the command data structure
|
||||
- **Remaining parameters** are a regular Go parameter list (zero or more typed args)
|
||||
- **Return values** can be either just an error, or one return value plus an error
|
||||
- **Streaming commands** return a channel instead of a direct value
|
||||
|
||||
|
|
@ -49,7 +49,7 @@ type WshRpcInterface interface {
|
|||
|
||||
- Method name must end with `Command`
|
||||
- First parameter must be `ctx context.Context`
|
||||
- Optional second parameter for input data
|
||||
- Remaining parameters are a regular Go parameter list (zero or more)
|
||||
- Return either `error` or `(ReturnType, error)`
|
||||
- For streaming, return `chan RespOrErrorUnion[T]`
|
||||
|
||||
|
|
|
|||
|
|
@ -69,6 +69,12 @@ export type MyEnv = WaveEnvSubset<{
|
|||
// --- wos: always take the whole thing, no sub-typing needed ---
|
||||
wos: WaveEnv["wos"];
|
||||
|
||||
// --- services: list only the services you call; no method-level narrowing ---
|
||||
services: {
|
||||
block: WaveEnv["services"]["block"];
|
||||
workspace: WaveEnv["services"]["workspace"];
|
||||
};
|
||||
|
||||
// --- key-parameterized atom factories: enumerate the keys you use ---
|
||||
getSettingsKeyAtom: SettingsKeyAtomFnType<"app:focusfollowscursor" | "window:magnifiedblockopacity">;
|
||||
getBlockMetaKeyAtom: BlockMetaKeyAtomFnType<"view" | "frame:title" | "connection">;
|
||||
|
|
@ -80,6 +86,14 @@ export type MyEnv = WaveEnvSubset<{
|
|||
}>;
|
||||
```
|
||||
|
||||
### Automatically Included Fields
|
||||
|
||||
Every `WaveEnvSubset<T>` automatically includes the mock fields — you never need to declare them:
|
||||
|
||||
- `isMock: boolean`
|
||||
- `mockSetWaveObj: <T extends WaveObj>(oref: string, obj: T) => void`
|
||||
- `mockModels?: Map<any, any>`
|
||||
|
||||
### Rules for Each Section
|
||||
|
||||
| Section | Pattern | Notes |
|
||||
|
|
@ -88,6 +102,7 @@ export type MyEnv = WaveEnvSubset<{
|
|||
| `rpc` | `rpc: { Cmd: WaveEnv["rpc"]["Cmd"]; }` | List every RPC command called; omit the rest. |
|
||||
| `atoms` | `atoms: { atom: WaveEnv["atoms"]["atom"]; }` | List every atom read; omit the rest. |
|
||||
| `wos` | `wos: WaveEnv["wos"]` | Take the whole `wos` object (no sub-typing needed), but **only add it if `wos` is actually used**. |
|
||||
| `services` | `services: { svc: WaveEnv["services"]["svc"]; }` | List each service used; take the whole service object (no method-level narrowing). |
|
||||
| `getSettingsKeyAtom` | `SettingsKeyAtomFnType<"key1" \| "key2">` | Union all settings keys accessed. |
|
||||
| `getBlockMetaKeyAtom` | `BlockMetaKeyAtomFnType<"key1" \| "key2">` | Union all block meta keys accessed. |
|
||||
| `getConnConfigKeyAtom` | `ConnConfigKeyAtomFnType<"key1">` | Union all conn config keys accessed. |
|
||||
|
|
|
|||
|
|
@ -88,7 +88,14 @@ func generateServicesFile(tsTypesMap map[reflect.Type]string) error {
|
|||
fmt.Fprintf(&buf, "// Copyright 2026, Command Line Inc.\n")
|
||||
fmt.Fprintf(&buf, "// SPDX-License-Identifier: Apache-2.0\n\n")
|
||||
fmt.Fprintf(&buf, "// generated by cmd/generate/main-generatets.go\n\n")
|
||||
fmt.Fprintf(&buf, "import * as WOS from \"./wos\";\n\n")
|
||||
fmt.Fprintf(&buf, "import * as WOS from \"./wos\";\n")
|
||||
fmt.Fprintf(&buf, "import type { WaveEnv } from \"@/app/waveenv/waveenv\";\n\n")
|
||||
fmt.Fprintf(&buf, "function callBackendService(waveEnv: WaveEnv, service: string, method: string, args: any[], noUIContext?: boolean): Promise<any> {\n")
|
||||
fmt.Fprintf(&buf, " if (waveEnv != null) {\n")
|
||||
fmt.Fprintf(&buf, " return waveEnv.callBackendService(service, method, args, noUIContext)\n")
|
||||
fmt.Fprintf(&buf, " }\n")
|
||||
fmt.Fprintf(&buf, " return WOS.callBackendService(service, method, args, noUIContext);\n")
|
||||
fmt.Fprintf(&buf, "}\n\n")
|
||||
orderedKeys := utilfn.GetOrderedMapKeys(service.ServiceMap)
|
||||
for _, serviceName := range orderedKeys {
|
||||
serviceObj := service.ServiceMap[serviceName]
|
||||
|
|
@ -96,6 +103,22 @@ func generateServicesFile(tsTypesMap map[reflect.Type]string) error {
|
|||
fmt.Fprint(&buf, svcStr)
|
||||
fmt.Fprint(&buf, "\n")
|
||||
}
|
||||
fmt.Fprintf(&buf, "export const AllServiceTypes = {\n")
|
||||
for _, serviceName := range orderedKeys {
|
||||
serviceObj := service.ServiceMap[serviceName]
|
||||
serviceType := reflect.TypeOf(serviceObj)
|
||||
tsServiceName := serviceType.Elem().Name()
|
||||
fmt.Fprintf(&buf, " %q: %sType,\n", serviceName, tsServiceName)
|
||||
}
|
||||
fmt.Fprintf(&buf, "};\n\n")
|
||||
fmt.Fprintf(&buf, "export const AllServiceImpls = {\n")
|
||||
for _, serviceName := range orderedKeys {
|
||||
serviceObj := service.ServiceMap[serviceName]
|
||||
serviceType := reflect.TypeOf(serviceObj)
|
||||
tsServiceName := serviceType.Elem().Name()
|
||||
fmt.Fprintf(&buf, " %q: %s,\n", serviceName, tsServiceName)
|
||||
}
|
||||
fmt.Fprintf(&buf, "};\n")
|
||||
written, err := utilfn.WriteFileIfDifferent(fileName, buf.Bytes())
|
||||
if !written {
|
||||
fmt.Fprintf(os.Stderr, "no changes to %s\n", fileName)
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ export default [
|
|||
{
|
||||
files: ["frontend/app/store/services.ts"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"prefer-rest-params": "off",
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2025, Command Line Inc.
|
||||
// Copyright 2026, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { CopyButton } from "@/app/element/copybutton";
|
||||
|
|
@ -314,11 +314,12 @@ export const WaveStreamdown = ({
|
|||
table: false,
|
||||
mermaid: true,
|
||||
}}
|
||||
mermaidConfig={{
|
||||
theme: "dark",
|
||||
darkMode: true,
|
||||
mermaid={{
|
||||
config: {
|
||||
theme: "dark",
|
||||
darkMode: true,
|
||||
},
|
||||
}}
|
||||
defaultOrigin="http://localhost"
|
||||
components={components}
|
||||
>
|
||||
{text}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
import { RpcApi } from "@/app/store/wshclientapi";
|
||||
import { TabRpcClient } from "@/app/store/wshrpcutil";
|
||||
import { WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv";
|
||||
import { fireAndForget, NullAtom } from "@/util/util";
|
||||
import { atom, Atom, PrimitiveAtom } from "jotai";
|
||||
import { v7 as uuidv7, version as uuidVersion } from "uuid";
|
||||
|
|
@ -10,10 +11,34 @@ import { globalStore } from "./jotaiStore";
|
|||
import * as WOS from "./wos";
|
||||
import { waveEventSubscribeSingle } from "./wps";
|
||||
|
||||
export type BadgeEnv = WaveEnvSubset<{
|
||||
rpc: {
|
||||
EventPublishCommand: WaveEnv["rpc"]["EventPublishCommand"];
|
||||
};
|
||||
}>;
|
||||
|
||||
export type LoadBadgesEnv = WaveEnvSubset<{
|
||||
rpc: {
|
||||
GetAllBadgesCommand: WaveEnv["rpc"]["GetAllBadgesCommand"];
|
||||
};
|
||||
}>;
|
||||
|
||||
export type TabBadgesEnv = WaveEnvSubset<{
|
||||
wos: WaveEnv["wos"];
|
||||
}>;
|
||||
|
||||
const BadgeMap = new Map<string, PrimitiveAtom<Badge>>();
|
||||
const TabBadgeAtomCache = new Map<string, Atom<Badge[]>>();
|
||||
|
||||
function clearBadgeInternal(oref: string) {
|
||||
function publishBadgeEvent(eventData: WaveEvent, env?: BadgeEnv) {
|
||||
if (env != null) {
|
||||
fireAndForget(() => env.rpc.EventPublishCommand(TabRpcClient, eventData));
|
||||
} else {
|
||||
fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData));
|
||||
}
|
||||
}
|
||||
|
||||
function clearBadgeInternal(oref: string, env?: BadgeEnv) {
|
||||
const eventData: WaveEvent = {
|
||||
event: "badge",
|
||||
scopes: [oref],
|
||||
|
|
@ -22,28 +47,28 @@ function clearBadgeInternal(oref: string) {
|
|||
clear: true,
|
||||
} as BadgeEvent,
|
||||
};
|
||||
fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData));
|
||||
publishBadgeEvent(eventData, env);
|
||||
}
|
||||
|
||||
function clearBadgesForBlockOnFocus(blockId: string) {
|
||||
function clearBadgesForBlockOnFocus(blockId: string, env?: BadgeEnv) {
|
||||
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);
|
||||
clearBadgeInternal(oref, env);
|
||||
}
|
||||
}
|
||||
|
||||
function clearBadgesForTabOnFocus(tabId: string) {
|
||||
function clearBadgesForTabOnFocus(tabId: string, env?: BadgeEnv) {
|
||||
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);
|
||||
clearBadgeInternal(oref, env);
|
||||
}
|
||||
}
|
||||
|
||||
function clearAllBadges() {
|
||||
function clearAllBadges(env?: BadgeEnv) {
|
||||
const eventData: WaveEvent = {
|
||||
event: "badge",
|
||||
scopes: [],
|
||||
|
|
@ -52,10 +77,10 @@ function clearAllBadges() {
|
|||
clearall: true,
|
||||
} as BadgeEvent,
|
||||
};
|
||||
fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData));
|
||||
publishBadgeEvent(eventData, env);
|
||||
}
|
||||
|
||||
function clearBadgesForTab(tabId: string) {
|
||||
function clearBadgesForTab(tabId: string, env?: BadgeEnv) {
|
||||
const tabAtom = WOS.getWaveObjectAtom<Tab>(WOS.makeORef("tab", tabId));
|
||||
const tab = globalStore.get(tabAtom);
|
||||
const blockIds = (tab as Tab)?.blockids ?? [];
|
||||
|
|
@ -63,7 +88,7 @@ function clearBadgesForTab(tabId: string) {
|
|||
const oref = WOS.makeORef("block", blockId);
|
||||
const badgeAtom = BadgeMap.get(oref);
|
||||
if (badgeAtom != null && globalStore.get(badgeAtom) != null) {
|
||||
clearBadgeInternal(oref);
|
||||
clearBadgeInternal(oref, env);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -88,7 +113,7 @@ function getBlockBadgeAtom(blockId: string): Atom<Badge> {
|
|||
return getBadgeAtom(oref);
|
||||
}
|
||||
|
||||
function getTabBadgeAtom(tabId: string): Atom<Badge[]> {
|
||||
function getTabBadgeAtom(tabId: string, env?: TabBadgesEnv): Atom<Badge[]> {
|
||||
if (tabId == null) {
|
||||
return NullAtom as Atom<Badge[]>;
|
||||
}
|
||||
|
|
@ -98,7 +123,8 @@ function getTabBadgeAtom(tabId: string): Atom<Badge[]> {
|
|||
}
|
||||
const tabOref = WOS.makeORef("tab", tabId);
|
||||
const tabBadgeAtom = getBadgeAtom(tabOref);
|
||||
const tabAtom = atom((get) => WOS.getObjectValue<Tab>(tabOref, get));
|
||||
const tabAtom =
|
||||
env != null ? env.wos.getWaveObjectAtom<Tab>(tabOref) : WOS.getWaveObjectAtom<Tab>(tabOref);
|
||||
rtn = atom((get) => {
|
||||
const tab = get(tabAtom);
|
||||
const blockIds = tab?.blockids ?? [];
|
||||
|
|
@ -119,8 +145,9 @@ function getTabBadgeAtom(tabId: string): Atom<Badge[]> {
|
|||
return rtn;
|
||||
}
|
||||
|
||||
async function loadBadges() {
|
||||
const badges = await RpcApi.GetAllBadgesCommand(TabRpcClient);
|
||||
async function loadBadges(env?: LoadBadgesEnv) {
|
||||
const rpc = env != null ? env.rpc : RpcApi;
|
||||
const badges = await rpc.GetAllBadgesCommand(TabRpcClient);
|
||||
if (badges == null) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -133,7 +160,7 @@ async function loadBadges() {
|
|||
}
|
||||
}
|
||||
|
||||
function setBadge(blockId: string, badge: Omit<Badge, "badgeid"> & { badgeid?: string }) {
|
||||
function setBadge(blockId: string, badge: Omit<Badge, "badgeid"> & { badgeid?: string }, env?: BadgeEnv) {
|
||||
if (!badge.badgeid) {
|
||||
badge = { ...badge, badgeid: uuidv7() };
|
||||
} else if (uuidVersion(badge.badgeid) !== 7) {
|
||||
|
|
@ -148,10 +175,10 @@ function setBadge(blockId: string, badge: Omit<Badge, "badgeid"> & { badgeid?: s
|
|||
badge: badge,
|
||||
} as BadgeEvent,
|
||||
};
|
||||
fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData));
|
||||
publishBadgeEvent(eventData, env);
|
||||
}
|
||||
|
||||
function clearBadgeById(blockId: string, badgeId: string) {
|
||||
function clearBadgeById(blockId: string, badgeId: string, env?: BadgeEnv) {
|
||||
const oref = WOS.makeORef("block", blockId);
|
||||
const eventData: WaveEvent = {
|
||||
event: "badge",
|
||||
|
|
@ -161,7 +188,7 @@ function clearBadgeById(blockId: string, badgeId: string) {
|
|||
clearbyid: badgeId,
|
||||
} as BadgeEvent,
|
||||
};
|
||||
fireAndForget(() => RpcApi.EventPublishCommand(TabRpcClient, eventData));
|
||||
publishBadgeEvent(eventData, env);
|
||||
}
|
||||
|
||||
function setupBadgesSubscription() {
|
||||
|
|
|
|||
|
|
@ -74,11 +74,11 @@ class ContextMenuModel {
|
|||
this.activeOpts = opts;
|
||||
const electronMenuItems = this._convertAndRegisterMenu(menu);
|
||||
|
||||
const workspace = globalStore.get(atoms.workspace);
|
||||
const workspaceId = globalStore.get(atoms.workspaceId);
|
||||
let oid: string;
|
||||
|
||||
if (workspace != null) {
|
||||
oid = workspace.oid;
|
||||
if (workspaceId != null) {
|
||||
oid = workspaceId;
|
||||
} else {
|
||||
oid = globalStore.get(atoms.builderId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,12 +43,16 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
|
|||
console.log("failed to initialize zoomFactorAtom", e);
|
||||
}
|
||||
|
||||
const workspaceAtom: Atom<Workspace> = atom((get) => {
|
||||
const workspaceIdAtom: Atom<string> = atom((get) => {
|
||||
const windowData = WOS.getObjectValue<WaveWindow>(WOS.makeORef("window", get(windowIdAtom)), get);
|
||||
if (windowData == null) {
|
||||
return windowData?.workspaceid ?? null;
|
||||
});
|
||||
const workspaceAtom: Atom<Workspace> = atom((get) => {
|
||||
const workspaceId = get(workspaceIdAtom);
|
||||
if (workspaceId == null) {
|
||||
return null;
|
||||
}
|
||||
return WOS.getObjectValue(WOS.makeORef("workspace", windowData.workspaceid), get);
|
||||
return WOS.getObjectValue(WOS.makeORef("workspace", workspaceId), get);
|
||||
});
|
||||
const fullConfigAtom = atom(null) as PrimitiveAtom<FullConfigType>;
|
||||
const waveaiModeConfigAtom = atom(null) as PrimitiveAtom<Record<string, AIModeConfigType>>;
|
||||
|
|
@ -67,6 +71,10 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
|
|||
}
|
||||
return false;
|
||||
}) as Atom<boolean>;
|
||||
const hasConfigErrors = atom((get) => {
|
||||
const fullConfig = get(fullConfigAtom);
|
||||
return fullConfig?.configerrors != null && fullConfig.configerrors.length > 0;
|
||||
}) as Atom<boolean>;
|
||||
// this is *the* tab that this tabview represents. it should never change.
|
||||
const staticTabIdAtom: Atom<string> = atom(initOpts.tabId);
|
||||
const controlShiftDelayAtom = atom(false);
|
||||
|
|
@ -123,11 +131,13 @@ function initGlobalAtoms(initOpts: GlobalInitOptions) {
|
|||
builderId: builderIdAtom,
|
||||
builderAppId: builderAppIdAtom,
|
||||
uiContext: uiContextAtom,
|
||||
workspaceId: workspaceIdAtom,
|
||||
workspace: workspaceAtom,
|
||||
fullConfigAtom,
|
||||
waveaiModeConfigAtom,
|
||||
settingsAtom,
|
||||
hasCustomAIPresetsAtom,
|
||||
hasConfigErrors,
|
||||
staticTabId: staticTabIdAtom,
|
||||
isFullScreen: isFullScreenAtom,
|
||||
zoomFactorAtom,
|
||||
|
|
|
|||
|
|
@ -547,6 +547,7 @@ function getAllBlockComponentModels(): BlockComponentModel[] {
|
|||
|
||||
function getFocusedBlockId(): string {
|
||||
const layoutModel = getLayoutModelForStaticTab();
|
||||
if (layoutModel?.focusedNode == null) return null;
|
||||
const focusedLayoutNode = globalStore.get(layoutModel.focusedNode);
|
||||
return focusedLayoutNode?.data?.blockId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2025, Command Line Inc.
|
||||
// Copyright 2026, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { WaveAIModel } from "@/app/aipanel/waveai-model";
|
||||
|
|
@ -129,11 +129,11 @@ function getStaticTabBlockCount(): number {
|
|||
}
|
||||
|
||||
function simpleCloseStaticTab() {
|
||||
const ws = globalStore.get(atoms.workspace);
|
||||
const workspaceId = globalStore.get(atoms.workspaceId);
|
||||
const tabId = globalStore.get(atoms.staticTabId);
|
||||
const confirmClose = globalStore.get(getSettingsKeyAtom("tab:confirmclose")) ?? false;
|
||||
getApi()
|
||||
.closeTab(ws.oid, tabId, confirmClose)
|
||||
.closeTab(workspaceId, tabId, confirmClose)
|
||||
.then((didClose) => {
|
||||
if (didClose) {
|
||||
deleteLayoutModelForTab(tabId);
|
||||
|
|
@ -490,7 +490,7 @@ function tryReinjectKey(event: WaveKeyboardEvent): boolean {
|
|||
function countTermBlocks(): number {
|
||||
const allBCMs = getAllBlockComponentModels();
|
||||
let count = 0;
|
||||
let gsGetBound = globalStore.get.bind(globalStore);
|
||||
const gsGetBound = globalStore.get.bind(globalStore);
|
||||
for (const bcm of allBCMs) {
|
||||
const viewModel = bcm.viewModel;
|
||||
if (viewModel.viewType == "term" && viewModel.isBasicTerm?.(gsGetBound)) {
|
||||
|
|
|
|||
|
|
@ -4,182 +4,233 @@
|
|||
// generated by cmd/generate/main-generatets.go
|
||||
|
||||
import * as WOS from "./wos";
|
||||
import type { WaveEnv } from "@/app/waveenv/waveenv";
|
||||
|
||||
function callBackendService(waveEnv: WaveEnv, service: string, method: string, args: any[], noUIContext?: boolean): Promise<any> {
|
||||
if (waveEnv != null) {
|
||||
return waveEnv.callBackendService(service, method, args, noUIContext)
|
||||
}
|
||||
return WOS.callBackendService(service, method, args, noUIContext);
|
||||
}
|
||||
|
||||
// blockservice.BlockService (block)
|
||||
class BlockServiceType {
|
||||
export class BlockServiceType {
|
||||
waveEnv: WaveEnv;
|
||||
|
||||
constructor(waveEnv?: WaveEnv) {
|
||||
this.waveEnv = waveEnv;
|
||||
}
|
||||
|
||||
// queue a layout action to cleanup orphaned blocks in the tab
|
||||
// @returns object updates
|
||||
CleanupOrphanedBlocks(tabId: string): Promise<void> {
|
||||
return WOS.callBackendService("block", "CleanupOrphanedBlocks", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "block", "CleanupOrphanedBlocks", Array.from(arguments))
|
||||
}
|
||||
GetControllerStatus(arg2: string): Promise<BlockControllerRuntimeStatus> {
|
||||
return WOS.callBackendService("block", "GetControllerStatus", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "block", "GetControllerStatus", Array.from(arguments))
|
||||
}
|
||||
|
||||
// save the terminal state to a blockfile
|
||||
SaveTerminalState(blockId: string, state: string, stateType: string, ptyOffset: number, termSize: TermSize): Promise<void> {
|
||||
return WOS.callBackendService("block", "SaveTerminalState", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "block", "SaveTerminalState", Array.from(arguments))
|
||||
}
|
||||
SaveWaveAiData(arg2: string, arg3: WaveAIPromptMessageType[]): Promise<void> {
|
||||
return WOS.callBackendService("block", "SaveWaveAiData", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "block", "SaveWaveAiData", Array.from(arguments))
|
||||
}
|
||||
}
|
||||
|
||||
export const BlockService = new BlockServiceType();
|
||||
|
||||
// clientservice.ClientService (client)
|
||||
class ClientServiceType {
|
||||
export class ClientServiceType {
|
||||
waveEnv: WaveEnv;
|
||||
|
||||
constructor(waveEnv?: WaveEnv) {
|
||||
this.waveEnv = waveEnv;
|
||||
}
|
||||
|
||||
// @returns object updates
|
||||
AgreeTos(): Promise<void> {
|
||||
return WOS.callBackendService("client", "AgreeTos", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "client", "AgreeTos", Array.from(arguments))
|
||||
}
|
||||
FocusWindow(arg2: string): Promise<void> {
|
||||
return WOS.callBackendService("client", "FocusWindow", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "client", "FocusWindow", Array.from(arguments))
|
||||
}
|
||||
GetAllConnStatus(): Promise<ConnStatus[]> {
|
||||
return WOS.callBackendService("client", "GetAllConnStatus", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "client", "GetAllConnStatus", Array.from(arguments))
|
||||
}
|
||||
GetClientData(): Promise<Client> {
|
||||
return WOS.callBackendService("client", "GetClientData", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "client", "GetClientData", Array.from(arguments))
|
||||
}
|
||||
GetTab(arg1: string): Promise<Tab> {
|
||||
return WOS.callBackendService("client", "GetTab", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "client", "GetTab", Array.from(arguments))
|
||||
}
|
||||
TelemetryUpdate(arg2: boolean): Promise<void> {
|
||||
return WOS.callBackendService("client", "TelemetryUpdate", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "client", "TelemetryUpdate", Array.from(arguments))
|
||||
}
|
||||
}
|
||||
|
||||
export const ClientService = new ClientServiceType();
|
||||
|
||||
// objectservice.ObjectService (object)
|
||||
class ObjectServiceType {
|
||||
export class ObjectServiceType {
|
||||
waveEnv: WaveEnv;
|
||||
|
||||
constructor(waveEnv?: WaveEnv) {
|
||||
this.waveEnv = waveEnv;
|
||||
}
|
||||
|
||||
// @returns blockId (and object updates)
|
||||
CreateBlock(blockDef: BlockDef, rtOpts: RuntimeOpts): Promise<string> {
|
||||
return WOS.callBackendService("object", "CreateBlock", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "object", "CreateBlock", Array.from(arguments))
|
||||
}
|
||||
|
||||
// @returns object updates
|
||||
DeleteBlock(blockId: string): Promise<void> {
|
||||
return WOS.callBackendService("object", "DeleteBlock", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "object", "DeleteBlock", Array.from(arguments))
|
||||
}
|
||||
|
||||
// get wave object by oref
|
||||
GetObject(oref: string): Promise<WaveObj> {
|
||||
return WOS.callBackendService("object", "GetObject", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "object", "GetObject", Array.from(arguments))
|
||||
}
|
||||
|
||||
// @returns objects
|
||||
GetObjects(orefs: string[]): Promise<WaveObj[]> {
|
||||
return WOS.callBackendService("object", "GetObjects", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "object", "GetObjects", Array.from(arguments))
|
||||
}
|
||||
|
||||
// @returns object updates
|
||||
UpdateObject(waveObj: WaveObj, returnUpdates: boolean): Promise<void> {
|
||||
return WOS.callBackendService("object", "UpdateObject", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "object", "UpdateObject", Array.from(arguments))
|
||||
}
|
||||
|
||||
// @returns object updates
|
||||
UpdateObjectMeta(oref: string, meta: MetaType): Promise<void> {
|
||||
return WOS.callBackendService("object", "UpdateObjectMeta", Array.from(arguments))
|
||||
}
|
||||
|
||||
// @returns object updates
|
||||
UpdateTabName(tabId: string, name: string): Promise<void> {
|
||||
return WOS.callBackendService("object", "UpdateTabName", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "object", "UpdateObjectMeta", Array.from(arguments))
|
||||
}
|
||||
}
|
||||
|
||||
export const ObjectService = new ObjectServiceType();
|
||||
|
||||
// userinputservice.UserInputService (userinput)
|
||||
class UserInputServiceType {
|
||||
export class UserInputServiceType {
|
||||
waveEnv: WaveEnv;
|
||||
|
||||
constructor(waveEnv?: WaveEnv) {
|
||||
this.waveEnv = waveEnv;
|
||||
}
|
||||
|
||||
SendUserInputResponse(arg1: UserInputResponse): Promise<void> {
|
||||
return WOS.callBackendService("userinput", "SendUserInputResponse", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "userinput", "SendUserInputResponse", Array.from(arguments))
|
||||
}
|
||||
}
|
||||
|
||||
export const UserInputService = new UserInputServiceType();
|
||||
|
||||
// windowservice.WindowService (window)
|
||||
class WindowServiceType {
|
||||
export class WindowServiceType {
|
||||
waveEnv: WaveEnv;
|
||||
|
||||
constructor(waveEnv?: WaveEnv) {
|
||||
this.waveEnv = waveEnv;
|
||||
}
|
||||
|
||||
CloseWindow(windowId: string, fromElectron: boolean): Promise<void> {
|
||||
return WOS.callBackendService("window", "CloseWindow", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "window", "CloseWindow", Array.from(arguments))
|
||||
}
|
||||
CreateWindow(winSize: WinSize, workspaceId: string): Promise<WaveWindow> {
|
||||
return WOS.callBackendService("window", "CreateWindow", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "window", "CreateWindow", Array.from(arguments))
|
||||
}
|
||||
GetWindow(windowId: string): Promise<WaveWindow> {
|
||||
return WOS.callBackendService("window", "GetWindow", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "window", "GetWindow", Array.from(arguments))
|
||||
}
|
||||
|
||||
// set window position and size
|
||||
// @returns object updates
|
||||
SetWindowPosAndSize(windowId: string, pos: Point, size: WinSize): Promise<void> {
|
||||
return WOS.callBackendService("window", "SetWindowPosAndSize", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "window", "SetWindowPosAndSize", Array.from(arguments))
|
||||
}
|
||||
SwitchWorkspace(windowId: string, workspaceId: string): Promise<Workspace> {
|
||||
return WOS.callBackendService("window", "SwitchWorkspace", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "window", "SwitchWorkspace", Array.from(arguments))
|
||||
}
|
||||
}
|
||||
|
||||
export const WindowService = new WindowServiceType();
|
||||
|
||||
// workspaceservice.WorkspaceService (workspace)
|
||||
class WorkspaceServiceType {
|
||||
export class WorkspaceServiceType {
|
||||
waveEnv: WaveEnv;
|
||||
|
||||
constructor(waveEnv?: WaveEnv) {
|
||||
this.waveEnv = waveEnv;
|
||||
}
|
||||
|
||||
// @returns CloseTabRtn (and object updates)
|
||||
CloseTab(workspaceId: string, tabId: string, fromElectron: boolean): Promise<CloseTabRtnType> {
|
||||
return WOS.callBackendService("workspace", "CloseTab", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "workspace", "CloseTab", Array.from(arguments))
|
||||
}
|
||||
|
||||
// @returns tabId (and object updates)
|
||||
CreateTab(workspaceId: string, tabName: string, activateTab: boolean): Promise<string> {
|
||||
return WOS.callBackendService("workspace", "CreateTab", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "workspace", "CreateTab", Array.from(arguments))
|
||||
}
|
||||
|
||||
// @returns workspaceId
|
||||
CreateWorkspace(name: string, icon: string, color: string, applyDefaults: boolean): Promise<string> {
|
||||
return WOS.callBackendService("workspace", "CreateWorkspace", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "workspace", "CreateWorkspace", Array.from(arguments))
|
||||
}
|
||||
|
||||
// @returns object updates
|
||||
DeleteWorkspace(workspaceId: string): Promise<string> {
|
||||
return WOS.callBackendService("workspace", "DeleteWorkspace", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "workspace", "DeleteWorkspace", Array.from(arguments))
|
||||
}
|
||||
|
||||
// @returns colors
|
||||
GetColors(): Promise<string[]> {
|
||||
return WOS.callBackendService("workspace", "GetColors", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "workspace", "GetColors", Array.from(arguments))
|
||||
}
|
||||
|
||||
// @returns icons
|
||||
GetIcons(): Promise<string[]> {
|
||||
return WOS.callBackendService("workspace", "GetIcons", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "workspace", "GetIcons", Array.from(arguments))
|
||||
}
|
||||
|
||||
// @returns workspace
|
||||
GetWorkspace(workspaceId: string): Promise<Workspace> {
|
||||
return WOS.callBackendService("workspace", "GetWorkspace", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "workspace", "GetWorkspace", Array.from(arguments))
|
||||
}
|
||||
ListWorkspaces(): Promise<WorkspaceListEntry[]> {
|
||||
return WOS.callBackendService("workspace", "ListWorkspaces", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "workspace", "ListWorkspaces", Array.from(arguments))
|
||||
}
|
||||
|
||||
// @returns object updates
|
||||
SetActiveTab(workspaceId: string, tabId: string): Promise<void> {
|
||||
return WOS.callBackendService("workspace", "SetActiveTab", Array.from(arguments))
|
||||
}
|
||||
|
||||
// @returns object updates
|
||||
UpdateTabIds(workspaceId: string, tabIds: string[]): Promise<void> {
|
||||
return WOS.callBackendService("workspace", "UpdateTabIds", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "workspace", "SetActiveTab", Array.from(arguments))
|
||||
}
|
||||
|
||||
// @returns object updates
|
||||
UpdateWorkspace(workspaceId: string, name: string, icon: string, color: string, applyDefaults: boolean): Promise<void> {
|
||||
return WOS.callBackendService("workspace", "UpdateWorkspace", Array.from(arguments))
|
||||
return callBackendService(this.waveEnv, "workspace", "UpdateWorkspace", Array.from(arguments))
|
||||
}
|
||||
}
|
||||
|
||||
export const WorkspaceService = new WorkspaceServiceType();
|
||||
|
||||
export const AllServiceTypes = {
|
||||
"block": BlockServiceType,
|
||||
"client": ClientServiceType,
|
||||
"object": ObjectServiceType,
|
||||
"userinput": UserInputServiceType,
|
||||
"window": WindowServiceType,
|
||||
"workspace": WorkspaceServiceType,
|
||||
};
|
||||
|
||||
export const AllServiceImpls = {
|
||||
"block": BlockService,
|
||||
"client": ClientService,
|
||||
"object": ObjectService,
|
||||
"userinput": UserInputService,
|
||||
"window": WindowService,
|
||||
"workspace": WorkspaceService,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,24 +1,28 @@
|
|||
// Copyright 2026, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { useWaveEnv, WaveEnv } from "@/app/waveenv/waveenv";
|
||||
import { WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv";
|
||||
import { atom, Atom, PrimitiveAtom } from "jotai";
|
||||
import { createContext, useContext } from "react";
|
||||
import { globalStore } from "./jotaiStore";
|
||||
import * as WOS from "./wos";
|
||||
|
||||
export type TabModelEnv = WaveEnvSubset<{
|
||||
wos: WaveEnv["wos"];
|
||||
}>;
|
||||
|
||||
const tabModelCache = new Map<string, TabModel>();
|
||||
export const activeTabIdAtom = atom<string>(null) as PrimitiveAtom<string>;
|
||||
|
||||
export class TabModel {
|
||||
tabId: string;
|
||||
waveEnv: WaveEnv;
|
||||
waveEnv: TabModelEnv;
|
||||
tabAtom: Atom<Tab>;
|
||||
tabNumBlocksAtom: Atom<number>;
|
||||
isTermMultiInput = atom(false) as PrimitiveAtom<boolean>;
|
||||
metaCache: Map<string, Atom<any>> = new Map();
|
||||
|
||||
constructor(tabId: string, waveEnv?: WaveEnv) {
|
||||
constructor(tabId: string, waveEnv?: TabModelEnv) {
|
||||
this.tabId = tabId;
|
||||
this.waveEnv = waveEnv;
|
||||
this.tabAtom = atom((get) => {
|
||||
|
|
@ -46,16 +50,25 @@ export class TabModel {
|
|||
}
|
||||
}
|
||||
|
||||
export function getTabModelByTabId(tabId: string, waveEnv?: WaveEnv): TabModel {
|
||||
let model = tabModelCache.get(tabId);
|
||||
export function getTabModelByTabId(tabId: string, waveEnv?: TabModelEnv): TabModel {
|
||||
if (!waveEnv?.isMock) {
|
||||
let model = tabModelCache.get(tabId);
|
||||
if (model == null) {
|
||||
model = new TabModel(tabId, waveEnv);
|
||||
tabModelCache.set(tabId, model);
|
||||
}
|
||||
return model;
|
||||
}
|
||||
const key = `TabModel:${tabId}`;
|
||||
let model = waveEnv.mockModels.get(key);
|
||||
if (model == null) {
|
||||
model = new TabModel(tabId, waveEnv);
|
||||
tabModelCache.set(tabId, model);
|
||||
waveEnv.mockModels.set(key, model);
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
export function getActiveTabModel(waveEnv?: WaveEnv): TabModel | null {
|
||||
export function getActiveTabModel(waveEnv?: TabModelEnv): TabModel | null {
|
||||
const activeTabId = globalStore.get(activeTabIdAtom);
|
||||
if (activeTabId == null) {
|
||||
return null;
|
||||
|
|
@ -66,11 +79,7 @@ export function getActiveTabModel(waveEnv?: WaveEnv): TabModel | null {
|
|||
export const TabModelContext = createContext<TabModel | undefined>(undefined);
|
||||
|
||||
export function useTabModel(): TabModel {
|
||||
const waveEnv = useWaveEnv();
|
||||
const ctxModel = useContext(TabModelContext);
|
||||
if (waveEnv?.mockTabModel != null) {
|
||||
return waveEnv.mockTabModel;
|
||||
}
|
||||
if (ctxModel == null) {
|
||||
throw new Error("useTabModel must be used within a TabModelProvider");
|
||||
}
|
||||
|
|
@ -78,10 +87,5 @@ export function useTabModel(): TabModel {
|
|||
}
|
||||
|
||||
export function useTabModelMaybe(): TabModel {
|
||||
const waveEnv = useWaveEnv();
|
||||
const ctxModel = useContext(TabModelContext);
|
||||
if (waveEnv?.mockTabModel != null) {
|
||||
return waveEnv.mockTabModel;
|
||||
}
|
||||
return ctxModel;
|
||||
return useContext(TabModelContext);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
// Copyright 2025, Command Line Inc.
|
||||
// Copyright 2026, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { WshClient } from "@/app/store/wshclient";
|
||||
import { RpcApi } from "@/app/store/wshclientapi";
|
||||
import { isPreviewWindow } from "@/app/store/windowtype";
|
||||
import { isBlank } from "@/util/util";
|
||||
import { Subject } from "rxjs";
|
||||
|
||||
|
|
@ -43,6 +44,9 @@ function wpsReconnectHandler() {
|
|||
}
|
||||
|
||||
function updateWaveEventSub(eventType: string) {
|
||||
if (isPreviewWindow()) {
|
||||
return;
|
||||
}
|
||||
const subjects = waveEventSubjects.get(eventType);
|
||||
if (subjects == null) {
|
||||
RpcApi.EventUnsubCommand(WpsRpcClient, eventType, { noresponse: true });
|
||||
|
|
@ -84,7 +88,7 @@ function waveEventSubscribeSingle<T extends WaveEventName>(subscription: WaveEve
|
|||
function waveEventUnsubscribe(...unsubscribes: WaveEventUnsubscribe[]) {
|
||||
const eventTypeSet = new Set<string>();
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
let subjects = waveEventSubjects.get(unsubscribe.eventType);
|
||||
const subjects = waveEventSubjects.get(unsubscribe.eventType);
|
||||
if (subjects == null) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -930,6 +930,18 @@ export class RpcApiType {
|
|||
return client.wshRpcCall("testmultiarg", { args: [arg1, arg2, arg3] }, opts);
|
||||
}
|
||||
|
||||
// command "updatetabname" [call]
|
||||
UpdateTabNameCommand(client: WshClient, arg1: string, arg2: string, opts?: RpcOpts): Promise<void> {
|
||||
if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "updatetabname", { args: [arg1, arg2] }, opts);
|
||||
return client.wshRpcCall("updatetabname", { args: [arg1, arg2] }, opts);
|
||||
}
|
||||
|
||||
// command "updateworkspacetabids" [call]
|
||||
UpdateWorkspaceTabIdsCommand(client: WshClient, arg1: string, arg2: string[], opts?: RpcOpts): Promise<void> {
|
||||
if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "updateworkspacetabids", { args: [arg1, arg2] }, opts);
|
||||
return client.wshRpcCall("updateworkspacetabids", { args: [arg1, arg2] }, opts);
|
||||
}
|
||||
|
||||
// command "vdomasyncinitiation" [call]
|
||||
VDomAsyncInitiationCommand(client: WshClient, data: VDomAsyncInitiationRequest, opts?: RpcOpts): Promise<void> {
|
||||
if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "vdomasyncinitiation", data, opts);
|
||||
|
|
|
|||
|
|
@ -2,21 +2,32 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { getTabBadgeAtom, sortBadgesForTab } from "@/app/store/badge";
|
||||
import { atoms, getOrefMetaKeyAtom, globalStore, recordTEvent, refocusNode } from "@/app/store/global";
|
||||
import { RpcApi } from "@/app/store/wshclientapi";
|
||||
import { getOrefMetaKeyAtom, globalStore, recordTEvent, refocusNode } from "@/app/store/global";
|
||||
import { TabRpcClient } from "@/app/store/wshrpcutil";
|
||||
import { WaveEnv, WaveEnvSubset, useWaveEnv } from "@/app/waveenv/waveenv";
|
||||
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, useMemo, useRef, useState } from "react";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import { ObjectService } from "../store/services";
|
||||
import { makeORef, useWaveObjectValue } from "../store/wos";
|
||||
import { makeORef } from "../store/wos";
|
||||
import "./tab.scss";
|
||||
|
||||
type TabEnv = WaveEnvSubset<{
|
||||
rpc: {
|
||||
ActivityCommand: WaveEnv["rpc"]["ActivityCommand"];
|
||||
SetMetaCommand: WaveEnv["rpc"]["SetMetaCommand"];
|
||||
UpdateTabNameCommand: WaveEnv["rpc"]["UpdateTabNameCommand"];
|
||||
};
|
||||
atoms: {
|
||||
fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"];
|
||||
};
|
||||
wos: WaveEnv["wos"];
|
||||
showContextMenu: WaveEnv["showContextMenu"];
|
||||
}>;
|
||||
|
||||
interface TabVProps {
|
||||
tabId: string;
|
||||
tabName: string;
|
||||
|
|
@ -95,7 +106,10 @@ const TabV = forwardRef<HTMLDivElement, TabVProps>((props, ref) => {
|
|||
onRename,
|
||||
renameRef,
|
||||
} = props;
|
||||
const [originalName, setOriginalName] = useState(tabName);
|
||||
const MaxTabNameLength = 14;
|
||||
const truncateTabName = (name: string) => [...(name ?? "")].slice(0, MaxTabNameLength).join("");
|
||||
const displayName = truncateTabName(tabName);
|
||||
const [originalName, setOriginalName] = useState(displayName);
|
||||
const [isEditable, setIsEditable] = useState(false);
|
||||
|
||||
const editableRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -105,7 +119,7 @@ const TabV = forwardRef<HTMLDivElement, TabVProps>((props, ref) => {
|
|||
useImperativeHandle(ref, () => tabRef.current as HTMLDivElement);
|
||||
|
||||
useEffect(() => {
|
||||
setOriginalName(tabName);
|
||||
setOriginalName(truncateTabName(tabName));
|
||||
}, [tabName]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -181,8 +195,11 @@ const TabV = forwardRef<HTMLDivElement, TabVProps>((props, ref) => {
|
|||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
} else if (curLen >= 14 && !["Backspace", "Delete", "ArrowLeft", "ArrowRight"].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.isCollapsed) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -222,7 +239,7 @@ const TabV = forwardRef<HTMLDivElement, TabVProps>((props, ref) => {
|
|||
onKeyDown={handleKeyDown}
|
||||
suppressContentEditableWarning={true}
|
||||
>
|
||||
{tabName}
|
||||
{displayName}
|
||||
</div>
|
||||
<TabBadges badges={badges} flagColor={flagColor} />
|
||||
<Button
|
||||
|
|
@ -253,7 +270,8 @@ const FlagColors: { label: string; value: string }[] = [
|
|||
function buildTabContextMenu(
|
||||
id: string,
|
||||
renameRef: React.RefObject<(() => void) | null>,
|
||||
onClose: (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null) => void
|
||||
onClose: (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null) => void,
|
||||
env: TabEnv
|
||||
): ContextMenuItem[] {
|
||||
const menu: ContextMenuItem[] = [];
|
||||
menu.push(
|
||||
|
|
@ -271,17 +289,23 @@ function buildTabContextMenu(
|
|||
label: "None",
|
||||
type: "checkbox",
|
||||
checked: currentFlagColor == null,
|
||||
click: () => fireAndForget(() => ObjectService.UpdateObjectMeta(tabORef, { "tab:flagcolor": null })),
|
||||
click: () =>
|
||||
fireAndForget(() =>
|
||||
env.rpc.SetMetaCommand(TabRpcClient, { oref: tabORef, meta: { "tab:flagcolor": null } })
|
||||
),
|
||||
},
|
||||
...FlagColors.map((fc) => ({
|
||||
label: fc.label,
|
||||
type: "checkbox" as const,
|
||||
checked: currentFlagColor === fc.value,
|
||||
click: () => fireAndForget(() => ObjectService.UpdateObjectMeta(tabORef, { "tab:flagcolor": fc.value })),
|
||||
click: () =>
|
||||
fireAndForget(() =>
|
||||
env.rpc.SetMetaCommand(TabRpcClient, { oref: tabORef, meta: { "tab:flagcolor": fc.value } })
|
||||
),
|
||||
})),
|
||||
];
|
||||
menu.push({ label: "Flag Tab", type: "submenu", submenu: flagSubmenu }, { type: "separator" });
|
||||
const fullConfig = globalStore.get(atoms.fullConfigAtom);
|
||||
const fullConfig = globalStore.get(env.atoms.fullConfigAtom);
|
||||
const bgPresets: string[] = [];
|
||||
for (const key in fullConfig?.presets ?? {}) {
|
||||
if (key.startsWith("bg@") && fullConfig.presets[key] != null) {
|
||||
|
|
@ -303,8 +327,8 @@ function buildTabContextMenu(
|
|||
label: preset["display:name"] ?? presetName,
|
||||
click: () =>
|
||||
fireAndForget(async () => {
|
||||
await ObjectService.UpdateObjectMeta(oref, preset);
|
||||
RpcApi.ActivityCommand(TabRpcClient, { settabtheme: 1 }, { noresponse: true });
|
||||
await env.rpc.SetMetaCommand(TabRpcClient, { oref, meta: preset });
|
||||
env.rpc.ActivityCommand(TabRpcClient, { settabtheme: 1 }, { noresponse: true });
|
||||
recordTEvent("action:settabtheme");
|
||||
}),
|
||||
});
|
||||
|
|
@ -330,8 +354,9 @@ interface TabProps {
|
|||
|
||||
const TabInner = forwardRef<HTMLDivElement, TabProps>((props, ref) => {
|
||||
const { id, active, showDivider, isDragging, tabWidth, isNew, onLoaded, onSelect, onClose, onDragStart } = props;
|
||||
const [tabData, _] = useWaveObjectValue<Tab>(makeORef("tab", id));
|
||||
const badges = useAtomValue(getTabBadgeAtom(id));
|
||||
const env = useWaveEnv<TabEnv>();
|
||||
const [tabData, _] = env.wos.useWaveObjectValue<Tab>(makeORef("tab", id));
|
||||
const badges = useAtomValue(getTabBadgeAtom(id, env));
|
||||
|
||||
const rawFlagColor = tabData?.meta?.["tab:flagcolor"];
|
||||
let flagColor: string | null = null;
|
||||
|
|
@ -361,18 +386,18 @@ const TabInner = forwardRef<HTMLDivElement, TabProps>((props, ref) => {
|
|||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
e.preventDefault();
|
||||
const menu = buildTabContextMenu(id, renameRef, onClose);
|
||||
ContextMenuModel.getInstance().showContextMenu(menu, e);
|
||||
const menu = buildTabContextMenu(id, renameRef, onClose, env);
|
||||
env.showContextMenu(menu, e);
|
||||
},
|
||||
[id, onClose]
|
||||
[id, onClose, env]
|
||||
);
|
||||
|
||||
const handleRename = useCallback(
|
||||
(newName: string) => {
|
||||
fireAndForget(() => ObjectService.UpdateTabName(id, newName));
|
||||
fireAndForget(() => env.rpc.UpdateTabNameCommand(TabRpcClient, id, newName));
|
||||
setTimeout(() => refocusNode(null), 10);
|
||||
},
|
||||
[id]
|
||||
[id, env]
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -40,15 +40,6 @@
|
|||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.tab-bar-right {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.add-tab {
|
||||
padding: 0 10px;
|
||||
height: 27px;
|
||||
|
|
|
|||
|
|
@ -1,22 +1,20 @@
|
|||
// Copyright 2026, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Button } from "@/app/element/button";
|
||||
import { Tooltip } from "@/app/element/tooltip";
|
||||
import { modalsModel } from "@/app/store/modalmodel";
|
||||
import { TabRpcClient } from "@/app/store/wshrpcutil";
|
||||
import { useWaveEnv } from "@/app/waveenv/waveenv";
|
||||
import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model";
|
||||
import { deleteLayoutModelForTab } from "@/layout/index";
|
||||
import { atoms, createTab, getApi, getSettingsKeyAtom, globalStore, setActiveTab } from "@/store/global";
|
||||
import { isMacOS, isWindows } from "@/util/platformutil";
|
||||
import { fireAndForget } from "@/util/util";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { OverlayScrollbars } from "overlayscrollbars";
|
||||
import { createRef, memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { debounce } from "throttle-debounce";
|
||||
import { IconButton } from "../element/iconbutton";
|
||||
import { WorkspaceService } from "../store/services";
|
||||
import { Tab } from "./tab";
|
||||
import "./tabbar.scss";
|
||||
import { TabBarEnv } from "./tabbarenv";
|
||||
import { UpdateStatusBanner } from "./updatebanner";
|
||||
import { WorkspaceSwitcher } from "./workspaceswitcher";
|
||||
|
||||
|
|
@ -44,8 +42,9 @@ interface TabBarProps {
|
|||
}
|
||||
|
||||
const WaveAIButton = memo(({ divRef }: { divRef?: React.RefObject<HTMLDivElement> }) => {
|
||||
const env = useWaveEnv<TabBarEnv>();
|
||||
const aiPanelOpen = useAtomValue(WorkspaceLayoutModel.getInstance().panelVisibleAtom);
|
||||
const hideAiButton = useAtomValue(getSettingsKeyAtom("app:hideaibutton"));
|
||||
const hideAiButton = useAtomValue(env.getSettingsKeyAtom("app:hideaibutton"));
|
||||
|
||||
const onClick = () => {
|
||||
const currentVisible = WorkspaceLayoutModel.getInstance().getAIPanelVisible();
|
||||
|
|
@ -73,7 +72,8 @@ const WaveAIButton = memo(({ divRef }: { divRef?: React.RefObject<HTMLDivElement
|
|||
WaveAIButton.displayName = "WaveAIButton";
|
||||
|
||||
const ConfigErrorMessage = () => {
|
||||
const fullConfig = useAtomValue(atoms.fullConfigAtom);
|
||||
const env = useWaveEnv<TabBarEnv>();
|
||||
const fullConfig = useAtomValue(env.atoms.fullConfigAtom);
|
||||
|
||||
if (fullConfig?.configerrors == null || fullConfig?.configerrors.length == 0) {
|
||||
return (
|
||||
|
|
@ -108,25 +108,28 @@ const ConfigErrorMessage = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const ConfigErrorIcon = ({ buttonRef }: { buttonRef: React.RefObject<HTMLElement> }) => {
|
||||
const fullConfig = useAtomValue(atoms.fullConfigAtom);
|
||||
const ConfigErrorIcon = () => {
|
||||
const env = useWaveEnv<TabBarEnv>();
|
||||
const hasConfigErrors = useAtomValue(env.atoms.hasConfigErrors);
|
||||
|
||||
function handleClick() {
|
||||
const handleClick = useCallback(() => {
|
||||
modalsModel.pushModal("MessageModal", { children: <ConfigErrorMessage /> });
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (fullConfig?.configerrors == null || fullConfig?.configerrors.length == 0) {
|
||||
if (!hasConfigErrors) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
ref={buttonRef as React.RefObject<HTMLButtonElement>}
|
||||
className="text-black flex-[0_0_fit-content] !h-full !px-3 red"
|
||||
onClick={handleClick}
|
||||
<Tooltip
|
||||
content="Configuration Error"
|
||||
placement="bottom"
|
||||
hideOnClick
|
||||
divClassName="flex h-[22px] px-2 mb-1 items-center rounded-md box-border cursor-pointer hover:bg-hoverbg transition-colors text-[12px] text-error"
|
||||
divStyle={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
divOnClick={handleClick}
|
||||
>
|
||||
<i className="fa fa-solid fa-exclamation-triangle" />
|
||||
Config Error
|
||||
</Button>
|
||||
<i className="fa fa-solid fa-triangle-exclamation" />
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -150,6 +153,7 @@ function strArrayIsEqual(a: string[], b: string[]) {
|
|||
}
|
||||
|
||||
const TabBar = memo(({ workspace }: TabBarProps) => {
|
||||
const env = useWaveEnv<TabBarEnv>();
|
||||
const [tabIds, setTabIds] = useState<string[]>([]);
|
||||
const [dragStartPositions, setDragStartPositions] = useState<number[]>([]);
|
||||
const [draggingTab, setDraggingTab] = useState<string>();
|
||||
|
|
@ -174,19 +178,21 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
|||
});
|
||||
const osInstanceRef = useRef<OverlayScrollbars>(null);
|
||||
const draggerLeftRef = useRef<HTMLDivElement>(null);
|
||||
const draggerRightRef = useRef<HTMLDivElement>(null);
|
||||
const rightContainerRef = useRef<HTMLDivElement>(null);
|
||||
const workspaceSwitcherRef = useRef<HTMLDivElement>(null);
|
||||
const waveAIButtonRef = useRef<HTMLDivElement>(null);
|
||||
const appMenuButtonRef = useRef<HTMLDivElement>(null);
|
||||
const tabWidthRef = useRef<number>(TabDefaultWidth);
|
||||
const scrollableRef = useRef<boolean>(false);
|
||||
const updateStatusBannerRef = useRef<HTMLButtonElement>(null);
|
||||
const configErrorButtonRef = useRef<HTMLElement>(null);
|
||||
const prevAllLoadedRef = useRef<boolean>(false);
|
||||
const activeTabId = useAtomValue(atoms.staticTabId);
|
||||
const isFullScreen = useAtomValue(atoms.isFullScreen);
|
||||
const zoomFactor = useAtomValue(atoms.zoomFactorAtom);
|
||||
const settings = useAtomValue(atoms.settingsAtom);
|
||||
const activeTabId = useAtomValue(env.atoms.staticTabId);
|
||||
const isFullScreen = useAtomValue(env.atoms.isFullScreen);
|
||||
const zoomFactor = useAtomValue(env.atoms.zoomFactorAtom);
|
||||
const showMenuBar = useAtomValue(env.getSettingsKeyAtom("window:showmenubar"));
|
||||
const confirmClose = useAtomValue(env.getSettingsKeyAtom("tab:confirmclose")) ?? false;
|
||||
const hideAiButton = useAtomValue(env.getSettingsKeyAtom("app:hideaibutton"));
|
||||
const appUpdateStatus = useAtomValue(env.atoms.updaterStatusAtom);
|
||||
const hasConfigErrors = useAtomValue(env.atoms.hasConfigErrors);
|
||||
|
||||
let prevDelta: number;
|
||||
let prevDragDirection: string;
|
||||
|
|
@ -230,22 +236,24 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
|||
const tabBar = tabBarRef.current;
|
||||
if (tabBar === null) return;
|
||||
|
||||
const getOuterWidth = (el: HTMLElement): number => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const style = getComputedStyle(el);
|
||||
return rect.width + parseFloat(style.marginLeft) + parseFloat(style.marginRight);
|
||||
};
|
||||
|
||||
const tabbarWrapperWidth = tabbarWrapperRef.current.getBoundingClientRect().width;
|
||||
const windowDragLeftWidth = draggerLeftRef.current.getBoundingClientRect().width;
|
||||
const windowDragRightWidth = draggerRightRef.current?.getBoundingClientRect().width ?? 0;
|
||||
const addBtnWidth = addBtnRef.current.getBoundingClientRect().width;
|
||||
const updateStatusLabelWidth = updateStatusBannerRef.current?.getBoundingClientRect().width ?? 0;
|
||||
const configErrorWidth = configErrorButtonRef.current?.getBoundingClientRect().width ?? 0;
|
||||
const rightContainerWidth = rightContainerRef.current?.getBoundingClientRect().width ?? 0;
|
||||
const addBtnWidth = getOuterWidth(addBtnRef.current);
|
||||
const appMenuButtonWidth = appMenuButtonRef.current?.getBoundingClientRect().width ?? 0;
|
||||
const workspaceSwitcherWidth = workspaceSwitcherRef.current?.getBoundingClientRect().width ?? 0;
|
||||
const waveAIButtonWidth = waveAIButtonRef.current?.getBoundingClientRect().width ?? 0;
|
||||
const waveAIButtonWidth = waveAIButtonRef.current != null ? getOuterWidth(waveAIButtonRef.current) : 0;
|
||||
|
||||
const nonTabElementsWidth =
|
||||
windowDragLeftWidth +
|
||||
windowDragRightWidth +
|
||||
rightContainerWidth +
|
||||
addBtnWidth +
|
||||
updateStatusLabelWidth +
|
||||
configErrorWidth +
|
||||
appMenuButtonWidth +
|
||||
workspaceSwitcherWidth +
|
||||
waveAIButtonWidth;
|
||||
|
|
@ -306,20 +314,23 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
|||
saveTabsPositionDebounced();
|
||||
}, [tabIds, newTabId, isFullScreen]);
|
||||
|
||||
const reinitVersion = useAtomValue(atoms.reinitVersion);
|
||||
// update layout on reinit version
|
||||
const reinitVersion = useAtomValue(env.atoms.reinitVersion);
|
||||
useEffect(() => {
|
||||
if (reinitVersion > 0) {
|
||||
setSizeAndPosition();
|
||||
}
|
||||
}, [reinitVersion]);
|
||||
|
||||
// update layout on resize
|
||||
useEffect(() => {
|
||||
window.addEventListener("resize", () => handleResizeTabs());
|
||||
window.addEventListener("resize", handleResizeTabs);
|
||||
return () => {
|
||||
window.removeEventListener("resize", () => handleResizeTabs());
|
||||
window.removeEventListener("resize", handleResizeTabs);
|
||||
};
|
||||
}, [handleResizeTabs]);
|
||||
|
||||
// update layout on changed tabIds, tabsLoaded, newTabId, hideAiButton, appUpdateStatus, hasConfigErrors, or zoomFactor
|
||||
useEffect(() => {
|
||||
// Check if all tabs are loaded
|
||||
const allLoaded = tabIds.length > 0 && tabIds.every((id) => tabsLoaded[id]);
|
||||
|
|
@ -330,7 +341,17 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
|||
prevAllLoadedRef.current = true;
|
||||
}
|
||||
}
|
||||
}, [tabIds, tabsLoaded, newTabId, saveTabsPosition]);
|
||||
}, [
|
||||
tabIds,
|
||||
tabsLoaded,
|
||||
newTabId,
|
||||
saveTabsPosition,
|
||||
hideAiButton,
|
||||
appUpdateStatus,
|
||||
hasConfigErrors,
|
||||
zoomFactor,
|
||||
showMenuBar,
|
||||
]);
|
||||
|
||||
const getDragDirection = (currentX: number) => {
|
||||
let dragDirection: string;
|
||||
|
|
@ -483,7 +504,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
|||
// Reset dragging state
|
||||
setDraggingTab(null);
|
||||
// Update workspace tab ids
|
||||
fireAndForget(() => WorkspaceService.UpdateTabIds(workspace.oid, tabIds));
|
||||
fireAndForget(() => env.rpc.UpdateWorkspaceTabIdsCommand(TabRpcClient, workspace.oid, tabIds));
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
|
@ -547,7 +568,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
|||
|
||||
const handleSelectTab = (tabId: string) => {
|
||||
if (!draggingTabDataRef.current.dragged) {
|
||||
setActiveTab(tabId);
|
||||
env.electron.setActiveTab(tabId);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -569,7 +590,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
|||
);
|
||||
|
||||
const handleAddTab = () => {
|
||||
createTab();
|
||||
env.electron.createTab();
|
||||
tabsWrapperRef.current.style.setProperty("--tabs-wrapper-transition", "width 0.1s ease");
|
||||
|
||||
updateScrollDebounced();
|
||||
|
|
@ -579,10 +600,8 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
|||
|
||||
const handleCloseTab = (event: React.MouseEvent<HTMLButtonElement, MouseEvent> | null, tabId: string) => {
|
||||
event?.stopPropagation();
|
||||
const ws = globalStore.get(atoms.workspace);
|
||||
const confirmClose = globalStore.get(getSettingsKeyAtom("tab:confirmclose")) ?? false;
|
||||
getApi()
|
||||
.closeTab(ws.oid, tabId, confirmClose)
|
||||
env.electron
|
||||
.closeTab(workspace.oid, tabId, confirmClose)
|
||||
.then((didClose) => {
|
||||
if (didClose) {
|
||||
tabsWrapperRef.current?.style.setProperty("--tabs-wrapper-transition", "width 0.3s ease");
|
||||
|
|
@ -607,15 +626,15 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
|||
const activeTabIndex = tabIds.indexOf(activeTabId);
|
||||
|
||||
function onEllipsisClick() {
|
||||
getApi().showWorkspaceAppMenu(workspace.oid);
|
||||
env.electron.showWorkspaceAppMenu(workspace.oid);
|
||||
}
|
||||
|
||||
const tabsWrapperWidth = tabIds.length * tabWidthRef.current;
|
||||
const showAppMenuButton = isWindows() || (!isMacOS() && !settings["window:showmenubar"]);
|
||||
const showAppMenuButton = env.isWindows() || (!env.isMacOS() && !showMenuBar);
|
||||
|
||||
// Calculate window drag left width based on platform and state
|
||||
let windowDragLeftWidth = 10;
|
||||
if (isMacOS() && !isFullScreen) {
|
||||
if (env.isMacOS() && !isFullScreen) {
|
||||
if (zoomFactor > 0) {
|
||||
windowDragLeftWidth = 74 / zoomFactor;
|
||||
} else {
|
||||
|
|
@ -625,7 +644,7 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
|||
|
||||
// Calculate window drag right width
|
||||
let windowDragRightWidth = 12;
|
||||
if (isWindows()) {
|
||||
if (env.isWindows()) {
|
||||
if (zoomFactor > 0) {
|
||||
windowDragRightWidth = 139 / zoomFactor;
|
||||
} else {
|
||||
|
|
@ -633,12 +652,6 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
|||
}
|
||||
}
|
||||
|
||||
const addtabButtonDecl: IconButtonDecl = {
|
||||
elemtype: "iconbutton",
|
||||
icon: "plus",
|
||||
click: handleAddTab,
|
||||
title: "Add Tab",
|
||||
};
|
||||
return (
|
||||
<div ref={tabbarWrapperRef} className="tab-bar-wrapper">
|
||||
<div
|
||||
|
|
@ -690,12 +703,20 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
|||
})}
|
||||
</div>
|
||||
</div>
|
||||
<IconButton className="add-tab" ref={addBtnRef} decl={addtabButtonDecl} />
|
||||
<div className="tab-bar-right">
|
||||
<UpdateStatusBanner ref={updateStatusBannerRef} />
|
||||
<ConfigErrorIcon buttonRef={configErrorButtonRef} />
|
||||
<button
|
||||
ref={addBtnRef}
|
||||
title="Add Tab"
|
||||
className="flex h-[22px] px-2 mb-1 mx-1 items-center rounded-md box-border cursor-pointer hover:bg-hoverbg transition-colors text-[12px] text-secondary hover:text-primary"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
onClick={handleAddTab}
|
||||
>
|
||||
<i className="fa fa-solid fa-plus" />
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<div ref={rightContainerRef} className="flex flex-row gap-1 items-end">
|
||||
<UpdateStatusBanner />
|
||||
<ConfigErrorIcon />
|
||||
<div
|
||||
ref={draggerRightRef}
|
||||
className="h-full shrink-0 z-window-drag"
|
||||
style={{ width: windowDragRightWidth, WebkitAppRegion: "drag" } as any}
|
||||
/>
|
||||
|
|
@ -704,4 +725,4 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
|
|||
);
|
||||
});
|
||||
|
||||
export { TabBar };
|
||||
export { ConfigErrorIcon, ConfigErrorMessage, TabBar, WaveAIButton };
|
||||
|
|
|
|||
31
frontend/app/tab/tabbarenv.ts
Normal file
31
frontend/app/tab/tabbarenv.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// Copyright 2026, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { SettingsKeyAtomFnType, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv";
|
||||
|
||||
export type TabBarEnv = WaveEnvSubset<{
|
||||
electron: {
|
||||
createTab: WaveEnv["electron"]["createTab"];
|
||||
closeTab: WaveEnv["electron"]["closeTab"];
|
||||
setActiveTab: WaveEnv["electron"]["setActiveTab"];
|
||||
showWorkspaceAppMenu: WaveEnv["electron"]["showWorkspaceAppMenu"];
|
||||
installAppUpdate: WaveEnv["electron"]["installAppUpdate"];
|
||||
};
|
||||
rpc: {
|
||||
UpdateWorkspaceTabIdsCommand: WaveEnv["rpc"]["UpdateWorkspaceTabIdsCommand"];
|
||||
};
|
||||
atoms: {
|
||||
fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"];
|
||||
hasConfigErrors: WaveEnv["atoms"]["hasConfigErrors"];
|
||||
staticTabId: WaveEnv["atoms"]["staticTabId"];
|
||||
isFullScreen: WaveEnv["atoms"]["isFullScreen"];
|
||||
zoomFactorAtom: WaveEnv["atoms"]["zoomFactorAtom"];
|
||||
reinitVersion: WaveEnv["atoms"]["reinitVersion"];
|
||||
updaterStatusAtom: WaveEnv["atoms"]["updaterStatusAtom"];
|
||||
};
|
||||
wos: WaveEnv["wos"];
|
||||
getSettingsKeyAtom: SettingsKeyAtomFnType<"app:hideaibutton" | "tab:confirmclose" | "window:showmenubar">;
|
||||
mockSetWaveObj: WaveEnv["mockSetWaveObj"];
|
||||
isWindows: WaveEnv["isWindows"];
|
||||
isMacOS: WaveEnv["isMacOS"];
|
||||
}>;
|
||||
|
|
@ -1,69 +1,54 @@
|
|||
import { Button } from "@/element/button";
|
||||
import { atoms, getApi } from "@/store/global";
|
||||
// Copyright 2026, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { Tooltip } from "@/element/tooltip";
|
||||
import { useWaveEnv } from "@/app/waveenv/waveenv";
|
||||
import { TabBarEnv } from "./tabbarenv";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { forwardRef, memo, useEffect, useState } from "react";
|
||||
import { memo, useCallback } from "react";
|
||||
|
||||
const UpdateStatusBannerComponent = forwardRef<HTMLButtonElement>((_, ref) => {
|
||||
let appUpdateStatus = useAtomValue(atoms.updaterStatusAtom);
|
||||
let [updateStatusMessage, setUpdateStatusMessage] = useState<string>();
|
||||
const [dismissBannerTimeout, setDismissBannerTimeout] = useState<NodeJS.Timeout>();
|
||||
|
||||
useEffect(() => {
|
||||
let message: string;
|
||||
let dismissBanner = false;
|
||||
switch (appUpdateStatus) {
|
||||
case "ready":
|
||||
message = "Update Available";
|
||||
break;
|
||||
case "downloading":
|
||||
message = "Downloading Update";
|
||||
break;
|
||||
case "installing":
|
||||
message = "Installing Update";
|
||||
break;
|
||||
case "error":
|
||||
message = "Updater Error: Try Checking Again";
|
||||
dismissBanner = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
setUpdateStatusMessage(message);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (dismissBannerTimeout) {
|
||||
clearTimeout(dismissBannerTimeout);
|
||||
}
|
||||
|
||||
// If we want to dismiss the banner, set the new timeout, otherwise clear the state
|
||||
if (dismissBanner) {
|
||||
setDismissBannerTimeout(
|
||||
setTimeout(() => {
|
||||
setUpdateStatusMessage(null);
|
||||
setDismissBannerTimeout(null);
|
||||
}, 10000)
|
||||
);
|
||||
} else {
|
||||
setDismissBannerTimeout(null);
|
||||
}
|
||||
}, [appUpdateStatus]);
|
||||
|
||||
function onClick() {
|
||||
getApi().installAppUpdate();
|
||||
function getUpdateStatusMessage(status: string): string {
|
||||
switch (status) {
|
||||
case "ready":
|
||||
return "Update";
|
||||
case "downloading":
|
||||
return "Downloading";
|
||||
case "installing":
|
||||
return "Installing";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
if (updateStatusMessage) {
|
||||
return (
|
||||
<Button
|
||||
className="text-black bg-[var(--accent-color)] flex-[0_0_fit-content] !h-full !px-3 disabled:!opacity-[unset]"
|
||||
title={appUpdateStatus === "ready" ? "Click to Install Update" : updateStatusMessage}
|
||||
onClick={onClick}
|
||||
disabled={appUpdateStatus !== "ready"}
|
||||
ref={ref}
|
||||
>
|
||||
{updateStatusMessage}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const UpdateStatusBanner = memo(UpdateStatusBannerComponent) as typeof UpdateStatusBannerComponent;
|
||||
const UpdateStatusBannerComponent = () => {
|
||||
const env = useWaveEnv<TabBarEnv>();
|
||||
const appUpdateStatus = useAtomValue(env.atoms.updaterStatusAtom);
|
||||
const updateStatusMessage = getUpdateStatusMessage(appUpdateStatus);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
env.electron.installAppUpdate();
|
||||
}, [env]);
|
||||
|
||||
if (!updateStatusMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isReady = appUpdateStatus === "ready";
|
||||
const tooltipContent = isReady ? "Click to Install Update" : updateStatusMessage;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={tooltipContent}
|
||||
placement="bottom"
|
||||
divOnClick={isReady ? onClick : undefined}
|
||||
divClassName={`flex items-center gap-1 px-2 mb-1 h-[22px] text-xs font-medium text-black bg-accent rounded-sm transition-all ${isReady ? "cursor-pointer hover:bg-[var(--button-green-border-color)]" : ""}`}
|
||||
divStyle={{ WebkitAppRegion: "no-drag" } as any}
|
||||
>
|
||||
<i className="fa fa-download" />
|
||||
{updateStatusMessage}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
UpdateStatusBannerComponent.displayName = "UpdateStatusBannerComponent";
|
||||
|
||||
export const UpdateStatusBanner = memo(UpdateStatusBannerComponent);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright 2025, Command Line
|
||||
// Copyright 2026, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { useWaveEnv, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv";
|
||||
import {
|
||||
ExpandableMenu,
|
||||
ExpandableMenuItem,
|
||||
|
|
@ -18,13 +19,27 @@ 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 { globalStore } from "@/app/store/jotaiStore";
|
||||
import { makeORef } from "../store/wos";
|
||||
import { waveEventSubscribeSingle } from "../store/wps";
|
||||
import { WorkspaceEditor } from "./workspaceeditor";
|
||||
import "./workspaceswitcher.scss";
|
||||
|
||||
export type WorkspaceSwitcherEnv = WaveEnvSubset<{
|
||||
electron: {
|
||||
deleteWorkspace: WaveEnv["electron"]["deleteWorkspace"];
|
||||
createWorkspace: WaveEnv["electron"]["createWorkspace"];
|
||||
switchWorkspace: WaveEnv["electron"]["switchWorkspace"];
|
||||
};
|
||||
atoms: {
|
||||
workspace: WaveEnv["atoms"]["workspace"];
|
||||
};
|
||||
services: {
|
||||
workspace: WaveEnv["services"]["workspace"];
|
||||
};
|
||||
wos: WaveEnv["wos"];
|
||||
}>;
|
||||
|
||||
type WorkspaceListEntry = {
|
||||
windowId: string;
|
||||
workspace: Workspace;
|
||||
|
|
@ -35,23 +50,24 @@ const workspaceMapAtom = atom<WorkspaceList>([]);
|
|||
const workspaceSplitAtom = splitAtom(workspaceMapAtom);
|
||||
const editingWorkspaceAtom = atom<string>();
|
||||
const WorkspaceSwitcher = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const env = useWaveEnv<WorkspaceSwitcherEnv>();
|
||||
const setWorkspaceList = useSetAtom(workspaceMapAtom);
|
||||
const activeWorkspace = useAtomValueSafe(atoms.workspace);
|
||||
const activeWorkspace = useAtomValueSafe(env.atoms.workspace);
|
||||
const workspaceList = useAtomValue(workspaceSplitAtom);
|
||||
const setEditingWorkspace = useSetAtom(editingWorkspaceAtom);
|
||||
|
||||
const updateWorkspaceList = useCallback(async () => {
|
||||
const workspaceList = await WorkspaceService.ListWorkspaces();
|
||||
const workspaceList = await env.services.workspace.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));
|
||||
globalStore.get(env.wos.getWaveObjectAtom(makeORef("workspace", entry.workspaceid)));
|
||||
newList.push({
|
||||
windowId: entry.windowid,
|
||||
workspace: await WorkspaceService.GetWorkspace(entry.workspaceid),
|
||||
workspace: await env.services.workspace.GetWorkspace(entry.workspaceid),
|
||||
});
|
||||
}
|
||||
setWorkspaceList(newList);
|
||||
|
|
@ -71,7 +87,7 @@ const WorkspaceSwitcher = forwardRef<HTMLDivElement>((_, ref) => {
|
|||
}, []);
|
||||
|
||||
const onDeleteWorkspace = useCallback((workspaceId: string) => {
|
||||
getApi().deleteWorkspace(workspaceId);
|
||||
env.electron.deleteWorkspace(workspaceId);
|
||||
}, []);
|
||||
|
||||
const isActiveWorkspaceSaved = !!(activeWorkspace.name && activeWorkspace.icon);
|
||||
|
|
@ -84,7 +100,7 @@ const WorkspaceSwitcher = forwardRef<HTMLDivElement>((_, ref) => {
|
|||
|
||||
const saveWorkspace = () => {
|
||||
fireAndForget(async () => {
|
||||
await WorkspaceService.UpdateWorkspace(activeWorkspace.oid, "", "", "", true);
|
||||
await env.services.workspace.UpdateWorkspace(activeWorkspace.oid, "", "", "", true);
|
||||
await updateWorkspaceList();
|
||||
setEditingWorkspace(activeWorkspace.oid);
|
||||
});
|
||||
|
|
@ -118,7 +134,7 @@ const WorkspaceSwitcher = forwardRef<HTMLDivElement>((_, ref) => {
|
|||
|
||||
<div className="actions">
|
||||
{isActiveWorkspaceSaved ? (
|
||||
<ExpandableMenuItem onClick={() => getApi().createWorkspace()}>
|
||||
<ExpandableMenuItem onClick={() => env.electron.createWorkspace()}>
|
||||
<ExpandableMenuItemLeftElement>
|
||||
<i className="fa-sharp fa-solid fa-plus"></i>
|
||||
</ExpandableMenuItemLeftElement>
|
||||
|
|
@ -145,7 +161,8 @@ const WorkspaceSwitcherItem = ({
|
|||
entryAtom: PrimitiveAtom<WorkspaceListEntry>;
|
||||
onDeleteWorkspace: (workspaceId: string) => void;
|
||||
}) => {
|
||||
const activeWorkspace = useAtomValueSafe(atoms.workspace);
|
||||
const env = useWaveEnv<WorkspaceSwitcherEnv>();
|
||||
const activeWorkspace = useAtomValueSafe(env.atoms.workspace);
|
||||
const [workspaceEntry, setWorkspaceEntry] = useAtom(entryAtom);
|
||||
const [editingWorkspace, setEditingWorkspace] = useAtom(editingWorkspaceAtom);
|
||||
|
||||
|
|
@ -156,7 +173,7 @@ const WorkspaceSwitcherItem = ({
|
|||
setWorkspaceEntry({ ...workspaceEntry, workspace: newWorkspace });
|
||||
if (newWorkspace.name != "") {
|
||||
fireAndForget(() =>
|
||||
WorkspaceService.UpdateWorkspace(
|
||||
env.services.workspace.UpdateWorkspace(
|
||||
workspace.oid,
|
||||
newWorkspace.name,
|
||||
newWorkspace.icon,
|
||||
|
|
@ -200,7 +217,7 @@ const WorkspaceSwitcherItem = ({
|
|||
>
|
||||
<ExpandableMenuItemGroupTitle
|
||||
onClick={() => {
|
||||
getApi().switchWorkspace(workspace.oid);
|
||||
env.electron.switchWorkspace(workspace.oid);
|
||||
// Create a fake escape key event to close the popover
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ import * as React from "react";
|
|||
import { useDimensionsWithExistingRef } from "@/app/hook/useDimensions";
|
||||
import { waveEventSubscribeSingle } from "@/app/store/wps";
|
||||
import { TabRpcClient } from "@/app/store/wshrpcutil";
|
||||
import type { BlockMetaKeyAtomFnType, WaveEnv } from "@/app/waveenv/waveenv";
|
||||
import type { BlockMetaKeyAtomFnType, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv";
|
||||
import { OverlayScrollbarsComponent, OverlayScrollbarsComponentRef } from "overlayscrollbars-react";
|
||||
|
||||
export type SysinfoEnv = {
|
||||
export type SysinfoEnv = WaveEnvSubset<{
|
||||
rpc: {
|
||||
EventReadHistoryCommand: WaveEnv["rpc"]["EventReadHistoryCommand"];
|
||||
SetMetaCommand: WaveEnv["rpc"]["SetMetaCommand"];
|
||||
|
|
@ -27,7 +27,7 @@ export type SysinfoEnv = {
|
|||
};
|
||||
getConnStatusAtom: WaveEnv["getConnStatusAtom"];
|
||||
getBlockMetaKeyAtom: BlockMetaKeyAtomFnType<"graph:numpoints" | "sysinfo:type" | "connection" | "count">;
|
||||
};
|
||||
}>;
|
||||
|
||||
const DefaultNumPoints = 120;
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import {
|
|||
recordTEvent,
|
||||
WOS,
|
||||
} from "@/store/global";
|
||||
import * as services from "@/store/services";
|
||||
import { base64ToString, fireAndForget, isSshConnName, isWslConnName } from "@/util/util";
|
||||
import debug from "debug";
|
||||
import type { TermWrap } from "./termwrap";
|
||||
|
|
@ -243,8 +242,9 @@ export function handleOsc7Command(data: string, blockId: string, loaded: boolean
|
|||
|
||||
setTimeout(() => {
|
||||
fireAndForget(async () => {
|
||||
await services.ObjectService.UpdateObjectMeta(WOS.makeORef("block", blockId), {
|
||||
"cmd:cwd": pathPart,
|
||||
await RpcApi.SetMetaCommand(TabRpcClient, {
|
||||
oref: WOS.makeORef("block", blockId),
|
||||
meta: { "cmd:cwd": pathPart },
|
||||
});
|
||||
|
||||
const rtInfo = { "shell:hascurcwd": true };
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright 2026, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { TabModel } from "@/app/store/tab-model";
|
||||
import type { AllServiceImpls } from "@/app/store/services";
|
||||
import { RpcApiType } from "@/app/store/wshclientapi";
|
||||
import { Atom, PrimitiveAtom } from "jotai";
|
||||
import React from "react";
|
||||
|
|
@ -33,18 +33,27 @@ type ComplexWaveEnvKeys = {
|
|||
electron: WaveEnv["electron"];
|
||||
atoms: WaveEnv["atoms"];
|
||||
wos: WaveEnv["wos"];
|
||||
services: WaveEnv["services"];
|
||||
};
|
||||
|
||||
export type WaveEnvSubset<T> = OmitNever<{
|
||||
[K in keyof T]: K extends keyof ComplexWaveEnvKeys
|
||||
? Subset<T[K], ComplexWaveEnvKeys[K]>
|
||||
: K extends keyof WaveEnv
|
||||
? T[K]
|
||||
: never;
|
||||
}>;
|
||||
type WaveEnvMockFields = {
|
||||
isMock: WaveEnv["isMock"];
|
||||
mockSetWaveObj: WaveEnv["mockSetWaveObj"];
|
||||
mockModels: WaveEnv["mockModels"];
|
||||
};
|
||||
|
||||
export type WaveEnvSubset<T> = WaveEnvMockFields &
|
||||
OmitNever<{
|
||||
[K in keyof T]: K extends keyof ComplexWaveEnvKeys
|
||||
? Subset<T[K], ComplexWaveEnvKeys[K]>
|
||||
: K extends keyof WaveEnv
|
||||
? T[K]
|
||||
: never;
|
||||
}>;
|
||||
|
||||
// default implementation for production is in ./waveenvimpl.ts
|
||||
export type WaveEnv = {
|
||||
isMock: boolean;
|
||||
electron: ElectronApi;
|
||||
rpc: RpcApiType;
|
||||
platform: NodeJS.Platform;
|
||||
|
|
@ -53,6 +62,8 @@ export type WaveEnv = {
|
|||
isMacOS: () => boolean;
|
||||
atoms: GlobalAtomsType;
|
||||
createBlock: (blockDef: BlockDef, magnified?: boolean, ephemeral?: boolean) => Promise<string>;
|
||||
services: typeof AllServiceImpls;
|
||||
callBackendService: (service: string, method: string, args: any[], noUIContext?: boolean) => Promise<any>;
|
||||
showContextMenu: (menu: ContextMenuItem[], e: React.MouseEvent) => void;
|
||||
getConnStatusAtom: (conn: string) => PrimitiveAtom<ConnStatus>;
|
||||
getLocalHostDisplayNameAtom: () => Atom<string>;
|
||||
|
|
@ -65,7 +76,10 @@ export type WaveEnv = {
|
|||
getSettingsKeyAtom: SettingsKeyAtomFnType;
|
||||
getBlockMetaKeyAtom: BlockMetaKeyAtomFnType;
|
||||
getConnConfigKeyAtom: ConnConfigKeyAtomFnType;
|
||||
mockTabModel?: TabModel;
|
||||
|
||||
// the mock fields are only usable in the preview server (may be be null or throw errors in production)
|
||||
mockSetWaveObj: <T extends WaveObj>(oref: string, obj: T) => void;
|
||||
mockModels: Map<any, any>;
|
||||
};
|
||||
|
||||
export const WaveEnvContext = React.createContext<WaveEnv>(null);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { ContextMenuModel } from "@/app/store/contextmenu";
|
||||
import { AllServiceImpls } from "@/app/store/services";
|
||||
import {
|
||||
atoms,
|
||||
createBlock,
|
||||
|
|
@ -19,6 +20,7 @@ import { isMacOS, isWindows, PLATFORM } from "@/util/platformutil";
|
|||
|
||||
export function makeWaveEnvImpl(): WaveEnv {
|
||||
return {
|
||||
isMock: false,
|
||||
electron: (window as any).api,
|
||||
rpc: RpcApi,
|
||||
getSettingsKeyAtom,
|
||||
|
|
@ -28,6 +30,8 @@ export function makeWaveEnvImpl(): WaveEnv {
|
|||
isMacOS,
|
||||
atoms,
|
||||
createBlock,
|
||||
services: AllServiceImpls,
|
||||
callBackendService: WOS.callBackendService,
|
||||
showContextMenu: (menu: ContextMenuItem[], e: React.MouseEvent) => {
|
||||
ContextMenuModel.getInstance().showContextMenu(menu, e);
|
||||
},
|
||||
|
|
@ -41,5 +45,10 @@ export function makeWaveEnvImpl(): WaveEnv {
|
|||
},
|
||||
getBlockMetaKeyAtom,
|
||||
getConnConfigKeyAtom,
|
||||
|
||||
mockSetWaveObj: <T extends WaveObj>(_oref: string, _obj: T) => {
|
||||
throw new Error("mockSetWaveObj is only available in the preview server");
|
||||
},
|
||||
mockModels: new Map<any, any>(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export type WidgetsEnv = WaveEnvSubset<{
|
|||
};
|
||||
atoms: {
|
||||
fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"];
|
||||
workspace: WaveEnv["atoms"]["workspace"];
|
||||
workspaceId: WaveEnv["atoms"]["workspaceId"];
|
||||
hasCustomAIPresetsAtom: WaveEnv["atoms"]["hasCustomAIPresetsAtom"];
|
||||
};
|
||||
createBlock: WaveEnv["createBlock"];
|
||||
|
|
@ -348,7 +348,7 @@ SettingsFloatingWindow.displayName = "SettingsFloatingWindow";
|
|||
const Widgets = memo(() => {
|
||||
const env = useWaveEnv<WidgetsEnv>();
|
||||
const fullConfig = useAtomValue(env.atoms.fullConfigAtom);
|
||||
const workspace = useAtomValue(env.atoms.workspace);
|
||||
const workspaceId = useAtomValue(env.atoms.workspaceId);
|
||||
const hasCustomAIPresets = useAtomValue(env.atoms.hasCustomAIPresetsAtom);
|
||||
const [mode, setMode] = useState<"normal" | "compact" | "supercompact">("normal");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -361,7 +361,7 @@ const Widgets = memo(() => {
|
|||
if (!hasCustomAIPresets && key === "defwidget@ai") {
|
||||
return false;
|
||||
}
|
||||
return shouldIncludeWidgetForWorkspace(widget, workspace?.oid);
|
||||
return shouldIncludeWidgetForWorkspace(widget, workspaceId);
|
||||
})
|
||||
);
|
||||
const widgets = sortByDisplayOrder(filteredWidgets);
|
||||
|
|
|
|||
|
|
@ -2,16 +2,42 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { makeDefaultConnStatus } from "@/app/store/global";
|
||||
import { TabModel } from "@/app/store/tab-model";
|
||||
import { globalStore } from "@/app/store/jotaiStore";
|
||||
import { AllServiceTypes } from "@/app/store/services";
|
||||
import { handleWaveEvent } from "@/app/store/wps";
|
||||
import { RpcApiType } from "@/app/store/wshclientapi";
|
||||
import { WaveEnv } from "@/app/waveenv/waveenv";
|
||||
import { PlatformMacOS, PlatformWindows } from "@/util/platformutil";
|
||||
import { Atom, atom, PrimitiveAtom } from "jotai";
|
||||
import { Atom, atom, PrimitiveAtom, useAtomValue } from "jotai";
|
||||
import { DefaultFullConfig } from "./defaultconfig";
|
||||
import { previewElectronApi } from "./preview-electron-api";
|
||||
|
||||
// What works "out of the box" in the mock environment (no MockEnv overrides needed):
|
||||
//
|
||||
// RPC calls (handled in makeMockRpc):
|
||||
// - rpc.EventPublishCommand -- dispatches to handleWaveEvent(); works when the subscriber
|
||||
// is purely FE-based (registered via WPS on the frontend)
|
||||
// - rpc.GetMetaCommand -- reads .meta from the mock WOS atom for the given oref
|
||||
// - rpc.SetMetaCommand -- writes .meta to the mock WOS atom (null values delete keys)
|
||||
// - rpc.UpdateTabNameCommand -- updates .name on the Tab WaveObj in the mock WOS
|
||||
// - rpc.UpdateWorkspaceTabIdsCommand -- updates .tabids on the Workspace WaveObj in the mock WOS
|
||||
//
|
||||
// Any other RPC call falls through to a console.log and resolves null.
|
||||
// Override specific calls via MockEnv.rpc (keys are the Command method names, e.g. "GetMetaCommand").
|
||||
//
|
||||
// Backend service calls (handled in callBackendService):
|
||||
// Any call falls through to a console.log and resolves null.
|
||||
// Override specific calls via MockEnv.services: { Service: { Method: impl } }
|
||||
// e.g. { "block": { "GetControllerStatus": (blockId) => myStatus } }
|
||||
|
||||
type RpcOverrides = {
|
||||
[K in keyof RpcApiType as K extends `${string}Command` ? K : never]?: (...args: any[]) => any;
|
||||
[K in keyof RpcApiType as K extends `${string}Command` ? K : never]?: (...args: any[]) => Promise<any>;
|
||||
};
|
||||
|
||||
type ServiceOverrides = {
|
||||
[Service: string]: {
|
||||
[Method: string]: (...args: any[]) => Promise<any>;
|
||||
};
|
||||
};
|
||||
|
||||
export type MockEnv = {
|
||||
|
|
@ -20,6 +46,7 @@ export type MockEnv = {
|
|||
platform?: NodeJS.Platform;
|
||||
settings?: Partial<SettingsType>;
|
||||
rpc?: RpcOverrides;
|
||||
services?: ServiceOverrides;
|
||||
atoms?: Partial<GlobalAtomsType>;
|
||||
electron?: Partial<ElectronApi>;
|
||||
createBlock?: WaveEnv["createBlock"];
|
||||
|
|
@ -38,12 +65,23 @@ function mergeRecords<T>(base: Record<string, T>, overrides: Record<string, T>):
|
|||
}
|
||||
|
||||
export function mergeMockEnv(base: MockEnv, overrides: MockEnv): MockEnv {
|
||||
let mergedServices: ServiceOverrides;
|
||||
if (base.services != null || overrides.services != null) {
|
||||
mergedServices = {};
|
||||
for (const svc of Object.keys(base.services ?? {})) {
|
||||
mergedServices[svc] = { ...(base.services[svc] ?? {}) };
|
||||
}
|
||||
for (const svc of Object.keys(overrides.services ?? {})) {
|
||||
mergedServices[svc] = { ...(mergedServices[svc] ?? {}), ...(overrides.services[svc] ?? {}) };
|
||||
}
|
||||
}
|
||||
return {
|
||||
isDev: overrides.isDev ?? base.isDev,
|
||||
tabId: overrides.tabId ?? base.tabId,
|
||||
platform: overrides.platform ?? base.platform,
|
||||
settings: mergeRecords(base.settings, overrides.settings),
|
||||
rpc: mergeRecords(base.rpc as any, overrides.rpc as any) as RpcOverrides,
|
||||
services: mergedServices,
|
||||
atoms: overrides.atoms != null || base.atoms != null ? { ...base.atoms, ...overrides.atoms } : undefined,
|
||||
electron:
|
||||
overrides.electron != null || base.electron != null
|
||||
|
|
@ -73,9 +111,10 @@ function makeMockSettingsKeyAtom(
|
|||
}
|
||||
|
||||
function makeMockGlobalAtoms(
|
||||
settingsOverrides?: Partial<SettingsType>,
|
||||
atomOverrides?: Partial<GlobalAtomsType>,
|
||||
tabId?: string
|
||||
settingsOverrides: Partial<SettingsType>,
|
||||
atomOverrides: Partial<GlobalAtomsType>,
|
||||
tabId: string,
|
||||
getWaveObjectAtom: <T extends WaveObj>(oref: string) => PrimitiveAtom<T>
|
||||
): GlobalAtomsType {
|
||||
let fullConfig = DefaultFullConfig;
|
||||
if (settingsOverrides) {
|
||||
|
|
@ -86,15 +125,28 @@ function makeMockGlobalAtoms(
|
|||
}
|
||||
const fullConfigAtom = atom(fullConfig) as PrimitiveAtom<FullConfigType>;
|
||||
const settingsAtom = atom((get) => get(fullConfigAtom)?.settings ?? {}) as Atom<SettingsType>;
|
||||
const workspaceIdAtom: Atom<string> = atomOverrides?.workspaceId ?? (atom(null as string) as Atom<string>);
|
||||
const workspaceAtom: Atom<Workspace> = atom((get) => {
|
||||
const wsId = get(workspaceIdAtom);
|
||||
if (wsId == null) {
|
||||
return null;
|
||||
}
|
||||
return get(getWaveObjectAtom<Workspace>("workspace:" + wsId));
|
||||
});
|
||||
const defaults: GlobalAtomsType = {
|
||||
builderId: atom(""),
|
||||
builderAppId: atom("") as any,
|
||||
uiContext: atom({ windowid: "", activetabid: tabId ?? "" } as UIContext),
|
||||
workspace: atom(null as Workspace),
|
||||
workspaceId: workspaceIdAtom,
|
||||
workspace: workspaceAtom,
|
||||
fullConfigAtom,
|
||||
waveaiModeConfigAtom: atom({}) as any,
|
||||
settingsAtom,
|
||||
hasCustomAIPresetsAtom: atom(false),
|
||||
hasConfigErrors: atom((get) => {
|
||||
const c = get(fullConfigAtom);
|
||||
return c?.configerrors != null && c.configerrors.length > 0;
|
||||
}),
|
||||
staticTabId: atom(tabId ?? ""),
|
||||
isFullScreen: atom(false) as any,
|
||||
zoomFactorAtom: atom(1.0) as any,
|
||||
|
|
@ -110,15 +162,67 @@ function makeMockGlobalAtoms(
|
|||
if (!atomOverrides) {
|
||||
return defaults;
|
||||
}
|
||||
return { ...defaults, ...atomOverrides };
|
||||
const merged = { ...defaults, ...atomOverrides };
|
||||
if (!atomOverrides.workspace) {
|
||||
merged.workspace = workspaceAtom;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function makeMockRpc(overrides?: RpcOverrides): RpcApiType {
|
||||
const dispatchMap = new Map<string, (...args: any[]) => any>();
|
||||
type MockWosFns = {
|
||||
getWaveObjectAtom: <T extends WaveObj>(oref: string) => PrimitiveAtom<T>;
|
||||
mockSetWaveObj: <T extends WaveObj>(oref: string, obj: T) => void;
|
||||
};
|
||||
|
||||
export function makeMockRpc(overrides: RpcOverrides, wos: MockWosFns): RpcApiType {
|
||||
const dispatchMap = new Map<string, (...args: any[]) => Promise<any>>();
|
||||
dispatchMap.set("eventpublish", async (_client, data: WaveEvent) => {
|
||||
console.log("[mock eventpublish]", data);
|
||||
handleWaveEvent(data);
|
||||
return null;
|
||||
});
|
||||
dispatchMap.set("getmeta", async (_client, data: CommandGetMetaData) => {
|
||||
const objAtom = wos.getWaveObjectAtom(data.oref);
|
||||
const current = globalStore.get(objAtom) as WaveObj & { meta?: MetaType };
|
||||
return current?.meta ?? {};
|
||||
});
|
||||
dispatchMap.set("setmeta", async (_client, data: CommandSetMetaData) => {
|
||||
const objAtom = wos.getWaveObjectAtom(data.oref);
|
||||
const current = globalStore.get(objAtom) as WaveObj & { meta?: MetaType };
|
||||
const updatedMeta = { ...(current?.meta ?? {}) };
|
||||
for (const [key, value] of Object.entries(data.meta)) {
|
||||
if (value === null) {
|
||||
delete updatedMeta[key];
|
||||
} else {
|
||||
(updatedMeta as any)[key] = value;
|
||||
}
|
||||
}
|
||||
const updated = { ...current, meta: updatedMeta };
|
||||
wos.mockSetWaveObj(data.oref, updated);
|
||||
return null;
|
||||
});
|
||||
dispatchMap.set("updatetabname", async (_client, data: { args: [string, string] }) => {
|
||||
const [tabId, newName] = data.args;
|
||||
const tabORef = "tab:" + tabId;
|
||||
const objAtom = wos.getWaveObjectAtom(tabORef);
|
||||
const current = globalStore.get(objAtom) as Tab;
|
||||
const updated = { ...current, name: newName };
|
||||
wos.mockSetWaveObj(tabORef, updated);
|
||||
return null;
|
||||
});
|
||||
dispatchMap.set("updateworkspacetabids", async (_client, data: { args: [string, string[]] }) => {
|
||||
const [workspaceId, tabIds] = data.args;
|
||||
const wsORef = "workspace:" + workspaceId;
|
||||
const objAtom = wos.getWaveObjectAtom(wsORef);
|
||||
const current = globalStore.get(objAtom) as Workspace;
|
||||
const updated = { ...current, tabids: tabIds };
|
||||
wos.mockSetWaveObj(wsORef, updated);
|
||||
return null;
|
||||
});
|
||||
if (overrides) {
|
||||
for (const key of Object.keys(overrides) as (keyof RpcOverrides)[]) {
|
||||
const cmdName = key.slice(0, -"Command".length).toLowerCase();
|
||||
dispatchMap.set(cmdName, overrides[key] as (...args: any[]) => any);
|
||||
dispatchMap.set(cmdName, overrides[key] as (...args: any[]) => Promise<any>);
|
||||
}
|
||||
}
|
||||
const rpc = new RpcApiType();
|
||||
|
|
@ -134,7 +238,7 @@ export function makeMockRpc(overrides?: RpcOverrides): RpcApiType {
|
|||
async *mockWshRpcStream(_client, command, data, _opts) {
|
||||
const fn = dispatchMap.get(command);
|
||||
if (fn) {
|
||||
yield* fn(_client, data, _opts);
|
||||
yield await fn(_client, data, _opts);
|
||||
return;
|
||||
}
|
||||
console.log("[mock rpc stream]", command, data);
|
||||
|
|
@ -154,10 +258,18 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv {
|
|||
const overrides: MockEnv = mockEnv ?? {};
|
||||
const platform = overrides.platform ?? PlatformMacOS;
|
||||
const connStatusAtomCache = new Map<string, PrimitiveAtom<ConnStatus>>();
|
||||
const waveObjectAtomCache = new Map<string, Atom<any>>();
|
||||
const waveObjectValueAtomCache = new Map<string, PrimitiveAtom<any>>();
|
||||
const waveObjectDerivedAtomCache = new Map<string, Atom<any>>();
|
||||
const blockMetaKeyAtomCache = new Map<string, Atom<any>>();
|
||||
const connConfigKeyAtomCache = new Map<string, Atom<any>>();
|
||||
const atoms = makeMockGlobalAtoms(overrides.settings, overrides.atoms, overrides.tabId);
|
||||
const getWaveObjectAtom = <T extends WaveObj>(oref: string): PrimitiveAtom<T> => {
|
||||
if (!waveObjectValueAtomCache.has(oref)) {
|
||||
const obj = (overrides.mockWaveObjs?.[oref] ?? null) as T;
|
||||
waveObjectValueAtomCache.set(oref, atom(obj) as PrimitiveAtom<T>);
|
||||
}
|
||||
return waveObjectValueAtomCache.get(oref) as PrimitiveAtom<T>;
|
||||
};
|
||||
const atoms = makeMockGlobalAtoms(overrides.settings, overrides.atoms, overrides.tabId, getWaveObjectAtom);
|
||||
const localHostDisplayNameAtom = atom<string>((get) => {
|
||||
const configValue = get(atoms.settingsAtom)?.["conn:localhostdisplayname"];
|
||||
if (configValue != null) {
|
||||
|
|
@ -165,14 +277,24 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv {
|
|||
}
|
||||
return "user@localhost";
|
||||
});
|
||||
const mockWosFns: MockWosFns = {
|
||||
getWaveObjectAtom,
|
||||
mockSetWaveObj: <T extends WaveObj>(oref: string, obj: T) => {
|
||||
if (!waveObjectValueAtomCache.has(oref)) {
|
||||
waveObjectValueAtomCache.set(oref, atom(null as WaveObj));
|
||||
}
|
||||
globalStore.set(waveObjectValueAtomCache.get(oref), obj);
|
||||
},
|
||||
};
|
||||
const env = {
|
||||
isMock: true,
|
||||
mockEnv: overrides,
|
||||
electron: {
|
||||
...previewElectronApi,
|
||||
getPlatform: () => platform,
|
||||
...overrides.electron,
|
||||
},
|
||||
rpc: makeMockRpc(overrides.rpc),
|
||||
rpc: makeMockRpc(overrides.rpc, mockWosFns),
|
||||
atoms,
|
||||
getSettingsKeyAtom: makeMockSettingsKeyAtom(atoms.settingsAtom, overrides.settings),
|
||||
platform,
|
||||
|
|
@ -201,34 +323,27 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv {
|
|||
return connStatusAtomCache.get(conn);
|
||||
},
|
||||
wos: {
|
||||
getWaveObjectAtom: <T extends WaveObj>(oref: string) => {
|
||||
const cacheKey = oref + ":value";
|
||||
if (!waveObjectAtomCache.has(cacheKey)) {
|
||||
const obj = (overrides.mockWaveObjs?.[oref] ?? null) as T;
|
||||
waveObjectAtomCache.set(cacheKey, atom(obj));
|
||||
}
|
||||
return waveObjectAtomCache.get(cacheKey) as PrimitiveAtom<T>;
|
||||
},
|
||||
getWaveObjectAtom: mockWosFns.getWaveObjectAtom,
|
||||
getWaveObjectLoadingAtom: (oref: string) => {
|
||||
const cacheKey = oref + ":loading";
|
||||
if (!waveObjectAtomCache.has(cacheKey)) {
|
||||
waveObjectAtomCache.set(cacheKey, atom(false));
|
||||
if (!waveObjectDerivedAtomCache.has(cacheKey)) {
|
||||
waveObjectDerivedAtomCache.set(cacheKey, atom(false));
|
||||
}
|
||||
return waveObjectAtomCache.get(cacheKey) as Atom<boolean>;
|
||||
return waveObjectDerivedAtomCache.get(cacheKey) as Atom<boolean>;
|
||||
},
|
||||
isWaveObjectNullAtom: (oref: string) => {
|
||||
const cacheKey = oref + ":isnull";
|
||||
if (!waveObjectAtomCache.has(cacheKey)) {
|
||||
waveObjectAtomCache.set(
|
||||
if (!waveObjectDerivedAtomCache.has(cacheKey)) {
|
||||
waveObjectDerivedAtomCache.set(
|
||||
cacheKey,
|
||||
atom((get) => get(env.wos.getWaveObjectAtom(oref)) == null)
|
||||
);
|
||||
}
|
||||
return waveObjectAtomCache.get(cacheKey) as Atom<boolean>;
|
||||
return waveObjectDerivedAtomCache.get(cacheKey) as Atom<boolean>;
|
||||
},
|
||||
useWaveObjectValue: <T extends WaveObj>(oref: string): [T, boolean] => {
|
||||
const obj = (overrides.mockWaveObjs?.[oref] ?? null) as T;
|
||||
return [obj, false];
|
||||
const objAtom = env.wos.getWaveObjectAtom<T>(oref);
|
||||
return [useAtomValue(objAtom), false];
|
||||
},
|
||||
},
|
||||
getBlockMetaKeyAtom: <T extends keyof MetaType>(blockId: string, key: T) => {
|
||||
|
|
@ -255,10 +370,20 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv {
|
|||
}
|
||||
return connConfigKeyAtomCache.get(cacheKey) as Atom<ConnKeywords[T]>;
|
||||
},
|
||||
mockTabModel: null as TabModel,
|
||||
services: null as any,
|
||||
callBackendService: (service: string, method: string, args: any[], noUIContext?: boolean) => {
|
||||
const fn = overrides.services?.[service]?.[method];
|
||||
if (fn) {
|
||||
return fn(...args);
|
||||
}
|
||||
console.log("[mock callBackendService]", service, method, args, noUIContext);
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
mockSetWaveObj: mockWosFns.mockSetWaveObj,
|
||||
mockModels: new Map<any, any>(),
|
||||
} as MockWaveEnv;
|
||||
if (overrides.tabId != null) {
|
||||
env.mockTabModel = new TabModel(overrides.tabId, env);
|
||||
}
|
||||
env.services = Object.fromEntries(
|
||||
Object.entries(AllServiceTypes).map(([key, ServiceClass]) => [key, new ServiceClass(env)])
|
||||
) as any;
|
||||
return env;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import Logo from "@/app/asset/logo.svg";
|
||||
import { ErrorBoundary } from "@/app/element/errorboundary";
|
||||
import { getAtoms, initGlobalAtoms } from "@/app/store/global-atoms";
|
||||
import { GlobalModel } from "@/app/store/global-model";
|
||||
import { globalStore } from "@/app/store/jotaiStore";
|
||||
|
|
@ -13,6 +14,7 @@ import { createRoot } from "react-dom/client";
|
|||
import { makeMockWaveEnv } from "./mock/mockwaveenv";
|
||||
import { installPreviewElectronApi } from "./mock/preview-electron-api";
|
||||
|
||||
import "overlayscrollbars/overlayscrollbars.css";
|
||||
import "../app/app.scss";
|
||||
|
||||
// preview.css should come *after* app.scss (don't remove the newline above otherwise prettier will reorder these imports)
|
||||
|
|
@ -95,6 +97,7 @@ function PreviewRoot() {
|
|||
atoms: {
|
||||
uiContext: atom({ windowid: PreviewWindowId, activetabid: PreviewTabId } as UIContext),
|
||||
staticTabId: atom(PreviewTabId),
|
||||
workspaceId: atom(PreviewWorkspaceId),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
|
@ -118,9 +121,11 @@ function PreviewApp() {
|
|||
<>
|
||||
<PreviewHeader previewName={previewName} />
|
||||
<div className="h-screen overflow-y-auto bg-background text-foreground font-sans flex flex-col items-center pt-12 pb-8">
|
||||
<Suspense fallback={null}>
|
||||
<PreviewComponent />
|
||||
</Suspense>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={null}>
|
||||
<PreviewComponent />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
@ -143,6 +148,7 @@ function PreviewApp() {
|
|||
|
||||
const PreviewTabId = crypto.randomUUID();
|
||||
const PreviewWindowId = crypto.randomUUID();
|
||||
const PreviewWorkspaceId = crypto.randomUUID();
|
||||
const PreviewClientId = crypto.randomUUID();
|
||||
|
||||
function initPreview() {
|
||||
|
|
|
|||
306
frontend/preview/previews/tabbar.preview.tsx
Normal file
306
frontend/preview/previews/tabbar.preview.tsx
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
// Copyright 2026, Command Line Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { loadBadges, LoadBadgesEnv } from "@/app/store/badge";
|
||||
import { globalStore } from "@/app/store/jotaiStore";
|
||||
import { TabBar } from "@/app/tab/tabbar";
|
||||
import { TabBarEnv } from "@/app/tab/tabbarenv";
|
||||
import { useWaveEnv, WaveEnvContext } from "@/app/waveenv/waveenv";
|
||||
import { applyMockEnvOverrides, MockWaveEnv } from "@/preview/mock/mockwaveenv";
|
||||
import { PlatformLinux, PlatformMacOS, PlatformWindows } from "@/util/platformutil";
|
||||
import { atom, useAtom, useAtomValue } from "jotai";
|
||||
import { CSSProperties, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
type PreviewTabEntry = {
|
||||
tabId: string;
|
||||
tabName: string;
|
||||
badges?: Badge[] | null;
|
||||
flagColor?: string | null;
|
||||
};
|
||||
|
||||
function badgeBlockId(tabId: string, badgeId: string): string {
|
||||
return `${tabId}-badge-${badgeId}`;
|
||||
}
|
||||
|
||||
function makeTabWaveObj(tab: PreviewTabEntry): Tab {
|
||||
const blockids = (tab.badges ?? []).map((b) => badgeBlockId(tab.tabId, b.badgeid));
|
||||
return {
|
||||
otype: "tab",
|
||||
oid: tab.tabId,
|
||||
version: 1,
|
||||
name: tab.tabName,
|
||||
blockids,
|
||||
meta: tab.flagColor ? { "tab:flagcolor": tab.flagColor } : {},
|
||||
} as Tab;
|
||||
}
|
||||
|
||||
function makeMockBadgeEvents(): BadgeEvent[] {
|
||||
const events: BadgeEvent[] = [];
|
||||
for (const tab of InitialTabs) {
|
||||
for (const badge of tab.badges ?? []) {
|
||||
events.push({ oref: `block:${badgeBlockId(tab.tabId, badge.badgeid)}`, badge });
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
const MockWorkspaceId = "preview-workspace-1";
|
||||
const InitialTabs: PreviewTabEntry[] = [
|
||||
{ tabId: "preview-tab-1", tabName: "Terminal" },
|
||||
{
|
||||
tabId: "preview-tab-2",
|
||||
tabName: "Build Logs",
|
||||
badges: [
|
||||
{
|
||||
badgeid: "01958000-0000-7000-0000-000000000001",
|
||||
icon: "triangle-exclamation",
|
||||
color: "#f59e0b",
|
||||
priority: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
tabId: "preview-tab-3",
|
||||
tabName: "Deploy",
|
||||
badges: [
|
||||
{ badgeid: "01958000-0000-7000-0000-000000000002", icon: "circle-check", color: "#4ade80", priority: 3 },
|
||||
],
|
||||
flagColor: "#429dff",
|
||||
},
|
||||
{
|
||||
tabId: "preview-tab-4",
|
||||
tabName: "A Very Long Tab Name To Show Truncation",
|
||||
badges: [
|
||||
{ badgeid: "01958000-0000-7000-0000-000000000003", icon: "bell", color: "#f87171", priority: 2 },
|
||||
{ badgeid: "01958000-0000-7000-0000-000000000004", icon: "circle-small", color: "#fbbf24", priority: 1 },
|
||||
],
|
||||
},
|
||||
{ tabId: "preview-tab-5", tabName: "Wave AI" },
|
||||
{ tabId: "preview-tab-6", tabName: "Preview", flagColor: "#bf55ec" },
|
||||
];
|
||||
|
||||
const MockConfigErrors: ConfigError[] = [
|
||||
{ file: "~/.waveterm/config.json", err: 'unknown preset "bg@aurora"' },
|
||||
{ file: "~/.waveterm/settings.json", err: "invalid color for tab theme" },
|
||||
];
|
||||
|
||||
function makeMockWorkspace(tabIds: string[]): Workspace {
|
||||
return {
|
||||
otype: "workspace",
|
||||
oid: MockWorkspaceId,
|
||||
version: 1,
|
||||
name: "Preview Workspace",
|
||||
tabids: tabIds,
|
||||
activetabid: tabIds[1] ?? tabIds[0] ?? "",
|
||||
meta: {},
|
||||
} as Workspace;
|
||||
}
|
||||
|
||||
export function TabBarPreview() {
|
||||
const baseEnv = useWaveEnv();
|
||||
const initialTabIds = InitialTabs.map((t) => t.tabId);
|
||||
const envRef = useRef<MockWaveEnv>(null);
|
||||
const [platform, setPlatform] = useState<NodeJS.Platform>(PlatformMacOS);
|
||||
|
||||
const tabEnv = useMemo(() => {
|
||||
const mockWaveObjs: Record<string, WaveObj> = {
|
||||
[`workspace:${MockWorkspaceId}`]: makeMockWorkspace(initialTabIds),
|
||||
};
|
||||
for (const tab of InitialTabs) {
|
||||
mockWaveObjs[`tab:${tab.tabId}`] = makeTabWaveObj(tab);
|
||||
}
|
||||
const env = applyMockEnvOverrides(baseEnv, {
|
||||
tabId: InitialTabs[1].tabId,
|
||||
platform,
|
||||
mockWaveObjs,
|
||||
atoms: {
|
||||
workspaceId: atom(MockWorkspaceId),
|
||||
staticTabId: atom(InitialTabs[1].tabId),
|
||||
},
|
||||
rpc: {
|
||||
GetAllBadgesCommand: () => Promise.resolve(makeMockBadgeEvents()),
|
||||
},
|
||||
electron: {
|
||||
createTab: () => {
|
||||
const e = envRef.current;
|
||||
if (e == null) return;
|
||||
const newTabId = `preview-tab-${crypto.randomUUID()}`;
|
||||
e.mockSetWaveObj(`tab:${newTabId}`, {
|
||||
otype: "tab",
|
||||
oid: newTabId,
|
||||
version: 1,
|
||||
name: "New Tab",
|
||||
blockids: [],
|
||||
meta: {},
|
||||
} as Tab);
|
||||
const ws = globalStore.get(e.wos.getWaveObjectAtom<Workspace>(`workspace:${MockWorkspaceId}`));
|
||||
e.mockSetWaveObj(`workspace:${MockWorkspaceId}`, {
|
||||
...ws,
|
||||
tabids: [...(ws.tabids ?? []), newTabId],
|
||||
});
|
||||
globalStore.set(e.atoms.staticTabId as any, newTabId);
|
||||
},
|
||||
closeTab: (_workspaceId: string, tabId: string) => {
|
||||
const e = envRef.current;
|
||||
if (e == null) return Promise.resolve(false);
|
||||
const ws = globalStore.get(e.wos.getWaveObjectAtom<Workspace>(`workspace:${MockWorkspaceId}`));
|
||||
const newTabIds = (ws.tabids ?? []).filter((id) => id !== tabId);
|
||||
if (newTabIds.length === 0) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
e.mockSetWaveObj(`workspace:${MockWorkspaceId}`, { ...ws, tabids: newTabIds });
|
||||
if (globalStore.get(e.atoms.staticTabId) === tabId) {
|
||||
globalStore.set(e.atoms.staticTabId as any, newTabIds[0]);
|
||||
}
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
setActiveTab: (tabId: string) => {
|
||||
const e = envRef.current;
|
||||
if (e == null) return;
|
||||
globalStore.set(e.atoms.staticTabId as any, tabId);
|
||||
},
|
||||
showWorkspaceAppMenu: () => {
|
||||
console.log("[preview] showWorkspaceAppMenu");
|
||||
},
|
||||
},
|
||||
});
|
||||
envRef.current = env;
|
||||
return env;
|
||||
}, [platform]);
|
||||
|
||||
return (
|
||||
<WaveEnvContext.Provider value={tabEnv}>
|
||||
<TabBarPreviewInner platform={platform} setPlatform={setPlatform} />
|
||||
</WaveEnvContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
type TabBarPreviewInnerProps = {
|
||||
platform: NodeJS.Platform;
|
||||
setPlatform: (platform: NodeJS.Platform) => void;
|
||||
};
|
||||
|
||||
function TabBarPreviewInner({ platform, setPlatform }: TabBarPreviewInnerProps) {
|
||||
const env = useWaveEnv<TabBarEnv>();
|
||||
const loadBadgesEnv = useWaveEnv<LoadBadgesEnv>();
|
||||
const [showConfigErrors, setShowConfigErrors] = useState(false);
|
||||
const [hideAiButton, setHideAiButton] = useState(false);
|
||||
const [showMenuBar, setShowMenuBar] = useState(false);
|
||||
const [isFullScreen, setIsFullScreen] = useAtom(env.atoms.isFullScreen);
|
||||
const [zoomFactor, setZoomFactor] = useAtom(env.atoms.zoomFactorAtom);
|
||||
const [fullConfig, setFullConfig] = useAtom(env.atoms.fullConfigAtom);
|
||||
const [updaterStatus, setUpdaterStatus] = useAtom(env.atoms.updaterStatusAtom);
|
||||
const workspace = useAtomValue(env.wos.getWaveObjectAtom<Workspace>(`workspace:${MockWorkspaceId}`));
|
||||
|
||||
useEffect(() => {
|
||||
loadBadges(loadBadgesEnv);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setFullConfig((prev) => ({
|
||||
...(prev ?? ({} as FullConfigType)),
|
||||
settings: {
|
||||
...(prev?.settings ?? {}),
|
||||
"app:hideaibutton": hideAiButton,
|
||||
"window:showmenubar": showMenuBar,
|
||||
},
|
||||
configerrors: showConfigErrors ? MockConfigErrors : [],
|
||||
}));
|
||||
}, [hideAiButton, showMenuBar, setFullConfig, showConfigErrors]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="grid gap-4 rounded-md border border-border bg-panel p-4 md:grid-cols-3 mx-6 mt-6">
|
||||
<label className="flex flex-col gap-2 text-xs text-muted">
|
||||
<span>Platform</span>
|
||||
<select
|
||||
value={platform}
|
||||
onChange={(event) => setPlatform(event.target.value as NodeJS.Platform)}
|
||||
className="rounded border border-border bg-background px-2 py-1 text-foreground cursor-pointer"
|
||||
>
|
||||
<option value={PlatformMacOS}>macOS</option>
|
||||
<option value={PlatformWindows}>Windows</option>
|
||||
<option value={PlatformLinux}>Linux</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 text-xs text-muted">
|
||||
<span>Updater banner</span>
|
||||
<select
|
||||
value={updaterStatus}
|
||||
onChange={(event) => setUpdaterStatus(event.target.value as UpdaterStatus)}
|
||||
className="rounded border border-border bg-background px-2 py-1 text-foreground"
|
||||
>
|
||||
<option value="up-to-date">Hidden</option>
|
||||
<option value="ready">Update Available</option>
|
||||
<option value="downloading">Downloading</option>
|
||||
<option value="installing">Installing</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showConfigErrors}
|
||||
onChange={(event) => setShowConfigErrors(event.target.checked)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
Show config error button
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hideAiButton}
|
||||
onChange={(event) => setHideAiButton(event.target.checked)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
Hide Wave AI button
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showMenuBar}
|
||||
onChange={(event) => setShowMenuBar(event.target.checked)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
Show menu bar
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isFullScreen}
|
||||
onChange={(event) => setIsFullScreen(event.target.checked)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
Full screen
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 text-xs text-muted">
|
||||
<span>Zoom factor: {zoomFactor.toFixed(2)}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0.8}
|
||||
max={1.5}
|
||||
step={0.05}
|
||||
value={zoomFactor}
|
||||
onChange={(event) => setZoomFactor(Number(event.target.value))}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex items-end text-xs text-muted">
|
||||
Double-click a tab name to rename it. Close/add buttons and drag reordering are fully functional.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="w-full border-y border-border shadow-xl overflow-hidden"
|
||||
style={{ "--zoomfactor-inv": zoomFactor > 0 ? 1 / zoomFactor : 1 } as CSSProperties}
|
||||
>
|
||||
{workspace != null && <TabBar key={platform} workspace={workspace} />}
|
||||
</div>
|
||||
|
||||
<div className="mx-6 mb-6 text-xs text-muted">
|
||||
Tabs: {workspace?.tabids?.length ?? 0} · Config errors: {fullConfig?.configerrors?.length ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
TabBarPreviewInner.displayName = "TabBarPreviewInner";
|
||||
|
|
@ -7,7 +7,6 @@ import { atom, useAtom } from "jotai";
|
|||
import { useRef } from "react";
|
||||
import { applyMockEnvOverrides } from "../mock/mockwaveenv";
|
||||
|
||||
const workspaceAtom = atom<Workspace>(null as Workspace);
|
||||
const resizableHeightAtom = atom(250);
|
||||
|
||||
function makeMockApp(name: string, icon: string, iconcolor: string): AppInfo {
|
||||
|
|
@ -91,7 +90,6 @@ function makeWidgetsEnv(baseEnv: WaveEnv, isDev: boolean, hasCustomAIPresets: bo
|
|||
rpc: { ListAllAppsCommand: () => Promise.resolve(apps ?? []) },
|
||||
atoms: {
|
||||
fullConfigAtom,
|
||||
workspace: workspaceAtom,
|
||||
hasCustomAIPresetsAtom: atom(hasCustomAIPresets),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
4
frontend/types/custom.d.ts
vendored
4
frontend/types/custom.d.ts
vendored
|
|
@ -11,11 +11,13 @@ declare global {
|
|||
builderId: jotai.Atom<string>; // readonly (for builder mode)
|
||||
builderAppId: jotai.PrimitiveAtom<string>; // app being edited in builder mode
|
||||
uiContext: jotai.Atom<UIContext>; // driven from windowId, tabId
|
||||
workspace: jotai.Atom<Workspace>; // driven from WOS
|
||||
workspaceId: jotai.Atom<string>; // derived from window WOS object
|
||||
workspace: jotai.Atom<Workspace>; // driven from workspaceId via WOS
|
||||
fullConfigAtom: jotai.PrimitiveAtom<FullConfigType>; // driven from WOS, settings -- updated via WebSocket
|
||||
waveaiModeConfigAtom: jotai.PrimitiveAtom<Record<string, AIModeConfigType>>; // resolved AI mode configs -- updated via WebSocket
|
||||
settingsAtom: jotai.Atom<SettingsType>; // derrived from fullConfig
|
||||
hasCustomAIPresetsAtom: jotai.Atom<boolean>; // derived from fullConfig
|
||||
hasConfigErrors: jotai.Atom<boolean>; // derived from fullConfig
|
||||
staticTabId: jotai.Atom<string>;
|
||||
isFullScreen: jotai.PrimitiveAtom<boolean>;
|
||||
zoomFactorAtom: jotai.PrimitiveAtom<number>;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
export const PlatformMacOS = "darwin";
|
||||
export const PlatformWindows = "win32";
|
||||
export const PlatformLinux = "linux";
|
||||
export let PLATFORM: NodeJS.Platform = PlatformMacOS;
|
||||
|
||||
export function setPlatform(platform: NodeJS.Platform) {
|
||||
|
|
|
|||
|
|
@ -72,23 +72,6 @@ func (svc *ObjectService) GetObjects(orefStrArr []string) ([]waveobj.WaveObj, er
|
|||
return wstore.DBSelectORefs(ctx, orefArr)
|
||||
}
|
||||
|
||||
func (svc *ObjectService) UpdateTabName_Meta() tsgenmeta.MethodMeta {
|
||||
return tsgenmeta.MethodMeta{
|
||||
ArgNames: []string{"uiContext", "tabId", "name"},
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *ObjectService) UpdateTabName(uiContext waveobj.UIContext, tabId, name string) (waveobj.UpdatesRtnType, error) {
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
|
||||
defer cancelFn()
|
||||
ctx = waveobj.ContextWithUpdates(ctx)
|
||||
err := wstore.UpdateTabName(ctx, tabId, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error updating tab name: %w", err)
|
||||
}
|
||||
return waveobj.ContextGetUpdatesRtn(ctx), nil
|
||||
}
|
||||
|
||||
func (svc *ObjectService) CreateBlock_Meta() tsgenmeta.MethodMeta {
|
||||
return tsgenmeta.MethodMeta{
|
||||
ArgNames: []string{"uiContext", "blockDef", "rtOpts"},
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ package workspaceservice
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/wavetermdev/waveterm/pkg/blockcontroller"
|
||||
|
|
@ -165,24 +164,6 @@ func (svc *WorkspaceService) CreateTab(workspaceId string, tabName string, activ
|
|||
return tabId, updates, nil
|
||||
}
|
||||
|
||||
func (svc *WorkspaceService) UpdateTabIds_Meta() tsgenmeta.MethodMeta {
|
||||
return tsgenmeta.MethodMeta{
|
||||
ArgNames: []string{"uiContext", "workspaceId", "tabIds"},
|
||||
}
|
||||
}
|
||||
|
||||
func (svc *WorkspaceService) UpdateTabIds(uiContext waveobj.UIContext, workspaceId string, tabIds []string) (waveobj.UpdatesRtnType, error) {
|
||||
log.Printf("UpdateTabIds %s %v\n", workspaceId, tabIds)
|
||||
ctx, cancelFn := context.WithTimeout(context.Background(), DefaultTimeout)
|
||||
defer cancelFn()
|
||||
ctx = waveobj.ContextWithUpdates(ctx)
|
||||
err := wcore.UpdateWorkspaceTabIds(ctx, workspaceId, tabIds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error updating workspace tab ids: %w", err)
|
||||
}
|
||||
return waveobj.ContextGetUpdatesRtn(ctx), nil
|
||||
}
|
||||
|
||||
func (svc *WorkspaceService) SetActiveTab_Meta() tsgenmeta.MethodMeta {
|
||||
return tsgenmeta.MethodMeta{
|
||||
ArgNames: []string{"workspaceId", "tabId"},
|
||||
|
|
|
|||
|
|
@ -412,7 +412,7 @@ func GenerateMethodSignature(serviceName string, method reflect.Method, meta tsg
|
|||
}
|
||||
|
||||
func GenerateMethodBody(serviceName string, method reflect.Method, meta tsgenmeta.MethodMeta) string {
|
||||
return fmt.Sprintf(" return WOS.callBackendService(%q, %q, Array.from(arguments))\n", serviceName, method.Name)
|
||||
return fmt.Sprintf(" return callBackendService(this.waveEnv, %q, %q, Array.from(arguments))\n", serviceName, method.Name)
|
||||
}
|
||||
|
||||
func GenerateServiceClass(serviceName string, serviceObj any, tsTypesMap map[reflect.Type]string) string {
|
||||
|
|
@ -420,9 +420,13 @@ func GenerateServiceClass(serviceName string, serviceObj any, tsTypesMap map[ref
|
|||
var sb strings.Builder
|
||||
tsServiceName := serviceType.Elem().Name()
|
||||
sb.WriteString(fmt.Sprintf("// %s (%s)\n", serviceType.Elem().String(), serviceName))
|
||||
sb.WriteString("class ")
|
||||
sb.WriteString("export class ")
|
||||
sb.WriteString(tsServiceName + "Type")
|
||||
sb.WriteString(" {\n")
|
||||
sb.WriteString(" waveEnv: WaveEnv;\n\n")
|
||||
sb.WriteString(" constructor(waveEnv?: WaveEnv) {\n")
|
||||
sb.WriteString(" this.waveEnv = waveEnv;\n")
|
||||
sb.WriteString(" }\n\n")
|
||||
isFirst := true
|
||||
for midx := 0; midx < serviceType.NumMethod(); midx++ {
|
||||
method := serviceType.Method(midx)
|
||||
|
|
|
|||
|
|
@ -921,6 +921,18 @@ func TestMultiArgCommand(w *wshutil.WshRpc, arg1 string, arg2 int, arg3 bool, op
|
|||
return resp, err
|
||||
}
|
||||
|
||||
// command "updatetabname", wshserver.UpdateTabNameCommand
|
||||
func UpdateTabNameCommand(w *wshutil.WshRpc, arg1 string, arg2 string, opts *wshrpc.RpcOpts) error {
|
||||
_, err := sendRpcRequestCallHelper[any](w, "updatetabname", wshrpc.MultiArg{Args: []any{arg1, arg2}}, opts)
|
||||
return err
|
||||
}
|
||||
|
||||
// command "updateworkspacetabids", wshserver.UpdateWorkspaceTabIdsCommand
|
||||
func UpdateWorkspaceTabIdsCommand(w *wshutil.WshRpc, arg1 string, arg2 []string, opts *wshrpc.RpcOpts) error {
|
||||
_, err := sendRpcRequestCallHelper[any](w, "updateworkspacetabids", wshrpc.MultiArg{Args: []any{arg1, arg2}}, opts)
|
||||
return err
|
||||
}
|
||||
|
||||
// command "vdomasyncinitiation", wshserver.VDomAsyncInitiationCommand
|
||||
func VDomAsyncInitiationCommand(w *wshutil.WshRpc, data vdom.VDomAsyncInitiationRequest, opts *wshrpc.RpcOpts) error {
|
||||
_, err := sendRpcRequestCallHelper[any](w, "vdomasyncinitiation", data, opts)
|
||||
|
|
|
|||
|
|
@ -94,6 +94,8 @@ type WshRpcInterface interface {
|
|||
FetchSuggestionsCommand(ctx context.Context, data FetchSuggestionsData) (*FetchSuggestionsResponse, error)
|
||||
DisposeSuggestionsCommand(ctx context.Context, widgetId string) error
|
||||
GetTabCommand(ctx context.Context, tabId string) (*waveobj.Tab, error)
|
||||
UpdateTabNameCommand(ctx context.Context, tabId string, newName string) error
|
||||
UpdateWorkspaceTabIdsCommand(ctx context.Context, workspaceId string, tabIds []string) error
|
||||
GetAllBadgesCommand(ctx context.Context) ([]baseds.BadgeEvent, error)
|
||||
|
||||
// connection functions
|
||||
|
|
|
|||
|
|
@ -160,6 +160,26 @@ func (ws *WshServer) GetMetaCommand(ctx context.Context, data wshrpc.CommandGetM
|
|||
return waveobj.GetMeta(obj), nil
|
||||
}
|
||||
|
||||
func (ws *WshServer) UpdateTabNameCommand(ctx context.Context, tabId string, newName string) error {
|
||||
oref := waveobj.ORef{OType: waveobj.OType_Tab, OID: tabId}
|
||||
err := wstore.UpdateTabName(ctx, tabId, newName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error updating tab name: %w", err)
|
||||
}
|
||||
wcore.SendWaveObjUpdate(oref)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ws *WshServer) UpdateWorkspaceTabIdsCommand(ctx context.Context, workspaceId string, tabIds []string) error {
|
||||
oref := waveobj.ORef{OType: waveobj.OType_Workspace, OID: workspaceId}
|
||||
err := wcore.UpdateWorkspaceTabIds(ctx, workspaceId, tabIds)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error updating workspace tab ids: %w", err)
|
||||
}
|
||||
wcore.SendWaveObjUpdate(oref)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ws *WshServer) SetMetaCommand(ctx context.Context, data wshrpc.CommandSetMetaData) error {
|
||||
log.Printf("SetMetaCommand: %s | %v\n", data.ORef, data.Meta)
|
||||
oref := data.ORef
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@
|
|||
"@/store/*": ["frontend/app/store/*"],
|
||||
"@/view/*": ["frontend/app/view/*"],
|
||||
"@/element/*": ["frontend/app/element/*"],
|
||||
"@/shadcn/*": ["frontend/app/shadcn/*"]
|
||||
"@/shadcn/*": ["frontend/app/shadcn/*"],
|
||||
"@/preview/*": ["frontend/preview/*"]
|
||||
},
|
||||
"lib": ["dom", "dom.iterable", "es6"],
|
||||
"allowJs": true,
|
||||
|
|
|
|||
Loading…
Reference in a new issue