Merge pull request #107 from DenchHQ/bp/6-workspace-ui-browse-mode

feat(workspace): support browse-mode paths in editors and file tree
This commit is contained in:
Kumar Abhirup 2026-03-15 04:20:52 -07:00 committed by GitHub
commit d6ca3b329c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 288 additions and 58 deletions

View File

@ -490,6 +490,25 @@ describe("MonacoCodeEditor save flow", () => {
});
});
it("preserves absolute browse-mode paths when saving code files", async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ ok: true }));
global.fetch = fetchMock;
render(<MonacoCodeEditor content="original" filename="app.ts" filePath="/tmp/app.ts" />);
simulateContentChange("browse mode content");
const btn = screen.getByRole("button", { name: /save/i });
await act(async () => {
btn.click();
});
expect(fetchMock).toHaveBeenCalledWith("/api/workspace/file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "/tmp/app.ts", content: "browse mode content" }),
});
});
it("shows 'Saved' indicator after successful save", async () => {
global.fetch = vi.fn().mockResolvedValue(jsonResponse({ ok: true }));

View File

@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import Editor, { type OnMount } from "@monaco-editor/react";
import type { editor } from "monaco-editor";
import { DiffCard } from "../diff-viewer";
import { fileWriteUrl } from "@/lib/workspace-paths";
const EXT_TO_MONACO_LANG: Record<string, string> = {
ts: "typescript",
@ -232,7 +233,7 @@ function EditorInner({ content, filename, filePath, className }: CodeEditorProps
const value = editorRef.current.getValue();
setSaveState("saving");
try {
const res = await fetch("/api/workspace/file", {
const res = await fetch(fileWriteUrl(filePath), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: filePath, content: value }),

View File

@ -16,6 +16,7 @@ import {
} from "@dnd-kit/core";
import { ContextMenu, type ContextMenuAction, type ContextMenuTarget } from "./context-menu";
import { InlineRename, RENAME_SHAKE_STYLE } from "./inline-rename";
import { classifyWorkspacePath, fileWriteUrl, isVirtualPath } from "@/lib/workspace-paths";
// --- Types ---
@ -44,9 +45,9 @@ export type TreeNode = {
/** Folder names reserved for virtual sections -- cannot be created/renamed to. */
const RESERVED_FOLDER_NAMES = new Set(["Chats", "Skills", "Memories"]);
/** Check if a node (or any of its ancestors) is virtual. Paths starting with ~ are always virtual. */
/** Check if a node (or any of its ancestors) is virtual. */
function isVirtualNode(node: TreeNode): boolean {
return !!node.virtual || node.path.startsWith("~");
return !!node.virtual || isVirtualPath(node.path);
}
type FileManagerTreeProps = {
@ -82,10 +83,23 @@ const ROOT_ONLY_SYSTEM_PATTERNS = [
/^workspace_context\.yaml$/,
];
function isSystemFile(path: string): boolean {
const base = path.split("/").pop() ?? "";
function toWorkspaceRelativePath(path: string, workspaceRoot?: string | null): string | null {
const kind = classifyWorkspacePath(path);
if (kind === "virtual" || kind === "homeRelative") {return null;}
if (kind === "workspaceRelative") {return path;}
if (!workspaceRoot) {return null;}
if (path !== workspaceRoot && !path.startsWith(`${workspaceRoot}/`)) {
return null;
}
return path === workspaceRoot ? "" : path.slice(workspaceRoot.length + 1);
}
function isSystemFile(path: string, workspaceRoot?: string | null): boolean {
const relativePath = toWorkspaceRelativePath(path, workspaceRoot);
if (relativePath == null) {return false;}
const base = relativePath.split("/").pop() ?? "";
if (ALWAYS_SYSTEM_PATTERNS.some((p) => p.test(base))) {return true;}
const isRoot = !path.includes("/");
const isRoot = relativePath !== "" && !relativePath.includes("/");
return isRoot && ROOT_ONLY_SYSTEM_PATTERNS.some((p) => p.test(base));
}
@ -302,7 +316,7 @@ async function apiMkdir(path: string) {
}
async function apiCreateFile(path: string, content: string = "") {
const res = await fetch("/api/workspace/file", {
const res = await fetch(fileWriteUrl(path), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, content }),
@ -461,7 +475,7 @@ function DraggableNode({
const isActive = activePath === node.path;
const isSelected = selectedPath === node.path;
const isRenaming = renamingPath === node.path;
const isSysFile = isSystemFile(node.path);
const isSysFile = isSystemFile(node.path, workspaceRoot);
const isVirtual = isVirtualNode(node);
const isProtected = isSysFile || isVirtual || isWorkspaceRoot;
const isDragOver = dragOverPath === node.path && isExpandable;
@ -963,7 +977,7 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact
// Context menu handlers
const handleContextMenu = useCallback((e: React.MouseEvent, node: TreeNode) => {
const isSys = isSystemFile(node.path) || isVirtualNode(node);
const isSys = isSystemFile(node.path, workspaceRoot) || isVirtualNode(node);
const isFolder = node.type === "folder" || node.type === "object";
setCtxMenu({
x: e.clientX,
@ -975,7 +989,7 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact
isSystem: isSys,
},
});
}, []);
}, [workspaceRoot]);
const handleEmptyContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault();
@ -1151,7 +1165,7 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact
case "Enter": {
e.preventDefault();
if (curNode) {
const curProtected = isSystemFile(curNode.path) || isVirtualNode(curNode);
const curProtected = isSystemFile(curNode.path, workspaceRoot) || isVirtualNode(curNode);
if (e.shiftKey || curProtected) {
onSelect(curNode);
} else {
@ -1162,14 +1176,14 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact
}
case "F2": {
e.preventDefault();
if (curNode && !isSystemFile(curNode.path) && !isVirtualNode(curNode)) {
if (curNode && !isSystemFile(curNode.path, workspaceRoot) && !isVirtualNode(curNode)) {
setRenamingPath(curNode.path);
}
break;
}
case "Backspace":
case "Delete": {
if (curNode && !isSystemFile(curNode.path) && !isVirtualNode(curNode)) {
if (curNode && !isSystemFile(curNode.path, workspaceRoot) && !isVirtualNode(curNode)) {
e.preventDefault();
setConfirmDelete(curNode.path);
}
@ -1181,7 +1195,7 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact
if (e.key === "c" && curNode) {
e.preventDefault();
void navigator.clipboard.writeText(curNode.path);
} else if (e.key === "d" && curNode && !isSystemFile(curNode.path)) {
} else if (e.key === "d" && curNode && !isSystemFile(curNode.path, workspaceRoot)) {
e.preventDefault();
void apiDuplicate(curNode.path).then(() => onRefresh());
} else if (e.key === "n") {
@ -1202,7 +1216,7 @@ export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact
}
}
},
[tree, expandedPaths, selectedPath, renamingPath, onSelect, onRefresh],
[tree, expandedPaths, selectedPath, renamingPath, onSelect, onRefresh, workspaceRoot],
);
if (tree.length === 0) {

View File

@ -0,0 +1,194 @@
// @vitest-environment jsdom
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MarkdownEditor } from "./markdown-editor";
type EditorOptions = {
onUpdate?: () => void;
};
let lastEditorOptions: EditorOptions | null = null;
let currentEditor: {
view: { dom: HTMLDivElement };
commands: { setContent: ReturnType<typeof vi.fn> };
isActive: ReturnType<typeof vi.fn>;
getHTML: ReturnType<typeof vi.fn>;
getMarkdown: ReturnType<typeof vi.fn>;
chain: ReturnType<typeof vi.fn>;
};
function createMockChain() {
const chain = {
focus: vi.fn(() => chain),
toggleBold: vi.fn(() => chain),
toggleItalic: vi.fn(() => chain),
toggleStrike: vi.fn(() => chain),
toggleCode: vi.fn(() => chain),
unsetLink: vi.fn(() => chain),
setLink: vi.fn(() => chain),
toggleHeading: vi.fn(() => chain),
toggleBulletList: vi.fn(() => chain),
toggleOrderedList: vi.fn(() => chain),
toggleTaskList: vi.fn(() => chain),
toggleBlockquote: vi.fn(() => chain),
toggleCodeBlock: vi.fn(() => chain),
setImage: vi.fn(() => chain),
insertTable: vi.fn(() => chain),
setHorizontalRule: vi.fn(() => chain),
run: vi.fn(() => true),
};
return chain;
}
vi.mock("@tiptap/react", () => ({
useEditor: (options: EditorOptions) => {
lastEditorOptions = options;
return currentEditor;
},
EditorContent: () => <div data-testid="editor-content" />,
}));
vi.mock("@tiptap/react/menus", () => ({
BubbleMenu: ({ children }: { children: React.ReactNode }) => (
<div data-testid="bubble-menu">{children}</div>
),
}));
vi.mock("@tiptap/starter-kit", () => ({ default: { configure: () => ({}) } }));
vi.mock("@tiptap/markdown", () => ({ Markdown: { configure: () => ({}) } }));
vi.mock("@tiptap/extension-image", () => ({ default: { configure: () => ({}) } }));
vi.mock("@tiptap/extension-link", () => ({ default: { configure: () => ({}) } }));
vi.mock("@tiptap/extension-table", () => ({ Table: { configure: () => ({}) } }));
vi.mock("@tiptap/extension-table-row", () => ({ default: {} }));
vi.mock("@tiptap/extension-table-cell", () => ({ default: {} }));
vi.mock("@tiptap/extension-table-header", () => ({ default: {} }));
vi.mock("@tiptap/extension-task-list", () => ({ default: {} }));
vi.mock("@tiptap/extension-task-item", () => ({ default: { configure: () => ({}) } }));
vi.mock("@tiptap/extension-placeholder", () => ({ default: { configure: () => ({}) } }));
vi.mock("./report-block-node", () => ({
ReportBlockNode: {},
preprocessReportBlocks: (value: string) => value,
postprocessReportBlocks: (value: string) => value,
}));
vi.mock("./slash-command", () => ({
createSlashCommand: () => ({}),
createWorkspaceMention: () => ({}),
createFileMention: () => ({}),
}));
vi.mock("./editor-toolbar-primitives", () => ({
ToolbarGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
ToolbarDivider: () => <div data-testid="toolbar-divider" />,
ToolbarButton: ({
children,
onClick,
title,
}: {
children: React.ReactNode;
onClick?: () => void;
title?: string;
}) => (
<button type="button" onClick={onClick} title={title}>
{children}
</button>
),
BubbleButton: ({
children,
onClick,
title,
}: {
children: React.ReactNode;
onClick?: () => void;
title?: string;
}) => (
<button type="button" onClick={onClick} title={title}>
{children}
</button>
),
}));
vi.mock("@/lib/workspace-links", () => ({
isWorkspaceLink: () => false,
}));
describe("MarkdownEditor", () => {
beforeEach(() => {
const chain = createMockChain();
currentEditor = {
view: { dom: document.createElement("div") },
commands: { setContent: vi.fn() },
isActive: vi.fn(() => false),
getHTML: vi.fn(() => "<p>fallback</p>"),
getMarkdown: vi.fn(() => "# updated markdown"),
chain: vi.fn(() => chain),
};
lastEditorOptions = null;
});
function markDirty() {
act(() => {
lastEditorOptions?.onUpdate?.();
});
}
it("saves home-relative markdown files through the real file API", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
global.fetch = fetchMock;
const user = userEvent.setup();
render(
<MarkdownEditor
content="# initial"
filePath="~/notes/daily.md"
tree={[]}
/>,
);
markDirty();
await user.click(screen.getByRole("button", { name: "Save" }));
expect(fetchMock).toHaveBeenCalledWith("/api/workspace/file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "~/notes/daily.md", content: "# updated markdown" }),
});
});
it("keeps virtual markdown paths on the virtual-file API", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
global.fetch = fetchMock;
const user = userEvent.setup();
render(
<MarkdownEditor
content="# initial"
filePath="~skills/demo/SKILL.md"
tree={[]}
/>,
);
markDirty();
await user.click(screen.getByRole("button", { name: "Save" }));
expect(fetchMock).toHaveBeenCalledWith("/api/workspace/virtual-file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "~skills/demo/SKILL.md", content: "# updated markdown" }),
});
});
});

View File

@ -18,6 +18,7 @@ import { useState, useCallback, useEffect, useRef, useMemo } from "react";
import { ReportBlockNode, preprocessReportBlocks, postprocessReportBlocks } from "./report-block-node";
import { createSlashCommand, createWorkspaceMention, createFileMention, type TreeNode, type MentionSearchFn } from "./slash-command";
import { ToolbarGroup, ToolbarDivider, ToolbarButton, BubbleButton } from "./editor-toolbar-primitives";
import { fileWriteUrl } from "@/lib/workspace-paths";
import { isWorkspaceLink } from "@/lib/workspace-links";
// --- Types ---
@ -311,11 +312,7 @@ export function MarkdownEditor({
// Prepend preserved frontmatter so it isn't lost on save
const finalContent = frontmatterRef.current + bodyContent;
// Virtual paths (~skills/*) use the virtual-file API
const saveEndpoint = filePath.startsWith("~")
? "/api/workspace/virtual-file"
: "/api/workspace/file";
const res = await fetch(saveEndpoint, {
const res = await fetch(fileWriteUrl(filePath), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: filePath, content: finalContent }),

View File

@ -295,6 +295,30 @@ describe("RichDocumentEditor save flows", () => {
expect(screen.getByText("Saved")).toBeInTheDocument();
});
it("preserves absolute browse-mode paths when saving TXT files", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" } }),
);
global.fetch = fetchMock;
const user = userEvent.setup();
render(
<RichDocumentEditor
mode="txt"
initialHtml="<p>hello</p>"
filePath="/tmp/today.txt"
/>,
);
markDirty();
await user.click(screen.getByRole("button", { name: "Save" }));
expect(fetchMock).toHaveBeenCalledWith("/api/workspace/file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "/tmp/today.txt", content: "Updated plain text" }),
});
});
it("saves DOCX via html-to-docx and /api/workspace/write-binary", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" } }),

View File

@ -21,6 +21,7 @@ import Superscript from "@tiptap/extension-superscript";
import Subscript from "@tiptap/extension-subscript";
import CharacterCount from "@tiptap/extension-character-count";
import { useState, useCallback, useEffect, useRef } from "react";
import { fileWriteUrl } from "@/lib/workspace-paths";
import {
ToolbarGroup,
@ -195,7 +196,7 @@ export function RichDocumentEditor({
try {
if (isTxt) {
const text = editor.getText();
const res = await fetch("/api/workspace/file", {
const res = await fetch(fileWriteUrl(filePath), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: filePath, content: text }),

View File

@ -28,6 +28,7 @@ import {
matrixToCsv,
selectionStats,
} from "./spreadsheet-utils";
import { fileWriteUrl } from "@/lib/workspace-paths";
// ---------------------------------------------------------------------------
// Types
@ -622,7 +623,7 @@ export function SpreadsheetEditor({
if (isTextSpreadsheet(filename)) {
const sep = fileExt(filename) === "tsv" ? "\t" : ",";
const text = matrixToCsv(sheets[activeSheet].data, sep);
const res = await fetch("/api/workspace/file", {
const res = await fetch(fileWriteUrl(filePath), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: filePath, content: text }),

View File

@ -81,6 +81,13 @@ import {
type ChatRunsSnapshot,
type ChatPanelRuntimeState,
} from "@/lib/chat-session-registry";
import {
fileReadUrl as fileApiUrl,
rawFileReadUrl as rawFileUrl,
isAbsolutePath,
isHomeRelativePath,
isVirtualPath,
} from "@/lib/workspace-paths";
import dynamic from "next/dynamic";
const TerminalDrawer = dynamic(
@ -202,42 +209,6 @@ type WebSession = {
messageCount: number;
};
// --- Helpers ---
/** Detect virtual paths (skills, memories) that live outside the main workspace. */
function isVirtualPath(path: string): boolean {
return path.startsWith("~") && !path.startsWith("~/");
}
/** Detect absolute filesystem paths (browse mode). */
function isAbsolutePath(path: string): boolean {
return path.startsWith("/");
}
/** Detect home-relative filesystem paths (e.g. ~/Desktop/file.txt). */
function isHomeRelativePath(path: string): boolean {
return path.startsWith("~/");
}
/** Pick the right file API endpoint based on virtual vs real vs absolute paths. */
function fileApiUrl(path: string): string {
if (isVirtualPath(path)) {
return `/api/workspace/virtual-file?path=${encodeURIComponent(path)}`;
}
if (isAbsolutePath(path) || isHomeRelativePath(path)) {
return `/api/workspace/browse-file?path=${encodeURIComponent(path)}`;
}
return `/api/workspace/file?path=${encodeURIComponent(path)}`;
}
/** Pick the right raw file URL for media preview. */
function rawFileUrl(path: string): string {
if (isAbsolutePath(path) || isHomeRelativePath(path)) {
return `/api/workspace/browse-file?path=${encodeURIComponent(path)}&raw=true`;
}
return `/api/workspace/raw-file?path=${encodeURIComponent(path)}`;
}
const LEFT_SIDEBAR_MIN = 200;
const LEFT_SIDEBAR_MAX = 480;
const RIGHT_SIDEBAR_MIN = 260;
@ -1177,6 +1148,14 @@ function WorkspacePageInner() {
const jobId = tab.path.slice("~cron/".length);
const job = cronJobs.find((j) => j.id === jobId);
if (job) setContent({ kind: "cron-job", jobId, job });
} else {
const fileName = tab.title || tab.path.split("/").pop() || tab.path;
const syntheticNode: TreeNode = {
name: fileName,
path: tab.path,
type: tab.type === "object" ? "object" : inferNodeTypeFromFileName(fileName),
};
void loadContent(syntheticNode);
}
}
}, [tree, loadContent, cronJobs]);