File indexing completed on 2024-05-12 09:55:38
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"