feat(editor): show tooltip when the editor is read-only (#34)

This commit is contained in:
Dominic Elm 2024-08-09 13:42:30 +02:00 committed by GitHub
parent 7465cab8f8
commit 6e99e4c11e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 109 additions and 10 deletions

View File

@ -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<string, EditorState>;
const readOnlyTooltipStateEffect = StateEffect.define<boolean>();
const editableTooltipField = StateField.define<readonly Tooltip[]>({
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<boolean>();
const editableStateField = StateField.define<boolean>({
@ -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 };
},
};
});
}

View File

@ -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',
},
},
});
}

View File

@ -144,7 +144,7 @@ export const EditorPanel = memo(
{activeFile && (
<div className="flex items-center flex-1 text-sm">
<div className="i-ph:file-duotone mr-2" />
{activeFile} {isStreaming && <span className="text-xs ml-1 font-semibold">(read-only)</span>}
{activeFile}
{activeFileUnsaved && (
<div className="flex gap-1 ml-auto -mr-1.5">
<PanelHeaderButton onClick={onFileSave}>

View File

@ -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<ViewType> = {
const sliderOptions: SliderOptions<WorkbenchViewType> = {
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<ViewType>(hasPreview ? 'preview' : 'code');
const setSelectedView = (view: WorkbenchViewType) => {
workbenchStore.currentView.set(view);
};
useEffect(() => {
if (hasPreview) {

View File

@ -21,6 +21,8 @@ export type ArtifactUpdateState = Pick<ArtifactState, 'title' | 'closed'>;
type Artifacts = MapStore<Record<string, ArtifactState>>;
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<boolean> = import.meta.hot?.data.showWorkbench ?? atom(false);
currentView: WritableAtom<WorkbenchViewType> = import.meta.hot?.data.currentView ?? atom('code');
unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>());
modifiedFiles = new Set<string>();
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;
}
}

View File

@ -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';

View File

@ -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) {