Skip to content

Commit 0a4592c

Browse files
author
ci-bot
committed
feat(editor): Implement dynamic type loading for TS/JS autocompletion
1 parent 8d6e375 commit 0a4592c

File tree

7 files changed

+253
-2
lines changed

7 files changed

+253
-2
lines changed

apps/remix-ide/src/app/editor/editor.js

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { Plugin } from '@remixproject/engine'
66
import * as packageJson from '../../../../../package.json'
77
import { PluginViewWrapper } from '@remix-ui/helper'
88

9+
import { fetchAndLoadTypes } from './type-fetcher'
10+
911
const EventManager = require('../../lib/events')
1012

1113
const profile = {
@@ -71,12 +73,19 @@ export default class Editor extends Plugin {
7173
this.api = {}
7274
this.dispatch = null
7375
this.ref = null
76+
77+
this.monaco = null
78+
this.typeLoaderDebounce = null
7479
}
7580

7681
setDispatch (dispatch) {
7782
this.dispatch = dispatch
7883
}
7984

85+
setMonaco (monaco) {
86+
this.monaco = monaco
87+
}
88+
8089
updateComponent(state) {
8190
return <EditorUI
8291
editorAPI={state.api}
@@ -86,6 +95,7 @@ export default class Editor extends Plugin {
8695
events={state.events}
8796
plugin={state.plugin}
8897
isDiff={state.isDiff}
98+
setMonaco={(monaco) => this.setMonaco(monaco)}
8999
/>
90100
}
91101

@@ -128,6 +138,17 @@ export default class Editor extends Plugin {
128138

129139
async onActivation () {
130140
this.activated = true
141+
this.on('editor', 'editorMounted', () => {
142+
if (!this.monaco) return
143+
const tsDefaults = this.monaco.languages.typescript.typescriptDefaults
144+
145+
tsDefaults.setCompilerOptions({
146+
moduleResolution: this.monaco.languages.typescript.ModuleResolutionKind.NodeJs,
147+
typeRoots: ["file:///node_modules/@types", "file:///node_modules"],
148+
target: this.monaco.languages.typescript.ScriptTarget.ES2020,
149+
allowNonTsExtensions: true,
150+
})
151+
})
131152
this.on('sidePanel', 'focusChanged', (name) => {
132153
this.keepDecorationsFor(name, 'sourceAnnotationsPerFile')
133154
this.keepDecorationsFor(name, 'markerPerFile')
@@ -158,6 +179,30 @@ export default class Editor extends Plugin {
158179

159180
async _onChange (file) {
160181
this.triggerEvent('didChangeFile', [file])
182+
183+
if (this.monaco && (file.endsWith('.ts') || file.endsWith('.js'))) {
184+
clearTimeout(this.typeLoaderDebounce)
185+
this.typeLoaderDebounce = setTimeout(async () => {
186+
if (!this.monaco) return
187+
const model = this.monaco.editor.getModel(this.monaco.Uri.parse(file))
188+
if (!model) return
189+
const code = model.getValue()
190+
191+
try {
192+
const npmImports = [...code.matchAll(/from\s+['"]((?![./]).+)['"]/g)].map(match => match[1])
193+
const uniquePackages = [...new Set(npmImports)]
194+
195+
if (uniquePackages.length > 0) {
196+
await Promise.all(uniquePackages.map(pkg => fetchAndLoadTypes(pkg, this.monaco)))
197+
const tsDefaults = this.monaco.languages.typescript.typescriptDefaults
198+
tsDefaults.setCompilerOptions(tsDefaults.getCompilerOptions())
199+
}
200+
} catch (error) {
201+
console.error('[Type Loader] Error during type loading process:', error)
202+
}
203+
}, 1500)
204+
}
205+
161206
const currentFile = await this.call('fileManager', 'file')
162207
if (!currentFile) {
163208
return
@@ -232,7 +277,7 @@ export default class Editor extends Plugin {
232277
this.emit('addModel', contentDep, 'typescript', pathDep, this.readOnlySessions[path])
233278
}
234279
} else {
235-
console.log("The file ", pathDep, " can't be found.")
280+
// console.log("The file ", pathDep, " can't be found.")
236281
}
237282
} catch (e) {
238283
console.log(e)
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { Monaco } from '@monaco-editor/react'
2+
3+
const loadedFiles = new Set<string>()
4+
5+
function resolvePath(baseFilePath: string, relativePath: string): string {
6+
const newUrl = new URL(relativePath, baseFilePath)
7+
return newUrl.href
8+
}
9+
10+
export async function fetchAndLoadTypes(packageName: string, monaco: Monaco) {
11+
const initialPackageJsonPath = `file:///node_modules/${packageName}/package.json`
12+
if (loadedFiles.has(initialPackageJsonPath)) return
13+
14+
try {
15+
const response = await fetch(`https://cdn.jsdelivr.net/npm/${packageName}/package.json`)
16+
if (!response.ok) {
17+
if (!packageName.startsWith('@types/')) {
18+
console.warn(`[Type Fetcher] Failed to get package.json for "${packageName}". Trying @types...`)
19+
return fetchAndLoadTypes(`@types/${packageName}`, monaco)
20+
}
21+
console.error(`[Type Fetcher] Failed to get package.json for "${packageName}".`)
22+
return
23+
}
24+
25+
const packageJson = await response.json()
26+
const filesToProcess: string[] = []
27+
28+
addFileToMonaco(initialPackageJsonPath, JSON.stringify(packageJson), monaco)
29+
30+
const mainTypeFile = packageJson.types || packageJson.typings || 'index.d.ts'
31+
const mainTypeFilePath = resolvePath(initialPackageJsonPath, mainTypeFile)
32+
filesToProcess.push(mainTypeFilePath)
33+
34+
if (packageJson.dependencies) {
35+
for (const depName of Object.keys(packageJson.dependencies)) {
36+
fetchAndLoadTypes(depName, monaco)
37+
}
38+
}
39+
40+
while (filesToProcess.length > 0) {
41+
const currentFilePath = filesToProcess.shift()
42+
if (!currentFilePath || loadedFiles.has(currentFilePath)) continue
43+
44+
try {
45+
const cdnUrl = currentFilePath.replace('file:///node_modules/', 'https://cdn.jsdelivr.net/npm/')
46+
const fileResponse = await fetch(cdnUrl)
47+
48+
if (fileResponse.ok) {
49+
const content = await fileResponse.text()
50+
addFileToMonaco(currentFilePath, content, monaco)
51+
52+
const relativeImports = [...content.matchAll(/(from\s+['"](\.\.?\/.*?)['"])|(import\s+['"](\.\.?\/.*?)['"])/g)]
53+
for (const match of relativeImports) {
54+
const relativePath = match[2] || match[4]
55+
if (relativePath) {
56+
const newPath = resolvePath(currentFilePath, relativePath)
57+
const finalPath = newPath.endsWith('.d.ts') ? newPath : `${newPath}.d.ts`
58+
if (!loadedFiles.has(finalPath)) {
59+
filesToProcess.push(finalPath)
60+
}
61+
}
62+
}
63+
} else {
64+
console.warn(`[Type Fetcher] 404 - Could not fetch ${cdnUrl}`)
65+
}
66+
} catch (e) {
67+
console.error(`[Type Fetcher] Error fetching or processing ${currentFilePath}`, e)
68+
loadedFiles.add(currentFilePath)
69+
}
70+
}
71+
} catch (error) {
72+
console.error(`[Type Fetcher] Critical error processing ${packageName}:`, error)
73+
}
74+
}
75+
76+
function addFileToMonaco(filePath: string, content: string, monaco: Monaco) {
77+
if (loadedFiles.has(filePath)) return
78+
79+
monaco.languages.typescript.typescriptDefaults.addExtraLib(content, filePath)
80+
loadedFiles.add(filePath)
81+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import * as acorn from 'acorn'
2+
3+
export function parseImports(code: string): string[] {
4+
const packages: string[] = []
5+
6+
try {
7+
const ast = acorn.parse(code, { sourceType: 'module', ecmaVersion: 'latest' })
8+
9+
for (const node of ast.body) {
10+
if (node.type === 'ImportDeclaration') {
11+
if (node.source && typeof node.source.value === 'string') {
12+
packages.push(node.source.value)
13+
}
14+
}
15+
}
16+
} catch (error) {
17+
console.error('[Type Parser] Code parsing error:', error.message)
18+
}
19+
20+
return [...new Set(packages)]
21+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { monacoTypes } from '@remix-ui/editor'
2+
3+
interface TsCompletionInfo {
4+
entries: {
5+
name: string
6+
kind: string
7+
}[]
8+
}
9+
10+
export class RemixTSCompletionProvider implements monacoTypes.languages.CompletionItemProvider {
11+
monaco: any
12+
13+
constructor(monaco: any) {
14+
this.monaco = monaco
15+
}
16+
17+
triggerCharacters = ['.', '"', "'", '/', '@']
18+
19+
async provideCompletionItems(model: monacoTypes.editor.ITextModel, position: monacoTypes.Position, context: monacoTypes.languages.CompletionContext): Promise<monacoTypes.languages.CompletionList | undefined> {
20+
const word = model.getWordUntilPosition(position)
21+
const range = {
22+
startLineNumber: position.lineNumber,
23+
endLineNumber: position.lineNumber,
24+
startColumn: word.startColumn,
25+
endColumn: word.endColumn
26+
}
27+
28+
try {
29+
const worker = await this.monaco.languages.typescript.getTypeScriptWorker()
30+
const client = await worker(model.uri)
31+
const completions: TsCompletionInfo = await client.getCompletionsAtPosition(
32+
model.uri.toString(),
33+
model.getOffsetAt(position)
34+
)
35+
36+
if (!completions || !completions.entries) {
37+
return { suggestions: []}
38+
}
39+
40+
const suggestions = completions.entries.map(entry => {
41+
return {
42+
label: entry.name,
43+
kind: this.mapTsCompletionKindToMonaco(entry.kind),
44+
insertText: entry.name,
45+
range: range
46+
}
47+
})
48+
49+
return { suggestions }
50+
} catch (error) {
51+
console.error('[TSCompletionProvider] Error fetching completions:', error)
52+
return { suggestions: []}
53+
}
54+
}
55+
56+
private mapTsCompletionKindToMonaco(kind: string): monacoTypes.languages.CompletionItemKind {
57+
const { CompletionItemKind } = this.monaco.languages
58+
switch (kind) {
59+
case 'method':
60+
case 'memberFunction':
61+
return CompletionItemKind.Method
62+
case 'function':
63+
return CompletionItemKind.Function
64+
case 'property':
65+
case 'memberVariable':
66+
return CompletionItemKind.Property
67+
case 'class':
68+
return CompletionItemKind.Class
69+
case 'interface':
70+
return CompletionItemKind.Interface
71+
case 'keyword':
72+
return CompletionItemKind.Keyword
73+
case 'variable':
74+
return CompletionItemKind.Variable
75+
case 'constructor':
76+
return CompletionItemKind.Constructor
77+
case 'enum':
78+
return CompletionItemKind.Enum
79+
case 'module':
80+
return CompletionItemKind.Module
81+
default:
82+
return CompletionItemKind.Text
83+
}
84+
}
85+
}

libs/remix-ui/editor/src/lib/remix-ui-editor.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { noirLanguageConfig, noirTokensProvider } from './syntaxes/noir'
2828
import { IPosition, IRange } from 'monaco-editor'
2929
import { GenerationParams } from '@remix/remix-ai-core';
3030
import { RemixInLineCompletionProvider } from './providers/inlineCompletionProvider'
31+
import { RemixTSCompletionProvider } from './providers/tsCompletionProvider'
3132
const _paq = (window._paq = window._paq || [])
3233

3334
// Key for localStorage
@@ -154,6 +155,7 @@ export interface EditorUIProps {
154155
}
155156
plugin: PluginType
156157
editorAPI: EditorAPIType
158+
setMonaco: (monaco: Monaco) => void
157159
}
158160
const contextMenuEvent = new EventManager()
159161
export const EditorUI = (props: EditorUIProps) => {
@@ -1152,6 +1154,7 @@ export const EditorUI = (props: EditorUIProps) => {
11521154

11531155
function handleEditorWillMount(monaco) {
11541156
monacoRef.current = monaco
1157+
props.setMonaco(monaco)
11551158
// Register a new language
11561159
monacoRef.current.languages.register({ id: 'remix-solidity' })
11571160
monacoRef.current.languages.register({ id: 'remix-cairo' })
@@ -1164,9 +1167,11 @@ export const EditorUI = (props: EditorUIProps) => {
11641167
// Allow JSON schema requests
11651168
monacoRef.current.languages.json.jsonDefaults.setDiagnosticsOptions({ enableSchemaRequest: true })
11661169

1170+
monacoRef.current.languages.registerCompletionItemProvider('typescript', new RemixTSCompletionProvider(monaco))
1171+
monacoRef.current.languages.registerCompletionItemProvider('javascript', new RemixTSCompletionProvider(monaco))
1172+
11671173
// hide the module resolution error. We have to remove this when we know how to properly resolve imports.
11681174
monacoRef.current.languages.typescript.typescriptDefaults.setDiagnosticsOptions({ diagnosticCodesToIgnore: [2792]})
1169-
11701175
// Register a tokens provider for the language
11711176
monacoRef.current.languages.setMonarchTokensProvider('remix-solidity', solidityTokensProvider as any)
11721177
monacoRef.current.languages.setLanguageConfiguration('remix-solidity', solidityLanguageConfig as any)

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,9 @@
127127
"@reown/appkit": "^1.7.4",
128128
"@reown/appkit-adapter-ethers": "^1.7.4",
129129
"@ricarso/react-image-magnifiers": "^1.9.0",
130+
"@types/acorn": "^6.0.4",
130131
"@types/nightwatch": "^2.3.1",
132+
"acorn": "^8.15.0",
131133
"ansi-gray": "^0.1.1",
132134
"assert": "^2.1.0",
133135
"async": "^2.6.2",

yarn.lock

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7248,6 +7248,13 @@
72487248
resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.3.tgz#472eaab5f15c1ffdd7f8628bd4c4f753995ec79e"
72497249
integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==
72507250

7251+
"@types/acorn@^6.0.4":
7252+
version "6.0.4"
7253+
resolved "https://registry.yarnpkg.com/@types/acorn/-/acorn-6.0.4.tgz#b1a652a373d0cace52dace608fced14f58e9c4a9"
7254+
integrity sha512-DafqcBAjbOOmgqIx3EF9EAdBKAKgspv00aQVIW3fVQ0TXo5ZPBeSRey1SboVAUzjw8Ucm7cd1gtTSlosYoEQLA==
7255+
dependencies:
7256+
acorn "*"
7257+
72517258
"@types/aria-query@^4.2.0":
72527259
version "4.2.2"
72537260
resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-4.2.2.tgz#ed4e0ad92306a704f9fb132a0cfcf77486dbe2bc"
@@ -8718,6 +8725,11 @@ acorn-walk@^8.0.0, acorn-walk@^8.1.1:
87188725
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1"
87198726
integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==
87208727

8728+
acorn@*, acorn@^8.15.0:
8729+
version "8.15.0"
8730+
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816"
8731+
integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==
8732+
87218733
"acorn@>= 2.5.2 <= 5.7.5":
87228734
version "5.7.4"
87238735
resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.4.tgz#3e8d8a9947d0599a1796d10225d7432f4a4acf5e"

0 commit comments

Comments
 (0)