Skip to content

Commit 796d0c9

Browse files
Restore intellisense to the Collaborative Editor (#4387)
* fix code assist zindex in legacy editor * restore code assist logic * refactor loading spinner * refactor * tidy * tidy options * Fix Monaco mock to include languages.typescript API CollaborativeMonaco now calls monaco.languages.typescript.javascriptDefaults.setCompilerOptions() for intellisense. Both the global Monaco mock and the local mock in the diff test need to provide this API to prevent "Cannot read properties of undefined (reading 'typescript')" errors. * Mock loadDTS to prevent unhandled rejection errors in tests The loadDTS function makes network calls that can fail in test environments, causing unhandled rejection errors that make vitest exit with code 1 even when all tests pass. * remove overflow stuff and add changelog --------- Co-authored-by: Elias W. BA <eliaswalyba@gmail.com>
1 parent bf0c5ef commit 796d0c9

8 files changed

Lines changed: 294 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ and this project adheres to
3838

3939
### Fixed
4040

41+
- Fixed code-assist widget
42+
[4386](https://github.com/OpenFn/lightning/issues/4386)
4143
- AI Assistant button now disabled when Apollo not configured, preventing silent
4244
failures [#4354](https://github.com/OpenFn/lightning/issues/4354)
4345
- Version chip missing tooltips

assets/js/collaborative-editor/components/CollaborativeMonaco.tsx

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,12 @@ import { type Monaco, MonacoEditor, setTheme } from '../../monaco';
2020
import { addKeyboardShortcutOverrides } from '../../monaco/keyboard-overrides';
2121
import { useHandleDiffDismissed } from '../contexts/MonacoRefContext';
2222

23+
import createCompletionProvider from '../../editor/magic-completion';
24+
25+
import { LoadingIndicator } from './common/LoadingIndicator';
2326
import { Cursors } from './Cursors';
2427
import { Tooltip } from './Tooltip';
28+
import { loadDTS, type Lib } from '../utils/loadDTS';
2529

2630
export interface MonacoHandle {
2731
showDiff: (originalCode: string, modifiedCode: string) => void;
@@ -33,6 +37,7 @@ interface CollaborativeMonacoProps {
3337
ytext: Y.Text;
3438
awareness: Awareness;
3539
adaptor?: string;
40+
metadata?: object;
3641
disabled?: boolean;
3742
className?: string;
3843
options?: editor.IStandaloneEditorConstructionOptions;
@@ -46,6 +51,7 @@ export const CollaborativeMonaco = forwardRef<
4651
ytext,
4752
awareness,
4853
adaptor = 'common',
54+
metadata,
4955
disabled = false,
5056
className,
5157
options = {},
@@ -57,6 +63,10 @@ export const CollaborativeMonaco = forwardRef<
5763
const bindingRef = useRef<MonacoBinding>();
5864
const [editorReady, setEditorReady] = useState(false);
5965

66+
// Type definitions state
67+
const [lib, setLib] = useState<Lib[]>();
68+
const [loading, setLoading] = useState(false);
69+
6070
// Get callback from context to notify when diff is dismissed
6171
const handleDiffDismissed = useHandleDiffDismissed();
6272

@@ -66,6 +76,9 @@ export const CollaborativeMonaco = forwardRef<
6676
const containerRef = useRef<HTMLDivElement | null>(null);
6777
const diffContainerRef = useRef<HTMLDivElement | null>(null);
6878

79+
// Overflow widgets container ref
80+
const overflowNodeRef = useRef<HTMLDivElement>();
81+
6982
// Base editor options shared between main and diff editors
7083
const baseEditorOptions: editor.IStandaloneEditorConstructionOptions =
7184
useMemo(
@@ -82,6 +95,18 @@ export const CollaborativeMonaco = forwardRef<
8295
insertSpaces: true,
8396
automaticLayout: true,
8497
fixedOverflowWidgets: true,
98+
codeLens: false,
99+
wordBasedSuggestions: 'off',
100+
showFoldingControls: 'always',
101+
suggest: {
102+
showModules: true,
103+
showKeywords: false,
104+
showFiles: false,
105+
showClasses: false,
106+
showInterfaces: false,
107+
showConstructors: false,
108+
showDeprecated: false,
109+
},
85110
}),
86111
[]
87112
);
@@ -99,6 +124,12 @@ export const CollaborativeMonaco = forwardRef<
99124

100125
addKeyboardShortcutOverrides(editor, monaco);
101126

127+
// Configure TypeScript compiler options
128+
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
129+
allowNonTsExtensions: true,
130+
noLib: true,
131+
});
132+
102133
// Don't create binding here - let the useEffect handle it
103134
// This ensures binding is created/updated whenever ytext changes
104135
},
@@ -210,6 +241,55 @@ export const CollaborativeMonaco = forwardRef<
210241
};
211242
}, []);
212243

244+
// Load type definitions when adaptor changes
245+
useEffect(() => {
246+
if (adaptor) {
247+
setLoading(true);
248+
setLib([]); // instantly clear intelligence
249+
loadDTS(adaptor)
250+
.then(l => {
251+
setLib(l);
252+
})
253+
.finally(() => {
254+
setLoading(false);
255+
});
256+
}
257+
}, [adaptor]);
258+
259+
// Set extra libs on Monaco when lib changes
260+
useEffect(() => {
261+
if (monacoRef.current && lib) {
262+
monacoRef.current.languages.typescript.javascriptDefaults.setExtraLibs(
263+
lib
264+
);
265+
}
266+
}, [lib]);
267+
268+
// Register metadata completion provider
269+
useEffect(() => {
270+
if (monacoRef.current && metadata) {
271+
const provider =
272+
monacoRef.current.languages.registerCompletionItemProvider(
273+
'javascript',
274+
createCompletionProvider(monacoRef.current, metadata)
275+
);
276+
return () => {
277+
provider.dispose();
278+
};
279+
}
280+
}, [metadata]);
281+
282+
// Cleanup overflow node on unmount
283+
useEffect(() => {
284+
return () => {
285+
if (overflowNodeRef.current) {
286+
overflowNodeRef.current.parentNode?.removeChild(
287+
overflowNodeRef.current
288+
);
289+
}
290+
};
291+
}, []);
292+
213293
// showDiff function - creates diff editor overlay
214294
const showDiff = useCallback(
215295
(originalCode: string, modifiedCode: string) => {
@@ -354,6 +434,9 @@ export const CollaborativeMonaco = forwardRef<
354434

355435
return (
356436
<div className={cn('relative', className || 'h-full w-full')}>
437+
<div className="relative z-10 h-0 overflow-visible text-right text-xs text-white">
438+
{loading && <LoadingIndicator text="Loading types" />}
439+
</div>
357440
{/* Standard editor container */}
358441
<div ref={containerRef} className="h-full w-full">
359442
<Cursors />
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Spinner } from './Spinner';
2+
3+
interface LoadingIndicatorProps {
4+
text?: string;
5+
}
6+
7+
/**
8+
* LoadingIndicator - Text with spinner for loading states
9+
*
10+
* Displays a text message alongside a spinning icon.
11+
* Used for Monaco editor type definitions loading.
12+
*
13+
* @example
14+
* <LoadingIndicator text="Loading types" />
15+
* <LoadingIndicator text="Loading workflow" />
16+
*/
17+
export function LoadingIndicator({ text = 'Loading' }: LoadingIndicatorProps) {
18+
return (
19+
<div className="inline-block p-2">
20+
<Spinner size="md" className="inline-block mr-2" />
21+
<span>{text}</span>
22+
</div>
23+
);
24+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { cn } from '#/utils/cn';
2+
3+
interface SpinnerProps {
4+
size?: 'sm' | 'md' | 'lg';
5+
className?: string;
6+
}
7+
8+
/**
9+
* Spinner - Reusable loading spinner using heroicons
10+
*
11+
* Uses the `hero-arrow-path` icon with Tailwind's `animate-spin` class,
12+
* following the established pattern across the collaborative-editor.
13+
*
14+
* @example
15+
* <Spinner size="md" />
16+
* <Spinner size="sm" className="text-primary-500" />
17+
*/
18+
export function Spinner({ size = 'md', className }: SpinnerProps) {
19+
const sizeClasses = {
20+
sm: 'h-3.5 w-3.5',
21+
md: 'h-4 w-4',
22+
lg: 'h-5 w-5',
23+
};
24+
25+
return (
26+
<span
27+
className={cn(
28+
'hero-arrow-path animate-spin',
29+
sizeClasses[size],
30+
className
31+
)}
32+
aria-label="Loading"
33+
/>
34+
);
35+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { fetchDTSListing, fetchFile } from '@openfn/describe-package';
2+
3+
import dts_es5 from '../../editor/lib/es5.min.dts';
4+
5+
export type Lib = {
6+
content: string;
7+
filePath?: string;
8+
};
9+
10+
/**
11+
* Load TypeScript definition files for an adaptor from jsDelivr
12+
*
13+
* Fetches .d.ts files for the specified adaptor and @openfn/language-common,
14+
* then wraps them in appropriate module declarations for Monaco editor.
15+
*
16+
* @param specifier - Fully qualified adaptor name (e.g., "@openfn/language-http@5.0.0")
17+
* @returns Array of lib objects containing TypeScript definitions
18+
*
19+
* @example
20+
* const libs = await loadDTS('@openfn/language-http@5.0.0');
21+
* monaco.languages.typescript.javascriptDefaults.setExtraLibs(libs);
22+
*/
23+
export async function loadDTS(specifier: string): Promise<Lib[]> {
24+
// Work out the module name from the specifier
25+
// (this gets a bit tricky with @openfn/ module names)
26+
const nameParts = specifier.split('@');
27+
nameParts.pop(); // remove the version
28+
const name = nameParts.join('@');
29+
30+
const results: Lib[] = [{ content: dts_es5 }];
31+
32+
// Load common into its own module
33+
// TODO maybe we need other dependencies too? collections?
34+
if (name !== '@openfn/language-common') {
35+
const pkg = await fetchFile(`${specifier}/package.json`);
36+
const commonVersion = (JSON.parse(pkg || '{}') as any).dependencies?.[
37+
'@openfn/language-common'
38+
];
39+
40+
// jsDeliver doesn't appear to support semver range syntax (^1.0.0, 1.x, ~1.1.0)
41+
const commonVersionMatch = commonVersion?.match(/^\d+\.\d+\.\d+/);
42+
if (!commonVersionMatch) {
43+
console.warn(
44+
`@openfn/language-common@${commonVersion} contains semver range syntax.`
45+
);
46+
}
47+
48+
const commonSpecifier = `@openfn/language-common@${commonVersion.replace(
49+
'^',
50+
''
51+
)}`;
52+
for await (const filePath of fetchDTSListing(commonSpecifier)) {
53+
if (!filePath.startsWith('node_modules')) {
54+
// Load every common typedef into the common module
55+
let content = await fetchFile(`${commonSpecifier}${filePath}`);
56+
content = content.replace(/\* +@(.+?)\*\//gs, '*/');
57+
results.push({
58+
content: `declare module '@openfn/language-common' { ${content} }`,
59+
});
60+
}
61+
}
62+
}
63+
64+
// This will store types.d.ts, if we can find it
65+
let types = '';
66+
67+
// This stores string content for our adaptor
68+
let adaptorDefs: string[] = [];
69+
70+
for await (const filePath of fetchDTSListing(specifier)) {
71+
if (!filePath.startsWith('node_modules')) {
72+
let content = await fetchFile(`${specifier}${filePath}`);
73+
// Convert relative paths
74+
content = content
75+
.replace(/from '\.\//g, `from '${name}/`)
76+
.replace(/import '\.\//g, `import '${name}/`);
77+
78+
// Remove js doc annotations
79+
// this regex means: find a * then an @ (with 1+ space in between), then match everything up to a closing comment */
80+
// content = content.replace(/\* +@(.+?)\*\//gs, '*/');
81+
82+
const fileName = filePath.split('/').at(-1)!.replace('.d.ts', '');
83+
84+
// Import the index as the global namespace - but take care to convert all paths to absolute
85+
if (fileName === 'index' || fileName === 'Adaptor') {
86+
// It turns out that "export * as " seems to straight up not work in Monaco
87+
// So this little hack will refactor import statements in a way that works
88+
content = content.replace(
89+
/export \* as (\w+) from '(.+)';/g,
90+
`
91+
92+
import * as $1 from '$2';
93+
export { $1 };`
94+
);
95+
adaptorDefs.push(`declare namespace {
96+
{{$TYPES}}
97+
${content}
98+
`);
99+
} else if (fileName === 'types') {
100+
types = content;
101+
} else {
102+
// Declare every other module as file
103+
adaptorDefs.push(`declare module '${name}/${fileName}' {
104+
{{$TYPES}}
105+
${content}
106+
}`);
107+
}
108+
}
109+
}
110+
111+
// This just ensures that the global type defs appear in every scope
112+
// This is basically a hack to work around https://github.com/OpenFn/lightning/issues/2641
113+
// If we find a types.d.ts, append it to every other file
114+
adaptorDefs = adaptorDefs.map(def => def.replace('{{$TYPES}}', types));
115+
116+
results.push(
117+
...adaptorDefs.map(content => ({
118+
content,
119+
}))
120+
);
121+
122+
return results;
123+
}

assets/js/editor/Editor.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,8 @@ export default function Editor({
323323
// This needs to be at the top level so that tooltips clip over Lightning UIs
324324
const overflowNode = document.createElement('div');
325325
overflowNode.className = 'monaco-editor widgets-overflow-container';
326+
// Total hackage - acceptable given that the editor will be retired soon
327+
overflowNode.style.zIndex = '9999';
326328
document.body.appendChild(overflowNode);
327329

328330
setOptions({

assets/test/__mocks__/monaco-editor.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@ export const editor = {
2121
setModelLanguage: () => {},
2222
};
2323

24+
export const languages = {
25+
typescript: {
26+
javascriptDefaults: {
27+
setCompilerOptions: () => {},
28+
setExtraLibs: () => {},
29+
},
30+
},
31+
registerCompletionItemProvider: () => ({ dispose: () => {} }),
32+
};
33+
2434
export const KeyMod = {
2535
CtrlCmd: 1,
2636
Shift: 2,
@@ -38,6 +48,7 @@ export const KeyCode = {
3848
// Export as default and named exports to match monaco-editor package
3949
export default {
4050
editor,
51+
languages,
4152
KeyMod,
4253
KeyCode,
4354
};

0 commit comments

Comments
 (0)