From 634d07677b932037ac744c95748dac3ca75bfacd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 01:30:29 +0000 Subject: [PATCH 1/5] Initial plan From fd6a7af20f80462c44d2e49a957f0a05007c77e0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 01:49:52 +0000 Subject: [PATCH 2/5] Add dev-only file explorer panel Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> --- .../app/fileexplorer/fileexplorer.test.ts | 51 +++++++++++ frontend/app/fileexplorer/fileexplorer.tsx | 87 +++++++++++++++++++ frontend/app/store/keymodel.ts | 24 ++++- frontend/app/treeview/treeview.tsx | 33 ++++++- .../app/workspace/workspace-layout-model.ts | 48 ++++++++-- frontend/app/workspace/workspace.tsx | 7 +- 6 files changed, 239 insertions(+), 11 deletions(-) create mode 100644 frontend/app/fileexplorer/fileexplorer.test.ts create mode 100644 frontend/app/fileexplorer/fileexplorer.tsx diff --git a/frontend/app/fileexplorer/fileexplorer.test.ts b/frontend/app/fileexplorer/fileexplorer.test.ts new file mode 100644 index 0000000000..deeeccd4ea --- /dev/null +++ b/frontend/app/fileexplorer/fileexplorer.test.ts @@ -0,0 +1,51 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { fileInfoToTreeNodeData } from "@/app/fileexplorer/fileexplorer"; +import { describe, expect, it } from "vitest"; + +describe("fileInfoToTreeNodeData", () => { + it("maps directories to unloaded tree nodes", () => { + const fileInfo: FileInfo = { + path: "~/projects", + name: "projects", + isdir: true, + readonly: true, + }; + + expect(fileInfoToTreeNodeData(fileInfo, "~")).toEqual({ + id: "~/projects", + parentId: "~", + path: "~/projects", + label: "projects", + isDirectory: true, + mimeType: undefined, + isReadonly: true, + notfound: undefined, + staterror: undefined, + childrenStatus: "unloaded", + }); + }); + + it("maps files to loaded tree nodes", () => { + const fileInfo: FileInfo = { + path: "~/notes/todo.md", + name: "todo.md", + isdir: false, + mimetype: "text/markdown", + }; + + expect(fileInfoToTreeNodeData(fileInfo, "~/notes")).toEqual({ + id: "~/notes/todo.md", + parentId: "~/notes", + path: "~/notes/todo.md", + label: "todo.md", + isDirectory: false, + mimeType: "text/markdown", + isReadonly: undefined, + notfound: undefined, + staterror: undefined, + childrenStatus: "loaded", + }); + }); +}); diff --git a/frontend/app/fileexplorer/fileexplorer.tsx b/frontend/app/fileexplorer/fileexplorer.tsx new file mode 100644 index 0000000000..513c5bff54 --- /dev/null +++ b/frontend/app/fileexplorer/fileexplorer.tsx @@ -0,0 +1,87 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { TreeNodeData, TreeView } from "@/app/treeview/treeview"; +import { isDev } from "@/app/store/global"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { cn, makeConnRoute } from "@/util/util"; +import { memo, useCallback, useMemo, useState } from "react"; + +const FileExplorerRootId = "~"; +const FileExplorerConn = "local"; +const FileExplorerRootNode: TreeNodeData = { + id: FileExplorerRootId, + path: FileExplorerRootId, + label: FileExplorerRootId, + isDirectory: true, + childrenStatus: "unloaded", +}; + +export function fileInfoToTreeNodeData(fileInfo: FileInfo, parentId: string): TreeNodeData { + const nodeId = fileInfo.path ?? `${parentId}/${fileInfo.name ?? "unknown"}`; + return { + id: nodeId, + parentId, + path: fileInfo.path, + label: fileInfo.name ?? fileInfo.path ?? nodeId, + isDirectory: !!fileInfo.isdir, + mimeType: fileInfo.mimetype, + isReadonly: fileInfo.readonly, + notfound: fileInfo.notfound, + staterror: fileInfo.staterror, + childrenStatus: fileInfo.isdir ? "unloaded" : "loaded", + }; +} + +const FileExplorerPanel = memo(() => { + const [selectedPath, setSelectedPath] = useState(FileExplorerRootId); + const initialNodes = useMemo(() => ({ [FileExplorerRootId]: FileExplorerRootNode }), []); + const initialExpandedIds = useMemo(() => [FileExplorerRootId], []); + + const fetchDir = useCallback(async (id: string, limit: number) => { + const nodes: TreeNodeData[] = []; + for await (const response of RpcApi.RemoteListEntriesCommand( + TabRpcClient, + { path: id, opts: { limit } }, + { route: makeConnRoute(FileExplorerConn) } + )) { + for (const fileInfo of response.fileinfo ?? []) { + nodes.push(fileInfoToTreeNodeData(fileInfo, id)); + } + } + return { nodes }; + }, []); + + if (!isDev()) { + return null; + } + + return ( +
+
+
File Explorer
+
local • {selectedPath}
+
+
+ setSelectedPath(node.path ?? node.id)} + /> +
+
+ ); +}); + +FileExplorerPanel.displayName = "FileExplorerPanel"; + +export { FileExplorerPanel }; diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index aa25448a0a..e3d28ef040 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -15,6 +15,7 @@ import { getFocusedBlockId, getSettingsKeyAtom, globalStore, + isDev, recordTEvent, refocusNode, replaceBlock, @@ -720,10 +721,29 @@ function registerGlobalKeys() { return false; }); globalKeyMap.set("Cmd:Shift:a", () => { - const currentVisible = WorkspaceLayoutModel.getInstance().getAIPanelVisible(); - WorkspaceLayoutModel.getInstance().setAIPanelVisible(!currentVisible); + const workspaceLayoutModel = WorkspaceLayoutModel.getInstance(); + const currentVisible = workspaceLayoutModel.getAIPanelVisible(); + const activePanel = workspaceLayoutModel.getActivePanel(); + if (currentVisible && activePanel === "waveai") { + workspaceLayoutModel.setAIPanelVisible(false); + return true; + } + workspaceLayoutModel.setAIPanelVisible(true); return true; }); + if (isDev()) { + globalKeyMap.set("Cmd:Shift:e", () => { + const workspaceLayoutModel = WorkspaceLayoutModel.getInstance(); + const currentVisible = workspaceLayoutModel.getAIPanelVisible(); + const activePanel = workspaceLayoutModel.getActivePanel(); + if (currentVisible && activePanel === "fileexplorer") { + workspaceLayoutModel.setFileExplorerPanelVisible(false); + return true; + } + workspaceLayoutModel.setFileExplorerPanelVisible(true); + return true; + }); + } const allKeys = Array.from(globalKeyMap.keys()); // special case keys, handled by web view allKeys.push("Cmd:l", "Cmd:r", "Cmd:ArrowRight", "Cmd:ArrowLeft", "Cmd:o"); diff --git a/frontend/app/treeview/treeview.tsx b/frontend/app/treeview/treeview.tsx index 4481d2c68f..20ac4ad166 100644 --- a/frontend/app/treeview/treeview.tsx +++ b/frontend/app/treeview/treeview.tsx @@ -57,6 +57,7 @@ export interface TreeViewProps { rootIds: string[]; initialNodes: Record; fetchDir?: (id: string, limit: number) => Promise; + initialExpandedIds?: string[]; maxDirEntries?: number; rowHeight?: number; indentWidth?: number; @@ -66,6 +67,7 @@ export interface TreeViewProps { width?: number | string; height?: number | string; className?: string; + expandDirectoriesOnClick?: boolean; onOpenFile?: (id: string, node: TreeNodeData) => void; onSelectionChange?: (id: string, node: TreeNodeData) => void; } @@ -208,6 +210,7 @@ export const TreeView = forwardRef((props, ref) => { rootIds, initialNodes, fetchDir, + initialExpandedIds = [], maxDirEntries = 500, rowHeight = DefaultRowHeight, indentWidth = DefaultIndentWidth, @@ -217,6 +220,7 @@ export const TreeView = forwardRef((props, ref) => { width = "100%", height = 360, className, + expandDirectoriesOnClick = false, onOpenFile, onSelectionChange, } = props; @@ -226,7 +230,7 @@ export const TreeView = forwardRef((props, ref) => { Object.entries(initialNodes).map(([id, node]) => [id, { ...node, childrenStatus: node.childrenStatus ?? "unloaded" }]) ) ); - const [expandedIds, setExpandedIds] = useState>(new Set()); + const [expandedIds, setExpandedIds] = useState>(() => new Set(initialExpandedIds)); const [selectedId, setSelectedId] = useState(rootIds[0]); const scrollRef = useRef(null); @@ -244,6 +248,10 @@ export const TreeView = forwardRef((props, ref) => { ); }, [initialNodes]); + useEffect(() => { + setExpandedIds(new Set(initialExpandedIds)); + }, [initialExpandedIds]); + const visibleRows = useMemo(() => buildVisibleRows(nodesById, rootIds, expandedIds), [nodesById, rootIds, expandedIds]); const idToIndex = useMemo( () => new Map(visibleRows.map((row, index) => [row.id, index])), @@ -355,6 +363,19 @@ export const TreeView = forwardRef((props, ref) => { scrollToId(id); }; + useEffect(() => { + expandedIds.forEach((id) => { + const node = nodesById.get(id); + if (node == null || !node.isDirectory) { + return; + } + const status = node.childrenStatus ?? "unloaded"; + if (status === "unloaded") { + void loadChildren(id); + } + }); + }, [expandedIds, nodesById]); + const selectVisibleNodeAt = (index: number) => { if (index < 0 || index >= visibleRows.length) { return; @@ -455,7 +476,15 @@ export const TreeView = forwardRef((props, ref) => { height: rowHeight, transform: `translateY(${virtualRow.start}px)`, }} - onClick={() => row.kind === "node" && commitSelection(row.id)} + onClick={() => { + if (row.kind !== "node") { + return; + } + commitSelection(row.id); + if (expandDirectoriesOnClick && row.isDirectory) { + toggleExpand(row.id); + } + }} onDoubleClick={() => { if (row.kind !== "node") { return; diff --git a/frontend/app/workspace/workspace-layout-model.ts b/frontend/app/workspace/workspace-layout-model.ts index 725c9a17b5..99189dff77 100644 --- a/frontend/app/workspace/workspace-layout-model.ts +++ b/frontend/app/workspace/workspace-layout-model.ts @@ -7,7 +7,7 @@ import * as WOS from "@/app/store/wos"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { getLayoutModelForStaticTab } from "@/layout/lib/layoutModelHooks"; -import { atoms, getApi, getOrefMetaKeyAtom, recordTEvent, refocusNode } from "@/store/global"; +import { atoms, getApi, getOrefMetaKeyAtom, isDev, recordTEvent, refocusNode } from "@/store/global"; import debug from "debug"; import * as jotai from "jotai"; import { debounce } from "lodash-es"; @@ -19,6 +19,7 @@ const AIPANEL_DEFAULTWIDTH = 300; const AIPANEL_DEFAULTWIDTHRATIO = 0.33; const AIPANEL_MINWIDTH = 300; const AIPANEL_MAXWIDTHRATIO = 0.66; +type SidePanelView = "waveai" | "fileexplorer"; class WorkspaceLayoutModel { private static instance: WorkspaceLayoutModel | null = null; @@ -30,11 +31,13 @@ class WorkspaceLayoutModel { inResize: boolean; // prevents recursive setLayout calls (setLayout triggers onLayout which calls setLayout) private aiPanelVisible: boolean; private aiPanelWidth: number | null; + activePanel: SidePanelView; private debouncedPersistWidth: (width: number) => void; private initialized: boolean = false; private transitionTimeoutRef: NodeJS.Timeout | null = null; private focusTimeoutRef: NodeJS.Timeout | null = null; panelVisibleAtom: jotai.PrimitiveAtom; + activePanelAtom: jotai.PrimitiveAtom; private constructor() { this.aiPanelRef = null; @@ -44,7 +47,9 @@ class WorkspaceLayoutModel { this.inResize = false; this.aiPanelVisible = false; this.aiPanelWidth = null; + this.activePanel = "waveai"; this.panelVisibleAtom = jotai.atom(this.aiPanelVisible); + this.activePanelAtom = jotai.atom(this.activePanel); this.handleWindowResize = this.handleWindowResize.bind(this); this.handlePanelLayout = this.handlePanelLayout.bind(this); @@ -220,14 +225,29 @@ class WorkspaceLayoutModel { return this.aiPanelVisible; } - setAIPanelVisible(visible: boolean, opts?: { nofocus?: boolean }): void { + getActivePanel(): SidePanelView { + if (!isDev()) { + return "waveai"; + } + return this.activePanel; + } + + setActivePanel(panel: SidePanelView): void { + if (!isDev() && panel !== "waveai") { + return; + } + this.activePanel = panel; + globalStore.set(this.activePanelAtom, panel); + } + + private applyPanelVisible(visible: boolean, opts?: { nofocus?: boolean }): void { if (this.focusTimeoutRef != null) { clearTimeout(this.focusTimeoutRef); this.focusTimeoutRef = null; } const wasVisible = this.aiPanelVisible; this.aiPanelVisible = visible; - if (visible && !wasVisible) { + if (visible && !wasVisible && this.getActivePanel() === "waveai") { recordTEvent("action:openwaveai"); } globalStore.set(this.panelVisibleAtom, visible); @@ -239,14 +259,17 @@ class WorkspaceLayoutModel { this.enableTransitions(250); this.syncAIPanelRef(); - if (visible) { + if (visible && this.getActivePanel() === "waveai") { if (!opts?.nofocus) { this.focusTimeoutRef = setTimeout(() => { WaveAIModel.getInstance().focusInput(); this.focusTimeoutRef = null; }, 350); } - } else { + return; + } + + if (!visible) { const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); if (focusedNode == null) { @@ -260,6 +283,21 @@ class WorkspaceLayoutModel { } } + setAIPanelVisible(visible: boolean, opts?: { nofocus?: boolean }): void { + if (visible) { + this.setActivePanel("waveai"); + } + this.applyPanelVisible(visible, opts); + } + + setFileExplorerPanelVisible(visible: boolean): void { + if (!isDev()) { + return; + } + this.setActivePanel("fileexplorer"); + this.applyPanelVisible(visible, { nofocus: true }); + } + getAIPanelWidth(): number { this.initializeFromTabMeta(); if (this.aiPanelWidth == null) { diff --git a/frontend/app/workspace/workspace.tsx b/frontend/app/workspace/workspace.tsx index fb1d78668f..c34677b618 100644 --- a/frontend/app/workspace/workspace.tsx +++ b/frontend/app/workspace/workspace.tsx @@ -3,13 +3,14 @@ import { AIPanel } from "@/app/aipanel/aipanel"; import { ErrorBoundary } from "@/app/element/errorboundary"; +import { FileExplorerPanel } from "@/app/fileexplorer/fileexplorer"; import { CenteredDiv } from "@/app/element/quickelems"; import { ModalsRenderer } from "@/app/modals/modalsrenderer"; import { TabBar } from "@/app/tab/tabbar"; import { TabContent } from "@/app/tab/tabcontent"; import { Widgets } from "@/app/workspace/widgets"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; -import { atoms, getApi } from "@/store/global"; +import { atoms, getApi, isDev } from "@/store/global"; import { useAtomValue } from "jotai"; import { memo, useEffect, useRef } from "react"; import { @@ -24,6 +25,7 @@ const WorkspaceElem = memo(() => { const workspaceLayoutModel = WorkspaceLayoutModel.getInstance(); const tabId = useAtomValue(atoms.staticTabId); const ws = useAtomValue(atoms.workspace); + const activePanel = useAtomValue(workspaceLayoutModel.activePanelAtom); const initialAiPanelPercentage = workspaceLayoutModel.getAIPanelPercentage(window.innerWidth); const panelGroupRef = useRef(null); const aiPanelRef = useRef(null); @@ -69,7 +71,8 @@ const WorkspaceElem = memo(() => { className="overflow-hidden" >
- {tabId !== "" && } + {tabId !== "" && + (isDev() && activePanel === "fileexplorer" ? : )}
From 246161a2c283ce80621a16a9c9efbb0ac257eafd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 01:52:00 +0000 Subject: [PATCH 3/5] Tighten file explorer panel follow-ups Co-authored-by: sawka <2722291+sawka@users.noreply.github.com> --- frontend/app/fileexplorer/fileexplorer.test.ts | 9 +++++++++ frontend/app/fileexplorer/fileexplorer.tsx | 6 +++++- frontend/app/treeview/treeview.tsx | 6 +++++- frontend/app/workspace/workspace-layout-model.ts | 9 +++++++-- 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/frontend/app/fileexplorer/fileexplorer.test.ts b/frontend/app/fileexplorer/fileexplorer.test.ts index deeeccd4ea..912692185e 100644 --- a/frontend/app/fileexplorer/fileexplorer.test.ts +++ b/frontend/app/fileexplorer/fileexplorer.test.ts @@ -48,4 +48,13 @@ describe("fileInfoToTreeNodeData", () => { childrenStatus: "loaded", }); }); + + it("falls back to a stable serialized id when path is missing", () => { + const fileInfo: FileInfo = { + name: "mystery", + isdir: false, + }; + + expect(fileInfoToTreeNodeData(fileInfo, "~").id).toBe("~::mystery::::file::"); + }); }); diff --git a/frontend/app/fileexplorer/fileexplorer.tsx b/frontend/app/fileexplorer/fileexplorer.tsx index 513c5bff54..a85c03cf18 100644 --- a/frontend/app/fileexplorer/fileexplorer.tsx +++ b/frontend/app/fileexplorer/fileexplorer.tsx @@ -19,7 +19,11 @@ const FileExplorerRootNode: TreeNodeData = { }; export function fileInfoToTreeNodeData(fileInfo: FileInfo, parentId: string): TreeNodeData { - const nodeId = fileInfo.path ?? `${parentId}/${fileInfo.name ?? "unknown"}`; + const nodeId = + fileInfo.path ?? + [parentId, fileInfo.name ?? "", fileInfo.dir ?? "", fileInfo.isdir ? "dir" : "file", fileInfo.staterror ?? ""].join( + "::" + ); return { id: nodeId, parentId, diff --git a/frontend/app/treeview/treeview.tsx b/frontend/app/treeview/treeview.tsx index 20ac4ad166..2d4e2aa114 100644 --- a/frontend/app/treeview/treeview.tsx +++ b/frontend/app/treeview/treeview.tsx @@ -233,6 +233,7 @@ export const TreeView = forwardRef((props, ref) => { const [expandedIds, setExpandedIds] = useState>(() => new Set(initialExpandedIds)); const [selectedId, setSelectedId] = useState(rootIds[0]); const scrollRef = useRef(null); + const loadingIdsRef = useRef>(new Set()); useEffect(() => { setNodesById( @@ -295,9 +296,10 @@ export const TreeView = forwardRef((props, ref) => { return; } const status = currentNode.childrenStatus ?? "unloaded"; - if (status !== "unloaded") { + if (status !== "unloaded" || loadingIdsRef.current.has(id)) { return; } + loadingIdsRef.current.add(id); setNodesById((prev) => { const next = new Map(prev); next.set(id, { ...currentNode, childrenStatus: "loading" }); @@ -339,6 +341,8 @@ export const TreeView = forwardRef((props, ref) => { }); return next; }); + } finally { + loadingIdsRef.current.delete(id); } }; diff --git a/frontend/app/workspace/workspace-layout-model.ts b/frontend/app/workspace/workspace-layout-model.ts index 99189dff77..9101c401c2 100644 --- a/frontend/app/workspace/workspace-layout-model.ts +++ b/frontend/app/workspace/workspace-layout-model.ts @@ -247,8 +247,13 @@ class WorkspaceLayoutModel { } const wasVisible = this.aiPanelVisible; this.aiPanelVisible = visible; - if (visible && !wasVisible && this.getActivePanel() === "waveai") { - recordTEvent("action:openwaveai"); + if (visible && !wasVisible) { + if (this.getActivePanel() === "waveai") { + recordTEvent("action:openwaveai"); + } + if (this.getActivePanel() === "fileexplorer") { + recordTEvent("action:openfileexplorer"); + } } globalStore.set(this.panelVisibleAtom, visible); getApi().setWaveAIOpen(visible); From c4e5d6ab2a8d94e75f088127bcf574d6b6167b46 Mon Sep 17 00:00:00 2001 From: sawka Date: Sat, 7 Mar 2026 13:34:11 -0800 Subject: [PATCH 4/5] lots of fixes (especially for workspace layout model) --- frontend/app/aipanel/waveai-model.tsx | 2 +- frontend/app/block/blockframe.tsx | 4 +- .../app/fileexplorer/fileexplorer.test.ts | 8 -- frontend/app/fileexplorer/fileexplorer.tsx | 14 ++- frontend/app/store/keymodel.ts | 20 +--- frontend/app/tab/tabbar.tsx | 5 +- frontend/app/treeview/treeview.test.ts | 5 +- frontend/app/treeview/treeview.tsx | 22 ++-- frontend/app/view/term/term-model.ts | 2 +- .../app/workspace/workspace-layout-model.ts | 113 +++++++++--------- pkg/telemetry/telemetrydata/telemetrydata.go | 21 ++-- 11 files changed, 102 insertions(+), 114 deletions(-) diff --git a/frontend/app/aipanel/waveai-model.tsx b/frontend/app/aipanel/waveai-model.tsx index 9af1d88508..bd7ffad743 100644 --- a/frontend/app/aipanel/waveai-model.tsx +++ b/frontend/app/aipanel/waveai-model.tsx @@ -112,7 +112,7 @@ export class WaveAIModel { if (this.inBuilder) { return true; } - return get(WorkspaceLayoutModel.getInstance().panelVisibleAtom); + return get(WorkspaceLayoutModel.getInstance().activePanelAtom) === "waveai"; }); this.defaultModeAtom = jotai.atom((get) => { diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index 1ed88fb574..d0e04542bb 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -90,7 +90,7 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => { const { nodeModel, viewModel, blockModel, preview, numBlocksInTab, children } = props; const [blockData] = WOS.useWaveObjectValue(WOS.makeORef("block", nodeModel.blockId)); const isFocused = jotai.useAtomValue(nodeModel.isFocused); - const aiPanelVisible = jotai.useAtomValue(WorkspaceLayoutModel.getInstance().panelVisibleAtom); + const sidePanelVisible = jotai.useAtomValue(WorkspaceLayoutModel.getInstance().activePanelAtom) != null; const viewIconUnion = util.useAtomValueSafe(viewModel?.viewIcon) ?? blockViewToIcon(blockData?.meta?.view); const customBg = util.useAtomValueSafe(viewModel?.blockBg); const manageConnection = util.useAtomValueSafe(viewModel?.manageConnection); @@ -157,7 +157,7 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => { className={clsx("block", "block-frame-default", "block-" + nodeModel.blockId, { "block-focused": isFocused || preview, "block-preview": preview, - "block-no-highlight": numBlocksInTab === 1 && !aiPanelVisible, + "block-no-highlight": numBlocksInTab === 1 && !sidePanelVisible, ephemeral: isEphemeral, magnified: isMagnified, })} diff --git a/frontend/app/fileexplorer/fileexplorer.test.ts b/frontend/app/fileexplorer/fileexplorer.test.ts index 912692185e..3c6ffcf9fe 100644 --- a/frontend/app/fileexplorer/fileexplorer.test.ts +++ b/frontend/app/fileexplorer/fileexplorer.test.ts @@ -49,12 +49,4 @@ describe("fileInfoToTreeNodeData", () => { }); }); - it("falls back to a stable serialized id when path is missing", () => { - const fileInfo: FileInfo = { - name: "mystery", - isdir: false, - }; - - expect(fileInfoToTreeNodeData(fileInfo, "~").id).toBe("~::mystery::::file::"); - }); }); diff --git a/frontend/app/fileexplorer/fileexplorer.tsx b/frontend/app/fileexplorer/fileexplorer.tsx index a85c03cf18..3d961eb22a 100644 --- a/frontend/app/fileexplorer/fileexplorer.tsx +++ b/frontend/app/fileexplorer/fileexplorer.tsx @@ -1,10 +1,10 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { TreeNodeData, TreeView } from "@/app/treeview/treeview"; import { isDev } from "@/app/store/global"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { TreeNodeData, TreeView } from "@/app/treeview/treeview"; import { cn, makeConnRoute } from "@/util/util"; import { memo, useCallback, useMemo, useState } from "react"; @@ -21,9 +21,13 @@ const FileExplorerRootNode: TreeNodeData = { export function fileInfoToTreeNodeData(fileInfo: FileInfo, parentId: string): TreeNodeData { const nodeId = fileInfo.path ?? - [parentId, fileInfo.name ?? "", fileInfo.dir ?? "", fileInfo.isdir ? "dir" : "file", fileInfo.staterror ?? ""].join( - "::" - ); + [ + parentId, + fileInfo.name ?? "", + fileInfo.dir ?? "", + fileInfo.isdir ? "dir" : "file", + fileInfo.staterror ?? "", + ].join("::"); return { id: nodeId, parentId, @@ -65,7 +69,7 @@ const FileExplorerPanel = memo(() => {
File Explorer
-
local • {selectedPath}
+
local • {selectedPath}
{ - const workspaceLayoutModel = WorkspaceLayoutModel.getInstance(); - const currentVisible = workspaceLayoutModel.getAIPanelVisible(); - const activePanel = workspaceLayoutModel.getActivePanel(); - if (currentVisible && activePanel === "waveai") { - workspaceLayoutModel.setAIPanelVisible(false); - return true; - } - workspaceLayoutModel.setAIPanelVisible(true); + WorkspaceLayoutModel.getInstance().togglePanel("waveai"); return true; }); if (isDev()) { globalKeyMap.set("Cmd:Shift:e", () => { - const workspaceLayoutModel = WorkspaceLayoutModel.getInstance(); - const currentVisible = workspaceLayoutModel.getAIPanelVisible(); - const activePanel = workspaceLayoutModel.getActivePanel(); - if (currentVisible && activePanel === "fileexplorer") { - workspaceLayoutModel.setFileExplorerPanelVisible(false); - return true; - } - workspaceLayoutModel.setFileExplorerPanelVisible(true); + WorkspaceLayoutModel.getInstance().togglePanel("fileexplorer", { nofocus: true }); return true; }); } diff --git a/frontend/app/tab/tabbar.tsx b/frontend/app/tab/tabbar.tsx index fa78ba8321..748a91f2b7 100644 --- a/frontend/app/tab/tabbar.tsx +++ b/frontend/app/tab/tabbar.tsx @@ -44,12 +44,11 @@ interface TabBarProps { } const WaveAIButton = memo(({ divRef }: { divRef?: React.RefObject }) => { - const aiPanelOpen = useAtomValue(WorkspaceLayoutModel.getInstance().panelVisibleAtom); + const aiPanelOpen = useAtomValue(WorkspaceLayoutModel.getInstance().activePanelAtom) === "waveai"; const hideAiButton = useAtomValue(getSettingsKeyAtom("app:hideaibutton")); const onClick = () => { - const currentVisible = WorkspaceLayoutModel.getInstance().getAIPanelVisible(); - WorkspaceLayoutModel.getInstance().setAIPanelVisible(!currentVisible); + WorkspaceLayoutModel.getInstance().togglePanel("waveai"); }; if (hideAiButton) { diff --git a/frontend/app/treeview/treeview.test.ts b/frontend/app/treeview/treeview.test.ts index c286be7a49..10bae3ff1e 100644 --- a/frontend/app/treeview/treeview.test.ts +++ b/frontend/app/treeview/treeview.test.ts @@ -25,7 +25,7 @@ describe("treeview visible rows", () => { expect(rows.map((row) => row.id)).toEqual(["root", "b", "c", "a"]); }); - it("renders loading and capped synthetic rows", () => { + it("renders loading state on node row and capped synthetic rows", () => { const nodes = makeNodes([ { id: "root", isDirectory: true, childrenStatus: "loading" }, { @@ -38,7 +38,8 @@ describe("treeview visible rows", () => { { id: "f1", parentId: "dir", isDirectory: false, label: "one.txt" }, ]); const loadingRows = buildVisibleRows(nodes, ["root"], new Set(["root"])); - expect(loadingRows.map((row) => row.kind)).toEqual(["node", "loading"]); + expect(loadingRows.map((row) => row.kind)).toEqual(["node"]); + expect(loadingRows[0].isLoading).toBe(true); const cappedRows = buildVisibleRows(nodes, ["dir"], new Set(["dir"])); expect(cappedRows.map((row) => row.kind)).toEqual(["node", "node", "capped"]); diff --git a/frontend/app/treeview/treeview.tsx b/frontend/app/treeview/treeview.tsx index 2d4e2aa114..e9bf9c1f6f 100644 --- a/frontend/app/treeview/treeview.tsx +++ b/frontend/app/treeview/treeview.tsx @@ -49,6 +49,7 @@ export interface TreeViewVisibleRow { isDirectory?: boolean; isExpanded?: boolean; hasChildren?: boolean; + isLoading?: boolean; icon?: string; node?: TreeNodeData; } @@ -121,7 +122,9 @@ export function buildVisibleRows( return; } const childIds = node.childrenIds ?? []; - const hasChildren = node.isDirectory && (childIds.length > 0 || node.childrenStatus !== "loaded"); + const status = node.childrenStatus ?? "unloaded"; + const isLoading = status === "loading"; + const hasChildren = node.isDirectory && (childIds.length > 0 || status !== "loaded"); const isExpanded = expandedIds.has(id); rows.push({ id, @@ -132,21 +135,14 @@ export function buildVisibleRows( isDirectory: node.isDirectory, isExpanded, hasChildren, + isLoading, icon: node.icon, node, }); if (!isExpanded || !node.isDirectory) { return; } - const status = node.childrenStatus ?? "unloaded"; if (status === "loading") { - rows.push({ - id: `${id}::__loading`, - parentId: id, - depth: depth + 1, - kind: "loading", - label: "Loading…", - }); return; } if (status === "error") { @@ -506,9 +502,13 @@ export const TreeView = forwardRef((props, ref) => { className="flex items-center" style={{ paddingLeft: row.depth * indentWidth, width: ChevronWidth + row.depth * indentWidth }} > - {row.kind === "node" && row.isDirectory && row.hasChildren ? ( + {row.kind === "node" && row.isDirectory && row.isLoading ? ( + + + + ) : row.kind === "node" && row.isDirectory && row.hasChildren ? (
); } diff --git a/frontend/app/tab/vtabbarenv.ts b/frontend/app/tab/vtabbarenv.ts new file mode 100644 index 0000000000..2533780776 --- /dev/null +++ b/frontend/app/tab/vtabbarenv.ts @@ -0,0 +1,41 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { SettingsKeyAtomFnType, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; + +export type VTabBarEnv = WaveEnvSubset<{ + electron: { + createTab: WaveEnv["electron"]["createTab"]; + closeTab: WaveEnv["electron"]["closeTab"]; + setActiveTab: WaveEnv["electron"]["setActiveTab"]; + deleteWorkspace: WaveEnv["electron"]["deleteWorkspace"]; + createWorkspace: WaveEnv["electron"]["createWorkspace"]; + switchWorkspace: WaveEnv["electron"]["switchWorkspace"]; + installAppUpdate: WaveEnv["electron"]["installAppUpdate"]; + }; + rpc: { + UpdateWorkspaceTabIdsCommand: WaveEnv["rpc"]["UpdateWorkspaceTabIdsCommand"]; + UpdateTabNameCommand: WaveEnv["rpc"]["UpdateTabNameCommand"]; + ActivityCommand: WaveEnv["rpc"]["ActivityCommand"]; + SetConfigCommand: WaveEnv["rpc"]["SetConfigCommand"]; + SetMetaCommand: WaveEnv["rpc"]["SetMetaCommand"]; + }; + atoms: { + staticTabId: WaveEnv["atoms"]["staticTabId"]; + fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; + reinitVersion: WaveEnv["atoms"]["reinitVersion"]; + documentHasFocus: WaveEnv["atoms"]["documentHasFocus"]; + workspace: WaveEnv["atoms"]["workspace"]; + updaterStatusAtom: WaveEnv["atoms"]["updaterStatusAtom"]; + isFullScreen: WaveEnv["atoms"]["isFullScreen"]; + }; + services: { + workspace: WaveEnv["services"]["workspace"]; + }; + wos: WaveEnv["wos"]; + showContextMenu: WaveEnv["showContextMenu"]; + getSettingsKeyAtom: SettingsKeyAtomFnType<"tab:confirmclose" | "app:tabbar" | "app:hideaibutton">; + mockSetWaveObj: WaveEnv["mockSetWaveObj"]; + isWindows: WaveEnv["isWindows"]; + isMacOS: WaveEnv["isMacOS"]; +}>; diff --git a/frontend/app/view/webview/webview.test.tsx b/frontend/app/view/webview/webview.test.tsx new file mode 100644 index 0000000000..99302dd2f0 --- /dev/null +++ b/frontend/app/view/webview/webview.test.tsx @@ -0,0 +1,20 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { getWebPreviewDisplayUrl, WebViewPreviewFallback } from "./webview"; + +describe("webview preview fallback", () => { + it("shows the requested URL", () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain("electron webview unavailable"); + expect(markup).toContain("https://waveterm.dev/docs"); + }); + + it("falls back to about:blank when no URL is available", () => { + expect(getWebPreviewDisplayUrl("")).toBe("about:blank"); + expect(getWebPreviewDisplayUrl(null)).toBe("about:blank"); + }); +}); diff --git a/frontend/app/view/webview/webview.tsx b/frontend/app/view/webview/webview.tsx index df50221764..3b10d4cce9 100644 --- a/frontend/app/view/webview/webview.tsx +++ b/frontend/app/view/webview/webview.tsx @@ -14,6 +14,7 @@ import { SuggestionControlNoData, SuggestionControlNoResults, } from "@/app/suggestion/suggestion"; +import { MockBoundary } from "@/app/waveenv/mockboundary"; import { WOS, globalStore } from "@/store/global"; import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil"; import { fireAndForget, useAtomValueSafe } from "@/util/util"; @@ -83,7 +84,7 @@ export class WebViewModel implements ViewModel { const defaultUrlAtom = getSettingsKeyAtom("web:defaulturl"); this.homepageUrl = atom((get) => { const defaultUrl = get(defaultUrlAtom); - const pinnedUrl = get(this.blockAtom).meta.pinnedurl; + const pinnedUrl = get(this.blockAtom)?.meta?.pinnedurl; return pinnedUrl ?? defaultUrl; }); this.urlWrapperClassName = atom(""); @@ -112,7 +113,7 @@ export class WebViewModel implements ViewModel { const refreshIcon = get(this.refreshIcon); const mediaPlaying = get(this.mediaPlaying); const mediaMuted = get(this.mediaMuted); - const url = currUrl ?? metaUrl ?? homepageUrl; + const url = currUrl ?? metaUrl ?? homepageUrl ?? ""; const rtn: HeaderElem[] = []; if (get(this.hideNav)) { return rtn; @@ -802,13 +803,35 @@ interface WebViewProps { initialSrc?: string; } +function getWebPreviewDisplayUrl(url?: string | null): string { + return url?.trim() || "about:blank"; +} + +function WebViewPreviewFallback({ url }: { url?: string | null }) { + const displayUrl = getWebPreviewDisplayUrl(url); + + return ( +
+
+
preview mock · electron webview unavailable
+
web widget placeholder
+
+ {displayUrl} +
+
+
+ ); +} + const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) => { const blockData = useAtomValue(model.blockAtom); const defaultUrl = useAtomValue(model.homepageUrl); const defaultSearchAtom = getSettingsKeyAtom("web:defaultsearch"); const defaultSearch = useAtomValue(defaultSearchAtom); - let metaUrl = blockData?.meta?.url || defaultUrl; - metaUrl = model.ensureUrlScheme(metaUrl, defaultSearch); + let metaUrl = blockData?.meta?.url || defaultUrl || ""; + if (metaUrl) { + metaUrl = model.ensureUrlScheme(metaUrl, defaultSearch); + } const metaUrlRef = useRef(metaUrl); const zoomFactor = useAtomValue(getBlockMetaKeyAtom(model.blockId, "web:zoom")) || 1; const partitionOverride = useAtomValueSafe(model.partitionOverride); @@ -1055,19 +1078,21 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) return ( - + }> + + {errorText && (
{errorText}
@@ -1079,4 +1104,4 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) ); }); -export { WebView }; +export { getWebPreviewDisplayUrl, WebView, WebViewPreviewFallback }; diff --git a/frontend/app/workspace/workspace-layout-model.ts b/frontend/app/workspace/workspace-layout-model.ts index 70ec1c38c1..a87019503c 100644 --- a/frontend/app/workspace/workspace-layout-model.ts +++ b/frontend/app/workspace/workspace-layout-model.ts @@ -1,8 +1,9 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { WaveAIModel } from "@/app/aipanel/waveai-model"; import { globalStore } from "@/app/store/jotaiStore"; +import { isBuilderWindow } from "@/app/store/windowtype"; import * as WOS from "@/app/store/wos"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; @@ -15,47 +16,90 @@ import { ImperativePanelGroupHandle, ImperativePanelHandle } from "react-resizab const dlog = debug("wave:workspace"); -const AIPANEL_DEFAULTWIDTH = 300; -const AIPANEL_DEFAULTWIDTHRATIO = 0.33; -const AIPANEL_MINWIDTH = 300; -const AIPANEL_MAXWIDTHRATIO = 0.66; +const AIPanel_DefaultWidth = 300; +const AIPanel_DefaultWidthRatio = 0.33; +const AIPanel_MinWidth = 300; +const AIPanel_MaxWidthRatio = 0.66; + +const VTabBar_DefaultWidth = 220; +const VTabBar_MinWidth = 110; +const VTabBar_MaxWidth = 280; + type SidePanelView = "waveai" | "fileexplorer"; +function clampVTabWidth(w: number): number { + return Math.max(VTabBar_MinWidth, Math.min(w, VTabBar_MaxWidth)); +} + +function clampAIPanelWidth(w: number, windowWidth: number): number { + const maxWidth = Math.floor(windowWidth * AIPanel_MaxWidthRatio); + if (AIPanel_MinWidth > maxWidth) return AIPanel_MinWidth; + return Math.max(AIPanel_MinWidth, Math.min(w, maxWidth)); +} + class WorkspaceLayoutModel { private static instance: WorkspaceLayoutModel | null = null; aiPanelRef: ImperativePanelHandle | null; - panelGroupRef: ImperativePanelGroupHandle | null; + vtabPanelRef: ImperativePanelHandle | null; + outerPanelGroupRef: ImperativePanelGroupHandle | null; + innerPanelGroupRef: ImperativePanelGroupHandle | null; panelContainerRef: HTMLDivElement | null; aiPanelWrapperRef: HTMLDivElement | null; - inResize: boolean; // prevents recursive setLayout calls (setLayout triggers onLayout which calls setLayout) + panelVisibleAtom: jotai.PrimitiveAtom; + vtabVisibleAtom: jotai.PrimitiveAtom; + activePanelAtom: jotai.PrimitiveAtom; + + private inResize: boolean; + private aiPanelVisible: boolean; private aiPanelWidth: number | null; - private debouncedPersistWidth: (width: number) => void; + private vtabWidth: number; + private vtabVisible: boolean; private initialized: boolean = false; private transitionTimeoutRef: NodeJS.Timeout | null = null; private focusTimeoutRef: NodeJS.Timeout | null = null; - activePanelAtom: jotai.PrimitiveAtom; + private debouncedPersistAIWidth: (width: number) => void; + private debouncedPersistVTabWidth: (width: number) => void; private constructor() { this.aiPanelRef = null; - this.panelGroupRef = null; + this.vtabPanelRef = null; + this.outerPanelGroupRef = null; + this.innerPanelGroupRef = null; this.panelContainerRef = null; this.aiPanelWrapperRef = null; this.inResize = false; + this.aiPanelVisible = false; this.aiPanelWidth = null; + this.vtabWidth = VTabBar_DefaultWidth; + this.vtabVisible = false; + this.panelVisibleAtom = jotai.atom(false); + this.vtabVisibleAtom = jotai.atom(false); this.activePanelAtom = jotai.atom(null) as jotai.PrimitiveAtom; this.handleWindowResize = this.handleWindowResize.bind(this); - this.handlePanelLayout = this.handlePanelLayout.bind(this); + this.handleOuterPanelLayout = this.handleOuterPanelLayout.bind(this); + this.handleInnerPanelLayout = this.handleInnerPanelLayout.bind(this); - this.debouncedPersistWidth = debounce((width: number) => { + this.debouncedPersistAIWidth = debounce((width: number) => { try { RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("tab", this.getTabId()), meta: { "waveai:panelwidth": width }, }); } catch (e) { - console.warn("Failed to persist panel width:", e); + console.warn("Failed to persist AI panel width:", e); + } + }, 300); + + this.debouncedPersistVTabWidth = debounce((width: number) => { + try { + RpcApi.SetMetaCommand(TabRpcClient, { + oref: WOS.makeORef("workspace", this.getWorkspaceId()), + meta: { "layout:vtabbarwidth": width }, + }); + } catch (e) { + console.warn("Failed to persist vtabbar width:", e); } }, 300); } @@ -67,78 +111,204 @@ class WorkspaceLayoutModel { return WorkspaceLayoutModel.instance; } - private initializeFromTabMeta(): void { + private getTabId(): string { + return globalStore.get(atoms.staticTabId); + } + + private getWorkspaceId(): string { + return globalStore.get(atoms.workspace)?.oid ?? ""; + } + + private getPanelOpenAtom(): jotai.Atom { + return getOrefMetaKeyAtom(WOS.makeORef("tab", this.getTabId()), "waveai:panelopen"); + } + + private getPanelWidthAtom(): jotai.Atom { + return getOrefMetaKeyAtom(WOS.makeORef("tab", this.getTabId()), "waveai:panelwidth"); + } + + private getVTabBarWidthAtom(): jotai.Atom { + return getOrefMetaKeyAtom(WOS.makeORef("workspace", this.getWorkspaceId()), "layout:vtabbarwidth"); + } + + private initializeFromMeta(): void { if (this.initialized) return; this.initialized = true; - try { const savedVisible = globalStore.get(this.getPanelOpenAtom()); - const savedWidth = globalStore.get(this.getPanelWidthAtom()); - - if (savedVisible != null && savedVisible) { - globalStore.set(this.activePanelAtom, "waveai"); + const savedAIWidth = globalStore.get(this.getPanelWidthAtom()); + const savedVTabWidth = globalStore.get(this.getVTabBarWidthAtom()); + if (savedVisible != null) { + this.aiPanelVisible = savedVisible; + globalStore.set(this.panelVisibleAtom, savedVisible); + globalStore.set(this.activePanelAtom, savedVisible ? "waveai" : null); } - if (savedWidth != null) { - this.aiPanelWidth = savedWidth; + if (savedAIWidth != null) { + this.aiPanelWidth = savedAIWidth; + } + if (savedVTabWidth != null && savedVTabWidth > 0) { + this.vtabWidth = savedVTabWidth; } } catch (e) { console.warn("Failed to initialize from tab meta:", e); } } - private getTabId(): string { - return globalStore.get(atoms.staticTabId); + private getResolvedAIWidth(windowWidth: number): number { + this.initializeFromMeta(); + let w = this.aiPanelWidth; + if (w == null) { + w = Math.max(AIPanel_DefaultWidth, windowWidth * AIPanel_DefaultWidthRatio); + this.aiPanelWidth = w; + } + return clampAIPanelWidth(w, windowWidth); } - private getPanelOpenAtom(): jotai.Atom { - const tabORef = WOS.makeORef("tab", this.getTabId()); - return getOrefMetaKeyAtom(tabORef, "waveai:panelopen"); + private getResolvedVTabWidth(): number { + this.initializeFromMeta(); + return clampVTabWidth(this.vtabWidth); } - private getPanelWidthAtom(): jotai.Atom { - const tabORef = WOS.makeORef("tab", this.getTabId()); - return getOrefMetaKeyAtom(tabORef, "waveai:panelwidth"); + private computeLayout(windowWidth: number): { outer: number[]; inner: number[] } { + const vtabW = this.vtabVisible ? this.getResolvedVTabWidth() : 0; + const aiW = this.aiPanelVisible ? this.getResolvedAIWidth(windowWidth) : 0; + const leftGroupW = vtabW + aiW; + + const leftPct = windowWidth > 0 ? (leftGroupW / windowWidth) * 100 : 0; + const contentPct = Math.max(0, 100 - leftPct); + + let vtabPct: number; + let aiPct: number; + if (leftGroupW > 0) { + vtabPct = (vtabW / leftGroupW) * 100; + aiPct = 100 - vtabPct; + } else { + vtabPct = 50; + aiPct = 50; + } + + return { outer: [leftPct, contentPct], inner: [vtabPct, aiPct] }; + } + + private commitLayouts(windowWidth: number): void { + if (!this.outerPanelGroupRef || !this.innerPanelGroupRef) return; + const { outer, inner } = this.computeLayout(windowWidth); + this.inResize = true; + this.outerPanelGroupRef.setLayout(outer); + this.innerPanelGroupRef.setLayout(inner); + this.inResize = false; + this.updateWrapperWidth(); + } + + handleOuterPanelLayout(sizes: number[]): void { + if (this.inResize) return; + const windowWidth = window.innerWidth; + const newLeftGroupPx = (sizes[0] / 100) * windowWidth; + + if (this.vtabVisible && this.aiPanelVisible) { + const vtabW = this.getResolvedVTabWidth(); + const newAIW = clampAIPanelWidth(newLeftGroupPx - vtabW, windowWidth); + this.aiPanelWidth = newAIW; + this.debouncedPersistAIWidth(newAIW); + } else if (this.vtabVisible) { + const clamped = clampVTabWidth(newLeftGroupPx); + this.vtabWidth = clamped; + this.debouncedPersistVTabWidth(clamped); + } else if (this.aiPanelVisible) { + const clamped = clampAIPanelWidth(newLeftGroupPx, windowWidth); + this.aiPanelWidth = clamped; + this.debouncedPersistAIWidth(clamped); + } + + this.commitLayouts(windowWidth); + } + + handleInnerPanelLayout(sizes: number[]): void { + if (this.inResize) return; + if (!this.vtabVisible || !this.aiPanelVisible) return; + + const windowWidth = window.innerWidth; + const vtabW = this.getResolvedVTabWidth(); + const aiW = this.getResolvedAIWidth(windowWidth); + const leftGroupW = vtabW + aiW; + + const newVTabW = (sizes[0] / 100) * leftGroupW; + const clampedVTab = clampVTabWidth(newVTabW); + const newAIW = clampAIPanelWidth(leftGroupW - clampedVTab, windowWidth); + + if (clampedVTab !== this.vtabWidth) { + this.vtabWidth = clampedVTab; + this.debouncedPersistVTabWidth(clampedVTab); + } + if (newAIW !== this.aiPanelWidth) { + this.aiPanelWidth = newAIW; + this.debouncedPersistAIWidth(newAIW); + } + + this.commitLayouts(windowWidth); + } + + handleWindowResize(): void { + this.commitLayouts(window.innerWidth); + } + + syncVTabWidthFromMeta(): void { + const savedVTabWidth = globalStore.get(this.getVTabBarWidthAtom()); + if (savedVTabWidth != null && savedVTabWidth > 0 && savedVTabWidth !== this.vtabWidth) { + this.vtabWidth = savedVTabWidth; + this.commitLayouts(window.innerWidth); + } } registerRefs( aiPanelRef: ImperativePanelHandle, - panelGroupRef: ImperativePanelGroupHandle, + outerPanelGroupRef: ImperativePanelGroupHandle, + innerPanelGroupRef: ImperativePanelGroupHandle, panelContainerRef: HTMLDivElement, - aiPanelWrapperRef: HTMLDivElement + aiPanelWrapperRef: HTMLDivElement, + vtabPanelRef?: ImperativePanelHandle, + showLeftTabBar?: boolean ): void { this.aiPanelRef = aiPanelRef; - this.panelGroupRef = panelGroupRef; + this.vtabPanelRef = vtabPanelRef ?? null; + this.outerPanelGroupRef = outerPanelGroupRef; + this.innerPanelGroupRef = innerPanelGroupRef; this.panelContainerRef = panelContainerRef; this.aiPanelWrapperRef = aiPanelWrapperRef; - this.syncAIPanelRef(); - this.updateWrapperWidth(); + this.vtabVisible = showLeftTabBar ?? false; + globalStore.set(this.vtabVisibleAtom, this.vtabVisible); + this.syncPanelCollapse(); + this.commitLayouts(window.innerWidth); } - updateWrapperWidth(): void { - if (!this.aiPanelWrapperRef) { - return; + private syncPanelCollapse(): void { + if (this.aiPanelRef) { + if (this.aiPanelVisible) { + this.aiPanelRef.expand(); + } else { + this.aiPanelRef.collapse(); + } + } + if (this.vtabPanelRef) { + if (this.vtabVisible) { + this.vtabPanelRef.expand(); + } else { + this.vtabPanelRef.collapse(); + } } - const width = this.getAIPanelWidth(); - const clampedWidth = this.getClampedAIPanelWidth(width, window.innerWidth); - this.aiPanelWrapperRef.style.width = `${clampedWidth}px`; } enableTransitions(duration: number): void { - if (!this.panelContainerRef) { - return; - } + if (!this.panelContainerRef) return; const panels = this.panelContainerRef.querySelectorAll("[data-panel]"); panels.forEach((panel: HTMLElement) => { panel.style.transition = "flex 0.2s ease-in-out"; }); - if (this.transitionTimeoutRef) { clearTimeout(this.transitionTimeoutRef); } this.transitionTimeoutRef = setTimeout(() => { - if (!this.panelContainerRef) { - return; - } + if (!this.panelContainerRef) return; const panels = this.panelContainerRef.querySelectorAll("[data-panel]"); panels.forEach((panel: HTMLElement) => { panel.style.transition = "none"; @@ -146,80 +316,49 @@ class WorkspaceLayoutModel { }, duration); } - handleWindowResize(): void { - if (!this.panelGroupRef) { - return; - } - const newWindowWidth = window.innerWidth; - const aiPanelPercentage = this.getAIPanelPercentage(newWindowWidth); - const mainContentPercentage = this.getMainContentPercentage(newWindowWidth); - this.inResize = true; - const layout = [aiPanelPercentage, mainContentPercentage]; - this.panelGroupRef.setLayout(layout); - this.inResize = false; - this.updateWrapperWidth(); + updateWrapperWidth(): void { + if (!this.aiPanelWrapperRef) return; + const width = this.getResolvedAIWidth(window.innerWidth); + this.aiPanelWrapperRef.style.width = `${width}px`; } - handlePanelLayout(sizes: number[]): void { - // dlog("handlePanelLayout", "inResize:", this.inResize, "sizes:", sizes); - if (this.inResize) { - return; - } - if (!this.panelGroupRef) { - return; - } - - const currentWindowWidth = window.innerWidth; - const aiPanelPixelWidth = (sizes[0] / 100) * currentWindowWidth; - this.handleAIPanelResize(aiPanelPixelWidth, currentWindowWidth); - const newPercentage = this.getAIPanelPercentage(currentWindowWidth); - const mainContentPercentage = 100 - newPercentage; - this.inResize = true; - const layout = [newPercentage, mainContentPercentage]; - this.panelGroupRef.setLayout(layout); - this.inResize = false; + getAIPanelVisible(): boolean { + this.initializeFromMeta(); + return this.aiPanelVisible; } - syncAIPanelRef(): void { - if (!this.aiPanelRef || !this.panelGroupRef) { - return; - } - - const currentWindowWidth = window.innerWidth; - const aiPanelPercentage = this.getAIPanelPercentage(currentWindowWidth); - const mainContentPercentage = this.getMainContentPercentage(currentWindowWidth); - - if (this.getAIPanelVisible()) { - this.aiPanelRef.expand(); - } else { - this.aiPanelRef.collapse(); - } - - this.inResize = true; - const layout = [aiPanelPercentage, mainContentPercentage]; - this.panelGroupRef.setLayout(layout); - this.inResize = false; + getAIPanelWidth(): number { + return this.getResolvedAIWidth(window.innerWidth); } - getMaxAIPanelWidth(windowWidth: number): number { - return Math.floor(windowWidth * AIPANEL_MAXWIDTHRATIO); + getActivePanel(): SidePanelView | null { + return globalStore.get(this.activePanelAtom); } - getClampedAIPanelWidth(width: number, windowWidth: number): number { - const maxWidth = this.getMaxAIPanelWidth(windowWidth); - if (AIPANEL_MINWIDTH > maxWidth) { - return AIPANEL_MINWIDTH; - } - return Math.max(AIPANEL_MINWIDTH, Math.min(width, maxWidth)); + getLeftGroupInitialPercentage(windowWidth: number, showLeftTabBar: boolean): number { + this.initializeFromMeta(); + const vtabW = showLeftTabBar && !isBuilderWindow() ? this.getResolvedVTabWidth() : 0; + const aiW = this.aiPanelVisible ? this.getResolvedAIWidth(windowWidth) : 0; + return ((vtabW + aiW) / windowWidth) * 100; } - getAIPanelVisible(): boolean { - this.initializeFromTabMeta(); - return globalStore.get(this.activePanelAtom) != null; + getInnerVTabInitialPercentage(windowWidth: number, showLeftTabBar: boolean): number { + if (!showLeftTabBar || isBuilderWindow()) return 0; + this.initializeFromMeta(); + const vtabW = this.getResolvedVTabWidth(); + const aiW = this.aiPanelVisible ? this.getResolvedAIWidth(windowWidth) : 0; + const total = vtabW + aiW; + if (total === 0) return 50; + return (vtabW / total) * 100; } - getActivePanel(): SidePanelView | null { - return globalStore.get(this.activePanelAtom); + getInnerAIPanelInitialPercentage(windowWidth: number, showLeftTabBar: boolean): number { + this.initializeFromMeta(); + const vtabW = showLeftTabBar && !isBuilderWindow() ? this.getResolvedVTabWidth() : 0; + const aiW = this.aiPanelVisible ? this.getResolvedAIWidth(windowWidth) : 0; + const total = vtabW + aiW; + if (total === 0) return 50; + return (aiW / total) * 100; } openPanel(panel: SidePanelView, opts?: { nofocus?: boolean }): void { @@ -230,8 +369,10 @@ class WorkspaceLayoutModel { clearTimeout(this.focusTimeoutRef); this.focusTimeoutRef = null; } - const wasVisible = globalStore.get(this.activePanelAtom) != null; + const wasVisible = this.aiPanelVisible; + this.aiPanelVisible = true; globalStore.set(this.activePanelAtom, panel); + globalStore.set(this.panelVisibleAtom, true); if (!wasVisible) { if (panel === "waveai") { recordTEvent("action:openwaveai"); @@ -245,7 +386,8 @@ class WorkspaceLayoutModel { meta: { "waveai:panelopen": true }, }); this.enableTransitions(250); - this.syncAIPanelRef(); + this.syncPanelCollapse(); + this.commitLayouts(window.innerWidth); if (panel === "waveai" && !opts?.nofocus) { this.focusTimeoutRef = setTimeout(() => { @@ -260,14 +402,17 @@ class WorkspaceLayoutModel { clearTimeout(this.focusTimeoutRef); this.focusTimeoutRef = null; } + this.aiPanelVisible = false; globalStore.set(this.activePanelAtom, null); + globalStore.set(this.panelVisibleAtom, false); getApi().setWaveAIOpen(false); RpcApi.SetMetaCommand(TabRpcClient, { oref: WOS.makeORef("tab", this.getTabId()), meta: { "waveai:panelopen": false }, }); this.enableTransitions(250); - this.syncAIPanelRef(); + this.syncPanelCollapse(); + this.commitLayouts(window.innerWidth); const layoutModel = getLayoutModelForStaticTab(); const focusedNode = globalStore.get(layoutModel.focusedNode); @@ -303,47 +448,20 @@ class WorkspaceLayoutModel { } if (visible) { this.openPanel("fileexplorer", { nofocus: true }); - } else { - this.closePanel(); - } - } - - getAIPanelWidth(): number { - this.initializeFromTabMeta(); - if (this.aiPanelWidth == null) { - this.aiPanelWidth = Math.max(AIPANEL_DEFAULTWIDTH, window.innerWidth * AIPANEL_DEFAULTWIDTHRATIO); + return; } - return this.aiPanelWidth; - } - - setAIPanelWidth(width: number): void { - this.aiPanelWidth = width; - this.updateWrapperWidth(); - this.debouncedPersistWidth(width); - } - - getAIPanelPercentage(windowWidth: number): number { - const isVisible = this.getAIPanelVisible(); - if (!isVisible) { - return 0; + if (this.getActivePanel() === "fileexplorer") { + this.closePanel(); } - const aiPanelWidth = this.getAIPanelWidth(); - const clampedWidth = this.getClampedAIPanelWidth(aiPanelWidth, windowWidth); - const percentage = (clampedWidth / windowWidth) * 100; - return Math.max(0, Math.min(percentage, 100)); - } - - getMainContentPercentage(windowWidth: number): number { - const aiPanelPercentage = this.getAIPanelPercentage(windowWidth); - return Math.max(0, 100 - aiPanelPercentage); } - handleAIPanelResize(width: number, windowWidth: number): void { - if (!this.getAIPanelVisible()) { - return; - } - const clampedWidth = this.getClampedAIPanelWidth(width, windowWidth); - this.setAIPanelWidth(clampedWidth); + setShowLeftTabBar(showLeftTabBar: boolean): void { + if (this.vtabVisible === showLeftTabBar) return; + this.vtabVisible = showLeftTabBar; + globalStore.set(this.vtabVisibleAtom, showLeftTabBar); + this.enableTransitions(250); + this.syncPanelCollapse(); + this.commitLayouts(window.innerWidth); } } diff --git a/frontend/app/workspace/workspace.tsx b/frontend/app/workspace/workspace.tsx index c34677b618..1904b1e101 100644 --- a/frontend/app/workspace/workspace.tsx +++ b/frontend/app/workspace/workspace.tsx @@ -1,16 +1,18 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { AIPanel } from "@/app/aipanel/aipanel"; import { ErrorBoundary } from "@/app/element/errorboundary"; -import { FileExplorerPanel } from "@/app/fileexplorer/fileexplorer"; import { CenteredDiv } from "@/app/element/quickelems"; +import { FileExplorerPanel } from "@/app/fileexplorer/fileexplorer"; import { ModalsRenderer } from "@/app/modals/modalsrenderer"; import { TabBar } from "@/app/tab/tabbar"; import { TabContent } from "@/app/tab/tabcontent"; -import { Widgets } from "@/app/workspace/widgets"; +import { VTabBar } from "@/app/tab/vtabbar"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; -import { atoms, getApi, isDev } from "@/store/global"; +import { Widgets } from "@/app/workspace/widgets"; +import { atoms, getApi, getSettingsKeyAtom } from "@/store/global"; +import { isMacOS } from "@/util/platformutil"; import { useAtomValue } from "jotai"; import { memo, useEffect, useRef } from "react"; import { @@ -21,24 +23,59 @@ import { PanelResizeHandle, } from "react-resizable-panels"; +const MacOSTabBarSpacer = memo(() => { + return ( +
+ ); +}); +MacOSTabBarSpacer.displayName = "MacOSTabBarSpacer"; + const WorkspaceElem = memo(() => { const workspaceLayoutModel = WorkspaceLayoutModel.getInstance(); const tabId = useAtomValue(atoms.staticTabId); const ws = useAtomValue(atoms.workspace); + const tabBarPosition = useAtomValue(getSettingsKeyAtom("app:tabbar")) ?? "top"; + const showLeftTabBar = tabBarPosition === "left"; const activePanel = useAtomValue(workspaceLayoutModel.activePanelAtom); - const initialAiPanelPercentage = workspaceLayoutModel.getAIPanelPercentage(window.innerWidth); - const panelGroupRef = useRef(null); + const aiPanelVisible = useAtomValue(workspaceLayoutModel.panelVisibleAtom); + const vtabVisible = useAtomValue(workspaceLayoutModel.vtabVisibleAtom); + const windowWidth = window.innerWidth; + const leftGroupInitialPct = workspaceLayoutModel.getLeftGroupInitialPercentage(windowWidth, showLeftTabBar); + const innerVTabInitialPct = workspaceLayoutModel.getInnerVTabInitialPercentage(windowWidth, showLeftTabBar); + const innerAIPanelInitialPct = workspaceLayoutModel.getInnerAIPanelInitialPercentage(windowWidth, showLeftTabBar); + const outerPanelGroupRef = useRef(null); + const innerPanelGroupRef = useRef(null); const aiPanelRef = useRef(null); + const vtabPanelRef = useRef(null); const panelContainerRef = useRef(null); const aiPanelWrapperRef = useRef(null); useEffect(() => { - if (aiPanelRef.current && panelGroupRef.current && panelContainerRef.current && aiPanelWrapperRef.current) { + if ( + aiPanelRef.current && + outerPanelGroupRef.current && + innerPanelGroupRef.current && + panelContainerRef.current && + aiPanelWrapperRef.current + ) { workspaceLayoutModel.registerRefs( aiPanelRef.current, - panelGroupRef.current, + outerPanelGroupRef.current, + innerPanelGroupRef.current, panelContainerRef.current, - aiPanelWrapperRef.current + aiPanelWrapperRef.current, + vtabPanelRef.current ?? undefined, + showLeftTabBar ); } }, []); @@ -48,40 +85,81 @@ const WorkspaceElem = memo(() => { getApi().setWaveAIOpen(isVisible); }, []); + useEffect(() => { + workspaceLayoutModel.setShowLeftTabBar(showLeftTabBar); + }, [showLeftTabBar]); + useEffect(() => { window.addEventListener("resize", workspaceLayoutModel.handleWindowResize); return () => window.removeEventListener("resize", workspaceLayoutModel.handleWindowResize); }, []); + useEffect(() => { + const handleFocus = () => workspaceLayoutModel.syncVTabWidthFromMeta(); + window.addEventListener("focus", handleFocus); + return () => window.removeEventListener("focus", handleFocus); + }, []); + + const innerHandleVisible = vtabVisible && aiPanelVisible; + const innerHandleClass = `bg-transparent hover:bg-zinc-500/20 transition-colors ${innerHandleVisible ? "w-0.5" : "w-0 pointer-events-none"}`; + const outerHandleVisible = vtabVisible || aiPanelVisible; + const outerHandleClass = `bg-transparent hover:bg-zinc-500/20 transition-colors ${outerHandleVisible ? "w-0.5" : "w-0 pointer-events-none"}`; + return (
- + {!(showLeftTabBar && isMacOS()) && } + {showLeftTabBar && isMacOS() && }
- -
- {tabId !== "" && - (isDev() && activePanel === "fileexplorer" ? : )} -
+ + + + {showLeftTabBar && } + + + +
+ {tabId !== "" && + (activePanel === "fileexplorer" ? ( + + ) : ( + + ))} +
+
+
- - + + {tabId === "" ? ( No Active Tab ) : (
- +
)} diff --git a/frontend/builder/builder-workspace.tsx b/frontend/builder/builder-workspace.tsx index 94a06fad83..aab14ff458 100644 --- a/frontend/builder/builder-workspace.tsx +++ b/frontend/builder/builder-workspace.tsx @@ -97,7 +97,7 @@ const BuilderWorkspace = memo(() => {
- + diff --git a/frontend/preview/mock/mockfilesystem.ts b/frontend/preview/mock/mockfilesystem.ts new file mode 100644 index 0000000000..6652bbb3fe --- /dev/null +++ b/frontend/preview/mock/mockfilesystem.ts @@ -0,0 +1,543 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { arrayToBase64 } from "@/util/util"; + +const MockHomePath = "/Users/mike"; +const MockDirMimeType = "directory"; +const MockDirMode = 0o040755; +const MockFileMode = 0o100644; +const MockDirectoryChunkSize = 128; +const MockFileChunkSize = 64 * 1024; +const MockBaseModTime = Date.parse("2026-03-10T09:00:00.000Z"); +const TinyPngBytes = Uint8Array.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x04, 0x00, 0x00, 0x00, 0xb5, 0x1c, 0x0c, + 0x02, 0x00, 0x00, 0x00, 0x0b, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, 0xfc, 0xff, 0x1f, 0x00, + 0x03, 0x03, 0x01, 0xff, 0xa5, 0xf8, 0x8f, 0xb1, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, + 0xae, 0x42, 0x60, 0x82, +]); +const TinyJpegBytes = Uint8Array.from([ + 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, + 0x00, 0x01, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43, 0x00, 0x03, 0x02, 0x02, 0x03, 0x02, 0x02, 0x03, + 0x03, 0x03, 0x03, 0x04, 0x03, 0x03, 0x04, 0x05, 0x08, 0x05, 0x05, 0x04, 0x04, 0x05, 0x0a, 0x07, + 0x07, 0x06, 0x08, 0x0c, 0x0a, 0x0c, 0x0c, 0x0b, 0x0a, 0x0b, 0x0b, 0x0d, 0x0e, 0x12, 0x10, 0x0d, + 0x0e, 0x11, 0x0e, 0x0b, 0x0b, 0x10, 0x16, 0x10, 0x11, 0x13, 0x14, 0x15, 0x15, 0x15, 0x0c, 0x0f, + 0x17, 0x18, 0x16, 0x14, 0x18, 0x12, 0x14, 0x15, 0x14, 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x01, + 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xff, 0xc4, 0x00, 0x14, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0xff, 0xc4, 0x00, 0x14, + 0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00, 0xbf, 0xff, 0xd9, +]); + +type MockFsEntry = { + path: string; + dir: string; + name: string; + isdir: boolean; + mimetype: string; + modtime: number; + mode: number; + size: number; + readonly?: boolean; + supportsmkdir?: boolean; + content?: Uint8Array; +}; + +type MockFsEntryInput = { + path: string; + isdir?: boolean; + mimetype?: string; + readonly?: boolean; + content?: string | Uint8Array; +}; + +export type MockFilesystem = { + homePath: string; + fileCount: number; + directoryCount: number; + entryCount: number; + fileInfo: (data: FileData) => Promise; + fileRead: (data: FileData) => Promise; + fileList: (data: FileListData) => Promise; + fileJoin: (paths: string[]) => Promise; + fileReadStream: (data: FileData) => AsyncGenerator; + fileListStream: (data: FileListData) => AsyncGenerator; +}; + +function normalizeMockPath(path: string, basePath = MockHomePath): string { + if (path == null || path === "") { + return basePath; + } + if (path.startsWith("wsh://")) { + const url = new URL(path); + path = url.pathname.replace(/^\/+/, "/"); + } + if (path === "~") { + path = MockHomePath; + } else if (path.startsWith("~/")) { + path = MockHomePath + path.slice(1); + } + if (!path.startsWith("/")) { + path = `${basePath}/${path}`; + } + const parts = path.split("/"); + const resolvedParts: string[] = []; + for (const part of parts) { + if (!part || part === ".") { + continue; + } + if (part === "..") { + resolvedParts.pop(); + continue; + } + resolvedParts.push(part); + } + const resolvedPath = "/" + resolvedParts.join("/"); + return resolvedPath === "" ? "/" : resolvedPath; +} + +function getDirName(path: string): string { + if (path === "/") { + return "/"; + } + const idx = path.lastIndexOf("/"); + if (idx <= 0) { + return "/"; + } + return path.slice(0, idx); +} + +function getBaseName(path: string): string { + if (path === "/") { + return "/"; + } + const idx = path.lastIndexOf("/"); + return idx < 0 ? path : path.slice(idx + 1); +} + +function getMimeType(path: string, isdir: boolean): string { + if (isdir) { + return MockDirMimeType; + } + if (path.endsWith(".md")) { + return "text/markdown"; + } + if (path.endsWith(".json")) { + return "application/json"; + } + if (path.endsWith(".ts")) { + return "text/typescript"; + } + if (path.endsWith(".tsx")) { + return "text/tsx"; + } + if (path.endsWith(".js")) { + return "text/javascript"; + } + if (path.endsWith(".txt") || path.endsWith(".log") || path.endsWith(".bashrc") || path.endsWith(".zprofile")) { + return "text/plain"; + } + if (path.endsWith(".png")) { + return "image/png"; + } + if (path.endsWith(".jpg") || path.endsWith(".jpeg")) { + return "image/jpeg"; + } + if (path.endsWith(".pdf")) { + return "application/pdf"; + } + if (path.endsWith(".zip")) { + return "application/zip"; + } + if (path.endsWith(".dmg")) { + return "application/x-apple-diskimage"; + } + if (path.endsWith(".svg")) { + return "image/svg+xml"; + } + if (path.endsWith(".yaml") || path.endsWith(".yml")) { + return "application/yaml"; + } + return "application/octet-stream"; +} + +function makeContentBytes(content: string | Uint8Array): Uint8Array { + if (content instanceof Uint8Array) { + return content; + } + return new TextEncoder().encode(content); +} + +function makeMockFsInput(path: string, content?: string | Uint8Array, mimetype?: string): MockFsEntryInput { + return { path, content, mimetype }; +} + +function createMockFilesystemEntries(): MockFsEntryInput[] { + const entries: MockFsEntryInput[] = [ + { path: "/", isdir: true }, + { path: "/Users", isdir: true }, + { path: MockHomePath, isdir: true }, + { path: `${MockHomePath}/Desktop`, isdir: true }, + { path: `${MockHomePath}/Documents`, isdir: true }, + { path: `${MockHomePath}/Downloads`, isdir: true }, + { path: `${MockHomePath}/Pictures`, isdir: true }, + { path: `${MockHomePath}/Projects`, isdir: true }, + { path: `${MockHomePath}/waveterm`, isdir: true }, + { path: `${MockHomePath}/waveterm/docs`, isdir: true }, + { path: `${MockHomePath}/waveterm/images`, isdir: true }, + { path: `${MockHomePath}/.config`, isdir: true }, + makeMockFsInput( + `${MockHomePath}/.bashrc`, + `export PATH="$HOME/bin:$PATH"\nalias gs="git status -sb"\nexport WAVETERM_THEME="midnight"\n`, + "text/plain" + ), + makeMockFsInput(`${MockHomePath}/.gitconfig`), + makeMockFsInput(`${MockHomePath}/.zprofile`), + makeMockFsInput(`${MockHomePath}/todo.txt`), + makeMockFsInput(`${MockHomePath}/notes.txt`), + makeMockFsInput(`${MockHomePath}/shell-aliases`), + makeMockFsInput(`${MockHomePath}/archive.log`), + makeMockFsInput(`${MockHomePath}/session.txt`), + makeMockFsInput(`${MockHomePath}/Desktop/launch-plan.md`), + makeMockFsInput(`${MockHomePath}/Desktop/coffee.txt`), + makeMockFsInput(`${MockHomePath}/Desktop/daily-standup.txt`), + makeMockFsInput(`${MockHomePath}/Desktop/snippets.txt`), + makeMockFsInput(`${MockHomePath}/Desktop/terminal-theme.png`), + makeMockFsInput(`${MockHomePath}/Desktop/macos-shortcuts.txt`), + makeMockFsInput(`${MockHomePath}/Desktop/bug-scrub.txt`), + makeMockFsInput(`${MockHomePath}/Desktop/parking-receipt.pdf`), + makeMockFsInput(`${MockHomePath}/Desktop/demo-script.md`), + makeMockFsInput(`${MockHomePath}/Desktop/roadmap-draft.txt`), + makeMockFsInput(`${MockHomePath}/Desktop/pairing-notes.txt`), + makeMockFsInput(`${MockHomePath}/Desktop/wave-window.jpg`), + makeMockFsInput( + `${MockHomePath}/Documents/meeting-notes.md`, + `# File Preview Notes\n\n- Build a richer preview mock environment.\n- Add a fake filesystem rooted at \`${MockHomePath}\`.\n- Make markdown previews resolve relative assets.\n`, + "text/markdown" + ), + makeMockFsInput(`${MockHomePath}/Documents/architecture-overview.md`), + makeMockFsInput(`${MockHomePath}/Documents/release-checklist.md`), + makeMockFsInput(`${MockHomePath}/Documents/ideas.txt`), + makeMockFsInput(`${MockHomePath}/Documents/customer-feedback.txt`), + makeMockFsInput(`${MockHomePath}/Documents/cli-ux-notes.txt`), + makeMockFsInput(`${MockHomePath}/Documents/migration-plan.md`), + makeMockFsInput(`${MockHomePath}/Documents/design-review.md`), + makeMockFsInput(`${MockHomePath}/Documents/ops-runbook.md`), + makeMockFsInput(`${MockHomePath}/Documents/troubleshooting.txt`), + makeMockFsInput(`${MockHomePath}/Documents/preview-fixtures.txt`), + makeMockFsInput(`${MockHomePath}/Documents/backlog.txt`), + makeMockFsInput(`${MockHomePath}/Documents/feature-flags.yaml`), + makeMockFsInput(`${MockHomePath}/Documents/connections.csv`), + makeMockFsInput(`${MockHomePath}/Documents/ssh-hosts.txt`), + makeMockFsInput(`${MockHomePath}/Documents/notes-2026-03-01.md`), + makeMockFsInput(`${MockHomePath}/Documents/notes-2026-03-05.md`), + makeMockFsInput(`${MockHomePath}/Documents/notes-2026-03-09.md`), + makeMockFsInput(`${MockHomePath}/Downloads/waveterm-nightly.dmg`), + makeMockFsInput(`${MockHomePath}/Downloads/screenshot-pack.zip`), + makeMockFsInput(`${MockHomePath}/Downloads/cli-reference.pdf`), + makeMockFsInput(`${MockHomePath}/Downloads/ssh-cheatsheet.pdf`), + makeMockFsInput(`${MockHomePath}/Downloads/perf-trace.json`), + makeMockFsInput(`${MockHomePath}/Downloads/terminal-icons.zip`), + makeMockFsInput(`${MockHomePath}/Downloads/demo-data.csv`), + makeMockFsInput(`${MockHomePath}/Downloads/deploy-plan.txt`), + makeMockFsInput(`${MockHomePath}/Downloads/customer-audio.m4a`), + makeMockFsInput(`${MockHomePath}/Downloads/mock-shell-history.txt`), + makeMockFsInput(`${MockHomePath}/Downloads/design-assets.zip`), + makeMockFsInput(`${MockHomePath}/Downloads/old-preview-build.dmg`), + makeMockFsInput(`${MockHomePath}/Downloads/testing-samples.tar`), + makeMockFsInput(`${MockHomePath}/Downloads/workflow-failure.log`), + makeMockFsInput(`${MockHomePath}/Downloads/team-photo.jpg`), + makeMockFsInput(`${MockHomePath}/Downloads/preview-recording.mov`), + makeMockFsInput(`${MockHomePath}/Downloads/standup-notes.txt`), + makeMockFsInput(`${MockHomePath}/Downloads/metadata.json`), + makeMockFsInput(`${MockHomePath}/Pictures/beach-sunrise.png`, TinyPngBytes, "image/png"), + makeMockFsInput(`${MockHomePath}/Pictures/terminal-screenshot.jpg`, TinyJpegBytes, "image/jpeg"), + makeMockFsInput(`${MockHomePath}/Pictures/diagram.png`), + makeMockFsInput(`${MockHomePath}/Pictures/launch-party.jpg`), + makeMockFsInput(`${MockHomePath}/Pictures/icon-sketch.png`), + makeMockFsInput(`${MockHomePath}/Pictures/backgrounds-01.png`), + makeMockFsInput(`${MockHomePath}/Pictures/backgrounds-02.png`), + makeMockFsInput(`${MockHomePath}/Pictures/backgrounds-03.png`), + makeMockFsInput(`${MockHomePath}/Pictures/backgrounds-04.png`), + makeMockFsInput(`${MockHomePath}/Pictures/backgrounds-05.png`), + makeMockFsInput(`${MockHomePath}/Pictures/product-shot-01.jpg`), + makeMockFsInput(`${MockHomePath}/Pictures/product-shot-02.jpg`), + makeMockFsInput(`${MockHomePath}/Pictures/product-shot-03.jpg`), + makeMockFsInput(`${MockHomePath}/Pictures/product-shot-04.jpg`), + makeMockFsInput(`${MockHomePath}/Pictures/product-shot-05.jpg`), + makeMockFsInput(`${MockHomePath}/Pictures/ui-concept.png`), + makeMockFsInput(`${MockHomePath}/Projects/local.env`), + makeMockFsInput(`${MockHomePath}/Projects/db-migration.sql`), + makeMockFsInput(`${MockHomePath}/Projects/prompt-lab.txt`), + makeMockFsInput(`${MockHomePath}/Projects/ui-spikes.tsx`), + makeMockFsInput(`${MockHomePath}/Projects/file-browser.tsx`), + makeMockFsInput(`${MockHomePath}/Projects/mock-data.json`), + makeMockFsInput(`${MockHomePath}/Projects/preview-api.ts`), + makeMockFsInput(`${MockHomePath}/Projects/bug-181.txt`), + makeMockFsInput( + `${MockHomePath}/waveterm/README.md`, + `# Mock WaveTerm Repo\n\nThis fake repo exists only in the preview environment.\nIt gives file previews something realistic to browse.\n`, + "text/markdown" + ), + makeMockFsInput(`${MockHomePath}/waveterm/package.json`), + makeMockFsInput(`${MockHomePath}/waveterm/tsconfig.json`), + makeMockFsInput(`${MockHomePath}/waveterm/Taskfile.yml`), + makeMockFsInput(`${MockHomePath}/waveterm/preview-model.tsx`), + makeMockFsInput(`${MockHomePath}/waveterm/mockwaveenv.ts`), + makeMockFsInput(`${MockHomePath}/waveterm/vite.config.ts`), + makeMockFsInput(`${MockHomePath}/waveterm/CHANGELOG.md`), + makeMockFsInput( + `${MockHomePath}/waveterm/docs/preview-notes.md`, + `# Preview Mocking\n\nUse the preview server to iterate on file previews without Electron.\nRelative markdown assets should resolve through \`FileJoinCommand\`.\n`, + "text/markdown" + ), + makeMockFsInput(`${MockHomePath}/waveterm/docs/filesystem-rpc.md`), + makeMockFsInput(`${MockHomePath}/waveterm/docs/test-plan.md`), + makeMockFsInput(`${MockHomePath}/waveterm/docs/connections.md`), + makeMockFsInput(`${MockHomePath}/waveterm/docs/preview-gallery.md`), + makeMockFsInput(`${MockHomePath}/waveterm/docs/release-notes.md`), + makeMockFsInput(`${MockHomePath}/waveterm/images/wave-logo.png`, TinyPngBytes, "image/png"), + makeMockFsInput(`${MockHomePath}/waveterm/images/hero.png`), + makeMockFsInput(`${MockHomePath}/waveterm/images/avatar.jpg`), + makeMockFsInput(`${MockHomePath}/waveterm/images/icon-16.png`), + makeMockFsInput(`${MockHomePath}/waveterm/images/icon-32.png`), + makeMockFsInput(`${MockHomePath}/waveterm/images/splash.jpg`), + makeMockFsInput( + `${MockHomePath}/.config/settings.json`, + JSON.stringify( + { + "app:theme": "wave-dark", + "preview:lastpath": `${MockHomePath}/Documents/meeting-notes.md`, + "window:magnifiedblockopacity": 0.92, + }, + null, + 2 + ), + "application/json" + ), + makeMockFsInput(`${MockHomePath}/.config/preview-cache.json`), + makeMockFsInput(`${MockHomePath}/.config/recent-workspaces.json`), + makeMockFsInput(`${MockHomePath}/.config/telemetry.log`), + ]; + return entries; +} + +function buildEntries(): Map { + const inputs = createMockFilesystemEntries(); + const entries = new Map(); + const ensureDir = (path: string) => { + const normalizedPath = normalizeMockPath(path, "/"); + if (entries.has(normalizedPath)) { + return; + } + const dir = getDirName(normalizedPath); + if (normalizedPath !== "/") { + ensureDir(dir); + } + entries.set(normalizedPath, { + path: normalizedPath, + dir: normalizedPath === "/" ? "/" : dir, + name: normalizedPath === "/" ? "/" : getBaseName(normalizedPath), + isdir: true, + mimetype: MockDirMimeType, + modtime: MockBaseModTime + entries.size * 60000, + mode: MockDirMode, + size: 0, + supportsmkdir: true, + }); + }; + for (const input of inputs) { + const normalizedPath = normalizeMockPath(input.path, "/"); + const isdir = input.isdir ?? false; + const dir = getDirName(normalizedPath); + if (normalizedPath !== "/") { + ensureDir(dir); + } + const content = input.content == null ? undefined : makeContentBytes(input.content); + entries.set(normalizedPath, { + path: normalizedPath, + dir: normalizedPath === "/" ? "/" : dir, + name: normalizedPath === "/" ? "/" : getBaseName(normalizedPath), + isdir, + mimetype: input.mimetype ?? getMimeType(normalizedPath, isdir), + modtime: MockBaseModTime + entries.size * 60000, + mode: isdir ? MockDirMode : MockFileMode, + size: content?.byteLength ?? 0, + readonly: input.readonly, + supportsmkdir: isdir, + content, + }); + } + return entries; +} + +function toFileInfo(entry: MockFsEntry): FileInfo { + return { + path: entry.path, + dir: entry.dir, + name: entry.name, + size: entry.size, + mode: entry.mode, + modtime: entry.modtime, + isdir: entry.isdir, + supportsmkdir: entry.supportsmkdir, + mimetype: entry.mimetype, + readonly: entry.readonly, + }; +} + +function makeNotFoundInfo(path: string): FileInfo { + const normalizedPath = normalizeMockPath(path); + return { + path: normalizedPath, + dir: getDirName(normalizedPath), + name: getBaseName(normalizedPath), + notfound: true, + supportsmkdir: true, + }; +} + +function sliceEntries(entries: FileInfo[], opts?: FileListOpts): FileInfo[] { + let filteredEntries = entries; + if (!opts?.all) { + filteredEntries = filteredEntries.filter((entry) => entry.name != null && !entry.name.startsWith(".")); + } + const offset = Math.max(opts?.offset ?? 0, 0); + const end = opts?.limit != null && opts.limit >= 0 ? offset + opts.limit : undefined; + return filteredEntries.slice(offset, end); +} + +function joinPaths(paths: string[]): string { + if (paths.length === 0) { + return MockHomePath; + } + let currentPath = normalizeMockPath(paths[0]); + for (const part of paths.slice(1)) { + currentPath = normalizeMockPath(part, currentPath); + } + return currentPath; +} + +function getReadRange(data: FileData, size: number): { offset: number; end: number } { + const offset = Math.max(data?.at?.offset ?? 0, 0); + const end = data?.at?.size != null ? Math.min(offset + data.at.size, size) : size; + return { offset, end: Math.max(offset, end) }; +} + +export function makeMockFilesystem(): MockFilesystem { + const entries = buildEntries(); + const childrenByDir = new Map(); + for (const entry of entries.values()) { + if (entry.path === "/") { + continue; + } + if (!childrenByDir.has(entry.dir)) { + childrenByDir.set(entry.dir, []); + } + childrenByDir.get(entry.dir).push(entry); + } + for (const childEntries of childrenByDir.values()) { + childEntries.sort((a, b) => { + if (a.isdir !== b.isdir) { + return a.isdir ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + } + const getEntry = (path: string): MockFsEntry => { + return entries.get(normalizeMockPath(path)); + }; + const fileInfo = async (data: FileData): Promise => { + const entry = getEntry(data?.info?.path ?? MockHomePath); + if (!entry) { + return makeNotFoundInfo(data?.info?.path ?? MockHomePath); + } + return toFileInfo(entry); + }; + const fileRead = async (data: FileData): Promise => { + const info = await fileInfo(data); + if (info.notfound) { + return { info }; + } + const entry = getEntry(info.path); + if (entry.isdir) { + const childEntries = (childrenByDir.get(entry.path) ?? []).map((child) => toFileInfo(child)); + return { info, entries: childEntries }; + } + if (entry.content == null || entry.content.byteLength === 0) { + return { info }; + } + const { offset, end } = getReadRange(data, entry.content.byteLength); + return { + info, + data64: arrayToBase64(entry.content.slice(offset, end)), + at: { offset, size: end - offset }, + }; + }; + const fileList = async (data: FileListData): Promise => { + const dirPath = normalizeMockPath(data?.path ?? MockHomePath); + const entry = getEntry(dirPath); + if (entry == null || !entry.isdir) { + return []; + } + const dirEntries = (childrenByDir.get(dirPath) ?? []).map((child) => toFileInfo(child)); + return sliceEntries(dirEntries, data?.opts); + }; + const fileJoin = async (paths: string[]): Promise => { + const path = paths.length === 1 ? normalizeMockPath(paths[0]) : joinPaths(paths); + const entry = getEntry(path); + if (!entry) { + return makeNotFoundInfo(path); + } + return toFileInfo(entry); + }; + const fileReadStream = async function* (data: FileData): AsyncGenerator { + const info = await fileInfo(data); + yield { info }; + if (info.notfound) { + return; + } + const entry = getEntry(info.path); + if (entry.isdir) { + const dirEntries = (childrenByDir.get(entry.path) ?? []).map((child) => toFileInfo(child)); + for (let idx = 0; idx < dirEntries.length; idx += MockDirectoryChunkSize) { + yield { entries: dirEntries.slice(idx, idx + MockDirectoryChunkSize) }; + } + return; + } + if (entry.content == null || entry.content.byteLength === 0) { + return; + } + const { offset, end } = getReadRange(data, entry.content.byteLength); + for (let currentOffset = offset; currentOffset < end; currentOffset += MockFileChunkSize) { + const chunkEnd = Math.min(currentOffset + MockFileChunkSize, end); + yield { + data64: arrayToBase64(entry.content.slice(currentOffset, chunkEnd)), + at: { offset: currentOffset, size: chunkEnd - currentOffset }, + }; + } + }; + const fileListStream = async function* (data: FileListData): AsyncGenerator { + const fileInfos = await fileList(data); + for (let idx = 0; idx < fileInfos.length; idx += MockDirectoryChunkSize) { + yield { fileinfo: fileInfos.slice(idx, idx + MockDirectoryChunkSize) }; + } + }; + const fileCount = Array.from(entries.values()).filter((entry) => !entry.isdir).length; + const directoryCount = Array.from(entries.values()).filter((entry) => entry.isdir).length; + return { + homePath: MockHomePath, + fileCount, + directoryCount, + entryCount: entries.size, + fileInfo, + fileRead, + fileList, + fileJoin, + fileReadStream, + fileListStream, + }; +} + +export const DefaultMockFilesystem = makeMockFilesystem(); diff --git a/frontend/preview/mock/mockwaveenv.test.ts b/frontend/preview/mock/mockwaveenv.test.ts index 953e8412d4..25aee22995 100644 --- a/frontend/preview/mock/mockwaveenv.test.ts +++ b/frontend/preview/mock/mockwaveenv.test.ts @@ -1,4 +1,6 @@ +import { base64ToArray, base64ToString } from "@/util/util"; import { describe, expect, it, vi } from "vitest"; +import { DefaultMockFilesystem } from "./mockfilesystem"; const { showPreviewContextMenu } = vi.hoisted(() => ({ showPreviewContextMenu: vi.fn(), @@ -19,4 +21,76 @@ describe("makeMockWaveEnv", () => { expect(showPreviewContextMenu).toHaveBeenCalledWith(menu, event); }); + + it("provides a populated mock filesystem rooted at /Users/mike", () => { + expect(DefaultMockFilesystem.homePath).toBe("/Users/mike"); + expect(DefaultMockFilesystem.fileCount).toBeGreaterThanOrEqual(100); + expect(DefaultMockFilesystem.directoryCount).toBeGreaterThanOrEqual(10); + }); + + it("implements file info, read, list, and join commands", async () => { + const { makeMockWaveEnv } = await import("./mockwaveenv"); + const env = makeMockWaveEnv(); + + const bashrcInfo = await env.rpc.FileInfoCommand(null as any, { + info: { path: "wsh://local//Users/mike/.bashrc" }, + }); + expect(bashrcInfo.path).toBe("/Users/mike/.bashrc"); + expect(bashrcInfo.mimetype).toBe("text/plain"); + + const bashrcData = await env.rpc.FileReadCommand(null as any, { + info: { path: "wsh://local//Users/mike/.bashrc" }, + }); + expect(base64ToString(bashrcData.data64)).toContain('alias gs="git status -sb"'); + + const visibleHomeEntries = await env.rpc.FileListCommand(null as any, { + path: "/Users/mike", + }); + expect(visibleHomeEntries.some((entry) => entry.name === ".bashrc")).toBe(false); + expect(visibleHomeEntries.some((entry) => entry.name === "waveterm")).toBe(true); + + const allHomeEntries = await env.rpc.FileListCommand(null as any, { + path: "/Users/mike", + opts: { all: true }, + }); + expect(allHomeEntries.some((entry) => entry.name === ".bashrc")).toBe(true); + + const dirRead = await env.rpc.FileReadCommand(null as any, { + info: { path: "/Users/mike/waveterm" }, + }); + expect(dirRead.entries.some((entry) => entry.name === "docs" && entry.isdir)).toBe(true); + + const joined = await env.rpc.FileJoinCommand(null as any, [ + "wsh://local//Users/mike/Documents", + "../waveterm/docs", + "preview-notes.md", + ]); + expect(joined.path).toBe("/Users/mike/waveterm/docs/preview-notes.md"); + expect(joined.mimetype).toBe("text/markdown"); + }); + + it("implements file list and read stream commands", async () => { + const { makeMockWaveEnv } = await import("./mockwaveenv"); + const env = makeMockWaveEnv(); + + const listPackets: CommandRemoteListEntriesRtnData[] = []; + for await (const packet of env.rpc.FileListStreamCommand(null as any, { + path: "/Users/mike", + opts: { all: true, limit: 4 }, + })) { + listPackets.push(packet); + } + expect(listPackets).toHaveLength(1); + expect(listPackets[0].fileinfo).toHaveLength(4); + + const readPackets: FileData[] = []; + for await (const packet of env.rpc.FileReadStreamCommand(null as any, { + info: { path: "/Users/mike/Pictures/beach-sunrise.png" }, + })) { + readPackets.push(packet); + } + expect(readPackets[0].info?.path).toBe("/Users/mike/Pictures/beach-sunrise.png"); + const imageBytes = base64ToArray(readPackets[1].data64); + expect(Array.from(imageBytes.slice(0, 4))).toEqual([0x89, 0x50, 0x4e, 0x47]); + }); }); diff --git a/frontend/preview/mock/mockwaveenv.ts b/frontend/preview/mock/mockwaveenv.ts index aaccb0dd32..5e787610e5 100644 --- a/frontend/preview/mock/mockwaveenv.ts +++ b/frontend/preview/mock/mockwaveenv.ts @@ -10,6 +10,7 @@ import { WaveEnv } from "@/app/waveenv/waveenv"; import { PlatformMacOS, PlatformWindows } from "@/util/platformutil"; import { Atom, atom, PrimitiveAtom, useAtomValue } from "jotai"; import { DefaultFullConfig } from "./defaultconfig"; +import { DefaultMockFilesystem } from "./mockfilesystem"; import { showPreviewContextMenu } from "../preview-contextmenu"; import { previewElectronApi } from "./preview-electron-api"; @@ -20,6 +21,7 @@ import { previewElectronApi } from "./preview-electron-api"; // 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.SetConfigCommand -- merges settings into fullConfigAtom (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 // @@ -32,7 +34,9 @@ import { previewElectronApi } from "./preview-electron-api"; // e.g. { "block": { "GetControllerStatus": (blockId) => myStatus } } type RpcOverrides = { - [K in keyof RpcApiType as K extends `${string}Command` ? K : never]?: (...args: any[]) => Promise; + [K in keyof RpcApiType as K extends `${string}Command` ? K : never]?: ( + ...args: any[] + ) => Promise | AsyncGenerator; }; type ServiceOverrides = { @@ -173,21 +177,29 @@ function makeMockGlobalAtoms( type MockWosFns = { getWaveObjectAtom: (oref: string) => PrimitiveAtom; mockSetWaveObj: (oref: string, obj: T) => void; + fullConfigAtom: PrimitiveAtom; }; export function makeMockRpc(overrides: RpcOverrides, wos: MockWosFns): RpcApiType { - const dispatchMap = new Map Promise>(); - dispatchMap.set("eventpublish", async (_client, data: WaveEvent) => { + const callDispatchMap = new Map Promise>(); + const streamDispatchMap = new Map AsyncGenerator>(); + const setCallHandler = (command: string, fn: (...args: any[]) => Promise) => { + callDispatchMap.set(command, fn); + }; + const setStreamHandler = (command: string, fn: (...args: any[]) => AsyncGenerator) => { + streamDispatchMap.set(command, fn); + }; + setCallHandler("eventpublish", async (_client, data: WaveEvent) => { console.log("[mock eventpublish]", data); handleWaveEvent(data); return null; }); - dispatchMap.set("getmeta", async (_client, data: CommandGetMetaData) => { + setCallHandler("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) => { + setCallHandler("setmeta", async (_client, data: CommandSetMetaData) => { const objAtom = wos.getWaveObjectAtom(data.oref); const current = globalStore.get(objAtom) as WaveObj & { meta?: MetaType }; const updatedMeta = { ...(current?.meta ?? {}) }; @@ -202,7 +214,7 @@ export function makeMockRpc(overrides: RpcOverrides, wos: MockWosFns): RpcApiTyp wos.mockSetWaveObj(data.oref, updated); return null; }); - dispatchMap.set("updatetabname", async (_client, data: { args: [string, string] }) => { + setCallHandler("updatetabname", async (_client, data: { args: [string, string] }) => { const [tabId, newName] = data.args; const tabORef = "tab:" + tabId; const objAtom = wos.getWaveObjectAtom(tabORef); @@ -211,7 +223,20 @@ export function makeMockRpc(overrides: RpcOverrides, wos: MockWosFns): RpcApiTyp wos.mockSetWaveObj(tabORef, updated); return null; }); - dispatchMap.set("updateworkspacetabids", async (_client, data: { args: [string, string[]] }) => { + setCallHandler("setconfig", async (_client, data: SettingsType) => { + const current = globalStore.get(wos.fullConfigAtom); + const updatedSettings = { ...(current?.settings ?? {}) }; + for (const [key, value] of Object.entries(data)) { + if (value === null) { + delete (updatedSettings as any)[key]; + } else { + (updatedSettings as any)[key] = value; + } + } + globalStore.set(wos.fullConfigAtom, { ...current, settings: updatedSettings as SettingsType }); + return null; + }); + setCallHandler("updateworkspacetabids", async (_client, data: { args: [string, string[]] }) => { const [workspaceId, tabIds] = data.args; const wsORef = "workspace:" + workspaceId; const objAtom = wos.getWaveObjectAtom(wsORef); @@ -220,16 +245,30 @@ export function makeMockRpc(overrides: RpcOverrides, wos: MockWosFns): RpcApiTyp wos.mockSetWaveObj(wsORef, updated); return null; }); + setCallHandler("fileinfo", async (_client, data: FileData) => DefaultMockFilesystem.fileInfo(data)); + setCallHandler("fileread", async (_client, data: FileData) => DefaultMockFilesystem.fileRead(data)); + setCallHandler("filelist", async (_client, data: FileListData) => DefaultMockFilesystem.fileList(data)); + setCallHandler("filejoin", async (_client, data: string[]) => DefaultMockFilesystem.fileJoin(data)); + setStreamHandler("filereadstream", async function* (_client, data: FileData) { + yield* DefaultMockFilesystem.fileReadStream(data); + }); + setStreamHandler("fileliststream", async function* (_client, data: FileListData) { + yield* DefaultMockFilesystem.fileListStream(data); + }); 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[]) => Promise); + if (cmdName === "filereadstream" || cmdName === "fileliststream") { + setStreamHandler(cmdName, overrides[key] as (...args: any[]) => AsyncGenerator); + } else { + setCallHandler(cmdName, overrides[key] as (...args: any[]) => Promise); + } } } const rpc = new RpcApiType(); rpc.setMockRpcClient({ mockWshRpcCall(_client, command, data, _opts) { - const fn = dispatchMap.get(command); + const fn = callDispatchMap.get(command); if (fn) { return fn(_client, data, _opts); } @@ -237,9 +276,14 @@ export function makeMockRpc(overrides: RpcOverrides, wos: MockWosFns): RpcApiTyp return Promise.resolve(null); }, async *mockWshRpcStream(_client, command, data, _opts) { - const fn = dispatchMap.get(command); - if (fn) { - yield await fn(_client, data, _opts); + const streamFn = streamDispatchMap.get(command); + if (streamFn) { + yield* streamFn(_client, data, _opts); + return; + } + const callFn = callDispatchMap.get(command); + if (callFn) { + yield await callFn(_client, data, _opts); return; } console.log("[mock rpc stream]", command, data); @@ -280,6 +324,7 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv { }); const mockWosFns: MockWosFns = { getWaveObjectAtom, + fullConfigAtom: atoms.fullConfigAtom, mockSetWaveObj: (oref: string, obj: T) => { if (!waveObjectValueAtomCache.has(oref)) { waveObjectValueAtomCache.set(oref, atom(null as WaveObj)); diff --git a/frontend/preview/mock/tabbar-mock.tsx b/frontend/preview/mock/tabbar-mock.tsx new file mode 100644 index 0000000000..c4a811f6e4 --- /dev/null +++ b/frontend/preview/mock/tabbar-mock.tsx @@ -0,0 +1,173 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { globalStore } from "@/app/store/jotaiStore"; +import { useWaveEnv, WaveEnv, WaveEnvContext } from "@/app/waveenv/waveenv"; +import { applyMockEnvOverrides, MockWaveEnv } from "@/preview/mock/mockwaveenv"; +import { PlatformMacOS } from "@/util/platformutil"; +import { atom } from "jotai"; +import React, { useMemo, useRef } 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 TabBarMockTabs) { + for (const badge of tab.badges ?? []) { + events.push({ oref: `block:${badgeBlockId(tab.tabId, badge.badgeid)}`, badge }); + } + } + return events; +} + +export const TabBarMockWorkspaceId = "preview-workspace-1"; + +export const TabBarMockTabs: 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" }, +]; + +function makeMockWorkspace(tabIds: string[]): Workspace { + return { + otype: "workspace", + oid: TabBarMockWorkspaceId, + version: 1, + name: "Preview Workspace", + tabids: tabIds, + activetabid: tabIds[1] ?? tabIds[0] ?? "", + meta: {}, + } as Workspace; +} + +export function makeTabBarMockEnv( + baseEnv: WaveEnv, + envRef: React.RefObject, + platform: NodeJS.Platform +): MockWaveEnv { + const initialTabIds = TabBarMockTabs.map((t) => t.tabId); + const mockWaveObjs: Record = { + [`workspace:${TabBarMockWorkspaceId}`]: makeMockWorkspace(initialTabIds), + }; + for (const tab of TabBarMockTabs) { + mockWaveObjs[`tab:${tab.tabId}`] = makeTabWaveObj(tab); + } + const env = applyMockEnvOverrides(baseEnv, { + tabId: TabBarMockTabs[1].tabId, + platform, + mockWaveObjs, + atoms: { + workspaceId: atom(TabBarMockWorkspaceId), + staticTabId: atom(TabBarMockTabs[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:${TabBarMockWorkspaceId}`)); + e.mockSetWaveObj(`workspace:${TabBarMockWorkspaceId}`, { + ...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:${TabBarMockWorkspaceId}`)); + const newTabIds = (ws.tabids ?? []).filter((id) => id !== tabId); + if (newTabIds.length === 0) { + return Promise.resolve(false); + } + e.mockSetWaveObj(`workspace:${TabBarMockWorkspaceId}`, { ...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; +} + +type TabBarMockEnvProviderProps = { + children: React.ReactNode; +}; + +export function TabBarMockEnvProvider({ children }: TabBarMockEnvProviderProps) { + const baseEnv = useWaveEnv(); + const envRef = useRef(null); + const tabEnv = useMemo(() => makeTabBarMockEnv(baseEnv, envRef, PlatformMacOS), []); + return {children}; +} +TabBarMockEnvProvider.displayName = "TabBarMockEnvProvider"; diff --git a/frontend/preview/previews/tabbar.preview.tsx b/frontend/preview/previews/tabbar.preview.tsx index 104ef4f8a6..f2ba2234b7 100644 --- a/frontend/preview/previews/tabbar.preview.tsx +++ b/frontend/preview/previews/tabbar.preview.tsx @@ -2,171 +2,26 @@ // 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 { makeTabBarMockEnv, TabBarMockWorkspaceId } from "@/preview/mock/tabbar-mock"; +import { MockWaveEnv } from "@/preview/mock/mockwaveenv"; import { PlatformLinux, PlatformMacOS, PlatformWindows } from "@/util/platformutil"; -import { atom, useAtom, useAtomValue } from "jotai"; +import { 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(null); const [platform, setPlatform] = useState(PlatformMacOS); - const tabEnv = useMemo(() => { - const mockWaveObjs: Record = { - [`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:${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:${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]); + const tabEnv = useMemo(() => makeTabBarMockEnv(baseEnv, envRef, platform), [platform]); return ( @@ -190,7 +45,7 @@ function TabBarPreviewInner({ platform, setPlatform }: TabBarPreviewInnerProps) 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:${MockWorkspaceId}`)); + const workspace = useAtomValue(env.wos.getWaveObjectAtom(`workspace:${TabBarMockWorkspaceId}`)); useEffect(() => { loadBadges(loadBadgesEnv); diff --git a/frontend/preview/previews/vtabbar.preview.tsx b/frontend/preview/previews/vtabbar.preview.tsx index c4739f593d..90b7907be2 100644 --- a/frontend/preview/previews/vtabbar.preview.tsx +++ b/frontend/preview/previews/vtabbar.preview.tsx @@ -1,80 +1,128 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { VTabBar, VTabItem } from "@/app/tab/vtabbar"; -import { useState } from "react"; - -const InitialTabs: VTabItem[] = [ - { id: "vtab-1", name: "Terminal" }, - { - id: "vtab-2", - name: "Build Logs", - badges: [ - { badgeid: "01957000-0000-7000-0000-000000000001", icon: "bell", color: "#f59e0b", priority: 2 }, - { badgeid: "01957000-0000-7000-0000-000000000002", icon: "circle-small", color: "#4ade80", priority: 3 }, - ], - }, - { id: "vtab-3", name: "Deploy", flagColor: "#429DFF" }, - { id: "vtab-4", name: "Wave AI" }, - { - id: "vtab-5", - name: "A Very Long Tab Name To Show Truncation", - badges: [{ badgeid: "01957000-0000-7000-0000-000000000003", icon: "solid@terminal", color: "#fbbf24", priority: 3 }], - flagColor: "#BF55EC", - }, -]; +import { loadBadges, LoadBadgesEnv } from "@/app/store/badge"; +import { VTabBar } from "@/app/tab/vtabbar"; +import { VTabBarEnv } from "@/app/tab/vtabbarenv"; +import { useWaveEnv, WaveEnvContext } from "@/app/waveenv/waveenv"; +import { MockWaveEnv } from "@/preview/mock/mockwaveenv"; +import { makeTabBarMockEnv, TabBarMockWorkspaceId } from "@/preview/mock/tabbar-mock"; +import { PlatformLinux, PlatformMacOS, PlatformWindows } from "@/util/platformutil"; +import { useAtom, useAtomValue } from "jotai"; +import { useEffect, useMemo, useRef, useState } from "react"; export function VTabBarPreview() { - const [tabs, setTabs] = useState(InitialTabs); - const [activeTabId, setActiveTabId] = useState(InitialTabs[0].id); + const baseEnv = useWaveEnv(); + const envRef = useRef(null); + const [platform, setPlatform] = useState(PlatformMacOS); + + const tabEnv = useMemo(() => makeTabBarMockEnv(baseEnv, envRef, platform), [platform]); + + return ( + + + + ); +} + +type VTabBarPreviewInnerProps = { + platform: NodeJS.Platform; + setPlatform: (platform: NodeJS.Platform) => void; +}; + +function VTabBarPreviewInner({ platform, setPlatform }: VTabBarPreviewInnerProps) { + const env = useWaveEnv(); + const loadBadgesEnv = useWaveEnv(); + const [hideAiButton, setHideAiButton] = useState(false); + const [isFullScreen, setIsFullScreen] = useAtom(env.atoms.isFullScreen); + const [fullConfig, setFullConfig] = useAtom(env.atoms.fullConfigAtom); + const [updaterStatus, setUpdaterStatus] = useAtom(env.atoms.updaterStatusAtom); const [width, setWidth] = useState(220); + const workspace = useAtomValue(env.wos.getWaveObjectAtom(`workspace:${TabBarMockWorkspaceId}`)); - const handleCloseTab = (tabId: string) => { - setTabs((prevTabs) => { - const nextTabs = prevTabs.filter((tab) => tab.id !== tabId); - if (activeTabId === tabId && nextTabs.length > 0) { - setActiveTabId(nextTabs[0].id); - } - return nextTabs; - }); - }; + useEffect(() => { + loadBadges(loadBadgesEnv); + }, []); + + useEffect(() => { + setFullConfig((prev) => ({ + ...(prev ?? ({} as FullConfigType)), + settings: { + ...(prev?.settings ?? {}), + "app:hideaibutton": hideAiButton, + }, + })); + }, [hideAiButton, setFullConfig]); return ( -
-
-
Width: {width}px
- setWidth(Number(event.target.value))} - className="w-full cursor-pointer" - /> -

- Drag tabs to reorder. Names, badges, and close buttons remain single-line. -

+
+
+ + + + +
-
- { - setTabs((prevTabs) => - prevTabs.map((tab) => (tab.id === tabId ? { ...tab, name: newName } : tab)) - ); - }} - onReorderTabs={(tabIds) => { - setTabs((prevTabs) => { - const tabById = new Map(prevTabs.map((tab) => [tab.id, tab])); - return tabIds.map((tabId) => tabById.get(tabId)).filter((tab) => tab != null); - }); - }} - /> + +
+
+ {workspace != null && } +
); } +VTabBarPreviewInner.displayName = "VTabBarPreviewInner"; diff --git a/frontend/preview/previews/web.preview.tsx b/frontend/preview/previews/web.preview.tsx new file mode 100644 index 0000000000..56f9c215a4 --- /dev/null +++ b/frontend/preview/previews/web.preview.tsx @@ -0,0 +1,135 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Block } from "@/app/block/block"; +import { globalStore } from "@/app/store/jotaiStore"; +import { getTabModelByTabId, TabModelContext } from "@/app/store/tab-model"; +import { mockObjectForPreview } from "@/app/store/wos"; +import { useWaveEnv, WaveEnvContext } from "@/app/waveenv/waveenv"; +import type { NodeModel } from "@/layout/index"; +import { atom } from "jotai"; +import * as React from "react"; +import { applyMockEnvOverrides, MockWaveEnv } from "../mock/mockwaveenv"; + +const PreviewWorkspaceId = "preview-web-workspace"; +const PreviewTabId = "preview-web-tab"; +const PreviewNodeId = "preview-web-node"; +const PreviewBlockId = "preview-web-block"; +const PreviewUrl = "https://waveterm.dev"; + +function makeMockWorkspace(): Workspace { + return { + otype: "workspace", + oid: PreviewWorkspaceId, + version: 1, + name: "Preview Workspace", + tabids: [PreviewTabId], + activetabid: PreviewTabId, + meta: {}, + } as Workspace; +} + +function makeMockTab(): Tab { + return { + otype: "tab", + oid: PreviewTabId, + version: 1, + name: "Web Preview", + blockids: [PreviewBlockId], + meta: {}, + } as Tab; +} + +function makeMockBlock(): Block { + return { + otype: "block", + oid: PreviewBlockId, + version: 1, + meta: { + view: "web", + url: PreviewUrl, + }, + } as Block; +} + +const previewWaveObjs: Record = { + [`workspace:${PreviewWorkspaceId}`]: makeMockWorkspace(), + [`tab:${PreviewTabId}`]: makeMockTab(), + [`block:${PreviewBlockId}`]: makeMockBlock(), +}; + +for (const [oref, obj] of Object.entries(previewWaveObjs)) { + mockObjectForPreview(oref, obj); +} + +function makePreviewNodeModel(): NodeModel { + const isFocusedAtom = atom(true); + const isMagnifiedAtom = atom(false); + + return { + additionalProps: atom({} as any), + innerRect: atom({ width: "1040px", height: "620px" }), + blockNum: atom(1), + numLeafs: atom(1), + nodeId: PreviewNodeId, + blockId: PreviewBlockId, + addEphemeralNodeToLayout: () => {}, + animationTimeS: atom(0), + isResizing: atom(false), + isFocused: isFocusedAtom, + isMagnified: isMagnifiedAtom, + anyMagnified: atom(false), + isEphemeral: atom(false), + ready: atom(true), + disablePointerEvents: atom(false), + toggleMagnify: () => { + globalStore.set(isMagnifiedAtom, !globalStore.get(isMagnifiedAtom)); + }, + focusNode: () => { + globalStore.set(isFocusedAtom, true); + }, + onClose: () => {}, + dragHandleRef: { current: null }, + displayContainerRef: { current: null }, + }; +} + +function WebPreviewInner() { + const baseEnv = useWaveEnv(); + const nodeModel = React.useMemo(() => makePreviewNodeModel(), []); + + const env = React.useMemo(() => { + return applyMockEnvOverrides(baseEnv, { + tabId: PreviewTabId, + mockWaveObjs: previewWaveObjs, + atoms: { + workspaceId: atom(PreviewWorkspaceId), + staticTabId: atom(PreviewTabId), + }, + settings: { + "web:defaultsearch": "https://www.google.com/search?q=%s", + }, + }); + }, [baseEnv]); + + const tabModel = React.useMemo(() => getTabModelByTabId(PreviewTabId, env), [env]); + + return ( + + +
+
full web block using preview mock fallback
+
+
+ +
+
+
+
+
+ ); +} + +export function WebPreview() { + return ; +} diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index ddcb4a63e7..3dede74071 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -1121,6 +1121,7 @@ declare global { "bg:blendmode"?: string; "bg:bordercolor"?: string; "bg:activebordercolor"?: string; + "layout:vtabbarwidth"?: number; "waveai:panelopen"?: boolean; "waveai:panelwidth"?: number; "waveai:model"?: string; @@ -1305,6 +1306,7 @@ declare global { "app:disablectrlshiftarrows"?: boolean; "app:disablectrlshiftdisplay"?: boolean; "app:focusfollowscursor"?: string; + "app:tabbar"?: string; "feature:waveappbuilder"?: boolean; "ai:*"?: boolean; "ai:preset"?: string; diff --git a/frontend/util/platformutil.ts b/frontend/util/platformutil.ts index ded79d3394..92fc240b0a 100644 --- a/frontend/util/platformutil.ts +++ b/frontend/util/platformutil.ts @@ -1,15 +1,28 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 export const PlatformMacOS = "darwin"; export const PlatformWindows = "win32"; export const PlatformLinux = "linux"; export let PLATFORM: NodeJS.Platform = PlatformMacOS; +export let MacOSVersion: string = null; export function setPlatform(platform: NodeJS.Platform) { PLATFORM = platform; } +export function setMacOSVersion(version: string) { + MacOSVersion = version; +} + +export function isMacOSTahoeOrLater(): boolean { + if (!isMacOS() || MacOSVersion == null) { + return false; + } + const major = parseInt(MacOSVersion.split(".")[0], 10); + return major >= 16; +} + export function isMacOS(): boolean { return PLATFORM == PlatformMacOS; } diff --git a/frontend/util/util.ts b/frontend/util/util.ts index 2e5a3b5a13..8c2d330580 100644 --- a/frontend/util/util.ts +++ b/frontend/util/util.ts @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0s import base64 from "base64-js"; @@ -108,7 +108,7 @@ function jsonDeepEqual(v1: any, v2: any): boolean { if (keys1.length !== keys2.length) { return false; } - for (let key of keys1) { + for (const key of keys1) { if (!jsonDeepEqual(v1[key], v2[key])) { return false; } diff --git a/frontend/wave.ts b/frontend/wave.ts index a2ecb8a426..20ee2ba97a 100644 --- a/frontend/wave.ts +++ b/frontend/wave.ts @@ -32,6 +32,7 @@ import { activeTabIdAtom } from "@/store/tab-model"; import * as WOS from "@/store/wos"; import { loadFonts } from "@/util/fontutil"; import { setKeyUtilPlatform } from "@/util/keyutil"; +import { isMacOS, setMacOSVersion } from "@/util/platformutil"; import { createElement } from "react"; import { createRoot } from "react-dom/client"; @@ -159,13 +160,17 @@ async function initWave(initOpts: WaveInitOpts) { const globalWS = initWshrpc(makeTabRouteId(initOpts.tabId)); (window as any).globalWS = globalWS; (window as any).TabRpcClient = TabRpcClient; - await loadConnStatus(); - await loadBadges(); - initGlobalWaveEventSubs(initOpts); - subscribeToConnEvents(); // ensures client/window/workspace are loaded into the cache before rendering try { + await loadConnStatus(); + await loadBadges(); + initGlobalWaveEventSubs(initOpts); + subscribeToConnEvents(); + if (isMacOS()) { + const macOSVersion = await RpcApi.MacOSVersionCommand(TabRpcClient); + setMacOSVersion(macOSVersion); + } const [_client, waveWindow, initialTab] = await Promise.all([ WOS.loadAndPinWaveObject(WOS.makeORef("client", initOpts.clientId)), WOS.loadAndPinWaveObject(WOS.makeORef("window", initOpts.windowId)), diff --git a/go.mod b/go.mod index 7615a351ee..bf6f92bab6 100644 --- a/go.mod +++ b/go.mod @@ -31,11 +31,11 @@ require ( github.com/ubuntu/gowsl v0.0.0-20240906163211-049fd49bd93b github.com/wavetermdev/htmltoken v0.2.0 github.com/wavetermdev/waveterm/tsunami v0.12.3 - golang.org/x/crypto v0.48.0 + golang.org/x/crypto v0.49.0 golang.org/x/mod v0.33.0 - golang.org/x/sync v0.19.0 - golang.org/x/sys v0.41.0 - golang.org/x/term v0.40.0 + golang.org/x/sync v0.20.0 + golang.org/x/sys v0.42.0 + golang.org/x/term v0.41.0 google.golang.org/api v0.269.0 ) @@ -76,9 +76,9 @@ require ( go.opentelemetry.io/otel v1.39.0 // indirect go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect - golang.org/x/net v0.50.0 // indirect + golang.org/x/net v0.51.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect diff --git a/go.sum b/go.sum index 03a89cd1d2..7928668ed7 100644 --- a/go.sum +++ b/go.sum @@ -176,28 +176,28 @@ go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5w go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220721230656-c6bc011c0c49/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/package-lock.json b/package-lock.json index 99c2a025b4..fbf3fa1c69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waveterm", - "version": "0.14.2-beta.1", + "version": "0.14.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waveterm", - "version": "0.14.2-beta.1", + "version": "0.14.3", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ @@ -107,7 +107,7 @@ "@types/ws": "^8", "@vitejs/plugin-react-swc": "4.2.3", "@vitest/coverage-istanbul": "^3.0.9", - "electron": "^40.4.1", + "electron": "^41.0.2", "electron-builder": "^26.8", "electron-vite": "^5.0", "eslint": "^9.39", @@ -14902,9 +14902,9 @@ } }, "node_modules/electron": { - "version": "40.4.1", - "resolved": "https://registry.npmjs.org/electron/-/electron-40.4.1.tgz", - "integrity": "sha512-N1ZXybQZL8kYemO8vAeh9nrk4mSvqlAO8xs0QCHkXIvRnuB/7VGwEehjvQbsU5/f4bmTKpG+2GQERe/zmKpudQ==", + "version": "41.0.2", + "resolved": "https://registry.npmjs.org/electron/-/electron-41.0.2.tgz", + "integrity": "sha512-raotm/aO8kOs1jD8SI8ssJ7EKciQOY295AOOprl1TxW7B0At8m5Ae7qNU1xdMxofiHMR8cNEGi9PKD3U+yT/mA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -30005,9 +30005,9 @@ } }, "node_modules/tar": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.10.tgz", - "integrity": "sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw==", + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz", + "integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { diff --git a/package.json b/package.json index cd727bf39f..eeb75930ed 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "productName": "Wave", "description": "Open-Source AI-Native Terminal Built for Seamless Workflows", "license": "Apache-2.0", - "version": "0.14.2-beta.1", + "version": "0.14.3", "homepage": "https://waveterm.dev", "build": { "appId": "dev.commandline.waveterm" @@ -48,7 +48,7 @@ "@types/ws": "^8", "@vitejs/plugin-react-swc": "4.2.3", "@vitest/coverage-istanbul": "^3.0.9", - "electron": "^40.4.1", + "electron": "^41.0.2", "electron-builder": "^26.8", "electron-vite": "^5.0", "eslint": "^9.39", diff --git a/pkg/tsgen/tsgen.go b/pkg/tsgen/tsgen.go index 8d92893afc..89c782c595 100644 --- a/pkg/tsgen/tsgen.go +++ b/pkg/tsgen/tsgen.go @@ -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 callBackendService(this.waveEnv, %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 { diff --git a/pkg/waveobj/metaconsts.go b/pkg/waveobj/metaconsts.go index bf348023ac..7028d050be 100644 --- a/pkg/waveobj/metaconsts.go +++ b/pkg/waveobj/metaconsts.go @@ -98,6 +98,8 @@ const ( MetaKey_BgBorderColor = "bg:bordercolor" MetaKey_BgActiveBorderColor = "bg:activebordercolor" + MetaKey_LayoutVTabBarWidth = "layout:vtabbarwidth" + MetaKey_WaveAiPanelOpen = "waveai:panelopen" MetaKey_WaveAiPanelWidth = "waveai:panelwidth" MetaKey_WaveAiModel = "waveai:model" diff --git a/pkg/waveobj/wtypemeta.go b/pkg/waveobj/wtypemeta.go index adda079c1f..027ff3eff2 100644 --- a/pkg/waveobj/wtypemeta.go +++ b/pkg/waveobj/wtypemeta.go @@ -100,6 +100,9 @@ type MetaTSType struct { BgBorderColor string `json:"bg:bordercolor,omitempty"` // frame:bordercolor BgActiveBorderColor string `json:"bg:activebordercolor,omitempty"` // frame:activebordercolor + // for workspace + LayoutVTabBarWidth int `json:"layout:vtabbarwidth,omitempty"` + // for tabs+waveai WaveAiPanelOpen bool `json:"waveai:panelopen,omitempty"` WaveAiPanelWidth int `json:"waveai:panelwidth,omitempty"` diff --git a/pkg/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json index ab10987fc9..8ed6af7235 100644 --- a/pkg/wconfig/defaultconfig/settings.json +++ b/pkg/wconfig/defaultconfig/settings.json @@ -4,6 +4,7 @@ "ai:maxtokens": 4000, "ai:timeoutms": 60000, "app:defaultnewblock": "term", + "app:tabbar": "top", "app:confirmquit": true, "app:hideaibutton": false, "app:disablectrlshiftarrows": false, diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go index 084dab1793..8195495ad2 100644 --- a/pkg/wconfig/metaconsts.go +++ b/pkg/wconfig/metaconsts.go @@ -17,6 +17,7 @@ const ( ConfigKey_AppDisableCtrlShiftArrows = "app:disablectrlshiftarrows" ConfigKey_AppDisableCtrlShiftDisplay = "app:disablectrlshiftdisplay" ConfigKey_AppFocusFollowsCursor = "app:focusfollowscursor" + ConfigKey_AppTabBar = "app:tabbar" ConfigKey_FeatureWaveAppBuilder = "feature:waveappbuilder" diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index 17aafa6685..b1b10d977f 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -68,6 +68,7 @@ type SettingsType struct { AppDisableCtrlShiftArrows bool `json:"app:disablectrlshiftarrows,omitempty"` AppDisableCtrlShiftDisplay bool `json:"app:disablectrlshiftdisplay,omitempty"` AppFocusFollowsCursor string `json:"app:focusfollowscursor,omitempty" jsonschema:"enum=off,enum=on,enum=term"` + AppTabBar string `json:"app:tabbar,omitempty" jsonschema:"enum=top,enum=left"` FeatureWaveAppBuilder bool `json:"feature:waveappbuilder,omitempty"` diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index 110e1695ef..103089144e 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -615,6 +615,12 @@ func ListAllEditableAppsCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]wshr return resp, err } +// command "macosversion", wshserver.MacOSVersionCommand +func MacOSVersionCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (string, error) { + resp, err := sendRpcRequestCallHelper[string](w, "macosversion", nil, opts) + return resp, err +} + // command "makedraftfromlocal", wshserver.MakeDraftFromLocalCommand func MakeDraftFromLocalCommand(w *wshutil.WshRpc, data wshrpc.CommandMakeDraftFromLocalData, opts *wshrpc.RpcOpts) (*wshrpc.CommandMakeDraftFromLocalRtnData, error) { resp, err := sendRpcRequestCallHelper[*wshrpc.CommandMakeDraftFromLocalRtnData](w, "makedraftfromlocal", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 8ddff8128b..2fee3e392e 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -83,6 +83,7 @@ type WshRpcInterface interface { DebugTermCommand(ctx context.Context, data CommandDebugTermData) (*CommandDebugTermRtnData, error) BlocksListCommand(ctx context.Context, data BlocksListRequest) ([]BlocksListEntry, error) WaveInfoCommand(ctx context.Context) (*WaveInfoData, error) + MacOSVersionCommand(ctx context.Context) (string, error) WshActivityCommand(ct context.Context, data map[string]int) error ActivityCommand(ctx context.Context, data ActivityUpdate) error RecordTEventCommand(ctx context.Context, data telemetrydata.TEvent) error diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 670c949f2e..b9d320f697 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -878,6 +878,10 @@ func (ws *WshServer) WaveInfoCommand(ctx context.Context) (*wshrpc.WaveInfoData, }, nil } +func (ws *WshServer) MacOSVersionCommand(ctx context.Context) (string, error) { + return wavebase.ClientMacOSVersion(), nil +} + // BlocksListCommand returns every block visible in the requested // scope (current workspace by default). func (ws *WshServer) BlocksListCommand( diff --git a/schema/settings.json b/schema/settings.json index 348c937dac..5213fed365 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -43,6 +43,13 @@ "term" ] }, + "app:tabbar": { + "type": "string", + "enum": [ + "top", + "left" + ] + }, "feature:waveappbuilder": { "type": "boolean" },