feat(files): per-stack file explorer (#780)

* feat(files): backend foundation for stack file explorer

Install multer for multipart file upload handling. Add
isValidRelativeStackPath to validation.ts to guard client-supplied
relative paths against traversal, absolute paths, NUL bytes, backslash
injection, and double-slash segments. Add isBinaryBuffer to a new
binaryDetect.ts utility for heuristic text/binary detection via
NUL-byte fast exit and non-printable byte ratio sampling.

* fix(files): reject bare dot segments in isValidRelativeStackPath

* feat(files): add safe stack-scoped file I/O methods to FileSystemService

Adds FileEntry interface and seven new public methods to FileSystemService
for stack-scoped file operations: listStackDirectory, readStackFile,
streamStackFile, writeStackFile, deleteStackPath, mkdirStackPath, and
statStackEntry.

Each method routes through a private resolveSafeStackPath helper that
enforces two-phase path containment: a pre-realpath lexical check plus a
post-realpath symlink-escape check. ENOENT targets are handled by walking
up to the deepest existing ancestor, realpaths that ancestor, and
reattaching the remaining suffix.

Binary detection delegates to isBinaryBuffer; path safety delegates to
isPathWithinBase. Protected file names and the MIME map are module-level
constants to avoid repeated allocation.

* feat(files): frontend API wrappers and Monaco language helper

* fix(files): tighten stackFilesApi error handling and localOnly support

* fix(files): FileSystemService safety and correctness fixes

* feat(files): add file explorer API endpoints to stacks router

* feat(files): FileTree and FileTreeNode components

* fix(files): route security hardening and stream cleanup

* fix(files): FileTree accessibility, icon stroke, stale fetch guard

Add strokeWidth={1.5} to all Lucide icons in FileTreeNode to match the
design system. Add aria-expanded to directory rows for accessibility.
Guard handleDirClick .then() callbacks against stale stack name
references when the component re-renders with a new stack. Add
toast.info fallbacks when compose.yaml or .env is clicked without a
navigation callback registered.

* feat(files): FileViewer, FileUploadDropzone, NewFolderDialog, DeleteFileConfirm

* fix(files): resolve code quality findings in file explorer components

- Move editorOptions useMemo above conditional returns in FileViewer (Rules of Hooks fix)
- Fix blob download: append anchor to DOM before click, defer URL revoke 100ms
- Keep protected-file confirm input visible during NOT_EMPTY recursive retry in DeleteFileConfirm
- Remove non-functional cursor-pointer/onClick from Community upgrade pill in FileUploadDropzone
- Add success toast on folder creation in NewFolderDialog
- Switch all (e as Error).message casts to instanceof Error narrowing

* test(files): unit tests for binary detection, stack path safety, and file explorer routes

- binary-detection.test.ts: covers isBinaryBuffer edge cases (empty, NUL,
  PNG header, threshold boundary, sampleBytes parameter)
- filesystem-stack-paths.test.ts: covers isValidRelativeStackPath (accepts/
  rejects matrix) and FileSystemService stack methods against a real temp dir
  (listStackDirectory sort and protection flags, readStackFile text/binary/
  oversized paths, writeStackFile/Buffer, deleteStackPath, mkdirStackPath,
  traversal guard); platform-specific empty-dir/NOT_EMPTY cases skip on Windows
- stack-files-routes.test.ts: route-level integration tests for all seven
  file explorer endpoints; covers auth gating, Community-tier 403 gates,
  input validation, 413 TOO_LARGE upload limit, and 204/200 happy paths

* feat(files): StackFileExplorer container with lazy tree, viewer, and action bar

* fix(files): add Download button to explorer toolbar, fix Community upgrade pill, reset state on stack change

* test(files): add missing test coverage for file explorer routes and service

* feat(files): add Files tab to EditorLayout with StackFileExplorer integration

* fix(files): add defensive activeTab guard to saveFile and discardChanges

* test(files): unit tests for FileTree expand/collapse and FileViewer render modes

Covers the three FileViewer content modes (text/Monaco, binary panel,
oversized panel) and the FileTree expand/collapse/cache cycle: first
expand fetches the subdirectory, second click collapses without a fetch,
third click re-expands from the in-memory cache without a second fetch.

* test(e2e): file explorer community and skipper+ flows

Covers the full file-explorer feature surface in two describe blocks:

Community (read-only): intercepts /api/license to simulate community
tier, confirms the upgrade pill is visible in the left pane, and
asserts that the Save button is absent after opening a text file.

Skipper+ (full CRUD): uploads a text file and confirms it appears in
the tree; edits config/app.conf and saves via Monaco; deletes an
uploaded file and asserts the tree entry is gone; issues a raw HTTP
request to the download endpoint and checks for status 200 and the
content-disposition: attachment header.

Also adds data-testid="file-action-delete" to the action bar Delete
button in StackFileExplorer for stable targeting, and exports
waitForStacksLoaded from e2e/helpers.ts to eliminate the three
identical local copies in stacks, deploy-log-panel, and stack-files
spec files.

* fix(e2e): improve test isolation and selector stability in stack-files spec

Move beforeEach seed to beforeAll/afterAll so fixtures are created once per
suite, not before every test. Extract shared seedSuite/teardownSuite helpers
to eliminate the duplicate beforeAll/afterAll blocks. Wrap teardown in
try/catch so failures log a warning rather than masking test results.

Replace waitForTimeout(500) with a deterministic expect on the file tree
sentinel. Add data-testid="anatomy-files-btn" and data-testid="delete-confirm-btn"
to replace the fragile button text/positional selectors. Assert Save button
starts disabled before editing.

* docs(files): add stack file explorer documentation

Add user-facing guide for the stack file explorer feature covering
tier access (Community read-only, Skipper+ read-write), viewing
limits, upload/download caps, protected file routing, and
troubleshooting. Update the editor page to reference the new guide
and register the page in the navigation.

* fix(docs): use canonical Skipper tier name in file explorer overview card

* fix(files): resolve lint errors blocking CI

Remove unnecessary backslash escape before double-quote in the
Content-Disposition regex (no-useless-escape). Replace five synchronous
setState resets at the top of the FileTree mount effect with a React key
prop on the FileTree element in StackFileExplorer so remounting resets
state automatically, eliminating the react-hooks/set-state-in-effect
violation.

* test(files): fix e2e seeding to work on community-tier CI

Replace the browser-side paid upload/mkdir API calls in seedTestStack with
direct Node fs writes. The upload and folder endpoints require Skipper+ so
they returned 403 on CI, which runs with no license set. Stack creation
via POST /api/stacks stays as an API call since it is community-allowed and
keeps the backend registry in sync.

Add a per-test tier check in the Skipper+ beforeEach that skips gracefully
when the instance is community, matching the pattern in auto-heal-policies.
This commit is contained in:
Anso
2026-04-26 13:05:19 -04:00
committed by GitHub
parent dd9d33813b
commit 801a098a5b
29 changed files with 3645 additions and 71 deletions
@@ -0,0 +1,170 @@
/**
* Coverage for FileTree.
*
* Locks the expand/collapse behavior: root directory loaded on mount,
* subdirectory fetched on first expand, collapsed on second click, and
* re-expanded from cache (no second fetch) on third click.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { FileEntry } from '@/lib/stackFilesApi';
vi.mock('@/lib/stackFilesApi', () => ({
listStackDirectory: vi.fn(),
}));
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(),
},
}));
// ScrollArea just renders children so the tree nodes are accessible in jsdom.
vi.mock('@/components/ui/scroll-area', () => ({
ScrollArea: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@/components/ui/skeleton', () => ({
Skeleton: () => <div data-testid="skeleton" />,
}));
import { listStackDirectory } from '@/lib/stackFilesApi';
import { FileTree } from '../FileTree';
const mockListDir = listStackDirectory as unknown as ReturnType<typeof vi.fn>;
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);
}
const defaultProps = {
stackName: 'my-stack',
selectedPath: '',
onSelectFile: vi.fn(),
};
beforeEach(() => {
mockListDir.mockReset();
defaultProps.onSelectFile = vi.fn();
});
afterEach(() => vi.clearAllMocks());
describe('FileTree', () => {
it('fetches root entries on mount and renders them', async () => {
mockListDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
render(<FileTree {...defaultProps} />);
await waitFor(() => expect(mockListDir).toHaveBeenCalledWith('my-stack', ''));
expect(await screen.findByText('src')).toBeInTheDocument();
expect(screen.getByText('README.md')).toBeInTheDocument();
});
it('fetches subdirectory on first expand and shows children', async () => {
mockListDir
.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(mockListDir).toHaveBeenCalledTimes(1);
await user.click(screen.getByText('src'));
await waitFor(() => expect(mockListDir).toHaveBeenCalledTimes(2));
expect(mockListDir).toHaveBeenNthCalledWith(2, 'my-stack', 'src');
expect(await screen.findByText('index.ts')).toBeInTheDocument();
expect(screen.getByText('app.ts')).toBeInTheDocument();
});
it('collapses on second click (no additional fetch)', async () => {
mockListDir
.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 = mockListDir.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(mockListDir).toHaveBeenCalledTimes(callsAfterExpand);
});
it('re-expands from cache on third click (no second fetch for that dir)', async () => {
mockListDir
.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 = mockListDir.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(mockListDir).toHaveBeenCalledTimes(callsAfterCollapse);
});
it('shows error message when root fetch fails', async () => {
mockListDir.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 () => {
mockListDir.mockReturnValue(fakeOk([]));
render(<FileTree {...defaultProps} />);
expect(await screen.findByText(/empty folder/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,180 @@
/**
* Coverage for FileViewer.
*
* Locks the three content-render modes: Monaco editor for text files,
* binary panel for binary files, and oversized panel for files that
* exceed the preview limit.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import type { FileContentResult } from '@/lib/stackFilesApi';
vi.mock('@monaco-editor/react', () => ({
default: () => <div data-testid="monaco-editor" />,
}));
vi.mock('@/lib/stackFilesApi', () => ({
readStackFile: vi.fn(),
writeStackFile: vi.fn(),
downloadStackFile: vi.fn(),
}));
vi.mock('@/components/ui/toast-store', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
loading: vi.fn(() => 'loading-id'),
dismiss: vi.fn(),
},
}));
vi.mock('@/components/ui/skeleton', () => ({
Skeleton: () => <div data-testid="skeleton" />,
}));
vi.mock('@/components/ui/button', () => ({
Button: ({
children,
disabled,
onClick,
}: {
children: React.ReactNode;
disabled?: boolean;
onClick?: () => void;
}) => (
<button disabled={disabled} onClick={onClick}>
{children}
</button>
),
}));
vi.mock('@/lib/utils', () => ({
formatBytes: (n: number) => `${n}B`,
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
}));
vi.mock('@/lib/monacoLanguages', () => ({
extensionToLanguage: () => 'plaintext',
}));
const licenseState = { isPaid: true };
vi.mock('@/context/LicenseContext', () => ({
useLicense: () => licenseState,
}));
import { readStackFile } from '@/lib/stackFilesApi';
import { FileViewer } from '../FileViewer';
const mockReadFile = readStackFile as unknown as ReturnType<typeof vi.fn>;
function textResult(content = 'hello world'): FileContentResult {
return { content, binary: false, oversized: false, size: content.length, mime: 'text/plain' };
}
function binaryResult(): FileContentResult {
return { binary: true, oversized: false, size: 1024, mime: 'application/octet-stream' };
}
function oversizedResult(): FileContentResult {
return { binary: false, oversized: true, size: 5_000_000, mime: 'text/plain' };
}
const defaultProps = {
stackName: 'my-stack',
canEdit: true,
isDarkMode: false,
};
beforeEach(() => {
mockReadFile.mockReset();
licenseState.isPaid = true;
});
afterEach(() => vi.clearAllMocks());
describe('FileViewer', () => {
it('shows "Select a file" placeholder when selectedPath is null', () => {
render(<FileViewer {...defaultProps} selectedPath={null} />);
expect(screen.getByText(/select a file/i)).toBeInTheDocument();
expect(mockReadFile).not.toHaveBeenCalled();
});
it('renders Monaco editor for a regular text file', async () => {
mockReadFile.mockResolvedValue(textResult());
render(<FileViewer {...defaultProps} selectedPath="config/app.txt" />);
await waitFor(() => expect(screen.getByTestId('monaco-editor')).toBeInTheDocument());
expect(screen.queryByText(/binary file/i)).not.toBeInTheDocument();
expect(screen.queryByText(/too large/i)).not.toBeInTheDocument();
});
it('calls readStackFile with the correct stack name and path', async () => {
mockReadFile.mockResolvedValue(textResult());
render(<FileViewer {...defaultProps} selectedPath="src/index.ts" />);
await waitFor(() => expect(mockReadFile).toHaveBeenCalledWith('my-stack', 'src/index.ts'));
});
it('renders binary panel (not Monaco) for a binary file', async () => {
mockReadFile.mockResolvedValue(binaryResult());
render(<FileViewer {...defaultProps} selectedPath="assets/logo.png" />);
expect(await screen.findByText(/binary file/i)).toBeInTheDocument();
expect(screen.queryByTestId('monaco-editor')).not.toBeInTheDocument();
});
it('renders oversized panel (not Monaco) when file is too large to preview', async () => {
mockReadFile.mockResolvedValue(oversizedResult());
render(<FileViewer {...defaultProps} selectedPath="logs/huge.log" />);
expect(await screen.findByText(/too large to preview/i)).toBeInTheDocument();
expect(screen.queryByTestId('monaco-editor')).not.toBeInTheDocument();
});
it('renders error message when readStackFile rejects', async () => {
mockReadFile.mockRejectedValue(new Error('Not found'));
render(<FileViewer {...defaultProps} selectedPath="missing.txt" />);
expect(await screen.findByText('Not found')).toBeInTheDocument();
expect(screen.queryByTestId('monaco-editor')).not.toBeInTheDocument();
});
it('shows Download button when user has a paid tier', async () => {
mockReadFile.mockResolvedValue(binaryResult());
render(<FileViewer {...defaultProps} selectedPath="data.bin" />);
await screen.findByText(/binary file/i);
const downloadBtn = screen.getByRole('button', { name: /download/i });
expect(downloadBtn).not.toBeDisabled();
});
it('shows disabled Download button for community tier', async () => {
licenseState.isPaid = false;
mockReadFile.mockResolvedValue(binaryResult());
render(<FileViewer {...defaultProps} selectedPath="data.bin" />);
await screen.findByText(/binary file/i);
const downloadBtn = screen.getByRole('button', { name: /download/i });
expect(downloadBtn).toBeDisabled();
});
it('re-fetches when selectedPath changes', async () => {
mockReadFile.mockResolvedValue(textResult());
const { rerender } = render(<FileViewer {...defaultProps} selectedPath="a.txt" />);
await waitFor(() => expect(mockReadFile).toHaveBeenCalledTimes(1));
rerender(<FileViewer {...defaultProps} selectedPath="b.txt" />);
await waitFor(() => expect(mockReadFile).toHaveBeenCalledTimes(2));
expect(mockReadFile).toHaveBeenNthCalledWith(2, 'my-stack', 'b.txt');
});
});