feat(web): drop a folder onto the analyzer to upload it (#3315)

* feat(web): drop a folder onto the analyzer to upload it

The Local Folder panel of RepoAnalyzer is now a drop target. A dropped
folder is walked with the File and Directory Entries API
(DataTransferItem.webkitGetAsEntry), directories on the shared exclusion
list are pruned before they are read, and the resulting File objects are
handed to the existing filterRepoFiles -> uploadFolder -> trackJob path
with webkitRelativePath set to <folder>/<rest>, so the server receives the
same manifest shape the webkitdirectory picker produces.

Compared with the picker, the walk never enumerates node_modules or .git,
stops at the server's 20000 file cap and 64 path segments, skips
unreadable entries instead of failing, and reports progress while it runs.
Loose files and several folders at once are refused with a message (the
server accepts one top-level folder). The walk runs under the request
controller, so a mode switch or unmount aborts it; Analyze and the picker
are blocked while it runs. The panel-wide target also stops the browser
from navigating to a file dropped a few pixels off the button.

New strings in en and zh-CN; browsers without webkitGetAsEntry keep the
picker button and get an explanation.

* Address PR review feedback (#3315)

Clear readingCount only when this drop still owns the request controller, and skip oversized files before they count toward the 20k drop cap.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3315)

Count oversized files the drop walk skips in the summary droppedCount, and correct the 250-file batch-read comment.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Ahmad Othman Ammar Adi 2026-09-20 11:12:54 +02:00 • committed by GitHub
parent ee2feb7a5b
commit 5d32e345bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1035 additions and 23 deletions

View file

@ -3,10 +3,11 @@
*
* Two input modes:
* - "github" → GitHub URL (https://github.com/owner/repo)
* - "local" → Select a local folder via the browser's native directory picker
* - "local" → Select a local folder via the browser's native directory picker,
* or drop one onto the upload control
*/
import { useState, useRef, useEffect, useId } from 'react';
import { useState, useRef, useEffect, useId, type DragEvent as ReactDragEvent } from 'react';
import {
Github,
Gitlab,
@ -27,7 +28,14 @@ import {
type JobProgress,
} from '../services/backend-client';
import { AnalyzeProgress } from './AnalyzeProgress';
import { filterRepoFiles } from '@/lib/upload-filter';
import { filterRepoFiles, type FilterResult } from '@/lib/upload-filter';
import {
collectDropEntries,
isFolderDropSupported,
readDroppedFolder,
DropRejection,
type DropRejectionReason,
} from '@/lib/folder-drop';
import { useTranslation } from 'react-i18next';
import { formatBackendError } from '../i18n/error-messages';
@ -55,6 +63,23 @@ function isValidAzureUrl(value: string): boolean {
return AZURE_RE.test(value.trim());
}
/** i18n key (under onboarding:repoAnalyzer.upload) for a refused folder drop. */
function dropRejectionKey(reason: DropRejectionReason): string {
switch (reason) {
case 'unsupported':
return 'dropUnsupported';
case 'tooManyFiles':
return 'dropTooManyFiles';
case 'notSingleFolder':
return 'dropSingleFolder';
}
}
/** True for a drag that carries files or folders from the OS, not text or links. */
function isFileDrag(e: ReactDragEvent): boolean {
return Array.from(e.dataTransfer.types).includes('Files');
}
// ── Mode tabs ────────────────────────────────────────────────────────────────
function ModeTabs({ mode, onChange }: { mode: InputMode; onChange: (m: InputMode) => void }) {
@ -198,9 +223,16 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
const inputId = useId();
const [mode, setMode] = useState<InputMode>('github');
const [uploading, setUploading] = useState(false);
const [uploadSummary, setUploadSummary] = useState<{ count: number; dropped: number } | null>(
null,
);
// Files found so far while walking a dropped folder; null when not reading.
const [readingCount, setReadingCount] = useState<number | null>(null);
const [dragActive, setDragActive] = useState(false);
// `skippedDirs` is set only for a dropped folder: directories the walk left
// out without enumerating them (a directory count, unlike `dropped`).
const [uploadSummary, setUploadSummary] = useState<{
count: number;
dropped: number;
skippedDirs?: number;
} | null>(null);
const [githubUrl, setGithubUrl] = useState('');
const [githubToken, setGithubToken] = useState('');
const [gitlabUrl, setGitlabUrl] = useState('');
@ -224,6 +256,9 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
const requestControllerRef = useRef<AbortController | null>(null);
const completeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const folderInputRef = useRef<HTMLInputElement>(null);
// dragenter/dragleave fire for every child boundary crossed; count them so
// the highlight does not flicker while the cursor moves over the button.
const dragDepthRef = useRef(0);
useEffect(() => {
return () => {
@ -277,6 +312,10 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
setValidationError(null);
setUploadSummary(null);
setUploading(false);
// The aborted controller also stops a folder walk that is still running.
setReadingCount(null);
setDragActive(false);
dragDepthRef.current = 0;
// An aborted request no longer resolves to move `phase` off 'starting';
// reset so the new mode's form is immediately usable (also clears a stale
// 'error' phase). Only reachable while showInput is true.
@ -287,14 +326,17 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
// exposes an absolute path, so the old typed-path/browse approach couldn't
// work — see handleFolderUpload). A typed server path is also still accepted.
// A folder walk in progress (readingCount) blocks Analyze as well: starting a
// request would abort the walk through renewRequestController.
const canSubmit =
mode === 'github'
readingCount === null &&
(mode === 'github'
? isValidGithubUrl(githubUrl) && (phase === 'input' || phase === 'error')
: mode === 'gitlab'
? isValidGitlabUrl(gitlabUrl) && (phase === 'input' || phase === 'error')
: mode === 'azure'
? isValidAzureUrl(azureUrl) && (phase === 'input' || phase === 'error')
: localPath.trim().length > 1 && (phase === 'input' || phase === 'error');
: localPath.trim().length > 1 && (phase === 'input' || phase === 'error'));
const handleAnalyze = async () => {
if (mode === 'github' && !isValidGithubUrl(githubUrl)) {
@ -398,20 +440,33 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
// Upload a browser-selected folder (webkitdirectory) and start analysis. The
// upload endpoint returns a jobId, which then joins the normal SSE flow.
const handleFolderUpload = async (fileList: FileList) => {
if (uploading || isLoading) return; // guard against a concurrent upload
const { files, manifest, droppedCount } = filterRepoFiles(fileList);
// guard against a concurrent upload or a folder walk still running
if (uploading || isLoading || readingCount !== null) return;
await startFolderUpload(filterRepoFiles(fileList), renewRequestController());
};
// Shared tail of the picker and drop paths: `filtered` is the client-side
// filter result, `controller` owns the request, `skippedDirs` is set for a
// drop and counts the directories the walk left out before reading them
// (the picker enumerates everything and lets filterRepoFiles drop the files
// instead, so its count arrives inside `filtered.droppedCount`).
const startFolderUpload = async (
filtered: FilterResult,
controller: AbortController,
skippedDirs?: number,
) => {
const { files, manifest, droppedCount } = filtered;
if (files.length === 0) {
setValidationError(t('onboarding:repoAnalyzer.upload.empty'));
return;
}
setValidationError(null);
setUploadSummary({ count: files.length, dropped: droppedCount });
setUploadSummary({ count: files.length, dropped: droppedCount, skippedDirs });
setUploading(true);
setPhase('starting');
// The selected folder's name (manifest entries are `<folder>/<rest>`) is a
// sensible fallback if the server's complete event omits repoName.
const folderName = manifest[0]?.split('/')[0] ?? null;
const controller = renewRequestController();
try {
const { jobId } = await uploadFolder(files, manifest, controller.signal);
if (controller.signal.aborted) {
@ -436,6 +491,76 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
}
};
// Drag and drop a folder onto the upload control. The entries have to be
// taken from dataTransfer synchronously inside the drop handler (browsers
// empty `items` once the handler yields). Only a single folder is accepted,
// mirroring the server's one-top-level-directory rule, and the walk runs
// under the same request controller as the upload so a mode switch or an
// unmount aborts both.
const isDropBlocked = () => uploading || phase === 'starting' || readingCount !== null;
const handleDragEnter = (e: ReactDragEvent<HTMLDivElement>) => {
if (!isFileDrag(e)) return;
e.preventDefault();
if (isDropBlocked()) return;
dragDepthRef.current += 1;
setDragActive(true);
};
const handleDragOver = (e: ReactDragEvent<HTMLDivElement>) => {
if (!isFileDrag(e)) return;
// Without preventDefault the drop never fires and the browser navigates
// to the dropped file instead, so it is called even while blocked.
e.preventDefault();
e.dataTransfer.dropEffect = isDropBlocked() ? 'none' : 'copy';
};
const handleDragLeave = () => {
// No type check here: some engines hand dragleave an empty `types` list,
// and a stray decrement is harmless because the depth is clamped at 0.
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) setDragActive(false);
};
const handleDrop = async (e: ReactDragEvent<HTMLDivElement>) => {
if (!isFileDrag(e)) return;
e.preventDefault();
dragDepthRef.current = 0;
setDragActive(false);
if (isDropBlocked()) return;
const controller = renewRequestController();
setValidationError(null);
setUploadSummary(null);
setReadingCount(0);
try {
const entries = collectDropEntries(e.dataTransfer); // sync, before any await
const { files, skipped, oversized } = await readDroppedFolder(entries, {
signal: controller.signal,
onProgress: setReadingCount,
});
// Only the walk that still owns the request controller may clear the
// reading mutex. An aborted drop that settles after a later drop started
// must not steal the live walk's lock (readingCount === null is what
// unblocks Analyze and a second drop).
if (requestControllerRef.current === controller) setReadingCount(null);
if (controller.signal.aborted) return;
const filtered = filterRepoFiles(files);
await startFolderUpload(
{ ...filtered, droppedCount: filtered.droppedCount + oversized },
controller,
skipped,
);
} catch (err) {
if (requestControllerRef.current === controller) setReadingCount(null);
if (controller.signal.aborted) return;
setValidationError(
err instanceof DropRejection
? t(`onboarding:repoAnalyzer.upload.${dropRejectionKey(err.reason)}`, { max: err.max })
: formatBackendError(err, t),
);
}
};
const handleCancel = async () => {
sseControllerRef.current?.abort();
sseControllerRef.current = null;
@ -453,6 +578,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
setPhase('input');
setProgress({ phase: 'queued', percent: 0, message: t('common:analyzePhases.queued') });
setUploading(false);
setReadingCount(null);
setUploadSummary(null);
};
@ -658,9 +784,19 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
</div>
)}
{/* Local folder input */}
{/* Local folder input. The whole panel is the drop target for a folder:
a drop that lands on the path input or the label is caught too, and
the browser's default (navigating to the dropped file) never fires. */}
{showInput && mode === 'local' && (
<div className="space-y-2">
<div
className="space-y-2"
data-testid="folder-drop-zone"
data-drag-active={dragActive || undefined}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<label
htmlFor={`${inputId}-local`}
className="block text-xs font-medium tracking-wider text-text-secondary uppercase"
@ -702,7 +838,9 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
)}
</div>
{/* Upload a folder from your computer — no server path or mount needed.
The browser can't expose an absolute path, so we upload the files. */}
The browser can't expose an absolute path, so we upload the files.
Dropping a folder onto this panel walks it with webkitGetAsEntry and
feeds the same upload path. */}
<input
ref={folderInputRef}
type="file"
@ -722,12 +860,35 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
type="button"
data-testid="upload-folder"
onClick={() => folderInputRef.current?.click()}
disabled={isLoading}
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg border border-border-subtle bg-elevated px-3 py-2 text-xs font-medium text-text-secondary transition-all duration-150 hover:bg-hover hover:text-text-primary disabled:opacity-50"
disabled={isLoading || readingCount !== null}
className={`flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg border bg-elevated px-3 py-2 text-xs font-medium transition-all duration-150 hover:bg-hover hover:text-text-primary disabled:opacity-50 ${
dragActive
? 'border-accent/50 text-text-primary shadow-[0_0_0_3px_rgba(124,58,237,0.08)]'
: 'border-border-subtle text-text-secondary'
}`}
>
<FolderOpen className="h-3.5 w-3.5" />
{t('onboarding:repoAnalyzer.upload.button')}
</button>
{isFolderDropSupported() && !isDropBlocked() && (
<p className="text-center text-[11px] text-text-muted" data-testid="drop-hint">
{t(
dragActive
? 'onboarding:repoAnalyzer.upload.dropActive'
: 'onboarding:repoAnalyzer.upload.dropHint',
)}
</p>
)}
{readingCount !== null && (
<div role="status" aria-busy="true" data-testid="drop-reading" className="space-y-1">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-elevated">
<div className="h-full w-1/3 animate-pulse rounded-full bg-accent" />
</div>
<p className="text-xs text-text-muted">
{t('onboarding:repoAnalyzer.upload.reading', { fileCount: readingCount })}
</p>
</div>
)}
{uploading && (
<div role="status" aria-busy="true" data-testid="upload-progress" className="space-y-1">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-elevated">
@ -740,10 +901,16 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
)}
{uploadSummary && !uploading && phase !== 'error' && (
<p className="text-xs text-text-muted" data-testid="upload-summary">
{t('onboarding:repoAnalyzer.upload.selected', {
fileCount: uploadSummary.count,
dropped: uploadSummary.dropped,
})}
{uploadSummary.skippedDirs === undefined
? t('onboarding:repoAnalyzer.upload.selected', {
fileCount: uploadSummary.count,
dropped: uploadSummary.dropped,
})
: t('onboarding:repoAnalyzer.upload.selectedDrop', {
fileCount: uploadSummary.count,
dropped: uploadSummary.dropped,
skippedDirs: uploadSummary.skippedDirs,
})}
</p>
)}
</div>

View file

@ -0,0 +1,283 @@
import { describe, expect, it, vi } from 'vitest';
import {
collectDropEntries,
readDroppedFolder,
DropRejection,
MAX_DROP_FILES,
MAX_PATH_SEGMENTS,
} from './folder-drop';
import { filterRepoFiles, MAX_FILE_BYTES } from './upload-filter';
// jsdom has no File and Directory Entries API, so the fixtures are plain
// objects with the members the walk uses: isFile/isDirectory/name,
// createReader().readEntries(ok, err) and file(ok, err).
function fileEntry(name: string, size = 1): FileSystemFileEntry {
return {
isFile: true,
isDirectory: false,
name,
fullPath: `/browser-root/${name}`,
file: (ok: (f: File) => void) => ok(new File([new Uint8Array(size)], name)),
} as unknown as FileSystemFileEntry;
}
type DirFixture = FileSystemDirectoryEntry & { reads: number; readerCreated: number };
/** Directory whose reader hands `children` out in `batchSize` chunks, then `[]`. */
function dirEntry(name: string, children: FileSystemEntry[], batchSize = 100): DirFixture {
const dir = {
isFile: false,
isDirectory: true,
name,
fullPath: `/browser-root/${name}`,
reads: 0,
readerCreated: 0,
createReader: () => {
dir.readerCreated++;
let i = 0;
return {
readEntries: (ok: (entries: FileSystemEntry[]) => void) => {
dir.reads++;
const batch = children.slice(i, i + batchSize);
i += batch.length;
ok(batch);
},
};
},
};
return dir as unknown as DirFixture;
}
/** Directory whose reader fails, like a subtree the browser may not read. */
function unreadableDir(name: string): FileSystemDirectoryEntry {
return {
isFile: false,
isDirectory: true,
name,
fullPath: `/browser-root/${name}`,
createReader: () => ({
readEntries: (_ok: unknown, err: (e: Error) => void) => err(new Error('EACCES')),
}),
} as unknown as FileSystemDirectoryEntry;
}
function dataTransfer(entries: (FileSystemEntry | null)[], supported = true): DataTransfer {
const items = entries.map((e) => (supported ? { webkitGetAsEntry: () => e } : {}));
return { items, types: ['Files'], files: [] } as unknown as DataTransfer;
}
const paths = (files: File[]) => files.map((f) => f.webkitRelativePath);
describe('collectDropEntries', () => {
it('returns the entries synchronously and skips null (non-file) items', () => {
const dir = dirEntry('repo', []);
const entries = collectDropEntries(dataTransfer([dir, null]));
expect(entries).toEqual([dir]);
});
it('returns nothing for an empty transfer', () => {
expect(collectDropEntries({ items: [] } as unknown as DataTransfer)).toEqual([]);
});
it('rejects a browser without webkitGetAsEntry', () => {
let caught: unknown;
try {
collectDropEntries(dataTransfer([dirEntry('repo', [])], false));
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(DropRejection);
expect((caught as DropRejection).reason).toBe('unsupported');
});
});
describe('readDroppedFolder', () => {
it('builds <folder>/<rest> paths with forward slashes from entry names', async () => {
const root = dirEntry('repo', [
fileEntry('README.md'),
dirEntry('src', [fileEntry('a.ts'), dirEntry('deep', [fileEntry('b.ts')])]),
]);
const { files, skipped } = await readDroppedFolder([root]);
expect(paths(files)).toEqual(['repo/README.md', 'repo/src/a.ts', 'repo/src/deep/b.ts']);
expect(files.every((f) => f instanceof File)).toBe(true);
expect(skipped).toBe(0);
});
it('loops readEntries until an empty batch', async () => {
const children = Array.from({ length: 250 }, (_, i) => fileEntry(`f${i}.ts`));
const root = dirEntry('repo', children, 100);
const { files } = await readDroppedFolder([root]);
expect(files).toHaveLength(250);
// two full 100-entry batches, one 50-entry batch, then the empty read
expect(root.reads).toBe(4);
});
it('prunes excluded directories before reading them and counts them', async () => {
const nodeModules = dirEntry('node_modules', [fileEntry('x.js')]);
const git = dirEntry('.git', [fileEntry('HEAD')]);
const root = dirEntry('repo', [nodeModules, git, dirEntry('src', [fileEntry('a.ts')])]);
const { files, skipped } = await readDroppedFolder([root]);
expect(paths(files)).toEqual(['repo/src/a.ts']);
expect(skipped).toBe(2);
expect(nodeModules.readerCreated).toBe(0);
expect(git.readerCreated).toBe(0);
});
it('rejects a loose file', async () => {
await expect(readDroppedFolder([fileEntry('a.ts')])).rejects.toMatchObject({
name: 'DropRejection',
reason: 'notSingleFolder',
});
});
it('rejects several roots', async () => {
await expect(
readDroppedFolder([dirEntry('a', [fileEntry('x')]), dirEntry('b', [fileEntry('y')])]),
).rejects.toMatchObject({ reason: 'notSingleFolder' });
});
it('rejects an empty drop', async () => {
await expect(readDroppedFolder([])).rejects.toMatchObject({ reason: 'notSingleFolder' });
});
it('stops past MAX_DROP_FILES', async () => {
const children = Array.from({ length: MAX_DROP_FILES + 1 }, (_, i) => fileEntry(`f${i}`));
await expect(readDroppedFolder([dirEntry('repo', children)])).rejects.toMatchObject({
reason: 'tooManyFiles',
max: MAX_DROP_FILES,
});
});
it('does not count oversized files toward MAX_DROP_FILES', async () => {
const oversized = fileEntry('big.bin', MAX_FILE_BYTES + 1);
const keepers = Array.from({ length: MAX_DROP_FILES }, (_, i) => fileEntry(`f${i}`));
const { files, oversized: oversizedCount } = await readDroppedFolder([
dirEntry('repo', [oversized, ...keepers]),
]);
expect(files).toHaveLength(MAX_DROP_FILES);
expect(paths(files)).not.toContain('repo/big.bin');
expect(oversizedCount).toBe(1);
});
it('skips oversized files before they reach filterRepoFiles', async () => {
const { files, skipped, oversized } = await readDroppedFolder([
dirEntry('repo', [fileEntry('a.ts'), fileEntry('big.bin', MAX_FILE_BYTES + 1)]),
]);
expect(paths(files)).toEqual(['repo/a.ts']);
expect(skipped).toBe(0);
expect(oversized).toBe(1);
});
it('aborts mid-walk and does not read later siblings', async () => {
const controller = new AbortController();
const later = dirEntry('later', [fileEntry('z.ts')]);
const root = dirEntry('repo', [
dirEntry(
'first',
Array.from({ length: 60 }, (_, i) => fileEntry(`f${i}`)),
),
later,
]);
const promise = readDroppedFolder([root], {
signal: controller.signal,
// progress is reported every 50 files; abort on the first report
onProgress: () => controller.abort(),
});
await expect(promise).rejects.toMatchObject({ name: 'AbortError' });
expect(later.readerCreated).toBe(0);
});
it('rejects when the abort lands during the last file read', async () => {
const controller = new AbortController();
const last = {
isFile: true,
isDirectory: false,
name: 'last.ts',
fullPath: '/browser-root/last.ts',
file: (ok: (f: File) => void) => {
controller.abort();
ok(new File(['x'], 'last.ts'));
},
} as unknown as FileSystemFileEntry;
const onProgress = vi.fn();
await expect(
readDroppedFolder([dirEntry('repo', [fileEntry('a.ts'), last])], {
signal: controller.signal,
onProgress,
}),
).rejects.toMatchObject({ name: 'AbortError' });
expect(onProgress).not.toHaveBeenCalled();
});
it('skips an unreadable subtree and keeps the rest', async () => {
const root = dirEntry('repo', [unreadableDir('locked'), dirEntry('src', [fileEntry('a.ts')])]);
const { files, skipped } = await readDroppedFolder([root]);
expect(paths(files)).toEqual(['repo/src/a.ts']);
expect(skipped).toBe(1);
});
it('skips a file whose file() call fails and keeps the rest', async () => {
const broken = {
isFile: true,
isDirectory: false,
name: 'dangling-symlink',
fullPath: '/browser-root/dangling-symlink',
file: (_ok: unknown, err: (e: Error) => void) =>
err(new DOMException('not found', 'NotFoundError')),
} as unknown as FileSystemFileEntry;
const root = dirEntry('repo', [fileEntry('a.ts'), broken, fileEntry('b.ts')]);
const { files, skipped } = await readDroppedFolder([root]);
expect(paths(files)).toEqual(['repo/a.ts', 'repo/b.ts']);
expect(skipped).toBe(1);
});
it('answers a folder named like build output without walking it', async () => {
const root = dirEntry('dist', [fileEntry('bundle.js')]);
const { files, skipped } = await readDroppedFolder([root]);
expect(files).toEqual([]);
expect(skipped).toBe(1);
expect(root.readerCreated).toBe(0);
});
it('skips files that would exceed the server path depth', async () => {
// A file directly inside a directory at depth d has d + 1 segments.
const build = (depth: number, leaf: string): FileSystemEntry => {
let entry: FileSystemEntry = fileEntry(leaf);
for (let i = depth; i >= 2; i--) entry = dirEntry(`d${i}`, [entry]);
return entry;
};
// root (1) + 62 dirs = 63 segments, leaf makes 64: allowed
const okChain = build(63, 'ok.ts');
// root (1) + 63 dirs = 64 segments, leaf would make 65: skipped
const tooDeep = build(64, 'deep.ts');
const root = dirEntry('repo', [okChain, tooDeep]);
const { files, skipped } = await readDroppedFolder([root]);
expect(skipped).toBe(1);
const rels = paths(files);
expect(rels).toHaveLength(1);
const [only] = rels;
expect(only?.endsWith('/ok.ts')).toBe(true);
expect(only?.split('/')).toHaveLength(MAX_PATH_SEGMENTS);
});
it('reports progress every 50 files and once at the end', async () => {
const onProgress = vi.fn();
const children = Array.from({ length: 120 }, (_, i) => fileEntry(`f${i}`));
await readDroppedFolder([dirEntry('repo', children)], { onProgress });
expect(onProgress.mock.calls.map((c) => c[0])).toEqual([50, 100, 120]);
});
it('produces files that filterRepoFiles turns into an aligned manifest', async () => {
const root = dirEntry('repo', [
fileEntry('a.ts'),
fileEntry('big.bin', MAX_FILE_BYTES + 1),
dirEntry('src', [fileEntry('b.ts')]),
]);
const { files } = await readDroppedFolder([root]);
const result = filterRepoFiles(files);
expect(result.manifest).toEqual(['repo/a.ts', 'repo/src/b.ts']);
expect(result.droppedCount).toBe(0);
result.files.forEach((f, i) => expect(f.webkitRelativePath).toBe(result.manifest[i]));
});
});

View file

@ -0,0 +1,214 @@
/**
* Read a folder that was dragged and dropped onto the analyzer.
*
* The `webkitdirectory` picker hands us a `FileList` whose entries carry
* `webkitRelativePath` (`<folder>/<rest>`). A drop hands us
* `DataTransferItem`s instead, and the `File`s behind them report an empty
* `webkitRelativePath`, so the tree has to be walked with the File and
* Directory Entries API (`webkitGetAsEntry`). This module does that walk and
* returns real `File` objects shaped exactly like the picker's, so the result
* goes through the unchanged `filterRepoFiles -> uploadFolder` path and the
* server sees the same manifest either way.
*
* Two platform constraints shape the API:
* - `dataTransfer.items` is readable only synchronously inside the drop
* handler, so `collectDropEntries` is sync and must run before the first
* `await`; `readDroppedFolder` does the async walk afterwards.
* - `FileSystemDirectoryReader.readEntries` returns batches (Chromium caps a
* batch at 100 entries) and signals the end with an empty array, so it is
* called in a loop.
*
* Directories on the shared exclusion list are pruned before they are read:
* `node_modules` is never enumerated, which is the point of dropping over
* picking on a large checkout (the picker enumerates everything first and
* only then filters).
*/
import { EXCLUDED_DIRS, MAX_FILE_BYTES } from './upload-filter';
/** Matches the server's `maxFiles` (DEFAULT_INGEST_LIMITS in upload-ingest.ts). */
export const MAX_DROP_FILES = 20000;
/**
* Matches the server's MAX_PATH_DEPTH: a manifest entry may have at most 64
* `/`-separated segments including the folder name and the file name.
*/
export const MAX_PATH_SEGMENTS = 64;
/** How many files are collected between two `onProgress` calls. */
const PROGRESS_EVERY = 50;
export type DropRejectionReason = 'unsupported' | 'notSingleFolder' | 'tooManyFiles';
/** Thrown when a drop cannot be turned into a single-folder upload. */
export class DropRejection extends Error {
constructor(
readonly reason: DropRejectionReason,
readonly max?: number,
) {
super(`Folder drop rejected: ${reason}`);
this.name = 'DropRejection';
}
}
export interface ReadDroppedFolderOptions {
signal?: AbortSignal;
/** Called with the running number of files collected so far. */
onProgress?: (filesFound: number) => void;
}
export interface DroppedFolder {
/** Every file under the dropped folder, `webkitRelativePath` set to `<folder>/<rest>`. */
files: File[];
/**
* Directories left out of `files`: pruned because their name is on the
* exclusion list, deeper than the server accepts, or unreadable. Their
* contents were never enumerated, so this is a directory count, not a file
* count. Unreadable single files are counted here as well.
*/
skipped: number;
/**
* Readable files omitted because they exceed MAX_FILE_BYTES. They do not
* count toward MAX_DROP_FILES; the drop summary adds this to droppedCount
* so it matches the picker (filterRepoFiles) skip count.
*/
oversized: number;
}
/**
* True when the browser can hand out directory entries for a drop. A missing
* `DataTransferItem` global (test environments) counts as supported; only an
* engine that has the class without `webkitGetAsEntry` cannot read folders.
*/
export function isFolderDropSupported(): boolean {
return (
typeof DataTransferItem === 'undefined' || 'webkitGetAsEntry' in DataTransferItem.prototype
);
}
/**
* Take the `FileSystemEntry` of every dropped item. Synchronous on purpose:
* call it first thing in the drop handler, before any `await`. Throws
* `DropRejection('unsupported')` when the browser has no `webkitGetAsEntry`;
* the picker button keeps working there.
*/
export function collectDropEntries(dataTransfer: DataTransfer): FileSystemEntry[] {
const items = dataTransfer.items;
if (!items || items.length === 0) return [];
const entries: FileSystemEntry[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (typeof item.webkitGetAsEntry !== 'function') throw new DropRejection('unsupported');
const entry = item.webkitGetAsEntry();
// Non-file items (dragged text, URLs) yield null and are simply ignored.
if (entry) entries.push(entry);
}
return entries;
}
/**
* Walk exactly one dropped directory into `File`s. Rejects with
* `DropRejection('notSingleFolder')` for loose files, no folder, or several
* roots (the server accepts one top-level folder per upload), with
* `DropRejection('tooManyFiles')` past MAX_DROP_FILES, and with the signal's
* reason (an `AbortError`) when aborted mid-walk.
*/
export async function readDroppedFolder(
entries: FileSystemEntry[],
{ signal, onProgress }: ReadDroppedFolderOptions = {},
): Promise<DroppedFolder> {
if (entries.length !== 1 || !entries[0].isDirectory) {
throw new DropRejection('notSingleFolder');
}
const root = entries[0] as FileSystemDirectoryEntry;
const files: File[] = [];
let skipped = 0;
let oversized = 0;
// A folder that is itself named like build output (`dist`, `out`, ...) would
// lose every file to filterRepoFiles anyway (the root is a path segment
// too); answer without walking it.
if (EXCLUDED_DIRS.has(root.name)) return { files, skipped: 1, oversized: 0 };
const throwIfAborted = () => {
if (signal?.aborted) throw signal.reason ?? new DOMException('Aborted', 'AbortError');
};
const report = (count: number) => {
if (!signal?.aborted) onProgress?.(count);
};
// Paths are joined from entry names, never taken from `entry.fullPath`: the
// result is `<root>/<rest>` with forward slashes in every browser and never
// carries a leading slash, which the server rejects.
const walk = async (dir: FileSystemDirectoryEntry, prefix: string, segments: number) => {
// Files inside this directory would have `segments + 1` path segments.
if (segments + 1 > MAX_PATH_SEGMENTS) {
skipped++;
return;
}
const reader = dir.createReader();
for (;;) {
throwIfAborted();
let batch: FileSystemEntry[];
try {
batch = await readBatch(reader);
} catch {
// An unreadable subtree (permissions) is skipped, like the picker
// silently omits files it cannot read; the rest of the drop proceeds.
skipped++;
return;
}
if (batch.length === 0) return;
for (const child of batch) {
throwIfAborted();
const rel = `${prefix}/${child.name}`;
if (child.isDirectory) {
if (EXCLUDED_DIRS.has(child.name)) {
skipped++;
continue;
}
await walk(child as FileSystemDirectoryEntry, rel, segments + 1);
} else if (child.isFile) {
let file: File;
try {
file = await getFile(child as FileSystemFileEntry);
} catch {
// Deleted or renamed mid-walk, a dangling symlink, a locked file:
// skip it the same way an unreadable directory is skipped.
skipped++;
continue;
}
throwIfAborted();
// Do not push oversized files: they used to count toward
// MAX_DROP_FILES, so a tree the picker accepts (20k keepers +
// oversized siblings) was rejected as tooManyFiles. Count them so
// the drop summary's droppedCount still matches the picker.
if (file.size > MAX_FILE_BYTES) {
oversized++;
continue;
}
// A dropped File reports '' here while the picker reports
// `<folder>/<rest>`. An own property shadows the prototype getter, so
// filterRepoFiles (and therefore the server) sees one shape.
Object.defineProperty(file, 'webkitRelativePath', { value: rel, configurable: true });
files.push(file);
if (files.length > MAX_DROP_FILES) {
throw new DropRejection('tooManyFiles', MAX_DROP_FILES);
}
if (files.length % PROGRESS_EVERY === 0) report(files.length);
}
}
}
};
await walk(root, root.name, 1);
throwIfAborted();
report(files.length);
return { files, skipped, oversized };
}
const readBatch = (reader: FileSystemDirectoryReader) =>
new Promise<FileSystemEntry[]>((resolve, reject) => reader.readEntries(resolve, reject));
const getFile = (entry: FileSystemFileEntry) =>
new Promise<File>((resolve, reject) => entry.file(resolve, reject));

View file

@ -72,7 +72,14 @@
"button": "Upload a folder",
"uploading": "Uploading…",
"selected": "{{fileCount}} files ready ({{dropped}} skipped: .git, node_modules, build output)",
"empty": "No analyzable files found in that folder."
"selectedDrop": "{{fileCount, number}} files ready ({{skippedDirs, number}} folders and {{dropped, number}} files skipped: .git, node_modules, build output, files over 25 MB)",
"empty": "No analyzable files found in that folder.",
"dropHint": "or drop a folder here",
"dropActive": "Release to upload this folder",
"reading": "Reading folder… {{fileCount, number}} files found",
"dropSingleFolder": "Drop a single folder, not files or several folders.",
"dropTooManyFiles": "That folder holds more than {{max, number}} files. Drop a subfolder instead.",
"dropUnsupported": "This browser cannot read a dropped folder. Use the “Upload a folder” button instead."
}
}
}

View file

@ -72,7 +72,14 @@
"button": "上传文件夹",
"uploading": "上传中…",
"selected": "已准备 {{fileCount}} 个文件(已跳过 {{dropped}} 个:.git、node_modules、构建产物)",
"empty": "该文件夹中未找到可分析的文件。"
"selectedDrop": "已准备 {{fileCount, number}} 个文件(已跳过 {{skippedDirs, number}} 个文件夹和 {{dropped, number}} 个文件:.git、node_modules、构建产物、超过 25 MB 的文件)",
"empty": "该文件夹中未找到可分析的文件。",
"dropHint": "或将文件夹拖放到此处",
"dropActive": "松开以上传此文件夹",
"reading": "正在读取文件夹… 已找到 {{fileCount, number}} 个文件",
"dropSingleFolder": "请拖放单个文件夹,而不是文件或多个文件夹。",
"dropTooManyFiles": "该文件夹的文件超过 {{max, number}} 个,请改为拖放子文件夹。",
"dropUnsupported": "此浏览器无法读取拖放的文件夹,请改用“上传文件夹”按钮。"
}
}
}

View file

@ -0,0 +1,334 @@
/**
* Folder drag-and-drop on RepoAnalyzer's Local Folder tab.
*
* A dropped folder is walked client-side (src/lib/folder-drop.ts) and then
* takes the exact path the "Upload a folder" picker takes: filterRepoFiles ->
* uploadFolder -> streamAnalyzeProgress. These tests drive the drop with
* fixture FileSystemEntry objects (jsdom has no File and Directory Entries
* API) and assert the upload the server would receive.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { RepoAnalyzer } from '../../src/components/RepoAnalyzer';
import { i18nReady } from '../../src/i18n';
import {
cancelAnalyze,
streamAnalyzeProgress,
uploadFolder,
} from '../../src/services/backend-client';
vi.mock('../../src/services/backend-client', () => ({
BackendError: class BackendError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly code: string,
public readonly retryAfterMs?: number,
) {
super(message);
this.name = 'BackendError';
}
},
startAnalyze: vi.fn(),
cancelAnalyze: vi.fn(),
streamAnalyzeProgress: vi.fn(),
uploadFolder: vi.fn(),
}));
const JOB = { jobId: 'job-1', status: 'queued' };
// ── Fixtures ─────────────────────────────────────────────────────────────────
function fileEntry(name: string, size = 1): FileSystemFileEntry {
return {
isFile: true,
isDirectory: false,
name,
fullPath: `/browser-root/${name}`,
file: (ok: (f: File) => void) => ok(new File([new Uint8Array(size)], name)),
} as unknown as FileSystemFileEntry;
}
/** A file whose `file()` callback is released manually, to hold the walk open. */
function deferredFileEntry(name: string) {
let release: (() => void) | undefined;
const entry = {
isFile: true,
isDirectory: false,
name,
fullPath: `/browser-root/${name}`,
file: (ok: (f: File) => void) => {
release = () => ok(new File(['x'], name));
},
} as unknown as FileSystemFileEntry;
return { entry, release: () => release?.() };
}
function dirEntry(name: string, children: FileSystemEntry[]): FileSystemDirectoryEntry {
return {
isFile: false,
isDirectory: true,
name,
fullPath: `/browser-root/${name}`,
createReader: () => {
let done = false;
return {
readEntries: (ok: (entries: FileSystemEntry[]) => void) => {
if (done) return ok([]);
done = true;
ok(children);
},
};
},
} as unknown as FileSystemDirectoryEntry;
}
function dataTransfer(entries: (FileSystemEntry | null)[], supported = true) {
const items = entries.map((e) => (supported ? { webkitGetAsEntry: () => e } : {}));
return { items, types: ['Files'], files: [], dropEffect: 'none' };
}
const filesDrag = { dataTransfer: { types: ['Files'], items: [], dropEffect: 'none' } };
// ── Setup ────────────────────────────────────────────────────────────────────
beforeEach(async () => {
await i18nReady;
vi.clearAllMocks();
vi.mocked(cancelAnalyze).mockResolvedValue(undefined as never);
vi.mocked(uploadFolder).mockResolvedValue(JOB);
vi.mocked(streamAnalyzeProgress).mockImplementation(() => new AbortController());
});
function renderLocalTab() {
const onComplete = vi.fn();
render(<RepoAnalyzer variant="onboarding" onComplete={onComplete} />);
fireEvent.click(screen.getByRole('tab', { name: 'Local Folder' }));
return { zone: screen.getByTestId('folder-drop-zone'), onComplete };
}
const REPO = () =>
dirEntry('repo', [
fileEntry('a.ts'),
dirEntry('src', [fileEntry('b.ts')]),
dirEntry('node_modules', [fileEntry('dep.js')]),
dirEntry('.git', [fileEntry('HEAD')]),
]);
// ── Tests ────────────────────────────────────────────────────────────────────
describe('folder drop on the Local Folder tab', () => {
it('uploads a dropped folder through the existing upload path', async () => {
const { zone } = renderLocalTab();
fireEvent.drop(zone, { dataTransfer: dataTransfer([REPO()]) });
await act(async () => {});
expect(uploadFolder).toHaveBeenCalledTimes(1);
const [files = [], manifest = [], signal] = vi.mocked(uploadFolder).mock.calls[0] ?? [];
expect(manifest).toEqual(['repo/a.ts', 'repo/src/b.ts']);
expect(files.map((f) => f.webkitRelativePath)).toEqual(manifest);
expect(signal).toBeInstanceOf(AbortSignal);
expect(streamAnalyzeProgress).toHaveBeenCalledWith(
'job-1',
expect.any(Function),
expect.any(Function),
expect.any(Function),
);
});
it('shows the reading state while the folder is walked', async () => {
const { zone } = renderLocalTab();
const held = deferredFileEntry('slow.ts');
const repo = dirEntry('repo', [fileEntry('a.ts'), held.entry]);
fireEvent.drop(zone, { dataTransfer: dataTransfer([repo]) });
await act(async () => {});
expect(screen.getByTestId('drop-reading')).toBeInTheDocument();
expect(screen.getByTestId('upload-folder')).toBeDisabled();
expect(uploadFolder).not.toHaveBeenCalled();
await act(async () => {
held.release();
});
expect(screen.queryByTestId('drop-reading')).toBeNull();
expect(uploadFolder).toHaveBeenCalledTimes(1);
});
it('refuses a loose file', async () => {
const { zone } = renderLocalTab();
fireEvent.drop(zone, { dataTransfer: dataTransfer([fileEntry('a.ts')]) });
await act(async () => {});
expect(uploadFolder).not.toHaveBeenCalled();
expect(
screen.getByText('Drop a single folder, not files or several folders.'),
).toBeInTheDocument();
});
it('refuses several folders at once', async () => {
const { zone } = renderLocalTab();
fireEvent.drop(zone, {
dataTransfer: dataTransfer([
dirEntry('a', [fileEntry('x')]),
dirEntry('b', [fileEntry('y')]),
]),
});
await act(async () => {});
expect(uploadFolder).not.toHaveBeenCalled();
expect(
screen.getByText('Drop a single folder, not files or several folders.'),
).toBeInTheDocument();
});
it('explains when the browser cannot read dropped folders', async () => {
const { zone } = renderLocalTab();
fireEvent.drop(zone, { dataTransfer: dataTransfer([REPO()], false) });
await act(async () => {});
expect(uploadFolder).not.toHaveBeenCalled();
expect(
screen.getByText(
'This browser cannot read a dropped folder. Use the “Upload a folder” button instead.',
),
).toBeInTheDocument();
expect(screen.getByTestId('upload-folder')).toBeEnabled();
});
it('shows the existing empty-folder message for a folder without analyzable files', async () => {
const { zone } = renderLocalTab();
fireEvent.drop(zone, {
dataTransfer: dataTransfer([dirEntry('repo', [dirEntry('node_modules', [fileEntry('x')])])]),
});
await act(async () => {});
expect(uploadFolder).not.toHaveBeenCalled();
expect(screen.getByText('No analyzable files found in that folder.')).toBeInTheDocument();
});
it('aborts the walk on a mode switch and never uploads', async () => {
const { zone } = renderLocalTab();
const held = deferredFileEntry('slow.ts');
const repo = dirEntry('repo', [fileEntry('a.ts'), held.entry]);
fireEvent.drop(zone, { dataTransfer: dataTransfer([repo]) });
await act(async () => {});
expect(screen.getByTestId('drop-reading')).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: /GitHub/ }));
await act(async () => {
held.release();
});
expect(uploadFolder).not.toHaveBeenCalled();
expect(screen.queryByTestId('drop-reading')).toBeNull();
expect(screen.queryByText(/single folder|cannot read/)).toBeNull();
});
it('keeps the highlight while the cursor crosses the button, clears it on leave', () => {
const { zone } = renderLocalTab();
const button = screen.getByTestId('upload-folder');
fireEvent.dragEnter(zone, filesDrag);
expect(zone).toHaveAttribute('data-drag-active', 'true');
expect(screen.getByTestId('drop-hint')).toHaveTextContent('Release to upload this folder');
fireEvent.dragEnter(button, filesDrag);
fireEvent.dragLeave(button, filesDrag);
expect(zone).toHaveAttribute('data-drag-active', 'true');
fireEvent.dragLeave(zone, filesDrag);
expect(zone).not.toHaveAttribute('data-drag-active');
expect(screen.getByTestId('drop-hint')).toHaveTextContent('or drop a folder here');
});
it('catches a drop that lands on the path input inside the panel', async () => {
renderLocalTab();
fireEvent.drop(screen.getByPlaceholderText(/project/), {
dataTransfer: dataTransfer([REPO()]),
});
await act(async () => {});
expect(uploadFolder).toHaveBeenCalledTimes(1);
});
it('disables Analyze while a dropped folder is being read', async () => {
const { zone } = renderLocalTab();
fireEvent.change(screen.getByPlaceholderText(/project/), { target: { value: '/srv/repo' } });
expect(screen.getByRole('button', { name: /Analyze Repository/ })).toBeEnabled();
const held = deferredFileEntry('slow.ts');
fireEvent.drop(zone, { dataTransfer: dataTransfer([dirEntry('repo', [held.entry])]) });
await act(async () => {});
expect(screen.getByRole('button', { name: /Analyze Repository/ })).toBeDisabled();
await act(async () => {
held.release();
});
expect(uploadFolder).toHaveBeenCalledTimes(1);
});
it('ignores drags that carry no files', () => {
const { zone } = renderLocalTab();
fireEvent.dragEnter(zone, { dataTransfer: { types: ['text/plain'], items: [] } });
expect(zone).not.toHaveAttribute('data-drag-active');
fireEvent.drop(zone, { dataTransfer: { types: ['text/plain'], items: [] } });
expect(uploadFolder).not.toHaveBeenCalled();
});
it("does not let an aborted drop clear a later drop's reading state", async () => {
const { zone } = renderLocalTab();
const heldA = deferredFileEntry('slow-a.ts');
fireEvent.drop(zone, { dataTransfer: dataTransfer([dirEntry('repo-a', [heldA.entry])]) });
await act(async () => {});
expect(screen.getByTestId('drop-reading')).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: /GitHub/ }));
fireEvent.click(screen.getByRole('tab', { name: 'Local Folder' }));
const zoneB = screen.getByTestId('folder-drop-zone');
const heldB = deferredFileEntry('slow-b.ts');
fireEvent.drop(zoneB, { dataTransfer: dataTransfer([dirEntry('repo-b', [heldB.entry])]) });
await act(async () => {});
expect(screen.getByTestId('drop-reading')).toBeInTheDocument();
await act(async () => {
heldA.release();
});
expect(screen.getByTestId('drop-reading')).toBeInTheDocument();
expect(screen.getByTestId('upload-folder')).toBeDisabled();
expect(uploadFolder).not.toHaveBeenCalled();
await act(async () => {
heldB.release();
});
expect(screen.queryByTestId('drop-reading')).toBeNull();
expect(uploadFolder).toHaveBeenCalledTimes(1);
const [, manifest] = vi.mocked(uploadFolder).mock.calls[0] ?? [];
expect(manifest).toEqual(['repo-b/slow-b.ts']);
});
it('ignores a drop while an upload is already in flight', async () => {
const { zone } = renderLocalTab();
vi.mocked(uploadFolder).mockImplementation(() => new Promise(() => {}));
fireEvent.drop(zone, { dataTransfer: dataTransfer([REPO()]) });
await act(async () => {});
expect(uploadFolder).toHaveBeenCalledTimes(1);
fireEvent.drop(zone, { dataTransfer: dataTransfer([REPO()]) });
await act(async () => {});
expect(uploadFolder).toHaveBeenCalledTimes(1);
});
});