mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
feat: add folder tree and unified folder tree endpoints, enhance download modal with folder path input and tree navigation
This commit is contained in:
@@ -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}/scan', self.scan_models)
|
||||||
app.router.add_get(f'/api/{prefix}/roots', self.get_model_roots)
|
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}/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-duplicates', self.find_duplicate_models)
|
||||||
app.router.add_get(f'/api/{prefix}/find-filename-conflicts', self.find_filename_conflicts)
|
app.router.add_get(f'/api/{prefix}/find-filename-conflicts', self.find_filename_conflicts)
|
||||||
|
|
||||||
@@ -346,6 +348,43 @@ class BaseModelRoutes(ABC):
|
|||||||
'error': str(e)
|
'error': str(e)
|
||||||
}, status=500)
|
}, 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:
|
async def find_duplicate_models(self, request: web.Request) -> web.Response:
|
||||||
"""Find models with duplicate SHA256 hashes"""
|
"""Find models with duplicate SHA256 hashes"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -272,4 +272,62 @@ class BaseModelService(ABC):
|
|||||||
|
|
||||||
def get_model_roots(self) -> List[str]:
|
def get_model_roots(self) -> List[str]:
|
||||||
"""Get model root directories"""
|
"""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
|
||||||
@@ -150,6 +150,246 @@
|
|||||||
border: 1px solid var(--lora-accent);
|
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 Styles */
|
||||||
.path-preview {
|
.path-preview {
|
||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-3);
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ export function getApiEndpoints(modelType) {
|
|||||||
baseModels: `/api/${modelType}/base-models`,
|
baseModels: `/api/${modelType}/base-models`,
|
||||||
roots: `/api/${modelType}/roots`,
|
roots: `/api/${modelType}/roots`,
|
||||||
folders: `/api/${modelType}/folders`,
|
folders: `/api/${modelType}/folders`,
|
||||||
|
folderTree: `/api/${modelType}/folder-tree`,
|
||||||
|
unifiedFolderTree: `/api/${modelType}/unified-folder-tree`,
|
||||||
duplicates: `/api/${modelType}/find-duplicates`,
|
duplicates: `/api/${modelType}/find-duplicates`,
|
||||||
conflicts: `/api/${modelType}/find-filename-conflicts`,
|
conflicts: `/api/${modelType}/find-filename-conflicts`,
|
||||||
verify: `/api/${modelType}/verify-duplicates`,
|
verify: `/api/${modelType}/verify-duplicates`,
|
||||||
|
|||||||
@@ -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) {
|
async downloadModel(modelId, versionId, modelRoot, relativePath, downloadId) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(DOWNLOAD_ENDPOINTS.download, {
|
const response = await fetch(DOWNLOAD_ENDPOINTS.download, {
|
||||||
|
|||||||
499
static/js/components/FolderTreeManager.js
Normal file
499
static/js/components/FolderTreeManager.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { showToast } from '../utils/uiHelpers.js';
|
|||||||
import { LoadingManager } from './LoadingManager.js';
|
import { LoadingManager } from './LoadingManager.js';
|
||||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||||
|
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
||||||
|
|
||||||
export class DownloadManager {
|
export class DownloadManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -17,6 +18,7 @@ export class DownloadManager {
|
|||||||
this.apiClient = null;
|
this.apiClient = null;
|
||||||
|
|
||||||
this.loadingManager = new LoadingManager();
|
this.loadingManager = new LoadingManager();
|
||||||
|
this.folderTreeManager = new FolderTreeManager();
|
||||||
this.folderClickHandler = null;
|
this.folderClickHandler = null;
|
||||||
this.updateTargetPath = this.updateTargetPath.bind(this);
|
this.updateTargetPath = this.updateTargetPath.bind(this);
|
||||||
|
|
||||||
@@ -106,9 +108,10 @@ export class DownloadManager {
|
|||||||
document.getElementById('modelUrl').value = '';
|
document.getElementById('modelUrl').value = '';
|
||||||
document.getElementById('urlError').textContent = '';
|
document.getElementById('urlError').textContent = '';
|
||||||
|
|
||||||
const newFolderInput = document.getElementById('newFolder');
|
// Clear folder path input
|
||||||
if (newFolderInput) {
|
const folderPathInput = document.getElementById('folderPath');
|
||||||
newFolderInput.value = '';
|
if (folderPathInput) {
|
||||||
|
folderPathInput.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
this.currentVersion = null;
|
this.currentVersion = null;
|
||||||
@@ -118,10 +121,10 @@ export class DownloadManager {
|
|||||||
this.modelVersionId = null;
|
this.modelVersionId = null;
|
||||||
|
|
||||||
this.selectedFolder = '';
|
this.selectedFolder = '';
|
||||||
const folderBrowser = document.getElementById('folderBrowser');
|
|
||||||
if (folderBrowser) {
|
// Clear folder tree selection
|
||||||
folderBrowser.querySelectorAll('.folder-item').forEach(f =>
|
if (this.folderTreeManager) {
|
||||||
f.classList.remove('selected'));
|
this.folderTreeManager.clearSelection();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,15 +305,24 @@ export class DownloadManager {
|
|||||||
modelRoot.value = defaultRoot;
|
modelRoot.value = defaultRoot;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch folders
|
// Initialize folder tree
|
||||||
const foldersData = await this.apiClient.fetchModelFolders();
|
await this.initializeFolderTree();
|
||||||
const folderBrowser = document.getElementById('folderBrowser');
|
|
||||||
|
|
||||||
folderBrowser.innerHTML = foldersData.folders.map(folder =>
|
// Setup folder tree manager
|
||||||
`<div class="folder-item" data-folder="${folder}">${folder}</div>`
|
this.folderTreeManager.init({
|
||||||
).join('');
|
onPathChange: (path) => {
|
||||||
|
this.selectedFolder = path;
|
||||||
this.initializeFolderBrowser();
|
this.updateTargetPath();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup model root change handler
|
||||||
|
modelRoot.addEventListener('change', async () => {
|
||||||
|
await this.initializeFolderTree();
|
||||||
|
this.updateTargetPath();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.updateTargetPath();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error.message, 'error');
|
showToast(error.message, 'error');
|
||||||
}
|
}
|
||||||
@@ -327,12 +339,15 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
closeModal() {
|
closeModal() {
|
||||||
|
// Clean up folder tree manager
|
||||||
|
if (this.folderTreeManager) {
|
||||||
|
this.folderTreeManager.destroy();
|
||||||
|
}
|
||||||
modalManager.closeModal('downloadModal');
|
modalManager.closeModal('downloadModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
async startDownload() {
|
async startDownload() {
|
||||||
const modelRoot = document.getElementById('modelRoot').value;
|
const modelRoot = document.getElementById('modelRoot').value;
|
||||||
const newFolder = document.getElementById('newFolder').value.trim();
|
|
||||||
const config = this.apiClient.apiConfig.config;
|
const config = this.apiClient.apiConfig.config;
|
||||||
|
|
||||||
if (!modelRoot) {
|
if (!modelRoot) {
|
||||||
@@ -340,15 +355,8 @@ export class DownloadManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construct relative path
|
// Get selected folder path from folder tree manager
|
||||||
let targetFolder = '';
|
const targetFolder = this.folderTreeManager.getSelectedPath();
|
||||||
if (this.selectedFolder) {
|
|
||||||
targetFolder = this.selectedFolder;
|
|
||||||
}
|
|
||||||
if (newFolder) {
|
|
||||||
targetFolder = targetFolder ?
|
|
||||||
`${targetFolder}/${newFolder}` : newFolder;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updateProgress = this.loadingManager.showDownloadProgress(1);
|
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() {
|
initializeFolderBrowser() {
|
||||||
const folderBrowser = document.getElementById('folderBrowser');
|
const folderBrowser = document.getElementById('folderBrowser');
|
||||||
if (!folderBrowser) return;
|
if (!folderBrowser) return;
|
||||||
@@ -479,17 +505,14 @@ export class DownloadManager {
|
|||||||
updateTargetPath() {
|
updateTargetPath() {
|
||||||
const pathDisplay = document.getElementById('targetPathDisplay');
|
const pathDisplay = document.getElementById('targetPathDisplay');
|
||||||
const modelRoot = document.getElementById('modelRoot').value;
|
const modelRoot = document.getElementById('modelRoot').value;
|
||||||
const newFolder = document.getElementById('newFolder').value.trim();
|
|
||||||
const config = this.apiClient.apiConfig.config;
|
const config = this.apiClient.apiConfig.config;
|
||||||
|
|
||||||
let fullPath = modelRoot || `Select a ${config.displayName} root directory`;
|
let fullPath = modelRoot || `Select a ${config.displayName} root directory`;
|
||||||
|
|
||||||
if (modelRoot) {
|
if (modelRoot) {
|
||||||
if (this.selectedFolder) {
|
const selectedPath = this.folderTreeManager ? this.folderTreeManager.getSelectedPath() : '';
|
||||||
fullPath += '/' + this.selectedFolder;
|
if (selectedPath) {
|
||||||
}
|
fullPath += '/' + selectedPath;
|
||||||
if (newFolder) {
|
|
||||||
fullPath += '/' + newFolder;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,15 +42,34 @@
|
|||||||
<label for="modelRoot" id="modelRootLabel">Select Model Root:</label>
|
<label for="modelRoot" id="modelRootLabel">Select Model Root:</label>
|
||||||
<select id="modelRoot"></select>
|
<select id="modelRoot"></select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Path input with autocomplete -->
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label>Target Folder:</label>
|
<label for="folderPath">Target Folder Path:</label>
|
||||||
<div class="folder-browser" id="folderBrowser">
|
<div class="path-input-container">
|
||||||
<!-- Folders will be loaded dynamically -->
|
<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>
|
||||||
|
<div class="path-suggestions" id="pathSuggestions" style="display: none;"></div>
|
||||||
</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">
|
<div class="input-group">
|
||||||
<label for="newFolder">New Folder (optional):</label>
|
<label>Browse Folders:</label>
|
||||||
<input type="text" id="newFolder" placeholder="Enter folder name" />
|
<div class="folder-tree-container">
|
||||||
|
<div class="folder-tree" id="folderTree">
|
||||||
|
<!-- Tree will be loaded dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
|
|||||||
Reference in New Issue
Block a user