facebook

Blog

Resta aggiornato

Integriamo Monaco, l’editor alla base di Visual Studio Code, nelle nostre applicazioni web
Visual Studio Code nella tua App!
martedì 23 Luglio 2019

Durante un’attività di consulenza presso un cliente che aveva commissionato lo sviluppo di una piattaforma di e-commerce, ci è stato chiesto se e come fosse possibile consentire agli utenti di customizzare la propria interfaccia modificando stili e temi delle pagine, e salvarli su una sorgente dati, magari versionandoli, in modo da consentirne il recupero.

Abbiamo quindi stilato una lista dei diversi problemi da affrontare:

  • versioning: definire una modalità di versionamento dei file, magari prendendo in considerazione anche le versioni non definitive (draft);
  • editing inline: modifica dei file, possibilmente tramite colorazione di sintassi;
  • comparing: confronto dei files con versioni precedenti;
  • provisioning: definire una modalità di recupero dei file.

La piattaforma e-commerce era basata sul framework ASP.NET MVC 5. Ciò ci ha spinto a cercare soluzioni compatibili con l’ambiente .NET.

Per le attività di modifica dei file, la scelta è ricaduta su Monaco Editor, strumento che è anche motore di Visual Studio Code: supportato da tutti i principali browser, e che fornisce una serie di facilitazioni, come la colorazione del testo basata sul linguaggio scelto, l’intellisense e il diff dei files.

Monaco Editor risulta di facile installazione e configurazione, dal momento che necessita del link a una libreria JS e, tramite le API documentate (API Monaco Editor), è possibile ottenere un’istanza pronta ad essere utilizzata. Ad esempio, l’istruzione:

monaco.editor.create(document.getElementById("container"), {
    value: "function hello() {\n\t alert('Hello world!');\n}",
    language: "javascript"
});

sulla pagina:

<div id="container" style="height:100%;"></div>

genera la schermata:

Nel nostro caso, abbiamo utilizzato l’editor sia per modificare che per confrontare le varie versioni dei files.

Per definire i file che possono essere modificati abbiamo creato la classe EditableContent:

public class EditableContent
    {
        [Key]
        public int Id { get; set; }
        [Required]
        public string Path { get; set; }
        public string Type { get; set; }
        [DefaultValue(false)]
        public bool Disabled { get; set; }
    } 

L’attributo Type serve alla corretta visualizzazione del file su Monaco Editor, dal momento che, come scritto prima, è possibile definire di che tipo è il file, al fine di ottenere la colorazione del testo e segnalazione degli errori.

Per i file già modificati, invece, abbiamo la classe StoredContent:

public class StoredContent
{
    [Key]
    public int Id { get; set; }
    [Required]
    public string Path { get; set; }
    [Required]
    public string Content { get; set; }
    [Required]
    public int Version { get; set; }
    [DefaultValue(false)]
    public bool Draft { get; set; }
}

In StoredContent, quindi, è presente sia il numero di versione sia l’attributo draft che ci indica se si tratti di una versione definitiva.

Per le operazioni CRUD (Create, Read, Update e Delete) dei files, abbiamo creato il controller API ContentsController, che presenta i seguenti metodi:

  • IEnumerable<string> GetEditableContents(): serve ad ottenere la lista dei contenuti modificabili;
  • IEnumerable<StoredContentVersionModel> GetContentAvailableVersions(BaseContentModel baseContent): ottiene la lista delle versioni disponibili per il path specificato;
  • string GetContent(StoredContentModel storedContent): ottiene il contenuto di un file a partire dal percorso e dal numero di versione;
  • StoredContentModel GetLatestContent(BaseContentModel baseContent): ottiene il contenuto di un file a partire dal percorso e relativo all’ultima versione disponibile;
  • int CreateStoredContent(StoredContentModel storedContent): crea una nuova versione di un file;
  • int RecoverStoredContentVersion([FromBody] StoredContentModel storedContent): recupera una versione precedentemente salvata e la importa come ultima.

BaseContentModel, StoredContentModel, StoredContentVersionModel sono viewmodel utilizzati per lo scambio dei dati con le view, e sono così definite:

public class BaseContentModel
    {
        public string Path { get; set; }
    }
 
    public class StoredContentVersionModel 
    {
        public int Version { get; set; }
        public bool Draft { get; set; }
    }
 
    public class StoredContentModel : BaseContentModel
    {
        public string Content { get; set; }
        public string Type { get; set; }
        public bool? Draft { get; set; }
        public int Version { get; set; }
    }

Nello specifico, l’implementazione presenta alcuni punti da definire meglio, come ad esempio GetLatestContent:

public StoredContentModel GetLatestContent([FromBody] BaseContentModel baseContent)
{
    var storedContent = _context.StoredContents
        .Where(sc => sc.Path == baseContent.Path)
        .OrderByDescending(sc => sc.Version).FirstOrDefault();
 
    if (storedContent == null)
    {
        var webRoot = _env.WebRootPath;
        var file = System.IO.Path.Combine(webRoot, baseContent.Path);
        storedContent = new StoredContent()
        {
            Path = baseContent.Path,
            Content = System.IO.File.ReadAllText(file, System.Text.Encoding.Default),
            Version = 0,
            Draft = false
        };
        _context.StoredContents.Add(storedContent);
        _context.SaveChanges();
    }
 
    return new StoredContentModel()
    {
        [...]
    };
}

Quando non c’è nessun file corrispondente su db, viene creata una versione 0, in cui c’è il contenuto del file presente su file system.

GetLatestContent viene invocato da una chiamata Ajax tramite jquery:

function loadPage(path) {
    $.ajax({
        url: "/api/contents/GetLatestContent",
        type: "POST",
        data: JSON.stringify({Path: path }),
        contentType: "application/json; charset=utf-8",
        dataType: "json",
        success: function (data) {
            setMonacoEditor(data.content, data.type, data.path);
        }
    })
}

dove setMonacoEditor() inizializza l’editor in modalità lettura e modifica del singolo file:

function setMonacoEditor(text, mode, path) {
    if (!editor) {
        $('#editor').empty();
        editor = monaco.editor.create(document.getElementById('editor'), {
            model: null,
            automaticLayout: true
        });
    }
 
    var oldModel = editor.getModel();
    var newModel = monaco.editor.createModel(text, mode);
    editor.setModel(newModel);
    if (oldModel) {
        oldModel.dispose();
    }
    //[...]
}

Per salvare il nuovo contenuto, c’è il metodo CreateStoredContent che crea una nuova versione del file:

public int CreateStoredContent([FromBody] StoredContentModel storedContent)
        {
            var storedContentToCreate = new StoredContent()
            {
                Content = storedContent.Content,
                Path = storedContent.Path,
                Draft = storedContent.Draft.GetValueOrDefault(false)
            };
            var latestStoredContent = _context.StoredContents
                .Where(sc => sc.Path == storedContent.Path)
                .OrderByDescending(sc => sc.Version).FirstOrDefault();
            if (latestStoredContent == null){
                storedContentToCreate.Version = 1;
                }
            else if (latestStoredContent.Draft){
                storedContentToCreate.Version = latestStoredContent.Version;
                }
            else {
            storedContentToCreate.Version = ++latestStoredContent.Version;
            }
 
            _context.StoredContents.Add(storedContentToCreate);
            _context.SaveChanges();
            return storedContentToCreate.Id;
        }

Da notare la gestione del numero di versione tramite il flag Draft impostato. Se l’ultima versione presente sul db è draft, allora non si incrementa di numero di versione, altrimenti dipende dal flag passato al servizio.

Nel nostro esempio, in cui abbiamo posto come modificabile il file site.css, la finestra appare così:

Per il diff dei files, richiamiamo un altro metodo di ContentsController, GetContent, e nel body passiamo path e numero di versione.

public string GetContent([FromBody] StoredContentModel storedContent)
{
    var _storedContent = _context.StoredContents.FirstOrDefault(sc => sc.Path == storedContent.Path && sc.Version == storedContent.Version);
    if (_storedContent != null)
        return _storedContent.Content;
    else
        return "";
}

Lato front-end, invece abbiamo il metodo javascript loadPageDiff:

function loadPageDiff(path, newversion, oldversion, mode) {
 
            var onError = function () {
                $('.loading.diff-editor').fadeOut({ duration: 200 });
                $('#diff-editor').append('<p class="alert alert-error">Failed to load diff editor sample</p>');
            };
 
            $('.loading.diff-editor').show();
 
            var lhsData = null, rhsData = null, jsMode = null;
 
            $.ajax({
                url: "/api/contents/GetContent",
                type: "POST",
                data: JSON.stringify({ Path: path, Version: newversion }),
                contentType: "application/json; charset=utf-8",
                dataType: "text",
                success: function (data) {
                    lhsData = data;
                    onProgress();
                }
            })
 
            $.ajax({
                url: "/api/contents/GetContent",
                type: "POST",
                data: JSON.stringify({ Path: path, Version: oldversion }),
                contentType: "application/json; charset=utf-8",
                dataType: "text",
                success: function (data) {
                    rhsData = data;
                    onProgress();
                }
            })
 
            function onProgress() {
            // set diff environment
            }
        }

che richiama due volte GetContent e posiziona le versioni in finestre affiancate, in modo da consentire all’utente di verificare al meglio le modifiche fatte al file e confrontare con le versioni precedenti.

La funzionalità di recupero viene garantita dal metodo RecoverStoredContentVersion

public int RecoverStoredContentVersion([FromBody] StoredContentModel storedContent)
{
    var versionedStoredContent = _context.StoredContents
        .Where(sc => sc.Path == storedContent.Path && sc.Version == storedContent.Version)
        .FirstOrDefault();
 
    var latestStoredContentVersion = _context.StoredContents
        .Where(sc => sc.Path == storedContent.Path)
        .Max(sc => sc.Version);
 
    var storedContentToCreate = new StoredContent()
    {
        Path = storedContent.Path,
        Content = versionedStoredContent.Content,
        Version = ++latestStoredContentVersion,
        Draft = false
    };
 
    _context.StoredContents.Add(storedContentToCreate);
    _context.SaveChanges();
    return storedContentToCreate.Id;
}

Nel nostro esempio, la finestra in diff appare così:

Il recupero dei file modificati non è oggetto di questo articolo, ma vorremmo introdurre due classi importanti. Su .NET Framework, nella libreria System.Web.Hosting, c’è la classe VirtualPathProvider (qui), che definisce la creazione di un file system virtuale a partire da un insieme di dati di varia natura e consente di creare delle logiche di recupero customizzate e caching dei file tramite l’override di una serie di metodi:

  • VirtualFile GetFile(string virtualPath): Permette di recuperare un file, sia dal path fisico che da quello virtuale;
  • bool FileExists(string virtualPath): Consente di verificare se esiste un file virtuale;
  • CacheDependency GetCacheDependency(string virtualPath, IEnumerable virtualPathDependencies, DateTime utcStart): Gestisce il caching dei files virtuali;
  • String GetFileHash(String virtualPath, IEnumerable virtualPathDependencies): Restituisce un hash del file specificato.

Su .NET Core c’è l’interfaccia IfileProvider (qui) che, analogamente a VirtualPathProvider, definisce le logiche di recupero dei file da varie sorgenti tramite l’implementazione di alcuni metodi:

  • IDirectoryContents GetDirectoryContents(string subpath): Restituisce il contenuto di una directory;
  • IFileInfo GetFileInfo(string subpath): Restituisce un oggetto di tipo FileInfo (contiene la logica di business);
  • IChangeToken Watch(string filter): Definisce le modalità per verificare se il file richiesto è cambiato o meno rispetto all’ultimo utilizzo.

Un esempio di utilizzo di IFileProvider, oltre al codice utilizzato nell’articolo, è disponibile qui:

https://github.com/enricobencivenga/MonacoEditor