diff --git a/apps/web/app/components/workspace/code-editor.test.tsx b/apps/web/app/components/workspace/code-editor.test.tsx index 8885fadeaae..bee8a33e39b 100644 --- a/apps/web/app/components/workspace/code-editor.test.tsx +++ b/apps/web/app/components/workspace/code-editor.test.tsx @@ -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(); + 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 })); diff --git a/apps/web/app/components/workspace/code-editor.tsx b/apps/web/app/components/workspace/code-editor.tsx index b2f93df4714..ba7b3bb1331 100644 --- a/apps/web/app/components/workspace/code-editor.tsx +++ b/apps/web/app/components/workspace/code-editor.tsx @@ -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 = { 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 }), diff --git a/apps/web/app/components/workspace/file-manager-tree.tsx b/apps/web/app/components/workspace/file-manager-tree.tsx index f7f71c8442a..ba4a7a75bfc 100644 --- a/apps/web/app/components/workspace/file-manager-tree.tsx +++ b/apps/web/app/components/workspace/file-manager-tree.tsx @@ -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) { diff --git a/apps/web/app/components/workspace/markdown-editor.test.tsx b/apps/web/app/components/workspace/markdown-editor.test.tsx new file mode 100644 index 00000000000..a9a96177b9c --- /dev/null +++ b/apps/web/app/components/workspace/markdown-editor.test.tsx @@ -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 }; + isActive: ReturnType; + getHTML: ReturnType; + getMarkdown: ReturnType; + chain: ReturnType; +}; + +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: () =>
, +})); + +vi.mock("@tiptap/react/menus", () => ({ + BubbleMenu: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +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 }) =>
{children}
, + ToolbarDivider: () =>
, + ToolbarButton: ({ + children, + onClick, + title, + }: { + children: React.ReactNode; + onClick?: () => void; + title?: string; + }) => ( + + ), + BubbleButton: ({ + children, + onClick, + title, + }: { + children: React.ReactNode; + onClick?: () => void; + title?: string; + }) => ( + + ), +})); + +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(() => "

fallback

"), + 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( + , + ); + + 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( + , + ); + + 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" }), + }); + }); +}); diff --git a/apps/web/app/components/workspace/markdown-editor.tsx b/apps/web/app/components/workspace/markdown-editor.tsx index 2798888cf2d..2332b48bc7b 100644 --- a/apps/web/app/components/workspace/markdown-editor.tsx +++ b/apps/web/app/components/workspace/markdown-editor.tsx @@ -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 }), diff --git a/apps/web/app/components/workspace/rich-document-editor.test.tsx b/apps/web/app/components/workspace/rich-document-editor.test.tsx index 5b193a27b21..739e60dd740 100644 --- a/apps/web/app/components/workspace/rich-document-editor.test.tsx +++ b/apps/web/app/components/workspace/rich-document-editor.test.tsx @@ -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( + , + ); + 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" } }), diff --git a/apps/web/app/components/workspace/rich-document-editor.tsx b/apps/web/app/components/workspace/rich-document-editor.tsx index 33fe1632d05..971bbc64dda 100644 --- a/apps/web/app/components/workspace/rich-document-editor.tsx +++ b/apps/web/app/components/workspace/rich-document-editor.tsx @@ -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 }), diff --git a/apps/web/app/components/workspace/spreadsheet-editor.tsx b/apps/web/app/components/workspace/spreadsheet-editor.tsx index b04b7afee24..12eea62fada 100644 --- a/apps/web/app/components/workspace/spreadsheet-editor.tsx +++ b/apps/web/app/components/workspace/spreadsheet-editor.tsx @@ -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 }), diff --git a/apps/web/app/workspace/workspace-content.tsx b/apps/web/app/workspace/workspace-content.tsx index 9eef0a1860e..91831f19635 100644 --- a/apps/web/app/workspace/workspace-content.tsx +++ b/apps/web/app/workspace/workspace-content.tsx @@ -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]);