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