516 lines
19 KiB
JavaScript
516 lines
19 KiB
JavaScript
/**
|
|
* Centralized State Management for Sidebar
|
|
* Provides a single source of truth for sidebar state with debugging and change tracking
|
|
* Includes localStorage persistence to maintain state across sidebar close/reopen
|
|
*/
|
|
|
|
// LocalStorage key for persisting state
|
|
const PERSISTENCE_KEY = 'sageutils_sidebar_state';
|
|
|
|
// Debounce timer for saving state
|
|
let saveDebounceTimer = null;
|
|
const SAVE_DEBOUNCE_MS = 500;
|
|
|
|
/**
|
|
* @typedef {Object} ModelFilters
|
|
* @property {string} type - Model type filter ('all', 'LORA', 'Checkpoint', etc.)
|
|
* @property {string} search - Search term filter
|
|
* @property {string} lastUsed - Last used filter ('all', 'today', 'week', 'month', 'never')
|
|
* @property {string} updates - Update availability filter ('all', 'available', 'none')
|
|
* @property {string} sort - Sort criteria ('name', 'lastused', 'size', 'type', etc.)
|
|
* @property {boolean} showNsfw - Whether to show NSFW content
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} ModelsTabState
|
|
* @property {string|null} selectedHash - Currently selected model hash
|
|
* @property {boolean} isDropdownOpen - Whether the file dropdown is open
|
|
* @property {ModelFilters} filters - Current filter settings
|
|
* @property {Object} cacheData - Cached model data (hash and info)
|
|
* @property {boolean} isLoading - Whether data is currently loading
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} NotesTabState
|
|
* @property {string|null} currentFile - Currently selected/edited file
|
|
* @property {boolean} isModified - Whether current file has unsaved changes
|
|
* @property {boolean} showPreview - Whether preview panel is visible
|
|
* @property {Array<string>} filesData - List of available files
|
|
* @property {boolean} isLoading - Whether file list is loading
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} GalleryTabState
|
|
* @property {string} selectedFolder - Current folder ('notes', 'input', 'output', 'custom')
|
|
* @property {Array<string>} customFolders - Array of remembered custom folder paths
|
|
* @property {string|null} selectedImage - Currently selected image path
|
|
* @property {Array<Object>} images - Array of image objects in current folder
|
|
* @property {string} sortBy - Sort criteria ('name', 'name-desc', 'date', 'date-desc')
|
|
* @property {string} searchQuery - Current search filter
|
|
* @property {string} viewMode - View mode ('grid', 'list')
|
|
* @property {string} thumbnailSize - Thumbnail size ('small', 'medium', 'large')
|
|
* @property {boolean} showMetadata - Whether metadata panel is visible
|
|
* @property {boolean} isLoading - Whether data is currently loading
|
|
* @property {Object} fullImageView - Full image viewer state
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} PromptBuilderTabState
|
|
* @property {string} positivePrompt - Positive prompt text
|
|
* @property {string} negativePrompt - Negative prompt text
|
|
* @property {number} seed - Random seed for generation
|
|
* @property {number} count - Number of prompts to generate
|
|
* @property {Array<Object>} results - Array of generated prompt results
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} SidebarState
|
|
* @property {string} activeTab - Currently active tab ('models', 'notes', 'civitai', 'gallery', 'promptBuilder')
|
|
* @property {ModelsTabState} models - Models tab state
|
|
* @property {NotesTabState} notes - Notes tab state
|
|
* @property {GalleryTabState} gallery - Gallery tab state
|
|
* @property {PromptBuilderTabState} promptBuilder - Prompt Builder tab state
|
|
*/
|
|
|
|
// Initial state
|
|
const initialState = {
|
|
activeTab: 'models',
|
|
models: {
|
|
selectedHash: null,
|
|
isDropdownOpen: false,
|
|
filters: {
|
|
type: 'all',
|
|
search: '',
|
|
lastUsed: 'all',
|
|
updates: 'all',
|
|
sort: 'name',
|
|
showNsfw: false
|
|
},
|
|
cacheData: {
|
|
hash: {},
|
|
info: {}
|
|
},
|
|
isLoading: false
|
|
},
|
|
notes: {
|
|
currentFile: null,
|
|
isModified: false,
|
|
showPreview: false,
|
|
filesData: [],
|
|
isLoading: false
|
|
},
|
|
gallery: {
|
|
selectedFolder: 'notes',
|
|
customFolders: [],
|
|
selectedImage: null,
|
|
images: [],
|
|
folders: [],
|
|
currentPath: '',
|
|
sortBy: 'name',
|
|
searchQuery: '',
|
|
viewMode: 'grid',
|
|
thumbnailSize: 'medium',
|
|
showMetadata: false,
|
|
showMetadataOnly: false,
|
|
isLoading: false,
|
|
fullImageView: {
|
|
isOpen: false,
|
|
currentImage: null,
|
|
showMetadata: false
|
|
}
|
|
},
|
|
promptBuilder: {
|
|
positivePrompt: '',
|
|
negativePrompt: '',
|
|
seed: 0,
|
|
count: 1,
|
|
results: []
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Load persisted state from localStorage
|
|
* @returns {Object|null} Persisted state or null if none exists
|
|
*/
|
|
function loadPersistedState() {
|
|
try {
|
|
const stored = localStorage.getItem(PERSISTENCE_KEY);
|
|
if (stored) {
|
|
const parsed = JSON.parse(stored);
|
|
console.debug('[StateManager] Loaded persisted state from localStorage:', parsed);
|
|
return parsed;
|
|
} else {
|
|
console.debug('[StateManager] No persisted state found in localStorage');
|
|
}
|
|
} catch (error) {
|
|
console.error('[StateManager] Error loading persisted state:', error);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Save current state to localStorage (debounced)
|
|
*/
|
|
function savePersistedState() {
|
|
// Clear any pending save
|
|
if (saveDebounceTimer) {
|
|
clearTimeout(saveDebounceTimer);
|
|
}
|
|
|
|
// Debounce the save operation
|
|
saveDebounceTimer = setTimeout(() => {
|
|
try {
|
|
// Don't persist temporary data like loading states and cache data
|
|
const stateToPersist = {
|
|
activeTab: currentState.activeTab,
|
|
models: {
|
|
selectedHash: currentState.models.selectedHash,
|
|
filters: currentState.models.filters
|
|
},
|
|
notes: {
|
|
currentFile: currentState.notes.currentFile,
|
|
showPreview: currentState.notes.showPreview
|
|
},
|
|
gallery: {
|
|
selectedFolder: currentState.gallery.selectedFolder,
|
|
customFolders: currentState.gallery.customFolders,
|
|
selectedImage: currentState.gallery.selectedImage,
|
|
currentPath: currentState.gallery.currentPath,
|
|
sortBy: currentState.gallery.sortBy,
|
|
searchQuery: currentState.gallery.searchQuery,
|
|
viewMode: currentState.gallery.viewMode,
|
|
thumbnailSize: currentState.gallery.thumbnailSize,
|
|
showMetadata: currentState.gallery.showMetadata,
|
|
showMetadataOnly: currentState.gallery.showMetadataOnly
|
|
},
|
|
promptBuilder: {
|
|
positivePrompt: currentState.promptBuilder.positivePrompt,
|
|
negativePrompt: currentState.promptBuilder.negativePrompt,
|
|
seed: currentState.promptBuilder.seed,
|
|
count: currentState.promptBuilder.count,
|
|
results: currentState.promptBuilder.results
|
|
}
|
|
};
|
|
|
|
localStorage.setItem(PERSISTENCE_KEY, JSON.stringify(stateToPersist));
|
|
console.debug('[StateManager] State persisted to localStorage');
|
|
} catch (error) {
|
|
console.error('[StateManager] Error saving persisted state:', error);
|
|
}
|
|
}, SAVE_DEBOUNCE_MS);
|
|
}
|
|
|
|
/**
|
|
* Deep merge two objects, with source overwriting target
|
|
* @param {Object} target - Target object
|
|
* @param {Object} source - Source object
|
|
* @returns {Object} Merged object
|
|
*/
|
|
function deepMerge(target, source) {
|
|
const output = { ...target };
|
|
|
|
for (const key in source) {
|
|
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
|
output[key] = deepMerge(target[key] || {}, source[key]);
|
|
} else {
|
|
output[key] = source[key];
|
|
}
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
// Create a deep copy of initial state
|
|
let currentState = JSON.parse(JSON.stringify(initialState));
|
|
|
|
// Try to load persisted state and merge with initial state
|
|
const persistedState = loadPersistedState();
|
|
if (persistedState) {
|
|
currentState = deepMerge(currentState, persistedState);
|
|
console.debug('[StateManager] Initialized with persisted state:', currentState);
|
|
} else {
|
|
console.debug('[StateManager] Initialized with default state:', currentState);
|
|
}
|
|
|
|
// State change listeners
|
|
const listeners = new Set();
|
|
|
|
/**
|
|
* Get a deep copy of the current state
|
|
* @returns {SidebarState} Current state
|
|
*/
|
|
export function getState() {
|
|
return JSON.parse(JSON.stringify(currentState));
|
|
}
|
|
|
|
/**
|
|
* Get a specific part of the state using dot notation
|
|
* @param {string} path - Path to the state value (e.g., 'models.selectedHash')
|
|
* @returns {any} State value at the specified path
|
|
*/
|
|
export function getStateValue(path) {
|
|
const keys = path.split('.');
|
|
let value = currentState;
|
|
|
|
for (const key of keys) {
|
|
if (value && typeof value === 'object' && key in value) {
|
|
value = value[key];
|
|
} else {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Update state immutably with change tracking and validation
|
|
* @param {string} path - Path to the state value (e.g., 'models.selectedHash')
|
|
* @param {any} value - New value to set
|
|
* @param {string} [action] - Optional action description for debugging
|
|
*/
|
|
export function updateState(path, value, action = 'update') {
|
|
const oldValue = getStateValue(path);
|
|
|
|
// Don't update if value hasn't changed (shallow comparison)
|
|
if (oldValue === value) {
|
|
return;
|
|
}
|
|
|
|
// Create new state with immutable update
|
|
const newState = JSON.parse(JSON.stringify(currentState));
|
|
const keys = path.split('.');
|
|
let target = newState;
|
|
|
|
// Navigate to the parent object
|
|
for (let i = 0; i < keys.length - 1; i++) {
|
|
const key = keys[i];
|
|
if (!target[key] || typeof target[key] !== 'object') {
|
|
target[key] = {};
|
|
}
|
|
target = target[key];
|
|
}
|
|
|
|
// Set the final value
|
|
const finalKey = keys[keys.length - 1];
|
|
target[finalKey] = value;
|
|
|
|
// Validate state structure
|
|
if (!validateState(newState)) {
|
|
console.error('[StateManager] Invalid state structure after update:', newState);
|
|
return;
|
|
}
|
|
|
|
// Update current state
|
|
const previousState = currentState;
|
|
currentState = newState;
|
|
|
|
// Notify listeners
|
|
notifyListeners({
|
|
type: 'stateChange',
|
|
path,
|
|
oldValue,
|
|
newValue: value,
|
|
action,
|
|
previousState: JSON.parse(JSON.stringify(previousState)),
|
|
currentState: getState()
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Subscribe to state changes
|
|
* @param {Function} listener - Function to call on state changes
|
|
* @returns {Function} Unsubscribe function
|
|
*/
|
|
export function subscribe(listener) {
|
|
listeners.add(listener);
|
|
|
|
// Return unsubscribe function
|
|
return () => {
|
|
listeners.delete(listener);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Notify all listeners of state changes and trigger persistence
|
|
* @param {Object} changeInfo - Information about the change
|
|
*/
|
|
function notifyListeners(changeInfo) {
|
|
listeners.forEach(listener => {
|
|
try {
|
|
listener(changeInfo);
|
|
} catch (error) {
|
|
console.error('[StateManager] Error in listener:', error);
|
|
}
|
|
});
|
|
|
|
// Trigger persistence after state changes
|
|
savePersistedState();
|
|
}
|
|
|
|
/**
|
|
* Validate state structure to prevent corruption
|
|
* @param {Object} state - State to validate
|
|
* @returns {boolean} Whether state is valid
|
|
*/
|
|
function validateState(state) {
|
|
try {
|
|
// Check required top-level keys
|
|
const requiredKeys = ['activeTab', 'models', 'notes', 'gallery', 'promptBuilder'];
|
|
for (const key of requiredKeys) {
|
|
if (!(key in state)) {
|
|
console.error(`[StateManager] Missing required key: ${key}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Validate activeTab
|
|
if (!['models', 'notes', 'civitai', 'gallery', 'promptBuilder'].includes(state.activeTab)) {
|
|
console.error(`[StateManager] Invalid activeTab: ${state.activeTab}`);
|
|
return false;
|
|
}
|
|
|
|
// Validate models section
|
|
if (!state.models || typeof state.models !== 'object') {
|
|
console.error('[StateManager] Invalid models section');
|
|
return false;
|
|
}
|
|
|
|
// Validate notes section
|
|
if (!state.notes || typeof state.notes !== 'object') {
|
|
console.error('[StateManager] Invalid notes section');
|
|
return false;
|
|
}
|
|
|
|
// Validate gallery section
|
|
if (!state.gallery || typeof state.gallery !== 'object') {
|
|
console.error('[StateManager] Invalid gallery section');
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error('[StateManager] State validation error:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convenient selectors for common state access patterns
|
|
*/
|
|
export const selectors = {
|
|
// Models tab selectors
|
|
selectedHash: () => getStateValue('models.selectedHash'),
|
|
isDropdownOpen: () => getStateValue('models.isDropdownOpen'),
|
|
modelFilters: () => getStateValue('models.filters'),
|
|
cacheData: () => getStateValue('models.cacheData'),
|
|
isModelsLoading: () => getStateValue('models.isLoading'),
|
|
|
|
// Notes tab selectors
|
|
currentFile: () => getStateValue('notes.currentFile'),
|
|
isModified: () => getStateValue('notes.isModified'),
|
|
showPreview: () => getStateValue('notes.showPreview'),
|
|
filesData: () => getStateValue('notes.filesData'),
|
|
isNotesLoading: () => getStateValue('notes.isLoading'),
|
|
|
|
// Gallery tab selectors
|
|
selectedFolder: () => getStateValue('gallery.selectedFolder'),
|
|
customFolders: () => getStateValue('gallery.customFolders'),
|
|
selectedImage: () => getStateValue('gallery.selectedImage'),
|
|
galleryImages: () => getStateValue('gallery.images'),
|
|
galleryFolders: () => getStateValue('gallery.folders'),
|
|
currentPath: () => getStateValue('gallery.currentPath'),
|
|
gallerySortBy: () => getStateValue('gallery.sortBy'),
|
|
gallerySearchQuery: () => getStateValue('gallery.searchQuery'),
|
|
galleryViewMode: () => getStateValue('gallery.viewMode'),
|
|
thumbnailSize: () => getStateValue('gallery.thumbnailSize'),
|
|
showGalleryMetadata: () => getStateValue('gallery.showMetadata'),
|
|
showMetadataOnly: () => getStateValue('gallery.showMetadataOnly'),
|
|
isGalleryLoading: () => getStateValue('gallery.isLoading'),
|
|
fullImageView: () => getStateValue('gallery.fullImageView'),
|
|
|
|
// Prompt Builder tab selectors
|
|
positivePrompt: () => getStateValue('promptBuilder.positivePrompt'),
|
|
negativePrompt: () => getStateValue('promptBuilder.negativePrompt'),
|
|
promptSeed: () => getStateValue('promptBuilder.seed'),
|
|
promptCount: () => getStateValue('promptBuilder.count'),
|
|
promptResults: () => getStateValue('promptBuilder.results'),
|
|
|
|
// General selectors
|
|
activeTab: () => getStateValue('activeTab')
|
|
};
|
|
|
|
/**
|
|
* Action creators for common state updates
|
|
*/
|
|
export const actions = {
|
|
// Models tab actions
|
|
selectModel: (hash) => updateState('models.selectedHash', hash, 'selectModel'),
|
|
toggleDropdown: (isOpen) => updateState('models.isDropdownOpen', isOpen, 'toggleDropdown'),
|
|
updateFilters: (filters) => updateState('models.filters', { ...getStateValue('models.filters'), ...filters }, 'updateFilters'),
|
|
setCacheData: (data) => updateState('models.cacheData', data, 'setCacheData'),
|
|
setModelsLoading: (loading) => updateState('models.isLoading', loading, 'setModelsLoading'),
|
|
|
|
// Notes tab actions
|
|
selectFile: (filename) => updateState('notes.currentFile', filename, 'selectFile'),
|
|
setModified: (modified) => updateState('notes.isModified', modified, 'setModified'),
|
|
togglePreview: (show) => updateState('notes.showPreview', show, 'togglePreview'),
|
|
setFilesData: (files) => updateState('notes.filesData', files, 'setFilesData'),
|
|
setNotesLoading: (loading) => updateState('notes.isLoading', loading, 'setNotesLoading'),
|
|
|
|
// Gallery tab actions
|
|
selectFolder: (folder) => updateState('gallery.selectedFolder', folder, 'selectFolder'),
|
|
addCustomFolder: (path) => updateState('gallery.customFolders', [...selectors.customFolders(), path], 'addCustomFolder'),
|
|
selectImage: (imagePath) => updateState('gallery.selectedImage', imagePath, 'selectImage'),
|
|
setImages: (images) => updateState('gallery.images', images, 'setImages'),
|
|
setFolders: (folders) => updateState('gallery.folders', folders, 'setFolders'),
|
|
setCurrentPath: (path) => updateState('gallery.currentPath', path, 'setCurrentPath'),
|
|
updateSort: (sortBy) => updateState('gallery.sortBy', sortBy, 'updateSort'),
|
|
setSearchQuery: (query) => updateState('gallery.searchQuery', query, 'setSearchQuery'),
|
|
setViewMode: (mode) => updateState('gallery.viewMode', mode, 'setViewMode'),
|
|
setThumbnailSize: (size) => updateState('gallery.thumbnailSize', size, 'setThumbnailSize'),
|
|
toggleMetadata: (show) => updateState('gallery.showMetadata', show, 'toggleMetadata'),
|
|
toggleMetadataOnly: (show) => updateState('gallery.showMetadataOnly', show, 'toggleMetadataOnly'),
|
|
setGalleryLoading: (loading) => updateState('gallery.isLoading', loading, 'setGalleryLoading'),
|
|
openFullImage: (image) => updateState('gallery.fullImageView', { isOpen: true, currentImage: image, showMetadata: false }, 'openFullImage'),
|
|
closeFullImage: () => updateState('gallery.fullImageView', { isOpen: false, currentImage: null, showMetadata: false }, 'closeFullImage'),
|
|
toggleFullImageMetadata: (show) => updateState('gallery.fullImageView.showMetadata', show, 'toggleFullImageMetadata'),
|
|
|
|
// Prompt Builder tab actions
|
|
setPositivePrompt: (text) => updateState('promptBuilder.positivePrompt', text, 'setPositivePrompt'),
|
|
setNegativePrompt: (text) => updateState('promptBuilder.negativePrompt', text, 'setNegativePrompt'),
|
|
setPromptSeed: (seed) => updateState('promptBuilder.seed', seed, 'setPromptSeed'),
|
|
setPromptCount: (count) => updateState('promptBuilder.count', count, 'setPromptCount'),
|
|
setPromptResults: (results) => updateState('promptBuilder.results', results, 'setPromptResults'),
|
|
addPromptResults: (newResults) => updateState('promptBuilder.results', [...selectors.promptResults(), ...newResults], 'addPromptResults'),
|
|
clearPromptResults: () => updateState('promptBuilder.results', [], 'clearPromptResults'),
|
|
|
|
// General actions
|
|
switchTab: (tab) => updateState('activeTab', tab, 'switchTab')
|
|
};
|
|
|
|
/**
|
|
* Reset state to initial values and clear localStorage
|
|
*/
|
|
export function resetState() {
|
|
try {
|
|
// Clear localStorage
|
|
localStorage.removeItem(PERSISTENCE_KEY);
|
|
|
|
// Reset to initial state
|
|
const previousState = currentState;
|
|
currentState = JSON.parse(JSON.stringify(initialState));
|
|
|
|
// Notify listeners
|
|
notifyListeners({
|
|
type: 'stateReset',
|
|
previousState: JSON.parse(JSON.stringify(previousState)),
|
|
currentState: getState()
|
|
});
|
|
|
|
console.debug('[StateManager] State reset to initial values');
|
|
} catch (error) {
|
|
console.error('[StateManager] Error resetting state:', error);
|
|
}
|
|
}
|