diff --git a/web/src/components/features/workspace/CodeEditor/utils/commands.ts b/web/src/components/features/workspace/CodeEditor/utils/commands.ts index a917c822..e278d6d5 100644 --- a/web/src/components/features/workspace/CodeEditor/utils/commands.ts +++ b/web/src/components/features/workspace/CodeEditor/utils/commands.ts @@ -1,6 +1,6 @@ import * as monaco from 'monaco-editor' import { runFileDispatcher, type StateDispatch } from '~/store' -import { dispatchFormatFile, dispatchResetWorkspace } from '~/store/workspace' +import { dispatchFormatFile, dispatchResetWorkspace, dispatchShareSnippet } from '~/store/workspace' /** * MonacoDIContainer is undocumented DI service container of monaco editor. @@ -39,7 +39,21 @@ export const attachCustomCommands = (editorInstance: monaco.editor.IStandaloneCo ) } +const debounced = (fn: (arg: TArg) => void, delay: number) => { + let tid: ReturnType | undefined + + return (arg: TArg) => { + if (tid) { + clearTimeout(tid) + } + + tid = setTimeout(fn, delay, arg) + } +} + export const registerEditorActions = (editor: monaco.editor.IStandaloneCodeEditor, dispatcher: StateDispatch) => { + const dispatchDebounce = debounced(dispatcher, 750) + const actions = [ { id: 'clear', @@ -67,6 +81,15 @@ export const registerEditorActions = (editor: monaco.editor.IStandaloneCodeEdito dispatcher(dispatchFormatFile()) }, }, + { + id: 'share', + label: 'Share Snippet', + contextMenuGroupId: 'navigation', + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS], + run: () => { + dispatchDebounce(dispatchShareSnippet()) + }, + }, ] actions.forEach((action) => editor.addAction(action)) diff --git a/web/src/store/workspace/dispatchers/snippet.ts b/web/src/store/workspace/dispatchers/snippet.ts index 94b7013c..cf627d07 100644 --- a/web/src/store/workspace/dispatchers/snippet.ts +++ b/web/src/store/workspace/dispatchers/snippet.ts @@ -12,7 +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' +import { type WorkspaceState, getDefaultWorkspaceState } from '../state' /** * Dispatch snippet load from a predefined source. @@ -104,9 +104,29 @@ export const dispatchLoadSnippet = } } +const workspaceHasChanges = (state: WorkspaceState) => { + if (state.snippet?.loading) { + return false + } + + if (!state.snippet?.id) { + return true + } + + return !!state.dirty +} + +const workspaceNotChangedNotificationID = 'WS_NOT_CHANGED' + export const dispatchShareSnippet = () => async (dispatch: DispatchFn, getState: StateProvider) => { const notificationId = newNotificationId() - const { workspace } = getState() + const { workspace, status } = getState() + + if (status?.loading) { + // Prevent sharing during any kind of loading progress. + // This also prevents concurrent share process. + return + } if (!workspace.files) { dispatch( @@ -121,6 +141,29 @@ export const dispatchShareSnippet = () => async (dispatch: DispatchFn, getState: return } + if (!workspaceHasChanges(workspace)) { + // Prevent from sharing already shared shippets + dispatch( + newAddNotificationAction({ + id: workspaceNotChangedNotificationID, + type: NotificationType.Info, + canDismiss: true, + title: 'Share snippet', + description: "You haven't made any changes to a snippet. Please edit any file before sharing.", + actions: [ + { + label: 'OK', + key: 'ok', + primary: true, + onClick: () => newRemoveNotificationAction(workspaceNotChangedNotificationID), + }, + ], + }), + ) + return + } + + dispatch(newRemoveNotificationAction(workspaceNotChangedNotificationID)) dispatch(newLoadingAction()) dispatch( newAddNotificationAction({ @@ -149,6 +192,7 @@ export const dispatchShareSnippet = () => async (dispatch: DispatchFn, getState: type: WorkspaceAction.WORKSPACE_IMPORT, payload: { ...workspace, + dirty: false, snippet: { id: snippetID, }, diff --git a/web/src/store/workspace/reducers.ts b/web/src/store/workspace/reducers.ts index aa15bcb6..33559d08 100644 --- a/web/src/store/workspace/reducers.ts +++ b/web/src/store/workspace/reducers.ts @@ -11,6 +11,7 @@ export const reducers = mapByAction( const addedFiles = Object.fromEntries(items.map(({ filename, content }) => [filename, content])) return { ...rest, + dirty: true, selectedFile: items[0].filename, files: { ...files, @@ -25,6 +26,7 @@ export const reducers = mapByAction( const { files = {}, ...rest } = s return { ...rest, + dirty: true, files: { ...files, [filename]: content, @@ -69,6 +71,7 @@ export const reducers = mapByAction( const { [filename]: _, ...restFiles } = files return { ...rest, + dirty: true, selectedFile: newSelectedFile, files: restFiles, } diff --git a/web/src/store/workspace/state.ts b/web/src/store/workspace/state.ts index b69366f6..62dbfa18 100644 --- a/web/src/store/workspace/state.ts +++ b/web/src/store/workspace/state.ts @@ -48,6 +48,11 @@ export interface WorkspaceState { * Key-value pair of file names and their content. */ files?: Record + + /** + * Indicates whether any of workspace files were changed. + */ + dirty?: boolean } export const initialWorkspaceState: WorkspaceState = {