diff --git a/app/react/components/CollapseExpandButton.tsx b/app/react/components/CollapseExpandButton.tsx index 5757270c26..10e24d3009 100644 --- a/app/react/components/CollapseExpandButton.tsx +++ b/app/react/components/CollapseExpandButton.tsx @@ -7,8 +7,12 @@ import { Icon } from './Icon'; export function CollapseExpandButton({ onClick, isExpanded, + quarterRotation = false, ...props -}: { isExpanded: boolean } & ComponentProps<'button'>) { +}: { + isExpanded: boolean; + quarterRotation?: boolean; +} & ComponentProps<'button'>) { return ( + + {matchingPaths.length}{' '} + {pluralize(matchingPaths.length, 'match', 'matches')} + + + )} + {value && ( + + )} + {(isFocused || !openDropdownOnFocus) && + (openDropdownOnFocus || filterActive) && + (renderDropdown + ? renderDropdown(matchingPaths) + : matchingPaths.length > 0 && ( + + ))} + + ); + + function handleAddExpression() { + onCompletion(filterToPattern(filterTrimmed)); + setIsFocused(false); + inputRef.current?.blur(); + onChange(''); + } + } +); diff --git a/app/react/components/CommandPalette/utils.test.ts b/app/react/components/CommandPalette/utils.test.ts new file mode 100644 index 0000000000..800c3dc3d4 --- /dev/null +++ b/app/react/components/CommandPalette/utils.test.ts @@ -0,0 +1,53 @@ +import { globToRegex } from './utils'; + +describe('globToRegex', () => { + it('matches a file by extension using *', () => { + const re = globToRegex('*.yml'); + expect(re.test('foo.yml')).toBe(true); + expect(re.test('foo.ts')).toBe(false); + }); + + it('wraps pattern in "*" when not explicitly used', () => { + const re = globToRegex('yml'); + expect(re.test('foo.yml')).toBe(true); + expect(re.test('foo.ts')).toBe(false); + }); + + it('matches nested paths with **', () => { + const re = globToRegex('src/**'); + expect(re.test('src/foo.ts')).toBe(true); + expect(re.test('src/nested/bar.ts')).toBe(true); + expect(re.test('lib/foo.ts')).toBe(false); + }); + + it('is case-insensitive', () => { + const re = globToRegex('*.YML'); + expect(re.test('foo.yml')).toBe(true); + expect(re.test('FOO.YML')).toBe(true); + }); + + it('escapes dots so they match only literal dots', () => { + const re = globToRegex('file.ts'); + expect(re.test('file.ts')).toBe(true); + expect(re.test('fileXts')).toBe(false); + }); + + it('matches exact paths with no wildcards', () => { + const re = globToRegex('src/foo.ts'); + expect(re.test('src/foo.ts')).toBe(true); + expect(re.test('src/bar.ts')).toBe(false); + }); + + it('matches any path with a bare wildcard', () => { + const re = globToRegex('*'); + expect(re.test('anything')).toBe(true); + }); + + it('matches exactly one character with ?', () => { + const re = globToRegex('te?t'); + expect(re.test('testy')).toBe(true); + expect(re.test('text')).toBe(true); + expect(re.test('tesst')).toBe(false); + expect(re.test('tet')).toBe(false); + }); +}); diff --git a/app/react/components/CommandPalette/utils.ts b/app/react/components/CommandPalette/utils.ts new file mode 100644 index 0000000000..40aeaea22d --- /dev/null +++ b/app/react/components/CommandPalette/utils.ts @@ -0,0 +1,18 @@ +/** + * Converts a glob pattern to a case-insensitive RegExp. + * + * Patterns without `*` are wrapped in `*...*` for substring matching + * `*` matches any sequence of characters + * `?` matches exactly one character + */ +export function globToRegex(pattern: string): RegExp { + let glob = pattern; + if (!pattern.includes('*')) { + glob = `*${pattern}*`; + } + const escaped = glob + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*+/g, '.*') + .replace(/\?/g, '.'); + return new RegExp(`^${escaped}$`, 'i'); +} diff --git a/app/react/components/form-components/FilePicker/FilePicker.stories.tsx b/app/react/components/form-components/FilePicker/FilePicker.stories.tsx new file mode 100644 index 0000000000..55fa81a46d --- /dev/null +++ b/app/react/components/form-components/FilePicker/FilePicker.stories.tsx @@ -0,0 +1,219 @@ +import { Meta, StoryObj } from '@storybook/react-webpack5'; +import { useEffect, useState } from 'react'; + +import { Button } from '@@/buttons/Button'; +import { FileNode } from '@@/form-components/FilePicker/types'; + +import { FilePicker } from './FilePicker'; +import { FilePickerSkeleton } from './FilePickerSkeleton'; + +const meta: Meta = { + title: 'Components/Forms/File Picker', + component: FilePicker, + tags: [], + decorators: [ + (Story, { viewMode }) => ( +
+ +
+ ), + ], + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: + '`FilePicker` lets users select individual files or build wildcard expressions (e.g. `*.yml`, `src/**/*.ts`) from a repository tree. Selected items surface as **File** or **Expression** chips. A blue dot marks every tree row that is selected or matched by an expression. Typing in the filter bar shows a live preview of matching files and offers a one-click "Add expression (N matches)" button.', + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +// ─── Demo data ──────────────────────────────────────────────────────────────── + +const REPO_NODES: FileNode[] = [ + { + name: '.github', + children: [ + { + name: 'workflows', + children: [ + { name: 'ci.yml' }, + { name: 'deploy.yml' }, + { name: 'release.yml' }, + ], + }, + ], + }, + { + name: 'config', + children: [ + { name: 'app.yml' }, + { name: 'database.yml' }, + { name: 'cache.yml' }, + { name: 'logging.yml' }, + ], + }, + { + name: 'deploy', + children: [ + { name: 'k8s.yaml' }, + { name: 'helm-values.yaml' }, + { name: 'terraform.tfvars' }, + ], + }, + { + name: 'docs', + children: [ + { name: 'README.md' }, + { name: 'architecture.md' }, + { name: 'CONTRIBUTING.md' }, + ], + }, + { + name: 'scripts', + children: [ + { name: 'build.sh' }, + { name: 'deploy.sh' }, + { name: 'seed.sh' }, + ], + }, + { + name: 'src', + children: [ + { + name: 'api', + children: [ + { name: 'routes.ts' }, + { name: 'handlers.ts' }, + { name: 'middleware.ts' }, + ], + }, + { + name: 'components', + children: [ + { name: 'Button.tsx' }, + { name: 'Modal.tsx' }, + { name: 'Table.tsx' }, + ], + }, + { + name: 'utils', + children: [{ name: 'helpers.ts' }, { name: 'types.ts' }], + }, + ], + }, + { + name: 'tests', + children: [ + { name: 'setup.ts' }, + { name: 'integration.ts' }, + { name: 'e2e.ts' }, + ], + }, + { name: '.gitignore' }, + { name: 'docker-compose.yml' }, + { name: 'docker-compose.prod.yml' }, + { name: 'docker-compose.staging.yml' }, + { name: 'Dockerfile' }, + { name: 'package.json' }, + { name: 'tsconfig.json' }, +]; + +// ─── Skeleton ───────────────────────────────────────────────────────────────── + +export const Skeleton: StoryObj<{ loading: boolean }> = { + render: () => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [loading, setLoading] = useState(true); + // eslint-disable-next-line react-hooks/rules-of-hooks + const [value, setValue] = useState([]); + + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + const id = setTimeout(() => setLoading(false), 2000); + return () => clearTimeout(id); + }, []); + + return ( +
+ {loading ? ( + + ) : ( + + )} +
+ ); + }, + parameters: { + docs: { + description: { + story: + 'Shows a skeleton placeholder while file tree data is loading, then transitions to the real `FilePicker` after 2 seconds. Reload the story to replay the transition.', + }, + }, + }, +}; + +// ─── Default ────────────────────────────────────────────────────────────────── + +export const Default: Story = { + args: { + files: REPO_NODES, + exampleExpressions: ['*.yml', '*.yaml', '*.ts', 'src/**', 'deploy/**'], + }, + render: (args) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [value, setValue] = useState([]); + return ( +
+ + +
+ ); + }, + parameters: { + docs: { + description: { + story: + 'Default state — header shows total file count, suggested pattern chips for one-click expression adding, and an empty tree with all folders collapsed. Click a pattern chip (e.g. `*.yml`) to add it as an expression immediately.', + }, + source: { + code: ``, + }, + }, + }, +}; diff --git a/app/react/components/form-components/FilePicker/FilePicker.test.tsx b/app/react/components/form-components/FilePicker/FilePicker.test.tsx new file mode 100644 index 0000000000..3c539a2488 --- /dev/null +++ b/app/react/components/form-components/FilePicker/FilePicker.test.tsx @@ -0,0 +1,105 @@ +import { useState } from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { FilePicker } from './FilePicker'; +import { FileNode } from './types'; + +const flatFiles: FileNode[] = [ + { name: 'foo.yml' }, + { name: 'bar.ts' }, + { name: 'baz.json' }, +]; + +function renderFilePicker({ + files = flatFiles, + initialValue = [], + examplePatterns = ['*.yml', 'src/**'], +}: { + files?: FileNode[]; + initialValue?: string[]; + examplePatterns?: string[]; +} = {}) { + const user = userEvent.setup(); + const onChange = vi.fn(); + + function Wrapper() { + const [value, setValue] = useState(initialValue); + return ( + { + setValue(paths); + onChange(paths); + }} + exampleExpressions={examplePatterns} + /> + ); + } + + render(); + return { user, onChange }; +} + +describe('FilePicker', () => { + it('calls onChange with slash-prefixed sorted paths when files are selected', async () => { + const { user, onChange } = renderFilePicker({ + files: [{ name: 'foo.yml' }, { name: 'bar.ts' }], + }); + const checkboxes = screen.getAllByRole('checkbox'); + await user.click(checkboxes[0]); // foo.yml + await user.click(checkboxes[1]); // bar.ts + expect(onChange).toHaveBeenLastCalledWith(['/bar.ts', '/foo.yml']); + }); + + it('sets the search filter when an example pattern button is clicked', async () => { + const { user } = renderFilePicker({ examplePatterns: ['*.yml'] }); + await user.click(screen.getByRole('button', { name: '*.yml' })); + expect(screen.getByRole('textbox')).toHaveValue('*.yml'); + }); + + it('counts expression-matched files as selected', async () => { + const { user } = renderFilePicker({ + files: [{ name: 'foo.yml' }, { name: 'bar.ts' }], + }); + const filterInput = screen.getByRole('textbox'); + await user.click(filterInput); + await user.type(filterInput, '*.yml'); + await user.keyboard('{Enter}'); + // *.yml matches foo.yml only → 1 selected + expect(screen.getByText('1 selected')).toBeVisible(); + }); + + it('selecting a directory checkbox selects all its descendant files', async () => { + const { user, onChange } = renderFilePicker({ + files: [{ name: 'src', children: [{ name: 'a.ts' }, { name: 'b.ts' }] }], + }); + // Only the directory checkbox is visible (not expanded) + await user.click(screen.getByRole('checkbox')); + expect(onChange).toHaveBeenCalledWith(['/src/a.ts', '/src/b.ts']); + }); + + it('deselects all directory files when a fully-checked directory is clicked again', async () => { + const { user, onChange } = renderFilePicker({ + files: [{ name: 'src', children: [{ name: 'a.ts' }] }], + }); + const checkbox = screen.getByRole('checkbox'); + await user.click(checkbox); // select all + await user.click(checkbox); // deselect all + expect(onChange).toHaveBeenLastCalledWith([]); + }); + + it('clears all selected files when Clear is clicked', async () => { + const { user } = renderFilePicker({ + files: [{ name: 'foo.yml' }], + }); + const filterInput = screen.getByRole('textbox'); + await user.click(filterInput); + await user.type(filterInput, '*.yml'); + await user.keyboard('{Enter}'); + expect(screen.getByText('1 selected')).toBeVisible(); + await user.click(screen.getByRole('button', { name: /clear/i })); + expect(screen.getByText('0 selected')).toBeVisible(); + }); +}); diff --git a/app/react/components/form-components/FilePicker/FilePicker.tsx b/app/react/components/form-components/FilePicker/FilePicker.tsx new file mode 100644 index 0000000000..00b5ccda80 --- /dev/null +++ b/app/react/components/form-components/FilePicker/FilePicker.tsx @@ -0,0 +1,206 @@ +import { useMemo, useRef, useState, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; + +import { pluralize } from '@/react/common/string-utils'; + +import { Button } from '@@/buttons/Button'; +import { Directory, File, FileNode } from '@@/form-components/FilePicker/types'; +import { CommandPalette } from '@@/CommandPalette/CommandPalette'; +import { globToRegex } from '@@/CommandPalette/utils'; +import { Widget } from '@@/Widget'; + +import { isDirectory, getAllFilePaths, getFolderState } from './utils'; +import { TreeNode } from './TreeNode'; +import { SelectedPanel } from './SelectedPanel'; + +interface Props { + files: FileNode[]; + /* array of file paths that have been selected */ + value: string[]; + onChange: (filePaths: string[]) => void; + exampleExpressions?: string[]; +} + +export function FilePicker({ + files, + value, + onChange, + exampleExpressions = ['*.yml', 'src/**', '**/dist/*'], +}: Props) { + const [expanded, setExpanded] = useState>(() => new Set([])); + const selected = useMemo( + () => new Set(value.map((p) => (p.startsWith('/') ? p.slice(1) : p))), + [value] + ); + const [filter, setFilter] = useState(''); + const treeAreaRef = useRef(null); + const commandPaletteRef = useRef(null); + + const allFilePaths = useMemo( + () => files.flatMap((file) => getAllFilePaths(file, file.name)), + [files] + ); + + return ( + +
+

+ Browse files, select individually, or use wildcard expressions. + Contains {allFilePaths.length}{' '} + {pluralize(allFilePaths.length, 'file')}. Try patterns like: +

+
+ {exampleExpressions.map((pattern, index) => { + return ( + + ); + })} +
+
+ +
+ +
+ +
+ {allFilePaths.length === 0 && ( +

+ No files available +

+ )} + {!filter.trim() && ( + <> +
+ {files.map((file) => ( + + ))} +
+ +
+ +
+ + )} +
+ +
+ + {selected.size} selected + + +
+
+ ); + + function renderDropdown(paths: string[]): ReactNode { + if (!treeAreaRef.current) return null; + return createPortal( + paths.length === 0 ? ( +

+ No matching files +

+ ) : ( +
    + {paths.map((path) => ( +
  • + + /{path} + +
  • + ))} +
+ ), + treeAreaRef.current + ); + } + + function handleChange(next: Set) { + onChange( + Array.from(next) + .map((f) => `/${f}`) + .sort() + ); + } + + function toggleDirectory(path: string) { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + } + + function toggleSelect(path: string, item: Directory | File) { + if (!isDirectory(item)) { + const next = new Set(selected); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + handleChange(next); + return; + } + + const filePaths = getAllFilePaths(item, path); + const state = getFolderState(item, selected, path); + const next = new Set(selected); + if (state === 'checked') { + filePaths.forEach((p) => next.delete(p)); + } else { + filePaths.forEach((p) => next.add(p)); + } + handleChange(next); + } + + function removeFile(path: string) { + const next = new Set(selected); + next.delete(path); + handleChange(next); + } + + function addExpression(pattern: string) { + const re = globToRegex(pattern); + const matchedPaths = allFilePaths.filter((p) => re.test(p)); + handleChange(new Set([...selected, ...matchedPaths])); + } +} diff --git a/app/react/components/form-components/FilePicker/FilePickerSkeleton.tsx b/app/react/components/form-components/FilePicker/FilePickerSkeleton.tsx new file mode 100644 index 0000000000..43a1215370 --- /dev/null +++ b/app/react/components/form-components/FilePicker/FilePickerSkeleton.tsx @@ -0,0 +1,50 @@ +export function FilePickerSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+
+ {Array.from({ length: 8 }).map((_, i) => ( +
+
+
+
+ ))} +
+ +
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ ); +} diff --git a/app/react/components/form-components/FilePicker/SelectedPanel.test.tsx b/app/react/components/form-components/FilePicker/SelectedPanel.test.tsx new file mode 100644 index 0000000000..090aae50a7 --- /dev/null +++ b/app/react/components/form-components/FilePicker/SelectedPanel.test.tsx @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { SelectedPanel } from './SelectedPanel'; + +function renderSelectedPanel({ + selected = new Set(), + removeFile = vi.fn(), +}: { + selected?: Set; + removeFile?: (path: string) => void; +} = {}) { + const user = userEvent.setup(); + render(); + return { user, removeFile }; +} + +describe('SelectedPanel', () => { + it('calls removeFile with the correct path when the remove button is clicked', async () => { + const removeFile = vi.fn(); + const { user } = renderSelectedPanel({ + selected: new Set(['src/foo.ts']), + removeFile, + }); + await user.click( + screen.getByRole('button', { name: /remove file \/src\/foo\.ts/i }) + ); + expect(removeFile).toHaveBeenCalledWith('src/foo.ts'); + }); + + it('shows "No files selected" when nothing is selected', () => { + renderSelectedPanel(); + expect(screen.getByText(/no files selected/i)).toBeVisible(); + }); + + it('renders all selected file paths', () => { + renderSelectedPanel({ + selected: new Set(['lib/index.ts', 'src/foo.ts']), + }); + expect(screen.getByTitle('/lib/index.ts')).toBeVisible(); + expect(screen.getByTitle('/src/foo.ts')).toBeVisible(); + }); +}); diff --git a/app/react/components/form-components/FilePicker/SelectedPanel.tsx b/app/react/components/form-components/FilePicker/SelectedPanel.tsx new file mode 100644 index 0000000000..68af273b32 --- /dev/null +++ b/app/react/components/form-components/FilePicker/SelectedPanel.tsx @@ -0,0 +1,62 @@ +import { X } from 'lucide-react'; + +import { pluralize } from '@/react/common/string-utils'; + +import { Badge } from '@@/Badge'; + +interface Props { + selected: Set; + removeFile: (path: string) => void; +} + +export function SelectedPanel({ selected, removeFile }: Props) { + const selectedPaths = Array.from(selected).sort(); + + return ( + <> +
+ {selected.size} Selected {pluralize(selected.size, 'File')} +
+
+ {selectedPaths.length === 0 ? ( +

+ No files selected +

+ ) : ( + selectedPaths.map((path) => ( + removeFile(path)} /> + )) + )} +
+ + ); +} + +interface FileRowProps { + path: string; + onRemove: () => void; +} + +function FileRow({ path, onRemove }: FileRowProps) { + return ( +
+ + File + + + /{path} + + +
+ ); +} diff --git a/app/react/components/form-components/FilePicker/TreeNode.tsx b/app/react/components/form-components/FilePicker/TreeNode.tsx new file mode 100644 index 0000000000..19bef78dab --- /dev/null +++ b/app/react/components/form-components/FilePicker/TreeNode.tsx @@ -0,0 +1,115 @@ +import { useRef } from 'react'; +import { slugify } from 'markdown-to-jsx'; + +import { CollapseExpandButton } from '@@/CollapseExpandButton'; +import { Checkbox } from '@@/form-components/Checkbox'; + +import { isDirectory, getFolderState, isDirectoryWithChildren } from './utils'; +import { TreeNodeIcon } from './TreeNodeIcon'; +import { FileNode } from './types'; + +const INDENT_PX = 16; +const DOUBLE_CLICK_DELAY_MS = 250; + +interface Props { + item: FileNode; + depth: number; + expanded: Set; + selected: Set; + nodePath: string; + onExpandDirectory: (path: string) => void; + onToggleSelect: (path: string, item: FileNode) => void; +} + +export function TreeNode({ + item, + depth, + expanded, + selected, + nodePath, + onExpandDirectory, + onToggleSelect, +}: Props) { + const isDir = isDirectory(item); + const hasSubDir = isDirectoryWithChildren(item); + const isOpen = isDir && expanded.has(nodePath); + const folderState = isDir ? getFolderState(item, selected, nodePath) : null; + const isChecked = isDir ? folderState === 'checked' : selected.has(nodePath); + const isIndeterminate = folderState === 'indeterminate'; + + const checkboxId = slugify(`${nodePath}/${item.name}`); + const clickTimerRef = useRef | null>(null); + + function handleRowClick() { + if (clickTimerRef.current) { + clearTimeout(clickTimerRef.current); + clickTimerRef.current = null; + if (isDir) { + onExpandDirectory(nodePath); + } + return; + } + clickTimerRef.current = setTimeout(() => { + clickTimerRef.current = null; + onToggleSelect(nodePath, item); + }, DOUBLE_CLICK_DELAY_MS); + } + + return ( + <> +
{ + if (e.key === 'Enter' && isDir) onExpandDirectory(nodePath); + if (e.key === 'Space') onToggleSelect(nodePath, item); + }} + > + {isDir ? ( + onExpandDirectory(nodePath)} + /> + ) : ( + + )} + onToggleSelect(nodePath, item)} + onClick={(e) => e.stopPropagation()} + aria-labelledby={`${checkboxId}-label`} + /> + + + {item.name} + +
+ + {isOpen && + hasSubDir && + item.children.map((child, index) => ( + + ))} + + ); +} diff --git a/app/react/components/form-components/FilePicker/TreeNodeIcon.tsx b/app/react/components/form-components/FilePicker/TreeNodeIcon.tsx new file mode 100644 index 0000000000..d3210697cc --- /dev/null +++ b/app/react/components/form-components/FilePicker/TreeNodeIcon.tsx @@ -0,0 +1,23 @@ +import { File as FileIcon, Folder } from 'lucide-react'; + +import { isDirectory } from '@@/form-components/FilePicker/utils'; + +import { Directory, File } from './types'; + +interface Props { + node: Directory | File; +} + +export function TreeNodeIcon({ node }: Props) { + return isDirectory(node) ? ( + + ) : ( + + ); +} diff --git a/app/react/components/form-components/FilePicker/types.ts b/app/react/components/form-components/FilePicker/types.ts new file mode 100644 index 0000000000..c89225f339 --- /dev/null +++ b/app/react/components/form-components/FilePicker/types.ts @@ -0,0 +1,10 @@ +export type FileNode = Directory | File; + +export interface File { + name: string; +} + +export interface Directory { + name: string; + children?: FileNode[]; +} diff --git a/app/react/components/form-components/FilePicker/utils.test.ts b/app/react/components/form-components/FilePicker/utils.test.ts new file mode 100644 index 0000000000..eb85c4ad46 --- /dev/null +++ b/app/react/components/form-components/FilePicker/utils.test.ts @@ -0,0 +1,121 @@ +import { Directory, File, FileNode } from './types'; +import { + isDirectory, + getAllFilePaths, + getFolderState, + filterToPattern, +} from './utils'; + +function file(name: string): File { + return { name }; +} + +function dir(name: string, children: FileNode[] = []): Directory { + return { name, children }; +} + +describe('isDirectory', () => { + it('returns true when the item has a children property', () => { + expect(isDirectory(dir('src'))).toBe(true); + }); + + it('returns true when the children property is present but undefined', () => { + const dirWithUndefinedChildren: Directory = { + name: 'empty', + children: undefined, + }; + expect(isDirectory(dirWithUndefinedChildren)).toBe(true); + }); + + it('returns false for a file without children', () => { + expect(isDirectory(file('foo.ts'))).toBe(false); + }); +}); + +describe('getAllFilePaths', () => { + it('returns the node path for a single file', () => { + expect(getAllFilePaths(file('foo.ts'), 'foo.ts')).toEqual(['foo.ts']); + }); + + it('returns all child file paths for a flat directory', () => { + const node = dir('src', [file('a.ts'), file('b.ts')]); + expect(getAllFilePaths(node, 'src')).toEqual(['src/a.ts', 'src/b.ts']); + }); + + it('returns an empty array for an empty directory', () => { + expect(getAllFilePaths(dir('empty'), 'empty')).toEqual([]); + }); + + it('returns deeply nested file paths', () => { + const node = dir('root', [dir('sub', [file('deep.ts')])]); + expect(getAllFilePaths(node, 'root')).toEqual(['root/sub/deep.ts']); + }); + + it('returns all paths across multiple nested levels', () => { + const node = dir('a', [ + file('top.ts'), + dir('b', [file('nested.ts'), dir('c', [file('deep.ts')])]), + ]); + expect(getAllFilePaths(node, 'a').sort()).toEqual([ + 'a/b/c/deep.ts', + 'a/b/nested.ts', + 'a/top.ts', + ]); + }); +}); + +describe('getFolderState', () => { + const tree = dir('src', [file('a.ts'), file('b.ts')]); + + it('returns unchecked when no files are selected', () => { + expect(getFolderState(tree, new Set(), 'src')).toBe('unchecked'); + }); + + it('returns checked when all files in the directory are selected', () => { + expect(getFolderState(tree, new Set(['src/a.ts', 'src/b.ts']), 'src')).toBe( + 'checked' + ); + }); + + it('returns indeterminate when some but not all files are selected', () => { + expect(getFolderState(tree, new Set(['src/a.ts']), 'src')).toBe( + 'indeterminate' + ); + }); + + it('returns unchecked for an empty directory', () => { + expect(getFolderState(dir('empty'), new Set(), 'empty')).toBe('unchecked'); + }); + + it('ignores selected paths outside the directory', () => { + expect(getFolderState(tree, new Set(['other/file.ts']), 'src')).toBe( + 'unchecked' + ); + }); +}); + +describe('filterToPattern', () => { + it('wraps plain text with wildcards', () => { + expect(filterToPattern('foo')).toBe('*foo*'); + }); + + it('leaves a glob pattern with * unchanged', () => { + expect(filterToPattern('*.yml')).toBe('*.yml'); + }); + + it('leaves a glob pattern with ? unchanged', () => { + expect(filterToPattern('file?.ts')).toBe('file?.ts'); + }); + + it('trims whitespace before wrapping plain text', () => { + expect(filterToPattern(' foo ')).toBe('*foo*'); + }); + + it('trims whitespace before preserving a glob', () => { + expect(filterToPattern(' *.yml ')).toBe('*.yml'); + }); + + it('handles empty string after trimming', () => { + expect(filterToPattern(' ')).toBe('**'); + }); +}); diff --git a/app/react/components/form-components/FilePicker/utils.ts b/app/react/components/form-components/FilePicker/utils.ts new file mode 100644 index 0000000000..daa73c6951 --- /dev/null +++ b/app/react/components/form-components/FilePicker/utils.ts @@ -0,0 +1,39 @@ +import { Directory, FileNode } from './types'; + +export function isDirectory(item: FileNode): item is Directory { + return 'children' in item; +} + +export function isDirectoryWithChildren( + item: FileNode +): item is Required { + return isDirectory(item) && 'children' in item; +} + +export function getAllFilePaths(item: FileNode, nodePath: string): string[] { + if (!isDirectory(item)) { + return [nodePath]; + } + return (item.children ?? []).flatMap((child) => + getAllFilePaths(child, `${nodePath}/${child.name}`) + ); +} + +export function getFolderState( + item: Directory, + selected: Set, + nodePath: string +): 'checked' | 'indeterminate' | 'unchecked' { + const filePaths = getAllFilePaths(item, nodePath); + if (filePaths.length === 0) return 'unchecked'; + const selectedCount = filePaths.filter((p) => selected.has(p)).length; + if (selectedCount === filePaths.length) return 'checked'; + if (selectedCount > 0) return 'indeterminate'; + return 'unchecked'; +} + +export function filterToPattern(text: string): string { + const t = text.trim(); + if (/[*?]/.test(t)) return t; + return `*${t}*`; +}