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 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/store/keymodel.ts b/frontend/app/store/keymodel.ts index aa25448a0a..f832d92e0d 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; } @@ -720,10 +721,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/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 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 ? (