@@ -202,7 +97,7 @@ const triggerFileInput = () => {
Selected: {{ selectedFolder }}
-
+
Drop your folder here or click to browse (max 10MB)
@@ -214,7 +109,7 @@ const triggerFileInput = () => {
diff --git a/website/client/composables/useFileUpload.ts b/website/client/composables/useFileUpload.ts
new file mode 100644
index 0000000..da55d3e
--- /dev/null
+++ b/website/client/composables/useFileUpload.ts
@@ -0,0 +1,358 @@
+import { computed, ref } from 'vue';
+
+export interface FileUploadOptions {
+ maxFileSize?: number;
+ acceptedTypes?: string[];
+ accept?: string;
+ multiple?: boolean;
+ webkitdirectory?: boolean;
+ validateFile?: (file: File) => { valid: boolean; error?: string };
+ validateFiles?: (files: File[]) => { valid: boolean; error?: string };
+ preprocessFiles?: (files: File[], folderName?: string) => Promise
;
+}
+
+export interface FileUploadConfig {
+ mode: 'file' | 'folder';
+ placeholder: string;
+ icon: 'file' | 'folder';
+ options: FileUploadOptions;
+}
+
+export function useFileUpload(config: FileUploadConfig) {
+ const {
+ maxFileSize = 10 * 1024 * 1024, // 10MB default
+ acceptedTypes = [],
+ accept = '',
+ multiple = false,
+ webkitdirectory = false,
+ validateFile,
+ validateFiles,
+ preprocessFiles,
+ } = config.options;
+
+ // Reactive state
+ const fileInput = ref(null);
+ const dragActive = ref(false);
+ const selectedItem = ref(null);
+ const errorMessage = ref(null);
+ const isProcessing = ref(false);
+
+ // Computed
+ const hasError = computed(() => !!errorMessage.value);
+ const hasSelection = computed(() => !!selectedItem.value);
+ const isValid = computed(() => hasSelection.value && !hasError.value);
+
+ // Default file validation
+ function defaultValidateFile(file: File): { valid: boolean; error?: string } {
+ if (acceptedTypes.length > 0) {
+ const isValidType = acceptedTypes.some((type) => {
+ if (type.startsWith('.')) {
+ return file.name.toLowerCase().endsWith(type.toLowerCase());
+ }
+ return file.type === type;
+ });
+
+ if (!isValidType) {
+ return {
+ valid: false,
+ error: `Please upload a ${acceptedTypes.join(' or ')} file`,
+ };
+ }
+ }
+
+ if (file.size > maxFileSize) {
+ const sizeMB = (file.size / (1024 * 1024)).toFixed(1);
+ const limitMB = (maxFileSize / (1024 * 1024)).toFixed(0);
+ return {
+ valid: false,
+ error: `File size (${sizeMB}MB) exceeds the ${limitMB}MB limit`,
+ };
+ }
+
+ return { valid: true };
+ }
+
+ // Default files validation (for folder/multiple files)
+ function defaultValidateFiles(files: File[]): { valid: boolean; error?: string } {
+ if (files.length === 0) {
+ return { valid: false, error: 'No files found' };
+ }
+
+ const totalSize = files.reduce((sum, file) => sum + file.size, 0);
+ if (totalSize > maxFileSize) {
+ const sizeMB = (totalSize / (1024 * 1024)).toFixed(1);
+ const limitMB = (maxFileSize / (1024 * 1024)).toFixed(0);
+ return {
+ valid: false,
+ error: `Total size (${sizeMB}MB) exceeds the ${limitMB}MB limit`,
+ };
+ }
+
+ return { valid: true };
+ }
+
+ // Clear error and selection
+ function clearError() {
+ errorMessage.value = null;
+ }
+
+ function clearSelection() {
+ selectedItem.value = null;
+ errorMessage.value = null;
+ // Clear file input to prevent re-selection issues
+ if (fileInput.value) {
+ fileInput.value.value = '';
+ }
+ }
+
+ // Validate and process files
+ async function processFiles(
+ files: File[],
+ folderName?: string,
+ ): Promise<{ success: boolean; result?: File; error?: string }> {
+ clearError();
+ isProcessing.value = true;
+
+ try {
+ // Validation
+ const validator =
+ config.mode === 'folder' || multiple
+ ? validateFiles || defaultValidateFiles
+ : validateFile || defaultValidateFile;
+
+ let validationResult: { valid: boolean; error?: string };
+ if (config.mode === 'folder' || multiple) {
+ validationResult = (validator as typeof defaultValidateFiles)(files);
+ } else {
+ validationResult = (validator as typeof defaultValidateFile)(files[0]);
+ }
+
+ if (!validationResult.valid) {
+ errorMessage.value = validationResult.error || 'Validation failed';
+ return { success: false, error: validationResult.error };
+ }
+
+ // Preprocessing (e.g., ZIP creation for folders)
+ let resultFile: File;
+ if (preprocessFiles) {
+ resultFile = await preprocessFiles(files, folderName);
+ } else {
+ if ((config.mode === 'folder' || multiple) && files.length > 1) {
+ throw new Error('Multiple files require a preprocessor function');
+ }
+ resultFile = files[0];
+ }
+
+ // Update selection
+ selectedItem.value = folderName || resultFile.name;
+
+ // Clear file input to prevent re-selection issues
+ if (fileInput.value) {
+ fileInput.value.value = '';
+ }
+
+ return { success: true, result: resultFile };
+ } catch (error) {
+ const errorMsg = error instanceof Error ? error.message : 'Processing failed';
+ errorMessage.value = errorMsg;
+ return { success: false, error: errorMsg };
+ } finally {
+ isProcessing.value = false;
+ }
+ }
+
+ // Handle file input selection
+ async function handleFileSelect(
+ files: FileList | null,
+ ): Promise<{ success: boolean; result?: File; error?: string }> {
+ if (!files || files.length === 0) {
+ return { success: false, error: 'No files selected' };
+ }
+
+ const fileArray = Array.from(files);
+ let folderName: string | undefined;
+
+ if (config.mode === 'folder' && files[0].webkitRelativePath) {
+ folderName = files[0].webkitRelativePath.split('/')[0];
+ }
+
+ return await processFiles(fileArray, folderName);
+ }
+
+ // Handle drag and drop
+ function handleDragOver(event: DragEvent) {
+ event.preventDefault();
+ dragActive.value = true;
+ }
+
+ function handleDragLeave() {
+ dragActive.value = false;
+ }
+
+ async function handleDrop(event: DragEvent): Promise<{ success: boolean; result?: File; error?: string }> {
+ event.preventDefault();
+ dragActive.value = false;
+
+ if (config.mode === 'folder') {
+ return await handleFolderDrop(event);
+ }
+ return await handleFileSelect(event.dataTransfer?.files || null);
+ }
+
+ // Specialized folder drop handling
+ async function handleFolderDrop(event: DragEvent): Promise<{ success: boolean; result?: File; error?: string }> {
+ if (!event.dataTransfer?.items?.length) {
+ return { success: false, error: 'No items found' };
+ }
+
+ // Check directory reading capability
+ if (!('webkitGetAsEntry' in DataTransferItem.prototype)) {
+ const error = "Your browser doesn't support folder drop. Please use the browse button instead.";
+ errorMessage.value = error;
+ return { success: false, error };
+ }
+
+ const entry = event.dataTransfer.items[0].webkitGetAsEntry();
+ if (!entry?.isDirectory) {
+ const error = 'Please drop a folder, not a file.';
+ errorMessage.value = error;
+ return { success: false, error };
+ }
+
+ try {
+ const files = await collectFilesFromEntry(entry);
+ return await processFiles(files, entry.name);
+ } catch (error) {
+ const errorMsg = 'Failed to process the folder. Please try again or use the browse button.';
+ errorMessage.value = errorMsg;
+ return { success: false, error: errorMsg };
+ }
+ }
+
+ // Constants for safety limits
+ const MAX_DEPTH = 20;
+ const MAX_FILES = 10000;
+
+ // Helper functions for folder processing
+ async function collectFilesFromEntry(
+ entry: FileSystemEntry,
+ path = '',
+ depth = 0,
+ fileCount = { current: 0 },
+ ): Promise {
+ // Check depth limit
+ if (depth > MAX_DEPTH) {
+ throw new Error(`Directory structure too deep (max depth: ${MAX_DEPTH})`);
+ }
+
+ // Check file count limit
+ if (fileCount.current > MAX_FILES) {
+ throw new Error(`Too many files in directory structure (max files: ${MAX_FILES})`);
+ }
+
+ if (entry.isFile) {
+ return new Promise((resolve, reject) => {
+ (entry as FileSystemFileEntry).file((file: File) => {
+ // Check file count before adding
+ if (fileCount.current >= MAX_FILES) {
+ reject(new Error(`Too many files in directory structure (max files: ${MAX_FILES})`));
+ return;
+ }
+
+ fileCount.current++;
+
+ const customFile = new File([file], file.name, {
+ type: file.type,
+ lastModified: file.lastModified,
+ });
+
+ Object.defineProperty(customFile, 'webkitRelativePath', {
+ value: path ? `${path}/${file.name}` : file.name,
+ });
+
+ resolve([customFile]);
+ }, reject);
+ });
+ }
+
+ if (entry.isDirectory && (entry as FileSystemDirectoryEntry).createReader) {
+ return new Promise((resolve, reject) => {
+ const dirReader = (entry as FileSystemDirectoryEntry).createReader();
+ const allFiles: File[] = [];
+
+ function readEntries() {
+ dirReader.readEntries(async (entries: FileSystemEntry[]) => {
+ if (entries.length === 0) {
+ resolve(allFiles);
+ } else {
+ try {
+ for (const childEntry of entries) {
+ // Check limits before processing each entry
+ if (depth + 1 > MAX_DEPTH) {
+ throw new Error(`Directory structure too deep (max depth: ${MAX_DEPTH})`);
+ }
+ if (fileCount.current > MAX_FILES) {
+ throw new Error(`Too many files in directory structure (max files: ${MAX_FILES})`);
+ }
+
+ const newPath = path ? `${path}/${childEntry.name}` : childEntry.name;
+ const files = await collectFilesFromEntry(childEntry, newPath, depth + 1, fileCount);
+ allFiles.push(...files);
+ }
+ readEntries();
+ } catch (error) {
+ reject(error);
+ }
+ }
+ }, reject);
+ }
+
+ readEntries();
+ });
+ }
+
+ return [];
+ }
+
+ // Trigger file input
+ function triggerFileInput() {
+ fileInput.value?.click();
+ }
+
+ // Input attributes for template
+ const inputAttributes = computed(() => ({
+ type: 'file',
+ accept,
+ multiple,
+ webkitdirectory: webkitdirectory || config.mode === 'folder',
+ ...(config.mode === 'folder' && {
+ directory: true,
+ mozdirectory: true,
+ }),
+ }));
+
+ return {
+ // Refs
+ fileInput,
+ dragActive,
+ selectedItem,
+ errorMessage,
+ isProcessing,
+
+ // Computed
+ hasError,
+ hasSelection,
+ isValid,
+ inputAttributes,
+
+ // Methods
+ handleFileSelect,
+ handleDragOver,
+ handleDragLeave,
+ handleDrop,
+ triggerFileInput,
+ clearError,
+ clearSelection,
+ processFiles,
+ };
+}
diff --git a/website/client/composables/usePackOptions.ts b/website/client/composables/usePackOptions.ts
new file mode 100644
index 0000000..c8bc9d1
--- /dev/null
+++ b/website/client/composables/usePackOptions.ts
@@ -0,0 +1,58 @@
+import { computed, reactive } from 'vue';
+
+export interface PackOptions {
+ format: 'xml' | 'markdown' | 'plain';
+ removeComments: boolean;
+ removeEmptyLines: boolean;
+ showLineNumbers: boolean;
+ fileSummary: boolean;
+ directoryStructure: boolean;
+ includePatterns: string;
+ ignorePatterns: string;
+ outputParsable: boolean;
+ compress: boolean;
+}
+
+const DEFAULT_PACK_OPTIONS: PackOptions = {
+ format: 'xml',
+ removeComments: false,
+ removeEmptyLines: false,
+ showLineNumbers: false,
+ fileSummary: true,
+ directoryStructure: true,
+ includePatterns: '',
+ ignorePatterns: '',
+ outputParsable: false,
+ compress: false,
+};
+
+export function usePackOptions() {
+ const packOptions = reactive({ ...DEFAULT_PACK_OPTIONS });
+
+ const getPackRequestOptions = computed(() => ({
+ removeComments: packOptions.removeComments,
+ removeEmptyLines: packOptions.removeEmptyLines,
+ showLineNumbers: packOptions.showLineNumbers,
+ fileSummary: packOptions.fileSummary,
+ directoryStructure: packOptions.directoryStructure,
+ includePatterns: packOptions.includePatterns ? packOptions.includePatterns.trim() : undefined,
+ ignorePatterns: packOptions.ignorePatterns ? packOptions.ignorePatterns.trim() : undefined,
+ outputParsable: packOptions.outputParsable,
+ compress: packOptions.compress,
+ }));
+
+ function updateOption(key: K, value: PackOptions[K]) {
+ packOptions[key] = value;
+ }
+
+ function resetOptions() {
+ Object.assign(packOptions, DEFAULT_PACK_OPTIONS);
+ }
+
+ return {
+ packOptions,
+ getPackRequestOptions,
+ updateOption,
+ resetOptions,
+ };
+}
diff --git a/website/client/composables/usePackRequest.ts b/website/client/composables/usePackRequest.ts
new file mode 100644
index 0000000..2429068
--- /dev/null
+++ b/website/client/composables/usePackRequest.ts
@@ -0,0 +1,134 @@
+import { computed, ref } from 'vue';
+import type { PackResult } from '../components/api/client';
+import { handlePackRequest } from '../components/utils/requestHandlers';
+import { isValidRemoteValue } from '../components/utils/validation';
+import { usePackOptions } from './usePackOptions';
+
+export type InputMode = 'url' | 'file' | 'folder';
+
+export function usePackRequest() {
+ const packOptionsComposable = usePackOptions();
+ const { packOptions, getPackRequestOptions } = packOptionsComposable;
+
+ // Input states
+ const inputUrl = ref('');
+ const inputRepositoryUrl = ref('');
+ const mode = ref('url');
+ const uploadedFile = ref(null);
+
+ // Request states
+ const loading = ref(false);
+ const error = ref(null);
+ const result = ref(null);
+ const hasExecuted = ref(false);
+
+ // Request controller for cancellation
+ let requestController: AbortController | null = null;
+ const TIMEOUT_MS = 30_000;
+
+ // Computed validation
+ const isSubmitValid = computed(() => {
+ switch (mode.value) {
+ case 'url':
+ return !!inputUrl.value && isValidRemoteValue(inputUrl.value.trim());
+ case 'file':
+ case 'folder':
+ return !!uploadedFile.value;
+ default:
+ return false;
+ }
+ });
+
+ function setMode(newMode: InputMode) {
+ mode.value = newMode;
+ }
+
+ function handleFileUpload(file: File) {
+ uploadedFile.value = file;
+ }
+
+ function resetRequest() {
+ error.value = null;
+ result.value = null;
+ hasExecuted.value = false;
+ }
+
+ async function submitRequest() {
+ if (!isSubmitValid.value) return;
+
+ // Cancel any pending request
+ if (requestController) {
+ requestController.abort();
+ }
+ requestController = new AbortController();
+
+ loading.value = true;
+ error.value = null;
+ result.value = null;
+ hasExecuted.value = true;
+ inputRepositoryUrl.value = inputUrl.value;
+
+ const timeoutId = setTimeout(() => {
+ if (requestController) {
+ requestController.abort('Request timed out');
+ }
+ }, TIMEOUT_MS);
+
+ try {
+ await handlePackRequest(
+ mode.value === 'url' ? inputUrl.value : '',
+ packOptions.format,
+ getPackRequestOptions.value,
+ {
+ onSuccess: (response) => {
+ result.value = response;
+ },
+ onError: (errorMessage) => {
+ error.value = errorMessage;
+ },
+ signal: requestController.signal,
+ file: mode.value === 'file' || mode.value === 'folder' ? uploadedFile.value || undefined : undefined,
+ },
+ );
+ } finally {
+ clearTimeout(timeoutId);
+ loading.value = false;
+ requestController = null;
+ }
+ }
+
+ function cancelRequest() {
+ if (requestController) {
+ requestController.abort();
+ requestController = null;
+ }
+ loading.value = false;
+ }
+
+ return {
+ // Pack options (re-exported for convenience)
+ ...packOptionsComposable,
+
+ // Input states
+ inputUrl,
+ inputRepositoryUrl,
+ mode,
+ uploadedFile,
+
+ // Request states
+ loading,
+ error,
+ result,
+ hasExecuted,
+
+ // Computed
+ isSubmitValid,
+
+ // Actions
+ setMode,
+ handleFileUpload,
+ resetRequest,
+ submitRequest,
+ cancelRequest,
+ };
+}
diff --git a/website/client/composables/useZipProcessor.ts b/website/client/composables/useZipProcessor.ts
new file mode 100644
index 0000000..ec30c12
--- /dev/null
+++ b/website/client/composables/useZipProcessor.ts
@@ -0,0 +1,31 @@
+import JSZip from 'jszip';
+
+export function useZipProcessor() {
+ async function createZipFromFiles(files: File[], folderName: string): Promise {
+ try {
+ const zip = new JSZip();
+
+ for (const file of files) {
+ const path = file.webkitRelativePath || file.name;
+ zip.file(path, file);
+ }
+
+ const zipBlob = await zip.generateAsync({ type: 'blob' });
+ return new File([zipBlob], `${folderName}.zip`, { type: 'application/zip' });
+ } catch (error) {
+ throw new Error(`Failed to create ZIP file: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+
+ function validateZipFile(file: File): { valid: boolean; error?: string } {
+ if (file.type !== 'application/zip' && !file.name.endsWith('.zip')) {
+ return { valid: false, error: 'Please upload a ZIP file' };
+ }
+ return { valid: true };
+ }
+
+ return {
+ createZipFromFiles,
+ validateZipFile,
+ };
+}