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/4] 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/4] 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/4] 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/4] 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 ? (