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:
commit
d6ca3b329c
@ -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 }));
|
||||
|
||||
|
||||
@ -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 }),
|
||||
|
||||
@ -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) {
|
||||
|
||||
194
apps/web/app/components/workspace/markdown-editor.test.tsx
Normal file
194
apps/web/app/components/workspace/markdown-editor.test.tsx
Normal 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" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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 }),
|
||||
|
||||
@ -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" } }),
|
||||
|
||||
@ -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 }),
|
||||
|
||||
@ -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 }),
|
||||
|
||||
@ -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]);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user