File indexing completed on 2024-04-28 05:49:26

0001 /*
0002     SPDX-FileCopyrightText: 2021 Waqar Ahmed <waqar.17a@gmail.com>
0003     SPDX-FileCopyrightText: 2021 Christoph Cullmann <cullmann@kde.org>
0004     SPDX-License-Identifier: LGPL-2.0-or-later
0005  */
0006 
0007 #include "commitfilesview.h"
0008 
0009 #include "hostprocess.h"
0010 #include <diffparams.h>
0011 #include <gitprocess.h>
0012 #include <ktexteditor_utils.h>
0013 
0014 #include <QApplication>
0015 #include <QByteArray>
0016 #include <QClipboard>
0017 #include <QDebug>
0018 #include <QDir>
0019 #include <QFileInfo>
0020 #include <QMenu>
0021 #include <QMimeDatabase>
0022 #include <QPainter>
0023 #include <QProcess>
0024 #include <QStandardItem>
0025 #include <QStyledItemDelegate>
0026 #include <QToolButton>
0027 #include <QTreeView>
0028 #include <QUrl>
0029 #include <QVBoxLayout>
0030 
0031 #include <KColorScheme>
0032 #include <KLocalizedString>
0033 #include <KTextEditor/Application>
0034 #include <KTextEditor/Editor>
0035 #include <KTextEditor/MainWindow>
0036 
0037 #include <optional>
0038 
0039 struct GitFileItem {
0040     QByteArray file;
0041     int linesAdded;
0042     int linesRemoved;
0043 };
0044 
0045 /**
0046  * Class representing a item inside the treeview
0047  * Copied from KateProject with modifications as needed
0048  */
0049 class FileItem : public QStandardItem
0050 {
0051 public:
0052     enum Type { Directory = 1, File = 2 };
0053     enum Role {
0054         Path = Qt::UserRole,
0055         TypeRole,
0056         LinesAdded,
0057         LinesRemoved,
0058     };
0059 
0060     FileItem(Type type, const QString &text)
0061         : QStandardItem(text)
0062         , m_type(type)
0063     {
0064     }
0065 
0066     QVariant data(int role = Qt::UserRole + 1) const override
0067     {
0068         if (role == Qt::DecorationRole) {
0069             return icon();
0070         }
0071 
0072         if (role == TypeRole) {
0073             return QVariant(m_type);
0074         }
0075 
0076         return QStandardItem::data(role);
0077     }
0078 
0079     /**
0080      * We want case-insensitive sorting and directories first!
0081      */
0082     bool operator<(const QStandardItem &other) const override
0083     {
0084         // let directories stay first
0085         const auto thisType = data(TypeRole).toInt();
0086         const auto otherType = other.data(TypeRole).toInt();
0087         if (thisType != otherType) {
0088             return thisType < otherType;
0089         }
0090 
0091         // case-insensitive compare of the filename
0092         return data(Qt::DisplayRole).toString().compare(other.data(Qt::DisplayRole).toString(), Qt::CaseInsensitive) < 0;
0093     }
0094 
0095     QIcon icon() const
0096     {
0097         if (!m_icon.isNull()) {
0098             return m_icon;
0099         }
0100 
0101         if (m_type == Directory) {
0102             m_icon = QIcon::fromTheme(QStringLiteral("folder"));
0103         } else if (m_type == File) {
0104             QIcon icon = QIcon::fromTheme(QMimeDatabase().mimeTypeForFile(data(Path).toString(), QMimeDatabase::MatchExtension).iconName());
0105             if (icon.isNull()) {
0106                 icon = QIcon::fromTheme(QStringLiteral("unknown"));
0107             }
0108             m_icon = icon;
0109         } else {
0110             Q_UNREACHABLE();
0111         }
0112         return m_icon;
0113     }
0114 
0115 private:
0116     const Type m_type;
0117     mutable QIcon m_icon;
0118 };
0119 
0120 class DiffStyleDelegate : public QStyledItemDelegate
0121 {
0122 public:
0123     explicit DiffStyleDelegate(QObject *parent)
0124         : QStyledItemDelegate(parent)
0125     {
0126     }
0127 
0128     void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override
0129     {
0130         if (index.data(FileItem::TypeRole).toInt() == FileItem::Directory) {
0131             QStyledItemDelegate::paint(painter, option, index);
0132             return;
0133         }
0134 
0135         QStyleOptionViewItem options = option;
0136         initStyleOption(&options, index);
0137 
0138         painter->save();
0139 
0140         // paint background
0141         if (option.state & QStyle::State_Selected) {
0142             painter->fillRect(option.rect, option.palette.highlight());
0143         } else {
0144             painter->fillRect(option.rect, option.palette.base());
0145         }
0146 
0147         int add = index.data(FileItem::LinesAdded).toInt();
0148         int sub = index.data(FileItem::LinesRemoved).toInt();
0149         QString adds = QString(QStringLiteral("+") + QString::number(add));
0150         QString subs = QString(QStringLiteral(" -") + QString::number(sub));
0151         QString file = options.text;
0152 
0153         options.text = QString(); // clear old text
0154         options.widget->style()->drawControl(QStyle::CE_ItemViewItem, &options, painter, options.widget);
0155 
0156         QRect r = options.rect;
0157 
0158         // don't draw over icon
0159         r.setX(r.x() + option.decorationSize.width() + 5);
0160 
0161         const QFontMetrics &fm = options.fontMetrics;
0162 
0163         // adds width
0164         int aw = fm.horizontalAdvance(adds);
0165         // subs width
0166         int sw = fm.horizontalAdvance(subs);
0167 
0168         // subtract this from total width of rect
0169         int totalw = r.width();
0170         totalw = totalw - (aw + sw);
0171 
0172         // get file name, elide if necessary
0173         QString filename = fm.elidedText(file, Qt::ElideRight, totalw);
0174 
0175         painter->drawText(r, Qt::AlignVCenter, filename);
0176 
0177         KColorScheme c;
0178         const auto red = c.shade(c.foreground(KColorScheme::NegativeText).color(), KColorScheme::MidlightShade, 1);
0179         const auto green = c.shade(c.foreground(KColorScheme::PositiveText).color(), KColorScheme::MidlightShade, 1);
0180 
0181         r.setX(r.x() + totalw);
0182         painter->setPen(green);
0183         painter->drawText(r, Qt::AlignVCenter, adds);
0184 
0185         painter->setPen(red);
0186         r.setX(r.x() + aw);
0187         painter->drawText(r, Qt::AlignVCenter, subs);
0188 
0189         painter->restore();
0190     }
0191 };
0192 
0193 // Copied from KateProjectWorker
0194 static QStandardItem *directoryParent(const QDir &base, QHash<QString, QStandardItem *> &dir2Item, QString path)
0195 {
0196     /**
0197      * throw away simple /
0198      */
0199     if (path == QLatin1String("/")) {
0200         path = QString();
0201     }
0202 
0203     /**
0204      * quick check: dir already seen?
0205      */
0206     const auto existingIt = dir2Item.find(path);
0207     if (existingIt != dir2Item.end()) {
0208         return existingIt.value();
0209     }
0210 
0211     /**
0212      * else: construct recursively
0213      */
0214     const int slashIndex = path.lastIndexOf(QLatin1Char('/'));
0215 
0216     /**
0217      * no slash?
0218      * simple, no recursion, append new item toplevel
0219      */
0220     if (slashIndex < 0) {
0221         const auto item = new FileItem(FileItem::Directory, path);
0222         item->setData(base.absoluteFilePath(path), Qt::UserRole);
0223         dir2Item[path] = item;
0224         dir2Item[QString()]->appendRow(item);
0225         return item;
0226     }
0227 
0228     /**
0229      * else, split and recurse
0230      */
0231     const QString leftPart = path.left(slashIndex);
0232     const QString rightPart = path.right(path.size() - (slashIndex + 1));
0233 
0234     /**
0235      * special handling if / with nothing on one side are found
0236      */
0237     if (leftPart.isEmpty() || rightPart.isEmpty()) {
0238         return directoryParent(base, dir2Item, leftPart.isEmpty() ? rightPart : leftPart);
0239     }
0240 
0241     /**
0242      * else: recurse on left side
0243      */
0244     const auto item = new FileItem(FileItem::Directory, rightPart);
0245     item->setData(base.absoluteFilePath(path), Qt::UserRole);
0246     dir2Item[path] = item;
0247     directoryParent(base, dir2Item, leftPart)->appendRow(item);
0248     return item;
0249 }
0250 
0251 // Copied from CompareBranchView in KateProject plugin
0252 static void createFileTree(QStandardItem *parent, const QString &basePath, const std::vector<GitFileItem> &files)
0253 {
0254     QDir dir(basePath);
0255     const QString dirPath = dir.path() + QLatin1Char('/');
0256     QHash<QString, QStandardItem *> dir2Item;
0257     dir2Item[QString()] = parent;
0258     for (const auto &file : files) {
0259         const QString filePath = QString::fromUtf8(file.file);
0260         /**
0261          * cheap file name computation
0262          * we do this A LOT, QFileInfo is very expensive just for this operation
0263          */
0264         const int slashIndex = filePath.lastIndexOf(QLatin1Char('/'));
0265         const QString fileName = (slashIndex < 0) ? filePath : filePath.mid(slashIndex + 1);
0266         const QString filePathName = (slashIndex < 0) ? QString() : filePath.left(slashIndex);
0267         const QString fullFilePath = dirPath + filePath;
0268 
0269         /**
0270          * construct the item with right directory prefix
0271          * already hang in directories in tree
0272          */
0273         FileItem *fileItem = new FileItem(FileItem::File, fileName);
0274         fileItem->setData(fullFilePath, FileItem::Path);
0275         fileItem->setData(file.linesAdded, FileItem::LinesAdded);
0276         fileItem->setData(file.linesRemoved, FileItem::LinesRemoved);
0277 
0278         // put in our item to the right directory parent
0279         directoryParent(dir, dir2Item, filePathName)->appendRow(fileItem);
0280     }
0281 }
0282 
0283 static bool getNum(const QByteArray &numBytes, int *num)
0284 {
0285     bool res = false;
0286     *num = numBytes.toInt(&res);
0287     return res;
0288 }
0289 
0290 static void parseNumStat(const QByteArray &raw, std::vector<GitFileItem> *items)
0291 {
0292     const auto lines = raw.split(0x00);
0293     for (const auto &line : lines) {
0294         // format: 12(adds)\t10(subs)\tFileName
0295         const auto cols = line.split('\t');
0296         if (cols.length() < 3) {
0297             continue;
0298         }
0299 
0300         int add = 0;
0301         if (!getNum(cols.at(0), &add)) {
0302             continue;
0303         }
0304         int sub = 0;
0305         if (!getNum(cols.at(1), &sub)) {
0306             continue;
0307         }
0308 
0309         const auto file = cols.at(2);
0310 
0311         items->push_back(GitFileItem{file, add, sub});
0312     }
0313 }
0314 
0315 class CommitDiffTreeView : public QWidget
0316 {
0317     Q_OBJECT
0318 public:
0319     explicit CommitDiffTreeView(const QString &repoBase, const QString &hash, KTextEditor::MainWindow *mainWindow, QWidget *parent);
0320 
0321     /**
0322      * open treeview for commit with @p hash
0323      * @filePath can be path of any file in the repo
0324      */
0325     void openCommit(const QString &filePath);
0326 
0327     Q_SIGNAL void showDiffRequested(const QByteArray &diffContents, const QString &file);
0328 
0329 public:
0330     void createFileTreeForCommit(const QByteArray &rawNumStat);
0331 
0332 private Q_SLOTS:
0333     void showDiff(const QModelIndex &idx);
0334     void openContextMenu(QPoint pos);
0335 
0336 private:
0337     KTextEditor::MainWindow *m_mainWindow;
0338     QToolButton m_backBtn;
0339     QTreeView m_tree;
0340     QStandardItemModel m_model;
0341     QString m_gitDir;
0342     QString m_commitHash;
0343 };
0344 
0345 CommitDiffTreeView::CommitDiffTreeView(const QString &repoBase, const QString &hash, KTextEditor::MainWindow *mainWindow, QWidget *parent)
0346     : QWidget(parent)
0347     , m_mainWindow(mainWindow)
0348     , m_gitDir(repoBase)
0349 {
0350     setLayout(new QVBoxLayout);
0351     layout()->setContentsMargins({});
0352     layout()->setSpacing(0);
0353 
0354     m_backBtn.setText(i18n("Close"));
0355     m_backBtn.setIcon(QIcon::fromTheme(QStringLiteral("tab-close")));
0356     m_backBtn.setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
0357     m_backBtn.setAutoRaise(true);
0358     m_backBtn.setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Preferred);
0359     connect(&m_backBtn, &QAbstractButton::clicked, this, [parent, mainWindow] {
0360         Q_ASSERT(parent);
0361         parent->deleteLater();
0362         mainWindow->hideToolView(parent);
0363     });
0364     layout()->addWidget(&m_backBtn);
0365 
0366     m_tree.setModel(&m_model);
0367     layout()->addWidget(&m_tree);
0368 
0369     m_tree.setHeaderHidden(true);
0370     m_tree.setEditTriggers(QTreeView::NoEditTriggers);
0371     m_tree.setItemDelegate(new DiffStyleDelegate(this));
0372     m_tree.setIndentation(10);
0373     m_tree.setProperty("_breeze_borders_sides", QVariant::fromValue(QFlags{Qt::TopEdge}));
0374 
0375     m_tree.setContextMenuPolicy(Qt::CustomContextMenu);
0376     connect(&m_tree, &QTreeView::customContextMenuRequested, this, &CommitDiffTreeView::openContextMenu);
0377 
0378     connect(&m_tree, &QTreeView::clicked, this, &CommitDiffTreeView::showDiff);
0379     openCommit(hash);
0380 }
0381 
0382 void CommitDiffTreeView::openContextMenu(QPoint pos)
0383 {
0384     const auto idx = m_tree.indexAt(pos);
0385     if (!idx.isValid()) {
0386         return;
0387     }
0388 
0389     const auto file = idx.data(FileItem::Path).toString();
0390     QFileInfo fi(file);
0391 
0392     QMenu menu(this);
0393     if (fi.exists() && fi.isFile()) {
0394         menu.addAction(i18n("Open File"), this, [this, fi] {
0395             m_mainWindow->openUrl(QUrl::fromLocalFile(fi.absoluteFilePath()));
0396         });
0397     }
0398 
0399     if (qApp->clipboard()) {
0400         menu.addAction(i18n("Copy Location"), this, [fi] {
0401             qApp->clipboard()->setText(fi.absoluteFilePath());
0402         });
0403     }
0404 
0405     menu.exec(m_tree.viewport()->mapToGlobal(pos));
0406 }
0407 
0408 void CommitDiffTreeView::openCommit(const QString &hash)
0409 {
0410     m_commitHash = hash;
0411 
0412     QProcess *git = new QProcess(this);
0413     if (!setupGitProcess(*git,
0414                          m_gitDir,
0415                          {QStringLiteral("show"), hash, QStringLiteral("--numstat"), QStringLiteral("--pretty=oneline"), QStringLiteral("-z")})) {
0416         delete git;
0417         return;
0418     }
0419     connect(git, &QProcess::finished, this, [this, git](int e, QProcess::ExitStatus s) {
0420         git->deleteLater();
0421         if (e != 0 || s != QProcess::NormalExit) {
0422             Utils::showMessage(QString::fromUtf8(git->readAllStandardError()), gitIcon(), i18n("Git"), MessageType::Error, m_mainWindow);
0423             m_backBtn.click();
0424             return;
0425         }
0426         auto contents = git->readAllStandardOutput();
0427         int firstNull = contents.indexOf(char(0x00));
0428         if (firstNull == -1) {
0429             return;
0430         }
0431         QByteArray numstat = contents.mid(firstNull + 1);
0432         createFileTreeForCommit(numstat);
0433     });
0434     startHostProcess(*git);
0435 }
0436 
0437 void CommitDiffTreeView::createFileTreeForCommit(const QByteArray &rawNumStat)
0438 {
0439     QStandardItem root;
0440     std::vector<GitFileItem> items;
0441     parseNumStat(rawNumStat, &items);
0442     createFileTree(&root, m_gitDir, items);
0443 
0444     // Remove nodes that have only one item. i.e.,
0445     // - kate
0446     // -- addons
0447     //    -- file 1
0448     // kate will be removed since it has only item
0449     // The tree will start from addons instead.
0450     QList<QStandardItem *> tree = root.takeColumn(0);
0451     while (tree.size() == 1) {
0452         auto subRoot = tree.takeFirst();
0453         auto subTree = subRoot->takeColumn(0);
0454 
0455         // if its just one file
0456         if (subTree.isEmpty()) {
0457             tree.append(subRoot);
0458             break;
0459         }
0460 
0461         if (subTree.size() > 1) {
0462             tree.append(subRoot);
0463             subRoot->insertColumn(0, subTree);
0464             break;
0465         } else {
0466             // Is the only child of this node a "File" item?
0467             if (subTree.first()->data(FileItem::TypeRole).toInt() == FileItem::File) {
0468                 subRoot->insertColumn(0, subTree);
0469                 tree.append(subRoot);
0470                 break;
0471             }
0472 
0473             delete subRoot;
0474             tree = subTree;
0475         }
0476     }
0477 
0478     m_model.clear();
0479     m_model.invisibleRootItem()->appendColumn(tree);
0480 
0481     m_tree.expandAll();
0482 }
0483 
0484 void CommitDiffTreeView::showDiff(const QModelIndex &idx)
0485 {
0486     const QString file = idx.data(FileItem::Path).toString();
0487     QProcess git;
0488     if (!setupGitProcess(git, m_gitDir, {QStringLiteral("show"), m_commitHash, QStringLiteral("--"), file})) {
0489         return;
0490     }
0491     startHostProcess(git, QProcess::ReadOnly);
0492 
0493     if (git.waitForStarted() && git.waitForFinished(-1)) {
0494         if (git.exitStatus() != QProcess::NormalExit || git.exitCode() != 0) {
0495             return;
0496         }
0497     }
0498 
0499     DiffParams d;
0500     d.srcFile = file;
0501     d.flags.setFlag(DiffParams::ShowCommitInfo);
0502     d.workingDir = m_gitDir;
0503     if (m_tree.model()->rowCount(idx) > 1) {
0504         d.flags.setFlag(DiffParams::ShowFileName);
0505     }
0506     Utils::showDiff(git.readAllStandardOutput(), d, m_mainWindow);
0507 }
0508 
0509 void CommitView::openCommit(const QString &hash, const QString &path, KTextEditor::MainWindow *mainWindow)
0510 {
0511     QFileInfo fi(path);
0512     if (!fi.exists()) {
0513         qWarning() << "Unexpected non-existent file: " << path;
0514         return;
0515     }
0516 
0517     if (hash.length() < 7) {
0518         Utils::showMessage(i18n("Invalid hash"), gitIcon(), i18n("Git"), MessageType::Error, mainWindow);
0519         return;
0520     }
0521 
0522     const auto repoBase = getRepoBasePath(fi.absolutePath());
0523     if (!repoBase.has_value()) {
0524         Utils::showMessage(i18n("%1 doesn't exist in a git repo.", path), gitIcon(), i18n("Git"), MessageType::Error, mainWindow);
0525         return;
0526     }
0527 
0528     if (!mainWindow) {
0529         mainWindow = KTextEditor::Editor::instance()->application()->activeMainWindow();
0530     }
0531 
0532     QWidget *toolView = Utils::toolviewForName(mainWindow, QStringLiteral("git_commit_view_%1").arg(hash));
0533     if (!toolView) {
0534         const auto icon = QIcon::fromTheme(QStringLiteral("vcs-commit"));
0535         toolView = mainWindow->createToolView(nullptr,
0536                                               QStringLiteral("git_commit_view_%1").arg(hash),
0537                                               KTextEditor::MainWindow::Left,
0538                                               icon,
0539                                               i18nc("@title:tab", "Commit %1", hash.mid(0, 7)));
0540         new CommitDiffTreeView(repoBase.value(), hash, mainWindow, toolView);
0541     }
0542     mainWindow->showToolView(toolView);
0543 }
0544 
0545 #include "commitfilesview.moc"