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 : }
|