Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/app/aipanel/waveai-model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
4 changes: 2 additions & 2 deletions frontend/app/block/blockframe.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ const BlockFrame_Default_Component = (props: BlockFrameProps) => {
const { nodeModel, viewModel, blockModel, preview, numBlocksInTab, children } = props;
const [blockData] = WOS.useWaveObjectValue<Block>(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);
Expand Down Expand Up @@ -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,
})}
Expand Down
52 changes: 52 additions & 0 deletions frontend/app/fileexplorer/fileexplorer.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});

});
95 changes: 95 additions & 0 deletions frontend/app/fileexplorer/fileexplorer.tsx
Original file line number Diff line number Diff line change
@@ -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<string>(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 (
<div className="flex h-full min-h-0 flex-col bg-panel">
<div className="border-b border-border px-4 py-3">
<div className="text-sm font-semibold text-foreground">File Explorer</div>
<div className="mt-1 overflow-hidden whitespace-nowrap text-ellipsis text-xs text-muted">local • {selectedPath}</div>
</div>
<div className="min-h-0 flex-1">
<TreeView
rootIds={[FileExplorerRootId]}
initialNodes={initialNodes}
initialExpandedIds={initialExpandedIds}
fetchDir={fetchDir}
width="100%"
minWidth={0}
maxWidth={100000}
height="100%"
className={cn("h-full rounded-none border-0")}
expandDirectoriesOnClick
onSelectionChange={(_, node) => setSelectedPath(node.path ?? node.id)}
/>
</div>
</div>
);
});

FileExplorerPanel.displayName = "FileExplorerPanel";

export { FileExplorerPanel };
12 changes: 9 additions & 3 deletions frontend/app/store/keymodel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
getFocusedBlockId,
getSettingsKeyAtom,
globalStore,
isDev,
recordTEvent,
refocusNode,
replaceBlock,
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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");
Expand Down
5 changes: 2 additions & 3 deletions frontend/app/tab/tabbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,11 @@ interface TabBarProps {
}

const WaveAIButton = memo(({ divRef }: { divRef?: React.RefObject<HTMLDivElement> }) => {
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) {
Expand Down
5 changes: 3 additions & 2 deletions frontend/app/treeview/treeview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
{
Expand All @@ -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"]);
Expand Down
Loading