Skip to main content

Command Palette

Search for a command to run...

GitRaven: How to setup Monaco Editor for QWebEngine

Updated
7 min read

Hello,

This blog post will talk about integrating Monaco Editor into a Qt + C++ app using QWebEngine.

Background

GitRaven is being built to serve as a near-identical replacement to VSCode’s source control management. It aims to offer a similar, yet opinionated, user experience.

I personally like side-by-side diff when dealing with file changes. It feels natural to me and I guess, I am pampered by VSCode for a couple of years.

In my first GitRaven post, you might have seen a diff viewer in the screenshots. It was built using a custom side-by-side implementation using KTextEditor. I gave up on that approach as it was a difficult problem to solve with my current level.

My future goal (distant) is to replace Monaco based diff viewer with a C++ native side-by-side diff viewer as seen in Kompare or Kate’s diff viewers.

AI Usage

This project uses AI to help me overcome challenges faced in the project. Some code written here has been derived from code snippets shared by LLMs.

Theory

The idea is to instantiate QWebEngineView into the app which loads index.html file which contains essential code to initialize Monaco editor as well as setup communication with C++ side as needed.

  1. Create a new class RavenMonaco which extends from QWebEngineView and set it up in main window class.

  2. Create a new class which extends QWebEnginePage to send commands to Monaco editor. This is primarily used to communicate with Monaco, to init the widget or update diff text when a different file is chosen. We also use it to log messages to stdout printed on JS’s console for debugging purposes.

  3. Create the “bridge” class which maintains communication between JS ←—→ C++ side. It is currently used to save text from Monaco’s modified buffer to C++.

  4. A custom class is needed which handles rendering of Monaco editor on-demand like when a user clicks on diff item from LHS tree. This class is also responsible to save text buffer contents sent in (3) to disk.

  5. Lastly, we need a class that can serve Monaco code via a HTTP server for enabling Web Workers.

    HTTP server is needed for improving performance of Monaco editor. You can find more info in FAQ section of the project.

Implementation

Let’s start with custom web engine view class.

ravenmonaco.h

#ifndef RAVENMONACO_H
#define RAVENMONACO_H

#include "ravenmonacopage.h"
#include "ravenmonacohttpserver.h"
#include "ravenmonacobridge.h"

#include <QWebEngineView>
#include <QWidget>
#include <qevent.h>

#include <QJsonObject>

class RavenMonaco : public QWebEngineView
{
    Q_OBJECT
public:
    explicit RavenMonaco(QWidget *parent = nullptr);

    RavenMonacoPage *page() const;

protected:
    void resizeEvent(QResizeEvent *event) override
    {
        // Ensure that the web view resizes dynamically when the parent widget is resized
        resize(event->size()); // Resize webView to match the parent widget
        QWidget::resizeEvent(event);
    }
    void setDefaultUrl();

public slots:
    // Nice to have :)
    void setTheme(Qt::ColorScheme colorScheme);
private:
    RavenMonacoPage *m_page;
    RavenMonacoHTTPServer *m_server;
    RavenMonacoBridge *m_bridge;
    QWebChannel *m_channel;
};

#endif // RAVENMONACO_H

This is pretty straightforward. I store references to page, http server, bridge class and channel for future use.

ravenmonaco.cpp

#include "ravenmonaco.h"

#include <QStyleHints>
#include <QWebChannel>
#include <qmessagebox.h>

namespace fs = std::filesystem;

RavenMonaco::RavenMonaco(QWidget *parent)
    : QWebEngineView{parent}
{
    // Init page
    m_page = new RavenMonacoPage(this);
    setPage(m_page);

    // Light/dark theme switcher
    QStyleHints *hint = QGuiApplication::styleHints();

    // Init monaco when the page load is finished.
    connect(page(), &QWebEnginePage::loadFinished, this, [this](bool ok) {
        if (!ok) {
            qCritical() << "Failed to load Monaco editor, check Monaco HTTP server.";
            QMessageBox errorMsg(QMessageBox::Critical, "GitRaven" , "Failed to load Diff Viewer components.", QMessageBox::Ok);
            errorMsg.exec();
            std::exit(-1);
        }

        // Initialize Monaco internally (doesn't show up in UI yet)
        page()->runJavaScript("init()", 0, [this](const QVariant &) {
            // Update theme
            setTheme(QGuiApplication::styleHints()->colorScheme());
        });
    });

    // Init HTTP server for monaco-editor
    m_server = new RavenMonacoHTTPServer(this);
    m_server->init();

    setDefaultUrl();

    // Init bridge
    m_bridge = new RavenMonacoBridge(this, (RavenEditor*)parent);
    m_channel = new QWebChannel(this);
    m_page->setWebChannel(m_channel);
    // Inform JS side of a JS object available in 
    // `window` that can communicate with C++ world.
    m_channel->registerObject("cppBridge", m_bridge);

    // Light/dark theme switcher
    connect(hint, &QStyleHints::colorSchemeChanged, this, &RavenMonaco::setTheme);
}

RavenMonacoPage *RavenMonaco::page() const
{
    return m_page;
}

void RavenMonaco::setDefaultUrl()
{
    setUrl(QUrl("http://localhost:9191/index.html"));
    load(url());
}

void RavenMonaco::setTheme(Qt::ColorScheme colorScheme)
{
    QJsonObject obj;
    obj["theme"] = colorScheme == Qt::ColorScheme::Light ? "light" : "dark";
    QJsonDocument jd(obj);
    m_page->runJavaScript(QString("setTheme({opt})").replace("{opt}", jd.toJson()));
}

This class does a couple of things:

  1. Calls init() in the JS side to initialize Monaco editor. I want to make the app “ready for rendering diffs” ASAP and so I came up with this logic.

  2. Then, we setup color scheme detection based on OS’s preference (light/dark) and listen to this event. I personally like this feature.

It’s a (great) gesture from app developers and I appreciate every time an app does it. Most Linux apps follow this by default (at least ones I use) and naturally, I wanted to participate :)

  1. We initialize bridge class as well as QWebChannel which is the tech magic that enables us to communicate with/from C++ & JS side.

ravenmonacopage.h

#ifndef RAVENMONACOPAGE_H
#define RAVENMONACOPAGE_H

#include <QWebEnginePage>

#include "gitmanager.h"

class RavenMonacoPage : public QWebEnginePage
{
public:
    explicit RavenMonacoPage(QObject *parent = nullptr);
    // This is needed to disable editing Monaco's modified buffer when dealing with
    // staged files.
    void setReadonly(bool readonly);
    // Used to update old/new text content inside Monaco on each file item click.
    void updateText(GitManager::GitDiffItem diffItem);
private:
    void javaScriptConsoleMessage(JavaScriptConsoleMessageLevel level,
                                          const QString &message, int lineNumber,
                                          const QString &sourceID) override;

};

#endif // RAVENMONACOPAGE_H

ravenmonacopage.cpp

#include "ravenmonacopage.h"

#include <QJsonObject>


RavenMonacoPage::RavenMonacoPage(QObject *parent)
    : QWebEnginePage(parent)
{}

void RavenMonacoPage::setReadonly(bool readonly)
{
    runJavaScript(QString("setReadonly({opt})")
    .replace("{opt}", QVariant(readonly).toString()));
}

void RavenMonacoPage::updateText(GitManager::GitDiffItem diffItem)
{
    // Build JSON payload
    // Note: Is there a better way?
    QJsonObject payloadJ;
    payloadJ["oldText"] = diffItem.oldFileContent;
    payloadJ["oldPath"] = diffItem.oldFilePath;
    payloadJ["newText"] = diffItem.newFileContent;
    payloadJ["newPath"] = diffItem.newFilePath;

    QJsonDocument payloadJD(payloadJ);

    QString payloadJDStr = QString(payloadJD.toJson());

    // Send request
    runJavaScript(QString("update({opt})").replace("{opt}", payloadJDStr));
}

void RavenMonacoPage::javaScriptConsoleMessage(
    JavaScriptConsoleMessageLevel level,
    const QString &message,
    int lineNumber, const
    QString &sourceID
)
{
    qDebug() << "RavenMonacoPage::javaScriptConsoleMessage";
    qDebug() << level << message << lineNumber << sourceID;
}

ravenmonacobridge.h

#ifndef RAVENMONACOBRIDGE_H
#define RAVENMONACOBRIDGE_H

#include <QObject>
#include <QDebug>

// Forward declarations
class RavenEditor;

class RavenMonacoBridge : public QObject
{
    Q_OBJECT
public:
    explicit RavenMonacoBridge(QObject *parent = nullptr, RavenEditor *editor = nullptr);
    Q_INVOKABLE void saveModifiedChanges(QString modified);

private:
    RavenEditor *m_ravenEditor;
};

#endif // RAVENMONACOBRIDGE_H

ravenmonacobridge.cpp

#include "ravenmonacobridge.h"

#include "raveneditor.h"

RavenMonacoBridge::RavenMonacoBridge(QObject *parent, RavenEditor *editor)
    : QObject{parent}
{
    m_ravenEditor = editor;
}

/**
 * @brief This function is called by Monaco when user has modified file contents.
 * @param modifiedText - The modified text contents from Monaco side.
 */
void RavenMonacoBridge::saveModifiedChanges(QString modifiedText)
{
    qDebug() << "RavenMonacoBridge::saveModifiedChanges called";

    emit m_ravenEditor->signalSaveModifiedChanges(modifiedText);
}

This class exists to trigger saveModifiedChanges from JS side and forward it to RavenEditor class which actually holds the business logic for processing the request.

Also, here’s the code that allows us to render either a placeholder or Monaco editor as required.

ravenrhsview.h

#ifndef RAVENRHSVIEW_H
#define RAVENRHSVIEW_H

#include "gitmanager.h"
#include "mainwindow.h"

#include <QVBoxLayout>
#include <QWidget>

class RavenRHSView : public QWidget
{
    Q_OBJECT
public:
    explicit RavenRHSView(QWidget *parent);
    ~RavenRHSView() override;

    void initLandingInfo();

public slots:
    void updateUI(std::optional<GitManager::GitDiffItem> item);

private:
    bool m_showLandingInfo = true;

    MainWindow *m_mainWindow;
    RavenTree   *m_ravenTree;
    RavenEditor *m_ravenEditor;

    QWidget *m_landingInfoWidget;
};

#endif // RAVENRHSVIEW_H

ravenrhsview.cpp

#include "ravenrhsview.h"

#include "raveneditor.h"

#include <QLabel>

RavenRHSView::RavenRHSView(QWidget *parent)
    : QWidget{parent},
    m_mainWindow(static_cast<MainWindow*>(topLevelWidget()->window())),
    m_ravenEditor(new RavenEditor(this)),  // Editor widget
    m_landingInfoWidget(new QWidget(this)) // placeholder widget
{
    // Widget config
    setLayout(new QVBoxLayout(this));
    layout()->addWidget(m_landingInfoWidget);

    updateUI(std::nullopt);

    m_ravenTree = m_mainWindow->getRavenLHSView()->getRavenTree();
    connect(m_ravenTree, &RavenTree::renderDiffItem, this, &RavenRHSView::updateUI);
}

RavenRHSView::~RavenRHSView()
{
    // cleanup
    disconnect(m_ravenTree, &RavenTree::renderDiffItem, this, &RavenRHSView::updateUI);
}

void RavenRHSView::updateUI(std::optional<GitManager::GitDiffItem> item)
{
    qDebug() << "RavenRHSView::updateUI called";

    m_showLandingInfo = !item.has_value();

    // Determine whether we show Diff widget or the placeholder
    if (!m_showLandingInfo) {
        m_landingInfoWidget->hide();
        layout()->addWidget(m_ravenEditor);
        m_ravenEditor->openDiffItem(std::move(item.value()));
    } else {
        initLandingInfo();
    }
}

void RavenRHSView::initLandingInfo()
{
    auto widget = m_landingInfoWidget;

    auto layout = new QGridLayout(widget);
    layout->setAlignment(Qt::AlignCenter);
    widget->setLayout(layout);
    widget->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);

    auto *label = new QLabel(widget);
    label->setText("GitRaven");
    auto icon = QIcon::fromTheme("git");
    auto iconLabel = new QLabel(widget);
    iconLabel->setPixmap(icon.pixmap(64, 64));

    layout->addWidget(iconLabel);
    layout->addWidget(label);
}

ravenmonacohttpserver.h

#ifndef RAVENMONACOHTTPSERVER_H
#define RAVENMONACOHTTPSERVER_H

#include <QObject>
#include <QHttpServer>
#include <QTcpServer>

class RavenMonacoHTTPServer : QObject
{
    Q_OBJECT
public:
    RavenMonacoHTTPServer(QObject *parent);
    ~RavenMonacoHTTPServer();

    int init();

private:
    QUrl *m_url;
    int PORT = 9191;

    QHttpServer *m_server = new QHttpServer(this);
    QTcpServer *m_tcpserver = new QTcpServer(this);
};

#endif // RAVENMONACOHTTPSERVER_H

ravenmonacohttpserver.cpp

#include "ravenmonacohttpserver.h"

#include <filesystem>

#include "ravenutils.h"

using std::filesystem::absolute;
using std::filesystem::path;

RavenMonacoHTTPServer::RavenMonacoHTTPServer(QObject *parent)
    : QObject(parent) {}

RavenMonacoHTTPServer::~RavenMonacoHTTPServer()
{
    m_tcpserver->close();
}

int RavenMonacoHTTPServer::init()
{
    qDebug() << "RavenMonacoHTTPServer::init() called";

    if (!m_tcpserver->listen(QHostAddress::LocalHost, PORT) || !m_server->bind(m_tcpserver)) {
        qDebug() << "RavenMonacoHTTPServer::init() Failed to bind port for HTTP server.";
        return -1;
    }

    // Listen to / path and return the requested file in URL.
    // Note: Can this be a security risk? For ex: "/index.html/../../../../etc/passwd"
    m_server->route("/<arg>", [] (const QUrl &url) {
        QString urlPath = url.path();

        if (urlPath.length() < 5) {
            return QHttpServerResponse("");
        }

        // Locate editor directory
        path editorDirStdPath = path(RavenUtils::getEditorDirPath());
        path absolutePath = absolute(editorDirStdPath / path(urlPath.toStdString()));

        return QHttpServerResponse::fromFile(QString::fromStdString(absolutePath));
    });

    return 0;
}

Results

Default state (placeholder)

GitRaven default state with placeholder on right-hand side

Render a diff with Monaco (respecting OS color scheme)

GitRaven with Monaco rendering a side-by-side diff on right-hand side

Conclusion

Thanks for your time!

I reluctantly introduced this feature into the project because I couldn’t find a viable alternative to it and building something similar is way too advanced for me at the moment.

Anyway, I hope you liked this post. Please give it a Like to show your appreciation. If you feel there’s something I missed or can do better, let me know in the comments.

Bye for now :-)

GitRaven

Part 2 of 5

This series follows my dev journey of building GitRaven project. It is a hobby project which aims to replace SourceTree & GitHub Desktop on my desktop. It is written in C++ and Qt. Follow along to find out where the story goes!

Up next

GitRaven: How to draw custom widgets on QTreeView rows

Hello, In this blog post, we will be looking at customizing QTreeView to render custom widgets on each relevant rows. We will be adding the ability to stage/unstage an file/folder from tree and also show a label for the file’s git status - Modified, ...