diff --git a/.changeset/fn-7445-project-files-create-search.md b/.changeset/fn-7445-project-files-create-search.md new file mode 100644 index 0000000000..283e61279c --- /dev/null +++ b/.changeset/fn-7445-project-files-create-search.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add visible create buttons and recursive search to Project Files. +category: feature +dev: Files — Project uses the existing workspace-safe create and /files/search APIs with settings pickers left compact. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 79e5da0ac1..d0064cb0c3 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -768,7 +768,8 @@ For the full research workflow, provider setup, CLI commands, API reference, and The Files modal provides a workspace-aware file browser and editor. -- Use **New File** or **New Folder** in the browser header to create entries in the current folder; new files open in the editor after creation +- In **Files — Project**, use the visible **Create new file** and **Create new folder** buttons in the browser header to create entries in the current folder; new files open in the editor after creation +- In **Files — Project**, use **Search project files** to find project files recursively without navigating the tree; matching rows include path context so duplicate filenames can be distinguished - Source/text editing supports a **Line #** header toggle to show or hide line numbers in the editor gutter - The line-number preference is saved per project and restored automatically when you switch projects - Known image, video/movie, audio, and PDF files render browser-native read-only previews inline with their real content type from the selected project or task workspace download URL; the explicit **Download** action still saves files as attachments, text files remain editable, and unknown binary files keep the read-only editor fallback diff --git a/packages/dashboard/app/components/DockFilesView.tsx b/packages/dashboard/app/components/DockFilesView.tsx index 48fa584837..06af73cd93 100644 --- a/packages/dashboard/app/components/DockFilesView.tsx +++ b/packages/dashboard/app/components/DockFilesView.tsx @@ -168,6 +168,7 @@ export function DockFilesView({ projectId, openFile, layout = "auto" }: DockFile workspace="project" onRefresh={refresh} projectId={projectId} + showProjectFileControls /> diff --git a/packages/dashboard/app/components/FileBrowser.css b/packages/dashboard/app/components/FileBrowser.css index ca644fe78e..188e32ecb1 100644 --- a/packages/dashboard/app/components/FileBrowser.css +++ b/packages/dashboard/app/components/FileBrowser.css @@ -193,6 +193,32 @@ gap: var(--space-xs); } +.file-browser-search { + position: relative; + display: flex; + align-items: center; + flex: 1 1 calc(var(--space-xl) * 6); + min-width: calc(var(--space-xl) * 4.5); + max-width: calc(var(--space-xl) * 12); +} + +.file-browser-search-icon { + position: absolute; + left: var(--space-sm); + color: var(--text-muted); + pointer-events: none; +} + +.file-browser-search-input { + width: 100%; + min-width: 0; + padding-left: calc(var(--space-lg) + var(--space-md)); +} + +.file-browser-create-button { + white-space: nowrap; +} + .file-browser-new-menu { position: relative; flex-shrink: 0; @@ -254,6 +280,50 @@ font-style: italic; } +.file-browser-search-results { + display: flex; + flex-direction: column; + min-height: 100%; +} + +.file-browser-search-result { + width: 100%; + border: none; + background: transparent; + color: inherit; + font: inherit; + text-align: left; +} + +.file-browser-search-result:focus-visible { + outline: none; + box-shadow: var(--focus-ring-strong); +} + +.file-node-path { + margin-left: auto; + min-width: 0; + max-width: 55%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-muted); + font-size: var(--font-size-xs); +} + +.file-browser-search-status { + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-sm); + padding: var(--space-xl); + color: var(--text-muted); +} + +.file-browser-search-status--error { + color: var(--color-error); +} + .file-browser-loading, .file-browser-error { display: flex; @@ -660,12 +730,30 @@ Narrow Files windows use the same single-pane list/editor behavior as mobile eve max-width: none; } + .file-browser-search { + order: 3; + flex-basis: 100%; + max-width: none; + min-width: 0; + } + .file-browser .file-browser-header-actions { width: 100%; margin-left: 0; justify-content: flex-start; } + .file-browser-create-button { + flex: 1 1 calc(var(--space-xl) * 4.5); + justify-content: center; + } + + .file-node-path { + flex-basis: 100%; + max-width: none; + margin-left: calc(var(--space-lg) + var(--space-sm)); + } + .file-editor-toolbar { align-items: center; flex-wrap: nowrap; diff --git a/packages/dashboard/app/components/FileBrowser.tsx b/packages/dashboard/app/components/FileBrowser.tsx index b67323d141..e760f48064 100644 --- a/packages/dashboard/app/components/FileBrowser.tsx +++ b/packages/dashboard/app/components/FileBrowser.tsx @@ -1,9 +1,9 @@ import "./FileBrowser.css"; -import { useState, useCallback, useEffect, useRef } from "react"; +import { useState, useCallback, useEffect, useId, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { Folder, File, ChevronRight, Loader2, Copy, Move, Trash2, Pencil, Download, Archive, FilePlus2, FolderPlus, Plus, ChevronDown } from "lucide-react"; +import { Folder, File, ChevronRight, Loader2, Copy, Move, Trash2, Pencil, Download, Archive, FilePlus2, FolderPlus, Plus, ChevronDown, Search } from "lucide-react"; import type { FileNode } from "../api"; -import { copyFile, createWorkspaceDirectory, createWorkspaceFile, moveFile, deleteFile, renameFile, downloadFileUrl, downloadZipUrl } from "../api"; +import { copyFile, createWorkspaceDirectory, createWorkspaceFile, moveFile, deleteFile, renameFile, downloadFileUrl, downloadZipUrl, searchFiles } from "../api"; import { appendTokenQuery } from "../auth"; import { getErrorMessage } from "@fusion/core"; import { getParentDisplayPath, joinDisplayPath, normalizeDisplayPath } from "../utils/pathDisplay"; @@ -22,6 +22,8 @@ interface FileBrowserProps { onRefresh?: () => void; /** Optional project ID for multi-project scoping */ projectId?: string; + /** Show first-class Files — Project creation and recursive search controls instead of the compact picker chrome. */ + showProjectFileControls?: boolean; } function formatBytes(bytes?: number): string { @@ -339,8 +341,10 @@ export function FileBrowser({ workspace, onRefresh, projectId, + showProjectFileControls = false, }: FileBrowserProps) { const { t } = useTranslation("app"); + const searchInputId = useId(); const [contextMenu, setContextMenu] = useState(INITIAL_CONTEXT_MENU); const [dialog, setDialog] = useState(INITIAL_DIALOG); const [operationLoading, setOperationLoading] = useState(false); @@ -348,12 +352,17 @@ export function FileBrowser({ const [isLongPressing, setIsLongPressing] = useState(false); const [longPressTargetPath, setLongPressTargetPath] = useState(null); const [newMenuOpen, setNewMenuOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState>([]); + const [searchLoading, setSearchLoading] = useState(false); + const [searchError, setSearchError] = useState(null); const longPressTimerRef = useRef(null); const longPressFeedbackTimerRef = useRef(null); const touchStartRef = useRef(null); const touchOpenHandledRef = useRef(false); const newMenuRef = useRef(null); + const searchRequestIdRef = useRef(0); const clearLongPressTimers = useCallback(() => { if (longPressTimerRef.current !== null) { @@ -400,6 +409,51 @@ export function FileBrowser({ }; }, [newMenuOpen]); + const trimmedSearchQuery = searchQuery.trim(); + const isSearching = showProjectFileControls && Boolean(workspace) && trimmedSearchQuery.length > 0; + + const runSearch = useCallback((query: string) => { + if (!showProjectFileControls || !workspace) { + setSearchResults([]); + setSearchError(null); + setSearchLoading(false); + return; + } + + const requestId = searchRequestIdRef.current + 1; + searchRequestIdRef.current = requestId; + setSearchLoading(true); + setSearchError(null); + + searchFiles(query, workspace, projectId) + .then((result) => { + if (searchRequestIdRef.current !== requestId) return; + setSearchResults(result.files); + }) + .catch((err) => { + if (searchRequestIdRef.current !== requestId) return; + setSearchResults([]); + setSearchError(getErrorMessage(err) || t("fileBrowser.searchFailed", "Search failed")); + }) + .finally(() => { + if (searchRequestIdRef.current !== requestId) return; + setSearchLoading(false); + }); + }, [projectId, showProjectFileControls, t, workspace]); + + useEffect(() => { + if (!isSearching || !workspace) { + searchRequestIdRef.current += 1; + setSearchResults([]); + setSearchError(null); + setSearchLoading(false); + return; + } + + const timer = window.setTimeout(() => runSearch(trimmedSearchQuery), 250); + return () => window.clearTimeout(timer); + }, [isSearching, runSearch, trimmedSearchQuery, workspace]); + const openContextMenuAt = useCallback((x: number, y: number, entry: FileNode, fullPath: string) => { setContextMenu({ visible: true, @@ -581,6 +635,11 @@ export function FileBrowser({ setOperationError(null); }, []); + const handleSearchResultSelect = useCallback((path: string) => { + touchOpenHandledRef.current = false; + onSelectFile(path); + }, [onSelectFile]); + const handleFileNodeClick = useCallback((entry: FileNode, fullPath: string) => { if (touchOpenHandledRef.current) { touchOpenHandledRef.current = false; @@ -633,7 +692,50 @@ export function FileBrowser({ )} {currentPath === "." ? t("fileBrowser.root", "Root") : normalizeDisplayPath(currentPath)} + {showProjectFileControls && ( +
+
+ )}
+ {showProjectFileControls ? ( + <> + {/** + * FNXC:FileBrowser 2026-07-02-00:00: + * Files — Project needs visible create-file and create-folder targets plus recursive search, while embedded settings pickers keep the compact New menu to avoid misleading picker chrome. + */} + + + + ) : (
{/* * FNXC:FileBrowser 2026-06-22-15:24: @@ -680,11 +782,46 @@ export function FileBrowser({
)}
+ )}
- {entries.length === 0 ? ( + {isSearching ? ( +
+ {searchLoading ? ( +
+ + {t("fileBrowser.searchingFiles", "Searching files…")} +
+ ) : searchError ? ( +
+ {searchError} + +
+ ) : searchResults.length === 0 ? ( +
{t("fileBrowser.searchNoResults", "No files found")}
+ ) : ( + searchResults.map((result) => ( + + )) + )} +
+ ) : entries.length === 0 ? (
{t("fileBrowser.emptyDirectory", "(empty directory)")}
) : ( entries.map((entry) => { diff --git a/packages/dashboard/app/components/FileBrowserModal.tsx b/packages/dashboard/app/components/FileBrowserModal.tsx index 593d9cc847..e59200733a 100644 --- a/packages/dashboard/app/components/FileBrowserModal.tsx +++ b/packages/dashboard/app/components/FileBrowserModal.tsx @@ -421,6 +421,7 @@ export function FileBrowserModal({ workspace={currentWorkspace} onRefresh={refresh} projectId={projectId} + showProjectFileControls={currentWorkspace === "project"} />
diff --git a/packages/dashboard/app/components/__tests__/DockFilesView.test.tsx b/packages/dashboard/app/components/__tests__/DockFilesView.test.tsx index 9d0cb90f7c..bf6a11b8e8 100644 --- a/packages/dashboard/app/components/__tests__/DockFilesView.test.tsx +++ b/packages/dashboard/app/components/__tests__/DockFilesView.test.tsx @@ -110,17 +110,22 @@ vi.mock("../FileEditor", () => ({ }, })); +const capturedFileBrowserProps: Array<{ showProjectFileControls?: boolean; projectId?: string }> = []; + // Render the tree's files as buttons so we can click one. vi.mock("../FileBrowser", () => ({ - FileBrowser: ({ entries: e, onSelectFile }: { entries: FileNode[]; onSelectFile: (p: string) => void }) => ( -
- {e.map((entry) => ( - - ))} -
- ), + FileBrowser: ({ entries: e, onSelectFile, showProjectFileControls, projectId }: { entries: FileNode[]; onSelectFile: (p: string) => void; showProjectFileControls?: boolean; projectId?: string }) => { + capturedFileBrowserProps.push({ showProjectFileControls, projectId }); + return ( +
+ {e.map((entry) => ( + + ))} +
+ ); + }, })); const PROJECT_ID = "proj-1"; @@ -137,6 +142,7 @@ describe("DockFilesView shared current-file state", () => { mockSave.mockClear(); capturedFileEditorProps.length = 0; capturedEditorHookCalls.length = 0; + capturedFileBrowserProps.length = 0; }); afterEach(() => cleanup()); @@ -151,6 +157,16 @@ describe("DockFilesView shared current-file state", () => { expect(dockFilesCss).not.toContain("border-right: 1px solid var(--border);"); }); + it("enables Files — Project controls in both compact and two-pane dock layouts", () => { + const dock = render(); + expect(screen.getByTestId("mock-file-browser")).toHaveAttribute("data-project-controls", "true"); + dock.unmount(); + + render(); + expect(screen.getByTestId("mock-file-browser")).toHaveAttribute("data-project-controls", "true"); + expect(capturedFileBrowserProps.every((props) => props.showProjectFileControls === true && props.projectId === PROJECT_ID)).toBe(true); + }); + it("persists the selected file to scoped storage and a fresh expand instance reads it on mount", async () => { // 1. Dock instance: select a file. const dock = render(); diff --git a/packages/dashboard/app/components/__tests__/FileBrowser.test.tsx b/packages/dashboard/app/components/__tests__/FileBrowser.test.tsx index eb75d1c0b5..79277b5bed 100644 --- a/packages/dashboard/app/components/__tests__/FileBrowser.test.tsx +++ b/packages/dashboard/app/components/__tests__/FileBrowser.test.tsx @@ -23,6 +23,7 @@ vi.mock("lucide-react", async () => { Archive: (props: any) => , FilePlus2: (props: any) => , FolderPlus: (props: any) => , + Search: (props: any) => , }; }); @@ -38,6 +39,7 @@ const mockDownloadFileUrl = vi.fn((_workspace: string, filePath: string) => const mockDownloadZipUrl = vi.fn((_workspace: string, filePath: string) => `/api/files/${encodeURIComponent(filePath)}/download-zip?workspace=test-ws`, ); +const mockSearchFiles = vi.fn(); vi.mock("../../api", () => ({ copyFile: (...args: any[]) => mockCopyFile(...args), @@ -48,6 +50,7 @@ vi.mock("../../api", () => ({ renameFile: (...args: any[]) => mockRenameFile(...args), downloadFileUrl: (workspace: string, filePath: string) => mockDownloadFileUrl(workspace, filePath), downloadZipUrl: (workspace: string, filePath: string) => mockDownloadZipUrl(workspace, filePath), + searchFiles: (...args: any[]) => mockSearchFiles(...args), })); // ── Test Data ─────────────────────────────────────────────────────────── @@ -84,6 +87,7 @@ type FileBrowserTestOverrides = Partial & { loading?: boolean; error?: string | null; onRetry?: () => void; + showProjectFileControls?: boolean; }; function renderFileBrowser(overrides: FileBrowserTestOverrides = {}) { @@ -118,6 +122,11 @@ function getNewFolderAction() { return screen.getByRole("menuitem", { name: /New Folder/i }); } +async function typeProjectSearch(query: string) { + fireEvent.change(screen.getByRole("searchbox", { name: "Search project files" }), { target: { value: query } }); + await waitFor(() => expect(mockSearchFiles).toHaveBeenCalled()); +} + // ── Tests ─────────────────────────────────────────────────────────────── describe("FileBrowser", () => { @@ -131,6 +140,7 @@ describe("FileBrowser", () => { mockDownloadZipUrl.mockImplementation((_workspace: string, filePath: string) => `/api/files/${encodeURIComponent(filePath)}/download-zip?workspace=test-ws`, ); + mockSearchFiles.mockResolvedValue({ files: [] }); vi.useRealTimers(); Object.defineProperty(window, "innerWidth", { configurable: true, @@ -191,6 +201,42 @@ describe("FileBrowser", () => { expect(screen.getByRole("button", { name: /^New$/i })).toBeDisabled(); }); + it("shows visible Files — Project create buttons and search when enabled", () => { + renderFileBrowser({ showProjectFileControls: true }); + expect(screen.getByRole("button", { name: "Create new file" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Create new folder" })).toBeInTheDocument(); + expect(screen.getByRole("searchbox", { name: "Search project files" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^New$/i })).toBeNull(); + }); + + it("keeps settings-style picker chrome compact unless project controls are enabled", () => { + renderFileBrowser(); + expect(screen.getByRole("button", { name: /^New$/i })).toBeInTheDocument(); + expect(screen.queryByRole("searchbox", { name: "Search project files" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Create new file" })).toBeNull(); + }); + + it("disables visible create and search controls when no workspace is provided", () => { + renderFileBrowser({ showProjectFileControls: true, workspace: undefined }); + expect(screen.getByRole("button", { name: "Create new file" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Create new folder" })).toBeDisabled(); + expect(screen.getByRole("searchbox", { name: "Search project files" })).toBeDisabled(); + }); + + it("clicking visible Create new file opens the existing dialog", () => { + renderFileBrowser({ showProjectFileControls: true }); + fireEvent.click(screen.getByRole("button", { name: "Create new file" })); + expect(document.querySelector(".file-browser-dialog-title")?.textContent).toBe("New File"); + expect(screen.getByPlaceholderText("File name")).toBeDefined(); + }); + + it("clicking visible Create new folder opens the existing dialog", () => { + renderFileBrowser({ showProjectFileControls: true }); + fireEvent.click(screen.getByRole("button", { name: "Create new folder" })); + expect(document.querySelector(".file-browser-dialog-title")?.textContent).toBe("New Folder"); + expect(screen.getByPlaceholderText("Folder name")).toBeDefined(); + }); + it("clicking New File opens a dialog with name input", () => { renderFileBrowser(); openNewMenu(); @@ -260,6 +306,69 @@ describe("FileBrowser", () => { expect(screen.queryByPlaceholderText("File name")).toBeNull(); }); + it("searches project files recursively and selects a result with path context", async () => { + mockSearchFiles.mockResolvedValue({ + files: [ + { name: "config.json", path: "packages/app/config.json" }, + { name: "config.json", path: "packages/core/config.json" }, + ], + }); + const onSelectFile = vi.fn(); + renderFileBrowser({ showProjectFileControls: true, onSelectFile }); + + await typeProjectSearch("config"); + + await waitFor(() => { + expect(mockSearchFiles).toHaveBeenCalledWith("config", "test-ws", "project-1"); + expect(screen.getByText("packages/app/config.json")).toBeInTheDocument(); + expect(screen.getByText("packages/core/config.json")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getAllByRole("button", { name: /config.json/i })[1]); + expect(onSelectFile).toHaveBeenCalledWith("packages/core/config.json"); + }); + + it("does not search without a workspace and preserves normal browsing", async () => { + renderFileBrowser({ showProjectFileControls: true, workspace: undefined }); + fireEvent.change(screen.getByRole("searchbox", { name: "Search project files" }), { target: { value: "readme" } }); + await waitFor(() => expect(screen.getByDisplayValue("readme")).toBeInTheDocument()); + expect(mockSearchFiles).not.toHaveBeenCalled(); + expect(screen.getByText("readme.md")).toBeInTheDocument(); + }); + + it("shows search loading, no-results, and restores the current directory when cleared", async () => { + let resolveSearch: (value: { files: Array<{ path: string; name: string }> }) => void = () => {}; + mockSearchFiles.mockReturnValue(new Promise((resolve) => { + resolveSearch = resolve; + })); + renderFileBrowser({ showProjectFileControls: true, entries: [] }); + + await typeProjectSearch("missing"); + expect(screen.getByText("Searching files…")).toBeInTheDocument(); + + await act(async () => { + resolveSearch({ files: [] }); + }); + + await waitFor(() => expect(screen.getByText("No files found")).toBeInTheDocument()); + + fireEvent.change(screen.getByRole("searchbox", { name: "Search project files" }), { target: { value: "" } }); + expect(screen.getByText("(empty directory)")).toBeInTheDocument(); + }); + + it("shows search errors with retry", async () => { + mockSearchFiles.mockRejectedValueOnce(new Error("Index unavailable")); + mockSearchFiles.mockResolvedValueOnce({ files: [{ name: "readme.md", path: "readme.md" }] }); + renderFileBrowser({ showProjectFileControls: true }); + + await typeProjectSearch("readme"); + await waitFor(() => expect(screen.getByText("Index unavailable")).toBeInTheDocument()); + + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + await waitFor(() => expect(screen.getByRole("button", { name: /readme\.md/ })).toBeInTheDocument()); + expect(mockSearchFiles).toHaveBeenCalledTimes(2); + }); + it("closes create dialog on Escape", () => { renderFileBrowser(); openNewMenu(); diff --git a/packages/dashboard/app/components/__tests__/FileBrowserModal.test.tsx b/packages/dashboard/app/components/__tests__/FileBrowserModal.test.tsx index 8881c259db..adb433b69c 100644 --- a/packages/dashboard/app/components/__tests__/FileBrowserModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/FileBrowserModal.test.tsx @@ -119,6 +119,58 @@ describe("FileBrowserModal", () => { expect(mockUseWorkspaceFileBrowser).toHaveBeenCalledWith("project", true, undefined); }); + it("shows Files — Project create and search controls only for the project workspace", async () => { + const { rerender } = render( + , + ); + + expect(screen.getByRole("button", { name: "Create new file" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Create new folder" })).toBeInTheDocument(); + expect(screen.getByRole("searchbox", { name: "Search project files" })).toBeInTheDocument(); + + rerender( + , + ); + + await waitFor(() => { + expect(screen.queryByRole("searchbox", { name: "Search project files" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Create new file" })).toBeNull(); + expect(screen.getByRole("button", { name: /^New$/i })).toBeInTheDocument(); + }); + }); + + it("keeps Files — Project controls available in the narrow mobile list pane", async () => { + Object.defineProperty(window, "innerWidth", { + writable: true, + configurable: true, + value: 375, + }); + window.dispatchEvent(new Event("resize")); + + render( + , + ); + + await waitFor(() => { + expect(document.querySelector(".file-browser-modal--narrow")).toBeInTheDocument(); + expect(screen.getByRole("searchbox", { name: "Search project files" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Create new file" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Create new folder" })).toBeInTheDocument(); + }); + }); + it("opens a file in the editor when selected", async () => { render(