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 ¬e) 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 ¬e, 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 ¬e, 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