mirror of
https://github.com/stackblitz/bolt.new
synced 2024-11-27 22:42:21 +00:00
feat(editor): show tooltip when the editor is read-only (#34)
This commit is contained in:
parent
7465cab8f8
commit
6e99e4c11e
@ -4,14 +4,17 @@ import { bracketMatching, foldGutter, indentOnInput, indentUnit } from '@codemir
|
|||||||
import { searchKeymap } from '@codemirror/search';
|
import { searchKeymap } from '@codemirror/search';
|
||||||
import { Compartment, EditorSelection, EditorState, StateEffect, StateField, type Extension } from '@codemirror/state';
|
import { Compartment, EditorSelection, EditorState, StateEffect, StateField, type Extension } from '@codemirror/state';
|
||||||
import {
|
import {
|
||||||
EditorView,
|
|
||||||
drawSelection,
|
drawSelection,
|
||||||
dropCursor,
|
dropCursor,
|
||||||
|
EditorView,
|
||||||
highlightActiveLine,
|
highlightActiveLine,
|
||||||
highlightActiveLineGutter,
|
highlightActiveLineGutter,
|
||||||
keymap,
|
keymap,
|
||||||
lineNumbers,
|
lineNumbers,
|
||||||
scrollPastEnd,
|
scrollPastEnd,
|
||||||
|
showTooltip,
|
||||||
|
tooltips,
|
||||||
|
type Tooltip,
|
||||||
} from '@codemirror/view';
|
} from '@codemirror/view';
|
||||||
import { memo, useEffect, useRef, useState, type MutableRefObject } from 'react';
|
import { memo, useEffect, useRef, useState, type MutableRefObject } from 'react';
|
||||||
import type { Theme } from '~/types/theme';
|
import type { Theme } from '~/types/theme';
|
||||||
@ -73,6 +76,28 @@ interface Props {
|
|||||||
|
|
||||||
type EditorStates = Map<string, EditorState>;
|
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 editableStateEffect = StateEffect.define<boolean>();
|
||||||
|
|
||||||
const editableStateField = StateField.define<boolean>({
|
const editableStateField = StateField.define<boolean>({
|
||||||
@ -261,6 +286,17 @@ function newEditorState(
|
|||||||
|
|
||||||
onScrollRef.current?.({ left: view.scrollDOM.scrollLeft, top: view.scrollDOM.scrollTop });
|
onScrollRef.current?.({ left: view.scrollDOM.scrollLeft, top: view.scrollDOM.scrollTop });
|
||||||
}, debounceScroll),
|
}, debounceScroll),
|
||||||
|
keydown: (event, view) => {
|
||||||
|
if (view.state.readOnly) {
|
||||||
|
view.dispatch({
|
||||||
|
effects: [readOnlyTooltipStateEffect.of(event.key !== 'Escape')],
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
getTheme(theme, settings),
|
getTheme(theme, settings),
|
||||||
history(),
|
history(),
|
||||||
@ -283,6 +319,20 @@ function newEditorState(
|
|||||||
autocompletion({
|
autocompletion({
|
||||||
closeOnBlur: false,
|
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(),
|
closeBrackets(),
|
||||||
lineNumbers(),
|
lineNumbers(),
|
||||||
scrollPastEnd(),
|
scrollPastEnd(),
|
||||||
@ -291,9 +341,9 @@ function newEditorState(
|
|||||||
bracketMatching(),
|
bracketMatching(),
|
||||||
EditorState.tabSize.of(settings?.tabSize ?? 2),
|
EditorState.tabSize.of(settings?.tabSize ?? 2),
|
||||||
indentOnInput(),
|
indentOnInput(),
|
||||||
|
editableTooltipField,
|
||||||
editableStateField,
|
editableStateField,
|
||||||
EditorState.readOnly.from(editableStateField, (editable) => !editable),
|
EditorState.readOnly.from(editableStateField, (editable) => !editable),
|
||||||
EditorView.editable.from(editableStateField, (editable) => editable),
|
|
||||||
highlightActiveLineGutter(),
|
highlightActiveLineGutter(),
|
||||||
highlightActiveLine(),
|
highlightActiveLine(),
|
||||||
foldGutter({
|
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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
@ -168,6 +168,18 @@ function getEditorTheme(settings: EditorSettings) {
|
|||||||
'.cm-searchMatch': {
|
'.cm-searchMatch': {
|
||||||
backgroundColor: 'var(--cm-searchMatch-backgroundColor)',
|
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',
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -144,7 +144,7 @@ export const EditorPanel = memo(
|
|||||||
{activeFile && (
|
{activeFile && (
|
||||||
<div className="flex items-center flex-1 text-sm">
|
<div className="flex items-center flex-1 text-sm">
|
||||||
<div className="i-ph:file-duotone mr-2" />
|
<div className="i-ph:file-duotone mr-2" />
|
||||||
{activeFile} {isStreaming && <span className="text-xs ml-1 font-semibold">(read-only)</span>}
|
{activeFile}
|
||||||
{activeFileUnsaved && (
|
{activeFileUnsaved && (
|
||||||
<div className="flex gap-1 ml-auto -mr-1.5">
|
<div className="flex gap-1 ml-auto -mr-1.5">
|
||||||
<PanelHeaderButton onClick={onFileSave}>
|
<PanelHeaderButton onClick={onFileSave}>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { useStore } from '@nanostores/react';
|
import { useStore } from '@nanostores/react';
|
||||||
import { motion, type HTMLMotionProps, type Variants } from 'framer-motion';
|
import { motion, type HTMLMotionProps, type Variants } from 'framer-motion';
|
||||||
import { computed } from 'nanostores';
|
import { computed } from 'nanostores';
|
||||||
import { memo, useCallback, useEffect, useState } from 'react';
|
import { memo, useCallback, useEffect } from 'react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import {
|
import {
|
||||||
type OnChangeCallback as OnEditorChange,
|
type OnChangeCallback as OnEditorChange,
|
||||||
@ -10,7 +10,7 @@ import {
|
|||||||
import { IconButton } from '~/components/ui/IconButton';
|
import { IconButton } from '~/components/ui/IconButton';
|
||||||
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
|
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
|
||||||
import { Slider, type SliderOptions } from '~/components/ui/Slider';
|
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 { cubicEasingFn } from '~/utils/easings';
|
||||||
import { renderLogger } from '~/utils/logger';
|
import { renderLogger } from '~/utils/logger';
|
||||||
import { EditorPanel } from './EditorPanel';
|
import { EditorPanel } from './EditorPanel';
|
||||||
@ -21,11 +21,9 @@ interface WorkspaceProps {
|
|||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ViewType = 'code' | 'preview';
|
|
||||||
|
|
||||||
const viewTransition = { ease: cubicEasingFn };
|
const viewTransition = { ease: cubicEasingFn };
|
||||||
|
|
||||||
const sliderOptions: SliderOptions<ViewType> = {
|
const sliderOptions: SliderOptions<WorkbenchViewType> = {
|
||||||
left: {
|
left: {
|
||||||
value: 'code',
|
value: 'code',
|
||||||
text: 'Code',
|
text: 'Code',
|
||||||
@ -62,8 +60,11 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
|||||||
const currentDocument = useStore(workbenchStore.currentDocument);
|
const currentDocument = useStore(workbenchStore.currentDocument);
|
||||||
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
|
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
|
||||||
const files = useStore(workbenchStore.files);
|
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(() => {
|
useEffect(() => {
|
||||||
if (hasPreview) {
|
if (hasPreview) {
|
||||||
|
@ -21,6 +21,8 @@ export type ArtifactUpdateState = Pick<ArtifactState, 'title' | 'closed'>;
|
|||||||
|
|
||||||
type Artifacts = MapStore<Record<string, ArtifactState>>;
|
type Artifacts = MapStore<Record<string, ArtifactState>>;
|
||||||
|
|
||||||
|
export type WorkbenchViewType = 'code' | 'preview';
|
||||||
|
|
||||||
export class WorkbenchStore {
|
export class WorkbenchStore {
|
||||||
#previewsStore = new PreviewsStore(webcontainer);
|
#previewsStore = new PreviewsStore(webcontainer);
|
||||||
#filesStore = new FilesStore(webcontainer);
|
#filesStore = new FilesStore(webcontainer);
|
||||||
@ -30,6 +32,7 @@ export class WorkbenchStore {
|
|||||||
artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({});
|
artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({});
|
||||||
|
|
||||||
showWorkbench: WritableAtom<boolean> = import.meta.hot?.data.showWorkbench ?? atom(false);
|
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>());
|
unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>());
|
||||||
modifiedFiles = new Set<string>();
|
modifiedFiles = new Set<string>();
|
||||||
artifactIdList: string[] = [];
|
artifactIdList: string[] = [];
|
||||||
@ -39,6 +42,7 @@ export class WorkbenchStore {
|
|||||||
import.meta.hot.data.artifacts = this.artifacts;
|
import.meta.hot.data.artifacts = this.artifacts;
|
||||||
import.meta.hot.data.unsavedFiles = this.unsavedFiles;
|
import.meta.hot.data.unsavedFiles = this.unsavedFiles;
|
||||||
import.meta.hot.data.showWorkbench = this.showWorkbench;
|
import.meta.hot.data.showWorkbench = this.showWorkbench;
|
||||||
|
import.meta.hot.data.currentView = this.currentView;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
6
packages/bolt/app/lib/webcontainer/auth.client.ts
Normal file
6
packages/bolt/app/lib/webcontainer/auth.client.ts
Normal 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';
|
@ -6,12 +6,12 @@ import {
|
|||||||
type LoaderFunctionArgs,
|
type LoaderFunctionArgs,
|
||||||
} from '@remix-run/cloudflare';
|
} from '@remix-run/cloudflare';
|
||||||
import { useFetcher, useLoaderData } from '@remix-run/react';
|
import { useFetcher, useLoaderData } from '@remix-run/react';
|
||||||
import { auth, type AuthAPI } from '@webcontainer/api';
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { LoadingDots } from '~/components/ui/LoadingDots';
|
import { LoadingDots } from '~/components/ui/LoadingDots';
|
||||||
import { createUserSession, isAuthenticated, validateAccessToken } from '~/lib/.server/sessions';
|
import { createUserSession, isAuthenticated, validateAccessToken } from '~/lib/.server/sessions';
|
||||||
import { CLIENT_ID, CLIENT_ORIGIN } from '~/lib/constants';
|
import { CLIENT_ID, CLIENT_ORIGIN } from '~/lib/constants';
|
||||||
import { request as doRequest } from '~/lib/fetch';
|
import { request as doRequest } from '~/lib/fetch';
|
||||||
|
import { auth, type AuthAPI } from '~/lib/webcontainer/auth.client';
|
||||||
import { logger } from '~/utils/logger';
|
import { logger } from '~/utils/logger';
|
||||||
|
|
||||||
export async function loader({ request, context }: LoaderFunctionArgs) {
|
export async function loader({ request, context }: LoaderFunctionArgs) {
|
||||||
|
Loading…
Reference in New Issue
Block a user