Files
arcum42-ComfyUI_SageUtils/js/file/fileEditor.js
T

673 lines
22 KiB
JavaScript

/**
* Generic File Editor Component
* Provides reusable file editing functionality with syntax highlighting support
*/
import { api } from "../../../../scripts/api.js";
import { FILE_BROWSER_CONFIGS } from './fileBrowser.js';
import { createButton, BUTTON_VARIANTS } from '../components/buttons.js';
/**
* Generic File Editor component
* Provides a versatile text editor with auto-save, cancel, and delete functionality
*/
import { copyTextFromSelectedNode } from '../utils/textCopyFromNode.js';
import { app } from '../../../scripts/app.js';
export class GenericFileEditor {
constructor(configKey, callbacks = {}) {
this.config = FILE_BROWSER_CONFIGS[configKey];
if (!this.config) {
throw new Error(`Unknown file editor config: ${configKey}`);
}
this.callbacks = {
onSave: callbacks.onSave || (() => {}),
onCancel: callbacks.onCancel || (() => {}),
onChange: callbacks.onChange || (() => {}),
onLoad: callbacks.onLoad || (() => {}),
onError: callbacks.onError || ((error) => console.error('File editor error:', error)),
...callbacks
};
this.currentFile = null;
this.isModified = false;
this.container = null;
this.textEditor = null;
this.saveButton = null;
this.deleteButton = null;
this.currentFileNameSpan = null;
}
/**
* Creates and returns the file editor UI
* @param {HTMLElement} parentContainer - Container to append the editor to
* @returns {Object} - Object containing references to created elements
*/
render(parentContainer) {
// Create main container
this.container = document.createElement('div');
this.container.className = 'generic-file-editor';
this.container.style.cssText = `
margin-bottom: 15px;
padding: 15px;
background: #2a2a2a;
border-radius: 6px;
border: 1px solid #444;
`;
// Create header
const header = this.createHeader();
this.container.appendChild(header);
// Create text editor
this.textEditor = this.createTextEditor();
this.container.appendChild(this.textEditor);
// Add to parent container
if (parentContainer) {
parentContainer.appendChild(this.container);
}
return {
container: this.container,
header: header,
textEditor: this.textEditor,
saveButton: this.saveButton,
deleteButton: this.deleteButton,
currentFileNameSpan: this.currentFileNameSpan
};
}
/**
* Creates the header section with title and action buttons
* @returns {HTMLElement} - Header element
*/
createHeader() {
const header = document.createElement('div');
header.className = 'file-editor-header';
header.style.cssText = `
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
`;
// Title with current file name
const titleContainer = document.createElement('div');
titleContainer.style.cssText = `
display: flex;
align-items: center;
gap: 8px;
`;
const title = document.createElement('h4');
title.style.cssText = `
margin: 0;
color: #4CAF50;
font-size: 14px;
`;
title.textContent = 'Editor';
this.currentFileNameSpan = document.createElement('span');
this.currentFileNameSpan.style.cssText = `
color: #999;
font-weight: normal;
font-size: 12px;
`;
titleContainer.appendChild(title);
titleContainer.appendChild(this.currentFileNameSpan);
// Actions container
const actions = document.createElement('div');
actions.style.cssText = `
display: flex;
gap: 8px;
`;
// Copy from node button
this.copyFromNodeButton = createButton('📥 From Node', {
color: '#9c27b0',
size: 'medium',
marginTop: '0',
onClick: () => this.copyFromNode()
});
actions.appendChild(this.copyFromNodeButton);
// Save button
this.saveButton = createButton('Save', {
variant: BUTTON_VARIANTS.SUCCESS,
size: 'medium',
marginTop: '0',
disabled: true,
onClick: () => this.saveFile()
});
actions.appendChild(this.saveButton);
// Delete button (if allowed)
if (this.config.allowDelete) {
this.deleteButton = createButton('Delete', {
variant: BUTTON_VARIANTS.DANGER,
size: 'medium',
marginTop: '0',
disabled: true,
onClick: () => this.deleteFile()
});
actions.appendChild(this.deleteButton);
}
header.appendChild(titleContainer);
header.appendChild(actions);
return header;
}
/**
* Creates an action button
* @deprecated Use createButton() from buttons.js instead
* @param {string} text - Button text
* @param {string} color - Button color
* @param {Function} onClick - Click handler
* @returns {HTMLElement} - Button element
*/
createActionButton(text, color, onClick) {
console.warn('createActionButton is deprecated. Use createButton() from buttons.js instead.');
return createButton(text, {
color,
size: 'medium',
marginTop: '0',
onClick
});
}
/**
* Creates the text editor textarea
* @returns {HTMLElement} - Text editor element
*/
createTextEditor() {
const textEditor = document.createElement('textarea');
textEditor.className = 'file-editor-textarea';
textEditor.style.cssText = `
width: 100%;
height: 300px;
background: #333;
color: #fff;
border: 1px solid #555;
border-radius: 4px;
padding: 10px;
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.4;
resize: vertical;
box-sizing: border-box;
`;
textEditor.placeholder = 'Select a file to edit or create a new one...';
// Track modifications
textEditor.addEventListener('input', () => {
this.setModified(true);
this.callbacks.onChange(textEditor.value);
});
// Auto-resize on content change
textEditor.addEventListener('input', () => {
this.autoResize(textEditor);
});
return textEditor;
}
/**
* Auto-resizes the textarea based on content
* @param {HTMLElement} textarea - Textarea element to resize
*/
autoResize(textarea) {
// Reset height to auto to get the correct scrollHeight
textarea.style.height = 'auto';
// Set height based on scroll height, with min/max constraints
const minHeight = 300;
const maxHeight = 600;
const scrollHeight = textarea.scrollHeight;
textarea.style.height = Math.min(Math.max(scrollHeight, minHeight), maxHeight) + 'px';
}
/**
* Copies text from selected node to editor
*/
copyFromNode() {
const result = copyTextFromSelectedNode(app);
if (result.success) {
this.textEditor.value = result.text;
this.setModified(true);
// Visual feedback
const originalBg = this.textEditor.style.backgroundColor;
this.textEditor.style.backgroundColor = '#9c27b033';
setTimeout(() => {
this.textEditor.style.backgroundColor = originalBg;
}, 300);
console.log(`[GenericFileEditor] Copied text from ${result.nodeType} node to editor`);
} else {
// Visual error feedback
const originalBg = this.textEditor.style.backgroundColor;
this.textEditor.style.backgroundColor = '#f4433633';
setTimeout(() => {
this.textEditor.style.backgroundColor = originalBg;
}, 300);
console.error(`[GenericFileEditor] Copy from node failed: ${result.error}`);
alert(`Failed to copy from node: ${result.error}`);
}
}
/**
* Loads a file for editing
* @param {string} filename - Name of the file to load
* @param {string} apiEndpoint - API endpoint to load the file from
*/
async loadFile(filename, apiEndpoint = null) {
if (!filename) {
this.clearEditor();
return;
}
try {
// Determine API endpoint based on config
const endpoint = apiEndpoint || this.getLoadEndpoint(filename);
// Check if file is editable
if (!this.isEditableFile(filename)) {
this.textEditor.value = '';
this.textEditor.placeholder = `${this.getFileType(filename)} files cannot be edited as text`;
this.textEditor.disabled = true;
this.updateFileDisplay(filename);
this.setModified(false);
this.callbacks.onLoad(filename, '');
return;
}
// Load file content
let response;
if (this.config.apiEndpoint.includes('wildcard')) {
// Wildcard files use GET with path parameter
response = await api.fetchApi(endpoint);
} else {
// Notes files use POST with JSON body
response = await api.fetchApi(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename })
});
}
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
// Update editor
this.currentFile = filename;
this.textEditor.value = result.content || '';
this.textEditor.placeholder = 'Select a file to edit or create a new one...';
this.textEditor.disabled = false;
this.updateFileDisplay(filename);
this.setModified(false);
// Auto-resize based on content
this.autoResize(this.textEditor);
this.callbacks.onLoad(filename, result.content || '');
} catch (error) {
this.callbacks.onError(error);
this.clearEditor();
}
}
/**
* Gets the appropriate load endpoint for a file
* @param {string} filename - Name of the file
* @returns {string} - API endpoint
*/
getLoadEndpoint(filename) {
// For notes, use the read_note endpoint
if (this.config.apiEndpoint.includes('notes')) {
return '/sage_utils/read_note';
}
// For wildcards, use the wildcard file endpoint
if (this.config.apiEndpoint.includes('wildcard')) {
return `/sage_utils/wildcard_file/${encodeURIComponent(filename)}`;
}
// Default fallback
return '/sage_utils/read_file';
}
/**
* Checks if a file can be edited as text
* @param {string} filename - Name of the file
* @returns {boolean} - True if file can be edited as text
*/
isEditableFile(filename) {
const textExtensions = ['.txt', '.md', '.markdown', '.json', '.yaml', '.yml', '.py'];
const fileExt = '.' + filename.toLowerCase().split('.').pop();
return textExtensions.includes(fileExt);
}
/**
* Gets the file type description
* @param {string} filename - Name of the file
* @returns {string} - File type description
*/
getFileType(filename) {
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'];
const videoExtensions = ['.mp4', '.webm', '.ogg', '.avi', '.mov', '.wmv', '.flv', '.mkv', '.m4v'];
const fileExt = '.' + filename.toLowerCase().split('.').pop();
if (imageExtensions.includes(fileExt)) {
return 'Image';
} else if (videoExtensions.includes(fileExt)) {
return 'Video';
} else {
return 'Binary';
}
}
/**
* Updates the file display in the header
* @param {string} filename - Name of the current file
*/
updateFileDisplay(filename) {
if (this.currentFileNameSpan) {
const displayText = filename ?
` - ${filename}${this.isModified ? ' *' : ''}` : '';
this.currentFileNameSpan.textContent = displayText;
}
// Enable/disable buttons based on file selection
if (this.saveButton) {
this.saveButton.disabled = !filename;
this.saveButton.style.opacity = filename ? '1' : '0.5';
}
if (this.deleteButton) {
this.deleteButton.disabled = !filename;
this.deleteButton.style.opacity = filename ? '1' : '0.5';
}
}
/**
* Sets the modified state
* @param {boolean} modified - Whether the file is modified
*/
setModified(modified) {
this.isModified = modified;
this.updateFileDisplay(this.currentFile);
}
/**
* Saves the current file
*/
async saveFile() {
if (!this.currentFile) {
console.error('FileEditor: No file selected to save');
this.callbacks.onError(new Error('No file selected to save'));
return;
}
console.log('FileEditor: Saving file:', this.currentFile);
console.log('FileEditor: File content length:', this.textEditor.value.length);
try {
const endpoint = this.getSaveEndpoint();
console.log('FileEditor: Using save endpoint:', endpoint);
const payload = {
filename: this.currentFile,
content: this.textEditor.value
};
console.log('FileEditor: Save payload:', payload);
const response = await api.fetchApi(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
console.log('FileEditor: Save response status:', response.status);
if (!response.ok) {
// Try to get more details about the error from the response
let errorDetails = `HTTP error! status: ${response.status}`;
try {
const errorText = await response.text();
console.log('FileEditor: Server error response:', errorText);
if (errorText) {
errorDetails += ` - ${errorText}`;
}
} catch (parseError) {
console.log('FileEditor: Could not parse error response');
}
throw new Error(errorDetails);
}
const result = await response.json();
console.log('FileEditor: Save result:', result);
if (result.success) {
console.log('FileEditor: File saved successfully');
this.setModified(false);
this.callbacks.onSave(this.currentFile, this.textEditor.value);
} else {
throw new Error(result.error || 'Unknown error occurred');
}
} catch (error) {
console.error('FileEditor: Save error:', error);
this.callbacks.onError(error);
}
}
/**
* Gets the appropriate save endpoint
* @returns {string} - API endpoint
*/
getSaveEndpoint() {
console.log('FileEditor: Getting save endpoint, config.apiEndpoint:', this.config.apiEndpoint);
// For notes, use the save_note endpoint
if (this.config.apiEndpoint.includes('notes')) {
console.log('FileEditor: Using notes save endpoint');
return '/sage_utils/save_note';
}
// For wildcards, use the wildcard save endpoint
if (this.config.apiEndpoint.includes('wildcard')) {
console.log('FileEditor: Using wildcards save endpoint');
return '/sage_utils/wildcard/file/save';
}
// Default fallback
console.log('FileEditor: Using default save endpoint');
return '/sage_utils/save_file';
}
/**
* Deletes the current file
*/
async deleteFile() {
if (!this.currentFile) {
this.callbacks.onError(new Error('No file selected to delete'));
return;
}
if (!confirm(`Are you sure you want to delete "${this.currentFile}"?`)) {
return;
}
try {
const endpoint = this.getDeleteEndpoint();
const response = await api.fetchApi(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: this.currentFile })
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result.success) {
const deletedFile = this.currentFile;
this.clearEditor();
this.callbacks.onSave(deletedFile, null); // null content indicates deletion
} else {
throw new Error(result.error || 'Unknown error occurred');
}
} catch (error) {
this.callbacks.onError(error);
}
}
/**
* Gets the appropriate delete endpoint
* @returns {string} - API endpoint
*/
getDeleteEndpoint() {
// For notes, use the delete_note endpoint
if (this.config.apiEndpoint.includes('notes')) {
return '/sage_utils/delete_note';
}
// For wildcards, use the wildcard delete endpoint
if (this.config.apiEndpoint.includes('wildcard')) {
return '/sage_utils/wildcard/file/delete';
}
// Default fallback
return '/sage_utils/delete_file';
}
/**
* Clears the editor and resets state
*/
clearEditor() {
this.currentFile = null;
this.textEditor.value = '';
this.textEditor.placeholder = 'Select a file to edit or create a new one...';
this.textEditor.disabled = false;
this.setModified(false);
this.updateFileDisplay(null);
}
/**
* Creates a new file
* @param {string} filename - Name of the new file
*/
createNewFile(filename) {
console.log('FileEditor: Creating new file:', filename);
this.currentFile = filename;
// Set a minimal default content to avoid empty string validation issues
const defaultContent = `# ${filename}\n\nCreated: ${new Date().toISOString()}\n\n`;
this.textEditor.value = defaultContent;
this.textEditor.disabled = false;
this.textEditor.focus();
this.setModified(true);
this.updateFileDisplay(filename);
console.log('FileEditor: New file setup complete, currentFile:', this.currentFile, 'isModified:', this.isModified);
}
/**
* Gets the current file content
* @returns {string} - Current content of the editor
*/
getContent() {
return this.textEditor.value;
}
/**
* Sets the editor content
* @param {string} content - Content to set
*/
setContent(content) {
this.textEditor.value = content;
this.autoResize(this.textEditor);
this.setModified(true);
}
/**
* Gets the current file name
* @returns {string|null} - Current file name or null
*/
getCurrentFile() {
return this.currentFile;
}
/**
* Checks if the file has been modified
* @returns {boolean} - True if modified
*/
getIsModified() {
return this.isModified;
}
/**
* Changes the configuration
* @param {string} newConfigKey - New configuration key
*/
changeConfiguration(newConfigKey) {
this.configKey = newConfigKey;
this.config = FILE_BROWSER_CONFIGS[newConfigKey];
if (!this.config) {
throw new Error(`Unknown file editor config: ${newConfigKey}`);
}
// Clear current file and reset state
this.currentFile = null;
this.isModified = false;
if (this.textEditor) {
this.textEditor.value = '';
this.textEditor.disabled = true;
}
if (this.currentFileNameSpan) {
this.currentFileNameSpan.textContent = '';
}
this.updateFileDisplay(null);
}
/**
* Destroys the editor and cleans up
*/
destroy() {
if (this.container && this.container.parentNode) {
this.container.parentNode.removeChild(this.container);
}
this.currentFile = null;
this.isModified = false;
this.container = null;
this.textEditor = null;
this.saveButton = null;
this.deleteButton = null;
this.currentFileNameSpan = null;
}
}
export default GenericFileEditor;