diff --git a/py/routes/base_model_routes.py b/py/routes/base_model_routes.py index 1b9da114..857aaa3c 100644 --- a/py/routes/base_model_routes.py +++ b/py/routes/base_model_routes.py @@ -57,6 +57,8 @@ class BaseModelRoutes(ABC): app.router.add_get(f'/api/{prefix}/scan', self.scan_models) app.router.add_get(f'/api/{prefix}/roots', self.get_model_roots) app.router.add_get(f'/api/{prefix}/folders', self.get_folders) + app.router.add_get(f'/api/{prefix}/folder-tree', self.get_folder_tree) + app.router.add_get(f'/api/{prefix}/unified-folder-tree', self.get_unified_folder_tree) app.router.add_get(f'/api/{prefix}/find-duplicates', self.find_duplicate_models) app.router.add_get(f'/api/{prefix}/find-filename-conflicts', self.find_filename_conflicts) @@ -346,6 +348,43 @@ class BaseModelRoutes(ABC): 'error': str(e) }, status=500) + async def get_folder_tree(self, request: web.Request) -> web.Response: + """Get hierarchical folder tree structure for download modal""" + try: + model_root = request.query.get('model_root') + if not model_root: + return web.json_response({ + 'success': False, + 'error': 'model_root parameter is required' + }, status=400) + + folder_tree = await self.service.get_folder_tree(model_root) + return web.json_response({ + 'success': True, + 'tree': folder_tree + }) + except Exception as e: + logger.error(f"Error getting folder tree: {e}") + return web.json_response({ + 'success': False, + 'error': str(e) + }, status=500) + + async def get_unified_folder_tree(self, request: web.Request) -> web.Response: + """Get unified folder tree across all model roots""" + try: + unified_tree = await self.service.get_unified_folder_tree() + return web.json_response({ + 'success': True, + 'tree': unified_tree + }) + except Exception as e: + logger.error(f"Error getting unified folder tree: {e}") + return web.json_response({ + 'success': False, + 'error': str(e) + }, status=500) + async def find_duplicate_models(self, request: web.Request) -> web.Response: """Find models with duplicate SHA256 hashes""" try: diff --git a/py/services/base_model_service.py b/py/services/base_model_service.py index cabcc98b..dc783cb5 100644 --- a/py/services/base_model_service.py +++ b/py/services/base_model_service.py @@ -272,4 +272,62 @@ class BaseModelService(ABC): def get_model_roots(self) -> List[str]: """Get model root directories""" - return self.scanner.get_model_roots() \ No newline at end of file + return self.scanner.get_model_roots() + + async def get_folder_tree(self, model_root: str) -> Dict: + """Get hierarchical folder tree for a specific model root""" + cache = await self.scanner.get_cached_data() + + # Build tree structure from folders + tree = {} + + for folder in cache.folders: + # Check if this folder belongs to the specified model root + folder_belongs_to_root = False + for root in self.scanner.get_model_roots(): + if root == model_root: + folder_belongs_to_root = True + break + + if not folder_belongs_to_root: + continue + + # Split folder path into components + parts = folder.split('/') if folder else [] + current_level = tree + + for part in parts: + if part not in current_level: + current_level[part] = {} + current_level = current_level[part] + + return tree + + async def get_unified_folder_tree(self) -> Dict: + """Get unified folder tree across all model roots""" + cache = await self.scanner.get_cached_data() + + # Build unified tree structure by analyzing all relative paths + unified_tree = {} + + # Get all model roots for path normalization + model_roots = self.scanner.get_model_roots() + + for folder in cache.folders: + if not folder: # Skip empty folders + continue + + # Find which root this folder belongs to by checking the actual file paths + # This is a simplified approach - we'll use the folder as-is since it should already be relative + relative_path = folder + + # Split folder path into components + parts = relative_path.split('/') + current_level = unified_tree + + for part in parts: + if part not in current_level: + current_level[part] = {} + current_level = current_level[part] + + return unified_tree \ No newline at end of file diff --git a/static/css/components/modal/download-modal.css b/static/css/components/modal/download-modal.css index b3d1f166..5a3f50c9 100644 --- a/static/css/components/modal/download-modal.css +++ b/static/css/components/modal/download-modal.css @@ -150,6 +150,246 @@ border: 1px solid var(--lora-accent); } +/* Path Input Styles */ +.path-input-container { + position: relative; + display: flex; + gap: 8px; + align-items: center; +} + +.path-input-container input { + flex: 1; +} + +.create-folder-btn { + padding: 8px; + border: 1px solid var(--border-color); + border-radius: var(--border-radius-xs); + background: var(--bg-color); + color: var(--text-color); + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; +} + +.create-folder-btn:hover { + border-color: var(--lora-accent); + background: oklch(var(--lora-accent) / 0.05); +} + +.path-suggestions { + position: absolute; + top: 42%; + left: 0; + right: 0; + z-index: 1000; + margin: 0 24px; + background: var(--bg-color); + border: 1px solid var(--border-color); + border-top: none; + border-radius: 0 0 var(--border-radius-xs) var(--border-radius-xs); + max-height: 200px; + overflow-y: auto; +} + +.path-suggestion { + padding: 8px 12px; + cursor: pointer; + transition: background-color 0.2s; + border-bottom: 1px solid var(--border-color); +} + +.path-suggestion:last-child { + border-bottom: none; +} + +.path-suggestion:hover { + background: var(--lora-surface); +} + +.path-suggestion.active { + background: oklch(var(--lora-accent) / 0.1); + color: var(--lora-accent); +} + +/* Breadcrumb Navigation Styles */ +.breadcrumb-nav { + display: flex; + align-items: center; + gap: 4px; + margin-bottom: var(--space-2); + padding: var(--space-1); + background: var(--lora-surface); + border-radius: var(--border-radius-xs); + border: 1px solid var(--border-color); + overflow-x: auto; + white-space: nowrap; +} + +.breadcrumb-item { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: var(--border-radius-xs); + cursor: pointer; + transition: all 0.2s ease; + color: var(--text-color); + opacity: 0.7; + text-decoration: none; +} + +.breadcrumb-item:hover { + background: var(--bg-color); + opacity: 1; +} + +.breadcrumb-item.active { + background: oklch(var(--lora-accent) / 0.1); + color: var(--lora-accent); + opacity: 1; +} + +.breadcrumb-separator { + color: var(--text-color); + opacity: 0.5; + margin: 0 4px; +} + +/* Folder Tree Styles */ +.folder-tree-container { + border: 1px solid var(--border-color); + border-radius: var(--border-radius-xs); + background: var(--bg-color); + max-height: 300px; + overflow-y: auto; +} + +.folder-tree { + padding: var(--space-1); +} + +.tree-node { + user-select: none; +} + +.tree-node-content { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + cursor: pointer; + border-radius: var(--border-radius-xs); + transition: all 0.2s ease; + position: relative; +} + +.tree-node-content:hover { + background: var(--lora-surface); +} + +.tree-node-content.selected { + background: oklch(var(--lora-accent) / 0.1); + border: 1px solid var(--lora-accent); +} + +.tree-expand-icon { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border-radius: 2px; + transition: all 0.2s ease; +} + +.tree-expand-icon:hover { + background: var(--lora-surface); +} + +.tree-expand-icon.expanded { + transform: rotate(90deg); +} + +.tree-folder-icon { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + color: var(--lora-accent); +} + +.tree-folder-name { + flex: 1; + font-size: 0.9em; + color: var(--text-color); +} + +.tree-children { + margin-left: 20px; + display: none; +} + +.tree-children.expanded { + display: block; +} + +.tree-node.has-children > .tree-node-content .tree-expand-icon { + opacity: 1; +} + +.tree-node:not(.has-children) > .tree-node-content .tree-expand-icon { + opacity: 0; + pointer-events: none; +} + +/* Create folder inline form */ +.create-folder-form { + display: flex; + gap: 8px; + margin-left: 20px; + margin-top: 4px; + align-items: center; +} + +.create-folder-form input { + flex: 1; + padding: 4px 8px; + border: 1px solid var(--lora-accent); + border-radius: var(--border-radius-xs); + background: var(--bg-color); + color: var(--text-color); + font-size: 0.9em; +} + +.create-folder-form button { + padding: 4px 8px; + border: 1px solid var(--border-color); + border-radius: var(--border-radius-xs); + background: var(--bg-color); + color: var(--text-color); + cursor: pointer; + font-size: 0.8em; + transition: all 0.2s ease; +} + +.create-folder-form button.confirm { + background: var(--lora-accent); + color: white; + border-color: var(--lora-accent); +} + +.create-folder-form button:hover { + background: var(--lora-surface); +} + /* Path Preview Styles */ .path-preview { margin-bottom: var(--space-3); diff --git a/static/js/api/apiConfig.js b/static/js/api/apiConfig.js index da1b1cd7..bd8c9104 100644 --- a/static/js/api/apiConfig.js +++ b/static/js/api/apiConfig.js @@ -83,6 +83,8 @@ export function getApiEndpoints(modelType) { baseModels: `/api/${modelType}/base-models`, roots: `/api/${modelType}/roots`, folders: `/api/${modelType}/folders`, + folderTree: `/api/${modelType}/folder-tree`, + unifiedFolderTree: `/api/${modelType}/unified-folder-tree`, duplicates: `/api/${modelType}/find-duplicates`, conflicts: `/api/${modelType}/find-filename-conflicts`, verify: `/api/${modelType}/verify-duplicates`, diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index 40893449..dc5f825e 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -586,6 +586,33 @@ export class BaseModelApiClient { } } + async fetchUnifiedFolderTree() { + try { + const response = await fetch(this.apiConfig.endpoints.unifiedFolderTree); + if (!response.ok) { + throw new Error(`Failed to fetch unified folder tree`); + } + return await response.json(); + } catch (error) { + console.error('Error fetching unified folder tree:', error); + throw error; + } + } + + async fetchFolderTree(modelRoot) { + try { + const params = new URLSearchParams({ model_root: modelRoot }); + const response = await fetch(`${this.apiConfig.endpoints.folderTree}?${params}`); + if (!response.ok) { + throw new Error(`Failed to fetch folder tree for root: ${modelRoot}`); + } + return await response.json(); + } catch (error) { + console.error('Error fetching folder tree:', error); + throw error; + } + } + async downloadModel(modelId, versionId, modelRoot, relativePath, downloadId) { try { const response = await fetch(DOWNLOAD_ENDPOINTS.download, { diff --git a/static/js/components/FolderTreeManager.js b/static/js/components/FolderTreeManager.js new file mode 100644 index 00000000..20f329b9 --- /dev/null +++ b/static/js/components/FolderTreeManager.js @@ -0,0 +1,499 @@ +/** + * FolderTreeManager - Manages folder tree UI for download modal + */ +export class FolderTreeManager { + constructor() { + this.treeData = {}; + this.selectedPath = ''; + this.expandedNodes = new Set(); + this.pathSuggestions = []; + this.onPathChangeCallback = null; + + // Bind methods + this.handleTreeClick = this.handleTreeClick.bind(this); + this.handlePathInput = this.handlePathInput.bind(this); + this.handlePathSuggestionClick = this.handlePathSuggestionClick.bind(this); + this.handleCreateFolder = this.handleCreateFolder.bind(this); + this.handleBreadcrumbClick = this.handleBreadcrumbClick.bind(this); + } + + /** + * Initialize the folder tree manager + * @param {Object} config - Configuration object + * @param {Function} config.onPathChange - Callback when path changes + */ + init(config = {}) { + this.onPathChangeCallback = config.onPathChange; + this.setupEventHandlers(); + } + + setupEventHandlers() { + const pathInput = document.getElementById('folderPath'); + const createFolderBtn = document.getElementById('createFolderBtn'); + const folderTree = document.getElementById('folderTree'); + const breadcrumbNav = document.getElementById('breadcrumbNav'); + const pathSuggestions = document.getElementById('pathSuggestions'); + + if (pathInput) { + pathInput.addEventListener('input', this.handlePathInput); + pathInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + this.selectCurrentInput(); + } + }); + } + + if (createFolderBtn) { + createFolderBtn.addEventListener('click', this.handleCreateFolder); + } + + if (folderTree) { + folderTree.addEventListener('click', this.handleTreeClick); + } + + if (breadcrumbNav) { + breadcrumbNav.addEventListener('click', this.handleBreadcrumbClick); + } + + if (pathSuggestions) { + pathSuggestions.addEventListener('click', this.handlePathSuggestionClick); + } + + // Hide suggestions when clicking outside + document.addEventListener('click', (e) => { + const pathInput = document.getElementById('folderPath'); + const suggestions = document.getElementById('pathSuggestions'); + + if (pathInput && suggestions && + !pathInput.contains(e.target) && + !suggestions.contains(e.target)) { + suggestions.style.display = 'none'; + } + }); + } + + /** + * Load and render folder tree data + * @param {Object} treeData - Hierarchical tree data + */ + async loadTree(treeData) { + this.treeData = treeData; + this.pathSuggestions = this.extractAllPaths(treeData); + this.renderTree(); + } + + /** + * Extract all paths from tree data for autocomplete + */ + extractAllPaths(treeData, currentPath = '') { + const paths = []; + + for (const [folderName, children] of Object.entries(treeData)) { + const newPath = currentPath ? `${currentPath}/${folderName}` : folderName; + paths.push(newPath); + + if (Object.keys(children).length > 0) { + paths.push(...this.extractAllPaths(children, newPath)); + } + } + + return paths.sort(); + } + + /** + * Render the complete folder tree + */ + renderTree() { + const folderTree = document.getElementById('folderTree'); + if (!folderTree) return; + + folderTree.innerHTML = this.renderTreeNode(this.treeData, ''); + } + + /** + * Render a single tree node + */ + renderTreeNode(nodeData, basePath) { + const entries = Object.entries(nodeData); + if (entries.length === 0) return ''; + + return entries.map(([folderName, children]) => { + const currentPath = basePath ? `${basePath}/${folderName}` : folderName; + const hasChildren = Object.keys(children).length > 0; + const isExpanded = this.expandedNodes.has(currentPath); + const isSelected = this.selectedPath === currentPath; + + return ` +