/** * useSftpFileAssociations - Hook for managing SFTP file opener associations * Uses a shared state pattern to sync across components */ import { useCallback, useEffect, useSyncExternalStore } from 'react'; import { STORAGE_KEY_SFTP_FILE_ASSOCIATIONS, STORAGE_KEY_SFTP_DEFAULT_OPENER } from '../../infrastructure/config/storageKeys'; import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter'; import type { FileAssociation, FileOpenerType, SystemAppInfo } from '../../lib/sftpFileUtils'; import { getFileExtension, isKnownBinaryFile } from '../../lib/sftpFileUtils'; export interface FileAssociationEntry { openerType: FileOpenerType; systemApp?: SystemAppInfo; } export interface FileAssociationsMap { [extension: string]: FileAssociationEntry; } // --------------------------------------------------------------------------- // Per-extension associations store // --------------------------------------------------------------------------- const subscribers = new Set<() => void>(); let snapshotRef: { associations: FileAssociationsMap } = { associations: {} }; function loadFromStorage(): FileAssociationsMap { const stored = localStorageAdapter.read(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS); if (stored) { const migrated: FileAssociationsMap = {}; for (const [ext, value] of Object.entries(stored)) { if (typeof value === 'string') { migrated[ext] = { openerType: value as FileOpenerType }; } else { migrated[ext] = value as FileAssociationEntry; } } return migrated; } return {}; } snapshotRef = { associations: loadFromStorage() }; function saveToStorage(associations: FileAssociationsMap) { localStorageAdapter.write(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS, associations); } function updateAssociations(newAssociations: FileAssociationsMap) { snapshotRef = { associations: newAssociations }; saveToStorage(newAssociations); subscribers.forEach(callback => callback()); } function subscribe(callback: () => void) { subscribers.add(callback); return () => subscribers.delete(callback); } function getSnapshot() { return snapshotRef; } // --------------------------------------------------------------------------- // Default opener store (separate from per-extension associations) // --------------------------------------------------------------------------- const defaultOpenerSubscribers = new Set<() => void>(); let defaultOpenerSnapshot: { entry: FileAssociationEntry | null } = { entry: localStorageAdapter.read(STORAGE_KEY_SFTP_DEFAULT_OPENER) ?? null, }; function subscribeDefaultOpener(callback: () => void) { defaultOpenerSubscribers.add(callback); return () => defaultOpenerSubscribers.delete(callback); } function getDefaultOpenerSnapshot() { return defaultOpenerSnapshot; } function updateDefaultOpener(entry: FileAssociationEntry | null) { defaultOpenerSnapshot = { entry }; if (entry) { localStorageAdapter.write(STORAGE_KEY_SFTP_DEFAULT_OPENER, entry); } else { localStorageAdapter.remove(STORAGE_KEY_SFTP_DEFAULT_OPENER); } defaultOpenerSubscribers.forEach(callback => callback()); } // --------------------------------------------------------------------------- // Hook // --------------------------------------------------------------------------- export function useSftpFileAssociations() { const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); const associations = snapshot.associations; const defaultOpenerState = useSyncExternalStore(subscribeDefaultOpener, getDefaultOpenerSnapshot, getDefaultOpenerSnapshot); // Listen for storage events from other tabs/windows useEffect(() => { const handleStorage = (e: StorageEvent) => { if (e.key === STORAGE_KEY_SFTP_FILE_ASSOCIATIONS) { updateAssociations(loadFromStorage()); } else if (e.key === STORAGE_KEY_SFTP_DEFAULT_OPENER) { updateDefaultOpener( localStorageAdapter.read(STORAGE_KEY_SFTP_DEFAULT_OPENER) ?? null, ); } }; window.addEventListener('storage', handleStorage); return () => window.removeEventListener('storage', handleStorage); }, []); /** * Get the opener entry for a file based on its extension. * Falls back to the default opener when no per-extension association exists. */ const getOpenerForFile = useCallback((fileName: string): FileAssociationEntry | null => { const ext = getFileExtension(fileName); if (associations[ext]) return associations[ext]; // Fall back to default opener, but skip built-in editor for binary files const fallback = defaultOpenerState.entry; if (fallback && fallback.openerType === 'builtin-editor' && isKnownBinaryFile(fileName)) { return null; } return fallback; }, [associations, defaultOpenerState]); /** * Get the default (fallback) opener, if set. */ const getDefaultOpener = useCallback((): FileAssociationEntry | null => { return defaultOpenerState.entry; }, [defaultOpenerState]); /** * Set the default opener used when no per-extension association exists. */ const setDefaultOpener = useCallback((openerType: FileOpenerType, systemApp?: SystemAppInfo) => { updateDefaultOpener({ openerType, systemApp }); }, []); /** * Remove the default opener. */ const removeDefaultOpener = useCallback(() => { updateDefaultOpener(null); }, []); /** * Set the opener type for a specific extension */ const setOpenerForExtension = useCallback(( extension: string, openerType: FileOpenerType, systemApp?: SystemAppInfo ) => { updateAssociations({ ...snapshotRef.associations, [extension.toLowerCase()]: { openerType, systemApp }, }); }, []); /** * Remove the association for a specific extension */ const removeAssociation = useCallback((extension: string) => { const next = { ...snapshotRef.associations }; delete next[extension.toLowerCase()]; updateAssociations(next); }, []); /** * Get all per-extension associations as an array. */ const getAllAssociations = useCallback((): FileAssociation[] => { return Object.entries(associations).map(([extension, entry]: [string, FileAssociationEntry]) => ({ extension, openerType: entry.openerType, systemApp: entry.systemApp, })); }, [associations]); /** * Clear all associations */ const clearAllAssociations = useCallback(() => { updateAssociations({}); }, []); return { associations, getOpenerForFile, getDefaultOpener, setDefaultOpener, removeDefaultOpener, setOpenerForExtension, removeAssociation, getAllAssociations, clearAllAssociations, }; }