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"