mirror of
https://github.com/yamadashy/repomix.git
synced 2025-06-11 00:25:54 +03:00
Merge pull request #642 from yamadashy/feat/refact-website
refactor(website/client): Modernize Vue.js architecture with composables
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,6 +3,7 @@ node_modules/
|
||||
|
||||
# Build output
|
||||
lib/
|
||||
website/client/.vitepress/dist/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
358
website/client/composables/useFileUpload.ts
Normal file
358
website/client/composables/useFileUpload.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
58
website/client/composables/usePackOptions.ts
Normal file
58
website/client/composables/usePackOptions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
134
website/client/composables/usePackRequest.ts
Normal file
134
website/client/composables/usePackRequest.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
31
website/client/composables/useZipProcessor.ts
Normal file
31
website/client/composables/useZipProcessor.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user