1 /*
2     SPDX-FileCopyrightText: 2021 Kåre Särs <kare.sars@iki.fi>
3 
4     SPDX-License-Identifier: LGPL-2.0-or-later
5 */
6 
7 #include "kategitblameplugin.h"
8 #include "commitfilesview.h"
9 
10 #include <gitprocess.h>
11 
12 #include <algorithm>
13 
14 #include <KActionCollection>
15 #include <KConfigGroup>
16 #include <KLocalizedString>
17 #include <KPluginFactory>
18 #include <KSharedConfig>
19 #include <KXMLGUIFactory>
20 
21 #include <KTextEditor/Document>
22 #include <KTextEditor/Editor>
23 #include <KTextEditor/InlineNoteInterface>
24 #include <KTextEditor/View>
25 
26 #include <QDir>
27 #include <QUrl>
28 
29 #include <QFontMetrics>
30 #include <QLayout>
31 #include <QPainter>
32 #include <QVariant>
33 
GitBlameInlineNoteProvider(KateGitBlamePluginView * pluginView)34 GitBlameInlineNoteProvider::GitBlameInlineNoteProvider(KateGitBlamePluginView *pluginView)
35     : KTextEditor::InlineNoteProvider()
36     , m_pluginView(pluginView)
37 {
38 }
39 
~GitBlameInlineNoteProvider()40 GitBlameInlineNoteProvider::~GitBlameInlineNoteProvider()
41 {
42     QPointer<KTextEditor::View> view = m_pluginView->activeView();
43     if (view) {
44         qobject_cast<KTextEditor::InlineNoteInterface *>(view)->unregisterInlineNoteProvider(this);
45     }
46 }
47 
inlineNotes(int line) const48 QVector<int> GitBlameInlineNoteProvider::inlineNotes(int line) const
49 {
50     if (!m_pluginView->hasBlameInfo()) {
51         return QVector<int>();
52     }
53 
54     QPointer<KTextEditor::Document> doc = m_pluginView->activeDocument();
55     if (!doc) {
56         qDebug() << "no document";
57         return QVector<int>();
58     }
59 
60     if (m_mode == KateGitBlameMode::None) {
61         return {};
62     }
63 
64     int lineLen = doc->line(line).size();
65     QPointer<KTextEditor::View> view = m_pluginView->activeView();
66     if (view->cursorPosition().line() == line || m_mode == KateGitBlameMode::AllLines) {
67         return QVector<int>{lineLen + 4};
68     }
69     return QVector<int>();
70 }
71 
inlineNoteSize(const KTextEditor::InlineNote & note) const72 QSize GitBlameInlineNoteProvider::inlineNoteSize(const KTextEditor::InlineNote &note) const
73 {
74     return QSize(note.lineHeight() * 50, note.lineHeight());
75 }
76 
paintInlineNote(const KTextEditor::InlineNote & note,QPainter & painter) const77 void GitBlameInlineNoteProvider::paintInlineNote(const KTextEditor::InlineNote &note, QPainter &painter) const
78 {
79     QFont font = note.font();
80     painter.setFont(font);
81     const QFontMetrics fm(note.font());
82 
83     int lineNr = note.position().line();
84     const CommitInfo &info = m_pluginView->blameInfo(lineNr);
85 
86     QString text = info.summary.isEmpty()
87         ? i18nc("git-blame information \"author: date \"", " %1: %2 ", info.authorName, m_locale.toString(info.authorDate, QLocale::NarrowFormat))
88         : i18nc("git-blame information \"author: date: commit title \"",
89                 " %1: %2: %3 ",
90                 info.authorName,
91                 m_locale.toString(info.authorDate, QLocale::NarrowFormat),
92                 QString::fromUtf8(info.summary));
93     QRect rectangle{0, 0, fm.horizontalAdvance(text), note.lineHeight()};
94 
95     auto editor = KTextEditor::Editor::instance();
96     auto color = QColor::fromRgba(editor->theme().textColor(KSyntaxHighlighting::Theme::Normal));
97     color.setAlpha(0);
98     painter.setPen(color);
99     color.setAlpha(8);
100     painter.setBrush(color);
101     painter.drawRect(rectangle);
102 
103     color.setAlpha(note.underMouse() ? 130 : 90);
104     painter.setPen(color);
105     painter.setBrush(color);
106     painter.drawText(rectangle, text);
107 }
108 
inlineNoteActivated(const KTextEditor::InlineNote & note,Qt::MouseButtons buttons,const QPoint &)109 void GitBlameInlineNoteProvider::inlineNoteActivated(const KTextEditor::InlineNote &note, Qt::MouseButtons buttons, const QPoint &)
110 {
111     if ((buttons & Qt::LeftButton) != 0) {
112         int lineNr = note.position().line();
113         const CommitInfo &info = m_pluginView->blameInfo(lineNr);
114 
115         // Hack: view->mainWindow()->view() to de-constify view
116         Q_ASSERT(note.view() == m_pluginView->activeView());
117         m_pluginView->showCommitInfo(QString::fromUtf8(info.hash), note.view()->mainWindow()->activeView());
118     }
119 }
120 
cycleMode()121 void GitBlameInlineNoteProvider::cycleMode()
122 {
123     int newMode = (int)m_mode + 1;
124     if (newMode > (int)KateGitBlameMode::Count) {
125         newMode = 0;
126     }
127     setMode(KateGitBlameMode(newMode));
128 }
129 
setMode(KateGitBlameMode mode)130 void GitBlameInlineNoteProvider::setMode(KateGitBlameMode mode)
131 {
132     m_mode = mode;
133     Q_EMIT inlineNotesReset();
134 }
135 
136 K_PLUGIN_FACTORY_WITH_JSON(KateGitBlamePluginFactory, "kategitblameplugin.json", registerPlugin<KateGitBlamePlugin>();)
137 
KateGitBlamePlugin(QObject * parent,const QList<QVariant> &)138 KateGitBlamePlugin::KateGitBlamePlugin(QObject *parent, const QList<QVariant> &)
139     : KTextEditor::Plugin(parent)
140 {
141 }
142 
createView(KTextEditor::MainWindow * mainWindow)143 QObject *KateGitBlamePlugin::createView(KTextEditor::MainWindow *mainWindow)
144 {
145     return new KateGitBlamePluginView(this, mainWindow);
146 }
147 
KateGitBlamePluginView(KateGitBlamePlugin * plugin,KTextEditor::MainWindow * mainwindow)148 KateGitBlamePluginView::KateGitBlamePluginView(KateGitBlamePlugin *plugin, KTextEditor::MainWindow *mainwindow)
149     : QObject(plugin)
150     , m_mainWindow(mainwindow)
151     , m_inlineNoteProvider(this)
152     , m_blameInfoProc(this)
153     , m_showProc(this)
154     , m_tooltip(this)
155 {
156     KXMLGUIClient::setComponentName(QStringLiteral("kategitblameplugin"), i18n("Git Blame"));
157     setXMLFile(QStringLiteral("ui.rc"));
158     QAction *showBlameAction = actionCollection()->addAction(QStringLiteral("git_blame_show"));
159     showBlameAction->setText(i18n("Show Git Blame Details"));
160     actionCollection()->setDefaultShortcut(showBlameAction, Qt::CTRL | Qt::ALT | Qt::Key_G);
161     QAction *toggleBlameAction = actionCollection()->addAction(QStringLiteral("git_blame_toggle"));
162     toggleBlameAction->setText(i18n("Toggle Git Blame Details"));
163     m_mainWindow->guiFactory()->addClient(this);
164 
165     connect(showBlameAction, &QAction::triggered, plugin, [this, showBlameAction]() {
166         KTextEditor::View *kv = m_mainWindow->activeView();
167         if (!kv) {
168             return;
169         }
170         setToolTipIgnoreKeySequence(showBlameAction->shortcut());
171         int lineNr = kv->cursorPosition().line();
172         const CommitInfo &info = blameInfo(lineNr);
173         showCommitInfo(QString::fromUtf8(info.hash), kv);
174     });
175     connect(toggleBlameAction, &QAction::triggered, this, [this]() {
176         m_inlineNoteProvider.cycleMode();
177     });
178 
179     connect(m_mainWindow, &KTextEditor::MainWindow::viewChanged, this, &KateGitBlamePluginView::viewChanged);
180 
181     connect(&m_blameInfoProc, static_cast<void (QProcess::*)(int, QProcess::ExitStatus)>(&QProcess::finished), this, &KateGitBlamePluginView::blameFinished);
182 
183     connect(&m_showProc, static_cast<void (QProcess::*)(int, QProcess::ExitStatus)>(&QProcess::finished), this, &KateGitBlamePluginView::showFinished);
184 
185     m_inlineNoteProvider.setMode(KateGitBlameMode::SingleLine);
186 }
187 
~KateGitBlamePluginView()188 KateGitBlamePluginView::~KateGitBlamePluginView()
189 {
190     // ensure to kill, we segfault otherwise
191     m_blameInfoProc.kill();
192     m_blameInfoProc.waitForFinished();
193     m_showProc.kill();
194     m_showProc.waitForFinished();
195 
196     m_mainWindow->guiFactory()->removeClient(this);
197 }
198 
activeView() const199 QPointer<KTextEditor::View> KateGitBlamePluginView::activeView() const
200 {
201     return m_mainWindow->activeView();
202 }
203 
activeDocument() const204 QPointer<KTextEditor::Document> KateGitBlamePluginView::activeDocument() const
205 {
206     KTextEditor::View *view = m_mainWindow->activeView();
207     if (view && view->document()) {
208         return view->document();
209     }
210     return nullptr;
211 }
212 
viewChanged(KTextEditor::View * view)213 void KateGitBlamePluginView::viewChanged(KTextEditor::View *view)
214 {
215     if (m_lastView) {
216         qobject_cast<KTextEditor::InlineNoteInterface *>(m_lastView)->unregisterInlineNoteProvider(&m_inlineNoteProvider);
217     }
218     m_lastView = view;
219 
220     if (view == nullptr || view->document() == nullptr) {
221         return;
222     }
223 
224     const auto url = view->document()->url();
225     // This can happen for example if you were looking at a "temporary"
226     // view like a diff. => do nothing
227     if (url.isEmpty() || !url.isValid()) {
228         return;
229     }
230 
231     qobject_cast<KTextEditor::InlineNoteInterface *>(view)->registerInlineNoteProvider(&m_inlineNoteProvider);
232 
233     startBlameProcess(url);
234 }
235 
startBlameProcess(const QUrl & url)236 void KateGitBlamePluginView::startBlameProcess(const QUrl &url)
237 {
238     // same document? maybe split view? => no work to do, reuse the result we already have
239     if (m_blameUrl == url) {
240         return;
241     }
242 
243     // clear everything
244     m_blameUrl.clear();
245     m_blamedLines.clear();
246     m_blameInfoForHash.clear();
247 
248     // Kill any existing process...
249     if (m_blameInfoProc.state() != QProcess::NotRunning) {
250         m_blameInfoProc.kill();
251         m_blameInfoProc.waitForFinished();
252     }
253 
254     QString fileName{url.fileName()};
255     QDir dir{url.toLocalFile()};
256     dir.cdUp();
257 
258     if (!setupGitProcess(m_blameInfoProc, dir.absolutePath(), {QStringLiteral("blame"), QStringLiteral("-p"), QStringLiteral("./%1").arg(fileName)})) {
259         return;
260     }
261     m_blameInfoProc.start(QIODevice::ReadOnly);
262     m_blameUrl = url;
263 }
264 
startShowProcess(const QUrl & url,const QString & hash)265 void KateGitBlamePluginView::startShowProcess(const QUrl &url, const QString &hash)
266 {
267     if (m_showProc.state() != QProcess::NotRunning) {
268         // Wait for the previous process to be done...
269         return;
270     }
271 
272     QDir dir{url.toLocalFile()};
273     dir.cdUp();
274 
275     if (!setupGitProcess(m_showProc, dir.absolutePath(), {QStringLiteral("show"), hash, QStringLiteral("--numstat")})) {
276         return;
277     }
278     m_showProc.start(QIODevice::ReadOnly);
279 }
280 
showCommitInfo(const QString & hash,KTextEditor::View * view)281 void KateGitBlamePluginView::showCommitInfo(const QString &hash, KTextEditor::View *view)
282 {
283     m_showHash = hash;
284     startShowProcess(view->document()->url(), hash);
285 }
286 
nextBlockStart(const QByteArray & out,int from)287 static int nextBlockStart(const QByteArray &out, int from)
288 {
289     int next = out.indexOf('\t', from);
290     // tab must be the first character in line for next block
291     if (next > 0 && out[next - 1] != '\n') {
292         next++;
293         // move forward one line
294         next = out.indexOf('\n', next);
295         // try to look for another tab char
296         next = out.indexOf('\t', next);
297         // if not found => end
298     }
299     return next;
300 }
301 
blameFinished(int,QProcess::ExitStatus)302 void KateGitBlamePluginView::blameFinished(int /*exitCode*/, QProcess::ExitStatus /*exitStatus*/)
303 {
304     const QByteArray out = m_blameInfoProc.readAllStandardOutput();
305     // printf("recieved output: %d for: git %s\n", out.size(), qPrintable(m_blameInfoProc.arguments().join(QLatin1Char(' '))));
306 
307     /**
308      * This is out git blame output parser.
309      *
310      * The output contains info about each line of text and commit info
311      * for that line. We store the commit info separately in a hash-map
312      * so that they don't need to be duplicated. For each line we store
313      * its line text and short commit. Text is needed because if you
314      * modify the doc, we use it to figure out where the original blame
315      * line is. The short commit is used to fetch the full commit from
316      * the hashmap
317      */
318 
319     int start = 0;
320     int next = out.indexOf('\t');
321     next = out.indexOf('\n', next);
322 
323     while (next != -1) {
324         //         printf("Block: (Size: %d) %s\n\n", (next - start), out.mid(start, next - start).constData());
325 
326         CommitInfo commitInfo;
327         BlamedLine lineInfo;
328 
329         /**
330          * Parse hash and line numbers
331          *
332          * 5c7f27a0915a9b20dc9f683d0d85b6e4b829bc85 1 1 5
333          */
334         int pos = out.indexOf(' ', start);
335         constexpr int hashLen = 40;
336         if (pos == -1 || (pos - start) != hashLen) {
337             printf("no proper hash\n");
338             break;
339         }
340         QByteArray hash = out.mid(start, pos - start);
341 
342         // skip to line end,
343         // we don't care about line no etc here
344         int from = pos + 1;
345         pos = out.indexOf('\n', from);
346         if (pos == -1) {
347             qWarning() << "Git blame: Invalid blame output : No new line";
348             break;
349         }
350         pos++;
351 
352         lineInfo.shortCommitHash = hash.mid(0, 7);
353 
354         m_blamedLines.push_back(lineInfo);
355 
356         // are we done because this line references the commit instead of
357         // containing the content?
358         if (out[pos] == '\t') {
359             pos++; // skip \t
360             from = pos;
361             pos = out.indexOf('\n', from); // go to line end
362             m_blamedLines.back().lineText = out.mid(from, pos - from);
363 
364             start = next + 1;
365             next = nextBlockStart(out, start);
366             if (next == -1)
367                 break;
368             next = out.indexOf('\n', next);
369             continue;
370         }
371 
372         /**
373          * Parse actual commit
374          */
375         commitInfo.hash = hash;
376 
377         // author Xyz
378         constexpr int authorLen = sizeof("author ") - 1;
379         pos += authorLen;
380         from = pos;
381         pos = out.indexOf('\n', pos);
382 
383         commitInfo.authorName = QString::fromUtf8(out.mid(from, pos - from));
384         pos++;
385 
386         // author-time timestamp
387         constexpr int authorTimeLen = sizeof("author-time ") - 1;
388         pos = out.indexOf("author-time ", pos);
389         if (pos == -1) {
390             qWarning() << "Invalid commit while git-blameing";
391             break;
392         }
393         pos += authorTimeLen;
394         from = pos;
395         pos = out.indexOf('\n', from);
396 
397         qint64 timestamp = out.mid(from, pos - from).toLongLong();
398         commitInfo.authorDate = QDateTime::fromSecsSinceEpoch(timestamp);
399 
400         constexpr int summaryLen = sizeof("summary ") - 1;
401         pos = out.indexOf("summary ", pos);
402         pos += summaryLen;
403         from = pos;
404         pos = out.indexOf('\n', pos);
405 
406         commitInfo.summary = out.mid(from, pos - from);
407         //         printf("Commit{\n %s,\n %s,\n %s,\n %s\n}\n", qPrintable(commitInfo.commitHash), qPrintable(commitInfo.name),
408         //         qPrintable(commitInfo.date.toString()), qPrintable(commitInfo.title));
409 
410         m_blameInfoForHash[lineInfo.shortCommitHash] = commitInfo;
411 
412         from = pos;
413         pos = out.indexOf('\t', from);
414         from = pos + 1;
415         pos = out.indexOf('\n', from);
416         m_blamedLines.back().lineText = out.mid(from, pos - from);
417 
418         start = next + 1;
419         next = nextBlockStart(out, start);
420         if (next == -1)
421             break;
422         next = out.indexOf('\n', next);
423     }
424 }
425 
showFinished(int exitCode,QProcess::ExitStatus exitStatus)426 void KateGitBlamePluginView::showFinished(int exitCode, QProcess::ExitStatus exitStatus)
427 {
428     if (exitCode != 0 || exitStatus != QProcess::NormalExit) {
429         qWarning() << "Failed to show commit";
430         return;
431     }
432 
433     QString stdOut = QString::fromUtf8(m_showProc.readAllStandardOutput());
434     QStringList args = m_showProc.arguments();
435 
436     int titleStart = 0;
437     for (int i = 0; i < 4; ++i) {
438         titleStart = stdOut.indexOf(QLatin1Char('\n'), titleStart + 1);
439         if (titleStart < 0 || titleStart >= stdOut.size() - 1) {
440             qWarning() << "This is not a known git show format";
441             return;
442         }
443     }
444 
445     int titleEnd = stdOut.indexOf(QLatin1Char('\n'), titleStart + 1);
446     if (titleEnd < 0 || titleEnd >= stdOut.size() - 1) {
447         qWarning() << "This is not a known git show format";
448         return;
449     }
450 
451     // Find 'Date:'
452     int dateIdx = stdOut.indexOf(QStringLiteral("Date:"));
453     if (dateIdx != -1) {
454         int newLine = stdOut.indexOf(QLatin1Char('\n'), dateIdx);
455         if (newLine != -1) {
456             QString btn = QLatin1String("\n<a href=\"%1\">Click To Show Commit In Tree View</a>\n").arg(args[1]);
457             stdOut.insert(newLine + 1, btn);
458         }
459     }
460 
461     if (!m_showHash.isEmpty() && m_showHash != args[1]) {
462         startShowProcess(m_mainWindow->activeView()->document()->url(), m_showHash);
463         return;
464     }
465     if (!m_showHash.isEmpty()) {
466         m_showHash.clear();
467         m_tooltip.show(stdOut, m_mainWindow->activeView());
468     }
469 }
470 
hasBlameInfo() const471 bool KateGitBlamePluginView::hasBlameInfo() const
472 {
473     return !m_blamedLines.empty();
474 }
475 
blameInfo(int lineNr)476 const CommitInfo &KateGitBlamePluginView::blameInfo(int lineNr)
477 {
478     if (m_blamedLines.empty() || m_blameInfoForHash.isEmpty() || !activeDocument()) {
479         return blameGetUpdateInfo(-1);
480     }
481 
482     int totalBlamedLines = m_blamedLines.size();
483 
484     int adjustedLineNr = lineNr + m_lineOffset;
485     const QByteArray lineText = activeDocument()->line(lineNr).toUtf8();
486 
487     if (adjustedLineNr >= 0 && adjustedLineNr < totalBlamedLines) {
488         if (m_blamedLines[adjustedLineNr].lineText == lineText) {
489             return blameGetUpdateInfo(adjustedLineNr);
490         }
491     }
492 
493     // search for the line 100 lines before and after until it matches
494     m_lineOffset = 0;
495     while (m_lineOffset < 100 && lineNr + m_lineOffset >= 0 && lineNr + m_lineOffset < totalBlamedLines) {
496         if (m_blamedLines[lineNr + m_lineOffset].lineText == lineText) {
497             return blameGetUpdateInfo(lineNr + m_lineOffset);
498         }
499         m_lineOffset++;
500     }
501 
502     m_lineOffset = 0;
503     while (m_lineOffset > -100 && lineNr + m_lineOffset >= 0 && (lineNr + m_lineOffset) < totalBlamedLines) {
504         if (m_blamedLines[lineNr + m_lineOffset].lineText == lineText) {
505             return blameGetUpdateInfo(lineNr + m_lineOffset);
506         }
507         m_lineOffset--;
508     }
509 
510     return blameGetUpdateInfo(-1);
511 }
512 
blameGetUpdateInfo(int lineNr)513 const CommitInfo &KateGitBlamePluginView::blameGetUpdateInfo(int lineNr)
514 {
515     static const CommitInfo dummy{"hash", i18n("Not Committed Yet"), QDateTime::currentDateTime(), {}};
516     if (m_blamedLines.empty() || lineNr < 0 || lineNr >= (int)m_blamedLines.size()) {
517         return dummy;
518     }
519 
520     auto &commitInfo = m_blamedLines[lineNr];
521 
522     Q_ASSERT(m_blameInfoForHash.contains(commitInfo.shortCommitHash));
523     return m_blameInfoForHash[commitInfo.shortCommitHash];
524 }
525 
setToolTipIgnoreKeySequence(QKeySequence sequence)526 void KateGitBlamePluginView::setToolTipIgnoreKeySequence(QKeySequence sequence)
527 {
528     m_tooltip.setIgnoreKeySequence(sequence);
529 }
530 
showCommitTreeView(const QUrl & url)531 void KateGitBlamePluginView::showCommitTreeView(const QUrl &url)
532 {
533     QString commitHash = url.toDisplayString();
534     createToolView();
535     m_commitFilesView->openCommit(commitHash, m_mainWindow->activeView()->document()->url().toLocalFile());
536     m_mainWindow->showToolView(m_toolView.get());
537 }
538 
createToolView()539 void KateGitBlamePluginView::createToolView()
540 {
541     if (m_toolView) {
542         return;
543     }
544 
545     auto plugin = static_cast<KTextEditor::Plugin *>(parent());
546     m_toolView.reset(m_mainWindow->createToolView(plugin,
547                                                   QStringLiteral("commitfilesview"),
548                                                   KTextEditor::MainWindow::Left,
549                                                   QIcon::fromTheme(QStringLiteral(":/icons/icons/sc-apps-git.svg")),
550                                                   i18n("Commit")));
551 
552     m_commitFilesView = new CommitDiffTreeView(m_toolView.get());
553     m_toolView->layout()->addWidget(m_commitFilesView);
554     connect(m_commitFilesView, &CommitDiffTreeView::closeRequested, this, &KateGitBlamePluginView::hideToolView);
555     connect(m_commitFilesView, &CommitDiffTreeView::showDiffRequested, this, &KateGitBlamePluginView::showDiffForFile);
556 }
557 
hideToolView()558 void KateGitBlamePluginView::hideToolView()
559 {
560     m_mainWindow->hideToolView(m_toolView.get());
561     m_toolView.reset();
562     // CommitFileView will be destroyed as well as it is the child of m_ToolView
563 }
564 
showDiffForFile(const QByteArray & diffContents)565 void KateGitBlamePluginView::showDiffForFile(const QByteArray &diffContents)
566 {
567     if (m_diffView) {
568         m_diffView->document()->setText(QString::fromUtf8(diffContents));
569         m_diffView->document()->setModified(false);
570         m_mainWindow->activateView(m_diffView->document());
571         m_diffView->setCursorPosition({0, 0});
572         return;
573     }
574     m_diffView = m_mainWindow->openUrl(QUrl());
575     m_diffView->document()->setHighlightingMode(QStringLiteral("Diff"));
576     m_diffView->document()->setText(QString::fromUtf8(diffContents));
577     m_diffView->document()->setModified(false);
578     m_mainWindow->activateView(m_diffView->document());
579     m_diffView->setCursorPosition({0, 0});
580 }
581 
582 #include "kategitblameplugin.moc"
583