From cc8eedcff74349773383e34dd08e3cfdc9ea18b9 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Tue, 15 Sep 2026 19:47:39 +0800 Subject: [PATCH] refactor(sidebar): inline new-folder row, drop drag-to-blank creation (#999) - Render the new-folder input as a temporary tree row at the creation location (file-explorer style): full-width input confirmed with Enter and canceled with Escape/blur; the parent folder auto-expands, and in list mode the row is inserted after the parent item - Remove the drag-to-blank-area folder creation (drop-zone strip, sidebar-level drag handlers, performDragMoveWithState); dropping models onto folder nodes still moves them - Update empty-state hints and locale keys accordingly --- locales/de.json | 5 +- locales/en.json | 5 +- locales/es.json | 5 +- locales/fr.json | 5 +- locales/he.json | 5 +- locales/ja.json | 5 +- locales/ko.json | 5 +- locales/ru.json | 5 +- locales/zh-CN.json | 5 +- locales/zh-TW.json | 5 +- static/css/components/sidebar.css | 138 +---- static/js/components/SidebarManager.js | 508 +++++------------- .../sidebarManager.folderManagement.test.js | 113 +++- 13 files changed, 258 insertions(+), 551 deletions(-) diff --git a/locales/de.json b/locales/de.json index e9fe6402..f7058d01 100644 --- a/locales/de.json +++ b/locales/de.json @@ -1266,17 +1266,14 @@ "dragDrop": { "unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.", "moveUnsupported": "Verschieben wird für dieses Element nicht unterstützt.", - "createFolderHint": "Loslassen, um einen neuen Ordner zu erstellen", "newFolderName": "Neuer Ordnername", - "folderNameHint": "Eingabetaste zum Bestätigen, Escape zum Abbrechen", "emptyFolderName": "Bitte geben Sie einen Ordnernamen ein", "invalidFolderName": "Ordnername enthält ungültige Zeichen", "noDragState": "Kein ausstehender Ziehvorgang gefunden" }, "empty": { "noFolders": "Keine Ordner gefunden", - "dragHint": "Elemente hierher ziehen, um Ordner zu erstellen", - "createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders" + "createHint": "[TODO: Translate] Click the New Folder button above to create folders" }, "folderUpdateCheck": { "label": "Auf Updates in diesem Ordner prüfen", diff --git a/locales/en.json b/locales/en.json index 183ccd12..db841f3b 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1266,17 +1266,14 @@ "dragDrop": { "unableToResolveRoot": "Unable to determine destination path for move.", "moveUnsupported": "Move is not supported for this item.", - "createFolderHint": "Release to create new folder", "newFolderName": "New folder name", - "folderNameHint": "Press Enter to confirm, Escape to cancel", "emptyFolderName": "Please enter a folder name", "invalidFolderName": "Folder name contains invalid characters", "noDragState": "No pending drag operation found" }, "empty": { "noFolders": "No folders found", - "dragHint": "Drag items here to create folders", - "createHint": "Click the New Folder button above, or drag items here to create folders" + "createHint": "Click the New Folder button above to create folders" }, "folderUpdateCheck": { "label": "Check for updates in this folder", diff --git a/locales/es.json b/locales/es.json index 71d7be61..28193ecd 100644 --- a/locales/es.json +++ b/locales/es.json @@ -1266,17 +1266,14 @@ "dragDrop": { "unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.", "moveUnsupported": "El movimiento no es compatible con este elemento.", - "createFolderHint": "Suelta para crear una nueva carpeta", "newFolderName": "Nombre de la nueva carpeta", - "folderNameHint": "Presiona Enter para confirmar, Escape para cancelar", "emptyFolderName": "Por favor, introduce un nombre de carpeta", "invalidFolderName": "El nombre de la carpeta contiene caracteres no válidos", "noDragState": "No se encontró ninguna operación de arrastre pendiente" }, "empty": { "noFolders": "No se encontraron carpetas", - "dragHint": "Arrastra elementos aquí para crear carpetas", - "createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders" + "createHint": "[TODO: Translate] Click the New Folder button above to create folders" }, "folderUpdateCheck": { "label": "Buscar actualizaciones en esta carpeta", diff --git a/locales/fr.json b/locales/fr.json index 35d5bcba..0ee41ce8 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -1266,17 +1266,14 @@ "dragDrop": { "unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.", "moveUnsupported": "Le déplacement n'est pas pris en charge pour cet élément.", - "createFolderHint": "Relâcher pour créer un nouveau dossier", "newFolderName": "Nom du nouveau dossier", - "folderNameHint": "Appuyez sur Entrée pour confirmer, Échap pour annuler", "emptyFolderName": "Veuillez saisir un nom de dossier", "invalidFolderName": "Le nom du dossier contient des caractères invalides", "noDragState": "Aucune opération de glissement en attente trouvée" }, "empty": { "noFolders": "Aucun dossier trouvé", - "dragHint": "Faites glisser des éléments ici pour créer des dossiers", - "createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders" + "createHint": "[TODO: Translate] Click the New Folder button above to create folders" }, "folderUpdateCheck": { "label": "Vérifier les mises à jour dans ce dossier", diff --git a/locales/he.json b/locales/he.json index 5a7b91e2..dbff4651 100644 --- a/locales/he.json +++ b/locales/he.json @@ -1266,17 +1266,14 @@ "dragDrop": { "unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.", "moveUnsupported": "העברה אינה נתמכת עבור פריט זה.", - "createFolderHint": "שחרר כדי ליצור תיקייה חדשה", "newFolderName": "שם תיקייה חדשה", - "folderNameHint": "הקש Enter לאישור, Escape לביטול", "emptyFolderName": "אנא הזן שם תיקייה", "invalidFolderName": "שם התיקייה מכיל תווים לא חוקיים", "noDragState": "לא נמצאה פעולת גרירה ממתינה" }, "empty": { "noFolders": "לא נמצאו תיקיות", - "dragHint": "גרור פריטים לכאן כדי ליצור תיקיות", - "createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders" + "createHint": "[TODO: Translate] Click the New Folder button above to create folders" }, "folderUpdateCheck": { "label": "בדוק עדכונים בתיקייה זו", diff --git a/locales/ja.json b/locales/ja.json index e39c2fa1..385564c5 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -1266,17 +1266,14 @@ "dragDrop": { "unableToResolveRoot": "移動先のパスを特定できません。", "moveUnsupported": "この項目の移動はサポートされていません。", - "createFolderHint": "放して新しいフォルダを作成", "newFolderName": "新しいフォルダ名", - "folderNameHint": "Enterで確定、Escでキャンセル", "emptyFolderName": "フォルダ名を入力してください", "invalidFolderName": "フォルダ名に無効な文字が含まれています", "noDragState": "保留中のドラッグ操作が見つかりません" }, "empty": { "noFolders": "フォルダが見つかりません", - "dragHint": "ここへアイテムをドラッグしてフォルダを作成します", - "createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders" + "createHint": "[TODO: Translate] Click the New Folder button above to create folders" }, "folderUpdateCheck": { "label": "このフォルダのアップデートを確認", diff --git a/locales/ko.json b/locales/ko.json index 7c224c20..e86ff47b 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -1266,17 +1266,14 @@ "dragDrop": { "unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.", "moveUnsupported": "이 항목은 이동을 지원하지 않습니다.", - "createFolderHint": "놓아서 새 폴더 만들기", "newFolderName": "새 폴더 이름", - "folderNameHint": "Enter를 눌러 확인, Escape를 눌러 취소", "emptyFolderName": "폴더 이름을 입력하세요", "invalidFolderName": "폴더 이름에 잘못된 문자가 포함되어 있습니다", "noDragState": "보류 중인 드래그 작업을 찾을 수 없습니다" }, "empty": { "noFolders": "폴더를 찾을 수 없습니다", - "dragHint": "항목을 여기로 드래그하여 폴더를 만듭니다", - "createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders" + "createHint": "[TODO: Translate] Click the New Folder button above to create folders" }, "folderUpdateCheck": { "label": "이 폴더의 업데이트 확인", diff --git a/locales/ru.json b/locales/ru.json index 5a4c18ba..4359e40d 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1266,17 +1266,14 @@ "dragDrop": { "unableToResolveRoot": "Не удалось определить путь назначения для перемещения.", "moveUnsupported": "Перемещение этого элемента не поддерживается.", - "createFolderHint": "Отпустите, чтобы создать новую папку", "newFolderName": "Имя новой папки", - "folderNameHint": "Нажмите Enter для подтверждения, Escape для отмены", "emptyFolderName": "Пожалуйста, введите имя папки", "invalidFolderName": "Имя папки содержит недопустимые символы", "noDragState": "Ожидающая операция перетаскивания не найдена" }, "empty": { "noFolders": "Папки не найдены", - "dragHint": "Перетащите элементы сюда, чтобы создать папки", - "createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders" + "createHint": "[TODO: Translate] Click the New Folder button above to create folders" }, "folderUpdateCheck": { "label": "Проверить обновления в этой папке", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 03893ba9..4abc41c0 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -1266,17 +1266,14 @@ "dragDrop": { "unableToResolveRoot": "无法确定移动的目标路径。", "moveUnsupported": "此条目不支持移动。", - "createFolderHint": "释放以创建新文件夹", "newFolderName": "新文件夹名称", - "folderNameHint": "按 Enter 确认,Escape 取消", "emptyFolderName": "请输入文件夹名称", "invalidFolderName": "文件夹名称包含无效字符", "noDragState": "未找到待处理的拖放操作" }, "empty": { "noFolders": "未找到文件夹", - "dragHint": "拖拽项目到此处以创建文件夹", - "createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders" + "createHint": "[TODO: Translate] Click the New Folder button above to create folders" }, "folderUpdateCheck": { "label": "检查此文件夹的更新", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 9ac896e3..a85a7081 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -1266,17 +1266,14 @@ "dragDrop": { "unableToResolveRoot": "無法確定移動的目標路徑。", "moveUnsupported": "此項目不支援移動。", - "createFolderHint": "放開以建立新資料夾", "newFolderName": "新資料夾名稱", - "folderNameHint": "按 Enter 確認,Escape 取消", "emptyFolderName": "請輸入資料夾名稱", "invalidFolderName": "資料夾名稱包含無效字元", "noDragState": "未找到待處理的拖放操作" }, "empty": { "noFolders": "未找到資料夾", - "dragHint": "將項目拖到此處以建立資料夾", - "createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders" + "createHint": "[TODO: Translate] Click the New Folder button above to create folders" }, "folderUpdateCheck": { "label": "檢查此資料夾的更新", diff --git a/static/css/components/sidebar.css b/static/css/components/sidebar.css index e52232a0..82007ea4 100644 --- a/static/css/components/sidebar.css +++ b/static/css/components/sidebar.css @@ -639,90 +639,40 @@ display: inline; } -/* Create folder drop zone */ -.sidebar-create-folder-zone { - position: absolute; - bottom: 16px; - left: 16px; - right: 16px; - padding: 16px; - border: 2px dashed oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.4); - border-radius: var(--border-radius-xs); - background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.08); +/* Create folder inline row: rendered inside the tree at the creation + location, styled like a regular node row with a full-width input */ +.sidebar-create-folder-row { + padding-top: 4px; + padding-bottom: 4px; + cursor: default; +} + +.sidebar-tree-node-content.sidebar-create-folder-row:hover, +.sidebar-node-content.sidebar-create-folder-row:hover { + background: transparent; + color: var(--text-color); +} + +.sidebar-create-folder-spacer { opacity: 0; - transform: translateY(10px); - transition: var(--transition-base); pointer-events: none; - z-index: 10; } -.sidebar-create-folder-zone.active { - opacity: 1; - transform: translateY(0); -} - -.sidebar-create-folder-content { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; +.sidebar-create-folder-row .sidebar-tree-folder-icon, +.sidebar-create-folder-row .sidebar-folder-icon { color: var(--lora-accent); - font-size: 0.85em; - text-align: center; -} - -.sidebar-create-folder-content i { - font-size: 1.5em; - opacity: 0.8; -} - -/* Create folder input container */ -.sidebar-create-folder-input-container { - /* Sticky footer inside the scroll container: always visible at the - bottom of the viewport regardless of tree scroll position */ - position: sticky; - bottom: 8px; - margin: 8px 16px 0; - padding: 12px; - background: var(--bg-color); - border: 1px solid var(--border-color); - border-radius: var(--border-radius-xs); - box-shadow: var(--shadow-lg); - z-index: 20; - animation: slideUp 0.2s ease; -} - -@keyframes slideUp { - from { - opacity: 0; - transform: translateY(10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.sidebar-create-folder-input-wrapper { - display: flex; - align-items: center; - gap: 8px; -} - -.sidebar-create-folder-input-wrapper > i { - color: var(--lora-accent); - font-size: 1em; + opacity: 0.9; } .sidebar-create-folder-input { flex: 1; min-width: 0; /* allow the input to shrink below its intrinsic width */ - padding: 6px 10px; + padding: 4px 8px; border: 1px solid var(--border-color); border-radius: var(--border-radius-xs); background: var(--bg-color); color: var(--text-color); - font-size: 0.85em; + font-size: 1em; outline: none; transition: var(--transition-base); } @@ -732,49 +682,6 @@ box-shadow: 0 0 0 2px oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15); } -.sidebar-create-folder-btn { - width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; - border: none; - border-radius: var(--border-radius-xs); - cursor: pointer; - transition: var(--transition-base); - background: transparent; - color: var(--text-muted); -} - -.sidebar-create-folder-btn:hover, -.sidebar-create-folder-btn:focus-visible { - background: var(--lora-surface); - color: var(--text-color); - outline: none; -} - -.sidebar-create-folder-confirm:hover, -.sidebar-create-folder-confirm:focus-visible { - background: oklch(from var(--success-color) l c h / 0.15); - color: var(--success-color); - outline: none; -} - -.sidebar-create-folder-cancel:hover, -.sidebar-create-folder-cancel:focus-visible { - background: oklch(from var(--error-color) l c h / 0.15); - color: var(--error-color); - outline: none; -} - -.sidebar-create-folder-hint { - margin-top: 6px; - font-size: 0.75em; - color: var(--text-muted); - text-align: center; - opacity: 0.8; -} - /* Dragging state for sidebar */ .folder-sidebar.dragging-active { border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.5); @@ -786,11 +693,6 @@ background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.02); } -/* Tree container positioning for create folder elements */ -.sidebar-tree-container { - position: relative; -} - /* Folder context menu - positioned relative to sidebar */ #sidebarFolderContextMenu { z-index: var(--z-modal, 1002); diff --git a/static/js/components/SidebarManager.js b/static/js/components/SidebarManager.js index c87ce7d9..08af10a0 100644 --- a/static/js/components/SidebarManager.js +++ b/static/js/components/SidebarManager.js @@ -36,17 +36,16 @@ export class SidebarManager { this.draggedRootPath = null; this.draggedFromBulk = false; this.dragHandlersInitialized = false; - this.sidebarDragHandlersInitialized = false; this.folderTreeElement = null; this.currentDropTarget = null; this.lastPageControls = null; this.isDisabledByPage = false; this.initializationPromise = null; this.isCreatingFolder = false; - this._pendingDragState = null; // 用于保存拖拽创建文件夹时的状态 this.showEmptyFolders = false; this.nonEmptyFolders = null; // models-only folder set used to dim empty nodes this._createFolderBasePath = null; + this._createFolderTempChildren = null; // children container added for a leaf parent during inline creation // Bind methods this.handleTreeClick = this.handleTreeClick.bind(this); @@ -68,10 +67,6 @@ export class SidebarManager { this.handleFolderDragOver = this.handleFolderDragOver.bind(this); this.handleFolderDragLeave = this.handleFolderDragLeave.bind(this); this.handleFolderDrop = this.handleFolderDrop.bind(this); - this.handleSidebarDragEnter = this.handleSidebarDragEnter.bind(this); - this.handleSidebarDragOver = this.handleSidebarDragOver.bind(this); - this.handleSidebarDragLeave = this.handleSidebarDragLeave.bind(this); - this.handleSidebarDrop = this.handleSidebarDrop.bind(this); this.handleCreateFolderSubmit = this.handleCreateFolderSubmit.bind(this); this.handleCreateFolderCancel = this.handleCreateFolderCancel.bind(this); this.handleHideToggle = this.handleHideToggle.bind(this); @@ -121,16 +116,6 @@ export class SidebarManager { this.resetDragState(); this.hideCreateFolderInput(); - // Cleanup sidebar drag handlers - const sidebar = document.getElementById('folderSidebar'); - if (sidebar && this.sidebarDragHandlersInitialized) { - sidebar.removeEventListener('dragenter', this.handleSidebarDragEnter); - sidebar.removeEventListener('dragover', this.handleSidebarDragOver); - sidebar.removeEventListener('dragleave', this.handleSidebarDragLeave); - sidebar.removeEventListener('drop', this.handleSidebarDrop); - this.sidebarDragHandlersInitialized = false; - } - this.hideSidebarHiddenIndicator(); // Reset state @@ -147,6 +132,7 @@ export class SidebarManager { this.showEmptyFolders = false; this.nonEmptyFolders = null; this._createFolderBasePath = null; + this._createFolderTempChildren = null; // Reset container margin const container = document.querySelector('.container'); @@ -233,16 +219,6 @@ export class SidebarManager { this.folderTreeElement = folderTree; } - - // Add sidebar-level drag handlers for creating new folders - const sidebar = document.getElementById('folderSidebar'); - if (sidebar && !this.sidebarDragHandlersInitialized) { - sidebar.addEventListener('dragenter', this.handleSidebarDragEnter); - sidebar.addEventListener('dragover', this.handleSidebarDragOver); - sidebar.addEventListener('dragleave', this.handleSidebarDragLeave); - sidebar.addEventListener('drop', this.handleSidebarDrop); - this.sidebarDragHandlersInitialized = true; - } } handleCardDragStart(event) { @@ -303,7 +279,7 @@ export class SidebarManager { if (sidebar) { sidebar.classList.remove('dragging-active'); } - + this.clearAllDropHighlights(); this.resetDragState(); } @@ -564,354 +540,141 @@ export class SidebarManager { this.draggedFromBulk = false; } - // Version of performDragMove that accepts state as parameters (for create folder submit) - async performDragMoveWithState(targetRelativePath, draggedFilePaths, draggedRootPath, draggedFromBulk) { - console.log('[SidebarManager] performDragMoveWithState called with:', { targetRelativePath, draggedFilePaths, draggedRootPath, draggedFromBulk }); - - if (!draggedFilePaths || draggedFilePaths.length === 0) { - console.log('[SidebarManager] performDragMoveWithState returning false - no draggedFilePaths'); - return false; - } - - if (!this.apiClient) { - this.apiClient = this.pageControls?.getSidebarApiClient?.() - || this.pageControls?.sidebarApiClient - || getModelApiClient(); - } - - if (this.apiClient?.apiConfig?.config?.supportsMove === false) { - console.log('[SidebarManager] performDragMoveWithState returning false - supportsMove is false'); - showToast('toast.models.moveFailed', { message: translate('sidebar.dragDrop.moveUnsupported', {}, 'Move not supported for this page') }, 'error'); - return false; - } - - const rootPath = draggedRootPath ? draggedRootPath.replace(/\\/g, '/') : ''; - console.log('[SidebarManager] rootPath:', rootPath); - if (!rootPath) { - console.log('[SidebarManager] performDragMoveWithState returning false - no rootPath'); - showToast( - 'toast.models.moveFailed', - { message: translate('sidebar.dragDrop.unableToResolveRoot', {}, 'Unable to determine destination path for move.') }, - 'error' - ); - return false; - } - - const destination = this.combineRootAndRelativePath(rootPath, targetRelativePath); - const useBulkMove = draggedFromBulk || draggedFilePaths.length > 1; - - try { - console.log('[SidebarManager] calling apiClient.move, useBulkMove:', useBulkMove); - let movedFiles = []; // Array of { original_file_path, new_file_path } - - if (useBulkMove) { - const results = await this.apiClient.moveBulkModels(draggedFilePaths, destination); - movedFiles = (results || []) - .filter(r => r.success) - .map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path })); - } else { - const result = await this.apiClient.moveSingleModel(draggedFilePaths[0], destination); - if (result) { - movedFiles.push({ - original_file_path: result.original_file_path || draggedFilePaths[0], - new_file_path: result.new_file_path - }); - } - } - console.log('[SidebarManager] apiClient.move successful'); - - // Update VirtualScroller in-place instead of full reload - if (movedFiles.length > 0 && state.virtualScroller) { - const pageState = getCurrentPageState(); - const normalizedActive = (pageState.activeFolder || '').replace(/\\/g, '/').replace(/\/$/, ''); - const isRecursive = pageState.searchOptions?.recursive ?? true; - const isFolderFiltered = pageState.activeFolder !== null; - - const normalizedTarget = targetRelativePath.replace(/\\/g, '/').replace(/\/$/, ''); - - // Determine if items in the target folder are visible in the current view - let itemsRemainVisible = true; - if (isFolderFiltered) { - if (isRecursive) { - itemsRemainVisible = normalizedActive === '' || - normalizedTarget === normalizedActive || - normalizedTarget.startsWith(normalizedActive + '/'); - } else { - itemsRemainVisible = normalizedTarget === normalizedActive; - } - } - - if (itemsRemainVisible) { - // Items stay visible — update each item's file_path to reflect new location - for (const moved of movedFiles) { - if (moved.original_file_path && moved.new_file_path) { - state.virtualScroller.updateSingleItem(moved.original_file_path, { - file_path: moved.new_file_path, - folder: normalizedTarget - }); - } - } - } else { - // Items no longer visible in current folder — remove from VirtualScroller - const pathsToRemove = movedFiles - .map(m => m.original_file_path) - .filter(Boolean); - if (pathsToRemove.length > 0) { - state.virtualScroller.removeMultipleItemsByFilePath(pathsToRemove); - } - } - } - - // Refresh sidebar folder tree only (no model data reload) - await this.refresh(); - - if (draggedFromBulk && state.bulkMode && typeof bulkManager?.toggleBulkMode === 'function') { - bulkManager.toggleBulkMode(); - } - - console.log('[SidebarManager] performDragMoveWithState returning true'); - return true; - } catch (error) { - console.error('[SidebarManager] Error moving model(s) via drag-and-drop:', error); - showToast('toast.models.moveFailed', { message: error.message || 'Unknown error' }, 'error'); - console.log('[SidebarManager] performDragMoveWithState returning false due to error'); - return false; - } - } - - // ===== Sidebar-level drag handlers for creating new folders ===== - - handleSidebarDragEnter(event) { - if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) return; - - const sidebar = document.getElementById('folderSidebar'); - if (!sidebar) return; - - // Only show create folder zone if not hovering over an existing folder - const folderElement = this.getFolderElementFromEvent(event); - if (folderElement) { - this.hideCreateFolderZone(); - return; - } - - // Check if drag is within the sidebar tree container area - const treeContainer = document.querySelector('.sidebar-tree-container'); - if (treeContainer && treeContainer.contains(event.target)) { - event.preventDefault(); - this.showCreateFolderZone(); - } - } - - handleSidebarDragOver(event) { - if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) return; - - const folderElement = this.getFolderElementFromEvent(event); - if (folderElement) { - this.hideCreateFolderZone(); - return; - } - - const treeContainer = document.querySelector('.sidebar-tree-container'); - if (treeContainer && treeContainer.contains(event.target)) { - event.preventDefault(); - if (event.dataTransfer) { - event.dataTransfer.dropEffect = 'move'; - } - } - } - - handleSidebarDragLeave(event) { - if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) return; - - const sidebar = document.getElementById('folderSidebar'); - if (!sidebar) return; - - const relatedTarget = event.relatedTarget instanceof Element ? event.relatedTarget : null; - - // Only hide if leaving the sidebar entirely - if (!relatedTarget || !sidebar.contains(relatedTarget)) { - this.hideCreateFolderZone(); - } - } - - async handleSidebarDrop(event) { - if (!this.draggedFilePaths || this.draggedFilePaths.length === 0) return; - - const folderElement = this.getFolderElementFromEvent(event); - if (folderElement) { - // Let the folder drop handler take over - return; - } - - const treeContainer = document.querySelector('.sidebar-tree-container'); - if (!treeContainer || !treeContainer.contains(event.target)) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - - // Show create folder input - this.showCreateFolderInput(); - } - - showCreateFolderZone() { - if (this.isCreatingFolder) return; - - const treeContainer = document.querySelector('.sidebar-tree-container'); - if (!treeContainer) return; - - let zone = document.getElementById('sidebarCreateFolderZone'); - if (!zone) { - zone = document.createElement('div'); - zone.id = 'sidebarCreateFolderZone'; - zone.className = 'sidebar-create-folder-zone'; - zone.innerHTML = ` - - `; - treeContainer.appendChild(zone); - } - - zone.classList.add('active'); - } - - hideCreateFolderZone() { - const zone = document.getElementById('sidebarCreateFolderZone'); - if (zone) { - zone.classList.remove('active'); - } - } - showCreateFolderInput(basePath = null) { - console.log('[SidebarManager] showCreateFolderInput called'); + // Remove any existing input first — hideCreateFolderInput() also + // clears isCreatingFolder, so it must run before the flag is set. + this.hideCreateFolderInput(); this.isCreatingFolder = true; // The folder is created under the given base path; falls back to the - // currently selected folder (drag-and-drop flow) or the root. + // currently selected folder or the root. this._createFolderBasePath = basePath !== null ? basePath : (this.selectedPath || ''); - // 立即保存拖拽状态,防止后续事件(如blur)清空状态 - this._pendingDragState = this.draggedFilePaths && this.draggedFilePaths.length > 0 ? { - filePaths: [...this.draggedFilePaths], - rootPath: this.draggedRootPath, - fromBulk: this.draggedFromBulk - } : null; - console.log('[SidebarManager] saved pending drag state:', this._pendingDragState); - - this.hideCreateFolderZone(); - - const treeContainer = document.querySelector('.sidebar-tree-container'); - if (!treeContainer) return; - - // Remove existing input if any - this.hideCreateFolderInput(); - - const inputContainer = document.createElement('div'); - inputContainer.id = 'sidebarCreateFolderInput'; - inputContainer.className = 'sidebar-create-folder-input-container'; - inputContainer.innerHTML = ` - - - `; - - treeContainer.appendChild(inputContainer); - - // Focus input - const input = inputContainer.querySelector('.sidebar-create-folder-input'); - if (input) { - input.focus(); + const folderTree = document.getElementById('sidebarFolderTree'); + if (!folderTree) { + this.isCreatingFolder = false; + return; } - // Bind events - const confirmBtn = inputContainer.querySelector('.sidebar-create-folder-confirm'); - const cancelBtn = inputContainer.querySelector('.sidebar-create-folder-cancel'); + // Inline row at the creation location, file-explorer style: the new + // folder will appear exactly where the input row is shown. + const row = this._buildCreateFolderRow(); + this._insertCreateFolderRow(folderTree, row); + row.scrollIntoView?.({ block: 'nearest' }); - // Flag to prevent blur from canceling when clicking buttons - let isButtonClick = false; + const input = row.querySelector('.sidebar-create-folder-input'); + if (!input) return; + input.focus(); - confirmBtn?.addEventListener('mousedown', () => { - isButtonClick = true; - console.log('[SidebarManager] confirmBtn mousedown - isButtonClick set to true'); - }); - cancelBtn?.addEventListener('mousedown', () => { - isButtonClick = true; - console.log('[SidebarManager] cancelBtn mousedown - isButtonClick set to true'); - }); - - confirmBtn?.addEventListener('click', (e) => { - console.log('[SidebarManager] confirmBtn click event triggered'); - this.handleCreateFolderSubmit(); - }); - cancelBtn?.addEventListener('click', () => { - console.log('[SidebarManager] cancelBtn click event triggered'); - this.handleCreateFolderCancel(); - }); - input?.addEventListener('keydown', (e) => { - console.log('[SidebarManager] input keydown:', e.key); + input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { - console.log('[SidebarManager] Enter pressed, calling handleCreateFolderSubmit'); this.handleCreateFolderSubmit(); } else if (e.key === 'Escape') { - console.log('[SidebarManager] Escape pressed, calling handleCreateFolderCancel'); this.handleCreateFolderCancel(); } }); - input?.addEventListener('blur', () => { - console.log('[SidebarManager] input blur event - isButtonClick:', isButtonClick); - // Delay to allow button clicks to process first + // Clicking away cancels creation, mirroring file-explorer behavior + input.addEventListener('blur', () => { setTimeout(() => { - console.log('[SidebarManager] blur timeout - isButtonClick:', isButtonClick, 'activeElement:', document.activeElement?.className); - if (!isButtonClick && document.activeElement !== confirmBtn && document.activeElement !== cancelBtn) { - console.log('[SidebarManager] blur timeout - calling handleCreateFolderCancel'); + if (this.isCreatingFolder) { this.handleCreateFolderCancel(); - } else { - console.log('[SidebarManager] blur timeout - NOT canceling (button click detected)'); } - isButtonClick = false; - }, 200); + }, 100); }); } - hideCreateFolderInput() { - console.log('[SidebarManager] hideCreateFolderInput called'); - const inputContainer = document.getElementById('sidebarCreateFolderInput'); - console.log('[SidebarManager] inputContainer:', inputContainer); - if (inputContainer) { - inputContainer.remove(); - console.log('[SidebarManager] inputContainer removed'); + _buildCreateFolderRow() { + const isListMode = this.displayMode === 'list'; + const row = document.createElement('div'); + row.id = 'sidebarCreateFolderInput'; + row.className = 'sidebar-create-folder-node'; + row.innerHTML = ` + + `; + return row; + } + + _insertCreateFolderRow(folderTree, row) { + const parentPath = this._createFolderBasePath; + + if (this.displayMode === 'list') { + if (parentPath) { + const parentItem = [...folderTree.querySelectorAll('.sidebar-folder-item')] + .find(item => item.dataset.path === parentPath); + if (parentItem) { + parentItem.after(row); + return; + } + } + folderTree.appendChild(row); + return; } + + if (parentPath) { + // Expand the parent so the row is visible, then insert as its + // first child. + if (!this.expandedNodes.has(parentPath)) { + this.expandedNodes.add(parentPath); + this.saveExpandedState(); + this.renderTree(); + } + const parentNode = [...folderTree.querySelectorAll('.sidebar-tree-node')] + .find(node => node.dataset.path === parentPath); + if (parentNode) { + let children = parentNode.querySelector(':scope > .sidebar-tree-children'); + if (!children) { + // Leaf folder: renderTree() only creates a children + // container for nodes with subfolders, so add one. + children = document.createElement('div'); + children.className = 'sidebar-tree-children expanded'; + parentNode.appendChild(children); + this._createFolderTempChildren = children; + } + children.prepend(row); + return; + } + } + + // Root creation (or parent not currently visible): append at top level + folderTree.appendChild(row); + } + + hideCreateFolderInput() { + // Clear the flag first so the input's blur handler does not treat + // removing the row as a cancel. this.isCreatingFolder = false; - console.log('[SidebarManager] isCreatingFolder set to false'); + + const row = document.getElementById('sidebarCreateFolderInput'); + if (row) { + row.remove(); + } + + // Remove the temporary children container if it is still empty + if (this._createFolderTempChildren) { + const container = this._createFolderTempChildren; + this._createFolderTempChildren = null; + if (container.isConnected && container.children.length === 0) { + container.remove(); + } + } } async handleCreateFolderSubmit() { - console.log('[SidebarManager] handleCreateFolderSubmit called'); const input = document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input'); - console.log('[SidebarManager] input element:', input); if (!input) { - console.log('[SidebarManager] input not found, returning'); return; } const folderName = input.value.trim(); - console.log('[SidebarManager] folderName:', folderName); if (!folderName) { showToast('sidebar.dragDrop.emptyFolderName', {}, 'warning'); return; @@ -927,41 +690,10 @@ export class SidebarManager { // opened (context-menu folder or current selection), or root const parentPath = this._createFolderBasePath || ''; const targetRelativePath = parentPath ? `${parentPath}/${folderName}` : folderName; - console.log('[SidebarManager] targetRelativePath:', targetRelativePath); - - // 使用 showCreateFolderInput 时保存的拖拽状态 - const pendingState = this._pendingDragState; - console.log('[SidebarManager] using pending drag state:', pendingState); this.hideCreateFolderInput(); - if (!pendingState || !pendingState.filePaths || pendingState.filePaths.length === 0) { - // Standalone creation (header button / context menu): create an - // empty folder on disk instead of moving dragged models into it. - await this._createFolder(targetRelativePath, parentPath); - return; - } - - // Perform the move with saved state - console.log('[SidebarManager] calling performDragMove with pending state'); - const success = await this.performDragMoveWithState(targetRelativePath, pendingState.filePaths, pendingState.rootPath, pendingState.fromBulk); - console.log('[SidebarManager] performDragMove result:', success); - - if (success) { - // Expand the parent folder to show the new folder - if (parentPath) { - this.expandedNodes.add(parentPath); - this.saveExpandedState(); - } - // Refresh the tree to show the newly created folder - // restoreSelectedFolder() inside refresh() will maintain the current active folder - await this.refresh(); - } - - // 清理待处理的拖拽状态 - this._pendingDragState = null; - this.resetDragState(); - this.clearAllDropHighlights(); + await this._createFolder(targetRelativePath, parentPath); } async _createFolder(targetRelativePath, parentPath) { @@ -1025,10 +757,6 @@ export class SidebarManager { handleCreateFolderCancel() { this.hideCreateFolderInput(); - // 清理待处理的拖拽状态 - this._pendingDragState = null; - this.resetDragState(); - this.clearAllDropHighlights(); } saveSelectedFolder() { @@ -1399,20 +1127,18 @@ export class SidebarManager { const folderTree = document.getElementById('sidebarFolderTree'); if (!folderTree) return; - // Pages with folder management (model libraries) also offer the - // header create button; recipes can only create folders via drag. - const hintKey = this._supportsFolderManagement() - ? 'sidebar.empty.createHint' - : 'sidebar.empty.dragHint'; + // Only pages with folder management (model libraries) offer the + // header create button; other pages just show the empty label. + const hintHtml = this._supportsFolderManagement() ? ` + ` : ''; folderTree.innerHTML = ` `; } @@ -1448,6 +1174,9 @@ export class SidebarManager { } handleTreeClick(event) { + // Clicks on the inline create-folder row must not select/toggle nodes + if (event.target.closest('.sidebar-create-folder-node')) return; + if (this.displayMode === 'list') { this.handleFolderListClick(event); return; @@ -1482,6 +1211,9 @@ export class SidebarManager { } handleTreeContextMenu(event) { + // No context menu on the inline create-folder row + if (event.target.closest('.sidebar-create-folder-node')) return; + const nodeContent = event.target.closest('.sidebar-tree-node, .sidebar-folder-item'); if (!nodeContent) return; diff --git a/tests/frontend/components/sidebarManager.folderManagement.test.js b/tests/frontend/components/sidebarManager.folderManagement.test.js index 4e2931b3..3ec95d45 100644 --- a/tests/frontend/components/sidebarManager.folderManagement.test.js +++ b/tests/frontend/components/sidebarManager.folderManagement.test.js @@ -332,22 +332,124 @@ describe('SidebarManager folder creation', () => { expect(manager.refresh).not.toHaveBeenCalled(); }); - it('opens the create-folder input for a context-menu folder', () => { + it('opens the create-folder input as an inline row under the context-menu folder', () => { const manager = createManager(createApiClient()); - document.body.innerHTML = ''; + document.body.innerHTML = '
'; + manager.treeData = { characters: {} }; + manager.renderTree(); manager._performFolderAction('create-subfolder', 'characters'); - const input = document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input'); - expect(input).not.toBeNull(); + const row = document.getElementById('sidebarCreateFolderInput'); + expect(row).not.toBeNull(); + expect(row.classList.contains('sidebar-create-folder-node')).toBe(true); expect(manager._createFolderBasePath).toBe('characters'); + // The leaf parent is expanded and the row sits inside its children container + expect(manager.expandedNodes.has('characters')).toBe(true); + const parentNode = document.querySelector('.sidebar-tree-node[data-path="characters"]'); + expect(parentNode.querySelector(':scope > .sidebar-tree-children').contains(row)).toBe(true); + }); + + it('inserts the row as the first child of an already-expanded parent', () => { + const manager = createManager(createApiClient()); + document.body.innerHTML = '
'; + manager.treeData = { characters: { anime: {} } }; + manager.expandedNodes = new Set(['characters']); + manager.renderTree(); + + manager.showCreateFolderInput('characters'); + + const children = document.querySelector('.sidebar-tree-node[data-path="characters"] > .sidebar-tree-children'); + expect(children.firstElementChild.id).toBe('sidebarCreateFolderInput'); + // Existing children container is reused, no temporary one is tracked + expect(manager._createFolderTempChildren).toBeNull(); + }); + + it('appends the row at the top level for root creation', () => { + const manager = createManager(createApiClient()); + document.body.innerHTML = '
'; + manager.treeData = { characters: {} }; + manager.renderTree(); + + manager.showCreateFolderInput(''); + + const folderTree = document.getElementById('sidebarFolderTree'); + const row = document.getElementById('sidebarCreateFolderInput'); + expect(row.parentElement).toBe(folderTree); + expect(folderTree.lastElementChild).toBe(row); + }); + + it('inserts the row after the parent item in list mode', () => { + const manager = createManager(createApiClient(), { displayMode: 'list' }); + document.body.innerHTML = '
'; + manager.foldersList = ['characters', 'characters/anime']; + manager.renderFolderList(); + + manager.showCreateFolderInput('characters'); + + const items = [...document.querySelectorAll('#sidebarFolderTree > div')]; + const parentIndex = items.findIndex(el => el.dataset.path === 'characters'); + expect(items[parentIndex + 1].id).toBe('sidebarCreateFolderInput'); + // List-mode rows use the list content styling, not the tree one + expect(items[parentIndex + 1].querySelector('.sidebar-node-content')).not.toBeNull(); + }); + + it('removes the temporary children container when creation is canceled', () => { + const manager = createManager(createApiClient()); + document.body.innerHTML = '
'; + manager.treeData = { characters: {} }; + manager.renderTree(); + + manager.showCreateFolderInput('characters'); + manager.handleCreateFolderCancel(); + + expect(document.getElementById('sidebarCreateFolderInput')).toBeNull(); + expect(manager.isCreatingFolder).toBe(false); + const parentNode = document.querySelector('.sidebar-tree-node[data-path="characters"]'); + expect(parentNode.querySelector(':scope > .sidebar-tree-children')).toBeNull(); + }); + + it('cancels creation when the input loses focus', () => { + vi.useFakeTimers(); + try { + const manager = createManager(createApiClient()); + document.body.innerHTML = '
'; + + manager.showCreateFolderInput(''); + const input = document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input'); + input.dispatchEvent(new Event('blur')); + vi.advanceTimersByTime(150); + + expect(document.getElementById('sidebarCreateFolderInput')).toBeNull(); + expect(manager.isCreatingFolder).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it('ignores tree clicks and context menus on the create row', () => { + const manager = createManager(createApiClient()); + document.body.innerHTML = '
'; + manager.treeData = { characters: {} }; + manager.renderTree(); + manager.selectFolder = vi.fn(); + const showMenu = vi.spyOn(manager, '_showFolderContextMenu').mockImplementation(() => {}); + + manager.showCreateFolderInput('characters'); + const input = document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input'); + + manager.handleTreeClick({ target: input }); + expect(manager.selectFolder).not.toHaveBeenCalled(); + + manager.handleTreeContextMenu({ target: input, preventDefault: vi.fn(), stopPropagation: vi.fn() }); + expect(showMenu).not.toHaveBeenCalled(); }); it('submits a standalone folder creation when no drag is pending', async () => { const apiClient = createApiClient(); const manager = createManager(apiClient); manager.refresh = vi.fn().mockResolvedValue(undefined); - document.body.innerHTML = ''; + document.body.innerHTML = '
'; manager.showCreateFolderInput('characters'); document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input').value = 'anime'; @@ -358,3 +460,4 @@ describe('SidebarManager folder creation', () => { expect(document.getElementById('sidebarCreateFolderInput')).toBeNull(); }); }); +