Dominic Elm
commited on
feat(editor): show tooltip when the editor is read-only (#34)
Browse files- packages/bolt/app/components/editor/codemirror/CodeMirrorEditor.tsx +78 -2
- packages/bolt/app/components/editor/codemirror/cm-theme.ts +12 -0
- packages/bolt/app/components/workbench/EditorPanel.tsx +1 -1
- packages/bolt/app/components/workbench/Workbench.client.tsx +7 -6
- packages/bolt/app/lib/stores/workbench.ts +4 -0
- packages/bolt/app/lib/webcontainer/auth.client.ts +6 -0
- packages/bolt/app/routes/login.tsx +1 -1
packages/bolt/app/components/editor/codemirror/CodeMirrorEditor.tsx
CHANGED
|
@@ -4,14 +4,17 @@ import { bracketMatching, foldGutter, indentOnInput, indentUnit } from '@codemir
|
|
| 4 |
import { searchKeymap } from '@codemirror/search';
|
| 5 |
import { Compartment, EditorSelection, EditorState, StateEffect, StateField, type Extension } from '@codemirror/state';
|
| 6 |
import {
|
| 7 |
-
EditorView,
|
| 8 |
drawSelection,
|
| 9 |
dropCursor,
|
|
|
|
| 10 |
highlightActiveLine,
|
| 11 |
highlightActiveLineGutter,
|
| 12 |
keymap,
|
| 13 |
lineNumbers,
|
| 14 |
scrollPastEnd,
|
|
|
|
|
|
|
|
|
|
| 15 |
} from '@codemirror/view';
|
| 16 |
import { memo, useEffect, useRef, useState, type MutableRefObject } from 'react';
|
| 17 |
import type { Theme } from '~/types/theme';
|
|
@@ -73,6 +76,28 @@ interface Props {
|
|
| 73 |
|
| 74 |
type EditorStates = Map<string, EditorState>;
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
const editableStateEffect = StateEffect.define<boolean>();
|
| 77 |
|
| 78 |
const editableStateField = StateField.define<boolean>({
|
|
@@ -261,6 +286,17 @@ function newEditorState(
|
|
| 261 |
|
| 262 |
onScrollRef.current?.({ left: view.scrollDOM.scrollLeft, top: view.scrollDOM.scrollTop });
|
| 263 |
}, debounceScroll),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
}),
|
| 265 |
getTheme(theme, settings),
|
| 266 |
history(),
|
|
@@ -283,6 +319,20 @@ function newEditorState(
|
|
| 283 |
autocompletion({
|
| 284 |
closeOnBlur: false,
|
| 285 |
}),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
closeBrackets(),
|
| 287 |
lineNumbers(),
|
| 288 |
scrollPastEnd(),
|
|
@@ -291,9 +341,9 @@ function newEditorState(
|
|
| 291 |
bracketMatching(),
|
| 292 |
EditorState.tabSize.of(settings?.tabSize ?? 2),
|
| 293 |
indentOnInput(),
|
|
|
|
| 294 |
editableStateField,
|
| 295 |
EditorState.readOnly.from(editableStateField, (editable) => !editable),
|
| 296 |
-
EditorView.editable.from(editableStateField, (editable) => editable),
|
| 297 |
highlightActiveLineGutter(),
|
| 298 |
highlightActiveLine(),
|
| 299 |
foldGutter({
|
|
@@ -383,3 +433,29 @@ function setEditorDocument(
|
|
| 383 |
});
|
| 384 |
});
|
| 385 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
import { searchKeymap } from '@codemirror/search';
|
| 5 |
import { Compartment, EditorSelection, EditorState, StateEffect, StateField, type Extension } from '@codemirror/state';
|
| 6 |
import {
|
|
|
|
| 7 |
drawSelection,
|
| 8 |
dropCursor,
|
| 9 |
+
EditorView,
|
| 10 |
highlightActiveLine,
|
| 11 |
highlightActiveLineGutter,
|
| 12 |
keymap,
|
| 13 |
lineNumbers,
|
| 14 |
scrollPastEnd,
|
| 15 |
+
showTooltip,
|
| 16 |
+
tooltips,
|
| 17 |
+
type Tooltip,
|
| 18 |
} from '@codemirror/view';
|
| 19 |
import { memo, useEffect, useRef, useState, type MutableRefObject } from 'react';
|
| 20 |
import type { Theme } from '~/types/theme';
|
|
|
|
| 76 |
|
| 77 |
type EditorStates = Map<string, EditorState>;
|
| 78 |
|
| 79 |
+
const readOnlyTooltipStateEffect = StateEffect.define<boolean>();
|
| 80 |
+
|
| 81 |
+
const editableTooltipField = StateField.define<readonly Tooltip[]>({
|
| 82 |
+
create: () => [],
|
| 83 |
+
update(_tooltips, transaction) {
|
| 84 |
+
if (!transaction.state.readOnly) {
|
| 85 |
+
return [];
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
for (const effect of transaction.effects) {
|
| 89 |
+
if (effect.is(readOnlyTooltipStateEffect) && effect.value) {
|
| 90 |
+
return getReadOnlyTooltip(transaction.state);
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
return [];
|
| 95 |
+
},
|
| 96 |
+
provide: (field) => {
|
| 97 |
+
return showTooltip.computeN([field], (state) => state.field(field));
|
| 98 |
+
},
|
| 99 |
+
});
|
| 100 |
+
|
| 101 |
const editableStateEffect = StateEffect.define<boolean>();
|
| 102 |
|
| 103 |
const editableStateField = StateField.define<boolean>({
|
|
|
|
| 286 |
|
| 287 |
onScrollRef.current?.({ left: view.scrollDOM.scrollLeft, top: view.scrollDOM.scrollTop });
|
| 288 |
}, debounceScroll),
|
| 289 |
+
keydown: (event, view) => {
|
| 290 |
+
if (view.state.readOnly) {
|
| 291 |
+
view.dispatch({
|
| 292 |
+
effects: [readOnlyTooltipStateEffect.of(event.key !== 'Escape')],
|
| 293 |
+
});
|
| 294 |
+
|
| 295 |
+
return true;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
return false;
|
| 299 |
+
},
|
| 300 |
}),
|
| 301 |
getTheme(theme, settings),
|
| 302 |
history(),
|
|
|
|
| 319 |
autocompletion({
|
| 320 |
closeOnBlur: false,
|
| 321 |
}),
|
| 322 |
+
tooltips({
|
| 323 |
+
position: 'absolute',
|
| 324 |
+
parent: document.body,
|
| 325 |
+
tooltipSpace: (view) => {
|
| 326 |
+
const rect = view.dom.getBoundingClientRect();
|
| 327 |
+
|
| 328 |
+
return {
|
| 329 |
+
top: rect.top - 50,
|
| 330 |
+
left: rect.left,
|
| 331 |
+
bottom: rect.bottom,
|
| 332 |
+
right: rect.right + 10,
|
| 333 |
+
};
|
| 334 |
+
},
|
| 335 |
+
}),
|
| 336 |
closeBrackets(),
|
| 337 |
lineNumbers(),
|
| 338 |
scrollPastEnd(),
|
|
|
|
| 341 |
bracketMatching(),
|
| 342 |
EditorState.tabSize.of(settings?.tabSize ?? 2),
|
| 343 |
indentOnInput(),
|
| 344 |
+
editableTooltipField,
|
| 345 |
editableStateField,
|
| 346 |
EditorState.readOnly.from(editableStateField, (editable) => !editable),
|
|
|
|
| 347 |
highlightActiveLineGutter(),
|
| 348 |
highlightActiveLine(),
|
| 349 |
foldGutter({
|
|
|
|
| 433 |
});
|
| 434 |
});
|
| 435 |
}
|
| 436 |
+
|
| 437 |
+
function getReadOnlyTooltip(state: EditorState) {
|
| 438 |
+
if (!state.readOnly) {
|
| 439 |
+
return [];
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
return state.selection.ranges
|
| 443 |
+
.filter((range) => {
|
| 444 |
+
return range.empty;
|
| 445 |
+
})
|
| 446 |
+
.map((range) => {
|
| 447 |
+
return {
|
| 448 |
+
pos: range.head,
|
| 449 |
+
above: true,
|
| 450 |
+
strictSide: true,
|
| 451 |
+
arrow: true,
|
| 452 |
+
create: () => {
|
| 453 |
+
const divElement = document.createElement('div');
|
| 454 |
+
divElement.className = 'cm-readonly-tooltip';
|
| 455 |
+
divElement.textContent = 'Cannot edit file while AI response is being generated';
|
| 456 |
+
|
| 457 |
+
return { dom: divElement };
|
| 458 |
+
},
|
| 459 |
+
};
|
| 460 |
+
});
|
| 461 |
+
}
|
packages/bolt/app/components/editor/codemirror/cm-theme.ts
CHANGED
|
@@ -168,6 +168,18 @@ function getEditorTheme(settings: EditorSettings) {
|
|
| 168 |
'.cm-searchMatch': {
|
| 169 |
backgroundColor: 'var(--cm-searchMatch-backgroundColor)',
|
| 170 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
});
|
| 172 |
}
|
| 173 |
|
|
|
|
| 168 |
'.cm-searchMatch': {
|
| 169 |
backgroundColor: 'var(--cm-searchMatch-backgroundColor)',
|
| 170 |
},
|
| 171 |
+
'.cm-tooltip.cm-readonly-tooltip': {
|
| 172 |
+
padding: '4px',
|
| 173 |
+
whiteSpace: 'nowrap',
|
| 174 |
+
backgroundColor: 'var(--bolt-elements-bg-depth-2)',
|
| 175 |
+
borderColor: 'var(--bolt-elements-borderColorActive)',
|
| 176 |
+
'& .cm-tooltip-arrow:before': {
|
| 177 |
+
borderTopColor: 'var(--bolt-elements-borderColorActive)',
|
| 178 |
+
},
|
| 179 |
+
'& .cm-tooltip-arrow:after': {
|
| 180 |
+
borderTopColor: 'transparent',
|
| 181 |
+
},
|
| 182 |
+
},
|
| 183 |
});
|
| 184 |
}
|
| 185 |
|
packages/bolt/app/components/workbench/EditorPanel.tsx
CHANGED
|
@@ -144,7 +144,7 @@ export const EditorPanel = memo(
|
|
| 144 |
{activeFile && (
|
| 145 |
<div className="flex items-center flex-1 text-sm">
|
| 146 |
<div className="i-ph:file-duotone mr-2" />
|
| 147 |
-
{activeFile}
|
| 148 |
{activeFileUnsaved && (
|
| 149 |
<div className="flex gap-1 ml-auto -mr-1.5">
|
| 150 |
<PanelHeaderButton onClick={onFileSave}>
|
|
|
|
| 144 |
{activeFile && (
|
| 145 |
<div className="flex items-center flex-1 text-sm">
|
| 146 |
<div className="i-ph:file-duotone mr-2" />
|
| 147 |
+
{activeFile}
|
| 148 |
{activeFileUnsaved && (
|
| 149 |
<div className="flex gap-1 ml-auto -mr-1.5">
|
| 150 |
<PanelHeaderButton onClick={onFileSave}>
|
packages/bolt/app/components/workbench/Workbench.client.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import { useStore } from '@nanostores/react';
|
| 2 |
import { motion, type HTMLMotionProps, type Variants } from 'framer-motion';
|
| 3 |
import { computed } from 'nanostores';
|
| 4 |
-
import { memo, useCallback, useEffect
|
| 5 |
import { toast } from 'react-toastify';
|
| 6 |
import {
|
| 7 |
type OnChangeCallback as OnEditorChange,
|
|
@@ -10,7 +10,7 @@ import {
|
|
| 10 |
import { IconButton } from '~/components/ui/IconButton';
|
| 11 |
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
|
| 12 |
import { Slider, type SliderOptions } from '~/components/ui/Slider';
|
| 13 |
-
import { workbenchStore } from '~/lib/stores/workbench';
|
| 14 |
import { cubicEasingFn } from '~/utils/easings';
|
| 15 |
import { renderLogger } from '~/utils/logger';
|
| 16 |
import { EditorPanel } from './EditorPanel';
|
|
@@ -21,11 +21,9 @@ interface WorkspaceProps {
|
|
| 21 |
isStreaming?: boolean;
|
| 22 |
}
|
| 23 |
|
| 24 |
-
type ViewType = 'code' | 'preview';
|
| 25 |
-
|
| 26 |
const viewTransition = { ease: cubicEasingFn };
|
| 27 |
|
| 28 |
-
const sliderOptions: SliderOptions<
|
| 29 |
left: {
|
| 30 |
value: 'code',
|
| 31 |
text: 'Code',
|
|
@@ -62,8 +60,11 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
|
|
| 62 |
const currentDocument = useStore(workbenchStore.currentDocument);
|
| 63 |
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
|
| 64 |
const files = useStore(workbenchStore.files);
|
|
|
|
| 65 |
|
| 66 |
-
const
|
|
|
|
|
|
|
| 67 |
|
| 68 |
useEffect(() => {
|
| 69 |
if (hasPreview) {
|
|
|
|
| 1 |
import { useStore } from '@nanostores/react';
|
| 2 |
import { motion, type HTMLMotionProps, type Variants } from 'framer-motion';
|
| 3 |
import { computed } from 'nanostores';
|
| 4 |
+
import { memo, useCallback, useEffect } from 'react';
|
| 5 |
import { toast } from 'react-toastify';
|
| 6 |
import {
|
| 7 |
type OnChangeCallback as OnEditorChange,
|
|
|
|
| 10 |
import { IconButton } from '~/components/ui/IconButton';
|
| 11 |
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
|
| 12 |
import { Slider, type SliderOptions } from '~/components/ui/Slider';
|
| 13 |
+
import { workbenchStore, type WorkbenchViewType } from '~/lib/stores/workbench';
|
| 14 |
import { cubicEasingFn } from '~/utils/easings';
|
| 15 |
import { renderLogger } from '~/utils/logger';
|
| 16 |
import { EditorPanel } from './EditorPanel';
|
|
|
|
| 21 |
isStreaming?: boolean;
|
| 22 |
}
|
| 23 |
|
|
|
|
|
|
|
| 24 |
const viewTransition = { ease: cubicEasingFn };
|
| 25 |
|
| 26 |
+
const sliderOptions: SliderOptions<WorkbenchViewType> = {
|
| 27 |
left: {
|
| 28 |
value: 'code',
|
| 29 |
text: 'Code',
|
|
|
|
| 60 |
const currentDocument = useStore(workbenchStore.currentDocument);
|
| 61 |
const unsavedFiles = useStore(workbenchStore.unsavedFiles);
|
| 62 |
const files = useStore(workbenchStore.files);
|
| 63 |
+
const selectedView = useStore(workbenchStore.currentView);
|
| 64 |
|
| 65 |
+
const setSelectedView = (view: WorkbenchViewType) => {
|
| 66 |
+
workbenchStore.currentView.set(view);
|
| 67 |
+
};
|
| 68 |
|
| 69 |
useEffect(() => {
|
| 70 |
if (hasPreview) {
|
packages/bolt/app/lib/stores/workbench.ts
CHANGED
|
@@ -21,6 +21,8 @@ export type ArtifactUpdateState = Pick<ArtifactState, 'title' | 'closed'>;
|
|
| 21 |
|
| 22 |
type Artifacts = MapStore<Record<string, ArtifactState>>;
|
| 23 |
|
|
|
|
|
|
|
| 24 |
export class WorkbenchStore {
|
| 25 |
#previewsStore = new PreviewsStore(webcontainer);
|
| 26 |
#filesStore = new FilesStore(webcontainer);
|
|
@@ -30,6 +32,7 @@ export class WorkbenchStore {
|
|
| 30 |
artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({});
|
| 31 |
|
| 32 |
showWorkbench: WritableAtom<boolean> = import.meta.hot?.data.showWorkbench ?? atom(false);
|
|
|
|
| 33 |
unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>());
|
| 34 |
modifiedFiles = new Set<string>();
|
| 35 |
artifactIdList: string[] = [];
|
|
@@ -39,6 +42,7 @@ export class WorkbenchStore {
|
|
| 39 |
import.meta.hot.data.artifacts = this.artifacts;
|
| 40 |
import.meta.hot.data.unsavedFiles = this.unsavedFiles;
|
| 41 |
import.meta.hot.data.showWorkbench = this.showWorkbench;
|
|
|
|
| 42 |
}
|
| 43 |
}
|
| 44 |
|
|
|
|
| 21 |
|
| 22 |
type Artifacts = MapStore<Record<string, ArtifactState>>;
|
| 23 |
|
| 24 |
+
export type WorkbenchViewType = 'code' | 'preview';
|
| 25 |
+
|
| 26 |
export class WorkbenchStore {
|
| 27 |
#previewsStore = new PreviewsStore(webcontainer);
|
| 28 |
#filesStore = new FilesStore(webcontainer);
|
|
|
|
| 32 |
artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({});
|
| 33 |
|
| 34 |
showWorkbench: WritableAtom<boolean> = import.meta.hot?.data.showWorkbench ?? atom(false);
|
| 35 |
+
currentView: WritableAtom<WorkbenchViewType> = import.meta.hot?.data.currentView ?? atom('code');
|
| 36 |
unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>());
|
| 37 |
modifiedFiles = new Set<string>();
|
| 38 |
artifactIdList: string[] = [];
|
|
|
|
| 42 |
import.meta.hot.data.artifacts = this.artifacts;
|
| 43 |
import.meta.hot.data.unsavedFiles = this.unsavedFiles;
|
| 44 |
import.meta.hot.data.showWorkbench = this.showWorkbench;
|
| 45 |
+
import.meta.hot.data.currentView = this.currentView;
|
| 46 |
}
|
| 47 |
}
|
| 48 |
|
packages/bolt/app/lib/webcontainer/auth.client.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* This client-only module that contains everything related to auth and is used
|
| 3 |
+
* to avoid importing `@webcontainer/api` in the server bundle.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
export { auth, type AuthAPI } from '@webcontainer/api';
|
packages/bolt/app/routes/login.tsx
CHANGED
|
@@ -6,12 +6,12 @@ import {
|
|
| 6 |
type LoaderFunctionArgs,
|
| 7 |
} from '@remix-run/cloudflare';
|
| 8 |
import { useFetcher, useLoaderData } from '@remix-run/react';
|
| 9 |
-
import { auth, type AuthAPI } from '@webcontainer/api';
|
| 10 |
import { useEffect, useState } from 'react';
|
| 11 |
import { LoadingDots } from '~/components/ui/LoadingDots';
|
| 12 |
import { createUserSession, isAuthenticated, validateAccessToken } from '~/lib/.server/sessions';
|
| 13 |
import { CLIENT_ID, CLIENT_ORIGIN } from '~/lib/constants';
|
| 14 |
import { request as doRequest } from '~/lib/fetch';
|
|
|
|
| 15 |
import { logger } from '~/utils/logger';
|
| 16 |
|
| 17 |
export async function loader({ request, context }: LoaderFunctionArgs) {
|
|
|
|
| 6 |
type LoaderFunctionArgs,
|
| 7 |
} from '@remix-run/cloudflare';
|
| 8 |
import { useFetcher, useLoaderData } from '@remix-run/react';
|
|
|
|
| 9 |
import { useEffect, useState } from 'react';
|
| 10 |
import { LoadingDots } from '~/components/ui/LoadingDots';
|
| 11 |
import { createUserSession, isAuthenticated, validateAccessToken } from '~/lib/.server/sessions';
|
| 12 |
import { CLIENT_ID, CLIENT_ORIGIN } from '~/lib/constants';
|
| 13 |
import { request as doRequest } from '~/lib/fetch';
|
| 14 |
+
import { auth, type AuthAPI } from '~/lib/webcontainer/auth.client';
|
| 15 |
import { logger } from '~/utils/logger';
|
| 16 |
|
| 17 |
export async function loader({ request, context }: LoaderFunctionArgs) {
|