feat: add folder tree and unified folder tree endpoints, enhance download modal with folder path input and tree navigation

This commit is contained in:
Will Miao
2025-08-12 22:34:53 +08:00
parent 71ddfafa98
commit 286f4ff384
8 changed files with 944 additions and 37 deletions

View File

@@ -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:

View File

@@ -272,4 +272,62 @@ class BaseModelService(ABC):
def get_model_roots(self) -> List[str]:
"""Get model root directories"""
return self.scanner.get_model_roots()
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

View File

@@ -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);

View File

@@ -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`,

View File

@@ -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, {

View File

@@ -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 `
<div class="tree-node ${hasChildren ? 'has-children' : ''}" data-path="${currentPath}">
<div class="tree-node-content ${isSelected ? 'selected' : ''}">
<div class="tree-expand-icon ${isExpanded ? 'expanded' : ''}"
style="${hasChildren ? '' : 'opacity: 0; pointer-events: none;'}">
<i class="fas fa-chevron-right"></i>
</div>
<div class="tree-folder-icon">
<i class="fas fa-folder"></i>
</div>
<div class="tree-folder-name">${folderName}</div>
</div>
${hasChildren ? `
<div class="tree-children ${isExpanded ? 'expanded' : ''}">
${this.renderTreeNode(children, currentPath)}
</div>
` : ''}
</div>
`;
}).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 `<div class="path-suggestion" data-path="${path}">${highlighted}</div>`;
}).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) +
`<strong>${text.substring(index, index + query.length)}</strong>` +
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 = `
<input type="text" placeholder="New folder name" class="new-folder-input" />
<button type="button" class="confirm">✓</button>
<button type="button" class="cancel">✗</button>
`;
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 = [`
<span class="breadcrumb-item ${!path ? 'active' : ''}" data-path="">
<i class="fas fa-home"></i> Root
</span>
`];
parts.forEach((part, index) => {
currentPath = currentPath ? `${currentPath}/${part}` : part;
const isLast = index === parts.length - 1;
if (index > 0) {
breadcrumbs.push(`<span class="breadcrumb-separator">/</span>`);
}
breadcrumbs.push(`
<span class="breadcrumb-item ${isLast ? 'active' : ''}" data-path="${currentPath}">
${part}
</span>
`);
});
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);
}
}
}

View File

@@ -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 =>
`<div class="folder-item" data-folder="${folder}">${folder}</div>`
).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;
}
}

View File

@@ -42,15 +42,34 @@
<label for="modelRoot" id="modelRootLabel">Select Model Root:</label>
<select id="modelRoot"></select>
</div>
<!-- Path input with autocomplete -->
<div class="input-group">
<label>Target Folder:</label>
<div class="folder-browser" id="folderBrowser">
<!-- Folders will be loaded dynamically -->
<label for="folderPath">Target Folder Path:</label>
<div class="path-input-container">
<input type="text" id="folderPath" placeholder="Type folder path or select from tree below..." />
<button type="button" id="createFolderBtn" class="create-folder-btn" title="Create new folder">
<i class="fas fa-plus"></i>
</button>
</div>
<div class="path-suggestions" id="pathSuggestions" style="display: none;"></div>
</div>
<!-- Breadcrumb navigation -->
<div class="breadcrumb-nav" id="breadcrumbNav">
<span class="breadcrumb-item root" data-path="">
<i class="fas fa-home"></i> Root
</span>
</div>
<!-- Hierarchical folder tree -->
<div class="input-group">
<label for="newFolder">New Folder (optional):</label>
<input type="text" id="newFolder" placeholder="Enter folder name" />
<label>Browse Folders:</label>
<div class="folder-tree-container">
<div class="folder-tree" id="folderTree">
<!-- Tree will be loaded dynamically -->
</div>
</div>
</div>
</div>
<div class="modal-actions">