File indexing completed on 2024-04-28 05:48:35
0001 /* 0002 SPDX-FileCopyrightText: 2021 Kåre Särs <kare.sars@iki.fi> 0003 0004 SPDX-License-Identifier: LGPL-2.0-or-later 0005 */ 0006 0007 #include "kategitblameplugin.h" 0008 #include "ktexteditor_utils.h" 0009 0010 #include "hostprocess.h" 0011 #include <commitfilesview.h> 0012 #include <gitprocess.h> 0013 0014 #include <KActionCollection> 0015 #include <KLocalizedString> 0016 #include <KPluginFactory> 0017 #include <KSharedConfig> 0018 #include <KXMLGUIFactory> 0019 0020 #include <KTextEditor/Document> 0021 #include <KTextEditor/Editor> 0022 #include <KTextEditor/View> 0023 0024 #include <QFileInfo> 0025 #include <QFontMetrics> 0026 #include <QKeySequence> 0027 #include <QLayout> 0028 #include <QPainter> 0029 #include <QUrl> 0030 #include <QVariant> 0031 #include <QtMath> 0032 0033 static bool isUncomittedLine(const QByteArray &hash) 0034 { 0035 return hash == "hash" || hash == "0000000000000000000000000000000000000000"; 0036 } 0037 0038 GitBlameInlineNoteProvider::GitBlameInlineNoteProvider(KateGitBlamePluginView *pluginView) 0039 : KTextEditor::InlineNoteProvider() 0040 , m_pluginView(pluginView) 0041 { 0042 } 0043 0044 GitBlameInlineNoteProvider::~GitBlameInlineNoteProvider() 0045 { 0046 if (m_pluginView->activeView()) { 0047 m_pluginView->activeView()->unregisterInlineNoteProvider(this); 0048 } 0049 } 0050 0051 QList<int> GitBlameInlineNoteProvider::inlineNotes(int line) const 0052 { 0053 if (!m_pluginView->hasBlameInfo()) { 0054 return QList<int>(); 0055 } 0056 0057 QPointer<KTextEditor::Document> doc = m_pluginView->activeDocument(); 0058 if (!doc) { 0059 return QList<int>(); 0060 } 0061 0062 if (m_mode == KateGitBlameMode::None) { 0063 return {}; 0064 } 0065 0066 int lineLen = doc->line(line).size(); 0067 QPointer<KTextEditor::View> view = m_pluginView->activeView(); 0068 if (view->cursorPosition().line() == line || m_mode == KateGitBlameMode::AllLines) { 0069 return QList<int>{lineLen + 4}; 0070 } 0071 return QList<int>(); 0072 } 0073 0074 QSize GitBlameInlineNoteProvider::inlineNoteSize(const KTextEditor::InlineNote ¬e) const 0075 { 0076 return QSize(note.lineHeight() * 50, note.lineHeight()); 0077 } 0078 0079 void GitBlameInlineNoteProvider::paintInlineNote(const KTextEditor::InlineNote ¬e, QPainter &painter, Qt::LayoutDirection dir) const 0080 { 0081 QFont font = note.font(); 0082 painter.setFont(font); 0083 const QFontMetrics fm(note.font()); 0084 0085 int lineNr = note.position().line(); 0086 const CommitInfo &info = m_pluginView->blameInfo(lineNr); 0087 0088 bool isToday = info.authorDate.date() == QDate::currentDate(); 0089 QString date = 0090 isToday ? m_locale.toString(info.authorDate.time(), QLocale::NarrowFormat) : m_locale.toString(info.authorDate.date(), QLocale::NarrowFormat); 0091 0092 QString text = info.summary.isEmpty() 0093 ? i18nc("git-blame information \"author: date \"", " %1: %2 ", info.authorName, date) 0094 : i18nc("git-blame information \"author: date: commit title \"", " %1: %2: %3 ", info.authorName, date, QString::fromUtf8(info.summary)); 0095 QRect rectangle{0, 0, fm.horizontalAdvance(text), note.lineHeight()}; 0096 bool isRTL = false; 0097 isRTL = dir == Qt::RightToLeft; 0098 0099 if (isRTL) { 0100 const qreal horizontalTx = painter.worldTransform().m31(); 0101 // the amount of translation by x is from start of view (0,0) i.e., its the max 0102 // amount of space we can use in rtl. 0103 const int availableWidth = qFloor(horizontalTx); 0104 const auto rectWidth = rectangle.width(); 0105 // the painter is translated to be at the end of line, move it left so that it is 0106 // in front of the RTL line 0107 const int moveBy = -(qAbs(rectWidth), qAbs(availableWidth)); 0108 rectangle.moveLeft(moveBy); 0109 if (rectWidth > availableWidth) { 0110 // reduce the width to available width 0111 rectangle.setWidth(availableWidth); 0112 // elide the text in the middle 0113 text = painter.fontMetrics().elidedText(text, Qt::ElideMiddle, availableWidth); 0114 } 0115 } 0116 0117 auto editor = KTextEditor::Editor::instance(); 0118 auto color = QColor::fromRgba(editor->theme().textColor(KSyntaxHighlighting::Theme::Normal)); 0119 color.setAlpha(0); 0120 painter.setPen(color); 0121 color.setAlpha(8); 0122 painter.setBrush(color); 0123 painter.drawRect(rectangle); 0124 0125 color.setAlpha(note.underMouse() ? 130 : 90); 0126 painter.setPen(color); 0127 painter.setBrush(color); 0128 painter.drawText(rectangle, Qt::AlignLeft | Qt::AlignVCenter, text); 0129 } 0130 0131 void GitBlameInlineNoteProvider::inlineNoteActivated(const KTextEditor::InlineNote ¬e, Qt::MouseButtons buttons, const QPoint &) 0132 { 0133 if ((buttons & Qt::LeftButton) != 0) { 0134 int lineNr = note.position().line(); 0135 const CommitInfo &info = m_pluginView->blameInfo(lineNr); 0136 0137 if (isUncomittedLine(info.hash)) { 0138 return; 0139 } 0140 0141 // Hack: view->mainWindow()->view() to de-constify view 0142 Q_ASSERT(note.view() == m_pluginView->activeView()); 0143 m_pluginView->showCommitInfo(QString::fromUtf8(info.hash), note.view()->mainWindow()->activeView()); 0144 } 0145 } 0146 0147 void GitBlameInlineNoteProvider::cycleMode() 0148 { 0149 int newMode = (int)m_mode + 1; 0150 if (newMode > (int)KateGitBlameMode::Count) { 0151 newMode = 0; 0152 } 0153 setMode(KateGitBlameMode(newMode)); 0154 } 0155 0156 void GitBlameInlineNoteProvider::setMode(KateGitBlameMode mode) 0157 { 0158 m_mode = mode; 0159 Q_EMIT inlineNotesReset(); 0160 } 0161 0162 K_PLUGIN_FACTORY_WITH_JSON(KateGitBlamePluginFactory, "kategitblameplugin.json", registerPlugin<KateGitBlamePlugin>();) 0163 0164 KateGitBlamePlugin::KateGitBlamePlugin(QObject *parent, const QVariantList &) 0165 : KTextEditor::Plugin(parent) 0166 { 0167 } 0168 0169 QObject *KateGitBlamePlugin::createView(KTextEditor::MainWindow *mainWindow) 0170 { 0171 return new KateGitBlamePluginView(this, mainWindow); 0172 } 0173 0174 KateGitBlamePluginView::KateGitBlamePluginView(KateGitBlamePlugin *plugin, KTextEditor::MainWindow *mainwindow) 0175 : QObject(plugin) 0176 , m_mainWindow(mainwindow) 0177 , m_inlineNoteProvider(this) 0178 , m_blameInfoProc(this) 0179 , m_showProc(this) 0180 , m_tooltip(this) 0181 { 0182 KXMLGUIClient::setComponentName(QStringLiteral("kategitblameplugin"), i18n("Git Blame")); 0183 setXMLFile(QStringLiteral("ui.rc")); 0184 QAction *showBlameAction = actionCollection()->addAction(QStringLiteral("git_blame_show")); 0185 showBlameAction->setText(i18n("Show Git Blame Details")); 0186 actionCollection()->setDefaultShortcut(showBlameAction, QKeySequence(QStringLiteral("Ctrl+T, B"), QKeySequence::PortableText)); 0187 QAction *toggleBlameAction = actionCollection()->addAction(QStringLiteral("git_blame_toggle")); 0188 toggleBlameAction->setText(i18n("Toggle Git Blame Details")); 0189 m_mainWindow->guiFactory()->addClient(this); 0190 0191 connect(showBlameAction, &QAction::triggered, plugin, [this, showBlameAction]() { 0192 KTextEditor::View *kv = m_mainWindow->activeView(); 0193 if (!kv) { 0194 return; 0195 } 0196 setToolTipIgnoreKeySequence(showBlameAction->shortcut()); 0197 int lineNr = kv->cursorPosition().line(); 0198 const CommitInfo &info = blameInfo(lineNr); 0199 showCommitInfo(QString::fromUtf8(info.hash), kv); 0200 }); 0201 connect(toggleBlameAction, &QAction::triggered, this, [this]() { 0202 m_inlineNoteProvider.cycleMode(); 0203 }); 0204 0205 m_startBlameTimer.setSingleShot(true); 0206 m_startBlameTimer.setInterval(400); 0207 m_startBlameTimer.callOnTimeout(this, &KateGitBlamePluginView::startGitBlameForActiveView); 0208 0209 connect(m_mainWindow, &KTextEditor::MainWindow::viewChanged, this, [this](KTextEditor::View *) { 0210 m_startBlameTimer.start(); 0211 }); 0212 0213 connect(&m_blameInfoProc, static_cast<void (QProcess::*)(int, QProcess::ExitStatus)>(&QProcess::finished), this, &KateGitBlamePluginView::blameFinished); 0214 0215 connect(&m_showProc, static_cast<void (QProcess::*)(int, QProcess::ExitStatus)>(&QProcess::finished), this, &KateGitBlamePluginView::showFinished); 0216 0217 m_inlineNoteProvider.setMode(KateGitBlameMode::SingleLine); 0218 } 0219 0220 KateGitBlamePluginView::~KateGitBlamePluginView() 0221 { 0222 // ensure to kill, we segfault otherwise 0223 m_blameInfoProc.kill(); 0224 m_blameInfoProc.waitForFinished(); 0225 m_showProc.kill(); 0226 m_showProc.waitForFinished(); 0227 0228 m_mainWindow->guiFactory()->removeClient(this); 0229 } 0230 0231 QPointer<KTextEditor::View> KateGitBlamePluginView::activeView() const 0232 { 0233 return m_mainWindow->activeView(); 0234 } 0235 0236 QPointer<KTextEditor::Document> KateGitBlamePluginView::activeDocument() const 0237 { 0238 KTextEditor::View *view = m_mainWindow->activeView(); 0239 if (view && view->document()) { 0240 return view->document(); 0241 } 0242 return nullptr; 0243 } 0244 0245 void KateGitBlamePluginView::startGitBlameForActiveView() 0246 { 0247 if (m_lastView) { 0248 m_lastView->unregisterInlineNoteProvider(&m_inlineNoteProvider); 0249 } 0250 0251 auto *view = m_mainWindow->activeView(); 0252 m_lastView = view; 0253 if (view == nullptr || view->document() == nullptr) { 0254 return; 0255 } 0256 0257 const auto url = view->document()->url(); 0258 // This can happen for example if you were looking at a "temporary" 0259 // view like a diff. => do nothing 0260 if (url.isEmpty() || !url.isValid()) { 0261 return; 0262 } 0263 0264 view->registerInlineNoteProvider(&m_inlineNoteProvider); 0265 startBlameProcess(url); 0266 } 0267 0268 void KateGitBlamePluginView::startBlameProcess(const QUrl &url) 0269 { 0270 // same document? maybe split view? => no work to do, reuse the result we already have 0271 if (m_blameUrl == url) { 0272 return; 0273 } 0274 0275 // clear everything 0276 m_blameUrl.clear(); 0277 m_blamedLines.clear(); 0278 m_blameInfoForHash.clear(); 0279 0280 // Kill any existing process... 0281 if (m_blameInfoProc.state() != QProcess::NotRunning) { 0282 m_blameInfoProc.kill(); 0283 m_blameInfoProc.waitForFinished(); 0284 } 0285 0286 const QFileInfo fi{url.toLocalFile()}; 0287 if (!setupGitProcess(m_blameInfoProc, fi.absolutePath(), {QStringLiteral("blame"), QStringLiteral("-p"), fi.absoluteFilePath()})) { 0288 return; 0289 } 0290 startHostProcess(m_blameInfoProc, QIODevice::ReadOnly); 0291 m_blameUrl = url; 0292 } 0293 0294 void KateGitBlamePluginView::startShowProcess(const QUrl &url, const QString &hash) 0295 { 0296 if (m_showProc.state() != QProcess::NotRunning) { 0297 // Wait for the previous process to be done... 0298 return; 0299 } 0300 0301 const QFileInfo fi{url.toLocalFile()}; 0302 if (!setupGitProcess(m_showProc, fi.absolutePath(), {QStringLiteral("show"), hash, QStringLiteral("--numstat")})) { 0303 return; 0304 } 0305 startHostProcess(m_showProc, QIODevice::ReadOnly); 0306 } 0307 0308 void KateGitBlamePluginView::showCommitInfo(const QString &hash, KTextEditor::View *view) 0309 { 0310 m_showHash = hash; 0311 startShowProcess(view->document()->url(), hash); 0312 } 0313 0314 static int nextBlockStart(const QByteArray &out, int from) 0315 { 0316 int next = out.indexOf('\t', from); 0317 // tab must be the first character in line for next block 0318 if (next > 0 && out[next - 1] != '\n') { 0319 next++; 0320 // move forward one line 0321 next = out.indexOf('\n', next); 0322 // try to look for another tab char 0323 next = out.indexOf('\t', next); 0324 // if not found => end 0325 } 0326 return next; 0327 } 0328 0329 void KateGitBlamePluginView::sendMessage(const QString &text, bool error) 0330 { 0331 Utils::showMessage(text, gitIcon(), i18n("Git"), error ? MessageType::Error : MessageType::Info, m_mainWindow); 0332 } 0333 0334 void KateGitBlamePluginView::blameFinished(int exitCode, QProcess::ExitStatus exitStatus) 0335 { 0336 // we ignore errors, we might just not be in a git repo, parsing errors is hard, as they are translated 0337 // switching to english is no good idea either, as the user will likely not understand it then anyways 0338 if (exitCode != 0 || exitStatus != QProcess::NormalExit) { 0339 return; 0340 } 0341 0342 QByteArray out = m_blameInfoProc.readAllStandardOutput(); 0343 out.replace("\r", ""); // KTextEditor removes all \r characters in the internal buffers 0344 // printf("recieved output: %d for: git %s\n", out.size(), qPrintable(m_blameInfoProc.arguments().join(QLatin1Char(' ')))); 0345 0346 /** 0347 * This is out git blame output parser. 0348 * 0349 * The output contains info about each line of text and commit info 0350 * for that line. We store the commit info separately in a hash-map 0351 * so that they don't need to be duplicated. For each line we store 0352 * its line text and short commit. Text is needed because if you 0353 * modify the doc, we use it to figure out where the original blame 0354 * line is. The short commit is used to fetch the full commit from 0355 * the hashmap 0356 */ 0357 0358 int start = 0; 0359 int next = out.indexOf('\t'); 0360 next = out.indexOf('\n', next); 0361 0362 while (next != -1) { 0363 // printf("Block: (Size: %d) %s\n\n", (next - start), out.mid(start, next - start).constData()); 0364 0365 CommitInfo commitInfo; 0366 BlamedLine lineInfo; 0367 0368 /** 0369 * Parse hash and line numbers 0370 * 0371 * 5c7f27a0915a9b20dc9f683d0d85b6e4b829bc85 1 1 5 0372 */ 0373 int pos = out.indexOf(' ', start); 0374 constexpr int hashLen = 40; 0375 if (pos == -1 || (pos - start) != hashLen) { 0376 printf("no proper hash\n"); 0377 break; 0378 } 0379 QByteArray hash = out.mid(start, pos - start); 0380 0381 // skip to line end, 0382 // we don't care about line no etc here 0383 int from = pos + 1; 0384 pos = out.indexOf('\n', from); 0385 if (pos == -1) { 0386 qWarning() << "Git blame: Invalid blame output : No new line"; 0387 break; 0388 } 0389 pos++; 0390 0391 lineInfo.shortCommitHash = hash.mid(0, 7); 0392 0393 m_blamedLines.push_back(lineInfo); 0394 0395 // are we done because this line references the commit instead of 0396 // containing the content? 0397 if (out[pos] == '\t') { 0398 pos++; // skip \t 0399 from = pos; 0400 pos = out.indexOf('\n', from); // go to line end 0401 m_blamedLines.back().lineText = out.mid(from, pos - from); 0402 0403 start = next + 1; 0404 next = nextBlockStart(out, start); 0405 if (next == -1) 0406 break; 0407 next = out.indexOf('\n', next); 0408 continue; 0409 } 0410 0411 /** 0412 * Parse actual commit 0413 */ 0414 commitInfo.hash = hash; 0415 0416 // author Xyz 0417 constexpr int authorLen = sizeof("author ") - 1; 0418 pos += authorLen; 0419 from = pos; 0420 pos = out.indexOf('\n', pos); 0421 0422 commitInfo.authorName = QString::fromUtf8(out.mid(from, pos - from)); 0423 pos++; 0424 0425 // author-time timestamp 0426 constexpr int authorTimeLen = sizeof("author-time ") - 1; 0427 pos = out.indexOf("author-time ", pos); 0428 if (pos == -1) { 0429 qWarning() << "Invalid commit while git-blameing"; 0430 break; 0431 } 0432 pos += authorTimeLen; 0433 from = pos; 0434 pos = out.indexOf('\n', from); 0435 0436 qint64 timestamp = out.mid(from, pos - from).toLongLong(); 0437 commitInfo.authorDate = QDateTime::fromSecsSinceEpoch(timestamp); 0438 0439 constexpr int summaryLen = sizeof("summary ") - 1; 0440 pos = out.indexOf("summary ", pos); 0441 pos += summaryLen; 0442 from = pos; 0443 pos = out.indexOf('\n', pos); 0444 0445 commitInfo.summary = out.mid(from, pos - from); 0446 // printf("Commit{\n %s,\n %s,\n %s,\n %s\n}\n", qPrintable(commitInfo.commitHash), qPrintable(commitInfo.name), 0447 // qPrintable(commitInfo.date.toString()), qPrintable(commitInfo.title)); 0448 0449 m_blameInfoForHash[lineInfo.shortCommitHash] = commitInfo; 0450 0451 from = pos; 0452 pos = out.indexOf('\t', from); 0453 from = pos + 1; 0454 pos = out.indexOf('\n', from); 0455 m_blamedLines.back().lineText = out.mid(from, pos - from); 0456 0457 start = next + 1; 0458 next = nextBlockStart(out, start); 0459 if (next == -1) 0460 break; 0461 next = out.indexOf('\n', next); 0462 } 0463 } 0464 0465 void KateGitBlamePluginView::showFinished(int exitCode, QProcess::ExitStatus exitStatus) 0466 { 0467 if (exitCode != 0 || exitStatus != QProcess::NormalExit) { 0468 QString text = i18n("Git blame, show commit failed."); 0469 sendMessage(text + QStringLiteral("\n") + QString::fromUtf8(m_showProc.readAllStandardError()), true); 0470 return; 0471 } 0472 0473 QString stdOut = QString::fromUtf8(m_showProc.readAllStandardOutput()); 0474 QStringList args = m_showProc.arguments(); 0475 0476 int titleStart = 0; 0477 for (int i = 0; i < 4; ++i) { 0478 titleStart = stdOut.indexOf(QLatin1Char('\n'), titleStart + 1); 0479 if (titleStart < 0 || titleStart >= stdOut.size() - 1) { 0480 qWarning() << "This is not a known git show format"; 0481 return; 0482 } 0483 } 0484 0485 int titleEnd = stdOut.indexOf(QLatin1Char('\n'), titleStart + 1); 0486 if (titleEnd < 0 || titleEnd >= stdOut.size() - 1) { 0487 qWarning() << "This is not a known git show format"; 0488 return; 0489 } 0490 0491 // Find 'Date:' 0492 int dateIdx = stdOut.indexOf(QStringLiteral("Date:")); 0493 if (dateIdx != -1) { 0494 int newLine = stdOut.indexOf(QLatin1Char('\n'), dateIdx); 0495 if (newLine != -1) { 0496 QString btn = QLatin1String("\n<a href=\"%1\">Click To Show Commit In Tree View</a>\n").arg(args[1]); 0497 stdOut.insert(newLine + 1, btn); 0498 } 0499 } 0500 0501 if (!m_showHash.isEmpty() && m_showHash != args[1]) { 0502 startShowProcess(m_mainWindow->activeView()->document()->url(), m_showHash); 0503 return; 0504 } 0505 if (!m_showHash.isEmpty()) { 0506 m_showHash.clear(); 0507 m_tooltip.show(stdOut, m_mainWindow->activeView()); 0508 } 0509 } 0510 0511 bool KateGitBlamePluginView::hasBlameInfo() const 0512 { 0513 return !m_blamedLines.empty(); 0514 } 0515 0516 const CommitInfo &KateGitBlamePluginView::blameInfo(int lineNr) 0517 { 0518 if (m_blamedLines.empty() || m_blameInfoForHash.isEmpty() || !activeDocument()) { 0519 return blameGetUpdateInfo(-1); 0520 } 0521 0522 const int totalBlamedLines = (int)m_blamedLines.size(); 0523 const int adjustedLineNr = lineNr + m_lineOffset; 0524 const QByteArray lineText = activeDocument()->line(lineNr).toUtf8(); 0525 0526 if (adjustedLineNr >= 0 && adjustedLineNr < totalBlamedLines) { 0527 if (m_blamedLines[adjustedLineNr].lineText == lineText) { 0528 return blameGetUpdateInfo(adjustedLineNr); 0529 } 0530 } 0531 0532 // search for the line 100 lines before and after until it matches 0533 m_lineOffset = 0; 0534 while (m_lineOffset < 100 && lineNr + m_lineOffset >= 0 && lineNr + m_lineOffset < totalBlamedLines) { 0535 if (m_blamedLines[lineNr + m_lineOffset].lineText == lineText) { 0536 return blameGetUpdateInfo(lineNr + m_lineOffset); 0537 } 0538 m_lineOffset++; 0539 } 0540 0541 m_lineOffset = 0; 0542 while (m_lineOffset > -100 && lineNr + m_lineOffset >= 0 && (lineNr + m_lineOffset) < totalBlamedLines) { 0543 if (m_blamedLines[lineNr + m_lineOffset].lineText == lineText) { 0544 return blameGetUpdateInfo(lineNr + m_lineOffset); 0545 } 0546 m_lineOffset--; 0547 } 0548 0549 return blameGetUpdateInfo(-1); 0550 } 0551 0552 const CommitInfo &KateGitBlamePluginView::blameGetUpdateInfo(int lineNr) 0553 { 0554 static const CommitInfo dummy{"hash", i18n("Not Committed Yet"), QDateTime::currentDateTime(), {}}; 0555 if (m_blamedLines.empty() || lineNr < 0 || lineNr >= (int)m_blamedLines.size()) { 0556 return dummy; 0557 } 0558 0559 auto &commitInfo = m_blamedLines[lineNr]; 0560 0561 Q_ASSERT(m_blameInfoForHash.contains(commitInfo.shortCommitHash)); 0562 return m_blameInfoForHash[commitInfo.shortCommitHash]; 0563 } 0564 0565 void KateGitBlamePluginView::setToolTipIgnoreKeySequence(QKeySequence sequence) 0566 { 0567 m_tooltip.setIgnoreKeySequence(sequence); 0568 } 0569 0570 void KateGitBlamePluginView::showCommitTreeView(const QUrl &url) 0571 { 0572 QString commitHash = url.toDisplayString(); 0573 const auto file = m_mainWindow->activeView()->document()->url().toLocalFile(); 0574 CommitView::openCommit(commitHash, file, m_mainWindow); 0575 } 0576 0577 #include "kategitblameplugin.moc" 0578 #include "moc_kategitblameplugin.cpp"