From 6e99e4c11ec0a3387a135a02a24f971c9ba5dd51 Mon Sep 17 00:00:00 2001 From: Dominic Elm Date: Fri, 9 Aug 2024 13:42:30 +0200 Subject: [PATCH] feat(editor): show tooltip when the editor is read-only (#34) --- .../editor/codemirror/CodeMirrorEditor.tsx | 80 ++++++++++++++++++- .../components/editor/codemirror/cm-theme.ts | 12 +++ .../app/components/workbench/EditorPanel.tsx | 2 +- .../components/workbench/Workbench.client.tsx | 13 +-- packages/bolt/app/lib/stores/workbench.ts | 4 + .../bolt/app/lib/webcontainer/auth.client.ts | 6 ++ packages/bolt/app/routes/login.tsx | 2 +- 7 files changed, 109 insertions(+), 10 deletions(-) create mode 100644 packages/bolt/app/lib/webcontainer/auth.client.ts diff --git a/packages/bolt/app/components/editor/codemirror/CodeMirrorEditor.tsx b/packages/bolt/app/components/editor/codemirror/CodeMirrorEditor.tsx index 0d3b68a..8e9f3a3 100644 --- a/packages/bolt/app/components/editor/codemirror/CodeMirrorEditor.tsx +++ b/packages/bolt/app/components/editor/codemirror/CodeMirrorEditor.tsx @@ -4,14 +4,17 @@ import { bracketMatching, foldGutter, indentOnInput, indentUnit } from '@codemir import { searchKeymap } from '@codemirror/search'; import { Compartment, EditorSelection, EditorState, StateEffect, StateField, type Extension } from '@codemirror/state'; import { - EditorView, drawSelection, dropCursor, + EditorView, highlightActiveLine, highlightActiveLineGutter, keymap, lineNumbers, scrollPastEnd, + showTooltip, + tooltips, + type Tooltip, } from '@codemirror/view'; import { memo, useEffect, useRef, useState, type MutableRefObject } from 'react'; import type { Theme } from '~/types/theme'; @@ -73,6 +76,28 @@ interface Props { type EditorStates = Map; +const readOnlyTooltipStateEffect = StateEffect.define(); + +const editableTooltipField = StateField.define({ + create: () => [], + update(_tooltips, transaction) { + if (!transaction.state.readOnly) { + return []; + } + + for (const effect of transaction.effects) { + if (effect.is(readOnlyTooltipStateEffect) && effect.value) { + return getReadOnlyTooltip(transaction.state); + } + } + + return []; + }, + provide: (field) => { + return showTooltip.computeN([field], (state) => state.field(field)); + }, +}); + const editableStateEffect = StateEffect.define(); const editableStateField = StateField.define({ @@ -261,6 +286,17 @@ function newEditorState( onScrollRef.current?.({ left: view.scrollDOM.scrollLeft, top: view.scrollDOM.scrollTop }); }, debounceScroll), + keydown: (event, view) => { + if (view.state.readOnly) { + view.dispatch({ + effects: [readOnlyTooltipStateEffect.of(event.key !== 'Escape')], + }); + + return true; + } + + return false; + }, }), getTheme(theme, settings), history(), @@ -283,6 +319,20 @@ function newEditorState( autocompletion({ closeOnBlur: false, }), + tooltips({ + position: 'absolute', + parent: document.body, + tooltipSpace: (view) => { + const rect = view.dom.getBoundingClientRect(); + + return { + top: rect.top - 50, + left: rect.left, + bottom: rect.bottom, + right: rect.right + 10, + }; + }, + }), closeBrackets(), lineNumbers(), scrollPastEnd(), @@ -291,9 +341,9 @@ function newEditorState( bracketMatching(), EditorState.tabSize.of(settings?.tabSize ?? 2), indentOnInput(), + editableTooltipField, editableStateField, EditorState.readOnly.from(editableStateField, (editable) => !editable), - EditorView.editable.from(editableStateField, (editable) => editable), highlightActiveLineGutter(), highlightActiveLine(), foldGutter({ @@ -383,3 +433,29 @@ function setEditorDocument( }); }); } + +function getReadOnlyTooltip(state: EditorState) { + if (!state.readOnly) { + return []; + } + + return state.selection.ranges + .filter((range) => { + return range.empty; + }) + .map((range) => { + return { + pos: range.head, + above: true, + strictSide: true, + arrow: true, + create: () => { + const divElement = document.createElement('div'); + divElement.className = 'cm-readonly-tooltip'; + divElement.textContent = 'Cannot edit file while AI response is being generated'; + + return { dom: divElement }; + }, + }; + }); +} diff --git a/packages/bolt/app/components/editor/codemirror/cm-theme.ts b/packages/bolt/app/components/editor/codemirror/cm-theme.ts index fcd6d05..6f3f363 100644 --- a/packages/bolt/app/components/editor/codemirror/cm-theme.ts +++ b/packages/bolt/app/components/editor/codemirror/cm-theme.ts @@ -168,6 +168,18 @@ function getEditorTheme(settings: EditorSettings) { '.cm-searchMatch': { backgroundColor: 'var(--cm-searchMatch-backgroundColor)', }, + '.cm-tooltip.cm-readonly-tooltip': { + padding: '4px', + whiteSpace: 'nowrap', + backgroundColor: 'var(--bolt-elements-bg-depth-2)', + borderColor: 'var(--bolt-elements-borderColorActive)', + '& .cm-tooltip-arrow:before': { + borderTopColor: 'var(--bolt-elements-borderColorActive)', + }, + '& .cm-tooltip-arrow:after': { + borderTopColor: 'transparent', + }, + }, }); } diff --git a/packages/bolt/app/components/workbench/EditorPanel.tsx b/packages/bolt/app/components/workbench/EditorPanel.tsx index 3cc0b40..847d198 100644 --- a/packages/bolt/app/components/workbench/EditorPanel.tsx +++ b/packages/bolt/app/components/workbench/EditorPanel.tsx @@ -144,7 +144,7 @@ export const EditorPanel = memo( {activeFile && (
- {activeFile} {isStreaming && (read-only)} + {activeFile} {activeFileUnsaved && (
diff --git a/packages/bolt/app/components/workbench/Workbench.client.tsx b/packages/bolt/app/components/workbench/Workbench.client.tsx index 9a973c3..391deaf 100644 --- a/packages/bolt/app/components/workbench/Workbench.client.tsx +++ b/packages/bolt/app/components/workbench/Workbench.client.tsx @@ -1,7 +1,7 @@ import { useStore } from '@nanostores/react'; import { motion, type HTMLMotionProps, type Variants } from 'framer-motion'; import { computed } from 'nanostores'; -import { memo, useCallback, useEffect, useState } from 'react'; +import { memo, useCallback, useEffect } from 'react'; import { toast } from 'react-toastify'; import { type OnChangeCallback as OnEditorChange, @@ -10,7 +10,7 @@ import { import { IconButton } from '~/components/ui/IconButton'; import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton'; import { Slider, type SliderOptions } from '~/components/ui/Slider'; -import { workbenchStore } from '~/lib/stores/workbench'; +import { workbenchStore, type WorkbenchViewType } from '~/lib/stores/workbench'; import { cubicEasingFn } from '~/utils/easings'; import { renderLogger } from '~/utils/logger'; import { EditorPanel } from './EditorPanel'; @@ -21,11 +21,9 @@ interface WorkspaceProps { isStreaming?: boolean; } -type ViewType = 'code' | 'preview'; - const viewTransition = { ease: cubicEasingFn }; -const sliderOptions: SliderOptions = { +const sliderOptions: SliderOptions = { left: { value: 'code', text: 'Code', @@ -62,8 +60,11 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) => const currentDocument = useStore(workbenchStore.currentDocument); const unsavedFiles = useStore(workbenchStore.unsavedFiles); const files = useStore(workbenchStore.files); + const selectedView = useStore(workbenchStore.currentView); - const [selectedView, setSelectedView] = useState(hasPreview ? 'preview' : 'code'); + const setSelectedView = (view: WorkbenchViewType) => { + workbenchStore.currentView.set(view); + }; useEffect(() => { if (hasPreview) { diff --git a/packages/bolt/app/lib/stores/workbench.ts b/packages/bolt/app/lib/stores/workbench.ts index 0fd72fb..4040de1 100644 --- a/packages/bolt/app/lib/stores/workbench.ts +++ b/packages/bolt/app/lib/stores/workbench.ts @@ -21,6 +21,8 @@ export type ArtifactUpdateState = Pick; type Artifacts = MapStore>; +export type WorkbenchViewType = 'code' | 'preview'; + export class WorkbenchStore { #previewsStore = new PreviewsStore(webcontainer); #filesStore = new FilesStore(webcontainer); @@ -30,6 +32,7 @@ export class WorkbenchStore { artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({}); showWorkbench: WritableAtom = import.meta.hot?.data.showWorkbench ?? atom(false); + currentView: WritableAtom = import.meta.hot?.data.currentView ?? atom('code'); unsavedFiles: WritableAtom> = import.meta.hot?.data.unsavedFiles ?? atom(new Set()); modifiedFiles = new Set(); artifactIdList: string[] = []; @@ -39,6 +42,7 @@ export class WorkbenchStore { import.meta.hot.data.artifacts = this.artifacts; import.meta.hot.data.unsavedFiles = this.unsavedFiles; import.meta.hot.data.showWorkbench = this.showWorkbench; + import.meta.hot.data.currentView = this.currentView; } } diff --git a/packages/bolt/app/lib/webcontainer/auth.client.ts b/packages/bolt/app/lib/webcontainer/auth.client.ts new file mode 100644 index 0000000..e5f33d9 --- /dev/null +++ b/packages/bolt/app/lib/webcontainer/auth.client.ts @@ -0,0 +1,6 @@ +/** + * This client-only module that contains everything related to auth and is used + * to avoid importing `@webcontainer/api` in the server bundle. + */ + +export { auth, type AuthAPI } from '@webcontainer/api'; diff --git a/packages/bolt/app/routes/login.tsx b/packages/bolt/app/routes/login.tsx index 2e27a0c..591a171 100644 --- a/packages/bolt/app/routes/login.tsx +++ b/packages/bolt/app/routes/login.tsx @@ -6,12 +6,12 @@ import { type LoaderFunctionArgs, } from '@remix-run/cloudflare'; import { useFetcher, useLoaderData } from '@remix-run/react'; -import { auth, type AuthAPI } from '@webcontainer/api'; import { useEffect, useState } from 'react'; import { LoadingDots } from '~/components/ui/LoadingDots'; import { createUserSession, isAuthenticated, validateAccessToken } from '~/lib/.server/sessions'; import { CLIENT_ID, CLIENT_ORIGIN } from '~/lib/constants'; import { request as doRequest } from '~/lib/fetch'; +import { auth, type AuthAPI } from '~/lib/webcontainer/auth.client'; import { logger } from '~/utils/logger'; export async function loader({ request, context }: LoaderFunctionArgs) {