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.
|
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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -421,6 +421,7 @@ export function FileBrowserModal({
|
|||||||
workspace={currentWorkspace}
|
workspace={currentWorkspace}
|
||||||
onRefresh={refresh}
|
onRefresh={refresh}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
showProjectFileControls={currentWorkspace === "project"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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.
|
// 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" />);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user