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
This commit is contained in:
Will Miao
2026-09-15 19:47:39 +08:00
parent 9734df15b4
commit cc8eedcff7
13 changed files with 258 additions and 551 deletions
+1 -4
View File
@@ -1266,17 +1266,14 @@
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.", "unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.",
"moveUnsupported": "Verschieben wird für dieses Element nicht unterstützt.", "moveUnsupported": "Verschieben wird für dieses Element nicht unterstützt.",
"createFolderHint": "Loslassen, um einen neuen Ordner zu erstellen",
"newFolderName": "Neuer Ordnername", "newFolderName": "Neuer Ordnername",
"folderNameHint": "Eingabetaste zum Bestätigen, Escape zum Abbrechen",
"emptyFolderName": "Bitte geben Sie einen Ordnernamen ein", "emptyFolderName": "Bitte geben Sie einen Ordnernamen ein",
"invalidFolderName": "Ordnername enthält ungültige Zeichen", "invalidFolderName": "Ordnername enthält ungültige Zeichen",
"noDragState": "Kein ausstehender Ziehvorgang gefunden" "noDragState": "Kein ausstehender Ziehvorgang gefunden"
}, },
"empty": { "empty": {
"noFolders": "Keine Ordner gefunden", "noFolders": "Keine Ordner gefunden",
"dragHint": "Elemente hierher ziehen, um Ordner zu erstellen", "createHint": "[TODO: Translate] Click the New Folder button above to create folders"
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "Auf Updates in diesem Ordner prüfen", "label": "Auf Updates in diesem Ordner prüfen",
+1 -4
View File
@@ -1266,17 +1266,14 @@
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Unable to determine destination path for move.", "unableToResolveRoot": "Unable to determine destination path for move.",
"moveUnsupported": "Move is not supported for this item.", "moveUnsupported": "Move is not supported for this item.",
"createFolderHint": "Release to create new folder",
"newFolderName": "New folder name", "newFolderName": "New folder name",
"folderNameHint": "Press Enter to confirm, Escape to cancel",
"emptyFolderName": "Please enter a folder name", "emptyFolderName": "Please enter a folder name",
"invalidFolderName": "Folder name contains invalid characters", "invalidFolderName": "Folder name contains invalid characters",
"noDragState": "No pending drag operation found" "noDragState": "No pending drag operation found"
}, },
"empty": { "empty": {
"noFolders": "No folders found", "noFolders": "No folders found",
"dragHint": "Drag items here to create folders", "createHint": "Click the New Folder button above to create folders"
"createHint": "Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "Check for updates in this folder", "label": "Check for updates in this folder",
+1 -4
View File
@@ -1266,17 +1266,14 @@
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.", "unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.",
"moveUnsupported": "El movimiento no es compatible con este elemento.", "moveUnsupported": "El movimiento no es compatible con este elemento.",
"createFolderHint": "Suelta para crear una nueva carpeta",
"newFolderName": "Nombre de la nueva carpeta", "newFolderName": "Nombre de la nueva carpeta",
"folderNameHint": "Presiona Enter para confirmar, Escape para cancelar",
"emptyFolderName": "Por favor, introduce un nombre de carpeta", "emptyFolderName": "Por favor, introduce un nombre de carpeta",
"invalidFolderName": "El nombre de la carpeta contiene caracteres no válidos", "invalidFolderName": "El nombre de la carpeta contiene caracteres no válidos",
"noDragState": "No se encontró ninguna operación de arrastre pendiente" "noDragState": "No se encontró ninguna operación de arrastre pendiente"
}, },
"empty": { "empty": {
"noFolders": "No se encontraron carpetas", "noFolders": "No se encontraron carpetas",
"dragHint": "Arrastra elementos aquí para crear carpetas", "createHint": "[TODO: Translate] Click the New Folder button above to create folders"
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "Buscar actualizaciones en esta carpeta", "label": "Buscar actualizaciones en esta carpeta",
+1 -4
View File
@@ -1266,17 +1266,14 @@
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.", "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.", "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", "newFolderName": "Nom du nouveau dossier",
"folderNameHint": "Appuyez sur Entrée pour confirmer, Échap pour annuler",
"emptyFolderName": "Veuillez saisir un nom de dossier", "emptyFolderName": "Veuillez saisir un nom de dossier",
"invalidFolderName": "Le nom du dossier contient des caractères invalides", "invalidFolderName": "Le nom du dossier contient des caractères invalides",
"noDragState": "Aucune opération de glissement en attente trouvée" "noDragState": "Aucune opération de glissement en attente trouvée"
}, },
"empty": { "empty": {
"noFolders": "Aucun dossier trouvé", "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 to create folders"
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "Vérifier les mises à jour dans ce dossier", "label": "Vérifier les mises à jour dans ce dossier",
+1 -4
View File
@@ -1266,17 +1266,14 @@
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.", "unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.",
"moveUnsupported": "העברה אינה נתמכת עבור פריט זה.", "moveUnsupported": "העברה אינה נתמכת עבור פריט זה.",
"createFolderHint": "שחרר כדי ליצור תיקייה חדשה",
"newFolderName": "שם תיקייה חדשה", "newFolderName": "שם תיקייה חדשה",
"folderNameHint": "הקש Enter לאישור, Escape לביטול",
"emptyFolderName": "אנא הזן שם תיקייה", "emptyFolderName": "אנא הזן שם תיקייה",
"invalidFolderName": "שם התיקייה מכיל תווים לא חוקיים", "invalidFolderName": "שם התיקייה מכיל תווים לא חוקיים",
"noDragState": "לא נמצאה פעולת גרירה ממתינה" "noDragState": "לא נמצאה פעולת גרירה ממתינה"
}, },
"empty": { "empty": {
"noFolders": "לא נמצאו תיקיות", "noFolders": "לא נמצאו תיקיות",
"dragHint": "גרור פריטים לכאן כדי ליצור תיקיות", "createHint": "[TODO: Translate] Click the New Folder button above to create folders"
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "בדוק עדכונים בתיקייה זו", "label": "בדוק עדכונים בתיקייה זו",
+1 -4
View File
@@ -1266,17 +1266,14 @@
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "移動先のパスを特定できません。", "unableToResolveRoot": "移動先のパスを特定できません。",
"moveUnsupported": "この項目の移動はサポートされていません。", "moveUnsupported": "この項目の移動はサポートされていません。",
"createFolderHint": "放して新しいフォルダを作成",
"newFolderName": "新しいフォルダ名", "newFolderName": "新しいフォルダ名",
"folderNameHint": "Enterで確定、Escでキャンセル",
"emptyFolderName": "フォルダ名を入力してください", "emptyFolderName": "フォルダ名を入力してください",
"invalidFolderName": "フォルダ名に無効な文字が含まれています", "invalidFolderName": "フォルダ名に無効な文字が含まれています",
"noDragState": "保留中のドラッグ操作が見つかりません" "noDragState": "保留中のドラッグ操作が見つかりません"
}, },
"empty": { "empty": {
"noFolders": "フォルダが見つかりません", "noFolders": "フォルダが見つかりません",
"dragHint": "ここへアイテムをドラッグしてフォルダを作成します", "createHint": "[TODO: Translate] Click the New Folder button above to create folders"
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "このフォルダのアップデートを確認", "label": "このフォルダのアップデートを確認",
+1 -4
View File
@@ -1266,17 +1266,14 @@
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.", "unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.",
"moveUnsupported": "이 항목은 이동을 지원하지 않습니다.", "moveUnsupported": "이 항목은 이동을 지원하지 않습니다.",
"createFolderHint": "놓아서 새 폴더 만들기",
"newFolderName": "새 폴더 이름", "newFolderName": "새 폴더 이름",
"folderNameHint": "Enter를 눌러 확인, Escape를 눌러 취소",
"emptyFolderName": "폴더 이름을 입력하세요", "emptyFolderName": "폴더 이름을 입력하세요",
"invalidFolderName": "폴더 이름에 잘못된 문자가 포함되어 있습니다", "invalidFolderName": "폴더 이름에 잘못된 문자가 포함되어 있습니다",
"noDragState": "보류 중인 드래그 작업을 찾을 수 없습니다" "noDragState": "보류 중인 드래그 작업을 찾을 수 없습니다"
}, },
"empty": { "empty": {
"noFolders": "폴더를 찾을 수 없습니다", "noFolders": "폴더를 찾을 수 없습니다",
"dragHint": "항목을 여기로 드래그하여 폴더를 만듭니다", "createHint": "[TODO: Translate] Click the New Folder button above to create folders"
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "이 폴더의 업데이트 확인", "label": "이 폴더의 업데이트 확인",
+1 -4
View File
@@ -1266,17 +1266,14 @@
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения.", "unableToResolveRoot": "Не удалось определить путь назначения для перемещения.",
"moveUnsupported": "Перемещение этого элемента не поддерживается.", "moveUnsupported": "Перемещение этого элемента не поддерживается.",
"createFolderHint": "Отпустите, чтобы создать новую папку",
"newFolderName": "Имя новой папки", "newFolderName": "Имя новой папки",
"folderNameHint": "Нажмите Enter для подтверждения, Escape для отмены",
"emptyFolderName": "Пожалуйста, введите имя папки", "emptyFolderName": "Пожалуйста, введите имя папки",
"invalidFolderName": "Имя папки содержит недопустимые символы", "invalidFolderName": "Имя папки содержит недопустимые символы",
"noDragState": "Ожидающая операция перетаскивания не найдена" "noDragState": "Ожидающая операция перетаскивания не найдена"
}, },
"empty": { "empty": {
"noFolders": "Папки не найдены", "noFolders": "Папки не найдены",
"dragHint": "Перетащите элементы сюда, чтобы создать папки", "createHint": "[TODO: Translate] Click the New Folder button above to create folders"
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "Проверить обновления в этой папке", "label": "Проверить обновления в этой папке",
+1 -4
View File
@@ -1266,17 +1266,14 @@
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "无法确定移动的目标路径。", "unableToResolveRoot": "无法确定移动的目标路径。",
"moveUnsupported": "此条目不支持移动。", "moveUnsupported": "此条目不支持移动。",
"createFolderHint": "释放以创建新文件夹",
"newFolderName": "新文件夹名称", "newFolderName": "新文件夹名称",
"folderNameHint": "按 Enter 确认,Escape 取消",
"emptyFolderName": "请输入文件夹名称", "emptyFolderName": "请输入文件夹名称",
"invalidFolderName": "文件夹名称包含无效字符", "invalidFolderName": "文件夹名称包含无效字符",
"noDragState": "未找到待处理的拖放操作" "noDragState": "未找到待处理的拖放操作"
}, },
"empty": { "empty": {
"noFolders": "未找到文件夹", "noFolders": "未找到文件夹",
"dragHint": "拖拽项目到此处以创建文件夹", "createHint": "[TODO: Translate] Click the New Folder button above to create folders"
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "检查此文件夹的更新", "label": "检查此文件夹的更新",
+1 -4
View File
@@ -1266,17 +1266,14 @@
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "無法確定移動的目標路徑。", "unableToResolveRoot": "無法確定移動的目標路徑。",
"moveUnsupported": "此項目不支援移動。", "moveUnsupported": "此項目不支援移動。",
"createFolderHint": "放開以建立新資料夾",
"newFolderName": "新資料夾名稱", "newFolderName": "新資料夾名稱",
"folderNameHint": "按 Enter 確認,Escape 取消",
"emptyFolderName": "請輸入資料夾名稱", "emptyFolderName": "請輸入資料夾名稱",
"invalidFolderName": "資料夾名稱包含無效字元", "invalidFolderName": "資料夾名稱包含無效字元",
"noDragState": "未找到待處理的拖放操作" "noDragState": "未找到待處理的拖放操作"
}, },
"empty": { "empty": {
"noFolders": "未找到資料夾", "noFolders": "未找到資料夾",
"dragHint": "將項目拖到此處以建立資料夾", "createHint": "[TODO: Translate] Click the New Folder button above to create folders"
"createHint": "[TODO: Translate] Click the New Folder button above, or drag items here to create folders"
}, },
"folderUpdateCheck": { "folderUpdateCheck": {
"label": "檢查此資料夾的更新", "label": "檢查此資料夾的更新",
+20 -118
View File
@@ -639,90 +639,40 @@
display: inline; display: inline;
} }
/* Create folder drop zone */ /* Create folder inline row: rendered inside the tree at the creation
.sidebar-create-folder-zone { location, styled like a regular node row with a full-width input */
position: absolute; .sidebar-create-folder-row {
bottom: 16px; padding-top: 4px;
left: 16px; padding-bottom: 4px;
right: 16px; cursor: default;
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); .sidebar-tree-node-content.sidebar-create-folder-row:hover,
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.08); .sidebar-node-content.sidebar-create-folder-row:hover {
background: transparent;
color: var(--text-color);
}
.sidebar-create-folder-spacer {
opacity: 0; opacity: 0;
transform: translateY(10px);
transition: var(--transition-base);
pointer-events: none; pointer-events: none;
z-index: 10;
} }
.sidebar-create-folder-zone.active { .sidebar-create-folder-row .sidebar-tree-folder-icon,
opacity: 1; .sidebar-create-folder-row .sidebar-folder-icon {
transform: translateY(0);
}
.sidebar-create-folder-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: var(--lora-accent); color: var(--lora-accent);
font-size: 0.85em; opacity: 0.9;
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;
} }
.sidebar-create-folder-input { .sidebar-create-folder-input {
flex: 1; flex: 1;
min-width: 0; /* allow the input to shrink below its intrinsic width */ 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: 1px solid var(--border-color);
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
background: var(--bg-color); background: var(--bg-color);
color: var(--text-color); color: var(--text-color);
font-size: 0.85em; font-size: 1em;
outline: none; outline: none;
transition: var(--transition-base); 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); 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 */ /* Dragging state for sidebar */
.folder-sidebar.dragging-active { .folder-sidebar.dragging-active {
border-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.5); 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); 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 */ /* Folder context menu - positioned relative to sidebar */
#sidebarFolderContextMenu { #sidebarFolderContextMenu {
z-index: var(--z-modal, 1002); z-index: var(--z-modal, 1002);
+120 -388
View File
@@ -36,17 +36,16 @@ export class SidebarManager {
this.draggedRootPath = null; this.draggedRootPath = null;
this.draggedFromBulk = false; this.draggedFromBulk = false;
this.dragHandlersInitialized = false; this.dragHandlersInitialized = false;
this.sidebarDragHandlersInitialized = false;
this.folderTreeElement = null; this.folderTreeElement = null;
this.currentDropTarget = null; this.currentDropTarget = null;
this.lastPageControls = null; this.lastPageControls = null;
this.isDisabledByPage = false; this.isDisabledByPage = false;
this.initializationPromise = null; this.initializationPromise = null;
this.isCreatingFolder = false; this.isCreatingFolder = false;
this._pendingDragState = null; // 用于保存拖拽创建文件夹时的状态
this.showEmptyFolders = false; this.showEmptyFolders = false;
this.nonEmptyFolders = null; // models-only folder set used to dim empty nodes this.nonEmptyFolders = null; // models-only folder set used to dim empty nodes
this._createFolderBasePath = null; this._createFolderBasePath = null;
this._createFolderTempChildren = null; // children container added for a leaf parent during inline creation
// Bind methods // Bind methods
this.handleTreeClick = this.handleTreeClick.bind(this); this.handleTreeClick = this.handleTreeClick.bind(this);
@@ -68,10 +67,6 @@ export class SidebarManager {
this.handleFolderDragOver = this.handleFolderDragOver.bind(this); this.handleFolderDragOver = this.handleFolderDragOver.bind(this);
this.handleFolderDragLeave = this.handleFolderDragLeave.bind(this); this.handleFolderDragLeave = this.handleFolderDragLeave.bind(this);
this.handleFolderDrop = this.handleFolderDrop.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.handleCreateFolderSubmit = this.handleCreateFolderSubmit.bind(this);
this.handleCreateFolderCancel = this.handleCreateFolderCancel.bind(this); this.handleCreateFolderCancel = this.handleCreateFolderCancel.bind(this);
this.handleHideToggle = this.handleHideToggle.bind(this); this.handleHideToggle = this.handleHideToggle.bind(this);
@@ -121,16 +116,6 @@ export class SidebarManager {
this.resetDragState(); this.resetDragState();
this.hideCreateFolderInput(); 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(); this.hideSidebarHiddenIndicator();
// Reset state // Reset state
@@ -147,6 +132,7 @@ export class SidebarManager {
this.showEmptyFolders = false; this.showEmptyFolders = false;
this.nonEmptyFolders = null; this.nonEmptyFolders = null;
this._createFolderBasePath = null; this._createFolderBasePath = null;
this._createFolderTempChildren = null;
// Reset container margin // Reset container margin
const container = document.querySelector('.container'); const container = document.querySelector('.container');
@@ -233,16 +219,6 @@ export class SidebarManager {
this.folderTreeElement = folderTree; 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) { handleCardDragStart(event) {
@@ -303,7 +279,7 @@ export class SidebarManager {
if (sidebar) { if (sidebar) {
sidebar.classList.remove('dragging-active'); sidebar.classList.remove('dragging-active');
} }
this.clearAllDropHighlights(); this.clearAllDropHighlights();
this.resetDragState(); this.resetDragState();
} }
@@ -564,354 +540,141 @@ export class SidebarManager {
this.draggedFromBulk = false; 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 = `
<div class="sidebar-create-folder-content">
<i class="fas fa-plus-circle"></i>
<span>${translate('sidebar.dragDrop.createFolderHint', {}, 'Release to create new folder')}</span>
</div>
`;
treeContainer.appendChild(zone);
}
zone.classList.add('active');
}
hideCreateFolderZone() {
const zone = document.getElementById('sidebarCreateFolderZone');
if (zone) {
zone.classList.remove('active');
}
}
showCreateFolderInput(basePath = null) { 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; this.isCreatingFolder = true;
// The folder is created under the given base path; falls back to the // 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 || ''); this._createFolderBasePath = basePath !== null ? basePath : (this.selectedPath || '');
// 立即保存拖拽状态,防止后续事件(如blur)清空状态 const folderTree = document.getElementById('sidebarFolderTree');
this._pendingDragState = this.draggedFilePaths && this.draggedFilePaths.length > 0 ? { if (!folderTree) {
filePaths: [...this.draggedFilePaths], this.isCreatingFolder = false;
rootPath: this.draggedRootPath, return;
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 = `
<div class="sidebar-create-folder-input-wrapper">
<i class="fas fa-folder-plus"></i>
<input type="text"
class="sidebar-create-folder-input"
placeholder="${translate('sidebar.dragDrop.newFolderName', {}, 'New folder name')}"
autofocus />
<button class="sidebar-create-folder-btn sidebar-create-folder-confirm" title="${translate('common.confirm', {}, 'Confirm')}">
<i class="fas fa-check"></i>
</button>
<button class="sidebar-create-folder-btn sidebar-create-folder-cancel" title="${translate('common.cancel', {}, 'Cancel')}">
<i class="fas fa-times"></i>
</button>
</div>
<div class="sidebar-create-folder-hint">
${translate('sidebar.dragDrop.folderNameHint', {}, 'Press Enter to confirm, Escape to cancel')}
</div>
`;
treeContainer.appendChild(inputContainer);
// Focus input
const input = inputContainer.querySelector('.sidebar-create-folder-input');
if (input) {
input.focus();
} }
// Bind events // Inline row at the creation location, file-explorer style: the new
const confirmBtn = inputContainer.querySelector('.sidebar-create-folder-confirm'); // folder will appear exactly where the input row is shown.
const cancelBtn = inputContainer.querySelector('.sidebar-create-folder-cancel'); const row = this._buildCreateFolderRow();
this._insertCreateFolderRow(folderTree, row);
row.scrollIntoView?.({ block: 'nearest' });
// Flag to prevent blur from canceling when clicking buttons const input = row.querySelector('.sidebar-create-folder-input');
let isButtonClick = false; if (!input) return;
input.focus();
confirmBtn?.addEventListener('mousedown', () => { input.addEventListener('keydown', (e) => {
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);
if (e.key === 'Enter') { if (e.key === 'Enter') {
console.log('[SidebarManager] Enter pressed, calling handleCreateFolderSubmit');
this.handleCreateFolderSubmit(); this.handleCreateFolderSubmit();
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
console.log('[SidebarManager] Escape pressed, calling handleCreateFolderCancel');
this.handleCreateFolderCancel(); this.handleCreateFolderCancel();
} }
}); });
input?.addEventListener('blur', () => { // Clicking away cancels creation, mirroring file-explorer behavior
console.log('[SidebarManager] input blur event - isButtonClick:', isButtonClick); input.addEventListener('blur', () => {
// Delay to allow button clicks to process first
setTimeout(() => { setTimeout(() => {
console.log('[SidebarManager] blur timeout - isButtonClick:', isButtonClick, 'activeElement:', document.activeElement?.className); if (this.isCreatingFolder) {
if (!isButtonClick && document.activeElement !== confirmBtn && document.activeElement !== cancelBtn) {
console.log('[SidebarManager] blur timeout - calling handleCreateFolderCancel');
this.handleCreateFolderCancel(); this.handleCreateFolderCancel();
} else {
console.log('[SidebarManager] blur timeout - NOT canceling (button click detected)');
} }
isButtonClick = false; }, 100);
}, 200);
}); });
} }
hideCreateFolderInput() { _buildCreateFolderRow() {
console.log('[SidebarManager] hideCreateFolderInput called'); const isListMode = this.displayMode === 'list';
const inputContainer = document.getElementById('sidebarCreateFolderInput'); const row = document.createElement('div');
console.log('[SidebarManager] inputContainer:', inputContainer); row.id = 'sidebarCreateFolderInput';
if (inputContainer) { row.className = 'sidebar-create-folder-node';
inputContainer.remove(); row.innerHTML = `
console.log('[SidebarManager] inputContainer removed'); <div class="${isListMode ? 'sidebar-node-content' : 'sidebar-tree-node-content'} sidebar-create-folder-row">
${isListMode ? '' : `
<div class="sidebar-tree-expand-icon sidebar-create-folder-spacer">
<i class="fas fa-chevron-right"></i>
</div>`}
<i class="fas fa-folder-plus sidebar-tree-folder-icon"></i>
<input type="text"
class="sidebar-create-folder-input"
placeholder="${translate('sidebar.dragDrop.newFolderName', {}, 'New folder name')}" />
</div>
`;
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; 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() { async handleCreateFolderSubmit() {
console.log('[SidebarManager] handleCreateFolderSubmit called');
const input = document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input'); const input = document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input');
console.log('[SidebarManager] input element:', input);
if (!input) { if (!input) {
console.log('[SidebarManager] input not found, returning');
return; return;
} }
const folderName = input.value.trim(); const folderName = input.value.trim();
console.log('[SidebarManager] folderName:', folderName);
if (!folderName) { if (!folderName) {
showToast('sidebar.dragDrop.emptyFolderName', {}, 'warning'); showToast('sidebar.dragDrop.emptyFolderName', {}, 'warning');
return; return;
@@ -927,41 +690,10 @@ export class SidebarManager {
// opened (context-menu folder or current selection), or root // opened (context-menu folder or current selection), or root
const parentPath = this._createFolderBasePath || ''; const parentPath = this._createFolderBasePath || '';
const targetRelativePath = parentPath ? `${parentPath}/${folderName}` : folderName; 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(); this.hideCreateFolderInput();
if (!pendingState || !pendingState.filePaths || pendingState.filePaths.length === 0) { await this._createFolder(targetRelativePath, parentPath);
// 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();
} }
async _createFolder(targetRelativePath, parentPath) { async _createFolder(targetRelativePath, parentPath) {
@@ -1025,10 +757,6 @@ export class SidebarManager {
handleCreateFolderCancel() { handleCreateFolderCancel() {
this.hideCreateFolderInput(); this.hideCreateFolderInput();
// 清理待处理的拖拽状态
this._pendingDragState = null;
this.resetDragState();
this.clearAllDropHighlights();
} }
saveSelectedFolder() { saveSelectedFolder() {
@@ -1399,20 +1127,18 @@ export class SidebarManager {
const folderTree = document.getElementById('sidebarFolderTree'); const folderTree = document.getElementById('sidebarFolderTree');
if (!folderTree) return; if (!folderTree) return;
// Pages with folder management (model libraries) also offer the // Only pages with folder management (model libraries) offer the
// header create button; recipes can only create folders via drag. // header create button; other pages just show the empty label.
const hintKey = this._supportsFolderManagement() const hintHtml = this._supportsFolderManagement() ? `
? 'sidebar.empty.createHint' <div class="sidebar-empty-hint">
: 'sidebar.empty.dragHint'; <i class="fas fa-hand-pointer"></i>
${translate('sidebar.empty.createHint', {}, 'Click the New Folder button above to create folders')}
</div>` : '';
folderTree.innerHTML = ` folderTree.innerHTML = `
<div class="sidebar-tree-placeholder"> <div class="sidebar-tree-placeholder">
<i class="fas fa-folder-open"></i> <i class="fas fa-folder-open"></i>
<div>${translate('sidebar.empty.noFolders', {}, 'No folders found')}</div> <div>${translate('sidebar.empty.noFolders', {}, 'No folders found')}</div>${hintHtml}
<div class="sidebar-empty-hint">
<i class="fas fa-hand-pointer"></i>
${translate(hintKey, {}, 'Drag items here to create folders')}
</div>
</div> </div>
`; `;
} }
@@ -1448,6 +1174,9 @@ export class SidebarManager {
} }
handleTreeClick(event) { 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') { if (this.displayMode === 'list') {
this.handleFolderListClick(event); this.handleFolderListClick(event);
return; return;
@@ -1482,6 +1211,9 @@ export class SidebarManager {
} }
handleTreeContextMenu(event) { 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'); const nodeContent = event.target.closest('.sidebar-tree-node, .sidebar-folder-item');
if (!nodeContent) return; if (!nodeContent) return;
@@ -332,22 +332,124 @@ describe('SidebarManager folder creation', () => {
expect(manager.refresh).not.toHaveBeenCalled(); 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()); const manager = createManager(createApiClient());
document.body.innerHTML = '<div class="sidebar-tree-container"></div>'; document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
manager.treeData = { characters: {} };
manager.renderTree();
manager._performFolderAction('create-subfolder', 'characters'); manager._performFolderAction('create-subfolder', 'characters');
const input = document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input'); const row = document.getElementById('sidebarCreateFolderInput');
expect(input).not.toBeNull(); expect(row).not.toBeNull();
expect(row.classList.contains('sidebar-create-folder-node')).toBe(true);
expect(manager._createFolderBasePath).toBe('characters'); 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 = '<div id="sidebarFolderTree"></div>';
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 = '<div id="sidebarFolderTree"></div>';
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 = '<div id="sidebarFolderTree"></div>';
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 = '<div id="sidebarFolderTree"></div>';
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 = '<div id="sidebarFolderTree"></div>';
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 = '<div id="sidebarFolderTree"></div>';
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 () => { it('submits a standalone folder creation when no drag is pending', async () => {
const apiClient = createApiClient(); const apiClient = createApiClient();
const manager = createManager(apiClient); const manager = createManager(apiClient);
manager.refresh = vi.fn().mockResolvedValue(undefined); manager.refresh = vi.fn().mockResolvedValue(undefined);
document.body.innerHTML = '<div class="sidebar-tree-container"></div>'; document.body.innerHTML = '<div id="sidebarFolderTree"></div>';
manager.showCreateFolderInput('characters'); manager.showCreateFolderInput('characters');
document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input').value = 'anime'; document.querySelector('#sidebarCreateFolderInput .sidebar-create-folder-input').value = 'anime';
@@ -358,3 +460,4 @@ describe('SidebarManager folder creation', () => {
expect(document.getElementById('sidebarCreateFolderInput')).toBeNull(); expect(document.getElementById('sidebarCreateFolderInput')).toBeNull();
}); });
}); });