1672 lines
63 KiB
JavaScript
1672 lines
63 KiB
JavaScript
/*
|
|
* Tree View Component for ComfyUI-Prompt-Companion
|
|
*
|
|
* This file implements the hierarchical tree view for managing folders and subprompts
|
|
* within the ComfyUI-Prompt-Companion interface.
|
|
*/
|
|
|
|
import { api } from "../../../scripts/api.js";
|
|
import { $el } from "../../../scripts/ui.js";
|
|
|
|
class PromptTreeView {
|
|
constructor(container) {
|
|
this.container = typeof container === 'string' ? document.getElementById(container) : container;
|
|
this.treeData = {};
|
|
this.selectedItems = new Set();
|
|
this.expandedFolders = new Set();
|
|
this.searchTerm = '';
|
|
this.sortMode = 'name';
|
|
this.onSelectionChange = null;
|
|
this.draggedItem = null;
|
|
this.contextMenu = null;
|
|
|
|
if (!this.container) {
|
|
console.error("Tree view container not found");
|
|
return;
|
|
}
|
|
|
|
this.initialize();
|
|
}
|
|
|
|
initialize() {
|
|
this.injectStyles();
|
|
this.createTreeStructure();
|
|
this.setupEventHandlers();
|
|
this.loadTreeData();
|
|
}
|
|
|
|
/**
|
|
* Inject necessary CSS styles for the tree view
|
|
*/
|
|
injectStyles() {
|
|
const styleId = 'prompt-companion-tree-styles';
|
|
if (document.getElementById(styleId)) return; // Already injected
|
|
|
|
const style = document.createElement('style');
|
|
style.id = styleId;
|
|
style.textContent = `
|
|
.prompt-companion-tree-item.drag-over {
|
|
background-color: rgba(0, 122, 204, 0.2) !important;
|
|
border: 2px dashed rgba(0, 122, 204, 0.5) !important;
|
|
border-radius: 4px !important;
|
|
}
|
|
|
|
.prompt-companion-tree-item.drag-over .prompt-companion-tree-item-content {
|
|
background-color: transparent !important;
|
|
}
|
|
`;
|
|
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
createTreeStructure() {
|
|
this.container.innerHTML = '';
|
|
this.container.className = 'prompt-companion-tree';
|
|
|
|
// Create tree content area
|
|
this.treeContent = $el("div", {
|
|
className: "prompt-companion-tree-content"
|
|
});
|
|
|
|
this.container.appendChild(this.treeContent);
|
|
}
|
|
|
|
setupEventHandlers() {
|
|
// Global click handler to close context menu
|
|
document.addEventListener('click', (e) => {
|
|
if (this.contextMenu && !this.contextMenu.contains(e.target)) {
|
|
this.hideContextMenu();
|
|
}
|
|
});
|
|
|
|
// Handle clicks on empty areas to deselect items
|
|
this.treeContent.addEventListener('click', async (e) => {
|
|
// Only handle clicks on the tree content itself (empty areas)
|
|
if (e.target === this.treeContent) {
|
|
// Check if we currently have a selection
|
|
if (this.selectedItems.size > 0 && this.onSelectionChange) {
|
|
// Clear selection and trigger callback with null (deselection)
|
|
this.selectedItems.clear();
|
|
this.updateSelection();
|
|
await this.onSelectionChange(null);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Keyboard shortcuts
|
|
this.container.addEventListener('keydown', (e) => {
|
|
this.handleKeyboard(e);
|
|
});
|
|
|
|
this.container.setAttribute('tabindex', '0');
|
|
}
|
|
|
|
async loadTreeData(skipRender = false) {
|
|
try {
|
|
// Fetch both subprompts and folders simultaneously
|
|
const [subpromptsResponse, foldersResponse] = await Promise.all([
|
|
api.fetchApi("/prompt_companion/subprompts"),
|
|
api.fetchApi("/prompt_companion/folders")
|
|
]);
|
|
|
|
if (subpromptsResponse.ok && foldersResponse.ok) {
|
|
const subpromptsData = await subpromptsResponse.json();
|
|
const foldersData = await foldersResponse.json();
|
|
|
|
// Organize tree data with both subprompts and folders
|
|
this.treeData = this.organizeTreeData(subpromptsData, foldersData);
|
|
if (!skipRender) {
|
|
this.renderTree();
|
|
}
|
|
} else {
|
|
// Handle partial failures - try to work with what we have
|
|
let subpromptsData = [];
|
|
let foldersData = [];
|
|
|
|
if (subpromptsResponse.ok) {
|
|
subpromptsData = await subpromptsResponse.json();
|
|
} else {
|
|
console.error("Subprompts response failed:", subpromptsResponse.status, subpromptsResponse.statusText);
|
|
}
|
|
|
|
if (foldersResponse.ok) {
|
|
foldersData = await foldersResponse.json();
|
|
} else {
|
|
console.error("Folders response failed:", foldersResponse.status, foldersResponse.statusText);
|
|
}
|
|
|
|
// Ensure data is in expected format (arrays)
|
|
if (!Array.isArray(subpromptsData)) {
|
|
console.warn("Subprompts data is not an array, converting:", subpromptsData);
|
|
subpromptsData = [];
|
|
}
|
|
if (!Array.isArray(foldersData)) {
|
|
console.warn("Folders data is not an array, converting:", foldersData);
|
|
foldersData = [];
|
|
}
|
|
|
|
// Organize with whatever data we have
|
|
this.treeData = this.organizeTreeData(subpromptsData, foldersData);
|
|
if (!skipRender) {
|
|
this.renderTree();
|
|
}
|
|
|
|
if (!subpromptsResponse.ok || !foldersResponse.ok) {
|
|
this.showError("Some data failed to load, showing partial results");
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Error loading tree data:", error);
|
|
this.showError(`Error loading tree data: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
organizeTreeData(subprompts = [], foldersData = []) {
|
|
// Ensure subprompts is an array
|
|
if (!Array.isArray(subprompts)) {
|
|
console.warn("organizeTreeData: subprompts is not an array:", subprompts);
|
|
subprompts = [];
|
|
}
|
|
|
|
// Ensure foldersData is an array
|
|
if (!Array.isArray(foldersData)) {
|
|
console.warn("organizeTreeData: foldersData is not an array:", foldersData);
|
|
foldersData = [];
|
|
}
|
|
const organized = {
|
|
folders: {},
|
|
items: {}
|
|
};
|
|
|
|
// Build folder lookup and hierarchy from Folder objects
|
|
const folderLookup = {};
|
|
|
|
// First pass: process all folder objects and build lookup
|
|
foldersData.forEach(folderObj => {
|
|
const folderId = folderObj.id;
|
|
const folderData = {
|
|
id: folderId,
|
|
name: folderObj.name,
|
|
parent_id: folderObj.parent_id,
|
|
children: [],
|
|
subfolders: [],
|
|
expanded: this.expandedFolders.has(folderId),
|
|
type: 'folder'
|
|
};
|
|
|
|
organized.folders[folderId] = folderData;
|
|
folderLookup[folderId] = folderObj;
|
|
});
|
|
|
|
// Create virtual root folder if it doesn't exist
|
|
const rootFolderId = 'root';
|
|
if (!organized.folders[rootFolderId]) {
|
|
organized.folders[rootFolderId] = {
|
|
id: rootFolderId,
|
|
name: 'Root',
|
|
parent_id: null,
|
|
children: [],
|
|
subfolders: [],
|
|
expanded: this.expandedFolders.has(rootFolderId),
|
|
type: 'folder'
|
|
};
|
|
}
|
|
|
|
// Second pass: collect all subprompts and assign them to folders
|
|
subprompts.forEach(subprompt => {
|
|
const subpromptId = subprompt.id;
|
|
|
|
// Determine which folder this subprompt belongs to
|
|
let targetFolderId = rootFolderId; // Default to root
|
|
|
|
// Handle both legacy folder_path and new folder_id references
|
|
if (subprompt.folder_id && organized.folders[subprompt.folder_id]) {
|
|
targetFolderId = subprompt.folder_id;
|
|
} else if (subprompt.folder_path) {
|
|
// Legacy support: find folder by path
|
|
targetFolderId = this.findFolderIdByPath(subprompt.folder_path, folderLookup) || rootFolderId;
|
|
}
|
|
|
|
// Create subprompt item
|
|
organized.items[subpromptId] = {
|
|
...subprompt,
|
|
id: subpromptId,
|
|
name: subprompt.name,
|
|
type: 'subprompt',
|
|
folderId: targetFolderId,
|
|
folderPath: subprompt.folder_path || '' // Keep for backward compatibility
|
|
};
|
|
|
|
// Add to target folder's children
|
|
if (organized.folders[targetFolderId]) {
|
|
organized.folders[targetFolderId].children.push(subpromptId);
|
|
}
|
|
});
|
|
|
|
// Third pass: build folder hierarchy
|
|
const rootFolders = [];
|
|
Object.values(organized.folders).forEach(folder => {
|
|
if (!folder.parent_id || folder.id === rootFolderId) {
|
|
// This is a root-level folder
|
|
if (folder.id !== rootFolderId) {
|
|
rootFolders.push(folder);
|
|
}
|
|
} else {
|
|
// This has a parent - add to parent's subfolders
|
|
const parentFolder = organized.folders[folder.parent_id];
|
|
if (parentFolder) {
|
|
parentFolder.subfolders.push(folder);
|
|
} else {
|
|
// Parent not found, treat as root folder
|
|
rootFolders.push(folder);
|
|
}
|
|
}
|
|
});
|
|
|
|
organized.rootFolders = rootFolders;
|
|
organized.rootFolder = organized.folders[rootFolderId];
|
|
return organized;
|
|
}
|
|
|
|
/**
|
|
* Helper method to find folder ID by legacy path
|
|
*/
|
|
findFolderIdByPath(path, folderLookup) {
|
|
if (!path || path === '') {
|
|
return 'root';
|
|
}
|
|
|
|
// Build reverse lookup from folder objects to find by path
|
|
for (const [folderId, folderObj] of Object.entries(folderLookup)) {
|
|
const folderPath = this.buildPathFromFolder(folderObj, folderLookup);
|
|
if (folderPath === path) {
|
|
return folderId;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Helper method to build path from folder object hierarchy
|
|
*/
|
|
buildPathFromFolder(folder, folderLookup) {
|
|
if (!folder || !folder.parent_id) {
|
|
return folder ? folder.name : '';
|
|
}
|
|
|
|
const pathParts = [];
|
|
let current = folder;
|
|
const visited = new Set();
|
|
|
|
while (current && !visited.has(current.id)) {
|
|
visited.add(current.id);
|
|
pathParts.unshift(current.name);
|
|
|
|
if (current.parent_id) {
|
|
current = folderLookup[current.parent_id];
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return pathParts.join('/');
|
|
}
|
|
|
|
/**
|
|
* Sort tree items with folders first, then subprompts, both alphabetically
|
|
*/
|
|
sortTreeItems(folders = [], items = []) {
|
|
// Sort folders alphabetically by name
|
|
const sortedFolders = [...folders].sort((a, b) => {
|
|
const nameA = (a.name || '').toLowerCase();
|
|
const nameB = (b.name || '').toLowerCase();
|
|
return nameA.localeCompare(nameB);
|
|
});
|
|
|
|
// Sort items (subprompts) alphabetically by name
|
|
const sortedItems = [...items].sort((a, b) => {
|
|
const nameA = (this.treeData.items[a]?.name || '').toLowerCase();
|
|
const nameB = (this.treeData.items[b]?.name || '').toLowerCase();
|
|
return nameA.localeCompare(nameB);
|
|
});
|
|
|
|
return { folders: sortedFolders, items: sortedItems };
|
|
}
|
|
|
|
renderTree() {
|
|
if (!this.treeContent) return;
|
|
|
|
this.treeContent.innerHTML = '';
|
|
|
|
// Check if we have any content at all (subprompts OR folders)
|
|
const hasSubprompts = Object.keys(this.treeData.items || {}).length > 0;
|
|
const hasFolders = Object.keys(this.treeData.folders || {}).length > 1; // More than just root
|
|
|
|
if (!hasSubprompts && !hasFolders) {
|
|
this.treeContent.appendChild($el("div", {
|
|
className: "prompt-companion-tree-empty",
|
|
textContent: "No subprompts or folders found"
|
|
}));
|
|
return;
|
|
}
|
|
|
|
// Get the root folder
|
|
const rootFolder = this.treeData.rootFolder || { id: 'root', name: 'Root', children: [], subfolders: [], expanded: true };
|
|
|
|
// Sort and render root folder's subfolders and children
|
|
const rootSorted = this.sortTreeItems(rootFolder.subfolders || [], rootFolder.children || []);
|
|
|
|
// Render root folder's subfolders directly at level 0 (don't show the root folder itself)
|
|
rootSorted.folders.forEach(subfolder => {
|
|
this.renderFolder(subfolder, 0);
|
|
});
|
|
|
|
// Render root folder's children (subprompts) directly at level 0
|
|
rootSorted.items.forEach(itemId => {
|
|
const item = this.treeData.items[itemId];
|
|
if (item && this.matchesSearch(item)) {
|
|
const itemElement = this.createItemElement(item, 0);
|
|
this.treeContent.appendChild(itemElement);
|
|
}
|
|
});
|
|
|
|
// Sort and render other root folders (including empty ones)
|
|
if (this.treeData.rootFolders) {
|
|
const sortedRootFolders = [...this.treeData.rootFolders].sort((a, b) => {
|
|
const nameA = (a.name || '').toLowerCase();
|
|
const nameB = (b.name || '').toLowerCase();
|
|
return nameA.localeCompare(nameB);
|
|
});
|
|
|
|
sortedRootFolders.forEach(folder => {
|
|
this.renderFolder(folder, 0);
|
|
});
|
|
}
|
|
}
|
|
|
|
renderFolder(folder, level = 0) {
|
|
if (!folder) return;
|
|
|
|
const folderElement = this.createFolderElement(folder, level);
|
|
this.treeContent.appendChild(folderElement);
|
|
|
|
if (folder.expanded) {
|
|
// Sort subfolders and items before rendering
|
|
const sorted = this.sortTreeItems(folder.subfolders || [], folder.children || []);
|
|
|
|
// Render subfolders first (alphabetically sorted)
|
|
sorted.folders.forEach(subfolder => {
|
|
this.renderFolder(subfolder, level + 1);
|
|
});
|
|
|
|
// Then render items (alphabetically sorted)
|
|
sorted.items.forEach(itemId => {
|
|
const item = this.treeData.items[itemId];
|
|
if (item && this.matchesSearch(item)) {
|
|
const itemElement = this.createItemElement(item, level + 1);
|
|
this.treeContent.appendChild(itemElement);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
createFolderElement(folder, level) {
|
|
const isExpanded = folder.expanded;
|
|
const hasChildren = (folder.children && folder.children.length > 0) ||
|
|
(folder.subfolders && folder.subfolders.length > 0);
|
|
|
|
// Show empty folders with a different icon to indicate they're empty
|
|
const folderIcon = hasChildren ? "📁" : "📂"; // Open folder icon for empty folders
|
|
const folderClass = hasChildren ? "" : " empty-folder";
|
|
|
|
const element = $el("div", {
|
|
className: `prompt-companion-tree-item prompt-companion-tree-folder${folderClass} ${isExpanded ? 'expanded' : ''}`,
|
|
style: { paddingLeft: `${level * 20 + 10}px` },
|
|
draggable: level > 0 && folder.id !== 'root', // Root folder is not draggable
|
|
dataset: {
|
|
type: 'folder',
|
|
id: folder.id,
|
|
level: level.toString()
|
|
}
|
|
}, [
|
|
$el("div", {
|
|
className: "prompt-companion-tree-item-content"
|
|
}, [
|
|
$el("span", {
|
|
className: `prompt-companion-tree-expander ${hasChildren ? 'has-children' : 'no-children'}`,
|
|
textContent: hasChildren ? (isExpanded ? '▼' : '▶') : '·', // Show dot for empty folders
|
|
onclick: (e) => {
|
|
e.stopPropagation();
|
|
if (hasChildren) {
|
|
this.toggleFolder(folder.id);
|
|
}
|
|
}
|
|
}),
|
|
$el("span", {
|
|
className: "prompt-companion-tree-icon",
|
|
textContent: folderIcon
|
|
}),
|
|
$el("span", {
|
|
className: "prompt-companion-tree-label",
|
|
textContent: folder.name || 'Root'
|
|
})
|
|
])
|
|
]);
|
|
|
|
// Add event listeners
|
|
this.setupItemEventListeners(element, { type: 'folder', ...folder });
|
|
|
|
// Add click handler to entire folder element for expand/collapse
|
|
element.addEventListener('click', (e) => {
|
|
// Only handle click if it's not on the expander itself (to avoid double-triggering)
|
|
if (!e.target.classList.contains('prompt-companion-tree-expander')) {
|
|
if (hasChildren) {
|
|
this.toggleFolder(folder.id);
|
|
}
|
|
// Prevent event from bubbling up
|
|
e.stopPropagation();
|
|
}
|
|
});
|
|
|
|
return element;
|
|
}
|
|
|
|
createItemElement(item, level) {
|
|
const isSelected = this.selectedItems.has(item.id);
|
|
|
|
// Determine icon and title based on whether subprompt has trigger words
|
|
const hasTriggerWords = item.trigger_words && item.trigger_words.length > 0;
|
|
const subpromptIcon = hasTriggerWords ? "⚡" : "📄"; // Lightning for automatic, document for manual
|
|
const subpromptTitle = hasTriggerWords ?
|
|
"Automatic subprompt (has trigger words)" :
|
|
"Manual subprompt (no trigger words)";
|
|
|
|
const element = $el("div", {
|
|
className: `prompt-companion-tree-item prompt-companion-tree-subprompt ${isSelected ? 'selected' : ''}`,
|
|
style: { paddingLeft: `${level * 20 + 10}px` },
|
|
draggable: true,
|
|
dataset: {
|
|
type: 'subprompt',
|
|
id: item.id,
|
|
level: level.toString()
|
|
}
|
|
}, [
|
|
$el("div", {
|
|
className: "prompt-companion-tree-item-content"
|
|
}, [
|
|
// Add spacing to align with folder expander
|
|
$el("span", {
|
|
className: "prompt-companion-tree-expander-spacer",
|
|
textContent: ' ',
|
|
style: { width: '16px', display: 'inline-block' }
|
|
}),
|
|
$el("span", {
|
|
className: "prompt-companion-tree-icon",
|
|
textContent: subpromptIcon,
|
|
title: subpromptTitle
|
|
}),
|
|
$el("span", {
|
|
className: "prompt-companion-tree-label",
|
|
textContent: item.name // Show clean name from UUID-based system
|
|
}),
|
|
$el("div", {
|
|
className: "prompt-companion-tree-item-info"
|
|
}, [
|
|
hasTriggerWords ?
|
|
$el("span", {
|
|
className: "prompt-companion-tree-tags",
|
|
textContent: item.trigger_words.join(', '),
|
|
title: "Trigger words: " + item.trigger_words.join(', ')
|
|
}) : null
|
|
].filter(Boolean))
|
|
])
|
|
]);
|
|
|
|
// Add event listeners
|
|
this.setupItemEventListeners(element, item);
|
|
|
|
return element;
|
|
}
|
|
|
|
setupItemEventListeners(element, item) {
|
|
// Click selection
|
|
element.addEventListener('click', (e) => {
|
|
this.handleItemClick(e, item);
|
|
});
|
|
|
|
// Double-click to edit
|
|
element.addEventListener('dblclick', (e) => {
|
|
this.handleItemDoubleClick(e, item);
|
|
});
|
|
|
|
// Context menu
|
|
element.addEventListener('contextmenu', (e) => {
|
|
e.preventDefault();
|
|
this.handleContextMenu(e, item);
|
|
});
|
|
|
|
// Drag and drop
|
|
if (element.draggable) {
|
|
element.addEventListener('dragstart', (e) => {
|
|
this.handleDragStart(e, item);
|
|
});
|
|
}
|
|
|
|
element.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'move';
|
|
|
|
// Add highlighting during dragover to ensure it works across entire item area
|
|
if (item.type === 'folder' && this.draggedItem) {
|
|
e.currentTarget.classList.add('drag-over');
|
|
}
|
|
});
|
|
|
|
element.addEventListener('dragenter', (e) => {
|
|
e.preventDefault();
|
|
this.handleDragEnter(e, item);
|
|
});
|
|
|
|
element.addEventListener('dragleave', (e) => {
|
|
e.preventDefault();
|
|
// Only remove highlighting if we're actually leaving the element
|
|
// Check if the related target is not a child of the current element
|
|
if (!e.currentTarget.contains(e.relatedTarget)) {
|
|
this.handleDragLeave(e, item);
|
|
}
|
|
});
|
|
|
|
element.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
this.handleDrop(e, item);
|
|
});
|
|
}
|
|
|
|
async handleItemClick(event, item) {
|
|
// Check for unsaved changes before changing selection
|
|
if (this.onSelectionChange && this.onSelectionChange.checkUnsavedChanges) {
|
|
const hasUnsavedChanges = await this.onSelectionChange.checkUnsavedChanges();
|
|
if (hasUnsavedChanges) {
|
|
// Let the edit dialog handle the unsaved changes dialog
|
|
const canProceed = await this.onSelectionChange.handleUnsavedChanges();
|
|
if (!canProceed) {
|
|
return; // Don't change selection
|
|
}
|
|
}
|
|
}
|
|
|
|
if (event.ctrlKey) {
|
|
// Multi-select
|
|
if (item.type === 'subprompt') {
|
|
if (this.selectedItems.has(item.id)) {
|
|
this.selectedItems.delete(item.id);
|
|
} else {
|
|
this.selectedItems.add(item.id);
|
|
}
|
|
}
|
|
} else {
|
|
// Single select
|
|
this.selectedItems.clear();
|
|
if (item.type === 'subprompt') {
|
|
this.selectedItems.add(item.id);
|
|
}
|
|
}
|
|
|
|
this.updateSelection();
|
|
|
|
if (item.type === 'subprompt' && this.onSelectionChange) {
|
|
this.onSelectionChange(item);
|
|
} else if (item.type === 'folder' && this.onSelectionChange) {
|
|
// When clicking on folder, deselect any subprompts (show placeholder panel)
|
|
this.onSelectionChange(null);
|
|
}
|
|
}
|
|
|
|
handleItemDoubleClick(event, item) {
|
|
if (item.type === 'folder') {
|
|
this.toggleFolder(item.id);
|
|
} else if (item.type === 'subprompt') {
|
|
// Open for editing
|
|
if (window.PromptCompanion?.showEditDialog) {
|
|
window.PromptCompanion.showEditDialog(item);
|
|
}
|
|
}
|
|
}
|
|
|
|
handleContextMenu(event, item) {
|
|
this.showContextMenu(event, item);
|
|
}
|
|
|
|
showContextMenu(event, item) {
|
|
this.hideContextMenu();
|
|
|
|
const menuItems = this.getContextMenuItems(item);
|
|
|
|
this.contextMenu = $el("div", {
|
|
className: "prompt-companion-context-menu",
|
|
style: {
|
|
position: "fixed",
|
|
left: `${event.clientX}px`,
|
|
top: `${event.clientY}px`,
|
|
zIndex: "10001"
|
|
}
|
|
}, menuItems.map(menuItem => {
|
|
if (menuItem === null) {
|
|
return $el("hr", { className: "prompt-companion-context-separator" });
|
|
}
|
|
|
|
return $el("div", {
|
|
className: "prompt-companion-context-menu-item",
|
|
textContent: menuItem.label,
|
|
onclick: () => {
|
|
this.hideContextMenu();
|
|
menuItem.action(item);
|
|
}
|
|
});
|
|
}));
|
|
|
|
document.body.appendChild(this.contextMenu);
|
|
|
|
// Adjust position if menu goes off-screen
|
|
const rect = this.contextMenu.getBoundingClientRect();
|
|
if (rect.right > window.innerWidth) {
|
|
this.contextMenu.style.left = `${event.clientX - rect.width}px`;
|
|
}
|
|
if (rect.bottom > window.innerHeight) {
|
|
this.contextMenu.style.top = `${event.clientY - rect.height}px`;
|
|
}
|
|
}
|
|
|
|
getContextMenuItems(item) {
|
|
const items = [];
|
|
|
|
if (item.type === 'folder') {
|
|
items.push(
|
|
{ label: "New Subprompt", action: () => this.createSubprompt(item.id) },
|
|
{ label: "New Folder", action: () => this.createFolderWithPrompt(item.id) },
|
|
null
|
|
);
|
|
|
|
if (item.id && item.id !== 'root') {
|
|
items.push(
|
|
{ label: "Rename Folder", action: () => this.renameFolder(item) },
|
|
{ label: "Delete Folder", action: () => this.deleteFolder(item) }
|
|
);
|
|
}
|
|
} else if (item.type === 'subprompt') {
|
|
items.push(
|
|
{ label: "Rename", action: () => this.renameSubprompt(item) },
|
|
{ label: "Duplicate", action: () => this.duplicateSubprompt(item) },
|
|
null,
|
|
{ label: "Delete", action: () => this.deleteSubprompt(item) }
|
|
);
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
hideContextMenu() {
|
|
if (this.contextMenu) {
|
|
if (this.contextMenu.parentNode) {
|
|
this.contextMenu.parentNode.removeChild(this.contextMenu);
|
|
}
|
|
this.contextMenu = null;
|
|
}
|
|
}
|
|
|
|
handleDragStart(event, item) {
|
|
this.draggedItem = item;
|
|
event.dataTransfer.effectAllowed = 'move';
|
|
event.dataTransfer.setData('text/plain', JSON.stringify(item));
|
|
|
|
// Add visual feedback
|
|
event.target.style.opacity = '0.5';
|
|
}
|
|
|
|
handleDragEnter(event, item) {
|
|
// Only highlight folders as valid drop targets
|
|
if (item.type === 'folder') {
|
|
event.currentTarget.classList.add('drag-over');
|
|
}
|
|
}
|
|
|
|
handleDragLeave(event, item) {
|
|
// Remove highlight when leaving the element
|
|
if (item.type === 'folder') {
|
|
event.currentTarget.classList.remove('drag-over');
|
|
}
|
|
}
|
|
|
|
handleDrop(event, targetItem) {
|
|
if (!this.draggedItem) return;
|
|
|
|
// Reset visual feedback
|
|
document.querySelectorAll('.prompt-companion-tree-item').forEach(el => {
|
|
el.style.opacity = '';
|
|
el.classList.remove('drag-over');
|
|
});
|
|
|
|
const sourceItem = this.draggedItem;
|
|
this.draggedItem = null;
|
|
|
|
// Don't allow dropping on itself or its current location
|
|
if (sourceItem.type === 'subprompt' && targetItem.type === 'subprompt' && sourceItem.id === targetItem.id) {
|
|
return;
|
|
}
|
|
if (sourceItem.type === 'folder' && targetItem.type === 'folder' && sourceItem.id === targetItem.id) {
|
|
return;
|
|
}
|
|
|
|
// For subprompts: don't allow dropping into the same folder it's already in
|
|
if (sourceItem.type === 'subprompt' && targetItem.type === 'folder') {
|
|
const currentFolderId = sourceItem.folder_id || sourceItem.folderId || 'root';
|
|
if (currentFolderId === targetItem.id) {
|
|
return; // Already in this folder
|
|
}
|
|
}
|
|
|
|
// Determine target folder ID
|
|
let targetFolderId = 'root';
|
|
if (targetItem.type === 'folder') {
|
|
targetFolderId = targetItem.id;
|
|
} else if (targetItem.type === 'subprompt') {
|
|
targetFolderId = targetItem.folderId || 'root';
|
|
}
|
|
|
|
// Move the item
|
|
this.moveItem(sourceItem, targetFolderId);
|
|
}
|
|
|
|
async moveItem(item, targetFolderId) {
|
|
if (item.type === 'subprompt') {
|
|
try {
|
|
// Use UUID for API call
|
|
const response = await api.fetchApi(`/prompt_companion/subprompts/${encodeURIComponent(item.id)}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: item.id, // Preserve UUID
|
|
name: item.name,
|
|
positive: item.positive || '',
|
|
negative: item.negative || '',
|
|
trigger_words: item.trigger_words || [],
|
|
order: item.order || ["attached"],
|
|
folder_id: targetFolderId === 'root' ? null : targetFolderId,
|
|
folder_path: "" // Keep empty for backward compatibility during transition
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
await this.refreshTreePreservingState();
|
|
} else {
|
|
console.error("Failed to move item");
|
|
}
|
|
} catch (error) {
|
|
console.error("Error moving item:", error);
|
|
}
|
|
}
|
|
}
|
|
|
|
toggleFolder(folderId) {
|
|
if (this.expandedFolders.has(folderId)) {
|
|
this.expandedFolders.delete(folderId);
|
|
} else {
|
|
this.expandedFolders.add(folderId);
|
|
}
|
|
|
|
// Update folder state
|
|
if (this.treeData.folders[folderId]) {
|
|
this.treeData.folders[folderId].expanded = this.expandedFolders.has(folderId);
|
|
}
|
|
|
|
this.renderTree();
|
|
}
|
|
|
|
/**
|
|
* Expand a folder by ID
|
|
*/
|
|
expandFolder(folderId) {
|
|
if (folderId !== null && folderId !== undefined) {
|
|
this.expandedFolders.add(folderId);
|
|
|
|
// Update folder state
|
|
if (this.treeData.folders[folderId]) {
|
|
this.treeData.folders[folderId].expanded = true;
|
|
}
|
|
|
|
this.renderTree();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Expand all parent folders of a given folder ID
|
|
*/
|
|
expandParentFolders(folderId) {
|
|
if (!folderId || folderId === 'root') return;
|
|
|
|
const folder = this.treeData.folders[folderId];
|
|
if (!folder) return;
|
|
|
|
// Expand this folder
|
|
this.expandedFolders.add(folderId);
|
|
if (this.treeData.folders[folderId]) {
|
|
this.treeData.folders[folderId].expanded = true;
|
|
}
|
|
|
|
// Recursively expand parent folders
|
|
if (folder.parent_id && folder.parent_id !== 'root') {
|
|
this.expandParentFolders(folder.parent_id);
|
|
}
|
|
|
|
// Only render if this is the top-level call (no tree refresh pending)
|
|
// This prevents conflicting refreshes when called after refreshTreePreservingState()
|
|
if (this._skipRenderOnExpand !== true) {
|
|
this.renderTree();
|
|
}
|
|
}
|
|
|
|
updateSelection() {
|
|
// Update visual selection
|
|
this.container.querySelectorAll('.prompt-companion-tree-item').forEach(element => {
|
|
const id = element.dataset.id;
|
|
if (id && this.selectedItems.has(id)) {
|
|
element.classList.add('selected');
|
|
} else {
|
|
element.classList.remove('selected');
|
|
}
|
|
});
|
|
}
|
|
|
|
searchTree(term) {
|
|
this.searchTerm = term.toLowerCase();
|
|
this.renderTree();
|
|
}
|
|
|
|
matchesSearch(item) {
|
|
if (!this.searchTerm) return true;
|
|
|
|
const searchableText = [
|
|
item.id,
|
|
item.positive,
|
|
item.negative,
|
|
...(item.trigger_words || [])
|
|
].join(' ').toLowerCase();
|
|
|
|
return searchableText.includes(this.searchTerm);
|
|
}
|
|
|
|
sortTree(mode) {
|
|
this.sortMode = mode;
|
|
// Re-organize and render tree with new sort order
|
|
this.renderTree();
|
|
}
|
|
|
|
async createFolder(folderName, parentId = 'root') {
|
|
// If folderName is not provided, prompt for it (backwards compatibility)
|
|
if (!folderName) {
|
|
folderName = await this.showInputDialog("Enter folder name:", "");
|
|
if (!folderName || !folderName.trim()) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const name = folderName.trim();
|
|
|
|
// Check if folder already exists in parent
|
|
if (this.treeData.folders) {
|
|
const existingFolder = Object.values(this.treeData.folders).find(
|
|
folder => folder.name === name && folder.parent_id === (parentId === 'root' ? null : parentId)
|
|
);
|
|
if (existingFolder) {
|
|
console.warn(`Folder "${name}" already exists in this location`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Use the new UUID-based folder creation API endpoint
|
|
try {
|
|
const response = await api.fetchApi('/prompt_companion/folders', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
name: name,
|
|
parent_id: parentId === 'root' ? null : parentId
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
// Expand the new folder and its parent
|
|
this.expandedFolders.add(result.id);
|
|
if (parentId !== 'root') {
|
|
this.expandedFolders.add(parentId);
|
|
}
|
|
await this.refreshTreePreservingState();
|
|
return true;
|
|
} else {
|
|
console.error("Folder creation failed:", response.status, response.statusText);
|
|
try {
|
|
const errorData = await response.json();
|
|
console.error(`Failed to create folder: ${errorData.error || 'Unknown error'}`);
|
|
} catch (e) {
|
|
const errorText = await response.text();
|
|
console.error("Error response:", errorText);
|
|
}
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
console.error("Error in createFolder:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create folder with prompt (for context menu)
|
|
*/
|
|
async createFolderWithPrompt(parentId = 'root') {
|
|
const name = await this.showInputDialog("Enter folder name:", "");
|
|
if (name && name.trim()) {
|
|
return await this.createFolder(name.trim(), parentId);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async createSubprompt(parentId = 'root') {
|
|
// Prompt for subprompt name
|
|
const name = await this.showInputDialog("Enter subprompt name:", "");
|
|
if (!name || !name.trim()) {
|
|
return; // User cancelled or entered empty name
|
|
}
|
|
|
|
const subpromptName = name.trim();
|
|
|
|
// Create subprompt on server first
|
|
try {
|
|
const newSubpromptData = {
|
|
name: subpromptName,
|
|
folder_id: parentId === 'root' ? null : parentId,
|
|
folder_path: "", // Keep empty for backward compatibility during transition
|
|
positive: '',
|
|
negative: '',
|
|
trigger_words: [],
|
|
order: ["attached"]
|
|
};
|
|
|
|
const response = await api.fetchApi('/prompt_companion/subprompts', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(newSubpromptData)
|
|
});
|
|
|
|
if (response.ok) {
|
|
const savedSubprompt = await response.json();
|
|
|
|
// Ensure parent folder is marked as expanded BEFORE refresh
|
|
if (parentId !== 'root') {
|
|
this.expandedFolders.add(parentId);
|
|
// Also ensure all parent folders are expanded
|
|
const folder = this.treeData.folders && this.treeData.folders[parentId];
|
|
if (folder) {
|
|
let currentFolder = folder;
|
|
while (currentFolder && currentFolder.parent_id && currentFolder.parent_id !== 'root') {
|
|
this.expandedFolders.add(currentFolder.parent_id);
|
|
currentFolder = this.treeData.folders[currentFolder.parent_id];
|
|
}
|
|
}
|
|
}
|
|
|
|
// Refresh tree view to show new subprompt while preserving expanded state
|
|
await this.refreshTreePreservingState();
|
|
|
|
// Select the new subprompt
|
|
this.selectedItems.clear();
|
|
this.selectedItems.add(savedSubprompt.id);
|
|
this.updateSelection();
|
|
|
|
// Open edit dialog with the saved subprompt
|
|
if (window.PromptCompanion?.showEditDialog) {
|
|
window.PromptCompanion.showEditDialog(savedSubprompt);
|
|
}
|
|
} else {
|
|
const errorData = await response.json();
|
|
await this.showAlertDialog(`Failed to create subprompt: ${errorData.error || 'Unknown error'}`);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error creating subprompt:", error);
|
|
await this.showAlertDialog(`Error creating subprompt: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
editSubprompt(item) {
|
|
if (window.PromptCompanion?.showEditDialog) {
|
|
window.PromptCompanion.showEditDialog(item);
|
|
}
|
|
}
|
|
|
|
async duplicateSubprompt(item) {
|
|
const newName = `${item.name}_copy_${Date.now()}`;
|
|
|
|
try {
|
|
const response = await api.fetchApi('/prompt_companion/subprompts', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
name: newName,
|
|
folder_id: item.folder_id || (item.folderId && item.folderId !== 'root' ? item.folderId : null),
|
|
folder_path: "", // Keep empty for backward compatibility during transition
|
|
positive: item.positive || '',
|
|
negative: item.negative || '',
|
|
trigger_words: item.trigger_words || [],
|
|
order: item.order || ["attached"]
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
await this.refreshTreePreservingState();
|
|
}
|
|
} catch (error) {
|
|
console.error("Error duplicating subprompt:", error);
|
|
}
|
|
}
|
|
|
|
async renameSubprompt(item) {
|
|
const newName = await this.showInputDialog("Enter new subprompt name:", item.name);
|
|
if (!newName || newName.trim() === item.name) return;
|
|
|
|
const trimmedName = newName.trim();
|
|
|
|
// Validate name is not empty
|
|
if (!trimmedName) {
|
|
await this.showAlertDialog("Subprompt name cannot be empty");
|
|
return;
|
|
}
|
|
|
|
// Check for duplicates in the same folder
|
|
const currentFolderId = item.folder_id || item.folderId || 'root';
|
|
const duplicateExists = Object.values(this.treeData.items || {}).some(otherItem =>
|
|
otherItem.id !== item.id &&
|
|
otherItem.name === trimmedName &&
|
|
(otherItem.folder_id || otherItem.folderId || 'root') === currentFolderId
|
|
);
|
|
|
|
if (duplicateExists) {
|
|
const folderName = currentFolderId === 'root' ? 'root folder' :
|
|
(this.treeData.folders[currentFolderId]?.name || 'current folder');
|
|
await this.showAlertDialog(`A subprompt named "${trimmedName}" already exists in ${folderName}`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Update the subprompt with the new name
|
|
const response = await api.fetchApi(`/prompt_companion/subprompts/${encodeURIComponent(item.id)}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: item.id,
|
|
name: trimmedName,
|
|
positive: item.positive || '',
|
|
negative: item.negative || '',
|
|
trigger_words: item.trigger_words || [],
|
|
order: item.order || ["attached"],
|
|
folder_id: item.folder_id || (item.folderId && item.folderId !== 'root' ? item.folderId : null),
|
|
folder_path: item.folder_path || "" // Keep for backward compatibility
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
const updatedItem = await response.json();
|
|
|
|
// Update the tree view while preserving expanded state
|
|
await this.refreshTreePreservingState();
|
|
|
|
// Update the edit dialog if this subprompt is currently being edited
|
|
if (window.PromptCompanion?.editDialog) {
|
|
const editDialog = window.PromptCompanion.editDialog;
|
|
if (editDialog && editDialog.currentSubprompt &&
|
|
editDialog.currentSubprompt.id === item.id) {
|
|
// Update the current subprompt reference and header
|
|
editDialog.currentSubprompt = updatedItem;
|
|
|
|
// Calculate folder path for display
|
|
let displayFolderPath = "";
|
|
try {
|
|
if (updatedItem.folder_id) {
|
|
displayFolderPath = await editDialog.calculateFolderPath(updatedItem.folder_id);
|
|
} else if (updatedItem.folder_path) {
|
|
displayFolderPath = updatedItem.folder_path;
|
|
}
|
|
} catch (error) {
|
|
console.warn("Error calculating folder path for rename update:", error);
|
|
displayFolderPath = updatedItem.folder_path || "";
|
|
}
|
|
|
|
editDialog.updateEditPanelHeader(updatedItem.name, displayFolderPath);
|
|
}
|
|
}
|
|
|
|
// Update node dropdowns in workflow
|
|
if (window.PromptCompanion?.editDialog) {
|
|
window.PromptCompanion.editDialog.updateNodeDropdowns();
|
|
}
|
|
|
|
// Show success message
|
|
if (window.PromptCompanion?.editDialog) {
|
|
window.PromptCompanion.editDialog.showMessage(`Subprompt renamed to "${trimmedName}" successfully`, "success");
|
|
}
|
|
|
|
return true;
|
|
} else {
|
|
const errorData = await response.json();
|
|
await this.showAlertDialog(`Failed to rename subprompt: ${errorData.error || 'Unknown error'}`);
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
console.error("Error renaming subprompt:", error);
|
|
await this.showAlertDialog(`Error renaming subprompt: ${error.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async deleteSubprompt(item) {
|
|
const shouldDelete = await this.showConfirmDialog(`Are you sure you want to delete "${item.name}"?`);
|
|
if (!shouldDelete) return;
|
|
|
|
try {
|
|
// Use UUID for API call
|
|
const response = await api.fetchApi(`/prompt_companion/subprompts/${encodeURIComponent(item.id)}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (response.ok) {
|
|
this.selectedItems.delete(item.id);
|
|
|
|
// If this subprompt is currently being edited, clear the edit panel
|
|
if (window.PromptCompanion?.editDialog) {
|
|
const editDialog = window.PromptCompanion.editDialog;
|
|
if (editDialog && editDialog.currentSubprompt &&
|
|
(editDialog.currentSubprompt.id === item.id || editDialog.currentSubprompt.name === item.name)) {
|
|
editDialog.clearForm();
|
|
}
|
|
}
|
|
|
|
await this.refreshTreePreservingState();
|
|
}
|
|
} catch (error) {
|
|
console.error("Error deleting subprompt:", error);
|
|
}
|
|
}
|
|
|
|
async deleteFolder(folder) {
|
|
// Check if folder has contents (subfolders or subprompts)
|
|
const hasContents = this.folderHasContents(folder);
|
|
|
|
if (hasContents) {
|
|
// Show smart deletion dialog
|
|
const action = await this.showFolderDeletionDialog(folder);
|
|
if (!action) return false; // User cancelled
|
|
|
|
if (action === 'move') {
|
|
return await this.deleteFolderAndMoveContents(folder);
|
|
} else {
|
|
return await this.deleteFolderAndAllContents(folder);
|
|
}
|
|
} else {
|
|
// Empty folder, simple deletion
|
|
const shouldDelete = await this.showConfirmDialog(`Are you sure you want to delete the empty folder "${folder.name}"?`);
|
|
if (!shouldDelete) return false;
|
|
return await this.deleteFolderAndAllContents(folder);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if folder has contents (subfolders or subprompts)
|
|
*/
|
|
folderHasContents(folder) {
|
|
const hasSubfolders = folder.subfolders && folder.subfolders.length > 0;
|
|
const hasSubprompts = folder.children && folder.children.length > 0;
|
|
return hasSubfolders || hasSubprompts;
|
|
}
|
|
|
|
/**
|
|
* Show dialog asking user what to do with folder contents
|
|
*/
|
|
async showFolderDeletionDialog(folder) {
|
|
return new Promise((resolve) => {
|
|
const dialog = $el("div", {
|
|
className: "prompt-companion-deletion-dialog",
|
|
style: {
|
|
position: "fixed",
|
|
top: "50%",
|
|
left: "50%",
|
|
transform: "translate(-50%, -50%)",
|
|
backgroundColor: "#2d2d2d",
|
|
border: "1px solid #404040",
|
|
borderRadius: "8px",
|
|
padding: "20px",
|
|
zIndex: "10002",
|
|
minWidth: "400px",
|
|
boxShadow: "0 4px 12px rgba(0,0,0,0.5)"
|
|
}
|
|
}, [
|
|
$el("h3", {
|
|
textContent: `Delete Folder "${folder.name}"?`,
|
|
style: { color: "#ffffff", marginTop: "0", marginBottom: "15px" }
|
|
}),
|
|
$el("p", {
|
|
textContent: "This folder contains subprompts or subfolders. What would you like to do?",
|
|
style: { color: "#cccccc", marginBottom: "20px" }
|
|
}),
|
|
$el("div", {
|
|
className: "prompt-companion-dialog-buttons",
|
|
style: { display: "flex", gap: "10px", justifyContent: "flex-end" }
|
|
}, [
|
|
$el("button", {
|
|
textContent: "Move Contents Up",
|
|
style: {
|
|
padding: "8px 16px",
|
|
backgroundColor: "#007acc",
|
|
color: "white",
|
|
border: "none",
|
|
borderRadius: "4px",
|
|
cursor: "pointer"
|
|
},
|
|
onclick: () => {
|
|
document.body.removeChild(overlay);
|
|
resolve('move');
|
|
}
|
|
}),
|
|
$el("button", {
|
|
textContent: "Delete All Contents",
|
|
style: {
|
|
padding: "8px 16px",
|
|
backgroundColor: "#dc3545",
|
|
color: "white",
|
|
border: "none",
|
|
borderRadius: "4px",
|
|
cursor: "pointer"
|
|
},
|
|
onclick: () => {
|
|
document.body.removeChild(overlay);
|
|
resolve('delete');
|
|
}
|
|
}),
|
|
$el("button", {
|
|
textContent: "Cancel",
|
|
style: {
|
|
padding: "8px 16px",
|
|
backgroundColor: "#6c757d",
|
|
color: "white",
|
|
border: "none",
|
|
borderRadius: "4px",
|
|
cursor: "pointer"
|
|
},
|
|
onclick: () => {
|
|
document.body.removeChild(overlay);
|
|
resolve(null);
|
|
}
|
|
})
|
|
])
|
|
]);
|
|
|
|
const overlay = $el("div", {
|
|
style: {
|
|
position: "fixed",
|
|
top: "0",
|
|
left: "0",
|
|
width: "100%",
|
|
height: "100%",
|
|
backgroundColor: "rgba(0,0,0,0.5)",
|
|
zIndex: "10001"
|
|
}
|
|
}, [dialog]);
|
|
|
|
document.body.appendChild(overlay);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Delete folder and move its contents to parent level
|
|
*/
|
|
async deleteFolderAndMoveContents(folder) {
|
|
try {
|
|
// Determine parent folder ID
|
|
const parentId = folder.parent_id; // This will be null for root-level folders
|
|
|
|
// Move subprompts to parent folder
|
|
if (folder.children && folder.children.length > 0) {
|
|
for (const itemId of folder.children) {
|
|
const item = this.treeData.items[itemId];
|
|
if (item) {
|
|
const response = await api.fetchApi(`/prompt_companion/subprompts/${encodeURIComponent(item.id)}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: item.id,
|
|
name: item.name,
|
|
positive: item.positive || '',
|
|
negative: item.negative || '',
|
|
trigger_words: item.trigger_words || [],
|
|
order: item.order || ["attached"],
|
|
folder_id: parentId,
|
|
folder_path: "" // Keep empty for backward compatibility during transition
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
console.error(`Failed to move subprompt ${item.id}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Move subfolders to parent level
|
|
if (folder.subfolders && folder.subfolders.length > 0) {
|
|
for (const subfolder of folder.subfolders) {
|
|
const response = await api.fetchApi(`/prompt_companion/folders/${encodeURIComponent(subfolder.id)}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: subfolder.id,
|
|
name: subfolder.name,
|
|
parent_id: parentId
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
console.error(`Failed to move subfolder ${subfolder.id}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Delete the empty folder
|
|
if (!folder.id || folder.id === 'undefined' || folder.id === 'null') {
|
|
console.error("Cannot delete folder: missing or invalid ID", folder);
|
|
return false;
|
|
}
|
|
|
|
const response = await api.fetchApi(`/prompt_companion/folders/${encodeURIComponent(folder.id)}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (response.ok) {
|
|
this.expandedFolders.delete(folder.id);
|
|
await this.refreshTreePreservingState();
|
|
return true;
|
|
} else {
|
|
const errorData = await response.json();
|
|
console.error(`Failed to delete folder: ${errorData.error || 'Unknown error'}`);
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
console.error("Error during folder deletion and content moving:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete folder and all its contents (original behavior)
|
|
*/
|
|
async deleteFolderAndAllContents(folder) {
|
|
try {
|
|
// Validate folder has ID
|
|
if (!folder.id || folder.id === 'undefined' || folder.id === 'null') {
|
|
console.error("TREE VIEW: Cannot delete folder - missing or invalid ID", folder);
|
|
await this.showAlertDialog("Cannot delete folder: missing or invalid ID");
|
|
return false;
|
|
}
|
|
|
|
const deleteUrl = `/prompt_companion/folders/${encodeURIComponent(folder.id)}`;
|
|
|
|
const response = await api.fetchApi(deleteUrl, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
this.expandedFolders.delete(folder.id);
|
|
await this.refreshTreePreservingState();
|
|
return true;
|
|
} else {
|
|
const errorData = await response.json();
|
|
console.error(`Failed to delete folder: ${errorData.error || 'Unknown error'}`);
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
console.error("Error deleting folder:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async renameFolder(folder) {
|
|
const newName = await this.showInputDialog("Enter new folder name:", folder.name);
|
|
if (!newName || newName.trim() === folder.name) return;
|
|
|
|
try {
|
|
// Validate folder has ID
|
|
if (!folder.id || folder.id === 'undefined' || folder.id === 'null') {
|
|
console.error("Cannot rename folder: missing or invalid ID", folder);
|
|
await this.showAlertDialog("Cannot rename folder: missing or invalid ID");
|
|
return false;
|
|
}
|
|
|
|
const response = await api.fetchApi(`/prompt_companion/folders/${encodeURIComponent(folder.id)}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: folder.id,
|
|
name: newName.trim(),
|
|
parent_id: folder.parent_id
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
// Keep the same expanded state for the renamed folder
|
|
await this.refreshTreePreservingState();
|
|
return true;
|
|
} else {
|
|
const errorData = await response.json();
|
|
console.error(`Failed to rename folder: ${errorData.error || 'Unknown error'}`);
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
console.error("Error renaming folder:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
handleKeyboard(event) {
|
|
if (event.key === 'Delete' && this.selectedItems.size > 0) {
|
|
// Delete selected items
|
|
const selectedIds = Array.from(this.selectedItems);
|
|
selectedIds.forEach(id => {
|
|
const item = this.treeData.items[id];
|
|
if (item) {
|
|
this.deleteSubprompt(item);
|
|
}
|
|
});
|
|
} else if (event.key === 'F2' && this.selectedItems.size === 1) {
|
|
// Rename selected item
|
|
const id = Array.from(this.selectedItems)[0];
|
|
const item = this.treeData.items[id];
|
|
if (item) {
|
|
this.editSubprompt(item);
|
|
}
|
|
}
|
|
}
|
|
|
|
selectItem(itemId) {
|
|
this.selectedItems.clear();
|
|
this.selectedItems.add(itemId);
|
|
this.updateSelection();
|
|
|
|
const item = this.treeData.items[itemId];
|
|
if (item && this.onSelectionChange) {
|
|
this.onSelectionChange(item);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Expand all folders in the tree
|
|
*/
|
|
expandAll() {
|
|
if (this.treeData && this.treeData.folders) {
|
|
Object.keys(this.treeData.folders).forEach(folderId => {
|
|
this.expandedFolders.add(folderId);
|
|
if (this.treeData.folders[folderId]) {
|
|
this.treeData.folders[folderId].expanded = true;
|
|
}
|
|
});
|
|
this.renderTree();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Collapse all folders in the tree
|
|
*/
|
|
collapseAll() {
|
|
this.expandedFolders.clear();
|
|
if (this.treeData && this.treeData.folders) {
|
|
Object.values(this.treeData.folders).forEach(folder => {
|
|
folder.expanded = false;
|
|
});
|
|
this.renderTree();
|
|
}
|
|
}
|
|
|
|
async refreshTree() {
|
|
await this.loadTreeData();
|
|
}
|
|
|
|
/**
|
|
* Refresh tree while preserving expanded folder state
|
|
*/
|
|
async refreshTreePreservingState() {
|
|
// Store current expanded state before refresh
|
|
const currentExpandedFolders = new Set(this.expandedFolders);
|
|
|
|
// Reload data without rendering (skip initial render to prevent conflicts)
|
|
await this.loadTreeData(true);
|
|
|
|
// Restore expanded state after reload
|
|
this.expandedFolders = currentExpandedFolders;
|
|
|
|
// Update folder objects with preserved expanded state
|
|
if (this.treeData && this.treeData.folders) {
|
|
Object.entries(this.treeData.folders).forEach(([folderId, folder]) => {
|
|
folder.expanded = this.expandedFolders.has(folderId);
|
|
});
|
|
}
|
|
|
|
// Re-render with preserved state
|
|
this.renderTree();
|
|
}
|
|
|
|
showError(message) {
|
|
this.treeContent.innerHTML = `
|
|
<div class="prompt-companion-tree-error">
|
|
<span>⚠️ ${message}</span>
|
|
<button onclick="this.parentNode.parentNode.previousSibling.click()">Retry</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Show input dialog using ComfyUI's native API
|
|
*/
|
|
async showInputDialog(message, defaultValue = "") {
|
|
// Try ComfyUI's native prompt dialog first
|
|
if (typeof app !== 'undefined' && app.extensionManager && app.extensionManager.dialog && app.extensionManager.dialog.prompt) {
|
|
try {
|
|
return await app.extensionManager.dialog.prompt({
|
|
title: "Input",
|
|
message: message,
|
|
defaultValue: defaultValue || ""
|
|
});
|
|
} catch (error) {
|
|
console.warn("ComfyUI extensionManager dialog.prompt failed, falling back to browser prompt:", error);
|
|
}
|
|
}
|
|
|
|
// Fallback to browser prompt
|
|
const result = prompt(message, defaultValue || "");
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Show confirmation dialog using ComfyUI's native API
|
|
*/
|
|
async showConfirmDialog(message) {
|
|
// Try ComfyUI's native confirm dialog first
|
|
if (typeof app !== 'undefined' && app.extensionManager && app.extensionManager.dialog && app.extensionManager.dialog.confirm) {
|
|
try {
|
|
return await app.extensionManager.dialog.confirm({
|
|
title: "Confirm",
|
|
message: message
|
|
});
|
|
} catch (error) {
|
|
console.warn("ComfyUI extensionManager dialog.confirm failed, falling back to browser confirm:", error);
|
|
}
|
|
}
|
|
|
|
// Fallback to browser confirm
|
|
return confirm(message);
|
|
}
|
|
|
|
/**
|
|
* Show alert dialog using ComfyUI's native API or notification
|
|
*/
|
|
async showAlertDialog(message) {
|
|
// Try ComfyUI's notification system first
|
|
if (typeof app !== 'undefined' && app.ui && app.ui.dialog && app.ui.dialog.show) {
|
|
try {
|
|
app.ui.dialog.show(message);
|
|
return;
|
|
} catch (error) {
|
|
console.warn("ComfyUI ui.dialog.show failed, falling back to alert:", error);
|
|
}
|
|
}
|
|
|
|
// Fallback to browser alert
|
|
alert(message);
|
|
}
|
|
}
|
|
|
|
// Tree view instance management
|
|
let treeView = null;
|
|
|
|
function initializePromptTreeView(containerId) {
|
|
const container = typeof containerId === 'string' ?
|
|
document.getElementById(containerId) : containerId;
|
|
|
|
if (container) {
|
|
// Preserve expanded folder state from existing tree view before creating new instance
|
|
let preservedExpandedFolders = new Set();
|
|
if (treeView && treeView.expandedFolders) {
|
|
preservedExpandedFolders = new Set(treeView.expandedFolders);
|
|
}
|
|
|
|
// Create new tree view instance (handles cases where dialog is reopened with new DOM elements)
|
|
treeView = new PromptTreeView(container);
|
|
|
|
// Restore preserved expanded folder state
|
|
if (preservedExpandedFolders.size > 0) {
|
|
treeView.expandedFolders = preservedExpandedFolders;
|
|
}
|
|
}
|
|
return treeView;
|
|
}
|
|
|
|
function getPromptTreeView() {
|
|
return treeView;
|
|
}
|
|
|
|
function clearPromptTreeView() {
|
|
treeView = null;
|
|
}
|
|
|
|
// Export functions for use by other components
|
|
window.PromptCompanion = window.PromptCompanion || {};
|
|
window.PromptCompanion.initializeTreeView = initializePromptTreeView;
|
|
window.PromptCompanion.getTreeView = getPromptTreeView;
|
|
window.PromptCompanion.clearTreeView = clearPromptTreeView; |