Files
NetMesh/components/terminalLayer/useTerminalThemePanelState.ts

324 lines
12 KiB
TypeScript
Raw Permalink Normal View History

import { startTransition, useCallback, useEffect, useMemo } from 'react';
import { useAppearanceChromeStore } from '../../application/state/appearanceChromeStore';
import type { ResolvedAppearance, TerminalAppearanceHostScope } from '../../domain/terminalAppearanceRuntime';
import {
clearHostFontFamilyOverride,
clearHostFontSizeOverride,
clearHostFontWeightOverride,
clearHostThemeOverride,
hasHostFontFamilyOverride,
hasHostFontSizeOverride,
hasHostFontWeightOverride,
hasHostThemeOverride,
resolveHostTerminalFontFamilyId,
resolveHostTerminalFontSize,
resolveHostTerminalFontWeight,
resolveHostTerminalThemeId,
} from '../../domain/terminalAppearance';
import { isSavedVaultHost } from '../../domain/ephemeralHosts';
import { isSameResolvedTerminalFont } from '../../infrastructure/config/fonts';
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
import { getScopedTopTabsThemeId } from '../terminalTopTabsTheme';
import type { SidePanelTab } from './TerminalLayerSupport';
const navigatorPlatform = typeof navigator !== 'undefined' ? navigator.platform : '';
interface UseTerminalThemePanelStateOptions {
activeSession: TerminalSession | undefined;
activeSidePanelTab: SidePanelTab | null;
activeWorkspace: Workspace | undefined;
clearIntent: () => void;
followAppTerminalTheme: boolean;
focusedSessionId: string | undefined;
fontSize: number;
hostMap: Map<string, Host>;
isSidePanelOpenForCurrentTab: boolean;
isVisible: boolean;
onUpdateHost: (host: Host) => void;
onUpdateTerminalFontFamilyId?: (fontFamilyId: string) => void;
onUpdateTerminalFontSize?: (fontSize: number) => void;
onUpdateTerminalFontWeight?: (fontWeight: number) => void;
onUpdateTerminalThemeId?: (themeId: string) => void;
onUpdateSessionFontSize?: (sessionId: string, fontSize: number) => void;
onClearSessionFontSizeOverride?: (sessionId: string) => void;
pickTheme: (themeId: string) => void;
resolveFocusedAppearance: (hostScope: TerminalAppearanceHostScope) => ResolvedAppearance;
sessionHostsMap: Map<string, Host>;
terminalFontFamilyId: string;
terminalSettings?: { fontWeight?: number };
terminalTheme: TerminalTheme;
}
export function useTerminalThemePanelState({
activeSession,
activeSidePanelTab,
activeWorkspace,
clearIntent,
followAppTerminalTheme,
focusedSessionId,
fontSize,
hostMap,
isSidePanelOpenForCurrentTab,
isVisible,
onUpdateHost,
onUpdateTerminalFontFamilyId,
onUpdateTerminalFontSize,
onUpdateTerminalFontWeight,
onUpdateTerminalThemeId,
onUpdateSessionFontSize,
onClearSessionFontSizeOverride,
pickTheme,
resolveFocusedAppearance,
sessionHostsMap,
terminalFontFamilyId,
terminalSettings,
terminalTheme,
}: UseTerminalThemePanelStateOptions) {
useEffect(() => {
if (isSidePanelOpenForCurrentTab) {
if (!followAppTerminalTheme && activeSidePanelTab !== 'theme') {
clearIntent();
}
return;
}
clearIntent();
}, [activeSidePanelTab, clearIntent, followAppTerminalTheme, isSidePanelOpenForCurrentTab]);
const focusedHost = useMemo((): Host | null => {
if (activeWorkspace && focusedSessionId) {
return sessionHostsMap.get(focusedSessionId) ?? null;
}
if (activeSession) {
return sessionHostsMap.get(activeSession.id) ?? null;
}
return null;
}, [activeWorkspace, focusedSessionId, activeSession, sessionHostsMap]);
const isFocusedHostLocal = useMemo(() => {
return focusedHost?.protocol === 'local' || !!focusedHost?.id?.startsWith('local-');
}, [focusedHost]);
const isFocusedHostEphemeral = useMemo(() => {
if (isFocusedHostLocal) return true;
if (!focusedHost) return true;
return !isSavedVaultHost(hostMap.get(focusedHost.id));
}, [focusedHost, isFocusedHostLocal, hostMap]);
const rawFocusedHost = useMemo(() => {
if (!focusedHost) return null;
return hostMap.get(focusedHost.id) ?? null;
}, [focusedHost, hostMap]);
const focusedHostScope = useMemo((): TerminalAppearanceHostScope => ({
host: focusedHost,
isEphemeral: isFocusedHostEphemeral,
}), [focusedHost, isFocusedHostEphemeral]);
// Subscribe to accent store so compose-bar / manual chrome recompute when
// color-picker changes, even though resolveFocusedAppearance identity is
// intentionally stable across accent drag (and TerminalLayer memo ignores
// accent props).
const appearanceChrome = useAppearanceChromeStore();
const focusedAppearance = useMemo(() => {
// resolveFocusedAppearance reads accent from the chrome store; pin these
// deps so compose-bar chrome updates while focusedHostScope stays stable.
void appearanceChrome.accentMode;
void appearanceChrome.customAccent;
return resolveFocusedAppearance(focusedHostScope);
}, [
appearanceChrome.accentMode,
appearanceChrome.customAccent,
focusedHostScope,
resolveFocusedAppearance,
]);
const previewTargetSessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id ?? null;
const focusedThemeId = resolveHostTerminalThemeId(focusedHost, terminalTheme.id);
const focusedFontFamilyId = resolveHostTerminalFontFamilyId(focusedHost, terminalFontFamilyId);
const focusedFontSize = resolveHostTerminalFontSize(focusedHost, fontSize);
const focusedThemeOverridden = hasHostThemeOverride(focusedHost);
const focusedFontFamilyOverridden = hasHostFontFamilyOverride(focusedHost);
const focusedFontSizeOverridden = hasHostFontSizeOverride(focusedHost);
const focusedFontWeight = resolveHostTerminalFontWeight(focusedHost, terminalSettings?.fontWeight ?? 400);
const focusedFontWeightOverridden = hasHostFontWeightOverride(focusedHost);
const visibleFocusedThemeId = followAppTerminalTheme ? terminalTheme.id : focusedThemeId;
const listSelectedThemeId = followAppTerminalTheme
? terminalTheme.id
: focusedAppearance.themeId;
const previewedOrVisibleThemeId = listSelectedThemeId;
const resolvedPreviewTheme = focusedAppearance.theme;
const activeTopTabsThemeId = useMemo(
() => getScopedTopTabsThemeId({
activeSidePanelTab,
activeThemePreviewId: null,
activeWorkspace,
followAppTerminalTheme,
isVisible,
previewTargetSessionId,
previewedOrVisibleThemeId,
resolveSessionThemeId: (sessionId) => {
const host = sessionHostsMap.get(sessionId) ?? null;
const isEphemeral = !host || !isSavedVaultHost(hostMap.get(host.id));
return resolveFocusedAppearance({ host, isEphemeral }).themeId;
},
}),
[
activeSidePanelTab,
activeWorkspace,
followAppTerminalTheme,
hostMap,
isVisible,
previewTargetSessionId,
previewedOrVisibleThemeId,
resolveFocusedAppearance,
sessionHostsMap,
],
);
const handleThemeChangeForFocusedSession = useCallback((themeId: string) => {
if (themeId === listSelectedThemeId) return;
if (!focusedHost && !followAppTerminalTheme) return;
if (followAppTerminalTheme) {
pickTheme(themeId);
return;
}
pickTheme(themeId, {
followApp: false,
scopeHostId: rawFocusedHost?.id ?? focusedHost?.id ?? null,
});
if (isFocusedHostEphemeral) {
onUpdateTerminalThemeId?.(themeId);
} else if (rawFocusedHost) {
onUpdateHost({ ...rawFocusedHost, theme: themeId, themeOverride: true });
}
}, [
focusedHost,
followAppTerminalTheme,
isFocusedHostEphemeral,
listSelectedThemeId,
onUpdateHost,
onUpdateTerminalThemeId,
pickTheme,
rawFocusedHost,
]);
const handleThemeResetForFocusedSession = useCallback(() => {
clearIntent();
if (!focusedHost || isFocusedHostEphemeral || !rawFocusedHost) return;
onUpdateHost(clearHostThemeOverride(rawFocusedHost));
}, [clearIntent, focusedHost, isFocusedHostEphemeral, onUpdateHost, rawFocusedHost]);
const handleFontFamilyChangeForFocusedSession = useCallback((fontFamilyId: string) => {
if (!focusedHost || isSameResolvedTerminalFont(fontFamilyId, focusedFontFamilyId, navigatorPlatform)) return;
startTransition(() => {
if (isFocusedHostEphemeral) {
onUpdateTerminalFontFamilyId?.(fontFamilyId);
return;
}
if (rawFocusedHost) {
onUpdateHost({ ...rawFocusedHost, fontFamily: fontFamilyId, fontFamilyOverride: true });
}
});
}, [focusedHost, focusedFontFamilyId, isFocusedHostEphemeral, onUpdateTerminalFontFamilyId, onUpdateHost, rawFocusedHost]);
const handleFontFamilyResetForFocusedSession = useCallback(() => {
if (!focusedHost || isFocusedHostEphemeral || !rawFocusedHost) return;
onUpdateHost(clearHostFontFamilyOverride(rawFocusedHost));
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost, rawFocusedHost]);
const handleFontSizeChangeForFocusedSession = useCallback((newFontSize: number) => {
if (!focusedHost || newFontSize === focusedFontSize) return;
startTransition(() => {
if (activeWorkspace && focusedSessionId) {
onUpdateSessionFontSize?.(focusedSessionId, newFontSize);
return;
}
if (isFocusedHostEphemeral) {
// Ephemeral hosts cannot persist host-level overrides; keep the
// change per-session (same path Ctrl+zoom uses) when possible.
const targetSessionId = focusedSessionId ?? activeSession?.id;
if (targetSessionId) {
onUpdateSessionFontSize?.(targetSessionId, newFontSize);
return;
}
onUpdateTerminalFontSize?.(newFontSize);
return;
}
if (rawFocusedHost) {
onUpdateHost({ ...rawFocusedHost, fontSize: newFontSize, fontSizeOverride: true });
}
});
}, [activeSession, activeWorkspace, focusedHost, focusedFontSize, focusedSessionId, isFocusedHostEphemeral, onUpdateSessionFontSize, onUpdateTerminalFontSize, onUpdateHost, rawFocusedHost]);
const handleFontSizeResetForFocusedSession = useCallback(() => {
if (!focusedHost) return;
if (activeWorkspace && focusedSessionId) {
onClearSessionFontSizeOverride?.(focusedSessionId);
return;
}
if (isFocusedHostEphemeral) {
const targetSessionId = focusedSessionId ?? activeSession?.id;
if (targetSessionId) onClearSessionFontSizeOverride?.(targetSessionId);
return;
}
if (!rawFocusedHost) return;
onUpdateHost(clearHostFontSizeOverride(rawFocusedHost));
}, [activeSession, activeWorkspace, focusedHost, focusedSessionId, isFocusedHostEphemeral, onClearSessionFontSizeOverride, onUpdateHost, rawFocusedHost]);
const handleFontWeightChangeForFocusedSession = useCallback((newFontWeight: number) => {
if (!focusedHost || newFontWeight === focusedFontWeight) return;
startTransition(() => {
if (isFocusedHostEphemeral) {
onUpdateTerminalFontWeight?.(newFontWeight);
return;
}
const rawHost = hostMap.get(focusedHost.id);
if (rawHost) {
onUpdateHost({ ...rawHost, fontWeight: newFontWeight, fontWeightOverride: true });
}
});
}, [focusedHost, focusedFontWeight, isFocusedHostEphemeral, onUpdateTerminalFontWeight, onUpdateHost, hostMap]);
const handleFontWeightResetForFocusedSession = useCallback(() => {
if (!focusedHost || isFocusedHostEphemeral) return;
const rawHost = hostMap.get(focusedHost.id);
if (rawHost) {
onUpdateHost(clearHostFontWeightOverride(rawHost));
}
}, [focusedHost, isFocusedHostEphemeral, onUpdateHost, hostMap]);
const composeBarThemeColors = useMemo(() => {
if (!activeWorkspace || !focusedSessionId) return terminalTheme.colors;
return resolvedPreviewTheme.colors;
}, [activeWorkspace, focusedSessionId, resolvedPreviewTheme.colors, terminalTheme.colors]);
return {
activeTopTabsThemeId,
composeBarThemeColors,
focusedFontFamilyId,
focusedFontFamilyOverridden,
focusedFontSize,
focusedFontSizeOverridden,
focusedFontWeight,
focusedFontWeightOverridden,
focusedThemeOverridden,
handleFontFamilyChangeForFocusedSession,
handleFontFamilyResetForFocusedSession,
handleFontSizeChangeForFocusedSession,
handleFontSizeResetForFocusedSession,
handleFontWeightChangeForFocusedSession,
handleFontWeightResetForFocusedSession,
handleThemeChangeForFocusedSession,
handleThemeResetForFocusedSession,
previewedOrVisibleThemeId,
previewTargetSessionId,
resolvedPreviewTheme,
visibleFocusedThemeId,
};
}