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