import * as vscode from 'vscode' ; import { ensureDirectoryExists , RootDir , initMailbox , VSCodeInbox , DecorationRange , DecorationRangeType , setErrorHandler , FSError , toUtopiaPath , initializeFS , BoundsInFile , Bounds , ToVSCodeMessage , parseToVSCodeMessage , sendMessage , editorCursorPositionChanged , readFileAsUTF8 , exists , writeFileUnsavedContentAsUTF8 , clearFileUnsavedContent , sendInitialData , applyPrettier , UtopiaVSCodeConfig , utopiaVSCodeConfigValues , fileOpened , } from 'utopia-vscode-common' ; import { UtopiaFSExtension } from './utopia-fs' ; import { fromUtopiaURI } from './path-utils' ; import { TextDocumentChangeEvent , TextDocumentWillSaveEvent , Uri } from 'vscode' ; const FollowSelectionConfigKey = 'utopia.editor.followSelection.enabled' ; export async function activate ( context ) { const workspaceRootUri = vscode . workspace . workspaceFolders [ 0 ] . uri ; const projectID = workspaceRootUri . scheme ; useFileSystemProviderErrors ( projectID ) ; await initFS ( projectID ) ; const utopiaFS = initUtopiaFSProvider ( projectID , context ) ; initMessaging ( context , workspaceRootUri ) ; watchForUnsavedContentChangesFromFS ( utopiaFS ) ; watchForChangesFromVSCode ( context , projectID ) ; sendMessage ( sendInitialData ( ) ) ; watchForFileDeletions ( ) ; } function watchForFileDeletions ( ) { let fileWatcherChain = Promise . resolve ( ) ; const fileWatcher = vscode . workspace . createFileSystemWatcher ( '**/*' ) ; fileWatcher . onDidDelete ( async ( deletedFile ) => { for ( const textDocument of vscode . workspace . textDocuments ) { if ( textDocument . uri . fsPath === deletedFile . fsPath ) { fileWatcherChain = fileWatcherChain . then ( async ( ) => { await vscode . window . showTextDocument ( textDocument ) ; return Promise . resolve ( ) ; } ) ; if ( textDocument . isDirty ) { await clearDirtyFlags ( textDocument . uri ) ; } fileWatcherChain = fileWatcherChain . then ( async ( ) => { return vscode . commands . executeCommand ( 'workbench.action.closeActiveEditor' ) ; } ) ; } } } ) ; } async function initFS ( projectID ) { await initializeFS ( projectID , 'VSCODE' ) ; await ensureDirectoryExists ( RootDir ) ; } function initUtopiaFSProvider ( projectID , context ) { const utopiaFS = new UtopiaFSExtension ( projectID ) ; context . subscriptions . push ( utopiaFS ) ; return utopiaFS ; } function watchForUnsavedContentChangesFromFS ( utopiaFS ) { utopiaFS . onUtopiaDidChangeUnsavedContent ( ( uris ) => { uris . forEach ( ( uri ) => { updateDirtyContent ( uri ) ; } ) ; } ) ; utopiaFS . onUtopiaDidChangeSavedContent ( ( uris ) => { uris . forEach ( ( uri ) => { clearDirtyFlags ( uri ) ; } ) ; } ) ; } let dirtyFiles = new Set ( ) ; let incomingFileChanges = new Set ( ) ; interface UpdateDirtyContentChange { type : 'UPDATE_DIRTY_CONTENT' ; path : string ; uri : Uri ; } function updateDirtyContentChange ( path , uri ) { return { type : 'UPDATE_DIRTY_CONTENT' , path : path , uri : uri , } ; } interface DidChangeTextChange { type : 'DID_CHANGE_TEXT' ; path : string ; event : TextDocumentChangeEvent ; } function didChangeTextChange ( path , event ) { return { type : 'DID_CHANGE_TEXT' , path : path , event : event , } ; } interface WillSaveText { type : 'WILL_SAVE_TEXT' ; path : string ; event : TextDocumentWillSaveEvent ; } function willSaveText ( path , event ) { return { type : 'WILL_SAVE_TEXT' , path : path , event : event , } ; } interface DidClose { type : 'DID_CLOSE' ; path : string ; } function didClose ( path ) { return { type : 'DID_CLOSE' , path : path , } ; } interface DidOpen { type : 'DID_OPEN' ; path : string ; } function didOpen ( path ) { return { type : 'DID_OPEN' , path : path , } ; } type SubscriptionWork = UpdateDirtyContentChange | DidChangeTextChange | WillSaveText | DidClose | DidOpen ; let pendingWork = [ ] ; function minimisePendingWork ( ) { let newPendingWork = [ ] ; for ( const workItem of pendingWork ) { const latestWorkItem = newPendingWork [ newPendingWork . length - 1 ] ; if ( latestWorkItem != null && latestWorkItem . type === 'DID_CHANGE_TEXT' && workItem . type === 'DID_CHANGE_TEXT' ) { if ( latestWorkItem . path === workItem . path ) { newPendingWork . splice ( newPendingWork . length - 1 , 1 , workItem ) ; } else { newPendingWork . push ( workItem ) ; } } else { newPendingWork . push ( workItem ) ; } } pendingWork = newPendingWork ; } async function doSubscriptionWork ( work ) { switch ( work . type ) { case 'DID_CHANGE_TEXT' : { const { path , event } = work ; if ( event . document . isDirty ) { dirtyFiles . add ( path ) ; } if ( dirtyFiles . has ( path ) ) { if ( incomingFileChanges . has ( path ) ) { incomingFileChanges . delete ( path ) ; } else { const fullText = event . document . getText ( ) ; await writeFileUnsavedContentAsUTF8 ( path , fullText ) ; } } break ; } case 'UPDATE_DIRTY_CONTENT' : { const { path , uri } = work ; if ( ! incomingFileChanges . has ( path ) ) { await updateDirtyContent ( uri ) ; } break ; } case 'WILL_SAVE_TEXT' : { const { path , event } = work ; dirtyFiles . delete ( path ) ; break ; } case 'DID_CLOSE' : { const { path } = work ; if ( dirtyFiles . has ( path ) ) { clearFileUnsavedContent ( path ) ; dirtyFiles . delete ( path ) ; } break ; } case 'DID_OPEN' : { const { path } = work ; sendMessage ( fileOpened ( path ) ) ; break ; } default : const _exhaustiveCheck = work ; console . error ( `Unhandled work type ${ JSON . stringify ( work ) }` ) ; } } const SUBSCRIPTION_POLLING_TIMEOUT = 100 ; async function runPendingSubscriptionChanges ( ) { minimisePendingWork ( ) ; for ( const work of pendingWork ) { await doSubscriptionWork ( work ) ; } pendingWork = [ ] ; setTimeout ( runPendingSubscriptionChanges , SUBSCRIPTION_POLLING_TIMEOUT ) ; } setTimeout ( runPendingSubscriptionChanges , SUBSCRIPTION_POLLING_TIMEOUT ) ; function watchForChangesFromVSCode ( context , projectID ) { function isUtopiaDocument ( document ) { return document . uri . scheme === projectID ; } for ( const document of vscode . workspace . textDocuments ) { if ( isUtopiaDocument ( document ) ) { const path = fromUtopiaURI ( document . uri ) ; pendingWork . push ( didOpen ( path ) ) ; } } context . subscriptions . push ( vscode . workspace . onDidChangeTextDocument ( ( event ) => { if ( isUtopiaDocument ( event . document ) ) { const resource = event . document . uri ; if ( resource . scheme === projectID ) { const path = fromUtopiaURI ( resource ) ; pendingWork . push ( didChangeTextChange ( path , event ) ) ; } } } ) , vscode . workspace . onWillSaveTextDocument ( ( event ) => { if ( isUtopiaDocument ( event . document ) ) { const path = fromUtopiaURI ( event . document . uri ) ; pendingWork . push ( willSaveText ( path , event ) ) ; if ( event . reason === vscode . TextDocumentSaveReason . Manual ) { const formattedCode = applyPrettier ( event . document . getText ( ) , false ) . formatted ; event . waitUntil ( Promise . resolve ( [ new vscode . TextEdit ( entireDocRange ( ) , formattedCode ) ] ) ) ; } } } ) , vscode . workspace . onDidCloseTextDocument ( ( document ) => { if ( isUtopiaDocument ( document ) ) { const path = fromUtopiaURI ( document . uri ) ; pendingWork . push ( didClose ( path ) ) ; } } ) , vscode . workspace . onDidOpenTextDocument ( ( document ) => { if ( isUtopiaDocument ( document ) ) { const path = fromUtopiaURI ( document . uri ) ; pendingWork . push ( updateDirtyContentChange ( path , document . uri ) ) ; pendingWork . push ( didOpen ( path ) ) ; } } ) ) ; } const baseDecorationTypeColor = '#007aff' ; const selectionDecorationType = vscode . window . createTextEditorDecorationType ( { borderColor : baseDecorationTypeColor , borderStyle : 'solid' , borderWidth : '0 0 0 3px' , isWholeLine : true , } ) ; const highlightDecorationType = vscode . window . createTextEditorDecorationType ( { borderColor : `${ baseDecorationTypeColor }11` , borderStyle : 'solid' , borderWidth : '0 0 0 3px' , isWholeLine : true , } ) ; const allDecorationRangeTypes = [ 'highlight' , 'selection' ] ; function getFollowSelectionEnabledConfig ( ) { const followSelectionEnabledConfig = vscode . workspace . getConfiguration ( ) . get ( FollowSelectionConfigKey ) ; return typeof followSelectionEnabledConfig === 'boolean' && followSelectionEnabledConfig ; } function getFullConfig ( ) { return { followSelection : { enabled : getFollowSelectionEnabledConfig ( ) , } , } ; } let currentDecorations = [ ] ; let currentSelection = null ; let squashNextSelectionChange = false ; function initMessaging ( context , workspaceRootUri ) { function handleMessage ( message ) { switch ( message . type ) { case 'OPEN_FILE' : openFile ( vscode . Uri . joinPath ( workspaceRootUri , message . filePath ) ) ; break ; case 'UPDATE_DECORATIONS' : currentDecorations = message . decorations ; updateDecorations ( currentDecorations ) ; break ; case 'SELECTED_ELEMENT_CHANGED' : const followSelectionEnabled = getFollowSelectionEnabledConfig ( ) ; if ( followSelectionEnabled ) { currentSelection = message . boundsInFile ; revealRangeIfPossible ( workspaceRootUri , message . boundsInFile ) ; } break ; case 'GET_UTOPIA_VSCODE_CONFIG' : sendFullConfigToUtopia ( ) ; break ; case 'SET_FOLLOW_SELECTION_CONFIG' : vscode . workspace . getConfiguration ( ) . update ( FollowSelectionConfigKey , message . enabled , vscode . ConfigurationTarget . Workspace ) ; break ; case 'ACCUMULATED_TO_VSCODE_MESSAGE' : for ( const innerMessage of message . messages ) { handleMessage ( innerMessage ) ; } break ; default : const _exhaustiveCheck = message ; console . error ( `Unhandled message type ${ JSON . stringify ( message ) }` ) ; } } initMailbox ( VSCodeInbox , parseToVSCodeMessage , handleMessage ) ; context . subscriptions . push ( vscode . window . onDidChangeVisibleTextEditors ( ( ) => { updateDecorations ( currentDecorations ) ; } ) , vscode . window . onDidChangeTextEditorSelection ( ( event ) => { if ( squashNextSelectionChange ) { squashNextSelectionChange = false ; } else { cursorPositionChanged ( event ) ; } } ) , vscode . workspace . onDidChangeConfiguration ( ( event ) => { if ( event . affectsConfiguration ( FollowSelectionConfigKey ) ) { sendFullConfigToUtopia ( ) ; } } ) ) ; } function sendFullConfigToUtopia ( ) { const fullConfig = getFullConfig ( ) ; return sendMessage ( utopiaVSCodeConfigValues ( fullConfig ) ) ; } function entireDocRange ( ) { return new vscode . Range ( new vscode . Position ( 0 , 0 ) , new vscode . Position ( Number . MAX_VALUE , Number . MAX_VALUE ) ) ; } async function clearDirtyFlags ( resource ) { return vscode . commands . executeCommand ( 'workbench.action.files.revertResource' , resource ) ; } async function updateDirtyContent ( resource ) { const filePath = fromUtopiaURI ( resource ) ; const { unsavedContent } = await readFileAsUTF8 ( filePath ) ; if ( unsavedContent != null ) { incomingFileChanges . add ( filePath ) ; const workspaceEdit = new vscode . WorkspaceEdit ( ) ; workspaceEdit . replace ( resource , entireDocRange ( ) , unsavedContent ) ; const editApplied = await vscode . workspace . applyEdit ( workspaceEdit ) ; if ( editApplied ) { updateDecorations ( currentDecorations ) ; if ( currentSelection != null ) { revealRangeIfPossibleInVisibleEditor ( currentSelection ) ; } } else { incomingFileChanges . delete ( filePath ) ; } } } async function openFile ( fileUri , retries = 5 ) { const filePath = fromUtopiaURI ( fileUri ) ; const fileExists = await exists ( filePath ) ; if ( fileExists ) { squashNextSelectionChange = true ; await vscode . commands . executeCommand ( 'vscode.open' , fileUri , { preserveFocus : true } ) ; return true ; } else { if ( retries > 0 ) { await new Promise < void > ( ( resolve ) => setTimeout ( ( ) => resolve ( ) , 100 ) ) ; return openFile ( fileUri , retries - 1 ) ; } else { return false ; } } } function cursorPositionChanged ( event ) { try { const editor = event . textEditor ; const filename = editor . document . uri . path ; const position = editor . selection . active ; sendMessage ( editorCursorPositionChanged ( filename , position . line , position . character ) ) ; } catch ( error ) { console . error ( 'cursorPositionChanged failure.' , error ) ; } } async function revealRangeIfPossible ( workspaceRootUri , boundsInFile , forceIfFocused = false ) { const focused = vscode . window . state . focused ; if ( forceIfFocused || ! focused ) { const visibleEditor = vscode . window . visibleTextEditors . find ( ( editor ) => editor . document . uri . path === boundsInFile . filePath ) ; if ( visibleEditor == null ) { const opened = await openFile ( vscode . Uri . joinPath ( workspaceRootUri , boundsInFile . filePath ) ) ; if ( opened ) { revealRangeIfPossibleInVisibleEditor ( boundsInFile ) ; } } else { revealRangeIfPossibleInVisibleEditor ( boundsInFile ) ; } } } async function revealRangeIfPossibleInVisibleEditor ( boundsInFile ) { const visibleEditor = vscode . window . visibleTextEditors . find ( ( editor ) => editor . document . uri . path === boundsInFile . filePath ) ; if ( visibleEditor != null ) { const rangeToReveal = getVSCodeRangeForScrolling ( boundsInFile ) ; const alreadyVisible = visibleEditor . visibleRanges . some ( ( r ) => r . contains ( visibleEditor . selection ) ) ; const selectionRange = getVSCodeRange ( boundsInFile ) ; const newSelection = new vscode . Selection ( selectionRange . start , selectionRange . start ) ; if ( ! visibleEditor . selection . isEqual ( newSelection ) ) { squashNextSelectionChange = true ; visibleEditor . selection = newSelection ; } const shouldReveal = ! alreadyVisible ; if ( shouldReveal ) { visibleEditor . revealRange ( rangeToReveal , vscode . TextEditorRevealType . InCenterIfOutsideViewport ) ; } } } type DecorationsByType = { [ key ] : Array < DecorationRange > ; } ; type DecorationsByFilenameAndType = { [ key ] : DecorationsByType ; } ; function getVSCodeDecorationType ( rangeType ) { switch ( rangeType ) { case 'highlight' : return highlightDecorationType ; case 'selection' : return selectionDecorationType ; default : const _exhaustiveCheck = rangeType ; throw new Error ( `Unhandled type ${ JSON . stringify ( rangeType ) }` ) ; } } function getDecorationsByFilenameAndType ( decorations ) { let result = { } ; for ( const range of decorations ) { let decorationsForFilename ; if ( range . filePath in result ) { decorationsForFilename = result [ range . filePath ] ; } else { decorationsForFilename = { } ; result [ range . filePath ] = decorationsForFilename ; } let decorationsForType ; if ( range . rangeType in decorationsForFilename ) { decorationsForType = decorationsForFilename [ range . rangeType ] ; } else { decorationsForType = [ ] ; decorationsForFilename [ range . rangeType ] = decorationsForType ; } decorationsForType . push ( range ) ; } return result ; } function getVSCodeRange ( bounds ) { const { startLine , endLine , startCol , endCol } = bounds ; return new vscode . Range ( new vscode . Position ( startLine , startCol ) , new vscode . Position ( endLine , endCol ) ) ; } function getVSCodeRangeForScrolling ( bounds ) { const { startLine , endLine , startCol , endCol } = bounds ; return new vscode . Range ( new vscode . Position ( startLine , 0 ) , new vscode . Position ( endLine , endCol ) ) ; } function updateDecorations ( decorations ) { const visibleEditors = vscode . window . visibleTextEditors ; const decorationsByFilenameAndType = getDecorationsByFilenameAndType ( decorations ) ; for ( const visibleEditor of visibleEditors ) { const filename = visibleEditor . document . uri . path ; const decorationsForFile = decorationsByFilenameAndType [ filename ] ?? { } ; for ( const rangeType of allDecorationRangeTypes ) { const decorationsForType = decorationsForFile [ rangeType ] ?? [ ] ; const vsCodeDecorationType = getVSCodeDecorationType ( rangeType ) ; const vsCodeRanges = decorationsForType . map ( getVSCodeRange ) ; visibleEditor . setDecorations ( vsCodeDecorationType , vsCodeRanges ) ; } } } function useFileSystemProviderErrors ( projectID ) { setErrorHandler ( ( e ) => toFileSystemProviderError ( projectID , e ) ) ; } function toFileSystemProviderError ( projectID , error ) { const { path : unadjustedPath , code } = error ; const path = toUtopiaPath ( projectID , unadjustedPath ) ; switch ( code ) { case 'ENOENT' : return vscode . FileSystemError . FileNotFound ( path ) ; case 'EISDIR' : return vscode . FileSystemError . FileIsADirectory ( path ) ; case 'ENOTDIR' : return vscode . FileSystemError . FileNotADirectory ( path ) ; case 'EEXIST' : return vscode . FileSystemError . FileExists ( path ) ; case 'FS_UNAVAILABLE' : return vscode . FileSystemError . Unavailable ( path ) ; default : const _exhaustiveCheck = code ; throw new Error ( `Unhandled FS Error ${ JSON . stringify ( error ) }` ) ; } }