@@ -39,6 +39,10 @@ interface CodeEditorProps {
3939}
4040
4141type FileResource = URI ;
42+ type JumpTarget = {
43+ line : number ;
44+ column ?: number ;
45+ } ;
4246
4347const decodeEscaped = ( input : string ) => {
4448 let result = "" ;
@@ -174,6 +178,64 @@ const toResourcePath = (resource: FileResource) => {
174178 return resource . path ?? resource . toString ( ) ;
175179} ;
176180
181+ const getLanguageId = ( filePath : string ) => {
182+ const normalized = filePath . toLowerCase ( ) ;
183+ if ( normalized . endsWith ( ".tsx" ) ) return "typescriptreact" ;
184+ if ( normalized . endsWith ( ".ts" ) ) return "typescript" ;
185+ if ( normalized . endsWith ( ".jsx" ) ) return "javascriptreact" ;
186+ if ( normalized . endsWith ( ".js" ) ) return "javascript" ;
187+ return "typescript" ;
188+ } ;
189+
190+ const normalizeLine = ( line ?: number ) => {
191+ if ( line == null || Number . isNaN ( line ) ) return undefined ;
192+ if ( line <= 0 ) return line + 1 ;
193+ return line ;
194+ } ;
195+
196+ const normalizeColumn = ( column ?: number ) => {
197+ if ( column == null || Number . isNaN ( column ) ) return undefined ;
198+ if ( column <= 0 ) return column + 1 ;
199+ return column ;
200+ } ;
201+
202+ const extractSelection = ( options : unknown ) : JumpTarget | null => {
203+ if ( ! options || typeof options !== "object" ) return null ;
204+ const candidate = options as {
205+ selection ?: unknown ;
206+ range ?: unknown ;
207+ } ;
208+ const selection = ( candidate . selection ?? candidate . range ) as
209+ | {
210+ startLineNumber ?: number ;
211+ startColumn ?: number ;
212+ start ?: { line ?: number ; character ?: number } ;
213+ }
214+ | undefined ;
215+ if ( ! selection ) return null ;
216+ if ( typeof selection . startLineNumber === "number" ) {
217+ const line = normalizeLine ( selection . startLineNumber ) ;
218+ if ( line == null ) return null ;
219+ const column = normalizeColumn ( selection . startColumn ) ;
220+ return { line, column } ;
221+ }
222+ if ( selection . start && typeof selection . start . line === "number" ) {
223+ const line = normalizeLine ( selection . start . line + 1 ) ;
224+ if ( line == null ) return null ;
225+ const column = normalizeColumn ( ( selection . start . character ?? 0 ) + 1 ) ;
226+ return { line, column } ;
227+ }
228+ return null ;
229+ } ;
230+
231+ const logNavigationIssue = ( message : string , detail ?: unknown ) => {
232+ if ( detail ) {
233+ console . warn ( `[editor-nav] ${ message } ` , detail ) ;
234+ } else {
235+ console . warn ( `[editor-nav] ${ message } ` ) ;
236+ }
237+ } ;
238+
177239export const CodeEditor = ( { width = 400 , onWidthChange } : CodeEditorProps ) => {
178240 const [ code , setCode ] = useState < string > ( "" ) ;
179241 const [ currentFile , setCurrentFile ] = useState < string > ( "project.tsx" ) ;
@@ -194,9 +256,14 @@ export const CodeEditor = ({ width = 400, onWidthChange }: CodeEditorProps) => {
194256 const resizerRef = useRef < HTMLDivElement > ( null ) ;
195257 const { registerEditor } = useEditor ( ) ;
196258 const loadIdRef = useRef ( 0 ) ;
197- const pendingJumpRef = useRef < number | null > ( null ) ;
259+ const pendingJumpRef = useRef < JumpTarget | null > ( null ) ;
198260 const currentFileRef = useRef < string > ( currentFile ) ;
199- const openFileRef = useRef < ( ( filePath : string , line ?: number ) => Promise < void > ) | null > ( null ) ;
261+ const openFileRef = useRef <
262+ ( ( filePath : string , line ?: number , column ?: number ) => Promise < void > ) | null
263+ > ( null ) ;
264+ const openFileWithContentRef = useRef <
265+ ( ( filePath : string , content : string , line ?: number , column ?: number ) => Promise < void > ) | null
266+ > ( null ) ;
200267
201268 useEffect ( ( ) => {
202269 currentFileRef . current = currentFile ;
@@ -232,18 +299,37 @@ export const CodeEditor = ({ width = 400, onWidthChange }: CodeEditorProps) => {
232299 return { path : filePath , content : extracted ?? text } ;
233300 } , [ ] ) ;
234301
235- const loadFile = useCallback ( async ( filePath : string ) => {
302+ const loadFile = useCallback ( async ( filePath : string , contentOverride ?: string ) => {
236303 const loadId = loadIdRef . current + 1 ;
237304 loadIdRef . current = loadId ;
238305 try {
239306 setIsLoading ( true ) ;
240307 setError ( null ) ;
241- const { content, path } = await readFile ( filePath ) ;
308+ const payload =
309+ contentOverride != null
310+ ? { content : contentOverride , path : filePath }
311+ : await readFile ( filePath ) ;
312+ const { content, path } = payload ;
242313 if ( loadId !== loadIdRef . current ) return ;
243314 const fileUri = toFileUri ( path ) ;
244315 setCode ( content ) ;
245316 setCurrentFile ( fileUri ) ;
246317 setIsDirty ( false ) ;
318+ const editor = editorRef . current ;
319+ const monacoApi = monacoRef . current ;
320+ if ( editor && monacoApi ) {
321+ const uri = monacoApi . Uri . parse ( fileUri ) ;
322+ let model = monacoApi . editor . getModel ( uri ) ;
323+ const languageId = getLanguageId ( fileUri ) ;
324+ if ( ! model ) {
325+ model = monacoApi . editor . createModel ( content , languageId , uri ) ;
326+ } else if ( model . getValue ( ) !== content ) {
327+ model . setValue ( content ) ;
328+ }
329+ if ( editor . getModel ( ) ?. uri . toString ( ) !== uri . toString ( ) ) {
330+ editor . setModel ( model ) ;
331+ }
332+ }
247333 } catch ( err ) {
248334 if ( loadId !== loadIdRef . current ) return ;
249335 console . error ( "Failed to load project file:" , err ) ;
@@ -259,15 +345,17 @@ export const CodeEditor = ({ width = 400, onWidthChange }: CodeEditorProps) => {
259345 const editor = editorRef . current ;
260346 const monaco = monacoRef . current ;
261347 if ( ! editor || ! monaco ) return ;
262- editor . revealLineInCenter ( line , monaco . editor . ScrollType . Smooth ) ;
263- editor . setPosition ( { lineNumber : line , column : 1 } ) ;
348+ const model = editor . getModel ( ) ;
349+ const clampedLine = model ? Math . max ( 1 , Math . min ( line , model . getLineCount ( ) ) ) : line ;
350+ editor . revealLineInCenter ( clampedLine , monaco . editor . ScrollType . Smooth ) ;
351+ editor . setPosition ( { lineNumber : clampedLine , column : 1 } ) ;
264352 editor . focus ( ) ;
265353
266354 const collection = highlightRef . current ;
267355 if ( ! collection ) return ;
268356 collection . set ( [
269357 {
270- range : new monaco . Range ( line , 1 , line , 1 ) ,
358+ range : new monaco . Range ( clampedLine , 1 , clampedLine , 1 ) ,
271359 options : {
272360 isWholeLine : true ,
273361 className : "highlight-line" ,
@@ -284,7 +372,43 @@ export const CodeEditor = ({ width = 400, onWidthChange }: CodeEditorProps) => {
284372 } , 2000 ) ;
285373 } , [ ] ) ;
286374
287- const configureMonaco = useCallback ( ( monaco : typeof import ( "@codingame/monaco-vscode-editor-api" ) ) => {
375+ const revealPosition = useCallback ( ( line : number , column ?: number ) => {
376+ const editor = editorRef . current ;
377+ const monaco = monacoRef . current ;
378+ if ( ! editor || ! monaco ) return ;
379+ const model = editor . getModel ( ) ;
380+ const clampedLine = model ? Math . max ( 1 , Math . min ( line , model . getLineCount ( ) ) ) : line ;
381+ const maxColumn = model ? model . getLineMaxColumn ( clampedLine ) : undefined ;
382+ let targetColumn = column && column > 0 ? column : 1 ;
383+ if ( maxColumn ) {
384+ targetColumn = Math . max ( 1 , Math . min ( targetColumn , maxColumn ) ) ;
385+ }
386+ editor . revealLineInCenter ( clampedLine , monaco . editor . ScrollType . Smooth ) ;
387+ editor . setPosition ( { lineNumber : clampedLine , column : targetColumn } ) ;
388+ editor . focus ( ) ;
389+
390+ const collection = highlightRef . current ;
391+ if ( ! collection ) return ;
392+ collection . set ( [
393+ {
394+ range : new monaco . Range ( clampedLine , 1 , clampedLine , 1 ) ,
395+ options : {
396+ isWholeLine : true ,
397+ className : "highlight-line" ,
398+ glyphMarginClassName : "highlight-glyph" ,
399+ } ,
400+ } ,
401+ ] ) ;
402+ if ( highlightTimerRef . current != null ) {
403+ window . clearTimeout ( highlightTimerRef . current ) ;
404+ }
405+ highlightTimerRef . current = window . setTimeout ( ( ) => {
406+ collection . set ( [ ] ) ;
407+ highlightTimerRef . current = null ;
408+ } , 2000 ) ;
409+ } , [ ] ) ;
410+
411+ const configureMonaco = useCallback ( ( monaco : typeof import ( "@codingame/monaco-vscode-editor-api" ) ) => {
288412 if ( monacoConfiguredRef . current ) return ;
289413 monacoConfiguredRef . current = true ;
290414
@@ -448,13 +572,42 @@ const configureMonaco = useCallback((monaco: typeof import("@codingame/monaco-vs
448572 viewsConfig : {
449573 $type : "EditorService" ,
450574 openEditorFunc : async ( modelRef , options ) => {
451- const target = modelRef ?. object ?. textEditorModel ?. uri ?. toString ( ) ;
575+ const model = modelRef ?. object ;
576+ if ( model && ! model . isResolved ( ) ) {
577+ try {
578+ await model . resolve ( ) ;
579+ } catch ( error ) {
580+ logNavigationIssue ( "Failed to resolve editor model" , error ) ;
581+ }
582+ }
583+ const textModel = model ?. textEditorModel ?? null ;
584+ const target = textModel ?. uri ?. toString ( ) ;
452585 if ( target ) {
453- const selection = ( options as { selection ?: { startLineNumber ?: number } } | undefined )
454- ?. selection ;
455- const line = selection ?. startLineNumber ;
456- await openFileRef . current ?.( target , line ) ;
586+ const selection = extractSelection ( options ) ;
587+ if ( ! selection && options ) {
588+ logNavigationIssue ( "Missing selection for editor navigation" , options ) ;
589+ }
590+ if ( textModel && editorRef . current ) {
591+ editorRef . current . setModel ( textModel ) ;
592+ const content = textModel . getValue ( ) ;
593+ setCode ( content ) ;
594+ setCurrentFile ( target ) ;
595+ setIsDirty ( false ) ;
596+ currentFileRef . current = target ;
597+ if ( selection ) {
598+ revealPosition ( selection . line , selection . column ) ;
599+ }
600+ return editorRef . current ;
601+ }
602+ const content = textModel ?. getValue ( ) ;
603+ if ( content != null ) {
604+ await openFileWithContentRef . current ?.( target , content , selection ?. line , selection ?. column ) ;
605+ } else {
606+ await openFileRef . current ?.( target , selection ?. line , selection ?. column ) ;
607+ }
608+ return editorRef . current ?? undefined ;
457609 }
610+ logNavigationIssue ( "Missing target URI for editor navigation" , { modelRef, options } ) ;
458611 return editorRef . current ?? undefined ;
459612 } ,
460613 } ,
@@ -624,20 +777,35 @@ const configureMonaco = useCallback((monaco: typeof import("@codingame/monaco-vs
624777 }
625778 } , [ revealLine ] ) ;
626779
627- const openFile = useCallback ( async ( filePath : string , line ?: number ) => {
780+ const openFile = useCallback ( async ( filePath : string , line ?: number , column ?: number ) => {
628781 if ( ! filePath ) return ;
629782 const fileUri = toFileUri ( filePath ) ;
630783 if ( currentFileRef . current === fileUri ) {
631- if ( line != null ) revealLine ( line ) ;
784+ if ( line != null ) revealPosition ( line , column ) ;
632785 return ;
633786 }
634- pendingJumpRef . current = line ?? null ;
787+ pendingJumpRef . current = line != null ? { line , column } : null ;
635788 await loadFile ( fileUri ) ;
636- } , [ loadFile , revealLine ] ) ;
789+ } , [ loadFile , revealPosition ] ) ;
790+
791+ const openFileWithContent = useCallback (
792+ async ( filePath : string , content : string , line ?: number , column ?: number ) => {
793+ if ( ! filePath ) return ;
794+ const fileUri = toFileUri ( filePath ) ;
795+ if ( currentFileRef . current === fileUri ) {
796+ if ( line != null ) revealPosition ( line , column ) ;
797+ return ;
798+ }
799+ pendingJumpRef . current = line != null ? { line, column } : null ;
800+ await loadFile ( fileUri , content ) ;
801+ } ,
802+ [ loadFile , revealPosition ] ,
803+ ) ;
637804
638805 useEffect ( ( ) => {
639806 openFileRef . current = openFile ;
640- } , [ openFile ] ) ;
807+ openFileWithContentRef . current = openFileWithContent ;
808+ } , [ openFile , openFileWithContent ] ) ;
641809
642810 // Load project.tsx content
643811 useEffect ( ( ) => {
@@ -663,10 +831,10 @@ const configureMonaco = useCallback((monaco: typeof import("@codingame/monaco-vs
663831
664832 useEffect ( ( ) => {
665833 if ( pendingJumpRef . current == null ) return ;
666- const line = pendingJumpRef . current ;
834+ const { line, column } = pendingJumpRef . current ;
667835 pendingJumpRef . current = null ;
668- revealLine ( line ) ;
669- } , [ code , currentFile , revealLine ] ) ;
836+ revealPosition ( line , column ) ;
837+ } , [ code , currentFile , revealPosition ] ) ;
670838
671839 const handleSave = async ( ) => {
672840 if ( ! code ) return ;
@@ -721,7 +889,7 @@ const configureMonaco = useCallback((monaco: typeof import("@codingame/monaco-vs
721889 } ;
722890
723891 const displayPath = resolveDisplayPath ( currentFile ) ;
724- const languageId = displayPath . endsWith ( ".tsx" ) ? "typescriptreact" : "typescript" ;
892+ const languageId = getLanguageId ( displayPath ) ;
725893
726894 return (
727895 < div
0 commit comments