LCOV - code coverage report
Current view: top level - dashql-app/src/app/internals - app_settings_view.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 98.9 % 270 267
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : import * as styles from './app_settings_view.module.css';
       3              : 
       4              : import { XIcon } from '../../ui/foundations/symbol_icon.js';
       5              : import { Button, ButtonVariant, IconButton } from '../../ui/foundations/button.js';
       6              : import { ToggleSwitch } from '../../ui/foundations/toggle_switch.js';
       7              : import { SegmentedControl } from '../../ui/foundations/segmented_control.js';
       8              : 
       9              : import { AppConfig, AppSettings as AppSettingsValue, useAppConfig, useAppReconfigure } from '../../config/app_config.js';
      10              : import { CONFIRM_FINISHED_SETUP, useRouteContext, useRouterNavigate } from '../router/router.js';
      11              : import { useLogger } from '../../platform/logger/logger_provider.js';
      12              : import { LogLevel } from '../../platform/logger/log_buffer.js';
      13              : import { useStorageReader } from '../../storage/storage_provider.js';
      14              : import { useQueryResultCache } from '../../storage/query_result_cache_provider.js';
      15              : import { AppFlavor, FLAVORS } from '../../config/flavors.js';
      16              : import { useAppFlavor, useRequestedAppFlavor } from '../../config/flavor_provider.js';
      17              : import { useAppRestart } from '../../platform/crosstab/app_hand_over.js';
      18              : import { ColorScheme, useColorSchemeSetting } from '../../config/color_scheme.js';
      19              : 
      20              : /// The boolean settings that the panel toggles directly
      21              : type ToggleSettingKey =
      22              :     | 'narrowScreenLayout'
      23              :     | 'macWindowLayout'
      24              :     | 'scriptDebugMode'
      25              :     | 'tableDebugMode'
      26              :     | 'formattingDebugMode'
      27              :     | 'forceReLogin'
      28              :     | 'experimental_file_attachments';
      29              : 
      30            1 : const LOG_LEVELS = [
      31            1 :     { level: LogLevel.Trace, label: 'Trace' },
      32            1 :     { level: LogLevel.Debug, label: 'Debug' },
      33            1 :     { level: LogLevel.Info, label: 'Info' },
      34            1 :     { level: LogLevel.Warn, label: 'Warn' },
      35            1 :     { level: LogLevel.Error, label: 'Error' },
      36            1 : ];
      37              : 
      38            1 : function SettingsSection(props: { title: string; hideTitle?: boolean; children: React.ReactNode; }) {
      39            1 :     return (
      40            1 :         <section className={styles.settings_section} aria-label={props.title}>
      41            1 :             {!props.hideTitle && <h3 className={styles.section_title}>{props.title}</h3>}
      42            1 :             <div className={styles.settings_container}>
      43            1 :                 {props.children}
      44            1 :             </div>
      45            1 :         </section>
      46              :     );
      47            1 : }
      48              : 
      49            1 : export function AppSettings(props: { onClose: () => void; }) {
      50            1 :     const config = useAppConfig();
      51            1 :     const reconfigure = useAppReconfigure();
      52            1 :     const routerNavigate = useRouterNavigate();
      53            1 :     const routerContext = useRouteContext();
      54            1 :     const logger = useLogger();
      55            1 :     const storageReader = useStorageReader();
      56            1 :     const queryResultCache = useQueryResultCache();
      57            1 :     const restartApp = useAppRestart();
      58              : 
      59            1 :     const [isClearing, setIsClearing] = React.useState(false);
      60            1 :     const [isRestarting, setIsRestarting] = React.useState(false);
      61              : 
      62              :     /// Toggle a setting, an unset setting is off
      63            1 :     const toggleSetting = React.useCallback((key: ToggleSettingKey) => {
      64            1 :         reconfigure((value: AppConfig | null) => (value == null ? null : {
      65            1 :             ...value,
      66            1 :             settings: { ...(value.settings ?? {}), [key]: !value.settings?.[key] },
      67            1 :         }));
      68            1 :     }, [reconfigure]);
      69              :     // The embedded database reads the backend when it starts, the app restarts to apply it.
      70              :     // The restart stops the writers like a hand over to another tab: the database cancels its running queries,
      71              :     // e.g. of the log monitors, and drops the computed tables.
      72            1 :     const toggleExperimentalWasmBackend = React.useCallback(async () => {
      73            1 :         if (config == null) return;
      74            1 :         const enabled = !config.settings?.experimental_wasm_backend;
      75            1 :         const confirmed = confirm(
      76            1 :             `${enabled ? "Enabling" : "Disabling"} the experimental wasm backend restarts the app. Running queries are cancelled and computed results are lost.\n\nRestart now?`
      77              :         );
      78            1 :         if (!confirmed) return;
      79            1 :         const settings: AppSettingsValue = { ...(config.settings ?? {}), experimental_wasm_backend: enabled };
      80            1 :         setIsRestarting(true);
      81            1 :         try {
      82              :             // The restart reloads the tab before the settings sync would persist the settings
      83            1 :             await storageReader.backend.saveAppSettings(settings);
      84            1 :             reconfigure((value: AppConfig | null) => (value == null ? null : { ...value, settings }));
      85            1 :             await restartApp();
      86            1 :         } catch (error) {
      87            1 :             logger.warn("Failed to restart the app", {
      88            1 :                 error: error instanceof Error ? error.message : String(error)
      89            1 :             }, "app_settings");
      90            1 :             setIsRestarting(false);
      91            1 :         }
      92            1 :     }, [config, reconfigure, restartApp, storageReader, logger]);
      93            1 :     const revertSetupConfirmation = React.useCallback(() => {
      94              :         // Enable pause after setup, then reload
      95            1 :         reconfigure((value: AppConfig | null) => (value == null ? null : {
      96            1 :             ...value,
      97            1 :             settings: {
      98            1 :                 ...(value.settings ?? {}),
      99            1 :                 pauseAfterAppSetup: true,
     100            1 :             }
     101            1 :         }));
     102            1 :         routerNavigate({
     103            1 :             type: CONFIRM_FINISHED_SETUP,
     104            1 :             value: false,
     105            1 :         });
     106            1 :     }, [reconfigure]);
     107              : 
     108            1 :     const minLogLevel = config?.settings?.minLogLevel ?? LogLevel.Info;
     109            1 :     const keywordCase = config?.settings?.keywordCase === 'upper' ? 'upper' : 'lower';
     110            1 :     const setKeywordCase = (value: 'lower' | 'upper') => {
     111            1 :         reconfigure(config => config == null ? null : {
     112            1 :             ...config,
     113            1 :             settings: { ...config.settings, keywordCase: value },
     114            1 :         });
     115            1 :     };
     116            1 :     const flavor = useAppFlavor();
     117            1 :     const flavorRequested = useRequestedAppFlavor() != null;
     118            1 :     const flavors = [...FLAVORS.values()];
     119              :     // The settings of a flavor are layered over the current settings when switching to it
     120            1 :     const setFlavor = (next: AppFlavor) => {
     121            1 :         reconfigure(config => config == null ? null : {
     122            1 :             ...config,
     123            1 :             settings: { ...config.settings, ...next.settings, flavor: next.id },
     124            1 :         });
     125            1 :     };
     126            1 :     const setMinLogLevel = React.useCallback((level: LogLevel) => {
     127            1 :         reconfigure((value: AppConfig | null) => (value == null ? null : {
     128            1 :             ...value,
     129            1 :             settings: {
     130            1 :                 ...(value.settings ?? {}),
     131            1 :                 minLogLevel: level,
     132            1 :             }
     133            1 :         }));
     134            1 :         logger.buffer.setMinLogLevel(level);
     135            1 :     }, [reconfigure, logger]);
     136              : 
     137            1 :     const clearStorage = React.useCallback(async () => {
     138            1 :         if (!storageReader.backend.clearAllStorage) {
     139            1 :             logger.warn("clearAllStorage not supported by storage backend", {}, "app_settings");
     140            1 :             return;
     141            1 :         }
     142              : 
     143            1 :         const confirmed = confirm(
     144            1 :             "This will delete ALL stored data including all connections, sessions, and scripts. This action cannot be undone.\n\nAre you sure you want to clear all storage?"
     145              :         );
     146              : 
     147            1 :         if (!confirmed) {
     148            1 :             return;
     149              :         }
     150              : 
     151            1 :         try {
     152            1 :             setIsClearing(true);
     153            1 :             logger.info("Clearing all storage", {}, "app_settings");
     154            1 :             await storageReader.backend.clearAllStorage();
     155              :             // The query results of every connection, files that other windows read right now stay
     156            1 :             await queryResultCache?.clear();
     157            0 :             logger.info("Storage cleared successfully", {}, "app_settings");
     158            0 :             alert("Storage cleared successfully. The page will now reload.");
     159            0 :             window.location.reload();
     160            1 :         } catch (error) {
     161            1 :             logger.warn("Failed to clear storage", {
     162            1 :                 error: error instanceof Error ? error.message : String(error)
     163            1 :             }, "app_settings");
     164            1 :             alert(`Failed to clear storage: ${error instanceof Error ? error.message : String(error)}`);
     165            1 :         } finally {
     166            1 :             setIsClearing(false);
     167            1 :         }
     168            1 :     }, [storageReader, queryResultCache, logger]);
     169              : 
     170              :     // A row with the name of a boolean setting and its switch
     171            1 :     const toggle = (key: ToggleSettingKey, name: string) => {
     172            1 :         const id = `app-setting-${key}`;
     173            1 :         return (
     174            1 :             <React.Fragment key={key}>
     175            1 :                 <div id={id} className={styles.setting_name}>
     176            1 :                     {name}
     177            1 :                 </div>
     178            1 :                 <div className={styles.setting_switch}>
     179            1 :                     <ToggleSwitch
     180            1 :                         size="medium"
     181            1 :                         checked={config?.settings?.[key] ?? false}
     182            1 :                         onClick={() => toggleSetting(key)}
     183            1 :                         disabled={config == null}
     184            1 :                         aria-labelledby={id}
     185            1 :                     />
     186            1 :                 </div>
     187            1 :             </React.Fragment>
     188              :         );
     189            1 :     };
     190              : 
     191            1 :     const [colorScheme, setColorScheme] = useColorSchemeSetting();
     192            1 :     const colorSchemes: [ColorScheme | null, string][] = [[null, 'System'], ['light', 'Light'], ['dark', 'Dark']];
     193              : 
     194              :     // Apply log level from config on mount
     195            1 :     React.useEffect(() => {
     196            1 :         logger.buffer.setMinLogLevel(minLogLevel);
     197            1 :     }, [minLogLevel, logger]);
     198              : 
     199            1 :     return (
     200            1 :         <div className={styles.settings_root}>
     201            1 :             <div className={styles.header_container}>
     202            1 :                 <div className={styles.header_left_container}>
     203            1 :                     <div className={styles.title}>App Settings</div>
     204            1 :                 </div>
     205            1 :                 <div className={styles.header_right_container}>
     206            1 :                     <IconButton
     207            1 :                         variant={ButtonVariant.Invisible}
     208            1 :                         aria-label="close-overlay"
     209            1 :                         onClick={props.onClose}
     210              :                     >
     211            1 :                         <XIcon />
     212            1 :                     </IconButton>
     213            1 :                 </div>
     214            1 :             </div>
     215            1 :             <div className={styles.internals_container}>
     216            1 :                 <SettingsSection title="General" hideTitle>
     217            1 :                     <div
     218            1 :                         id="app-setting-flavor"
     219            1 :                         className={styles.setting_name}
     220            1 :                         title={flavorRequested ? 'The flavor is set by the url' : undefined}
     221            1 :                     >
     222              :                         Flavor
     223            1 :                     </div>
     224            1 :                     <div className={styles.setting_switch}>
     225            1 :                         <SegmentedControl
     226            1 :                             aria-labelledby="app-setting-flavor"
     227            1 :                             onChange={index => setFlavor(flavors[index])}
     228              :                         >
     229            1 :                             {flavors.map(f => (
     230            1 :                                 <SegmentedControl.Button
     231            1 :                                     key={f.id}
     232            1 :                                     selected={flavor.id === f.id}
     233            1 :                                     disabled={config == null || flavorRequested}
     234              :                                 >
     235            1 :                                     {f.displayName}
     236            1 :                                 </SegmentedControl.Button>
     237            1 :                             ))}
     238            1 :                         </SegmentedControl>
     239            1 :                     </div>
     240            1 :                     <div id="app-setting-color-scheme" className={styles.setting_name}>
     241              :                         Color Scheme
     242            1 :                     </div>
     243            1 :                     <div className={styles.setting_switch}>
     244            1 :                         <SegmentedControl
     245            1 :                             aria-labelledby="app-setting-color-scheme"
     246            1 :                             onChange={index => setColorScheme(colorSchemes[index][0])}
     247              :                         >
     248            1 :                             {colorSchemes.map(([scheme, name]) => (
     249            1 :                                 <SegmentedControl.Button
     250            1 :                                     key={name}
     251            1 :                                     selected={colorScheme === scheme}
     252            1 :                                     disabled={config == null}
     253              :                                 >
     254            1 :                                     {name}
     255            1 :                                 </SegmentedControl.Button>
     256            1 :                             ))}
     257            1 :                         </SegmentedControl>
     258            1 :                     </div>
     259            1 :                     <div id="app-setting-keyword-case" className={styles.setting_name}>
     260              :                         SQL Keyword Case
     261            1 :                     </div>
     262            1 :                     <div className={styles.setting_switch}>
     263            1 :                         <SegmentedControl
     264            1 :                             aria-labelledby="app-setting-keyword-case"
     265            1 :                             onChange={index => setKeywordCase(index === 0 ? 'lower' : 'upper')}
     266              :                         >
     267            1 :                             <SegmentedControl.Button selected={keywordCase === 'lower'} disabled={config == null}>
     268              :                                 Lowercase
     269            1 :                             </SegmentedControl.Button>
     270            1 :                             <SegmentedControl.Button selected={keywordCase === 'upper'} disabled={config == null}>
     271              :                                 Uppercase
     272            1 :                             </SegmentedControl.Button>
     273            1 :                         </SegmentedControl>
     274            1 :                     </div>
     275            1 :                 </SettingsSection>
     276            1 :                 <SettingsSection title="Layout">
     277            1 :                     {toggle('narrowScreenLayout', 'Narrow Screen Layout')}
     278            1 :                     {toggle('macWindowLayout', 'Mac Window Layout')}
     279            1 :                 </SettingsSection>
     280            1 :                 <SettingsSection title="Experimental">
     281            1 :                     {toggle('experimental_file_attachments', 'File Attachments')}
     282            1 :                     <div
     283            1 :                         id="app-setting-experimental-wasm-backend"
     284            1 :                         className={styles.setting_name}
     285            1 :                         title="Runs the embedded Hyper on its experimental wasm backend. Changing it restarts the app."
     286            1 :                     >
     287              :                         Wasm Backend
     288            1 :                     </div>
     289            1 :                     <div className={styles.setting_switch}>
     290            1 :                         <ToggleSwitch
     291            1 :                             size="medium"
     292            1 :                             checked={config?.settings?.experimental_wasm_backend ?? false}
     293            1 :                             onClick={toggleExperimentalWasmBackend}
     294            1 :                             disabled={config == null || isRestarting}
     295            1 :                             aria-labelledby="app-setting-experimental-wasm-backend"
     296            1 :                         />
     297            1 :                     </div>
     298            1 :                 </SettingsSection>
     299            1 :                 <SettingsSection title="Debugging">
     300            1 :                     <div id="app-setting-min-log-level" className={styles.setting_name}>
     301              :                         Minimum Log Level
     302            1 :                     </div>
     303            1 :                     <div className={styles.setting_switch}>
     304            1 :                         <SegmentedControl
     305            1 :                             aria-labelledby="app-setting-min-log-level"
     306            1 :                             onChange={index => setMinLogLevel(LOG_LEVELS[index].level)}
     307              :                         >
     308            1 :                             {LOG_LEVELS.map(({ level, label }) => (
     309            1 :                                 <SegmentedControl.Button key={level} selected={minLogLevel === level}>
     310            1 :                                     {label}
     311            1 :                                 </SegmentedControl.Button>
     312            1 :                             ))}
     313            1 :                         </SegmentedControl>
     314            1 :                     </div>
     315            1 :                     {toggle('scriptDebugMode', 'Script Debug Mode')}
     316            1 :                     {toggle('tableDebugMode', 'Table Debug Mode')}
     317            1 :                     {toggle('formattingDebugMode', 'Formatting Debug Mode')}
     318            1 :                 </SettingsSection>
     319            1 :                 <SettingsSection title="Login & Setup">
     320            1 :                     {toggle('forceReLogin', 'Force Re-Login')}
     321            1 :                     <div className={styles.setting_name}>
     322              :                         Revert Setup Confirmation
     323            1 :                     </div>
     324            1 :                     <div className={styles.setting_switch}>
     325            1 :                         <Button onClick={revertSetupConfirmation}>
     326              :                             Restart
     327            1 :                         </Button>
     328            1 :                     </div>
     329            1 :                 </SettingsSection>
     330            1 :                 <div className={styles.reset_section}>
     331            1 :                     <div className={styles.reset_actions}>
     332            1 :                         <Button
     333            1 :                             variant={ButtonVariant.Danger}
     334            1 :                             onClick={clearStorage}
     335            1 :                             disabled={isClearing}
     336            1 :                             aria-label="Reset app state"
     337              :                         >
     338            1 :                             {isClearing ? "Resetting..." : "Reset App State"}
     339            1 :                         </Button>
     340            1 :                     </div>
     341            1 :                 </div>
     342            1 :             </div>
     343            1 :         </div>
     344              :     );
     345            1 : }
        

Generated by: LCOV version 2.0-1