FN-7445: add Project Files creation and search controls

Improve the Project Files browser with first-class creation actions and recursive file search.

- Add visible create-file and create-folder buttons for Files — Project while keeping compact controls for embedded pickers.
- Add debounced recursive project file search with loading, retry, empty, and path-context result states.
- Cover project files creation and search behavior in dock, modal, and file browser tests.
- Document the new Project Files controls and add a release changeset.

Files changed:
 .changeset/fn-7445-project-files-create-search.md  |   7 +
 docs/dashboard-guide.md                            |   3 +-
 .../dashboard/app/components/DockFilesView.tsx     |   1 +
 packages/dashboard/app/components/FileBrowser.css  |  88 +++++++++++++
 packages/dashboard/app/components/FileBrowser.tsx  | 145 ++++++++++++++++++++-
 .../dashboard/app/components/FileBrowserModal.tsx  |   1 +
 .../components/__tests__/DockFilesView.test.tsx    |  34 +++--
 .../app/components/__tests__/FileBrowser.test.tsx  | 109 ++++++++++++++++
 .../components/__tests__/FileBrowserModal.test.tsx |  52 ++++++++
 9 files changed, 426 insertions(+), 14 deletions(-)

Fusion-Task-Id: FN-7445

Fusion-Task-Lineage: 41510def-4900-42ee-beab-b67f28dfeaee

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-02 13:44:41 -07:00
parent ec0256f7e5
commit 5984f327d3
9 changed files with 426 additions and 14 deletions

View File

@@ -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.

View File

@@ -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. 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 - 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 - 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 - 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

View File

@@ -168,6 +168,7 @@ export function DockFilesView({ projectId, openFile, layout = "auto" }: DockFile
workspace="project" workspace="project"
onRefresh={refresh} onRefresh={refresh}
projectId={projectId} projectId={projectId}
showProjectFileControls
/> />
</div> </div>

View File

@@ -193,6 +193,32 @@
gap: var(--space-xs); 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 { .file-browser-new-menu {
position: relative; position: relative;
flex-shrink: 0; flex-shrink: 0;
@@ -254,6 +280,50 @@
font-style: italic; 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-loading,
.file-browser-error { .file-browser-error {
display: flex; display: flex;
@@ -660,12 +730,30 @@ Narrow Files windows use the same single-pane list/editor behavior as mobile eve
max-width: none; max-width: none;
} }
.file-browser-search {
order: 3;
flex-basis: 100%;
max-width: none;
min-width: 0;
}
.file-browser .file-browser-header-actions { .file-browser .file-browser-header-actions {
width: 100%; width: 100%;
margin-left: 0; margin-left: 0;
justify-content: flex-start; 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 { .file-editor-toolbar {
align-items: center; align-items: center;
flex-wrap: nowrap; flex-wrap: nowrap;

View File

@@ -1,9 +1,9 @@
import "./FileBrowser.css"; import "./FileBrowser.css";
import { useState, useCallback, useEffect, useRef } from "react"; import { useState, useCallback, useEffect, useId, useRef } from "react";
import { useTranslation } from "react-i18next"; 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 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 { appendTokenQuery } from "../auth";
import { getErrorMessage } from "@fusion/core"; import { getErrorMessage } from "@fusion/core";
import { getParentDisplayPath, joinDisplayPath, normalizeDisplayPath } from "../utils/pathDisplay"; import { getParentDisplayPath, joinDisplayPath, normalizeDisplayPath } from "../utils/pathDisplay";
@@ -22,6 +22,8 @@ interface FileBrowserProps {
onRefresh?: () => void; onRefresh?: () => void;
/** Optional project ID for multi-project scoping */ /** Optional project ID for multi-project scoping */
projectId?: string; 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 { function formatBytes(bytes?: number): string {
@@ -339,8 +341,10 @@ export function FileBrowser({
workspace, workspace,
onRefresh, onRefresh,
projectId, projectId,
showProjectFileControls = false,
}: FileBrowserProps) { }: FileBrowserProps) {
const { t } = useTranslation("app"); const { t } = useTranslation("app");
const searchInputId = useId();
const [contextMenu, setContextMenu] = useState<ContextMenuState>(INITIAL_CONTEXT_MENU); const [contextMenu, setContextMenu] = useState<ContextMenuState>(INITIAL_CONTEXT_MENU);
const [dialog, setDialog] = useState<DialogState>(INITIAL_DIALOG); const [dialog, setDialog] = useState<DialogState>(INITIAL_DIALOG);
const [operationLoading, setOperationLoading] = useState(false); const [operationLoading, setOperationLoading] = useState(false);
@@ -348,12 +352,17 @@ export function FileBrowser({
const [isLongPressing, setIsLongPressing] = useState(false); const [isLongPressing, setIsLongPressing] = useState(false);
const [longPressTargetPath, setLongPressTargetPath] = useState<string | null>(null); const [longPressTargetPath, setLongPressTargetPath] = useState<string | null>(null);
const [newMenuOpen, setNewMenuOpen] = useState(false); const [newMenuOpen, setNewMenuOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<Array<{ path: string; name: string }>>([]);
const [searchLoading, setSearchLoading] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
const longPressTimerRef = useRef<number | null>(null); const longPressTimerRef = useRef<number | null>(null);
const longPressFeedbackTimerRef = useRef<number | null>(null); const longPressFeedbackTimerRef = useRef<number | null>(null);
const touchStartRef = useRef<TouchPoint | null>(null); const touchStartRef = useRef<TouchPoint | null>(null);
const touchOpenHandledRef = useRef(false); const touchOpenHandledRef = useRef(false);
const newMenuRef = useRef<HTMLDivElement>(null); const newMenuRef = useRef<HTMLDivElement>(null);
const searchRequestIdRef = useRef(0);
const clearLongPressTimers = useCallback(() => { const clearLongPressTimers = useCallback(() => {
if (longPressTimerRef.current !== null) { if (longPressTimerRef.current !== null) {
@@ -400,6 +409,51 @@ export function FileBrowser({
}; };
}, [newMenuOpen]); }, [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) => { const openContextMenuAt = useCallback((x: number, y: number, entry: FileNode, fullPath: string) => {
setContextMenu({ setContextMenu({
visible: true, visible: true,
@@ -581,6 +635,11 @@ export function FileBrowser({
setOperationError(null); setOperationError(null);
}, []); }, []);
const handleSearchResultSelect = useCallback((path: string) => {
touchOpenHandledRef.current = false;
onSelectFile(path);
}, [onSelectFile]);
const handleFileNodeClick = useCallback((entry: FileNode, fullPath: string) => { const handleFileNodeClick = useCallback((entry: FileNode, fullPath: string) => {
if (touchOpenHandledRef.current) { if (touchOpenHandledRef.current) {
touchOpenHandledRef.current = false; touchOpenHandledRef.current = false;
@@ -633,7 +692,50 @@ export function FileBrowser({
</button> </button>
)} )}
<span className="file-browser-path">{currentPath === "." ? t("fileBrowser.root", "Root") : normalizeDisplayPath(currentPath)}</span> <span className="file-browser-path">{currentPath === "." ? t("fileBrowser.root", "Root") : normalizeDisplayPath(currentPath)}</span>
{showProjectFileControls && (
<div className="file-browser-search" role="search">
<Search size={16} aria-hidden="true" className="file-browser-search-icon" />
<input
id={searchInputId}
className="input file-browser-search-input"
type="search"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
aria-label={t("fileBrowser.searchProjectFiles", "Search project files")}
placeholder={t("fileBrowser.searchProjectFilesPlaceholder", "Search project files…")}
disabled={!workspace}
/>
</div>
)}
<div className="file-browser-header-actions"> <div className="file-browser-header-actions">
{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.
*/}
<button
type="button"
className="btn btn-sm file-browser-create-button"
onClick={() => openCreateDialog("create-file")}
disabled={!workspace}
title={t("fileBrowser.createNewFile", "Create new file")}
>
<FilePlus2 size={14} />
{t("fileBrowser.createNewFile", "Create new file")}
</button>
<button
type="button"
className="btn btn-sm file-browser-create-button"
onClick={() => openCreateDialog("create-folder")}
disabled={!workspace}
title={t("fileBrowser.createNewFolder", "Create new folder")}
>
<FolderPlus size={14} />
{t("fileBrowser.createNewFolder", "Create new folder")}
</button>
</>
) : (
<div className="file-browser-new-menu" ref={newMenuRef}> <div className="file-browser-new-menu" ref={newMenuRef}>
{/* {/*
* FNXC:FileBrowser 2026-06-22-15:24: * FNXC:FileBrowser 2026-06-22-15:24:
@@ -680,11 +782,46 @@ export function FileBrowser({
</div> </div>
)} )}
</div> </div>
)}
</div> </div>
</div> </div>
<div className="file-browser-list"> <div className="file-browser-list">
{entries.length === 0 ? ( {isSearching ? (
<div className="file-browser-search-results" aria-live="polite">
{searchLoading ? (
<div className="file-browser-search-status">
<Loader2 className="spin" size={18} />
<span>{t("fileBrowser.searchingFiles", "Searching files…")}</span>
</div>
) : searchError ? (
<div className="file-browser-search-status file-browser-search-status--error">
<span>{searchError}</span>
<button type="button" className="btn btn-sm" onClick={() => runSearch(trimmedSearchQuery)}>
{t("common.retry", "Retry")}
</button>
</div>
) : searchResults.length === 0 ? (
<div className="file-browser-empty">{t("fileBrowser.searchNoResults", "No files found")}</div>
) : (
searchResults.map((result) => (
<button
type="button"
key={result.path}
className="file-node file-node--file file-browser-search-result"
onClick={() => handleSearchResultSelect(result.path)}
title={result.path}
>
<div className="file-node-icon">
<File size={16} />
</div>
<div className="file-node-name">{result.name}</div>
<div className="file-node-path">{normalizeDisplayPath(result.path)}</div>
</button>
))
)}
</div>
) : entries.length === 0 ? (
<div className="file-browser-empty">{t("fileBrowser.emptyDirectory", "(empty directory)")}</div> <div className="file-browser-empty">{t("fileBrowser.emptyDirectory", "(empty directory)")}</div>
) : ( ) : (
entries.map((entry) => { entries.map((entry) => {

View File

@@ -421,6 +421,7 @@ export function FileBrowserModal({
workspace={currentWorkspace} workspace={currentWorkspace}
onRefresh={refresh} onRefresh={refresh}
projectId={projectId} projectId={projectId}
showProjectFileControls={currentWorkspace === "project"}
/> />
</div> </div>

View File

@@ -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. // Render the tree's files as buttons so we can click one.
vi.mock("../FileBrowser", () => ({ vi.mock("../FileBrowser", () => ({
FileBrowser: ({ entries: e, onSelectFile }: { entries: FileNode[]; onSelectFile: (p: string) => void }) => ( FileBrowser: ({ entries: e, onSelectFile, showProjectFileControls, projectId }: { entries: FileNode[]; onSelectFile: (p: string) => void; showProjectFileControls?: boolean; projectId?: string }) => {
<div data-testid="mock-file-browser"> capturedFileBrowserProps.push({ showProjectFileControls, projectId });
{e.map((entry) => ( return (
<button key={entry.name} type="button" onClick={() => onSelectFile(entry.name)}> <div data-testid="mock-file-browser" data-project-controls={showProjectFileControls ? "true" : "false"}>
{entry.name} {e.map((entry) => (
</button> <button key={entry.name} type="button" onClick={() => onSelectFile(entry.name)}>
))} {entry.name}
</div> </button>
), ))}
</div>
);
},
})); }));
const PROJECT_ID = "proj-1"; const PROJECT_ID = "proj-1";
@@ -137,6 +142,7 @@ describe("DockFilesView shared current-file state", () => {
mockSave.mockClear(); mockSave.mockClear();
capturedFileEditorProps.length = 0; capturedFileEditorProps.length = 0;
capturedEditorHookCalls.length = 0; capturedEditorHookCalls.length = 0;
capturedFileBrowserProps.length = 0;
}); });
afterEach(() => cleanup()); afterEach(() => cleanup());
@@ -151,6 +157,16 @@ describe("DockFilesView shared current-file state", () => {
expect(dockFilesCss).not.toContain("border-right: 1px solid var(--border);"); 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(<DockFilesView projectId={PROJECT_ID} layout="auto" />);
expect(screen.getByTestId("mock-file-browser")).toHaveAttribute("data-project-controls", "true");
dock.unmount();
render(<DockFilesView projectId={PROJECT_ID} layout="two-pane" />);
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 () => { it("persists the selected file to scoped storage and a fresh expand instance reads it on mount", async () => {
// 1. Dock instance: select a file. // 1. Dock instance: select a file.
const dock = render(<DockFilesView projectId={PROJECT_ID} layout="auto" />); const dock = render(<DockFilesView projectId={PROJECT_ID} layout="auto" />);

View File

@@ -23,6 +23,7 @@ vi.mock("lucide-react", async () => {
Archive: (props: any) => <span data-testid="icon-archive" {...props} />, Archive: (props: any) => <span data-testid="icon-archive" {...props} />,
FilePlus2: (props: any) => <span data-testid="icon-file-plus" {...props} />, FilePlus2: (props: any) => <span data-testid="icon-file-plus" {...props} />,
FolderPlus: (props: any) => <span data-testid="icon-folder-plus" {...props} />, FolderPlus: (props: any) => <span data-testid="icon-folder-plus" {...props} />,
Search: (props: any) => <span data-testid="icon-search" {...props} />,
}; };
}); });
@@ -38,6 +39,7 @@ const mockDownloadFileUrl = vi.fn((_workspace: string, filePath: string) =>
const mockDownloadZipUrl = vi.fn((_workspace: string, filePath: string) => const mockDownloadZipUrl = vi.fn((_workspace: string, filePath: string) =>
`/api/files/${encodeURIComponent(filePath)}/download-zip?workspace=test-ws`, `/api/files/${encodeURIComponent(filePath)}/download-zip?workspace=test-ws`,
); );
const mockSearchFiles = vi.fn();
vi.mock("../../api", () => ({ vi.mock("../../api", () => ({
copyFile: (...args: any[]) => mockCopyFile(...args), copyFile: (...args: any[]) => mockCopyFile(...args),
@@ -48,6 +50,7 @@ vi.mock("../../api", () => ({
renameFile: (...args: any[]) => mockRenameFile(...args), renameFile: (...args: any[]) => mockRenameFile(...args),
downloadFileUrl: (workspace: string, filePath: string) => mockDownloadFileUrl(workspace, filePath), downloadFileUrl: (workspace: string, filePath: string) => mockDownloadFileUrl(workspace, filePath),
downloadZipUrl: (workspace: string, filePath: string) => mockDownloadZipUrl(workspace, filePath), downloadZipUrl: (workspace: string, filePath: string) => mockDownloadZipUrl(workspace, filePath),
searchFiles: (...args: any[]) => mockSearchFiles(...args),
})); }));
// ── Test Data ─────────────────────────────────────────────────────────── // ── Test Data ───────────────────────────────────────────────────────────
@@ -84,6 +87,7 @@ type FileBrowserTestOverrides = Partial<typeof defaultProps> & {
loading?: boolean; loading?: boolean;
error?: string | null; error?: string | null;
onRetry?: () => void; onRetry?: () => void;
showProjectFileControls?: boolean;
}; };
function renderFileBrowser(overrides: FileBrowserTestOverrides = {}) { function renderFileBrowser(overrides: FileBrowserTestOverrides = {}) {
@@ -118,6 +122,11 @@ function getNewFolderAction() {
return screen.getByRole("menuitem", { name: /New Folder/i }); 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 ─────────────────────────────────────────────────────────────── // ── Tests ───────────────────────────────────────────────────────────────
describe("FileBrowser", () => { describe("FileBrowser", () => {
@@ -131,6 +140,7 @@ describe("FileBrowser", () => {
mockDownloadZipUrl.mockImplementation((_workspace: string, filePath: string) => mockDownloadZipUrl.mockImplementation((_workspace: string, filePath: string) =>
`/api/files/${encodeURIComponent(filePath)}/download-zip?workspace=test-ws`, `/api/files/${encodeURIComponent(filePath)}/download-zip?workspace=test-ws`,
); );
mockSearchFiles.mockResolvedValue({ files: [] });
vi.useRealTimers(); vi.useRealTimers();
Object.defineProperty(window, "innerWidth", { Object.defineProperty(window, "innerWidth", {
configurable: true, configurable: true,
@@ -191,6 +201,42 @@ describe("FileBrowser", () => {
expect(screen.getByRole("button", { name: /^New$/i })).toBeDisabled(); 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", () => { it("clicking New File opens a dialog with name input", () => {
renderFileBrowser(); renderFileBrowser();
openNewMenu(); openNewMenu();
@@ -260,6 +306,69 @@ describe("FileBrowser", () => {
expect(screen.queryByPlaceholderText("File name")).toBeNull(); 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", () => { it("closes create dialog on Escape", () => {
renderFileBrowser(); renderFileBrowser();
openNewMenu(); openNewMenu();

View File

@@ -119,6 +119,58 @@ describe("FileBrowserModal", () => {
expect(mockUseWorkspaceFileBrowser).toHaveBeenCalledWith("project", true, undefined); expect(mockUseWorkspaceFileBrowser).toHaveBeenCalledWith("project", true, undefined);
}); });
it("shows Files — Project create and search controls only for the project workspace", async () => {
const { rerender } = render(
<FileBrowserModal
initialWorkspace="project"
isOpen={true}
onClose={mockOnClose}
/>,
);
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(
<FileBrowserModal
initialWorkspace="FN-001"
isOpen={true}
onClose={mockOnClose}
/>,
);
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(
<FileBrowserModal
initialWorkspace="project"
isOpen={true}
onClose={mockOnClose}
/>,
);
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 () => { it("opens a file in the editor when selected", async () => {
render( render(
<FileBrowserModal <FileBrowserModal