diff --git a/.gitignore b/.gitignore index 161db5f191..a1c7240b5a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ out/ make/ artifacts/ mikework/ +aiplans/ manifests/ .env out diff --git a/Taskfile.yml b/Taskfile.yml index 80903ad60b..106ac99e0b 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -282,6 +282,18 @@ tasks: - cmd: powershell -NoProfile -NonInteractive -Command "Remove-Item -Force -ErrorAction SilentlyContinue -Path dist/bin/wsh*" platforms: [windows] ignore_error: true + - task: build:wsh:parallel + deps: + - go:mod:tidy + - generate + sources: + - "cmd/wsh/**/*.go" + - "pkg/**/*.go" + generates: + - "dist/bin/wsh*" + + build:wsh:parallel: + deps: - task: build:wsh:internal vars: GOOS: darwin @@ -314,14 +326,7 @@ tasks: vars: GOOS: windows GOARCH: arm64 - deps: - - go:mod:tidy - - generate - sources: - - "cmd/wsh/**/*.go" - - "pkg/**/*.go" - generates: - - "dist/bin/wsh*" + internal: true build:wsh:internal: vars: diff --git a/cmd/wsh/cmd/wshcmd-root.go b/cmd/wsh/cmd/wshcmd-root.go index 48a568d69c..534ce0c31a 100644 --- a/cmd/wsh/cmd/wshcmd-root.go +++ b/cmd/wsh/cmd/wshcmd-root.go @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd diff --git a/cmd/wsh/cmd/wshcmd-ssh.go b/cmd/wsh/cmd/wshcmd-ssh.go index 25dad3c098..4eb1d42a4e 100644 --- a/cmd/wsh/cmd/wshcmd-ssh.go +++ b/cmd/wsh/cmd/wshcmd-ssh.go @@ -1,4 +1,4 @@ -// Copyright 2025, Command Line Inc. +// Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package cmd @@ -7,6 +7,7 @@ import ( "fmt" "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/remote" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" "github.com/wavetermdev/waveterm/pkg/wshrpc" @@ -15,6 +16,8 @@ import ( var ( identityFiles []string + sshLogin string + sshPort string newBlock bool ) @@ -28,6 +31,8 @@ var sshCmd = &cobra.Command{ func init() { sshCmd.Flags().StringArrayVarP(&identityFiles, "identityfile", "i", []string{}, "add an identity file for publickey authentication") + sshCmd.Flags().StringVarP(&sshLogin, "login", "l", "", "set the remote login name") + sshCmd.Flags().StringVarP(&sshPort, "port", "p", "", "set the remote port") sshCmd.Flags().BoolVarP(&newBlock, "new", "n", false, "create a new terminal block with this connection") rootCmd.AddCommand(sshCmd) } @@ -38,6 +43,11 @@ func sshRun(cmd *cobra.Command, args []string) (rtnErr error) { }() sshArg := args[0] + var err error + sshArg, err = applySSHOverrides(sshArg, sshLogin, sshPort) + if err != nil { + return err + } blockId := RpcContext.BlockId if blockId == "" && !newBlock { return fmt.Errorf("cannot determine blockid (not in JWT)") @@ -91,10 +101,27 @@ func sshRun(cmd *cobra.Command, args []string) (rtnErr error) { waveobj.MetaKey_CmdCwd: nil, }, } - err := wshclient.SetMetaCommand(RpcClient, data, nil) + err = wshclient.SetMetaCommand(RpcClient, data, nil) if err != nil { return fmt.Errorf("setting connection in block: %w", err) } WriteStderr("switched connection to %q\n", sshArg) return nil } + +func applySSHOverrides(sshArg string, login string, port string) (string, error) { + if login == "" && port == "" { + return sshArg, nil + } + opts, err := remote.ParseOpts(sshArg) + if err != nil { + return "", err + } + if login != "" { + opts.SSHUser = login + } + if port != "" { + opts.SSHPort = port + } + return opts.String(), nil +} diff --git a/cmd/wsh/cmd/wshcmd-ssh_test.go b/cmd/wsh/cmd/wshcmd-ssh_test.go new file mode 100644 index 0000000000..36da037464 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-ssh_test.go @@ -0,0 +1,75 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import "testing" + +func TestApplySSHOverrides(t *testing.T) { + tests := []struct { + name string + sshArg string + login string + port string + want string + wantErr bool + }{ + { + name: "no overrides preserves target", + sshArg: "root@bar.com:2022", + want: "root@bar.com:2022", + }, + { + name: "login override replaces parsed user", + sshArg: "root@bar.com", + login: "foo", + want: "foo@bar.com", + }, + { + name: "port override replaces parsed port", + sshArg: "root@bar.com:2022", + port: "2222", + want: "root@bar.com:2222", + }, + { + name: "both overrides replace parsed user and port", + sshArg: "root@bar.com:2022", + login: "foo", + port: "2200", + want: "foo@bar.com:2200", + }, + { + name: "login override adds user to bare host", + sshArg: "bar.com", + login: "foo", + want: "foo@bar.com", + }, + { + name: "port override adds port to bare host", + sshArg: "bar.com", + port: "2200", + want: "bar.com:2200", + }, + { + name: "invalid target returns parse error when override requested", + sshArg: "bad host", + login: "foo", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := applySSHOverrides(tt.sshArg, tt.login, tt.port) + if (err != nil) != tt.wantErr { + t.Fatalf("applySSHOverrides() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + if got != tt.want { + t.Fatalf("applySSHOverrides() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx index 8a8a6330a0..ae83638ea5 100644 --- a/docs/docs/config.mdx +++ b/docs/docs/config.mdx @@ -44,6 +44,7 @@ wsh editconfig | app:disablectrlshiftarrows | bool | Set to true to disable Ctrl+Shift block-navigation keybindings (`Arrow` and `h/j/k/l`) (defaults to false) | | app:disablectrlshiftdisplay | bool | Set to true to disable the Ctrl+Shift visual indicator display (defaults to false) | | app:focusfollowscursor | string | Controls whether block focus follows cursor movement: `"off"` (default), `"on"` (all blocks), or `"term"` (terminal blocks only) | +| app:tabbar | string | Controls the position of the tab bar: `"top"` (default) for a horizontal tab bar at the top of the window, or `"left"` for a vertical tab bar on the left side of the window | | ai:preset | string | the default AI preset to use | | ai:baseurl | string | Set the AI Base Url (must be OpenAI compatible) | | ai:apitoken | string | your AI api token | diff --git a/emain/emain-ipc.ts b/emain/emain-ipc.ts index 1d1ec2108a..09830b9315 100644 --- a/emain/emain-ipc.ts +++ b/emain/emain-ipc.ts @@ -209,7 +209,7 @@ export function initIpcHandlers() { electron.ipcMain.on("webview-image-contextmenu", (event: electron.IpcMainEvent, payload: { src: string }) => { const menu = new electron.Menu(); - const win = getWaveWindowByWebContentsId(event.sender.hostWebContents.id); + const win = getWaveWindowByWebContentsId(event.sender.hostWebContents?.id); if (win == null) { return; } @@ -353,6 +353,7 @@ export function initIpcHandlers() { const png = PNG.sync.read(overlayBuffer); const color = fac.prepareResult(fac.getColorFromArray4(png.data)); const ww = getWaveWindowByWebContentsId(event.sender.id); + if (ww == null) return; ww.setTitleBarOverlay({ color: unamePlatform === "linux" ? color.rgba : "#00000000", symbolColor: color.isDark ? "white" : "black", diff --git a/emain/emain-tabview.ts b/emain/emain-tabview.ts index 7bf4cc23f3..753a53adec 100644 --- a/emain/emain-tabview.ts +++ b/emain/emain-tabview.ts @@ -109,6 +109,9 @@ function computeBgColor(fullConfig: FullConfigType): string { const wcIdToWaveTabMap = new Map(); export function getWaveTabViewByWebContentsId(webContentsId: number): WaveTabView { + if (webContentsId == null) { + return null; + } return wcIdToWaveTabMap.get(webContentsId); } @@ -154,14 +157,15 @@ export class WaveTabView extends WebContentsView { this.waveReadyPromise.then(() => { this.isWaveReady = true; }); - wcIdToWaveTabMap.set(this.webContents.id, this); + const wcId = this.webContents.id; + wcIdToWaveTabMap.set(wcId, this); if (isDevVite) { this.webContents.loadURL(`${process.env.ELECTRON_RENDERER_URL}/index.html`); } else { this.webContents.loadFile(path.join(getElectronAppBasePath(), "frontend", "index.html")); } this.webContents.on("destroyed", () => { - wcIdToWaveTabMap.delete(this.webContents.id); + wcIdToWaveTabMap.delete(wcId); removeWaveTabView(this.waveTabId); this.isDestroyed = true; }); @@ -283,7 +287,6 @@ function checkAndEvictCache(): void { // Otherwise, sort by lastUsedTs return a.lastUsedTs - b.lastUsedTs; }); - const now = Date.now(); for (let i = 0; i < sorted.length - MaxCacheSize; i++) { tryEvictEntry(sorted[i].waveTabId); } @@ -313,6 +316,9 @@ export async function getOrCreateWebViewForTab(waveWindowId: string, tabId: stri tabView.webContents.on("will-frame-navigate", shFrameNavHandler); tabView.webContents.on("did-attach-webview", (event, wc) => { wc.setWindowOpenHandler((details) => { + if (wc == null || wc.isDestroyed() || tabView.webContents == null || tabView.webContents.isDestroyed()) { + return { action: "deny" }; + } tabView.webContents.send("webview-new-window", wc.id, details); return { action: "deny" }; }); diff --git a/emain/emain-window.ts b/emain/emain-window.ts index 2c34d3a39c..5f481e30f1 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -674,6 +674,9 @@ export function getWaveWindowByTabId(tabId: string): WaveBrowserWindow { } export function getWaveWindowByWebContentsId(webContentsId: number): WaveBrowserWindow { + if (webContentsId == null) { + return null; + } const tabView = getWaveTabViewByWebContentsId(webContentsId); if (tabView == null) { return null; diff --git a/frontend/app/aipanel/aipanel.tsx b/frontend/app/aipanel/aipanel.tsx index 112a4cc79e..37c22709fe 100644 --- a/frontend/app/aipanel/aipanel.tsx +++ b/frontend/app/aipanel/aipanel.tsx @@ -245,7 +245,11 @@ const ConfigChangeModeFixer = memo(() => { ConfigChangeModeFixer.displayName = "ConfigChangeModeFixer"; -const AIPanelComponentInner = memo(() => { +type AIPanelComponentInnerProps = { + roundTopLeft: boolean; +}; + +const AIPanelComponentInner = memo(({ roundTopLeft }: AIPanelComponentInnerProps) => { const [isDragOver, setIsDragOver] = useState(false); const [isReactDndDragOver, setIsReactDndDragOver] = useState(false); const [initialLoadDone, setInitialLoadDone] = useState(false); @@ -554,6 +558,7 @@ const AIPanelComponentInner = memo(() => { isFocused ? "border-2 border-accent" : "border-2 border-transparent" )} style={{ + borderTopLeftRadius: roundTopLeft ? 10 : 0, borderTopRightRadius: model.inBuilder ? 0 : 10, borderBottomRightRadius: model.inBuilder ? 0 : 10, borderBottomLeftRadius: 10, @@ -607,10 +612,14 @@ const AIPanelComponentInner = memo(() => { AIPanelComponentInner.displayName = "AIPanelInner"; -const AIPanelComponent = () => { +type AIPanelComponentProps = { + roundTopLeft: boolean; +}; + +const AIPanelComponent = ({ roundTopLeft }: AIPanelComponentProps) => { return ( - + ); }; 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/blockenv.ts b/frontend/app/block/blockenv.ts index b2df51192d..000228c014 100644 --- a/frontend/app/block/blockenv.ts +++ b/frontend/app/block/blockenv.ts @@ -16,6 +16,7 @@ export type BlockEnv = WaveEnvSubset<{ | "window:magnifiedblockblurprimarypx" | "window:magnifiedblockopacity" >; + showContextMenu: WaveEnv["showContextMenu"]; atoms: { modalOpen: WaveEnv["atoms"]["modalOpen"]; controlShiftDelayAtom: WaveEnv["atoms"]["controlShiftDelayAtom"]; diff --git a/frontend/app/block/blockframe-header.tsx b/frontend/app/block/blockframe-header.tsx index 252f1f8845..319e9b4a49 100644 --- a/frontend/app/block/blockframe-header.tsx +++ b/frontend/app/block/blockframe-header.tsx @@ -11,26 +11,26 @@ import { import { ConnectionButton } from "@/app/block/connectionbutton"; import { DurableSessionFlyover } from "@/app/block/durable-session-flyover"; import { getBlockBadgeAtom } from "@/app/store/badge"; -import { ContextMenuModel } from "@/app/store/contextmenu"; import { recordTEvent, refocusNode } from "@/app/store/global"; import { globalStore } from "@/app/store/jotaiStore"; import { uxCloseBlock } from "@/app/store/keymodel"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { useWaveEnv } from "@/app/waveenv/waveenv"; -import { BlockEnv } from "./blockenv"; import { IconButton } from "@/element/iconbutton"; import { NodeModel } from "@/layout/index"; import * as util from "@/util/util"; import { cn, makeIconClass } from "@/util/util"; import * as jotai from "jotai"; import * as React from "react"; +import { BlockEnv } from "./blockenv"; import { BlockFrameProps } from "./blocktypes"; function handleHeaderContextMenu( e: React.MouseEvent, blockId: string, viewModel: ViewModel, - nodeModel: NodeModel + nodeModel: NodeModel, + blockEnv: BlockEnv ) { e.preventDefault(); e.stopPropagation(); @@ -59,7 +59,7 @@ function handleHeaderContextMenu( click: () => uxCloseBlock(blockId), } ); - ContextMenuModel.getInstance().showContextMenu(menu, e); + blockEnv.showContextMenu(menu, e); } type HeaderTextElemsProps = { @@ -113,6 +113,7 @@ type HeaderEndIconsProps = { }; const HeaderEndIcons = React.memo(({ viewModel, nodeModel, blockId }: HeaderEndIconsProps) => { + const blockEnv = useWaveEnv(); const endIconButtons = util.useAtomValueSafe(viewModel?.endIconButtons); const magnified = jotai.useAtomValue(nodeModel.isMagnified); const ephemeral = jotai.useAtomValue(nodeModel.isEphemeral); @@ -128,7 +129,7 @@ const HeaderEndIcons = React.memo(({ viewModel, nodeModel, blockId }: HeaderEndI elemtype: "iconbutton", icon: "cog", title: "Settings", - click: (e) => handleHeaderContextMenu(e, blockId, viewModel, nodeModel), + click: (e) => handleHeaderContextMenu(e, blockId, viewModel, nodeModel, blockEnv), }; endIconsElem.push(); if (ephemeral) { @@ -211,7 +212,7 @@ const BlockFrame_Header = ({ className={cn("block-frame-default-header", useTermHeader && "!pl-[2px]")} data-role="block-header" ref={dragHandleRef} - onContextMenu={(e) => handleHeaderContextMenu(e, nodeModel.blockId, viewModel, nodeModel)} + onContextMenu={(e) => handleHeaderContextMenu(e, nodeModel.blockId, viewModel, nodeModel, waveEnv)} > {!useTermHeader && ( <> diff --git a/frontend/app/block/blockframe.tsx b/frontend/app/block/blockframe.tsx index 0b4abb755b..1e0cba8229 100644 --- a/frontend/app/block/blockframe.tsx +++ b/frontend/app/block/blockframe.tsx @@ -96,7 +96,7 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => { const waveEnv = useWaveEnv(); const { nodeModel, viewModel, blockModel, preview, numBlocksInTab, children } = props; const isFocused = jotai.useAtomValue(nodeModel.isFocused); - const aiPanelVisible = jotai.useAtomValue(WorkspaceLayoutModel.getInstance().panelVisibleAtom); + const sidePanelVisible = jotai.useAtomValue(WorkspaceLayoutModel.getInstance().activePanelAtom) != null; const metaView = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "view")); const viewIconUnion = util.useAtomValueSafe(viewModel?.viewIcon) ?? blockViewToIcon(metaView); const customBg = util.useAtomValueSafe(viewModel?.blockBg); @@ -107,9 +107,13 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => { const connModalOpen = jotai.useAtomValue(changeConnModalAtom); const isMagnified = jotai.useAtomValue(nodeModel.isMagnified); const isEphemeral = jotai.useAtomValue(nodeModel.isEphemeral); - const [magnifiedBlockBlurAtom] = React.useState(() => waveEnv.getSettingsKeyAtom("window:magnifiedblockblurprimarypx")); + const [magnifiedBlockBlurAtom] = React.useState(() => + waveEnv.getSettingsKeyAtom("window:magnifiedblockblurprimarypx") + ); const magnifiedBlockBlur = jotai.useAtomValue(magnifiedBlockBlurAtom); - const [magnifiedBlockOpacityAtom] = React.useState(() => waveEnv.getSettingsKeyAtom("window:magnifiedblockopacity")); + const [magnifiedBlockOpacityAtom] = React.useState(() => + waveEnv.getSettingsKeyAtom("window:magnifiedblockopacity") + ); const magnifiedBlockOpacity = jotai.useAtomValue(magnifiedBlockOpacityAtom); const connBtnRef = React.useRef(null); const connName = jotai.useAtomValue(waveEnv.getBlockMetaKeyAtom(nodeModel.blockId, "connection")); @@ -141,7 +145,11 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => { if (!util.isLocalConnName(connName)) { console.log("ensure conn", nodeModel.blockId, connName); waveEnv.rpc - .ConnEnsureCommand(TabRpcClient, { connname: connName, logblockid: nodeModel.blockId }, { timeout: 60000 }) + .ConnEnsureCommand( + TabRpcClient, + { connname: connName, logblockid: nodeModel.blockId }, + { timeout: 60000 } + ) .catch((e) => { console.log("error ensuring connection", nodeModel.blockId, connName, e); }); @@ -163,7 +171,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 new file mode 100644 index 0000000000..3c6ffcf9fe --- /dev/null +++ b/frontend/app/fileexplorer/fileexplorer.test.ts @@ -0,0 +1,52 @@ +// 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..3d961eb22a --- /dev/null +++ b/frontend/app/fileexplorer/fileexplorer.tsx @@ -0,0 +1,95 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +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"; + +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 ?? "", + fileInfo.dir ?? "", + fileInfo.isdir ? "dir" : "file", + fileInfo.staterror ?? "", + ].join("::"); + 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/onboarding/onboarding-common.tsx b/frontend/app/onboarding/onboarding-common.tsx index 44001aca5d..60711746e1 100644 --- a/frontend/app/onboarding/onboarding-common.tsx +++ b/frontend/app/onboarding/onboarding-common.tsx @@ -1,7 +1,7 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -export const CurrentOnboardingVersion = "v0.14.2"; +export const CurrentOnboardingVersion = "v0.14.3"; export function OnboardingGradientBg() { return ( diff --git a/frontend/app/onboarding/onboarding-upgrade-patch.tsx b/frontend/app/onboarding/onboarding-upgrade-patch.tsx index 60760ffea1..0eded88f12 100644 --- a/frontend/app/onboarding/onboarding-upgrade-patch.tsx +++ b/frontend/app/onboarding/onboarding-upgrade-patch.tsx @@ -133,10 +133,10 @@ export const UpgradeOnboardingVersions: VersionConfig[] = [ version: "v0.14.1", content: () => , prevText: "Prev (v0.14.0)", - nextText: "Next (v0.14.2)", + nextText: "Next (v0.14.3)", }, { - version: "v0.14.2", + version: "v0.14.3", content: () => , prevText: "Prev (v0.14.1)", }, diff --git a/frontend/app/onboarding/onboarding-upgrade-v0142.tsx b/frontend/app/onboarding/onboarding-upgrade-v0142.tsx index 90ddb2cd69..2fb8c1bd82 100644 --- a/frontend/app/onboarding/onboarding-upgrade-v0142.tsx +++ b/frontend/app/onboarding/onboarding-upgrade-v0142.tsx @@ -10,7 +10,8 @@ const UpgradeOnboardingModal_v0_14_2_Content = () => {

Wave v0.14.2 introduces a new block badge system for at-a-glance status, along with directory - preview improvements and bug fixes. + preview improvements and bug fixes. v0.14.3 is a patch release fixing a showstopper bug in + onboarding.

@@ -62,6 +63,9 @@ const UpgradeOnboardingModal_v0_14_2_Content = () => {
Other Changes
    +
  • + [v0.14.3] [bugfix] Fixed a showstopper onboarding bug +
  • Directory Preview - Improved mod time formatting, zebra-striped rows, better default sort, and YAML file support diff --git a/frontend/app/onboarding/onboarding.tsx b/frontend/app/onboarding/onboarding.tsx index 7c95ef27a6..ba139e81df 100644 --- a/frontend/app/onboarding/onboarding.tsx +++ b/frontend/app/onboarding/onboarding.tsx @@ -59,7 +59,7 @@ const InitPage = ({ const acceptTos = () => { if (!clientData?.tosagreed) { - fireAndForget(services.ClientService.AgreeTos); + fireAndForget(() => services.ClientService.AgreeTos()); } if (telemetryEnabled) { WorkspaceLayoutModel.getInstance().setAIPanelVisible(true); @@ -325,7 +325,7 @@ const NewInstallOnboardingModal = () => { let pageComp: React.JSX.Element = null; switch (pageName) { case "init": - pageComp = ; + pageComp = services.ClientService.TelemetryUpdate(value)} />; break; case "notelemetrystar": pageComp = ; diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index afa5209116..b454b0df48 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, @@ -182,7 +183,7 @@ function uxCloseBlock(blockId: string) { function genericClose() { const focusType = FocusManager.getInstance().getFocusType(); if (focusType === "waveai") { - WorkspaceLayoutModel.getInstance().setAIPanelVisible(false); + WorkspaceLayoutModel.getInstance().closePanel(); return; } @@ -728,10 +729,15 @@ function registerGlobalKeys() { return false; }); globalKeyMap.set("Cmd:Shift:a", () => { - const currentVisible = WorkspaceLayoutModel.getInstance().getAIPanelVisible(); - WorkspaceLayoutModel.getInstance().setAIPanelVisible(!currentVisible); + WorkspaceLayoutModel.getInstance().togglePanel("waveai"); return true; }); + if (isDev()) { + globalKeyMap.set("Cmd:Shift:e", () => { + WorkspaceLayoutModel.getInstance().togglePanel("fileexplorer", { nofocus: 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/store/services.ts b/frontend/app/store/services.ts index 3dad2a3e5c..035834672a 100644 --- a/frontend/app/store/services.ts +++ b/frontend/app/store/services.ts @@ -24,18 +24,18 @@ export class BlockServiceType { // queue a layout action to cleanup orphaned blocks in the tab // @returns object updates CleanupOrphanedBlocks(tabId: string): Promise { - return callBackendService(this.waveEnv, "block", "CleanupOrphanedBlocks", Array.from(arguments)) + return callBackendService(this?.waveEnv, "block", "CleanupOrphanedBlocks", Array.from(arguments)) } GetControllerStatus(arg2: string): Promise { - return callBackendService(this.waveEnv, "block", "GetControllerStatus", Array.from(arguments)) + return callBackendService(this?.waveEnv, "block", "GetControllerStatus", Array.from(arguments)) } // save the terminal state to a blockfile SaveTerminalState(blockId: string, state: string, stateType: string, ptyOffset: number, termSize: TermSize): Promise { - return callBackendService(this.waveEnv, "block", "SaveTerminalState", Array.from(arguments)) + return callBackendService(this?.waveEnv, "block", "SaveTerminalState", Array.from(arguments)) } SaveWaveAiData(arg2: string, arg3: WaveAIPromptMessageType[]): Promise { - return callBackendService(this.waveEnv, "block", "SaveWaveAiData", Array.from(arguments)) + return callBackendService(this?.waveEnv, "block", "SaveWaveAiData", Array.from(arguments)) } } @@ -51,22 +51,22 @@ export class ClientServiceType { // @returns object updates AgreeTos(): Promise { - return callBackendService(this.waveEnv, "client", "AgreeTos", Array.from(arguments)) + return callBackendService(this?.waveEnv, "client", "AgreeTos", Array.from(arguments)) } FocusWindow(arg2: string): Promise { - return callBackendService(this.waveEnv, "client", "FocusWindow", Array.from(arguments)) + return callBackendService(this?.waveEnv, "client", "FocusWindow", Array.from(arguments)) } GetAllConnStatus(): Promise { - return callBackendService(this.waveEnv, "client", "GetAllConnStatus", Array.from(arguments)) + return callBackendService(this?.waveEnv, "client", "GetAllConnStatus", Array.from(arguments)) } GetClientData(): Promise { - return callBackendService(this.waveEnv, "client", "GetClientData", Array.from(arguments)) + return callBackendService(this?.waveEnv, "client", "GetClientData", Array.from(arguments)) } GetTab(arg1: string): Promise { - return callBackendService(this.waveEnv, "client", "GetTab", Array.from(arguments)) + return callBackendService(this?.waveEnv, "client", "GetTab", Array.from(arguments)) } TelemetryUpdate(arg2: boolean): Promise { - return callBackendService(this.waveEnv, "client", "TelemetryUpdate", Array.from(arguments)) + return callBackendService(this?.waveEnv, "client", "TelemetryUpdate", Array.from(arguments)) } } @@ -82,32 +82,32 @@ export class ObjectServiceType { // @returns blockId (and object updates) CreateBlock(blockDef: BlockDef, rtOpts: RuntimeOpts): Promise { - return callBackendService(this.waveEnv, "object", "CreateBlock", Array.from(arguments)) + return callBackendService(this?.waveEnv, "object", "CreateBlock", Array.from(arguments)) } // @returns object updates DeleteBlock(blockId: string): Promise { - return callBackendService(this.waveEnv, "object", "DeleteBlock", Array.from(arguments)) + return callBackendService(this?.waveEnv, "object", "DeleteBlock", Array.from(arguments)) } // get wave object by oref GetObject(oref: string): Promise { - return callBackendService(this.waveEnv, "object", "GetObject", Array.from(arguments)) + return callBackendService(this?.waveEnv, "object", "GetObject", Array.from(arguments)) } // @returns objects GetObjects(orefs: string[]): Promise { - return callBackendService(this.waveEnv, "object", "GetObjects", Array.from(arguments)) + return callBackendService(this?.waveEnv, "object", "GetObjects", Array.from(arguments)) } // @returns object updates UpdateObject(waveObj: WaveObj, returnUpdates: boolean): Promise { - return callBackendService(this.waveEnv, "object", "UpdateObject", Array.from(arguments)) + return callBackendService(this?.waveEnv, "object", "UpdateObject", Array.from(arguments)) } // @returns object updates UpdateObjectMeta(oref: string, meta: MetaType): Promise { - return callBackendService(this.waveEnv, "object", "UpdateObjectMeta", Array.from(arguments)) + return callBackendService(this?.waveEnv, "object", "UpdateObjectMeta", Array.from(arguments)) } } @@ -122,7 +122,7 @@ export class UserInputServiceType { } SendUserInputResponse(arg1: UserInputResponse): Promise { - return callBackendService(this.waveEnv, "userinput", "SendUserInputResponse", Array.from(arguments)) + return callBackendService(this?.waveEnv, "userinput", "SendUserInputResponse", Array.from(arguments)) } } @@ -137,22 +137,22 @@ export class WindowServiceType { } CloseWindow(windowId: string, fromElectron: boolean): Promise { - return callBackendService(this.waveEnv, "window", "CloseWindow", Array.from(arguments)) + return callBackendService(this?.waveEnv, "window", "CloseWindow", Array.from(arguments)) } CreateWindow(winSize: WinSize, workspaceId: string): Promise { - return callBackendService(this.waveEnv, "window", "CreateWindow", Array.from(arguments)) + return callBackendService(this?.waveEnv, "window", "CreateWindow", Array.from(arguments)) } GetWindow(windowId: string): Promise { - return callBackendService(this.waveEnv, "window", "GetWindow", Array.from(arguments)) + return callBackendService(this?.waveEnv, "window", "GetWindow", Array.from(arguments)) } // set window position and size // @returns object updates SetWindowPosAndSize(windowId: string, pos: Point, size: WinSize): Promise { - return callBackendService(this.waveEnv, "window", "SetWindowPosAndSize", Array.from(arguments)) + return callBackendService(this?.waveEnv, "window", "SetWindowPosAndSize", Array.from(arguments)) } SwitchWorkspace(windowId: string, workspaceId: string): Promise { - return callBackendService(this.waveEnv, "window", "SwitchWorkspace", Array.from(arguments)) + return callBackendService(this?.waveEnv, "window", "SwitchWorkspace", Array.from(arguments)) } } @@ -168,50 +168,50 @@ export class WorkspaceServiceType { // @returns CloseTabRtn (and object updates) CloseTab(workspaceId: string, tabId: string, fromElectron: boolean): Promise { - return callBackendService(this.waveEnv, "workspace", "CloseTab", Array.from(arguments)) + return callBackendService(this?.waveEnv, "workspace", "CloseTab", Array.from(arguments)) } // @returns tabId (and object updates) CreateTab(workspaceId: string, tabName: string, activateTab: boolean): Promise { - return callBackendService(this.waveEnv, "workspace", "CreateTab", Array.from(arguments)) + return callBackendService(this?.waveEnv, "workspace", "CreateTab", Array.from(arguments)) } // @returns workspaceId CreateWorkspace(name: string, icon: string, color: string, applyDefaults: boolean): Promise { - return callBackendService(this.waveEnv, "workspace", "CreateWorkspace", Array.from(arguments)) + return callBackendService(this?.waveEnv, "workspace", "CreateWorkspace", Array.from(arguments)) } // @returns object updates DeleteWorkspace(workspaceId: string): Promise { - return callBackendService(this.waveEnv, "workspace", "DeleteWorkspace", Array.from(arguments)) + return callBackendService(this?.waveEnv, "workspace", "DeleteWorkspace", Array.from(arguments)) } // @returns colors GetColors(): Promise { - return callBackendService(this.waveEnv, "workspace", "GetColors", Array.from(arguments)) + return callBackendService(this?.waveEnv, "workspace", "GetColors", Array.from(arguments)) } // @returns icons GetIcons(): Promise { - return callBackendService(this.waveEnv, "workspace", "GetIcons", Array.from(arguments)) + return callBackendService(this?.waveEnv, "workspace", "GetIcons", Array.from(arguments)) } // @returns workspace GetWorkspace(workspaceId: string): Promise { - return callBackendService(this.waveEnv, "workspace", "GetWorkspace", Array.from(arguments)) + return callBackendService(this?.waveEnv, "workspace", "GetWorkspace", Array.from(arguments)) } ListWorkspaces(): Promise { - return callBackendService(this.waveEnv, "workspace", "ListWorkspaces", Array.from(arguments)) + return callBackendService(this?.waveEnv, "workspace", "ListWorkspaces", Array.from(arguments)) } // @returns object updates SetActiveTab(workspaceId: string, tabId: string): Promise { - return callBackendService(this.waveEnv, "workspace", "SetActiveTab", Array.from(arguments)) + return callBackendService(this?.waveEnv, "workspace", "SetActiveTab", Array.from(arguments)) } // @returns object updates UpdateWorkspace(workspaceId: string, name: string, icon: string, color: string, applyDefaults: boolean): Promise { - return callBackendService(this.waveEnv, "workspace", "UpdateWorkspace", Array.from(arguments)) + return callBackendService(this?.waveEnv, "workspace", "UpdateWorkspace", Array.from(arguments)) } } diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 6b9f4a72d4..d64f7f06b0 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -618,6 +618,12 @@ export class RpcApiType { return client.wshRpcCall("listalleditableapps", null, opts); } + // command "macosversion" [call] + MacOSVersionCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "macosversion", null, opts); + return client.wshRpcCall("macosversion", null, opts); + } + // command "makedraftfromlocal" [call] MakeDraftFromLocalCommand(client: WshClient, data: CommandMakeDraftFromLocalData, opts?: RpcOpts): Promise { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "makedraftfromlocal", data, opts); diff --git a/frontend/app/tab/tab.tsx b/frontend/app/tab/tab.tsx index 6b3679bb37..7b2aa6856e 100644 --- a/frontend/app/tab/tab.tsx +++ b/frontend/app/tab/tab.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { getTabBadgeAtom } from "@/app/store/badge"; -import { getOrefMetaKeyAtom, globalStore, recordTEvent, refocusNode } from "@/app/store/global"; +import { refocusNode } from "@/app/store/global"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { WaveEnv, WaveEnvSubset, useWaveEnv } from "@/app/waveenv/waveenv"; import { Button } from "@/element/button"; @@ -14,10 +14,12 @@ import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useRef, import { makeORef } from "../store/wos"; import { TabBadges } from "./tabbadges"; import "./tab.scss"; +import { buildTabContextMenu } from "./tabcontextmenu"; -type TabEnv = WaveEnvSubset<{ +export type TabEnv = WaveEnvSubset<{ rpc: { ActivityCommand: WaveEnv["rpc"]["ActivityCommand"]; + SetConfigCommand: WaveEnv["rpc"]["SetConfigCommand"]; SetMetaCommand: WaveEnv["rpc"]["SetMetaCommand"]; UpdateTabNameCommand: WaveEnv["rpc"]["UpdateTabNameCommand"]; }; @@ -25,6 +27,7 @@ type TabEnv = WaveEnvSubset<{ fullConfigAtom: WaveEnv["atoms"]["fullConfigAtom"]; }; wos: WaveEnv["wos"]; + getSettingsKeyAtom: WaveEnv["getSettingsKeyAtom"]; showContextMenu: WaveEnv["showContextMenu"]; }>; @@ -216,88 +219,6 @@ const TabV = forwardRef((props, ref) => { TabV.displayName = "TabV"; -const FlagColors: { label: string; value: string }[] = [ - { label: "Green", value: "#58C142" }, - { label: "Teal", value: "#00FFDB" }, - { label: "Blue", value: "#429DFF" }, - { label: "Purple", value: "#BF55EC" }, - { label: "Red", value: "#FF453A" }, - { label: "Orange", value: "#FF9500" }, - { label: "Yellow", value: "#FFE900" }, -]; - -function buildTabContextMenu( - id: string, - renameRef: React.RefObject<(() => void) | null>, - onClose: (event: React.MouseEvent | null) => void, - env: TabEnv -): ContextMenuItem[] { - const menu: ContextMenuItem[] = []; - menu.push( - { label: "Rename Tab", click: () => renameRef.current?.() }, - { - label: "Copy TabId", - click: () => fireAndForget(() => navigator.clipboard.writeText(id)), - }, - { type: "separator" } - ); - const tabORef = makeORef("tab", id); - const currentFlagColor = globalStore.get(getOrefMetaKeyAtom(tabORef, "tab:flagcolor")) ?? null; - const flagSubmenu: ContextMenuItem[] = [ - { - label: "None", - type: "checkbox", - checked: currentFlagColor == null, - click: () => - fireAndForget(() => - env.rpc.SetMetaCommand(TabRpcClient, { oref: tabORef, meta: { "tab:flagcolor": null } }) - ), - }, - ...FlagColors.map((fc) => ({ - label: fc.label, - type: "checkbox" as const, - checked: currentFlagColor === fc.value, - click: () => - fireAndForget(() => - env.rpc.SetMetaCommand(TabRpcClient, { oref: tabORef, meta: { "tab:flagcolor": fc.value } }) - ), - })), - ]; - menu.push({ label: "Flag Tab", type: "submenu", submenu: flagSubmenu }, { type: "separator" }); - const fullConfig = globalStore.get(env.atoms.fullConfigAtom); - const bgPresets: string[] = []; - for (const key in fullConfig?.presets ?? {}) { - if (key.startsWith("bg@") && fullConfig.presets[key] != null) { - bgPresets.push(key); - } - } - bgPresets.sort((a, b) => { - const aOrder = fullConfig.presets[a]["display:order"] ?? 0; - const bOrder = fullConfig.presets[b]["display:order"] ?? 0; - return aOrder - bOrder; - }); - if (bgPresets.length > 0) { - const submenu: ContextMenuItem[] = []; - const oref = makeORef("tab", id); - for (const presetName of bgPresets) { - // preset cannot be null (filtered above) - const preset = fullConfig.presets[presetName]; - submenu.push({ - label: preset["display:name"] ?? presetName, - click: () => - fireAndForget(async () => { - await env.rpc.SetMetaCommand(TabRpcClient, { oref, meta: preset }); - env.rpc.ActivityCommand(TabRpcClient, { settabtheme: 1 }, { noresponse: true }); - recordTEvent("action:settabtheme"); - }), - }); - } - menu.push({ label: "Backgrounds", type: "submenu", submenu }, { type: "separator" }); - } - menu.push({ label: "Close Tab", click: () => onClose(null) }); - return menu; -} - interface TabProps { id: string; active: boolean; diff --git a/frontend/app/tab/tabbar.tsx b/frontend/app/tab/tabbar.tsx index a5cbff3398..019e6a9627 100644 --- a/frontend/app/tab/tabbar.tsx +++ b/frontend/app/tab/tabbar.tsx @@ -7,6 +7,7 @@ import { TabRpcClient } from "@/app/store/wshrpcutil"; import { useWaveEnv } from "@/app/waveenv/waveenv"; import { WorkspaceLayoutModel } from "@/app/workspace/workspace-layout-model"; import { deleteLayoutModelForTab } from "@/layout/index"; +import { isMacOSTahoeOrLater } from "@/util/platformutil"; import { fireAndForget } from "@/util/util"; import { useAtomValue } from "jotai"; import { OverlayScrollbars } from "overlayscrollbars"; @@ -20,6 +21,9 @@ import { WorkspaceSwitcher } from "./workspaceswitcher"; const TabDefaultWidth = 130; const TabMinWidth = 100; +const MacOSTrafficLightsWidth = 74; +const MacOSTahoeTrafficLightsWidth = 80; + const OSOptions = { overflow: { x: "scroll", @@ -39,16 +43,16 @@ const OSOptions = { interface TabBarProps { workspace: Workspace; + noTabs?: boolean; } const WaveAIButton = memo(({ divRef }: { divRef?: React.RefObject }) => { const env = useWaveEnv(); - const aiPanelOpen = useAtomValue(WorkspaceLayoutModel.getInstance().panelVisibleAtom); + const aiPanelOpen = useAtomValue(WorkspaceLayoutModel.getInstance().activePanelAtom) === "waveai"; const hideAiButton = useAtomValue(env.getSettingsKeyAtom("app:hideaibutton")); const onClick = () => { - const currentVisible = WorkspaceLayoutModel.getInstance().getAIPanelVisible(); - WorkspaceLayoutModel.getInstance().setAIPanelVisible(!currentVisible); + WorkspaceLayoutModel.getInstance().togglePanel("waveai"); }; if (hideAiButton) { @@ -152,7 +156,7 @@ function strArrayIsEqual(a: string[], b: string[]) { return true; } -const TabBar = memo(({ workspace }: TabBarProps) => { +const TabBar = memo(({ workspace, noTabs }: TabBarProps) => { const env = useWaveEnv(); const [tabIds, setTabIds] = useState([]); const [dragStartPositions, setDragStartPositions] = useState([]); @@ -635,10 +639,13 @@ const TabBar = memo(({ workspace }: TabBarProps) => { // Calculate window drag left width based on platform and state let windowDragLeftWidth = 10; if (env.isMacOS() && !isFullScreen) { + const trafficLightsWidth = isMacOSTahoeOrLater() + ? MacOSTahoeTrafficLightsWidth + : MacOSTrafficLightsWidth; if (zoomFactor > 0) { - windowDragLeftWidth = 74 / zoomFactor; + windowDragLeftWidth = trafficLightsWidth / zoomFactor; } else { - windowDragLeftWidth = 74; + windowDragLeftWidth = trafficLightsWidth; } } @@ -680,33 +687,41 @@ const TabBar = memo(({ workspace }: TabBarProps) => {
    -
    - {tabIds.map((tabId, index) => { - const isActive = activeTabId === tabId; - const showDivider = index !== 0 && !isActive && index !== activeTabIndex + 1; - return ( - handleSelectTab(tabId)} - active={isActive} - onDragStart={(event) => handleDragStart(event, tabId, tabRefs.current[index])} - onClose={(event) => handleCloseTab(event, tabId)} - onLoaded={() => handleTabLoaded(tabId)} - isDragging={draggingTab === tabId} - tabWidth={tabWidthRef.current} - isNew={tabId === newTabId} - /> - ); - })} +
    + {!noTabs && + tabIds.map((tabId, index) => { + const isActive = activeTabId === tabId; + const showDivider = index !== 0 && !isActive && index !== activeTabIndex + 1; + return ( + handleSelectTab(tabId)} + active={isActive} + onDragStart={(event) => handleDragStart(event, tabId, tabRefs.current[index])} + onClose={(event) => handleCloseTab(event, tabId)} + onLoaded={() => handleTabLoaded(tabId)} + isDragging={draggingTab === tabId} + tabWidth={tabWidthRef.current} + isNew={tabId === newTabId} + /> + ); + })}
    ); } 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/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 4481d2c68f..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; } @@ -57,6 +58,7 @@ export interface TreeViewProps { rootIds: string[]; initialNodes: Record; fetchDir?: (id: string, limit: number) => Promise; + initialExpandedIds?: string[]; maxDirEntries?: number; rowHeight?: number; indentWidth?: number; @@ -66,6 +68,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; } @@ -119,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, @@ -130,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") { @@ -208,6 +206,7 @@ export const TreeView = forwardRef((props, ref) => { rootIds, initialNodes, fetchDir, + initialExpandedIds = [], maxDirEntries = 500, rowHeight = DefaultRowHeight, indentWidth = DefaultIndentWidth, @@ -217,6 +216,7 @@ export const TreeView = forwardRef((props, ref) => { width = "100%", height = 360, className, + expandDirectoriesOnClick = false, onOpenFile, onSelectionChange, } = props; @@ -226,9 +226,10 @@ 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); + const loadingIdsRef = useRef>(new Set()); useEffect(() => { setNodesById( @@ -244,6 +245,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])), @@ -287,9 +292,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" }); @@ -331,6 +337,8 @@ export const TreeView = forwardRef((props, ref) => { }); return next; }); + } finally { + loadingIdsRef.current.delete(id); } }; @@ -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; @@ -473,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 ? (