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 ` +
+
+
+ +
+
+ +
+
${folderName}
+
+ ${hasChildren ? ` +
+ ${this.renderTreeNode(children, currentPath)} +
+ ` : ''} +
+ `; + }).join(''); + } + + /** + * Handle tree node clicks + */ + handleTreeClick(event) { + const expandIcon = event.target.closest('.tree-expand-icon'); + const nodeContent = event.target.closest('.tree-node-content'); + + if (expandIcon) { + // Toggle expand/collapse + const treeNode = expandIcon.closest('.tree-node'); + const path = treeNode.dataset.path; + const children = treeNode.querySelector('.tree-children'); + + if (this.expandedNodes.has(path)) { + this.expandedNodes.delete(path); + expandIcon.classList.remove('expanded'); + if (children) children.classList.remove('expanded'); + } else { + this.expandedNodes.add(path); + expandIcon.classList.add('expanded'); + if (children) children.classList.add('expanded'); + } + } else if (nodeContent) { + // Select folder + const treeNode = nodeContent.closest('.tree-node'); + const path = treeNode.dataset.path; + this.selectPath(path); + } + } + + /** + * Handle path input changes + */ + handlePathInput(event) { + const input = event.target; + const query = input.value.toLowerCase(); + + if (query.length === 0) { + this.hideSuggestions(); + return; + } + + const matches = this.pathSuggestions.filter(path => + path.toLowerCase().includes(query) + ).slice(0, 10); // Limit to 10 suggestions + + this.showSuggestions(matches, query); + } + + /** + * Show path suggestions + */ + showSuggestions(suggestions, query) { + const suggestionsEl = document.getElementById('pathSuggestions'); + if (!suggestionsEl) return; + + if (suggestions.length === 0) { + this.hideSuggestions(); + return; + } + + suggestionsEl.innerHTML = suggestions.map(path => { + const highlighted = this.highlightMatch(path, query); + return `
${highlighted}
`; + }).join(''); + + suggestionsEl.style.display = 'block'; + } + + /** + * Hide path suggestions + */ + hideSuggestions() { + const suggestionsEl = document.getElementById('pathSuggestions'); + if (suggestionsEl) { + suggestionsEl.style.display = 'none'; + } + } + + /** + * Highlight matching text in suggestions + */ + highlightMatch(text, query) { + const index = text.toLowerCase().indexOf(query.toLowerCase()); + if (index === -1) return text; + + return text.substring(0, index) + + `${text.substring(index, index + query.length)}` + + text.substring(index + query.length); + } + + /** + * Handle suggestion clicks + */ + handlePathSuggestionClick(event) { + const suggestion = event.target.closest('.path-suggestion'); + if (suggestion) { + const path = suggestion.dataset.path; + this.selectPath(path); + this.hideSuggestions(); + } + } + + /** + * Handle create folder button click + */ + handleCreateFolder() { + const currentPath = this.selectedPath; + this.showCreateFolderForm(currentPath); + } + + /** + * Show inline create folder form + */ + showCreateFolderForm(parentPath) { + // Find the parent node in the tree + const parentNode = parentPath ? + document.querySelector(`[data-path="${parentPath}"]`) : + document.getElementById('folderTree'); + + if (!parentNode) return; + + // Check if form already exists + if (parentNode.querySelector('.create-folder-form')) return; + + const form = document.createElement('div'); + form.className = 'create-folder-form'; + form.innerHTML = ` + + + + `; + + const input = form.querySelector('.new-folder-input'); + const confirmBtn = form.querySelector('.confirm'); + const cancelBtn = form.querySelector('.cancel'); + + confirmBtn.addEventListener('click', () => { + const folderName = input.value.trim(); + if (folderName) { + this.createFolder(parentPath, folderName); + } + form.remove(); + }); + + cancelBtn.addEventListener('click', () => { + form.remove(); + }); + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + confirmBtn.click(); + } else if (e.key === 'Escape') { + cancelBtn.click(); + } + }); + + if (parentPath) { + // Add to children area + const childrenEl = parentNode.querySelector('.tree-children'); + if (childrenEl) { + childrenEl.appendChild(form); + } else { + parentNode.appendChild(form); + } + } else { + // Add to root + parentNode.appendChild(form); + } + + input.focus(); + } + + /** + * Create new folder + */ + createFolder(parentPath, folderName) { + const newPath = parentPath ? `${parentPath}/${folderName}` : folderName; + + // Add to tree data + const pathParts = newPath.split('/'); + let current = this.treeData; + + for (const part of pathParts) { + if (!current[part]) { + current[part] = {}; + } + current = current[part]; + } + + // Update suggestions + this.pathSuggestions = this.extractAllPaths(this.treeData); + + // Expand parent if needed + if (parentPath) { + this.expandedNodes.add(parentPath); + } + + // Re-render tree + this.renderTree(); + + // Select the new folder + this.selectPath(newPath); + } + + /** + * Handle breadcrumb navigation clicks + */ + handleBreadcrumbClick(event) { + const breadcrumbItem = event.target.closest('.breadcrumb-item'); + if (breadcrumbItem) { + const path = breadcrumbItem.dataset.path; + this.selectPath(path); + } + } + + /** + * Select a path and update UI + */ + selectPath(path) { + this.selectedPath = path; + + // Update path input + const pathInput = document.getElementById('folderPath'); + if (pathInput) { + pathInput.value = path; + } + + // Update tree selection + document.querySelectorAll('.tree-node-content').forEach(node => { + node.classList.remove('selected'); + }); + + const selectedNode = document.querySelector(`[data-path="${path}"] .tree-node-content`); + if (selectedNode) { + selectedNode.classList.add('selected'); + + // Expand parents to show selection + this.expandPathParents(path); + } + + // Update breadcrumbs + this.updateBreadcrumbs(path); + + // Trigger callback + if (this.onPathChangeCallback) { + this.onPathChangeCallback(path); + } + } + + /** + * Expand all parent nodes of a given path + */ + expandPathParents(path) { + const parts = path.split('/'); + let currentPath = ''; + + for (let i = 0; i < parts.length - 1; i++) { + currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]; + this.expandedNodes.add(currentPath); + } + + this.renderTree(); + } + + /** + * Update breadcrumb navigation + */ + updateBreadcrumbs(path) { + const breadcrumbNav = document.getElementById('breadcrumbNav'); + if (!breadcrumbNav) return; + + const parts = path ? path.split('/') : []; + let currentPath = ''; + + const breadcrumbs = [` + + Root + + `]; + + parts.forEach((part, index) => { + currentPath = currentPath ? `${currentPath}/${part}` : part; + const isLast = index === parts.length - 1; + + if (index > 0) { + breadcrumbs.push(`/`); + } + + breadcrumbs.push(` + + ${part} + + `); + }); + + breadcrumbNav.innerHTML = breadcrumbs.join(''); + } + + /** + * Select current input value as path + */ + selectCurrentInput() { + const pathInput = document.getElementById('folderPath'); + if (pathInput) { + const path = pathInput.value.trim(); + this.selectPath(path); + } + } + + /** + * Get the currently selected path + */ + getSelectedPath() { + return this.selectedPath; + } + + /** + * Clear selection + */ + clearSelection() { + this.selectPath(''); + } + + /** + * Clean up event handlers + */ + destroy() { + 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.removeEventListener('input', this.handlePathInput); + } + if (createFolderBtn) { + createFolderBtn.removeEventListener('click', this.handleCreateFolder); + } + if (folderTree) { + folderTree.removeEventListener('click', this.handleTreeClick); + } + if (breadcrumbNav) { + breadcrumbNav.removeEventListener('click', this.handleBreadcrumbClick); + } + if (pathSuggestions) { + pathSuggestions.removeEventListener('click', this.handlePathSuggestionClick); + } + } +} diff --git a/static/js/managers/DownloadManager.js b/static/js/managers/DownloadManager.js index d0420b84..d8f3b652 100644 --- a/static/js/managers/DownloadManager.js +++ b/static/js/managers/DownloadManager.js @@ -3,6 +3,7 @@ import { showToast } from '../utils/uiHelpers.js'; import { LoadingManager } from './LoadingManager.js'; import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js'; import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; +import { FolderTreeManager } from '../components/FolderTreeManager.js'; export class DownloadManager { constructor() { @@ -17,6 +18,7 @@ export class DownloadManager { this.apiClient = null; this.loadingManager = new LoadingManager(); + this.folderTreeManager = new FolderTreeManager(); this.folderClickHandler = null; this.updateTargetPath = this.updateTargetPath.bind(this); @@ -106,9 +108,10 @@ export class DownloadManager { document.getElementById('modelUrl').value = ''; document.getElementById('urlError').textContent = ''; - const newFolderInput = document.getElementById('newFolder'); - if (newFolderInput) { - newFolderInput.value = ''; + // Clear folder path input + const folderPathInput = document.getElementById('folderPath'); + if (folderPathInput) { + folderPathInput.value = ''; } this.currentVersion = null; @@ -118,10 +121,10 @@ export class DownloadManager { this.modelVersionId = null; this.selectedFolder = ''; - const folderBrowser = document.getElementById('folderBrowser'); - if (folderBrowser) { - folderBrowser.querySelectorAll('.folder-item').forEach(f => - f.classList.remove('selected')); + + // Clear folder tree selection + if (this.folderTreeManager) { + this.folderTreeManager.clearSelection(); } } @@ -302,15 +305,24 @@ export class DownloadManager { modelRoot.value = defaultRoot; } - // Fetch folders - const foldersData = await this.apiClient.fetchModelFolders(); - const folderBrowser = document.getElementById('folderBrowser'); + // Initialize folder tree + await this.initializeFolderTree(); - folderBrowser.innerHTML = foldersData.folders.map(folder => - `
${folder}
` - ).join(''); - - this.initializeFolderBrowser(); + // Setup folder tree manager + this.folderTreeManager.init({ + onPathChange: (path) => { + this.selectedFolder = path; + this.updateTargetPath(); + } + }); + + // Setup model root change handler + modelRoot.addEventListener('change', async () => { + await this.initializeFolderTree(); + this.updateTargetPath(); + }); + + this.updateTargetPath(); } catch (error) { showToast(error.message, 'error'); } @@ -327,12 +339,15 @@ export class DownloadManager { } closeModal() { + // Clean up folder tree manager + if (this.folderTreeManager) { + this.folderTreeManager.destroy(); + } modalManager.closeModal('downloadModal'); } async startDownload() { const modelRoot = document.getElementById('modelRoot').value; - const newFolder = document.getElementById('newFolder').value.trim(); const config = this.apiClient.apiConfig.config; if (!modelRoot) { @@ -340,15 +355,8 @@ export class DownloadManager { return; } - // Construct relative path - let targetFolder = ''; - if (this.selectedFolder) { - targetFolder = this.selectedFolder; - } - if (newFolder) { - targetFolder = targetFolder ? - `${targetFolder}/${newFolder}` : newFolder; - } + // Get selected folder path from folder tree manager + const targetFolder = this.folderTreeManager.getSelectedPath(); try { const updateProgress = this.loadingManager.showDownloadProgress(1); @@ -426,6 +434,24 @@ export class DownloadManager { } } + async initializeFolderTree() { + try { + // Fetch unified folder tree + const treeData = await this.apiClient.fetchUnifiedFolderTree(); + + if (treeData.success) { + // Load tree data into folder tree manager + await this.folderTreeManager.loadTree(treeData.tree); + } else { + console.error('Failed to fetch folder tree:', treeData.error); + showToast('Failed to load folder tree', 'error'); + } + } catch (error) { + console.error('Error initializing folder tree:', error); + showToast('Error loading folder tree', 'error'); + } + } + initializeFolderBrowser() { const folderBrowser = document.getElementById('folderBrowser'); if (!folderBrowser) return; @@ -479,17 +505,14 @@ export class DownloadManager { updateTargetPath() { const pathDisplay = document.getElementById('targetPathDisplay'); const modelRoot = document.getElementById('modelRoot').value; - const newFolder = document.getElementById('newFolder').value.trim(); const config = this.apiClient.apiConfig.config; let fullPath = modelRoot || `Select a ${config.displayName} root directory`; if (modelRoot) { - if (this.selectedFolder) { - fullPath += '/' + this.selectedFolder; - } - if (newFolder) { - fullPath += '/' + newFolder; + const selectedPath = this.folderTreeManager ? this.folderTreeManager.getSelectedPath() : ''; + if (selectedPath) { + fullPath += '/' + selectedPath; } } diff --git a/templates/components/modals/download_modal.html b/templates/components/modals/download_modal.html index 6215e5f0..883e5b78 100644 --- a/templates/components/modals/download_modal.html +++ b/templates/components/modals/download_modal.html @@ -42,15 +42,34 @@ + +
- -
- + +
+ +
+
+ + + + +
- - + +
+
+ +
+