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

0001 /*
0002     SPDX-FileCopyrightText: 2021 Waqar Ahmed <waqar.17a@gmail.com>
0003 
0004     SPDX-License-Identifier: LGPL-2.0-or-later
0005 */
0006 
0007 #include "filehistorywidget.h"
0008 #include "commitfilesview.h"
0009 #include "diffparams.h"
0010 #include "hostprocess.h"
0011 #include "ktexteditor_utils.h"
0012 #include <bytearraysplitter.h>
0013 #include <gitprocess.h>
0014 
0015 #include <QAction>
0016 #include <QApplication>
0017 #include <QClipboard>
0018 #include <QDate>
0019 #include <QDebug>
0020 #include <QFileInfo>
0021 #include <QListView>
0022 #include <QMenu>
0023 #include <QPainter>
0024 #include <QPointer>
0025 #include <QProcess>
0026 #include <QStyledItemDelegate>
0027 #include <QToolButton>
0028 #include <QVBoxLayout>
0029 #include <QWidget>
0030 
0031 #include <KLocalizedString>
0032 #include <KTextEditor/Application>
0033 #include <KTextEditor/Editor>
0034 #include <KTextEditor/MainWindow>
0035 
0036 struct Commit {
0037     QByteArray hash;
0038     QString authorName;
0039     QString email;
0040     qint64 authorDate;
0041     qint64 commitDate;
0042     QByteArray parentHash;
0043     QString msg;
0044     QByteArray fileName;
0045 };
0046 Q_DECLARE_METATYPE(Commit)
0047 
0048 static std::vector<Commit> parseCommits(const QByteArray raw)
0049 {
0050     std::vector<Commit> commits;
0051 
0052     const auto splitted = ByteArraySplitter(raw, '\0');
0053     for (auto it = splitted.begin(); it != splitted.end(); ++it) {
0054         const auto commitDetails = *it;
0055         if (commitDetails.empty()) {
0056             continue;
0057         }
0058 
0059         const auto lines = ByteArraySplitter(commitDetails, '\n').toContainer<QVarLengthArray<strview, 7>>();
0060         if (lines.length() < 7) {
0061             continue;
0062         }
0063 
0064         QByteArray hash = lines.at(0).toByteArray();
0065         QString author = lines.at(1).toString();
0066         QString email = lines.at(2).toString();
0067 
0068         auto authDate = lines.at(3).to<qint64>();
0069         if (!authDate.has_value()) {
0070             continue;
0071         }
0072         qint64 authorDate = authDate.value();
0073 
0074         auto commtDate = lines.at(4).to<qint64>();
0075         if (!commtDate.has_value()) {
0076             continue;
0077         }
0078         qint64 commitDate = commtDate.value();
0079 
0080         QByteArray parent = lines.at(5).toByteArray();
0081         QString msg = lines.at(6).toString();
0082 
0083         QByteArray file;
0084         ++it;
0085         if (it != splitted.end()) {
0086             file = (*it).toByteArray().trimmed();
0087         }
0088 
0089         Commit c{hash, author, email, authorDate, commitDate, parent, msg, file};
0090         commits.push_back(std::move(c));
0091     }
0092 
0093     return commits;
0094 }
0095 
0096 class CommitListModel : public QAbstractListModel
0097 {
0098 public:
0099     CommitListModel(QObject *parent = nullptr)
0100         : QAbstractListModel(parent)
0101     {
0102     }
0103 
0104     enum Role { CommitRole = Qt::UserRole + 1 };
0105 
0106     int rowCount(const QModelIndex &) const override
0107     {
0108         return (int)m_rows.size();
0109     }
0110 
0111     QVariant data(const QModelIndex &index, int role) const override
0112     {
0113         if (!index.isValid()) {
0114             return {};
0115         }
0116         auto row = index.row();
0117         switch (role) {
0118         case Role::CommitRole:
0119             return QVariant::fromValue(m_rows.at(row));
0120         case Qt::ToolTipRole: {
0121             QString ret = m_rows.at(row).authorName + QStringLiteral("<br>") + m_rows.at(row).email;
0122             return ret;
0123         }
0124         }
0125 
0126         return {};
0127     }
0128 
0129     void addCommits(std::vector<Commit> &&cmts)
0130     {
0131         beginInsertRows(QModelIndex(), (int)m_rows.size(), (int)m_rows.size() + (int)cmts.size() - 1);
0132         m_rows.insert(m_rows.end(), std::make_move_iterator(cmts.begin()), std::make_move_iterator(cmts.end()));
0133         endInsertRows();
0134     }
0135 
0136 private:
0137     std::vector<Commit> m_rows;
0138 };
0139 
0140 class CommitDelegate : public QStyledItemDelegate
0141 {
0142 public:
0143     CommitDelegate(QObject *parent)
0144         : QStyledItemDelegate(parent)
0145     {
0146     }
0147 
0148     void paint(QPainter *painter, const QStyleOptionViewItem &opt, const QModelIndex &index) const override
0149     {
0150         auto commit = index.data(CommitListModel::CommitRole).value<Commit>();
0151         if (commit.hash.isEmpty()) {
0152             return;
0153         }
0154 
0155         QStyleOptionViewItem options = opt;
0156         initStyleOption(&options, index);
0157 
0158         options.text = QString();
0159         QStyledItemDelegate::paint(painter, options, index);
0160 
0161         constexpr int lineHeight = 2;
0162         QFontMetrics fm = opt.fontMetrics;
0163 
0164         QRect prect = opt.rect;
0165 
0166         // padding
0167         prect.setX(prect.x() + 5);
0168         prect.setY(prect.y() + lineHeight);
0169 
0170         // draw author on left
0171         QFont f = opt.font;
0172         f.setBold(true);
0173         painter->setFont(f);
0174         painter->drawText(prect, Qt::AlignLeft, commit.authorName);
0175         painter->setFont(opt.font);
0176 
0177         // draw author date on right
0178         auto dt = QDateTime::fromSecsSinceEpoch(commit.authorDate);
0179         QLocale l;
0180         const bool isToday = dt.date() == QDate::currentDate();
0181         QString timestamp = isToday ? l.toString(dt.time(), QLocale::ShortFormat) : l.toString(dt.date(), QLocale::ShortFormat);
0182         painter->drawText(prect, Qt::AlignRight, timestamp);
0183 
0184         // draw commit hash
0185         auto fg = painter->pen();
0186         painter->setPen(Qt::gray);
0187         prect.setY(prect.y() + fm.height() + lineHeight);
0188         painter->drawText(prect, Qt::AlignLeft, QString::fromUtf8(commit.hash.left(7)));
0189         painter->setPen(fg);
0190 
0191         // draw msg
0192         prect.setY(prect.y() + fm.height() + lineHeight);
0193         auto elidedMsg = opt.fontMetrics.elidedText(commit.msg, Qt::ElideRight, prect.width());
0194         painter->drawText(prect, Qt::AlignLeft, elidedMsg);
0195 
0196         // draw separator
0197         painter->setPen(opt.palette.button().color());
0198         painter->drawLine(opt.rect.bottomLeft(), opt.rect.bottomRight());
0199         painter->setPen(fg);
0200     }
0201 
0202     QSize sizeHint(const QStyleOptionViewItem &opt, const QModelIndex &) const override
0203     {
0204         auto height = opt.fontMetrics.height();
0205         return QSize(0, height * 3 + (3 * 2));
0206     }
0207 };
0208 
0209 class FileHistoryWidget : public QWidget
0210 {
0211     Q_OBJECT
0212 public:
0213     void itemClicked(const QModelIndex &idx);
0214     void onContextMenu(QPoint pos);
0215 
0216     void getFileHistory(const QString &file);
0217     explicit FileHistoryWidget(const QString &gitDir, const QString &file, KTextEditor::MainWindow *mw, QWidget *parent = nullptr);
0218     ~FileHistoryWidget() override;
0219 
0220     QToolButton m_backBtn;
0221     QListView *m_listView;
0222     QProcess m_git;
0223     const QString m_file;
0224     const QString m_gitDir;
0225     const QPointer<QWidget> m_toolView;
0226     const QPointer<KTextEditor::MainWindow> m_mainWindow;
0227 
0228 Q_SIGNALS:
0229     void backClicked();
0230     void errorMessage(const QString &msg, bool warn);
0231 };
0232 
0233 FileHistoryWidget::FileHistoryWidget(const QString &gitDir, const QString &file, KTextEditor::MainWindow *mw, QWidget *parent)
0234     : QWidget(parent)
0235     , m_file(file)
0236     , m_gitDir(gitDir)
0237     , m_toolView(parent)
0238     , m_mainWindow(mw)
0239 {
0240     auto model = new CommitListModel(this);
0241     m_listView = new QListView;
0242     m_listView->setModel(model);
0243     m_listView->setProperty("_breeze_borders_sides", QVariant::fromValue(QFlags{Qt::TopEdge}));
0244     getFileHistory(file);
0245 
0246     setLayout(new QVBoxLayout);
0247     layout()->setContentsMargins({});
0248     layout()->setSpacing(0);
0249 
0250     m_backBtn.setText(i18n("Close"));
0251     m_backBtn.setIcon(QIcon::fromTheme(QStringLiteral("tab-close")));
0252     m_backBtn.setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
0253     m_backBtn.setAutoRaise(true);
0254     m_backBtn.setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Preferred);
0255     connect(&m_backBtn, &QAbstractButton::clicked, this, [this] {
0256         deleteLater();
0257         m_mainWindow->hideToolView(m_toolView);
0258         m_toolView->deleteLater();
0259     });
0260     connect(m_listView, &QListView::clicked, this, &FileHistoryWidget::itemClicked);
0261     connect(m_listView, &QListView::activated, this, &FileHistoryWidget::itemClicked);
0262 
0263     m_listView->setItemDelegate(new CommitDelegate(this));
0264     m_listView->setContextMenuPolicy(Qt::CustomContextMenu);
0265     connect(m_listView, &QListView::customContextMenuRequested, this, &FileHistoryWidget::onContextMenu);
0266 
0267     layout()->addWidget(&m_backBtn);
0268     layout()->addWidget(m_listView);
0269 }
0270 
0271 FileHistoryWidget::~FileHistoryWidget()
0272 {
0273     m_git.kill();
0274     m_git.waitForFinished();
0275 }
0276 
0277 // git log --format=%H%n%aN%n%aE%n%at%n%ct%n%P%n%B --author-date-order
0278 void FileHistoryWidget::getFileHistory(const QString &file)
0279 {
0280     if (!setupGitProcess(m_git,
0281                          m_gitDir,
0282                          {QStringLiteral("log"),
0283                           QStringLiteral("--follow"), // get history accross renames
0284                           QStringLiteral("--name-only"), // get file name also, it could be different if renamed
0285                           QStringLiteral("--format=%H%n%aN%n%aE%n%at%n%ct%n%P%n%B"),
0286                           QStringLiteral("-z"),
0287                           file})) {
0288         Q_EMIT errorMessage(i18n("Failed to get file history: git executable not found in PATH"), true);
0289         return;
0290     }
0291 
0292     connect(&m_git, &QProcess::readyReadStandardOutput, this, [this] {
0293         std::vector<Commit> commits = parseCommits(m_git.readAllStandardOutput());
0294         if (!commits.empty()) {
0295             static_cast<CommitListModel *>(m_listView->model())->addCommits(std::move(commits));
0296         }
0297     });
0298 
0299     connect(&m_git, &QProcess::finished, this, [this](int exitCode, QProcess::ExitStatus s) {
0300         if (exitCode != 0 || s != QProcess::NormalExit) {
0301             Q_EMIT errorMessage(i18n("Failed to get file history: %1", QString::fromUtf8(m_git.readAllStandardError())), true);
0302         }
0303     });
0304 
0305     startHostProcess(m_git, QProcess::ReadOnly);
0306 }
0307 
0308 void FileHistoryWidget::onContextMenu(QPoint pos)
0309 {
0310     QMenu menu(m_listView->viewport());
0311 
0312     menu.addAction(i18nc("@menu:action", "Copy Commit Hash"), this, [this, pos] {
0313         const auto index = m_listView->indexAt(pos);
0314         const auto commit = index.data(CommitListModel::CommitRole).value<Commit>();
0315         if (!commit.hash.isEmpty()) {
0316             qApp->clipboard()->setText(QString::fromLatin1(commit.hash));
0317         }
0318     });
0319 
0320     menu.addAction(i18nc("@menu:action", "Show Full Commit"), this, [this, pos] {
0321         const auto index = m_listView->indexAt(pos);
0322         const auto commit = index.data(CommitListModel::CommitRole).value<Commit>();
0323         if (!commit.hash.isEmpty()) {
0324             const QString hash = QString::fromLatin1(commit.hash);
0325             CommitView::openCommit(hash, m_file, m_mainWindow);
0326         }
0327     });
0328 
0329     menu.exec(m_listView->viewport()->mapToGlobal(pos));
0330 }
0331 
0332 void FileHistoryWidget::itemClicked(const QModelIndex &idx)
0333 {
0334     QProcess git;
0335 
0336     const auto commit = idx.data(CommitListModel::CommitRole).value<Commit>();
0337     const QString file = QString::fromUtf8(commit.fileName);
0338 
0339     if (!setupGitProcess(git, m_gitDir, {QStringLiteral("show"), QString::fromUtf8(commit.hash), QStringLiteral("--"), file})) {
0340         return;
0341     }
0342 
0343     startHostProcess(git, QProcess::ReadOnly);
0344     if (git.waitForStarted() && git.waitForFinished(-1)) {
0345         if (git.exitStatus() != QProcess::NormalExit || git.exitCode() != 0) {
0346             return;
0347         }
0348         const QByteArray contents(git.readAllStandardOutput());
0349 
0350         DiffParams d;
0351         const QString shortCommit = QString::fromUtf8(commit.hash.mid(0, 7));
0352         d.tabTitle = QStringLiteral("%1[%2]").arg(Utils::fileNameFromPath(m_file), shortCommit);
0353         d.flags.setFlag(DiffParams::ShowCommitInfo);
0354         d.arguments = git.arguments();
0355         d.workingDir = m_gitDir;
0356         Utils::showDiff(contents, d, m_mainWindow);
0357     }
0358 }
0359 
0360 void FileHistory::showFileHistory(const QString &file, KTextEditor::MainWindow *mainWindow)
0361 {
0362     QFileInfo fi(file);
0363     if (!fi.exists()) {
0364         qWarning() << "Unexpected non-existent file: " << file;
0365         return;
0366     }
0367 
0368     const auto repoBase = getRepoBasePath(fi.absolutePath());
0369     if (!repoBase.has_value()) {
0370         Utils::showMessage(i18n("%1 doesn't exist in a git repo.", file), gitIcon(), i18n("Git"), MessageType::Error, mainWindow);
0371         return;
0372     }
0373 
0374     if (!mainWindow) {
0375         mainWindow = KTextEditor::Editor::instance()->application()->activeMainWindow();
0376     }
0377 
0378     const QString identifier = QStringLiteral("git_file_history_%1").arg(file);
0379     const QString title = i18nc("@title:tab", "File History - %1", fi.fileName());
0380     auto toolView = Utils::toolviewForName(mainWindow, identifier);
0381     if (!toolView) {
0382         toolView = mainWindow->createToolView(nullptr, identifier, KTextEditor::MainWindow::Left, gitIcon(), title);
0383         new FileHistoryWidget(repoBase.value(), file, mainWindow, toolView);
0384     }
0385     mainWindow->showToolView(toolView);
0386 }
0387 
0388 #include "filehistorywidget.moc"