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:
7
.changeset/fn-7445-project-files-create-search.md
Normal file
7
.changeset/fn-7445-project-files-create-search.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -168,6 +168,7 @@ export function DockFilesView({ projectId, openFile, layout = "auto" }: DockFile
|
||||
workspace="project"
|
||||
onRefresh={refresh}
|
||||
projectId={projectId}
|
||||
showProjectFileControls
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ContextMenuState>(INITIAL_CONTEXT_MENU);
|
||||
const [dialog, setDialog] = useState<DialogState>(INITIAL_DIALOG);
|
||||
const [operationLoading, setOperationLoading] = useState(false);
|
||||
@@ -348,12 +352,17 @@ export function FileBrowser({
|
||||
const [isLongPressing, setIsLongPressing] = useState(false);
|
||||
const [longPressTargetPath, setLongPressTargetPath] = useState<string | null>(null);
|
||||
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 longPressFeedbackTimerRef = useRef<number | null>(null);
|
||||
const touchStartRef = useRef<TouchPoint | null>(null);
|
||||
const touchOpenHandledRef = useRef(false);
|
||||
const newMenuRef = useRef<HTMLDivElement>(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({
|
||||
</button>
|
||||
)}
|
||||
<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">
|
||||
{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}>
|
||||
{/*
|
||||
* FNXC:FileBrowser 2026-06-22-15:24:
|
||||
@@ -680,11 +782,46 @@ export function FileBrowser({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
) : (
|
||||
entries.map((entry) => {
|
||||
|
||||
@@ -421,6 +421,7 @@ export function FileBrowserModal({
|
||||
workspace={currentWorkspace}
|
||||
onRefresh={refresh}
|
||||
projectId={projectId}
|
||||
showProjectFileControls={currentWorkspace === "project"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 }) => (
|
||||
<div data-testid="mock-file-browser">
|
||||
{e.map((entry) => (
|
||||
<button key={entry.name} type="button" onClick={() => onSelectFile(entry.name)}>
|
||||
{entry.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
FileBrowser: ({ entries: e, onSelectFile, showProjectFileControls, projectId }: { entries: FileNode[]; onSelectFile: (p: string) => void; showProjectFileControls?: boolean; projectId?: string }) => {
|
||||
capturedFileBrowserProps.push({ showProjectFileControls, projectId });
|
||||
return (
|
||||
<div data-testid="mock-file-browser" data-project-controls={showProjectFileControls ? "true" : "false"}>
|
||||
{e.map((entry) => (
|
||||
<button key={entry.name} type="button" onClick={() => onSelectFile(entry.name)}>
|
||||
{entry.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
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(<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 () => {
|
||||
// 1. Dock instance: select a file.
|
||||
const dock = render(<DockFilesView projectId={PROJECT_ID} layout="auto" />);
|
||||
|
||||
@@ -23,6 +23,7 @@ vi.mock("lucide-react", async () => {
|
||||
Archive: (props: any) => <span data-testid="icon-archive" {...props} />,
|
||||
FilePlus2: (props: any) => <span data-testid="icon-file-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) =>
|
||||
`/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<typeof defaultProps> & {
|
||||
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();
|
||||
|
||||
@@ -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(
|
||||
<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 () => {
|
||||
render(
|
||||
<FileBrowserModal
|
||||
|
||||
Reference in New Issue
Block a user