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 &note) const
0075 {
0076     return QSize(note.lineHeight() * 50, note.lineHeight());
0077 }
0078 
0079 void GitBlameInlineNoteProvider::paintInlineNote(const KTextEditor::InlineNote &note, 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 &note, 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"