From 3a28f33908ecda981795a0b34aee388601a82f0a Mon Sep 17 00:00:00 2001 From: Ralph Rosael Date: Tue, 12 Aug 2025 11:02:01 +0800 Subject: [PATCH 1/4] feat(ui): add italic sub-labels to customization options --- src/app/generator/customization-options.tsx | 25 ++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/app/generator/customization-options.tsx b/src/app/generator/customization-options.tsx index d49c025..fd35a70 100644 --- a/src/app/generator/customization-options.tsx +++ b/src/app/generator/customization-options.tsx @@ -33,7 +33,10 @@ const CustomizationOptions: React.FC = ({ options, on {/* Use Icons */}
- +
+ + BOTH VIEW +
= ({ options, on
{/* Show Line Numbers */}
- +
+ + ASCII VIEW ONLY +
= ({ options, on
{/* Show Descriptions */}
- +
+ + ASCII VIEW ONLY +
= ({ options, on
{/* Root Directory */}
- +
+ + ASCII VIEW ONLY +
= ({ options, on
{/* Trailing Slash */}
- +
+ + ASCII VIEW ONLY +
Date: Tue, 12 Aug 2025 13:29:24 +0800 Subject: [PATCH 2/4] feat: optimize large repository handling with validation, performance, and UX improvements --- src/app/generator/repo-tree-generator.tsx | 197 +++++++++++++--- src/lib/repo-tree-utils.ts | 266 +++++++++++++++------- 2 files changed, 355 insertions(+), 108 deletions(-) diff --git a/src/app/generator/repo-tree-generator.tsx b/src/app/generator/repo-tree-generator.tsx index e912053..504f63d 100644 --- a/src/app/generator/repo-tree-generator.tsx +++ b/src/app/generator/repo-tree-generator.tsx @@ -14,6 +14,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Alert, AlertDescription } from "@/components/ui/alert" import { analyzeRepository, buildStructureString, @@ -22,11 +23,14 @@ import { generateStructure, validateGitHubUrl, validateGitLabUrl, + type RepoValidationResult, + PERFORMANCE_THRESHOLDS, } from "@/lib/repo-tree-utils" import { convertMapToJson } from "@/lib/utils" import type { TreeCustomizationOptions } from "@/types/tree-customization" import { saveAs } from "file-saver" import { + AlertTriangle, Check, ChevronDown, CircleX, @@ -34,6 +38,7 @@ import { Download, Github, GitlabIcon as GitLab, + Info, Maximize, Minimize, RefreshCw, @@ -110,6 +115,9 @@ export default function RepoProjectStructure() { message: "", isError: false, }) + const [repoValidation, setRepoValidation] = useState(null) + const [showValidationDialog, setShowValidationDialog] = useState(false) + const [proceedWithLargeRepo, setProceedWithLargeRepo] = useState(false) const [copied, setCopied] = useState(false) const [expanded, setExpanded] = useState(false) const [viewMode, setViewMode] = useState<"ascii" | "interactive">("ascii") @@ -145,7 +153,7 @@ export default function RepoProjectStructure() { ) const handleFetchStructure = useCallback( - async (url: string = repoUrl) => { + async (url: string = repoUrl, skipValidation: boolean = false) => { if (!url) { setValidation({ message: "Repository URL is required", isError: true }) return @@ -161,7 +169,22 @@ export default function RepoProjectStructure() { setLoading(true) try { - const tree = await fetchProjectStructure(url, repoType) + const { tree, validation: repoVal } = await fetchProjectStructure(url, repoType) + setRepoValidation(repoVal) + + // Check if we should show validation warnings + if (!skipValidation && !repoVal.isValid) { + setShowValidationDialog(true) + setLoading(false) + return + } + + if (!skipValidation && repoVal.warnings.length > 0 && !proceedWithLargeRepo) { + setShowValidationDialog(true) + setLoading(false) + return + } + const map = generateStructure(tree) setStructureMap(map) setValidation({ message: "", isError: false }) @@ -170,6 +193,10 @@ export default function RepoProjectStructure() { const { fileTypes, languages } = analyzeRepository(map) setFileTypeData(fileTypes) setLanguageData(languages) + + // Reset validation dialog state + setShowValidationDialog(false) + setProceedWithLargeRepo(false) } catch (err: unknown) { if (err instanceof Error) { console.error(err) @@ -184,12 +211,19 @@ export default function RepoProjectStructure() { isError: true, }) } + setRepoValidation(null) } setLoading(false) }, - [repoUrl, repoType], + [repoUrl, repoType, proceedWithLargeRepo], ) + const handleProceedWithLargeRepo = useCallback(() => { + setProceedWithLargeRepo(true) + setShowValidationDialog(false) + handleFetchStructure(repoUrl, true) + }, [repoUrl, handleFetchStructure]) + useEffect(() => { const savedUrl = localStorage.getItem("lastRepoUrl") if (savedUrl) { @@ -219,17 +253,21 @@ export default function RepoProjectStructure() { } }, []) + // Memoized filtering with performance optimization const filterStructure = useCallback((map: DirectoryMap, term: string): DirectoryMap => { + if (!term.trim()) return map + const filteredMap: DirectoryMap = new Map() + const lowerTerm = term.toLowerCase() for (const [key, value] of map.entries()) { if (value && typeof value === "object" && "type" in value && value.type === "file") { - if (key.toLowerCase().includes(term.toLowerCase())) { + if (key.toLowerCase().includes(lowerTerm)) { filteredMap.set(key, value) } } else if (value instanceof Map) { const filteredSubMap = filterStructure(value, term) - if (filteredSubMap.size > 0 || key.toLowerCase().includes(term.toLowerCase())) { + if (filteredSubMap.size > 0 || key.toLowerCase().includes(lowerTerm)) { filteredMap.set(key, filteredSubMap) } } @@ -243,10 +281,15 @@ export default function RepoProjectStructure() { [filterStructure, structureMap, searchTerm], ) - const customizedStructure = useMemo( - () => buildStructureString(filteredStructureMap, "", customizationOptions), - [filteredStructureMap, customizationOptions], - ) + // Memoized structure string with performance optimization + const customizedStructure = useMemo(() => { + // For very large structures, limit rendering to prevent performance issues + const mapSize = structureMap.size + if (mapSize > PERFORMANCE_THRESHOLDS.LARGE_REPO_ENTRIES) { + return buildStructureString(filteredStructureMap, "", customizationOptions, "", 20) // Limit depth + } + return buildStructureString(filteredStructureMap, "", customizationOptions) + }, [filteredStructureMap, customizationOptions, structureMap.size]) const copyToClipboard = useCallback(() => { navigator.clipboard.writeText(customizedStructure).then(() => { @@ -259,6 +302,7 @@ export default function RepoProjectStructure() { setRepoUrl("") localStorage.removeItem("lastRepoUrl") setStructureMap(new Map()) + setRepoValidation(null) if (inputRef.current) { inputRef.current.focus() } @@ -275,19 +319,19 @@ export default function RepoProjectStructure() { switch (format) { case "md": - content = `# Repository Structure\n\n\`\`\`\n${customizedStructure}\`\`\`` + content = `# Directory Structure\n\n\`\`\`\n${customizedStructure}\`\`\`` mimeType = "text/markdown;charset=utf-8" fileName = "README.md" break case "txt": content = customizedStructure mimeType = "text/plain;charset=utf-8" - fileName = "repository-structure.txt" + fileName = "directory-structure.txt" break case "json": content = JSON.stringify(convertMapToJson(filteredStructureMap), null, 2) mimeType = "application/json;charset=utf-8" - fileName = "repository-structure.json" + fileName = "directory-structure.json" break case "html": content = ` @@ -305,7 +349,7 @@ export default function RepoProjectStructure() { ` mimeType = "text/html;charset=utf-8" - fileName = "repository-structure.html" + fileName = "directory-structure.html" break } @@ -334,6 +378,79 @@ export default function RepoProjectStructure() { return (
+ {/* Validation Dialog */} + + + + + {repoValidation?.isValid === false ? ( + + ) : ( + + )} + Repository Size Warning + + + + {repoValidation && ( +
+
+

Repository Statistics:

+
    +
  • • Total entries: {repoValidation.totalEntries.toLocaleString()}
  • +
  • • Estimated size: {(repoValidation.estimatedSize / (1024 * 1024)).toFixed(2)}MB
  • +
+
+ + {repoValidation.errors.length > 0 && ( + + + +
    + {repoValidation.errors.map((error, index) => ( +
  • • {error}
  • + ))} +
+
+
+ )} + + {repoValidation.warnings.length > 0 && ( + + + +
    + {repoValidation.warnings.map((warning, index) => ( +
  • • {warning}
  • + ))} +
+
+
+ )} + +
+ {repoValidation.isValid && ( + + )} + +
+
+ )} +
+
+ + {/* Repository Validation Status */} + {repoValidation && structureMap.size > 0 && ( + + + + Repository processed: {repoValidation.totalEntries.toLocaleString()} entries, + estimated size: {(repoValidation.estimatedSize / (1024 * 1024)).toFixed(2)}MB + {repoValidation.totalEntries > PERFORMANCE_THRESHOLDS.LARGE_REPO_ENTRIES && + " (Large repository - some features may be slower)" + } + + + )} +
{/* Repository Type Select */}
@@ -582,26 +713,28 @@ export default function RepoProjectStructure() { {/* Code Block */}
{viewMode === "ascii" ? ( - - {customizedStructure - ? customizedStructure - : searchTerm - ? noResultsMessage(searchTerm) - : noStructureMessage} - +
{/* CSS containment for performance */} + + {customizedStructure + ? customizedStructure + : searchTerm + ? noResultsMessage(searchTerm) + : noStructureMessage} + +
) : filteredStructureMap.size > 0 ? ( -
+
) : ( diff --git a/src/lib/repo-tree-utils.ts b/src/lib/repo-tree-utils.ts index 01c311b..a478ebe 100644 --- a/src/lib/repo-tree-utils.ts +++ b/src/lib/repo-tree-utils.ts @@ -6,6 +6,7 @@ export interface TreeItem { path: string type: "tree" | "blob" name: string + size?: number // size validation } export type DirectoryMap = Map @@ -16,6 +17,27 @@ interface GitLabTreeItem { name: string } +// GitHub API limits +export const GITHUB_LIMITS = { + MAX_ENTRIES: 100000, + MAX_SIZE_MB: 7, + MAX_SIZE_BYTES: 7 * 1024 * 1024 +} + +// Performance thresholds +export const PERFORMANCE_THRESHOLDS = { + LARGE_REPO_ENTRIES: 10000, // Show warning for repos with >10k entries + MAX_RECOMMENDED_ENTRIES: 50000, // Recommend against processing >50k entries +} + +export interface RepoValidationResult { + isValid: boolean + warnings: string[] + errors: string[] + totalEntries: number + estimatedSize: number +} + // Validate GitHub and GitLab URLs export const validateGitHubUrl = (url: string): boolean => { const githubUrlPattern = /^https?:\/\/github\.com\/[\w-]+\/[\w.-]+\/?$/ @@ -27,6 +49,51 @@ export const validateGitLabUrl = (url: string): boolean => { return gitlabUrlPattern.test(url) } +// Validate repository size and structure +export const validateRepositoryStructure = (tree: TreeItem[]): RepoValidationResult => { + const result: RepoValidationResult = { + isValid: true, + warnings: [], + errors: [], + totalEntries: tree.length, + estimatedSize: 0 + } + + // Calculate estimated size (rough approximation based on path lengths) + result.estimatedSize = tree.reduce((total, item) => { + // Rough estimation: each item consumes ~200 bytes on average (path + metadata) + return total + (item.path.length * 2) + 200 + }, 0) + + // Check GitHub API limits + if (result.totalEntries > GITHUB_LIMITS.MAX_ENTRIES) { + result.isValid = false + result.errors.push( + `Repository exceeds GitHub API limit of ${GITHUB_LIMITS.MAX_ENTRIES.toLocaleString()} entries. Found ${result.totalEntries.toLocaleString()} entries.` + ) + } + + if (result.estimatedSize > GITHUB_LIMITS.MAX_SIZE_BYTES) { + result.isValid = false + result.errors.push( + `Repository exceeds GitHub API size limit of ${GITHUB_LIMITS.MAX_SIZE_MB}MB. Estimated size: ${(result.estimatedSize / (1024 * 1024)).toFixed(2)}MB.` + ) + } + + // Performance warnings + if (result.totalEntries > PERFORMANCE_THRESHOLDS.MAX_RECOMMENDED_ENTRIES) { + result.warnings.push( + `Large repository detected (${result.totalEntries.toLocaleString()} entries). This may cause performance issues.` + ) + } else if (result.totalEntries > PERFORMANCE_THRESHOLDS.LARGE_REPO_ENTRIES) { + result.warnings.push( + `Medium-sized repository detected (${result.totalEntries.toLocaleString()} entries). Processing may take longer than usual.` + ) + } + + return result +} + // Initialize Octokit instance with proper token handling const getOctokit = () => { // Check for environment variable first @@ -60,13 +127,18 @@ const getGitLabToken = (): string | undefined => { return gitlabToken } -// Fetch project structure from GitHub or GitLab -export const fetchProjectStructure = async (repoUrl: string, repoType: "github" | "gitlab"): Promise => { - if (repoType === "github") { - return fetchGitHubProjectStructure(repoUrl) - } else { - return fetchGitLabProjectStructure(repoUrl) - } +// Enhanced fetch with validation +export const fetchProjectStructure = async ( + repoUrl: string, + repoType: "github" | "gitlab" +): Promise<{ tree: TreeItem[]; validation: RepoValidationResult }> => { + const tree = repoType === "github" + ? await fetchGitHubProjectStructure(repoUrl) + : await fetchGitLabProjectStructure(repoUrl) + + const validation = validateRepositoryStructure(tree) + + return { tree, validation } } const fetchGitHubProjectStructure = async (repoUrl: string): Promise => { @@ -98,6 +170,7 @@ const fetchGitHubProjectStructure = async (repoUrl: string): Promise path: item.path || "", type: item.type === "tree" ? "tree" : "blob", name: item.path ? item.path.split("/").pop() || "" : "", + size: item.size || 0 })) as TreeItem[] // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { @@ -167,35 +240,128 @@ const fetchGitLabProjectStructure = async (repoUrl: string): Promise } } -// Generate and build project structure +// Optimized structure generation with early returns and better memory management export const generateStructure = (tree: TreeItem[]): DirectoryMap => { const structureMap: DirectoryMap = new Map() - tree.forEach((item: TreeItem) => { + + // Sort paths to ensure consistent ordering and better cache locality + const sortedTree = tree.sort((a, b) => a.path.localeCompare(b.path)) + + for (const item of sortedTree) { const parts = item.path.split("/") - let currentLevel: DirectoryMap | { type: "file"; name: string } = structureMap - - parts.forEach((part: string, index: number) => { - if (!(currentLevel instanceof Map)) return - if (!currentLevel.has(part)) { - if (index === parts.length - 1 && item.type === "blob") { - currentLevel.set(part, { type: "file", name: item.name }) - } else { + let currentLevel: DirectoryMap = structureMap + + for (let i = 0; i < parts.length; i++) { + const part = parts[i] + if (!part) continue // Skip empty parts + + if (i === parts.length - 1 && item.type === "blob") { + // It's a file + currentLevel.set(part, { type: "file", name: item.name }) + } else { + // It's a directory + if (!currentLevel.has(part)) { currentLevel.set(part, new Map() as DirectoryMap) } + const next = currentLevel.get(part) + if (next instanceof Map) { + currentLevel = next + } } - currentLevel = currentLevel.get(part)! - }) - }) + } + } + return structureMap } +// Optimized structure building with chunking for large trees +export const buildStructureString = ( + map: DirectoryMap, + prefix = "", + options: TreeCustomizationOptions, + currentPath = "", + maxDepth = 50 // Prevent infinite recursion +): string => { + if (maxDepth <= 0) { + return `${prefix}... (max depth reached)\n` + } + + let result = "" + + // Add root directory indicator if enabled + if (prefix === "" && options.showRootDirectory) { + result += "./\n" + } + + const entries = Array.from(map.entries()) + + // Early return for empty directories + if (entries.length === 0) { + return result + } + + // Optimized sorting with single pass + const sortedEntries = entries.sort(([keyA, valueA], [keyB, valueB]) => { + const isDirectoryA = valueA instanceof Map + const isDirectoryB = valueB instanceof Map + + if (isDirectoryA !== isDirectoryB) { + return isDirectoryA ? -1 : 1 // Directories first + } + + return keyA.localeCompare(keyB) // Alphabetical within same type + }) + + const lastIndex = sortedEntries.length - 1 + + for (let index = 0; index < sortedEntries.length; index++) { + const [key, value] = sortedEntries[index] + const isLast = index === lastIndex + const connector = getConnector(isLast, options.asciiStyle) + const childPrefix = getChildPrefix(isLast, options.asciiStyle) + const icon = options.useIcons ? getIcon(value instanceof Map) : "" + const isDirectory = value instanceof Map + + // Build current file/directory path + const itemPath = currentPath ? `${currentPath}/${key}` : key + + // Add trailing slash for directories if enabled + const displayName = (isDirectory && options.showTrailingSlash) ? `${key}/` : key + + // Get description for this item (cached for performance) + const description = getDescription(key, isDirectory, itemPath) + const descriptionText = options.showDescriptions && description ? ` # ${description}` : "" + + result += `${prefix}${connector}${icon}${displayName}${descriptionText}\n` + + if (isDirectory) { + result += buildStructureString(value, `${prefix}${childPrefix}`, options, itemPath, maxDepth - 1) + } + } + + return result +} + +// Cache for descriptions to improve performance +const descriptionCache = new Map() + // Get description for files and directories const getDescription = (name: string, isDirectory: boolean, path?: string): string => { - if (isDirectory) { - return getDirectoryDescription(name, path || "") - } else { - return getFileDescription(name) + const cacheKey = `${name}:${isDirectory}:${path}` + if (descriptionCache.has(cacheKey)) { + return descriptionCache.get(cacheKey)! + } + + const description = isDirectory + ? getDirectoryDescription(name, path || "") + : getFileDescription(name) + + // Cache the result (limit cache size to prevent memory leaks) + if (descriptionCache.size < 1000) { + descriptionCache.set(cacheKey, description) } + + return description } // Directory descriptions based on common patterns @@ -415,58 +581,6 @@ const getFileDescription = (fileName: string): string => { return extensionDescriptions[extension] || "File" } -export const buildStructureString = (map: DirectoryMap, prefix = "", options: TreeCustomizationOptions, currentPath = ""): string => { - let result = "" - - // Add root directory indicator if enabled - if (prefix === "" && options.showRootDirectory) { - result += "./\n" - } - - const entries = Array.from(map.entries()) - - // Sort entries: directories first, then files - // Within each group, sort alphabetically - const sortedEntries = entries.sort(([keyA, valueA], [keyB, valueB]) => { - const isDirectoryA = valueA instanceof Map - const isDirectoryB = valueB instanceof Map - - // If one is directory and other is file, directory comes first - if (isDirectoryA && !isDirectoryB) return -1 - if (!isDirectoryA && isDirectoryB) return 1 - - // If both are same type (both directories or both files), sort alphabetically - return keyA.localeCompare(keyB) - }) - - const lastIndex = sortedEntries.length - 1 - - sortedEntries.forEach(([key, value], index) => { - const isLast = index === lastIndex - const connector = getConnector(isLast, options.asciiStyle) - const childPrefix = getChildPrefix(isLast, options.asciiStyle) - const icon = options.useIcons ? getIcon(value instanceof Map) : "" - const isDirectory = value instanceof Map - - // Build current file/directory path - const itemPath = currentPath ? `${currentPath}/${key}` : key - - // Add trailing slash for directories if enabled - const displayName = (isDirectory && options.showTrailingSlash) ? `${key}/` : key - - // Get description for this item - const description = getDescription(key, isDirectory, itemPath) - const descriptionText = options.showDescriptions && description ? ` # ${description}` : "" - - result += `${prefix}${connector}${icon}${displayName}${descriptionText}\n` - if (isDirectory) { - result += buildStructureString(value, `${prefix}${childPrefix}`, options, itemPath) - } - }) - - return result -} - const getConnector = (isLast: boolean, asciiStyle: string): string => { switch (asciiStyle) { case "basic": From e6b6b632fc244c50a09e4e8f2ef3580c5c448ad4 Mon Sep 17 00:00:00 2001 From: Ralph Rosael Date: Wed, 13 Aug 2025 11:50:12 +0800 Subject: [PATCH 3/4] update privacy and cookie policies --- src/app/legal/cookie-policy/page.tsx | 81 ++++++++++++++++++++------- src/app/legal/privacy-policy/page.tsx | 81 ++++++++++++++++++++------- src/components/back-button.tsx | 22 ++++++++ 3 files changed, 146 insertions(+), 38 deletions(-) create mode 100644 src/components/back-button.tsx diff --git a/src/app/legal/cookie-policy/page.tsx b/src/app/legal/cookie-policy/page.tsx index 64ed6c8..3e286a4 100644 --- a/src/app/legal/cookie-policy/page.tsx +++ b/src/app/legal/cookie-policy/page.tsx @@ -1,29 +1,57 @@ -'use client'; +import type { Metadata } from 'next'; +import BackButton from '@/components/back-button'; -import { useRouter } from 'next/navigation'; - -import { Button } from '@/components/ui/button'; -import { ArrowLeft } from 'lucide-react'; +export const metadata: Metadata = { + title: 'Cookie Policy - RepoTree', + description: + 'RepoTree Cookie Policy: We do not use cookies, tracking technologies, or third-party trackers. Full details here.', + robots: { index: true, follow: true }, + alternates: { + canonical: 'https://ascii-repotree.vercel.app/legal/cookie-policy', + }, +}; const CookiePolicy = () => { - const router = useRouter(); + const lastUpdated = '2025-07-21'; + + const structuredData = { + '@context': 'https://schema.org', + '@type': 'WebPage', + name: 'RepoTree Cookie Policy', + url: 'https://ascii-repotree.vercel.app/legal/cookie-policy', + dateModified: lastUpdated, + publisher: { + '@type': 'Organization', + name: 'RepoTree', + url: 'https://ascii-repotree.vercel.app', + }, + description: + 'RepoTree does not use cookies or tracking technologies. We do not place any cookies, including essential, functional, analytical, or advertising cookies.', + }; return ( -
+
+ {/* JSON-LD Structured Data */} +