[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
207
application/state/useSftpFileAssociations.ts
Normal file
207
application/state/useSftpFileAssociations.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* 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<FileAssociationsMap>(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<FileAssociationEntry>(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<FileAssociationEntry>(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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user