diff --git a/web/src/components/elements/misc/Kbd/Kbd.tsx b/web/src/components/elements/misc/Kbd/Kbd.tsx new file mode 100644 index 00000000..ad4d98b4 --- /dev/null +++ b/web/src/components/elements/misc/Kbd/Kbd.tsx @@ -0,0 +1,14 @@ +import { mergeStyles, useTheme } from '@fluentui/react' +import React from 'react' + +export const Kbd: React.FC = ({ children }) => { + const { semanticColors } = useTheme() + + const style = mergeStyles({ + padding: '1px 3px', + borderRadius: '4px', + background: semanticColors.disabledSubtext, + }) + + return {children} +} diff --git a/web/src/components/elements/misc/Kbd/index.ts b/web/src/components/elements/misc/Kbd/index.ts new file mode 100644 index 00000000..f9fae902 --- /dev/null +++ b/web/src/components/elements/misc/Kbd/index.ts @@ -0,0 +1 @@ +export * from './Kbd' diff --git a/web/src/components/features/settings/SettingsModal.tsx b/web/src/components/features/settings/SettingsModal.tsx index ac0bd484..5c854afc 100644 --- a/web/src/components/features/settings/SettingsModal.tsx +++ b/web/src/components/features/settings/SettingsModal.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Checkbox, Dropdown, type IPivotStyles, PivotItem, TextField } from '@fluentui/react' +import { Checkbox, Dropdown, getTheme, type IPivotStyles, PivotItem, Text, TextField } from '@fluentui/react' import { AnimatedPivot } from '~/components/elements/tabs/AnimatedPivot' import { ThemeableComponent } from '~/components/utils/ThemeableComponent' @@ -11,6 +11,8 @@ import type { RenderingBackend, TerminalSettings } from '~/store/terminal' import { connect, type StateDispatch, type MonacoParamsChanges, type SettingsState } from '~/store' import { cursorBlinkOptions, cursorLineOptions, fontOptions, terminalBackendOptions } from './options' +import { controlKeyLabel } from '~/utils/dom' +import { Kbd } from '~/components/elements/misc/Kbd' export interface SettingsChanges { monaco?: MonacoParamsChanges @@ -40,7 +42,7 @@ interface SettingsModalState { const modalStyles = { main: { - maxWidth: 480, + maxWidth: 520, }, } @@ -100,6 +102,7 @@ class SettingsModal extends ThemeableComponent { render() { const { isOpen } = this.props + const { spacing } = getTheme() return ( this.onClose()} isOpen={isOpen} styles={modalStyles}> @@ -120,14 +123,27 @@ class SettingsModal extends ThemeableComponent { } /> { - this.touchMonacoProperty('minimap', val) + this.touchSettingsProperty({ enableVimMode: val }) + }} + /> + } + /> + { + this.touchSettingsProperty({ autoSave: val }) }} /> } @@ -137,7 +153,7 @@ class SettingsModal extends ThemeableComponent { title="Use System Theme" control={ { this.touchSettingsProperty({ @@ -161,14 +177,18 @@ class SettingsModal extends ThemeableComponent { } /> ( + + Zoom the editor font when using mouse wheel and holding {controlKeyLabel()} key + + )} onChange={(_, val) => { - this.touchSettingsProperty({ enableVimMode: val }) + this.touchMonacoProperty('mouseWheelZoom', val) }} /> } @@ -192,7 +212,7 @@ class SettingsModal extends ThemeableComponent { { } /> { - this.touchMonacoProperty('cursorStyle', val) + this.touchMonacoProperty('smoothScrolling', val) }} /> } /> { - this.touchMonacoProperty('contextMenu', val) + this.touchMonacoProperty('minimap', val) }} /> } /> { - this.touchMonacoProperty('smoothScrolling', val) + this.touchMonacoProperty('cursorStyle', val) }} /> } /> { - this.touchMonacoProperty('mouseWheelZoom', val) + this.touchMonacoProperty('contextMenu', val) }} /> } diff --git a/web/src/services/config/config.ts b/web/src/services/config/config.ts index a134c1fe..4da355d6 100644 --- a/web/src/services/config/config.ts +++ b/web/src/services/config/config.ts @@ -9,6 +9,7 @@ import { type MonacoSettings, defaultMonacoSettings } from './monaco' const DARK_THEME_KEY = 'ui.darkTheme.enabled' const USE_SYSTEM_THEME_KEY = 'ui.darkTheme.useSystem' +const AUTOSAVE_ENABLED = 'ui.autosave.enabled' const RUN_TARGET_KEY = 'go.build.target' const ENABLE_VIM_MODE_KEY = 'ms.monaco.vimModeEnabled' const AUTOFORMAT_KEY = 'go.build.autoFormat' @@ -31,6 +32,14 @@ const Config = { this.setBoolean(DARK_THEME_KEY, enable) }, + get autoSave() { + return this.getBoolean(AUTOSAVE_ENABLED, false) + }, + + set autoSave(val: boolean) { + this.setBoolean(AUTOSAVE_ENABLED, val) + }, + get useSystemTheme() { return this.getBoolean(USE_SYSTEM_THEME_KEY, supportsPreferColorScheme()) }, diff --git a/web/src/store/dispatchers/settings.ts b/web/src/store/dispatchers/settings.ts index f5cf39dc..a3628102 100644 --- a/web/src/store/dispatchers/settings.ts +++ b/web/src/store/dispatchers/settings.ts @@ -12,6 +12,7 @@ import { newSettingsChangeAction, newToggleThemeAction, } from '../actions' +import { saveWorkspaceState, truncateWorkspaceState } from '../workspace/config' export function newMonacoParamsChangeDispatcher(changes: MonacoParamsChanges): Dispatcher { return (dispatch: DispatchFn, _: StateProvider) => { @@ -23,7 +24,7 @@ export function newMonacoParamsChangeDispatcher(changes: MonacoParamsChanges): D export const newSettingsChangeDispatcher = (changes: Partial): Dispatcher => - (dispatch: DispatchFn, _: StateProvider) => { + (dispatch: DispatchFn, getState: StateProvider) => { if ('useSystemTheme' in changes) { config.useSystemTheme = !!changes.useSystemTheme changes.darkMode = isDarkModeEnabled() @@ -37,6 +38,20 @@ export const newSettingsChangeDispatcher = config.enableVimMode = !!changes.enableVimMode } + if ('autoSave' in changes) { + config.autoSave = !!changes.autoSave + + // Immediately save workspace + if (changes.autoSave) { + const { workspace } = getState() + if (!workspace.snippet?.id) { + saveWorkspaceState(workspace) + } + } else { + truncateWorkspaceState() + } + } + dispatch(newSettingsChangeAction(changes)) } diff --git a/web/src/store/reducers.ts b/web/src/store/reducers.ts index cf7c52d0..ec8a24ef 100644 --- a/web/src/store/reducers.ts +++ b/web/src/store/reducers.ts @@ -27,6 +27,7 @@ import { type SettingsState, type State, type StatusState, type PanelState, type // TODO: move settings reducers and state to store/settings const initialSettingsState: SettingsState = { + autoSave: config.autoSave, darkMode: config.darkThemeEnabled, autoFormat: true, useSystemTheme: config.useSystemTheme, diff --git a/web/src/store/state.ts b/web/src/store/state.ts index 4b17f14b..c0b329af 100644 --- a/web/src/store/state.ts +++ b/web/src/store/state.ts @@ -25,6 +25,7 @@ export interface StatusState { export interface SettingsState { darkMode: boolean useSystemTheme: boolean + autoSave: boolean autoFormat: boolean enableVimMode: boolean goProxyUrl: string diff --git a/web/src/store/workspace/config.ts b/web/src/store/workspace/config.ts index 486aa918..f9e31800 100644 --- a/web/src/store/workspace/config.ts +++ b/web/src/store/workspace/config.ts @@ -8,6 +8,20 @@ const defaultWorkspace: WorkspaceState = { files: defaultFiles, } +const sanitizeState = (state: WorkspaceState) => { + // Skip current snippet URL. + const { selectedFile, files } = state + + if (!files) { + // Save defaults if ws is empty. + return defaultWorkspace + } + + return { selectedFile, files } +} + +export const truncateWorkspaceState = () => config.delete(CONFIG_KEY) + export const saveWorkspaceState = (state: WorkspaceState) => { const sanitized = sanitizeState(state) if (!sanitized.files || Object.keys(sanitized.files).length === 0) { @@ -20,15 +34,3 @@ export const saveWorkspaceState = (state: WorkspaceState) => { } export const loadWorkspaceState = (): WorkspaceState => sanitizeState(config.getObject(CONFIG_KEY, defaultWorkspace)) - -const sanitizeState = (state: WorkspaceState) => { - // Skip current snippet URL. - const { selectedFile, files } = state - - if (!files) { - // Save defaults if ws is empty. - return defaultWorkspace - } - - return { selectedFile, files } -} diff --git a/web/src/store/workspace/dispatchers/files.ts b/web/src/store/workspace/dispatchers/files.ts index 8896bf32..f8a74ed6 100644 --- a/web/src/store/workspace/dispatchers/files.ts +++ b/web/src/store/workspace/dispatchers/files.ts @@ -15,17 +15,29 @@ import { type WorkspaceState, defaultFiles } from '../state' const AUTOSAVE_INTERVAL = 1000 +const isAutosaveEnabled = (getState: StateProvider) => { + const { settings } = getState() + return settings.autoSave +} + let saveTimeout: NodeJS.Timeout + const cancelPendingAutosave = () => { clearTimeout(saveTimeout) } -const scheduleAutosave = (getState: StateProvider) => { + +const scheduleWorkspaceAutosave = (getState: StateProvider) => { cancelPendingAutosave() + if (!isAutosaveEnabled(getState)) { + return + } + saveTimeout = setTimeout(() => { const { + settings: { autoSave }, workspace: { snippet, ...wp }, } = getState() - if (snippet) { + if (snippet?.id || !autoSave) { // abort autosave when loaded external snippet. return } @@ -88,7 +100,7 @@ export const dispatchImportFile = (files: FileList) => async (dispatch: Dispatch }) } - scheduleAutosave(getState) + scheduleWorkspaceAutosave(getState) } export const dispatchCreateFile = @@ -112,7 +124,7 @@ export const dispatchCreateFile = payload: [{ filename, content }], }) - scheduleAutosave(getState) + scheduleWorkspaceAutosave(getState) } export const dispatchRemoveFile = (filename: string) => (dispatch: DispatchFn, getState: StateProvider) => { @@ -121,7 +133,7 @@ export const dispatchRemoveFile = (filename: string) => (dispatch: DispatchFn, g payload: { filename }, }) - scheduleAutosave(getState) + scheduleWorkspaceAutosave(getState) } export const dispatchUpdateFile = @@ -131,7 +143,7 @@ export const dispatchUpdateFile = payload: { filename, content }, }) - scheduleAutosave(getState) + scheduleWorkspaceAutosave(getState) } export const dispatchImportSource = (files: Record) => (dispatch: DispatchFn, _: StateProvider) => { diff --git a/web/src/store/workspace/dispatchers/snippet.ts b/web/src/store/workspace/dispatchers/snippet.ts index ccef9043..94b7013c 100644 --- a/web/src/store/workspace/dispatchers/snippet.ts +++ b/web/src/store/workspace/dispatchers/snippet.ts @@ -12,6 +12,7 @@ import { import { newLoadingAction, newErrorAction, newUIStateChangeAction } from '~/store/actions/ui' import { type SnippetLoadPayload, WorkspaceAction, type BulkFileUpdatePayload } from '../actions' import { loadWorkspaceState } from '../config' +import { getDefaultWorkspaceState } from '../state' /** * Dispatch snippet load from a predefined source. @@ -54,9 +55,15 @@ export const dispatchLoadSnippetFromSource = (source: SnippetSource) => async (d export const dispatchLoadSnippet = (snippetId: string | null) => async (dispatch: DispatchFn, getState: StateProvider) => { if (!snippetId) { + const { + settings: { autoSave }, + workspace: { snippet }, + } = getState() + + const shouldAutosave = autoSave && !snippet?.id dispatch({ type: WorkspaceAction.WORKSPACE_IMPORT, - payload: loadWorkspaceState(), + payload: shouldAutosave ? loadWorkspaceState() : getDefaultWorkspaceState(), }) return } diff --git a/web/src/store/workspace/state.ts b/web/src/store/workspace/state.ts index 43be49e0..b69366f6 100644 --- a/web/src/store/workspace/state.ts +++ b/web/src/store/workspace/state.ts @@ -59,3 +59,13 @@ export const initialWorkspaceState: WorkspaceState = { export const defaultFiles = { [defaultFileName]: defaultFile, } + +export const getDefaultWorkspaceState = (): WorkspaceState => ({ + selectedFile: defaultFileName, + snippet: { + loading: false, + }, + files: { + [defaultFileName]: defaultFile, + }, +}) diff --git a/web/src/utils/dom.ts b/web/src/utils/dom.ts index 9e852cec..2a707605 100644 --- a/web/src/utils/dom.ts +++ b/web/src/utils/dom.ts @@ -1,2 +1,11 @@ -export const isTouchDevice = () => - 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0 +export const isTouchDevice = () => 'ontouchstart' in window || navigator.maxTouchPoints > 0 + +export const isAppleDevice = () => /(iPad|iPhone|iPod|Mac)/g.test(navigator.userAgent) + +/** + * Returns name of a Ctrl or Command key label depending on user agent. + * + * Used to highlight shortcut key combinations. + * @returns + */ +export const controlKeyLabel = () => (isAppleDevice() ? '⌘' : 'Ctrl')