Merge pull request #642 from yamadashy/feat/refact-website

refactor(website/client): Modernize Vue.js architecture with composables
This commit is contained in:
Kazuki Yamada
2025-06-08 14:06:34 +09:00
committed by GitHub
9 changed files with 746 additions and 319 deletions

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@ node_modules/
# Build output
lib/
website/client/.vitepress/dist/
# Logs
*.log

View File

@@ -1,13 +1,30 @@
FROM node:23-alpine
# ==============================================================================
# Base stage
# ==============================================================================
FROM node:24-alpine AS base
# Install Git (required for VitePress)
RUN apk add --no-cache git
WORKDIR /app
# ==============================================================================
# Dependencies installation stage
# ==============================================================================
FROM base AS deps
# Copy package.json and package-lock.json
COPY package*.json ./
RUN npm i
# Install all dependencies (with npm cache optimization)
RUN npm ci && npm cache clean --force
# ==============================================================================
# Development stage
# ==============================================================================
FROM deps AS development
# Copy source code
COPY . .
EXPOSE 5173

View File

@@ -58,16 +58,16 @@
</div>
<TryItPackOptions
v-model:format="inputFormat"
v-model:include-patterns="inputIncludePatterns"
v-model:ignore-patterns="inputIgnorePatterns"
v-model:file-summary="inputFileSummary"
v-model:directory-structure="inputDirectoryStructure"
v-model:remove-comments="inputRemoveComments"
v-model:remove-empty-lines="inputRemoveEmptyLines"
v-model:show-line-numbers="inputShowLineNumbers"
v-model:output-parsable="inputOutputParsable"
v-model:compress="inputCompress"
v-model:format="packOptions.format"
v-model:include-patterns="packOptions.includePatterns"
v-model:ignore-patterns="packOptions.ignorePatterns"
v-model:file-summary="packOptions.fileSummary"
v-model:directory-structure="packOptions.directoryStructure"
v-model:remove-comments="packOptions.removeComments"
v-model:remove-empty-lines="packOptions.removeEmptyLines"
v-model:show-line-numbers="packOptions.showLineNumbers"
v-model:output-parsable="packOptions.outputParsable"
v-model:compress="packOptions.compress"
/>
<div v-if="hasExecuted">
@@ -84,9 +84,8 @@
<script setup lang="ts">
import { FolderArchive, FolderOpen, Link2 } from 'lucide-vue-next';
import { computed, nextTick, onMounted, ref } from 'vue';
import type { PackResult } from '../api/client';
import { handlePackRequest } from '../utils/requestHandlers';
import { nextTick, onMounted } from 'vue';
import { usePackRequest } from '../../composables/usePackRequest';
import { isValidRemoteValue } from '../utils/validation';
import PackButton from './PackButton.vue';
import TryItFileUpload from './TryItFileUpload.vue';
@@ -95,102 +94,34 @@ import TryItPackOptions from './TryItPackOptions.vue';
import TryItResult from './TryItResult.vue';
import TryItUrlInput from './TryItUrlInput.vue';
// Form input states
const inputUrl = ref('');
const inputFormat = ref<'xml' | 'markdown' | 'plain'>('xml');
const inputRemoveComments = ref(false);
const inputRemoveEmptyLines = ref(false);
const inputShowLineNumbers = ref(false);
const inputFileSummary = ref(true);
const inputDirectoryStructure = ref(true);
const inputIncludePatterns = ref('');
const inputIgnorePatterns = ref('');
const inputOutputParsable = ref(false);
const inputRepositoryUrl = ref('');
const inputCompress = ref(false);
// Use composables for state management
const {
// Pack options
packOptions,
// Processing states
const loading = ref(false);
const error = ref<string | null>(null);
const result = ref<PackResult | null>(null);
const hasExecuted = ref(false);
const mode = ref<'url' | 'file' | 'folder'>('url');
const uploadedFile = ref<File | null>(null);
// Input states
inputUrl,
inputRepositoryUrl,
mode,
uploadedFile,
// Compute if the current mode's input is valid for submission
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;
}
});
// Request states
loading,
error,
result,
hasExecuted,
// Explicitly set the mode and handle related state changes
function setMode(newMode: 'url' | 'file' | 'folder') {
mode.value = newMode;
}
// Computed
isSubmitValid,
const TIMEOUT_MS = 30_000;
let requestController: AbortController | null = null;
// Actions
setMode,
handleFileUpload,
submitRequest,
} = usePackRequest();
async function handleSubmit() {
// Check if current mode has valid input
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');
throw new Error('Request timed out');
}
}, TIMEOUT_MS);
await handlePackRequest(
mode.value === 'url' ? inputUrl.value : '',
inputFormat.value,
{
removeComments: inputRemoveComments.value,
removeEmptyLines: inputRemoveEmptyLines.value,
showLineNumbers: inputShowLineNumbers.value,
fileSummary: inputFileSummary.value,
directoryStructure: inputDirectoryStructure.value,
includePatterns: inputIncludePatterns.value ? inputIncludePatterns.value.trim() : undefined,
ignorePatterns: inputIgnorePatterns.value ? inputIgnorePatterns.value.trim() : undefined,
outputParsable: inputOutputParsable.value,
compress: inputCompress.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,
},
);
clearTimeout(timeoutId);
loading.value = false;
requestController = null;
await submitRequest();
}
function handleKeydown(event: KeyboardEvent) {
@@ -199,10 +130,6 @@ function handleKeydown(event: KeyboardEvent) {
}
}
function handleFileUpload(file: File) {
uploadedFile.value = file;
}
// Add repository parameter handling when component mounts
onMounted(() => {
// Get URL parameters from window location

View File

@@ -1,10 +1,9 @@
<script setup lang="ts">
import { AlertTriangle, FolderArchive } from 'lucide-vue-next';
import { ref } from 'vue';
import { useFileUpload } from '../../composables/useFileUpload';
import { useZipProcessor } from '../../composables/useZipProcessor';
import PackButton from './PackButton.vue';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const props = defineProps<{
loading: boolean;
showButton?: boolean;
@@ -14,42 +13,50 @@ const emit = defineEmits<{
upload: [file: File];
}>();
const fileInput = ref<HTMLInputElement | null>(null);
const dragActive = ref(false);
const selectedFile = ref<File | null>(null);
const errorMessage = ref<string | null>(null);
const { validateZipFile } = useZipProcessor();
function validateFile(file: File): boolean {
errorMessage.value = null;
const {
fileInput,
dragActive,
selectedItem: selectedFile,
errorMessage,
hasError,
isValid,
inputAttributes,
handleFileSelect,
handleDragOver,
handleDragLeave,
handleDrop,
triggerFileInput,
clearSelection,
} = useFileUpload({
mode: 'file',
placeholder: 'Drop your ZIP file here or click to browse (max 10MB)',
icon: 'file',
options: {
maxFileSize: 10 * 1024 * 1024, // 10MB
acceptedTypes: ['.zip'],
accept: '.zip',
validateFile: validateZipFile,
},
});
if (file.type !== 'application/zip' && !file.name.endsWith('.zip')) {
errorMessage.value = 'Please upload a ZIP file';
return false;
}
if (file.size > MAX_FILE_SIZE) {
const sizeMB = (file.size / (1024 * 1024)).toFixed(1);
errorMessage.value = `File size (${sizeMB}MB) exceeds the 10MB limit`;
return false;
}
return true;
}
function handleFileSelect(files: FileList | null) {
if (!files || files.length === 0) return;
const file = files[0];
if (validateFile(file)) {
selectedFile.value = file;
emit('upload', file);
} else {
selectedFile.value = null;
async function onFileSelect(files: FileList | null) {
const result = await handleFileSelect(files);
if (result.success && result.result) {
emit('upload', result.result);
}
}
function triggerFileInput() {
fileInput.value?.click();
async function onDrop(event: DragEvent) {
const result = await handleDrop(event);
if (result.success && result.result) {
emit('upload', result.result);
}
}
function clearFile() {
clearSelection();
}
</script>
@@ -57,22 +64,21 @@ function triggerFileInput() {
<div class="upload-wrapper">
<div
class="upload-container"
:class="{ 'drag-active': dragActive, 'has-error': errorMessage }"
@dragover.prevent="dragActive = true"
@dragleave="dragActive = false"
@drop.prevent="handleFileSelect($event.dataTransfer?.files || null)"
:class="{ 'drag-active': dragActive, 'has-error': hasError }"
@dragover.prevent="handleDragOver"
@dragleave="handleDragLeave"
@drop.prevent="onDrop"
@click="triggerFileInput"
>
<input
ref="fileInput"
type="file"
accept=".zip"
v-bind="inputAttributes"
class="hidden-input"
@change="(e) => handleFileSelect((e.target as HTMLInputElement).files)"
@change="(e) => onFileSelect((e.target as HTMLInputElement).files)"
/>
<div class="upload-content">
<div class="upload-icon">
<AlertTriangle v-if="errorMessage" class="icon-error" size="20" />
<AlertTriangle v-if="hasError" class="icon-error" size="20" />
<FolderArchive v-else class="icon-folder" size="20" />
</div>
<div class="upload-text">
@@ -80,8 +86,8 @@ function triggerFileInput() {
{{ errorMessage }}
</p>
<p v-else-if="selectedFile" class="selected-file">
Selected: {{ selectedFile.name }}
<button class="clear-button" @click.stop="selectedFile = null">×</button>
Selected: {{ selectedFile }}
<button class="clear-button" @click.stop="clearFile">×</button>
</p>
<template v-else>
<p>Drop your ZIP file here or click to browse (max 10MB)</p>
@@ -93,7 +99,7 @@ function triggerFileInput() {
<div v-if="showButton" class="pack-button-container">
<PackButton
:loading="loading"
:isValid="!!selectedFile"
:isValid="isValid"
/>
</div>
</template>

View File

@@ -1,11 +1,9 @@
<script setup lang="ts">
import JSZip from 'jszip';
import { AlertTriangle, FolderOpen } from 'lucide-vue-next';
import { ref } from 'vue';
import { useFileUpload } from '../../composables/useFileUpload';
import { useZipProcessor } from '../../composables/useZipProcessor';
import PackButton from './PackButton.vue';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const props = defineProps<{
loading: boolean;
showButton?: boolean;
@@ -15,185 +13,82 @@ const emit = defineEmits<{
upload: [file: File];
}>();
const fileInput = ref<HTMLInputElement | null>(null);
const dragActive = ref(false);
const selectedFolder = ref<string | null>(null);
const errorMessage = ref<string | null>(null);
const { createZipFromFiles } = useZipProcessor();
// Common validation logic
const validateFolder = (files: File[]): boolean => {
errorMessage.value = null;
if (files.length === 0) {
errorMessage.value = 'The folder is empty.';
return false;
}
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
if (totalSize > MAX_FILE_SIZE) {
const sizeMB = (totalSize / (1024 * 1024)).toFixed(1);
errorMessage.value = `File size (${sizeMB}MB) exceeds the 10MB limit`;
return false;
}
return true;
};
// Create ZIP file
const createZipFile = async (files: File[], folderName: string): Promise<File> => {
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`);
};
// Common folder processing logic
const processFolder = async (files: File[], folderName: string): Promise<void> => {
if (!validateFolder(files)) {
selectedFolder.value = null;
return;
}
const zipFile = await createZipFile(files, folderName);
selectedFolder.value = folderName;
emit('upload', zipFile);
};
// File selection handler
const handleFileSelect = async (files: FileList | null): Promise<void> => {
if (!files || files.length === 0) return;
const fileArray = Array.from(files);
const folderName = files[0].webkitRelativePath.split('/')[0];
await processFolder(fileArray, folderName);
};
// Folder drop handler
const handleFolderDrop = async (event: DragEvent): Promise<void> => {
event.preventDefault();
dragActive.value = false;
errorMessage.value = null;
if (!event.dataTransfer?.items?.length) return;
// Check directory reading capability
if (!('webkitGetAsEntry' in DataTransferItem.prototype)) {
errorMessage.value = "Your browser doesn't support folder drop. Please use the browse button instead.";
return;
}
const entry = event.dataTransfer.items[0].webkitGetAsEntry();
if (!entry?.isDirectory) {
errorMessage.value = 'Please drop a folder, not a file.';
return;
}
const folderName = entry.name;
try {
const files = await collectFilesFromEntry(entry);
await processFolder(files, folderName);
} catch (error) {
console.error('Error processing dropped folder:', error);
errorMessage.value = 'Failed to process the folder. Please try again or use the browse button.';
}
};
const isFileEntry = (entry: FileSystemEntry): entry is FileSystemFileEntry => {
return entry.isFile;
};
const isDirectoryEntry = (entry: FileSystemEntry): entry is FileSystemDirectoryEntry => {
return entry.isDirectory;
};
// Recursively collect files from entry
const collectFilesFromEntry = async (entry: FileSystemEntry, path = ''): Promise<File[]> => {
if (isFileEntry(entry)) {
return new Promise((resolve, reject) => {
entry.file((file: File) => {
// Create custom file with path information
const customFile = new File([file], file.name, {
type: file.type,
lastModified: file.lastModified,
});
// Add relative path information
Object.defineProperty(customFile, 'webkitRelativePath', {
value: path ? `${path}/${file.name}` : file.name,
});
resolve([customFile]);
}, reject);
});
}
if (isDirectoryEntry(entry) && entry.createReader) {
return new Promise((resolve, reject) => {
const dirReader = entry.createReader();
const allFiles: File[] = [];
// Function to read entries in directory
function readEntries() {
dirReader.readEntries(async (entries: FileSystemEntry[]) => {
if (entries.length === 0) {
resolve(allFiles);
} else {
try {
// Process each entry
for (const childEntry of entries) {
const newPath = path ? `${path}/${childEntry.name}` : childEntry.name;
const files = await collectFilesFromEntry(childEntry, newPath);
allFiles.push(...files);
}
// Continue reading (some browsers return entries in batches)
readEntries();
} catch (error) {
reject(error);
}
}
}, reject);
const {
fileInput,
dragActive,
selectedItem: selectedFolder,
errorMessage,
hasError,
isValid,
inputAttributes,
handleFileSelect,
handleDragOver,
handleDragLeave,
handleDrop,
triggerFileInput,
clearSelection,
} = useFileUpload({
mode: 'folder',
placeholder: 'Drop your folder here or click to browse (max 10MB)',
icon: 'folder',
options: {
maxFileSize: 10 * 1024 * 1024, // 10MB
webkitdirectory: true,
validateFiles: (files: File[]) => {
if (files.length === 0) {
return { valid: false, error: 'The folder is empty.' };
}
return { valid: true };
},
preprocessFiles: async (files: File[], folderName?: string) => {
if (!folderName) {
throw new Error('Folder name is required');
}
return await createZipFromFiles(files, folderName);
},
},
});
readEntries();
});
async function onFileSelect(files: FileList | null) {
const result = await handleFileSelect(files);
if (result.success && result.result) {
emit('upload', result.result);
}
}
return []; // If neither file nor directory
};
async function onDrop(event: DragEvent) {
const result = await handleDrop(event);
if (result.success && result.result) {
emit('upload', result.result);
}
}
const triggerFileInput = () => {
fileInput.value?.click();
};
function clearFolder() {
clearSelection();
}
</script>
<template>
<div class="upload-wrapper">
<div
class="upload-container"
:class="{ 'drag-active': dragActive, 'has-error': errorMessage }"
@dragover.prevent="dragActive = true"
@dragleave="dragActive = false"
@drop.prevent="handleFolderDrop($event)"
:class="{ 'drag-active': dragActive, 'has-error': hasError }"
@dragover.prevent="handleDragOver"
@dragleave="handleDragLeave"
@drop.prevent="onDrop"
@click="triggerFileInput"
>
<input
ref="fileInput"
type="file"
directory
webkitdirectory
mozdirectory
v-bind="inputAttributes"
class="hidden-input"
@change="(e) => handleFileSelect((e.target as HTMLInputElement).files)"
@change="(e) => onFileSelect((e.target as HTMLInputElement).files)"
/>
<div class="upload-content">
<div class="upload-icon">
<AlertTriangle v-if="errorMessage" class="icon-error" size="20" />
<AlertTriangle v-if="hasError" class="icon-error" size="20" />
<FolderOpen v-else class="icon-folder" size="20" />
</div>
<div class="upload-text">
@@ -202,7 +97,7 @@ const triggerFileInput = () => {
</p>
<p v-else-if="selectedFolder" class="selected-file">
Selected: {{ selectedFolder }}
<button class="clear-button" @click.stop="selectedFolder = null">×</button>
<button class="clear-button" @click.stop="clearFolder">×</button>
</p>
<template v-else>
<p>Drop your folder here or click to browse (max 10MB)</p>
@@ -214,7 +109,7 @@ const triggerFileInput = () => {
<div v-if="showButton" class="pack-button-container">
<PackButton
:loading="loading"
:isValid="!!selectedFolder"
:isValid="isValid"
/>
</div>
</template>

View File

@@ -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<File>;
}
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<HTMLInputElement | null>(null);
const dragActive = ref(false);
const selectedItem = ref<string | null>(null);
const errorMessage = ref<string | null>(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<File[]> {
// 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,
};
}

View File

@@ -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<PackOptions>({ ...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<K extends keyof PackOptions>(key: K, value: PackOptions[K]) {
packOptions[key] = value;
}
function resetOptions() {
Object.assign(packOptions, DEFAULT_PACK_OPTIONS);
}
return {
packOptions,
getPackRequestOptions,
updateOption,
resetOptions,
};
}

View File

@@ -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<InputMode>('url');
const uploadedFile = ref<File | null>(null);
// Request states
const loading = ref(false);
const error = ref<string | null>(null);
const result = ref<PackResult | null>(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,
};
}

View File

@@ -0,0 +1,31 @@
import JSZip from 'jszip';
export function useZipProcessor() {
async function createZipFromFiles(files: File[], folderName: string): Promise<File> {
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,
};
}