mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
6b4cecf94f
Enables users to open SFTP files using any system application via a new "Open With" dialog. Remembers associations per file extension, supports editing/removing these in settings, and synchronizes changes across components. Updates backend and Electron bridge to handle selecting and launching external applications on all platforms. Improves localization and user feedback for the new feature.
150 lines
5.0 KiB
TypeScript
150 lines
5.0 KiB
TypeScript
/**
|
|
* 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 } from '../../infrastructure/config/storageKeys';
|
|
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
|
import type { FileAssociation, FileOpenerType, SystemAppInfo } from '../../lib/sftpFileUtils';
|
|
import { getFileExtension } from '../../lib/sftpFileUtils';
|
|
|
|
export interface FileAssociationEntry {
|
|
openerType: FileOpenerType;
|
|
systemApp?: SystemAppInfo;
|
|
}
|
|
|
|
export interface FileAssociationsMap {
|
|
[extension: string]: FileAssociationEntry;
|
|
}
|
|
|
|
// Shared state and subscribers for cross-component synchronization
|
|
const subscribers = new Set<() => void>();
|
|
|
|
// Use a wrapper object so we can update the reference for useSyncExternalStore
|
|
let snapshotRef: { associations: FileAssociationsMap } = { associations: {} };
|
|
|
|
function loadFromStorage(): FileAssociationsMap {
|
|
const stored = localStorageAdapter.read<FileAssociationsMap>(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS);
|
|
console.log('[SftpFileAssociations] Loading from storage:', stored);
|
|
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;
|
|
}
|
|
}
|
|
console.log('[SftpFileAssociations] Migrated associations:', migrated);
|
|
return migrated;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
// Initialize from storage
|
|
snapshotRef = { associations: loadFromStorage() };
|
|
|
|
function saveToStorage(associations: FileAssociationsMap) {
|
|
console.log('[SftpFileAssociations] saveToStorage called with:', associations);
|
|
localStorageAdapter.write(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS, associations);
|
|
// Verify it was saved
|
|
const verify = localStorageAdapter.read(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS);
|
|
console.log('[SftpFileAssociations] Verification read from storage:', verify);
|
|
}
|
|
|
|
function updateAssociations(newAssociations: FileAssociationsMap) {
|
|
console.log('[SftpFileAssociations] Updating associations:', newAssociations);
|
|
// Create new reference so useSyncExternalStore detects change
|
|
snapshotRef = { associations: newAssociations };
|
|
saveToStorage(newAssociations);
|
|
console.log('[SftpFileAssociations] Notifying', subscribers.size, 'subscribers');
|
|
subscribers.forEach(callback => callback());
|
|
}
|
|
|
|
function subscribe(callback: () => void) {
|
|
subscribers.add(callback);
|
|
return () => subscribers.delete(callback);
|
|
}
|
|
|
|
function getSnapshot() {
|
|
return snapshotRef;
|
|
}
|
|
|
|
export function useSftpFileAssociations() {
|
|
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
const associations = snapshot.associations;
|
|
|
|
// Listen for storage events from other tabs/windows
|
|
useEffect(() => {
|
|
const handleStorage = (e: StorageEvent) => {
|
|
if (e.key === STORAGE_KEY_SFTP_FILE_ASSOCIATIONS) {
|
|
updateAssociations(loadFromStorage());
|
|
}
|
|
};
|
|
window.addEventListener('storage', handleStorage);
|
|
return () => window.removeEventListener('storage', handleStorage);
|
|
}, []);
|
|
|
|
/**
|
|
* Get the opener entry for a file based on its extension
|
|
*/
|
|
const getOpenerForFile = useCallback((fileName: string): FileAssociationEntry | null => {
|
|
const ext = getFileExtension(fileName);
|
|
return associations[ext] || null;
|
|
}, [associations]);
|
|
|
|
/**
|
|
* Set the opener type for a specific extension
|
|
*/
|
|
const setOpenerForExtension = useCallback((
|
|
extension: string,
|
|
openerType: FileOpenerType,
|
|
systemApp?: SystemAppInfo
|
|
) => {
|
|
console.log('[SftpFileAssociations] setOpenerForExtension called with:', { extension, openerType, systemApp });
|
|
console.log('[SftpFileAssociations] Current associations before update:', snapshotRef.associations);
|
|
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 associations as an array
|
|
*/
|
|
const getAllAssociations = useCallback((): FileAssociation[] => {
|
|
const result = Object.entries(associations).map(([extension, entry]: [string, FileAssociationEntry]) => ({
|
|
extension,
|
|
openerType: entry.openerType,
|
|
systemApp: entry.systemApp,
|
|
}));
|
|
console.log('[SftpFileAssociations] getAllAssociations called, returning', result.length, 'items:', result);
|
|
return result;
|
|
}, [associations]);
|
|
|
|
/**
|
|
* Clear all associations
|
|
*/
|
|
const clearAllAssociations = useCallback(() => {
|
|
updateAssociations({});
|
|
}, []);
|
|
|
|
return {
|
|
associations,
|
|
getOpenerForFile,
|
|
setOpenerForExtension,
|
|
removeAssociation,
|
|
getAllAssociations,
|
|
clearAllAssociations,
|
|
};
|
|
}
|