mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 17:08:10 +00:00
9b481212f6
Stop remounting the file tree on every refresh so expanded folders and scroll position survive delete, move, rename, and save. Relist open directories in place and drop stale collapsed caches.
895 lines
37 KiB
TypeScript
895 lines
37 KiB
TypeScript
/**
|
|
* Coverage for FileTree.
|
|
*
|
|
* Locks expand/collapse: root loaded on mount, subdirectory fetched on first
|
|
* expand, collapsed on second click, re-expanded from cache on third click
|
|
* when no refresh ran. Also locks soft refresh (`refreshKey`): expand state
|
|
* survives, collapsed caches drop, failed trees (and descendants) prune.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { FILE_ENTRY_DND_MIME, type FileEntry, type FileEntryDragPayload } from '@/lib/stackFilesApi';
|
|
|
|
vi.mock('@/components/ui/toast-store', () => ({
|
|
toast: {
|
|
error: vi.fn(),
|
|
success: vi.fn(),
|
|
warning: vi.fn(),
|
|
info: vi.fn(),
|
|
loading: vi.fn(),
|
|
dismiss: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const sa = vi.hoisted(() => ({ props: {} as Record<string, unknown> }));
|
|
// ScrollArea just renders children so the tree nodes are accessible in jsdom;
|
|
// capture its props so the horizontal-scroll opt-in is testable.
|
|
vi.mock('@/components/ui/scroll-area', () => ({
|
|
ScrollArea: ({ children, ...props }: { children: React.ReactNode } & Record<string, unknown>) => {
|
|
sa.props = props;
|
|
return <div>{children}</div>;
|
|
},
|
|
}));
|
|
|
|
vi.mock('@/components/ui/skeleton', () => ({
|
|
Skeleton: () => <div data-testid="skeleton" />,
|
|
}));
|
|
|
|
import { FileTree } from '../FileTree';
|
|
|
|
function makeFile(name: string): FileEntry {
|
|
return { name, type: 'file', size: 100, mtime: 0, isProtected: false };
|
|
}
|
|
|
|
function makeDir(name: string): FileEntry {
|
|
return { name, type: 'directory', size: 0, mtime: 0, isProtected: false };
|
|
}
|
|
|
|
const ROOT_ENTRIES: FileEntry[] = [makeDir('src'), makeFile('README.md')];
|
|
const SRC_ENTRIES: FileEntry[] = [makeFile('index.ts'), makeFile('app.ts')];
|
|
|
|
function fakeOk(entries: FileEntry[]): Promise<FileEntry[]> {
|
|
return Promise.resolve(entries);
|
|
}
|
|
|
|
let onSelectFile: ReturnType<typeof vi.fn> & ((relPath: string, entry: FileEntry) => void);
|
|
let mockLoadDir: ReturnType<typeof vi.fn> & ((relPath: string) => Promise<FileEntry[]>);
|
|
|
|
function defaultProps() {
|
|
return {
|
|
sourceKey: 'my-stack',
|
|
loadDir: mockLoadDir,
|
|
selectedPath: '',
|
|
onSelectFile,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
onSelectFile = vi.fn() as typeof onSelectFile;
|
|
mockLoadDir = vi.fn() as typeof mockLoadDir;
|
|
});
|
|
|
|
afterEach(() => vi.clearAllMocks());
|
|
|
|
describe('FileTree', () => {
|
|
it('fetches root entries on mount and renders them', async () => {
|
|
mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
|
|
|
|
render(<FileTree {...defaultProps()} />);
|
|
|
|
await waitFor(() => expect(mockLoadDir).toHaveBeenCalledWith(''));
|
|
expect(await screen.findByText('src')).toBeInTheDocument();
|
|
expect(screen.getByText('README.md')).toBeInTheDocument();
|
|
});
|
|
|
|
it('fetches subdirectory on first expand and shows children', async () => {
|
|
mockLoadDir
|
|
.mockReturnValueOnce(fakeOk(ROOT_ENTRIES))
|
|
.mockReturnValueOnce(fakeOk(SRC_ENTRIES));
|
|
|
|
const user = userEvent.setup();
|
|
render(<FileTree {...defaultProps()} />);
|
|
|
|
await screen.findByText('src');
|
|
|
|
// One call so far: root fetch.
|
|
expect(mockLoadDir).toHaveBeenCalledTimes(1);
|
|
|
|
await user.click(screen.getByText('src'));
|
|
|
|
await waitFor(() => expect(mockLoadDir).toHaveBeenCalledTimes(2));
|
|
expect(mockLoadDir).toHaveBeenNthCalledWith(2, 'src');
|
|
|
|
expect(await screen.findByText('index.ts')).toBeInTheDocument();
|
|
expect(screen.getByText('app.ts')).toBeInTheDocument();
|
|
});
|
|
|
|
it('collapses on second click (no additional fetch)', async () => {
|
|
mockLoadDir
|
|
.mockReturnValueOnce(fakeOk(ROOT_ENTRIES))
|
|
.mockReturnValueOnce(fakeOk(SRC_ENTRIES));
|
|
|
|
const user = userEvent.setup();
|
|
render(<FileTree {...defaultProps()} />);
|
|
|
|
await screen.findByText('src');
|
|
|
|
// Expand.
|
|
await user.click(screen.getByText('src'));
|
|
await screen.findByText('index.ts');
|
|
|
|
const callsAfterExpand = mockLoadDir.mock.calls.length;
|
|
|
|
// Collapse.
|
|
await user.click(screen.getByText('src'));
|
|
await waitFor(() => expect(screen.queryByText('index.ts')).not.toBeInTheDocument());
|
|
|
|
// No extra fetch should have happened.
|
|
expect(mockLoadDir).toHaveBeenCalledTimes(callsAfterExpand);
|
|
});
|
|
|
|
it('re-expands from cache on third click (no second fetch for that dir)', async () => {
|
|
mockLoadDir
|
|
.mockReturnValueOnce(fakeOk(ROOT_ENTRIES))
|
|
.mockReturnValueOnce(fakeOk(SRC_ENTRIES));
|
|
|
|
const user = userEvent.setup();
|
|
render(<FileTree {...defaultProps()} />);
|
|
|
|
await screen.findByText('src');
|
|
|
|
// First click: expand (fetches subdirectory).
|
|
await user.click(screen.getByText('src'));
|
|
await screen.findByText('index.ts');
|
|
|
|
// Second click: collapse.
|
|
await user.click(screen.getByText('src'));
|
|
await waitFor(() => expect(screen.queryByText('index.ts')).not.toBeInTheDocument());
|
|
|
|
const callsAfterCollapse = mockLoadDir.mock.calls.length;
|
|
|
|
// Third click: re-expand from cache.
|
|
await user.click(screen.getByText('src'));
|
|
await screen.findByText('index.ts');
|
|
|
|
// Fetch count must not have increased.
|
|
expect(mockLoadDir).toHaveBeenCalledTimes(callsAfterCollapse);
|
|
});
|
|
|
|
it('shows error message when root fetch fails', async () => {
|
|
mockLoadDir.mockRejectedValue(new Error('Network error'));
|
|
|
|
render(<FileTree {...defaultProps()} />);
|
|
|
|
expect(await screen.findByText('Network error')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows empty state when root returns no entries', async () => {
|
|
mockLoadDir.mockReturnValue(fakeOk([]));
|
|
|
|
render(<FileTree {...defaultProps()} />);
|
|
|
|
expect(await screen.findByText(/empty folder/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('filters visible entries by name as the user types', async () => {
|
|
const entries = [makeDir('src'), makeFile('README.md'), makeFile('Notes.txt'), makeFile('config.yaml')];
|
|
mockLoadDir.mockReturnValue(fakeOk(entries));
|
|
const user = userEvent.setup();
|
|
|
|
render(<FileTree {...defaultProps()} />);
|
|
await screen.findByText('README.md');
|
|
expect(screen.getByText('Notes.txt')).toBeInTheDocument();
|
|
expect(screen.getByText('config.yaml')).toBeInTheDocument();
|
|
|
|
const filter = screen.getByLabelText(/filter files/i);
|
|
await user.type(filter, 'note');
|
|
|
|
// Only Notes.txt survives (case-insensitive substring).
|
|
expect(screen.getByText('Notes.txt')).toBeInTheDocument();
|
|
expect(screen.queryByText('README.md')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('config.yaml')).not.toBeInTheDocument();
|
|
|
|
// Clear button restores the full listing.
|
|
await user.click(screen.getByLabelText(/clear filter/i));
|
|
expect(screen.getByText('README.md')).toBeInTheDocument();
|
|
expect(screen.getByText('Notes.txt')).toBeInTheDocument();
|
|
expect(screen.getByText('config.yaml')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows an empty-match hint when the filter matches nothing', async () => {
|
|
mockLoadDir.mockReturnValue(fakeOk([makeFile('README.md')]));
|
|
const user = userEvent.setup();
|
|
|
|
render(<FileTree {...defaultProps()} />);
|
|
await screen.findByText('README.md');
|
|
|
|
const filter = screen.getByLabelText(/filter files/i);
|
|
await user.type(filter, 'xyzzy');
|
|
|
|
expect(screen.queryByText('README.md')).not.toBeInTheDocument();
|
|
expect(screen.getByText(/no entries match/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('keeps a parent directory visible when a loaded descendant matches and auto-expands it', async () => {
|
|
// Root has `src` (dir) and `README.md`. The user expands `src` so its
|
|
// contents are loaded. Then they filter on a child of `src` whose name
|
|
// does not match the parent.
|
|
mockLoadDir
|
|
.mockReturnValueOnce(fakeOk([makeDir('src'), makeFile('README.md')]))
|
|
.mockReturnValueOnce(fakeOk([makeFile('app.ts'), makeFile('lib.ts')]));
|
|
const user = userEvent.setup();
|
|
|
|
render(<FileTree {...defaultProps()} />);
|
|
await screen.findByText('src');
|
|
await user.click(screen.getByText('src'));
|
|
await screen.findByText('app.ts');
|
|
|
|
const filter = screen.getByLabelText(/filter files/i);
|
|
await user.type(filter, 'app');
|
|
|
|
// `src` survives because it has a matching loaded descendant.
|
|
expect(screen.getByText('src')).toBeInTheDocument();
|
|
// The match itself is visible (src auto-expands while filter is active).
|
|
expect(screen.getByText('app.ts')).toBeInTheDocument();
|
|
// Non-matching siblings at root and inside `src` are filtered out.
|
|
expect(screen.queryByText('README.md')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('lib.ts')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('does not keep an unexpanded parent visible (filter only sees loaded entries)', async () => {
|
|
// Root has `src` (never expanded) and `README.md`. The filter can only
|
|
// judge directories by their loaded contents; an un-fetched subtree
|
|
// contributes nothing to the ancestor-keep rule.
|
|
mockLoadDir.mockReturnValue(fakeOk([makeDir('src'), makeFile('README.md')]));
|
|
const user = userEvent.setup();
|
|
|
|
render(<FileTree {...defaultProps()} />);
|
|
await screen.findByText('src');
|
|
|
|
const filter = screen.getByLabelText(/filter files/i);
|
|
await user.type(filter, 'app');
|
|
|
|
expect(screen.queryByText('src')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('README.md')).not.toBeInTheDocument();
|
|
expect(screen.getByText(/no entries match/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
// ── accessibility: tree roles + keyboard navigation ────────────────────────
|
|
|
|
describe('FileTree accessibility', () => {
|
|
it('exposes a tree with treeitem rows carrying level and selected state', async () => {
|
|
mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
|
|
render(<FileTree {...defaultProps()} selectedPath="README.md" />);
|
|
|
|
await screen.findByText('src');
|
|
expect(screen.getByRole('tree', { name: /files/i })).toBeInTheDocument();
|
|
const items = screen.getAllByRole('treeitem');
|
|
expect(items.length).toBe(2);
|
|
// aria-level is 1-based at the root.
|
|
expect(rowFor('src')).toHaveAttribute('aria-level', '1');
|
|
// The selected file reports aria-selected.
|
|
expect(rowFor('README.md')).toHaveAttribute('aria-selected', 'true');
|
|
expect(rowFor('src')).toHaveAttribute('aria-selected', 'false');
|
|
});
|
|
|
|
it('uses roving tabindex: only one row is tabbable at a time', async () => {
|
|
mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
|
|
render(<FileTree {...defaultProps()} selectedPath="README.md" />);
|
|
|
|
await screen.findByText('src');
|
|
// The selected row is the roving focus, so it holds tabIndex 0; the other -1.
|
|
expect(rowFor('README.md')).toHaveAttribute('tabindex', '0');
|
|
expect(rowFor('src')).toHaveAttribute('tabindex', '-1');
|
|
});
|
|
|
|
it('moves focus with ArrowDown/ArrowUp and jumps with Home/End', async () => {
|
|
mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
|
|
render(<FileTree {...defaultProps()} />);
|
|
await screen.findByText('src');
|
|
|
|
const tree = screen.getByRole('tree');
|
|
// No prior selection: the first node is the roving target.
|
|
expect(rowFor('src')).toHaveAttribute('tabindex', '0');
|
|
|
|
fireEvent.keyDown(tree, { key: 'ArrowDown' });
|
|
await waitFor(() => expect(rowFor('README.md')).toHaveFocus());
|
|
expect(rowFor('README.md')).toHaveAttribute('tabindex', '0');
|
|
expect(rowFor('src')).toHaveAttribute('tabindex', '-1');
|
|
|
|
fireEvent.keyDown(tree, { key: 'ArrowUp' });
|
|
await waitFor(() => expect(rowFor('src')).toHaveFocus());
|
|
|
|
fireEvent.keyDown(tree, { key: 'End' });
|
|
await waitFor(() => expect(rowFor('README.md')).toHaveFocus());
|
|
|
|
fireEvent.keyDown(tree, { key: 'Home' });
|
|
await waitFor(() => expect(rowFor('src')).toHaveFocus());
|
|
});
|
|
|
|
it('expands a collapsed directory with ArrowRight and collapses it with ArrowLeft', async () => {
|
|
mockLoadDir
|
|
.mockReturnValueOnce(fakeOk(ROOT_ENTRIES))
|
|
.mockReturnValueOnce(fakeOk(SRC_ENTRIES));
|
|
render(<FileTree {...defaultProps()} />);
|
|
await screen.findByText('src');
|
|
|
|
const tree = screen.getByRole('tree');
|
|
// src is the first (roving) node; ArrowRight expands it.
|
|
fireEvent.keyDown(tree, { key: 'ArrowRight' });
|
|
expect(await screen.findByText('index.ts')).toBeInTheDocument();
|
|
expect(rowFor('src')).toHaveAttribute('aria-expanded', 'true');
|
|
|
|
// ArrowLeft on the expanded directory collapses it.
|
|
fireEvent.keyDown(tree, { key: 'ArrowLeft' });
|
|
await waitFor(() => expect(screen.queryByText('index.ts')).not.toBeInTheDocument());
|
|
expect(rowFor('src')).toHaveAttribute('aria-expanded', 'false');
|
|
});
|
|
|
|
it('activates the focused row with Enter', async () => {
|
|
mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
|
|
render(<FileTree {...defaultProps()} />);
|
|
await screen.findByText('src');
|
|
|
|
const tree = screen.getByRole('tree');
|
|
// Move focus to the file, then Enter selects it.
|
|
fireEvent.keyDown(tree, { key: 'ArrowDown' });
|
|
await waitFor(() => expect(rowFor('README.md')).toHaveFocus());
|
|
fireEvent.keyDown(rowFor('README.md'), { key: 'Enter' });
|
|
expect(onSelectFile).toHaveBeenCalledWith('README.md', expect.objectContaining({ name: 'README.md' }));
|
|
});
|
|
|
|
it('steps into the first child with ArrowRight on an already-expanded directory', async () => {
|
|
mockLoadDir
|
|
.mockReturnValueOnce(fakeOk(ROOT_ENTRIES))
|
|
.mockReturnValueOnce(fakeOk(SRC_ENTRIES));
|
|
render(<FileTree {...defaultProps()} />);
|
|
await screen.findByText('src');
|
|
const tree = screen.getByRole('tree');
|
|
|
|
fireEvent.keyDown(tree, { key: 'ArrowRight' }); // expand src in place
|
|
await screen.findByText('index.ts');
|
|
expect(rowFor('src')).toHaveAttribute('aria-expanded', 'true');
|
|
|
|
fireEvent.keyDown(tree, { key: 'ArrowRight' }); // step into the first child
|
|
await waitFor(() => expect(rowFor('index.ts')).toHaveFocus());
|
|
expect(rowFor('index.ts')).toHaveAttribute('tabindex', '0');
|
|
});
|
|
|
|
it('moves to the parent directory with ArrowLeft from a child, without collapsing it', async () => {
|
|
mockLoadDir
|
|
.mockReturnValueOnce(fakeOk(ROOT_ENTRIES))
|
|
.mockReturnValueOnce(fakeOk(SRC_ENTRIES));
|
|
render(<FileTree {...defaultProps()} />);
|
|
await screen.findByText('src');
|
|
const tree = screen.getByRole('tree');
|
|
|
|
fireEvent.keyDown(tree, { key: 'ArrowRight' }); // expand src
|
|
await screen.findByText('index.ts');
|
|
fireEvent.keyDown(tree, { key: 'ArrowRight' }); // focus index.ts
|
|
await waitFor(() => expect(rowFor('index.ts')).toHaveFocus());
|
|
|
|
fireEvent.keyDown(tree, { key: 'ArrowLeft' }); // back up to the parent
|
|
await waitFor(() => expect(rowFor('src')).toHaveFocus());
|
|
// Moving to the parent must NOT collapse it.
|
|
expect(rowFor('src')).toHaveAttribute('aria-expanded', 'true');
|
|
});
|
|
|
|
it('keeps exactly one tabbable row when the active node is filtered out', async () => {
|
|
mockLoadDir
|
|
.mockReturnValueOnce(fakeOk(ROOT_ENTRIES))
|
|
.mockReturnValueOnce(fakeOk(SRC_ENTRIES));
|
|
const user = userEvent.setup();
|
|
render(<FileTree {...defaultProps()} />);
|
|
await screen.findByText('src');
|
|
const tree = screen.getByRole('tree');
|
|
|
|
fireEvent.keyDown(tree, { key: 'ArrowRight' }); // expand src
|
|
await screen.findByText('index.ts');
|
|
fireEvent.keyDown(tree, { key: 'ArrowRight' }); // active = src/index.ts
|
|
await waitFor(() => expect(rowFor('index.ts')).toHaveFocus());
|
|
|
|
// Filter to "src": the active child is removed from view, so the roving
|
|
// target must fall back rather than leave the tree with no tabbable row.
|
|
await user.type(screen.getByLabelText(/filter files/i), 'src');
|
|
await waitFor(() => expect(screen.queryByText('index.ts')).not.toBeInTheDocument());
|
|
const tabbable = screen.getAllByRole('treeitem').filter(r => r.getAttribute('tabindex') === '0');
|
|
expect(tabbable).toHaveLength(1);
|
|
});
|
|
|
|
it('announces the selected file basename in the live region', async () => {
|
|
mockLoadDir.mockReturnValue(fakeOk(SRC_ENTRIES));
|
|
const { container } = render(<FileTree {...defaultProps()} selectedPath="src/index.ts" />);
|
|
await screen.findByText('index.ts');
|
|
const live = container.querySelector('[aria-live="polite"]');
|
|
expect(live).toHaveTextContent('Selected index.ts');
|
|
});
|
|
|
|
it('clamps focus at the ends (ArrowUp at top, ArrowDown at bottom are no-ops)', async () => {
|
|
mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
|
|
render(<FileTree {...defaultProps()} />);
|
|
await screen.findByText('src');
|
|
const tree = screen.getByRole('tree');
|
|
|
|
// src is the first node; ArrowUp at the top stays on src.
|
|
fireEvent.keyDown(tree, { key: 'ArrowUp' });
|
|
expect(rowFor('src')).toHaveAttribute('tabindex', '0');
|
|
|
|
fireEvent.keyDown(tree, { key: 'End' });
|
|
await waitFor(() => expect(rowFor('README.md')).toHaveFocus());
|
|
// ArrowDown at the bottom stays on the last row.
|
|
fireEvent.keyDown(tree, { key: 'ArrowDown' });
|
|
expect(rowFor('README.md')).toHaveAttribute('tabindex', '0');
|
|
});
|
|
});
|
|
|
|
// ── bulk selection (checkbox + modifier clicks) ─────────────────────────────
|
|
|
|
describe('FileTree bulk selection', () => {
|
|
it('Ctrl/Cmd+click toggles a row into the selection without opening it', async () => {
|
|
mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
|
|
const onSelectionChange = vi.fn();
|
|
render(<FileTree {...defaultProps()} selectedPaths={new Set()} onSelectionChange={onSelectionChange} />);
|
|
await screen.findByText('README.md');
|
|
|
|
fireEvent.click(rowFor('README.md'), { ctrlKey: true });
|
|
expect(onSelectionChange).toHaveBeenCalledWith(new Set(['README.md']));
|
|
// Modifier-click must not open the file in the viewer.
|
|
expect(onSelectFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('a checkbox click toggles selection without opening the row', async () => {
|
|
mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
|
|
const onSelectionChange = vi.fn();
|
|
const user = userEvent.setup();
|
|
render(<FileTree {...defaultProps()} selectedPaths={new Set()} onSelectionChange={onSelectionChange} />);
|
|
await screen.findByText('README.md');
|
|
|
|
await user.click(screen.getByLabelText('Select README.md'));
|
|
expect(onSelectionChange).toHaveBeenCalledWith(new Set(['README.md']));
|
|
expect(onSelectFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('Shift+click selects the contiguous range from the anchor in visible order', async () => {
|
|
mockLoadDir.mockReturnValue(fakeOk([makeFile('a.txt'), makeFile('b.txt'), makeFile('c.txt')]));
|
|
const onSelectionChange = vi.fn();
|
|
render(<FileTree {...defaultProps()} selectedPaths={new Set()} onSelectionChange={onSelectionChange} />);
|
|
await screen.findByText('a.txt');
|
|
|
|
// Ctrl+click sets the anchor on the first row.
|
|
fireEvent.click(rowFor('a.txt'), { ctrlKey: true });
|
|
// Shift+click the third row selects the whole a..c range.
|
|
fireEvent.click(rowFor('c.txt'), { shiftKey: true });
|
|
expect(onSelectionChange).toHaveBeenLastCalledWith(new Set(['a.txt', 'b.txt', 'c.txt']));
|
|
expect(onSelectFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('a plain click still opens the file and does not change the selection', async () => {
|
|
mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
|
|
const onSelectionChange = vi.fn();
|
|
render(<FileTree {...defaultProps()} selectedPaths={new Set()} onSelectionChange={onSelectionChange} />);
|
|
await screen.findByText('README.md');
|
|
|
|
fireEvent.click(rowFor('README.md'));
|
|
expect(onSelectFile).toHaveBeenCalledWith('README.md', expect.objectContaining({ name: 'README.md' }));
|
|
expect(onSelectionChange).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('reflects checkbox membership via aria-selected', async () => {
|
|
mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
|
|
render(<FileTree {...defaultProps()} selectedPaths={new Set(['README.md'])} onSelectionChange={vi.fn()} />);
|
|
await screen.findByText('README.md');
|
|
expect(rowFor('README.md')).toHaveAttribute('aria-selected', 'true');
|
|
expect(rowFor('src')).toHaveAttribute('aria-selected', 'false');
|
|
});
|
|
});
|
|
|
|
// ── drag-and-drop move ──────────────────────────────────────────────────────
|
|
|
|
/** A minimal DataTransfer stand-in carrying our custom move payload (or an OS file drag). */
|
|
function makeDataTransfer(payload: FileEntryDragPayload | null): DataTransfer {
|
|
const types = payload ? [FILE_ENTRY_DND_MIME] : ['Files'];
|
|
return {
|
|
types,
|
|
getData: (type: string) => (payload && type === FILE_ENTRY_DND_MIME ? JSON.stringify(payload) : ''),
|
|
setData: () => undefined,
|
|
dropEffect: 'none',
|
|
effectAllowed: 'all',
|
|
} as unknown as DataTransfer;
|
|
}
|
|
|
|
function rowFor(name: string): HTMLElement {
|
|
const el = screen.getByText(name).closest('[role="treeitem"]');
|
|
if (!el) throw new Error(`no row for ${name}`);
|
|
return el as HTMLElement;
|
|
}
|
|
|
|
describe('FileTree drag-and-drop move', () => {
|
|
it('calls onMove when an entry is dropped on a folder node', async () => {
|
|
mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
|
|
const onMove = vi.fn();
|
|
|
|
render(<FileTree {...defaultProps()} canEdit onMove={onMove} />);
|
|
await screen.findByText('src');
|
|
|
|
const payload: FileEntryDragPayload = { relPath: 'README.md', name: 'README.md', type: 'file' };
|
|
fireEvent.drop(rowFor('src'), { dataTransfer: makeDataTransfer(payload) });
|
|
|
|
expect(onMove).toHaveBeenCalledWith('README.md', 'README.md', 'src');
|
|
});
|
|
|
|
it('ignores an OS file drag (dataTransfer carries Files, not our payload)', async () => {
|
|
mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
|
|
const onMove = vi.fn();
|
|
|
|
render(<FileTree {...defaultProps()} canEdit onMove={onMove} />);
|
|
await screen.findByText('src');
|
|
|
|
fireEvent.drop(rowFor('src'), { dataTransfer: makeDataTransfer(null) });
|
|
|
|
expect(onMove).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('moves a nested entry to the stack root when dropped on the root area', async () => {
|
|
mockLoadDir
|
|
.mockReturnValueOnce(fakeOk(ROOT_ENTRIES))
|
|
.mockReturnValueOnce(fakeOk(SRC_ENTRIES));
|
|
const onMove = vi.fn();
|
|
const user = userEvent.setup();
|
|
|
|
render(<FileTree {...defaultProps()} canEdit onMove={onMove} />);
|
|
await screen.findByText('src');
|
|
await user.click(screen.getByText('src'));
|
|
await screen.findByText('index.ts');
|
|
|
|
const rootZone = screen.getByTestId('file-tree-root-dropzone');
|
|
const payload: FileEntryDragPayload = { relPath: 'src/index.ts', name: 'index.ts', type: 'file' };
|
|
fireEvent.dragOver(rootZone, { dataTransfer: makeDataTransfer(payload) });
|
|
fireEvent.drop(rootZone, { dataTransfer: makeDataTransfer(payload) });
|
|
|
|
expect(onMove).toHaveBeenCalledWith('src/index.ts', 'index.ts', '');
|
|
});
|
|
|
|
it('ignores a drop of a folder onto one of its own descendants', async () => {
|
|
mockLoadDir
|
|
.mockReturnValueOnce(fakeOk([makeDir('src'), makeFile('README.md')]))
|
|
.mockReturnValueOnce(fakeOk([makeDir('lib'), makeFile('index.ts')]));
|
|
const onMove = vi.fn();
|
|
const user = userEvent.setup();
|
|
|
|
render(<FileTree {...defaultProps()} canEdit onMove={onMove} />);
|
|
await screen.findByText('src');
|
|
await user.click(screen.getByText('src'));
|
|
await screen.findByText('lib');
|
|
|
|
// Drop the `src` folder onto `src/lib`, its own descendant.
|
|
const payload: FileEntryDragPayload = { relPath: 'src', name: 'src', type: 'directory' };
|
|
fireEvent.drop(rowFor('lib'), { dataTransfer: makeDataTransfer(payload) });
|
|
|
|
expect(onMove).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('ignores a drop onto the entry\'s current parent (no-op)', async () => {
|
|
mockLoadDir
|
|
.mockReturnValueOnce(fakeOk([makeDir('src'), makeFile('README.md')]))
|
|
.mockReturnValueOnce(fakeOk([makeFile('index.ts')]));
|
|
const onMove = vi.fn();
|
|
const user = userEvent.setup();
|
|
|
|
render(<FileTree {...defaultProps()} canEdit onMove={onMove} />);
|
|
await screen.findByText('src');
|
|
await user.click(screen.getByText('src'));
|
|
await screen.findByText('index.ts');
|
|
|
|
// Drop `src/index.ts` back onto `src`, where it already lives.
|
|
const payload: FileEntryDragPayload = { relPath: 'src/index.ts', name: 'index.ts', type: 'file' };
|
|
fireEvent.drop(rowFor('src'), { dataTransfer: makeDataTransfer(payload) });
|
|
|
|
expect(onMove).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('makes ordinary entries draggable but not protected root files', async () => {
|
|
mockLoadDir.mockReturnValue(fakeOk([makeFile('compose.yaml'), makeFile('README.md')]));
|
|
|
|
render(<FileTree {...defaultProps()} canEdit onMove={vi.fn()} />);
|
|
await screen.findByText('README.md');
|
|
|
|
expect(rowFor('README.md').draggable).toBe(true);
|
|
expect(rowFor('compose.yaml').draggable).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── layout: full-row hit area + horizontal scroll for long names ────────────
|
|
|
|
describe('FileTree layout', () => {
|
|
it('opts the tree ScrollArea into a horizontal scrollbar', async () => {
|
|
mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
|
|
render(<FileTree {...defaultProps()} />);
|
|
await screen.findByText('src');
|
|
expect(sa.props.horizontal).toBe(true);
|
|
});
|
|
|
|
it('spans the tree and rows to the full pane width and stops clipping names', async () => {
|
|
mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
|
|
render(<FileTree {...defaultProps()} />);
|
|
await screen.findByText('src');
|
|
|
|
// The container and each row fill the pane while growing to content, so the
|
|
// right-click hit area covers the whole row at any horizontal scroll offset.
|
|
const tree = screen.getByRole('tree');
|
|
expect(tree.className).toMatch(/\bmin-w-full\b/);
|
|
expect(tree.className).toMatch(/\bw-max\b/);
|
|
expect(rowFor('src').className).toMatch(/\bmin-w-full\b/);
|
|
expect(rowFor('src').className).toMatch(/\bw-max\b/);
|
|
|
|
// The name renders in full (no truncation), it just does not wrap.
|
|
const nameSpan = screen.getByText('README.md');
|
|
expect(nameSpan.className).toContain('whitespace-nowrap');
|
|
expect(nameSpan.className).not.toContain('truncate');
|
|
});
|
|
|
|
it('opens the Sencho context menu when a directory row is right-clicked', async () => {
|
|
mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
|
|
render(<FileTree {...defaultProps()} canEdit />);
|
|
await screen.findByText('src');
|
|
|
|
fireEvent.contextMenu(rowFor('src'));
|
|
expect(await screen.findByText('New File')).toBeInTheDocument();
|
|
expect(screen.getByText('Rename')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
// ── soft refresh: expand + listings survive refreshKey without remount ───────
|
|
|
|
describe('FileTree soft refresh', () => {
|
|
const DEEP_ENTRIES: FileEntry[] = [makeFile('nested.ts')];
|
|
const SRC_WITH_DEEP: FileEntry[] = [makeDir('deep'), makeFile('index.ts')];
|
|
|
|
it('keeps expanded dirs open and re-lists them when refreshKey changes', async () => {
|
|
mockLoadDir.mockImplementation(async (relPath: string) => {
|
|
if (relPath === '') return ROOT_ENTRIES;
|
|
if (relPath === 'src') return SRC_ENTRIES;
|
|
throw new Error(`unexpected path ${relPath}`);
|
|
});
|
|
|
|
const user = userEvent.setup();
|
|
const { rerender } = render(<FileTree {...defaultProps()} refreshKey={0} />);
|
|
await screen.findByText('src');
|
|
await user.click(screen.getByText('src'));
|
|
await screen.findByText('index.ts');
|
|
|
|
mockLoadDir.mockClear();
|
|
rerender(<FileTree {...defaultProps()} refreshKey={1} />);
|
|
|
|
await waitFor(() => {
|
|
expect(mockLoadDir).toHaveBeenCalledWith('');
|
|
expect(mockLoadDir).toHaveBeenCalledWith('src');
|
|
});
|
|
expect(await screen.findByText('index.ts')).toBeInTheDocument();
|
|
expect(screen.getByText('app.ts')).toBeInTheDocument();
|
|
});
|
|
|
|
it('updates an open folder listing in place on soft refresh', async () => {
|
|
mockLoadDir.mockImplementation(async (relPath: string) => {
|
|
if (relPath === '') return ROOT_ENTRIES;
|
|
if (relPath === 'src') return SRC_ENTRIES;
|
|
throw new Error(`unexpected path ${relPath}`);
|
|
});
|
|
|
|
const user = userEvent.setup();
|
|
const { rerender } = render(<FileTree {...defaultProps()} refreshKey={0} />);
|
|
await screen.findByText('src');
|
|
await user.click(screen.getByText('src'));
|
|
await screen.findByText('index.ts');
|
|
|
|
mockLoadDir.mockImplementation(async (relPath: string) => {
|
|
if (relPath === '') return ROOT_ENTRIES;
|
|
if (relPath === 'src') return [makeFile('index.ts'), makeFile('new.ts')];
|
|
throw new Error(`unexpected path ${relPath}`);
|
|
});
|
|
rerender(<FileTree {...defaultProps()} refreshKey={1} />);
|
|
|
|
expect(await screen.findByText('new.ts')).toBeInTheDocument();
|
|
expect(screen.getByText('index.ts')).toBeInTheDocument();
|
|
expect(screen.queryByText('app.ts')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('prunes descendant expand state when only the parent re-list fails', async () => {
|
|
mockLoadDir.mockImplementation(async (relPath: string) => {
|
|
if (relPath === '') return ROOT_ENTRIES;
|
|
if (relPath === 'src') return SRC_WITH_DEEP;
|
|
if (relPath === 'src/deep') return DEEP_ENTRIES;
|
|
throw new Error(`unexpected path ${relPath}`);
|
|
});
|
|
|
|
const user = userEvent.setup();
|
|
const { rerender } = render(<FileTree {...defaultProps()} refreshKey={0} />);
|
|
await screen.findByText('src');
|
|
await user.click(screen.getByText('src'));
|
|
await screen.findByText('deep');
|
|
await user.click(screen.getByText('deep'));
|
|
await screen.findByText('nested.ts');
|
|
|
|
mockLoadDir.mockImplementation(async (relPath: string) => {
|
|
if (relPath === '') return ROOT_ENTRIES;
|
|
if (relPath === 'src') throw new Error('Not found');
|
|
if (relPath === 'src/deep') return DEEP_ENTRIES;
|
|
throw new Error(`unexpected path ${relPath}`);
|
|
});
|
|
rerender(<FileTree {...defaultProps()} refreshKey={1} />);
|
|
|
|
await waitFor(() => expect(screen.queryByText('nested.ts')).not.toBeInTheDocument());
|
|
expect(screen.getByText('src')).toBeInTheDocument();
|
|
expect(screen.queryByText('deep')).not.toBeInTheDocument();
|
|
|
|
mockLoadDir.mockClear();
|
|
mockLoadDir.mockImplementation(async (relPath: string) => {
|
|
if (relPath === '') return ROOT_ENTRIES;
|
|
if (relPath === 'src') return SRC_WITH_DEEP;
|
|
if (relPath === 'src/deep') return [makeFile('fresh.ts')];
|
|
throw new Error(`unexpected path ${relPath}`);
|
|
});
|
|
await user.click(screen.getByText('src'));
|
|
await screen.findByText('deep');
|
|
// Prune must drop descendant expand state: deep is visible but collapsed.
|
|
expect(screen.queryByText('nested.ts')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('fresh.ts')).not.toBeInTheDocument();
|
|
await user.click(screen.getByText('deep'));
|
|
// Re-expand must refetch, not reuse the pre-prune listing.
|
|
expect(await screen.findByText('fresh.ts')).toBeInTheDocument();
|
|
});
|
|
|
|
it('keeps nested expand state and updates deep listings on successful refresh', async () => {
|
|
mockLoadDir.mockImplementation(async (relPath: string) => {
|
|
if (relPath === '') return ROOT_ENTRIES;
|
|
if (relPath === 'src') return SRC_WITH_DEEP;
|
|
if (relPath === 'src/deep') return DEEP_ENTRIES;
|
|
throw new Error(`unexpected path ${relPath}`);
|
|
});
|
|
|
|
const user = userEvent.setup();
|
|
const { rerender } = render(<FileTree {...defaultProps()} refreshKey={0} />);
|
|
await screen.findByText('src');
|
|
await user.click(screen.getByText('src'));
|
|
await screen.findByText('deep');
|
|
await user.click(screen.getByText('deep'));
|
|
await screen.findByText('nested.ts');
|
|
|
|
mockLoadDir.mockClear();
|
|
mockLoadDir.mockImplementation(async (relPath: string) => {
|
|
if (relPath === '') return ROOT_ENTRIES;
|
|
if (relPath === 'src') return SRC_WITH_DEEP;
|
|
if (relPath === 'src/deep') return [makeFile('nested.ts'), makeFile('extra.ts')];
|
|
throw new Error(`unexpected path ${relPath}`);
|
|
});
|
|
rerender(<FileTree {...defaultProps()} refreshKey={1} />);
|
|
|
|
await waitFor(() => {
|
|
expect(mockLoadDir).toHaveBeenCalledWith('');
|
|
expect(mockLoadDir).toHaveBeenCalledWith('src');
|
|
expect(mockLoadDir).toHaveBeenCalledWith('src/deep');
|
|
});
|
|
expect(await screen.findByText('extra.ts')).toBeInTheDocument();
|
|
expect(screen.getByText('nested.ts')).toBeInTheDocument();
|
|
});
|
|
|
|
it('drops a collapsed sibling cache while another folder stays open', async () => {
|
|
const rootWithLib: FileEntry[] = [makeDir('src'), makeDir('lib'), makeFile('README.md')];
|
|
mockLoadDir.mockImplementation(async (relPath: string) => {
|
|
if (relPath === '') return rootWithLib;
|
|
if (relPath === 'src') return SRC_ENTRIES;
|
|
if (relPath === 'lib') return [makeFile('util.ts')];
|
|
throw new Error(`unexpected path ${relPath}`);
|
|
});
|
|
|
|
const user = userEvent.setup();
|
|
const { rerender } = render(<FileTree {...defaultProps()} refreshKey={0} />);
|
|
await screen.findByText('src');
|
|
await user.click(screen.getByText('src'));
|
|
await screen.findByText('index.ts');
|
|
await user.click(screen.getByText('lib'));
|
|
await screen.findByText('util.ts');
|
|
await user.click(screen.getByText('src'));
|
|
await waitFor(() => expect(screen.queryByText('index.ts')).not.toBeInTheDocument());
|
|
|
|
mockLoadDir.mockClear();
|
|
mockLoadDir.mockImplementation(async (relPath: string) => {
|
|
if (relPath === '') return rootWithLib;
|
|
if (relPath === 'src') return [makeFile('index.ts'), makeFile('added.ts')];
|
|
if (relPath === 'lib') return [makeFile('util.ts'), makeFile('extra.ts')];
|
|
throw new Error(`unexpected path ${relPath}`);
|
|
});
|
|
rerender(<FileTree {...defaultProps()} refreshKey={1} />);
|
|
|
|
await waitFor(() => {
|
|
expect(mockLoadDir).toHaveBeenCalledWith('');
|
|
expect(mockLoadDir).toHaveBeenCalledWith('lib');
|
|
});
|
|
expect(mockLoadDir).not.toHaveBeenCalledWith('src');
|
|
expect(await screen.findByText('extra.ts')).toBeInTheDocument();
|
|
|
|
await user.click(screen.getByText('src'));
|
|
expect(await screen.findByText('added.ts')).toBeInTheDocument();
|
|
expect(mockLoadDir).toHaveBeenCalledWith('src');
|
|
});
|
|
|
|
it('keeps a sibling expanded when another open folder fails to re-list', async () => {
|
|
const rootWithLib: FileEntry[] = [makeDir('src'), makeDir('lib'), makeFile('README.md')];
|
|
mockLoadDir.mockImplementation(async (relPath: string) => {
|
|
if (relPath === '') return rootWithLib;
|
|
if (relPath === 'src') return SRC_ENTRIES;
|
|
if (relPath === 'lib') return [makeFile('util.ts')];
|
|
throw new Error(`unexpected path ${relPath}`);
|
|
});
|
|
|
|
const user = userEvent.setup();
|
|
const { rerender } = render(<FileTree {...defaultProps()} refreshKey={0} />);
|
|
await screen.findByText('src');
|
|
await user.click(screen.getByText('src'));
|
|
await screen.findByText('index.ts');
|
|
await user.click(screen.getByText('lib'));
|
|
await screen.findByText('util.ts');
|
|
|
|
mockLoadDir.mockImplementation(async (relPath: string) => {
|
|
if (relPath === '') return rootWithLib;
|
|
if (relPath === 'src') throw new Error('Not found');
|
|
if (relPath === 'lib') return [makeFile('util.ts'), makeFile('kept.ts')];
|
|
throw new Error(`unexpected path ${relPath}`);
|
|
});
|
|
rerender(<FileTree {...defaultProps()} refreshKey={1} />);
|
|
|
|
expect(await screen.findByText('kept.ts')).toBeInTheDocument();
|
|
expect(screen.getByText('util.ts')).toBeInTheDocument();
|
|
await waitFor(() => expect(screen.queryByText('index.ts')).not.toBeInTheDocument());
|
|
expect(screen.getByText('src')).toBeInTheDocument();
|
|
});
|
|
|
|
it('invalidates collapsed dir cache so re-expand refetches after refresh', async () => {
|
|
mockLoadDir.mockImplementation(async (relPath: string) => {
|
|
if (relPath === '') return ROOT_ENTRIES;
|
|
if (relPath === 'src') return SRC_ENTRIES;
|
|
throw new Error(`unexpected path ${relPath}`);
|
|
});
|
|
|
|
const user = userEvent.setup();
|
|
const { rerender } = render(<FileTree {...defaultProps()} refreshKey={0} />);
|
|
await screen.findByText('src');
|
|
await user.click(screen.getByText('src'));
|
|
await screen.findByText('index.ts');
|
|
await user.click(screen.getByText('src'));
|
|
await waitFor(() => expect(screen.queryByText('index.ts')).not.toBeInTheDocument());
|
|
|
|
mockLoadDir.mockImplementation(async (relPath: string) => {
|
|
if (relPath === '') return ROOT_ENTRIES;
|
|
if (relPath === 'src') return [makeFile('index.ts'), makeFile('added.ts')];
|
|
throw new Error(`unexpected path ${relPath}`);
|
|
});
|
|
mockLoadDir.mockClear();
|
|
rerender(<FileTree {...defaultProps()} refreshKey={1} />);
|
|
await waitFor(() => expect(mockLoadDir).toHaveBeenCalledWith(''));
|
|
expect(mockLoadDir).not.toHaveBeenCalledWith('src');
|
|
|
|
await user.click(screen.getByText('src'));
|
|
expect(await screen.findByText('added.ts')).toBeInTheDocument();
|
|
expect(mockLoadDir).toHaveBeenCalledWith('src');
|
|
});
|
|
|
|
it('clears a root-load error when a later soft refresh succeeds', async () => {
|
|
mockLoadDir.mockRejectedValue(new Error('Network error'));
|
|
const { rerender } = render(<FileTree {...defaultProps()} refreshKey={0} />);
|
|
expect(await screen.findByText('Network error')).toBeInTheDocument();
|
|
|
|
mockLoadDir.mockImplementation(async (relPath: string) => {
|
|
if (relPath === '') return ROOT_ENTRIES;
|
|
throw new Error(`unexpected path ${relPath}`);
|
|
});
|
|
rerender(<FileTree {...defaultProps()} refreshKey={1} />);
|
|
|
|
expect(await screen.findByText('src')).toBeInTheDocument();
|
|
expect(screen.getByText('README.md')).toBeInTheDocument();
|
|
expect(screen.queryByText('Network error')).not.toBeInTheDocument();
|
|
});
|
|
});
|