Dominic Elm commited on
Commit
6e99e4c
·
unverified ·
1 Parent(s): 7465cab

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

Browse files
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} {isStreaming && <span className="text-xs ml-1 font-semibold">(read-only)</span>}
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, useState } from 'react';
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<ViewType> = {
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 [selectedView, setSelectedView] = useState<ViewType>(hasPreview ? 'preview' : 'code');
 
 
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) {