feat(KB-246): add mobile support to FileBrowserModal
- Add mobile view state and screen size detection in FileBrowserModal - Add back button and conditional layout with CSS classes for mobile/desktop - Add mobile CSS for two-state layout (file list and editor views) - Add comprehensive tests for mobile view interactions and navigation - Add changeset for release notes
This commit is contained in:
263
packages/dashboard/app/components/FileBrowserModal.test.tsx
Normal file
263
packages/dashboard/app/components/FileBrowserModal.test.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { FileBrowserModal } from "./FileBrowserModal";
|
||||
import * as useFileBrowserHook from "../hooks/useFileBrowser";
|
||||
import * as useFileEditorHook from "../hooks/useFileEditor";
|
||||
import * as useProjectFileBrowserHook from "../hooks/useProjectFileBrowser";
|
||||
import * as useProjectFileEditorHook from "../hooks/useProjectFileEditor";
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock("../hooks/useFileBrowser");
|
||||
vi.mock("../hooks/useFileEditor");
|
||||
vi.mock("../hooks/useProjectFileBrowser");
|
||||
vi.mock("../hooks/useProjectFileEditor");
|
||||
|
||||
describe("FileBrowserModal", () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const mockSave = vi.fn();
|
||||
const mockSetContent = vi.fn();
|
||||
const mockSetPath = vi.fn();
|
||||
const mockRefresh = vi.fn();
|
||||
|
||||
const defaultBrowserState = {
|
||||
entries: [
|
||||
{ name: "file1.ts", type: "file", size: 1024, mtime: "2024-01-01" },
|
||||
{ name: "folder1", type: "directory" },
|
||||
],
|
||||
currentPath: ".",
|
||||
setPath: mockSetPath,
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: mockRefresh,
|
||||
};
|
||||
|
||||
const defaultEditorState = {
|
||||
content: "console.log('hello');",
|
||||
setContent: mockSetContent,
|
||||
originalContent: "console.log('hello');",
|
||||
loading: false,
|
||||
saving: false,
|
||||
error: null,
|
||||
save: mockSave,
|
||||
hasChanges: false,
|
||||
mtime: "2024-01-01",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
// Default mock implementations
|
||||
vi.mocked(useFileBrowserHook.useFileBrowser).mockReturnValue(defaultBrowserState);
|
||||
vi.mocked(useFileEditorHook.useFileEditor).mockReturnValue(defaultEditorState);
|
||||
vi.mocked(useProjectFileBrowserHook.useProjectFileBrowser).mockReturnValue(defaultBrowserState);
|
||||
vi.mocked(useProjectFileEditorHook.useProjectFileEditor).mockReturnValue(defaultEditorState);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Desktop view", () => {
|
||||
beforeEach(() => {
|
||||
// Mock desktop viewport
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 1024,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders file browser sidebar and empty state on desktop", () => {
|
||||
render(
|
||||
<FileBrowserModal
|
||||
taskId="KB-001"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
);
|
||||
|
||||
// Sidebar should be visible
|
||||
expect(screen.getByText("file1.ts")).toBeInTheDocument();
|
||||
expect(screen.getByText("folder1")).toBeInTheDocument();
|
||||
|
||||
// Empty state placeholder should be visible
|
||||
expect(screen.getByText("Select a file to edit")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selecting a file shows editor on desktop (both views visible)", async () => {
|
||||
const editorStateWithChanges = {
|
||||
...defaultEditorState,
|
||||
hasChanges: true,
|
||||
};
|
||||
vi.mocked(useFileEditorHook.useFileEditor).mockReturnValue(editorStateWithChanges);
|
||||
|
||||
render(
|
||||
<FileBrowserModal
|
||||
taskId="KB-001"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
);
|
||||
|
||||
// Click on a file
|
||||
fireEvent.click(screen.getByText("file1.ts"));
|
||||
|
||||
// Editor content should be visible - check for the editor textarea
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Editor for file1.ts")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mobile view", () => {
|
||||
beforeEach(() => {
|
||||
// Mock mobile viewport
|
||||
Object.defineProperty(window, "innerWidth", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 375,
|
||||
});
|
||||
});
|
||||
|
||||
it("initially shows only file list (sidebar visible, content hidden)", () => {
|
||||
render(
|
||||
<FileBrowserModal
|
||||
taskId="KB-001"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
);
|
||||
|
||||
// Fire resize event to trigger mobile detection
|
||||
fireEvent(window, new Event("resize"));
|
||||
|
||||
// File list should be visible
|
||||
expect(screen.getByText("file1.ts")).toBeInTheDocument();
|
||||
|
||||
// Empty state should be in the DOM but hidden via CSS
|
||||
const placeholder = screen.getByText("Select a file to edit");
|
||||
expect(placeholder).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selecting a file switches to editor view with back button", async () => {
|
||||
const editorStateWithChanges = {
|
||||
...defaultEditorState,
|
||||
hasChanges: true,
|
||||
};
|
||||
vi.mocked(useFileEditorHook.useFileEditor).mockReturnValue(editorStateWithChanges);
|
||||
|
||||
render(
|
||||
<FileBrowserModal
|
||||
taskId="KB-001"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
);
|
||||
|
||||
// Trigger mobile detection
|
||||
fireEvent(window, new Event("resize"));
|
||||
|
||||
// Click on a file
|
||||
fireEvent.click(screen.getByText("file1.ts"));
|
||||
|
||||
// Back button should be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Back to file list")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("back button returns to list view when clicked", async () => {
|
||||
const editorStateWithChanges = {
|
||||
...defaultEditorState,
|
||||
hasChanges: true,
|
||||
};
|
||||
vi.mocked(useFileEditorHook.useFileEditor).mockReturnValue(editorStateWithChanges);
|
||||
|
||||
render(
|
||||
<FileBrowserModal
|
||||
taskId="KB-001"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
);
|
||||
|
||||
// Trigger mobile detection
|
||||
fireEvent(window, new Event("resize"));
|
||||
|
||||
// Click on a file to enter editor view
|
||||
fireEvent.click(screen.getByText("file1.ts"));
|
||||
|
||||
// Wait for back button to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Back to file list")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click back button
|
||||
fireEvent.click(screen.getByLabelText("Back to file list"));
|
||||
|
||||
// After clicking back, we should still see the file list (file node should be visible)
|
||||
expect(screen.getAllByText("file1.ts").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("back button only renders on mobile when file is selected", () => {
|
||||
render(
|
||||
<FileBrowserModal
|
||||
taskId="KB-001"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
);
|
||||
|
||||
// Trigger mobile detection
|
||||
fireEvent(window, new Event("resize"));
|
||||
|
||||
// Back button should NOT be visible when no file is selected
|
||||
expect(screen.queryByLabelText("Back to file list")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("modal resets to list view when reopened", () => {
|
||||
const { unmount } = render(
|
||||
<FileBrowserModal
|
||||
taskId="KB-001"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
);
|
||||
|
||||
// Trigger mobile detection
|
||||
fireEvent(window, new Event("resize"));
|
||||
|
||||
// Unmount and remount to simulate reopening
|
||||
unmount();
|
||||
|
||||
render(
|
||||
<FileBrowserModal
|
||||
taskId="KB-001"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
);
|
||||
|
||||
// Trigger mobile detection again
|
||||
fireEvent(window, new Event("resize"));
|
||||
|
||||
// Should start in list view - file list visible
|
||||
expect(screen.getByText("file1.ts")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Keyboard shortcuts", () => {
|
||||
it("calls onClose when Escape key is pressed", () => {
|
||||
render(
|
||||
<FileBrowserModal
|
||||
taskId="KB-001"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { X, Save, RotateCcw, Folder, FileType } from "lucide-react";
|
||||
import { X, Save, RotateCcw, Folder, FileType, ArrowLeft } from "lucide-react";
|
||||
import { useFileBrowser } from "../hooks/useFileBrowser";
|
||||
import { useFileEditor } from "../hooks/useFileEditor";
|
||||
import { useProjectFileBrowser } from "../hooks/useProjectFileBrowser";
|
||||
@@ -7,6 +7,9 @@ import { useProjectFileEditor } from "../hooks/useProjectFileEditor";
|
||||
import { FileBrowser } from "./FileBrowser";
|
||||
import { FileEditor } from "./FileEditor";
|
||||
|
||||
// Mobile breakpoint - must match existing codebase
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
/**
|
||||
* Binary file extensions that should be displayed as read-only.
|
||||
*/
|
||||
@@ -48,6 +51,31 @@ type FileBrowserModalProps = {
|
||||
export function FileBrowserModal(props: FileBrowserModalProps) {
|
||||
const { taskId, projectRoot, onClose } = props;
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
|
||||
// Mobile detection and view state (following GitHubImportModal pattern)
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [mobileView, setMobileView] = useState<'list' | 'editor'>('list');
|
||||
|
||||
// Detect mobile viewport
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT);
|
||||
};
|
||||
|
||||
// Check initially
|
||||
checkMobile();
|
||||
|
||||
// Listen for resize
|
||||
window.addEventListener("resize", checkMobile);
|
||||
return () => window.removeEventListener("resize", checkMobile);
|
||||
}, []);
|
||||
|
||||
// Reset to list view when modal opens (selectedFile becomes null)
|
||||
useEffect(() => {
|
||||
if (!selectedFile) {
|
||||
setMobileView('list');
|
||||
}
|
||||
}, [selectedFile]);
|
||||
|
||||
// Determine mode based on which prop is provided
|
||||
const isTaskMode = taskId !== undefined;
|
||||
@@ -102,6 +130,15 @@ export function FileBrowserModal(props: FileBrowserModalProps) {
|
||||
|
||||
const handleSelectFile = useCallback((path: string) => {
|
||||
setSelectedFile(path);
|
||||
// On mobile, switch to editor view when a file is selected
|
||||
if (isMobile) {
|
||||
setMobileView('editor');
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
// Handle back button - return to list view on mobile
|
||||
const handleBackToList = useCallback(() => {
|
||||
setMobileView('list');
|
||||
}, []);
|
||||
|
||||
const handleDiscard = useCallback(() => {
|
||||
@@ -137,7 +174,7 @@ export function FileBrowserModal(props: FileBrowserModalProps) {
|
||||
</div>
|
||||
|
||||
<div className="file-browser-body">
|
||||
<div className="file-browser-sidebar">
|
||||
<div className={`file-browser-sidebar ${isMobile ? 'mobile' : ''} ${mobileView === 'list' ? 'active' : ''}`}>
|
||||
<FileBrowser
|
||||
entries={entries}
|
||||
currentPath={currentPath}
|
||||
@@ -149,11 +186,21 @@ export function FileBrowserModal(props: FileBrowserModalProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="file-browser-content">
|
||||
<div className={`file-browser-content ${isMobile ? 'mobile' : ''} ${mobileView === 'editor' ? 'active' : ''}`}>
|
||||
{selectedFile ? (
|
||||
<>
|
||||
<div className="file-browser-toolbar">
|
||||
<div className="file-browser-file-info">
|
||||
{isMobile && mobileView === 'editor' && (
|
||||
<button
|
||||
className="file-browser-back-button"
|
||||
onClick={handleBackToList}
|
||||
aria-label="Back to file list"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
)}
|
||||
{selectedFile}
|
||||
{selectedFile && isBinaryFile(selectedFile) && (
|
||||
<span className="file-browser-binary-indicator">
|
||||
|
||||
@@ -6673,6 +6673,54 @@ html .column.drag-over * {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Mobile two-state view: show only list or editor at a time */
|
||||
.file-browser-sidebar.mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-browser-sidebar.mobile.active {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.file-browser-content.mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-browser-content.mobile.active {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Back button styles */
|
||||
.file-browser-back-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.file-browser-back-button:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.file-browser-back-button:focus {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
}
|
||||
|
||||
/* === Planning Mode Styles === */
|
||||
|
||||
Reference in New Issue
Block a user