1#include <CommitChangesWidget.h>
2#include <ui_CommitChangesWidget.h>
3
4#include <ClickableFrame.h>
5#include <CommitInfo.h>
6#include <FileWidget.h>
7#include <GitBase.h>
8#include <GitCache.h>
9#include <GitLocal.h>
10#include <GitQlientSettings.h>
11#include <GitQlientStyles.h>
12#include <GitRepoLoader.h>
13#include <GitWip.h>
14#include <RevisionFiles.h>
15#include <UnstagedMenu.h>
16
17#include <QDir>
18#include <QItemDelegate>
19#include <QKeyEvent>
20#include <QListWidgetItem>
21#include <QMenu>
22#include <QMessageBox>
23#include <QPainter>
24#include <QProcess>
25#include <QRegExp>
26#include <QScrollBar>
27#include <QTextCodec>
28#include <QTextStream>
29#include <QToolTip>
30
31#include <QLogger.h>
32
33using namespace QLogger;
34
35QString CommitChangesWidget::lastMsgBeforeError;
36
37enum GitQlientRole
38{
39 U_ListRole = Qt::UserRole,
40 U_IsConflict,
41 U_IsUntracked,
42 U_FullPath
43};
44
45CommitChangesWidget::CommitChangesWidget(const QSharedPointer<GitCache> &cache, const QSharedPointer<GitBase> &git,
46 QWidget *parent)
47 : QWidget(parent)
48 , ui(new Ui::CommitChangesWidget)
49 , mCache(cache)
50 , mGit(git)
51{
52 ui->setupUi(this);
53 setAttribute(Qt::WA_DeleteOnClose);
54
55 ui->amendFrame->setVisible(false);
56
57 mTitleMaxLength = GitQlientSettings().globalValue("commitTitleMaxLength", mTitleMaxLength).toInt();
58
59 ui->lCounter->setText(QString::number(mTitleMaxLength));
60 ui->leCommitTitle->setMaxLength(mTitleMaxLength);
61 ui->teDescription->setMaximumHeight(100);
62
63 connect(ui->leCommitTitle, &QLineEdit::textChanged, this, &CommitChangesWidget::updateCounter);
64 connect(ui->leCommitTitle, &QLineEdit::returnPressed, this, &CommitChangesWidget::commitChanges);
65 connect(ui->applyActionBtn, &QPushButton::clicked, this, &CommitChangesWidget::commitChanges);
66 connect(ui->warningButton, &QPushButton::clicked, this, [this]() { emit signalCancelAmend(mCurrentSha); });
67 connect(ui->stagedFilesList, &StagedFilesList::signalResetFile, this, &CommitChangesWidget::resetFile);
68 connect(ui->stagedFilesList, &StagedFilesList::signalShowDiff, this,
69 [this](const QString &fileName) { requestDiff(mGit->getWorkingDir() + "/" + fileName); });
70 connect(ui->unstagedFilesList, &QListWidget::customContextMenuRequested, this,
71 &CommitChangesWidget::showUnstagedMenu);
72 connect(ui->unstagedFilesList, &QListWidget::itemDoubleClicked, this,
73 [this](QListWidgetItem *item) { requestDiff(mGit->getWorkingDir() + "/" + item->toolTip()); });
74
75 ui->warningButton->setVisible(false);
76 ui->applyActionBtn->setText(tr("Commit"));
77}
78
79CommitChangesWidget::~CommitChangesWidget()
80{
81 delete ui;
82}
83
84void CommitChangesWidget::reload()
85{
86 configure(mCurrentSha);
87}
88
89void CommitChangesWidget::resetFile(QListWidgetItem *item)
90{
91 QScopedPointer<GitLocal> git(new GitLocal(mGit));
92 const auto ret = git->resetFile(item->toolTip());
93 const auto revInfo = mCache->commitInfo(mCurrentSha);
94 const auto files = mCache->revisionFile(mCurrentSha, revInfo.firstParent());
95
96 for (auto i = 0; i < files->count(); ++i)
97 {
98 auto fileName = files->getFile(i);
99
100 if (fileName == item->toolTip())
101 {
102 const auto isUnknown = files->statusCmp(i, RevisionFiles::UNKNOWN);
103 const auto isInIndex = files->statusCmp(i, RevisionFiles::IN_INDEX);
104 const auto untrackedFile = !isInIndex && isUnknown;
105 const auto row = ui->stagedFilesList->row(item);
106 const auto iconPath = QString(":/icons/add");
107 const auto fileWidget = qobject_cast<FileWidget *>(ui->stagedFilesList->itemWidget(item));
108
109 QFontMetrics metrix(item->font());
110 const auto clippedText = metrix.elidedText(item->toolTip(), Qt::ElideMiddle, width() - 10);
111
112 if (isInIndex || untrackedFile)
113 {
114 item->setData(GitQlientRole::U_ListRole, QVariant::fromValue(ui->unstagedFilesList));
115
116 ui->stagedFilesList->takeItem(row);
117 ui->unstagedFilesList->addItem(item);
118
119 const auto newFileWidget = new FileWidget(iconPath, clippedText, this);
120 newFileWidget->setTextColor(fileWidget->getTextColor());
121 newFileWidget->setToolTip(fileName);
122
123 connect(newFileWidget, &FileWidget::clicked, this, [this, item]() { addFileToCommitList(item); });
124 ui->unstagedFilesList->setItemWidget(item, newFileWidget);
125
126 delete fileWidget;
127 }
128 }
129 }
130
131 if (ret.success)
132 emit signalUpdateWip();
133}
134
135QColor CommitChangesWidget::getColorForFile(const RevisionFiles &files, int index) const
136{
137 const auto isUnknown = files.statusCmp(index, RevisionFiles::UNKNOWN);
138 const auto isInIndex = files.statusCmp(index, RevisionFiles::IN_INDEX);
139 const auto isConflict = files.statusCmp(index, RevisionFiles::CONFLICT);
140 const auto untrackedFile = !isInIndex && isUnknown;
141 const auto isPartiallyCached = files.statusCmp(index, RevisionFiles::PARTIALLY_CACHED);
142
143 QColor myColor;
144 const auto isDeleted = files.statusCmp(index, RevisionFiles::DELETED);
145
146 if (isConflict)
147 myColor = GitQlientStyles::getBlue();
148 else if (isDeleted)
149 myColor = GitQlientStyles::getRed();
150 else if (untrackedFile)
151 myColor = GitQlientStyles::getOrange();
152 else if (files.statusCmp(index, RevisionFiles::NEW) || isUnknown || isInIndex || isPartiallyCached)
153 myColor = GitQlientStyles::getGreen();
154 else
155 myColor = GitQlientStyles::getTextColor();
156
157 return myColor;
158}
159
160void CommitChangesWidget::deleteUntrackedFiles()
161{
162 for (auto i = 0; i < ui->unstagedFilesList->count(); ++i)
163 {
164 const auto item = ui->unstagedFilesList->item(i);
165
166 if (item->data(GitQlientRole::U_IsUntracked).toBool())
167 {
168 const auto path = QString("%1").arg(item->data(GitQlientRole::U_FullPath).toString());
169
170 QLog_Info("UI", "Removing path: " + path);
171
172 QProcess p;
173 p.setWorkingDirectory(mGit->getWorkingDir());
174 p.start("rm", { "-rf", path });
175
176 p.waitForFinished();
177 }
178 }
179
180 emit signalCheckoutPerformed();
181}
182
183void CommitChangesWidget::prepareCache()
184{
185 for (auto it = mInternalCache.begin(); it != mInternalCache.end(); ++it)
186 it.value().keep = false;
187}
188
189void CommitChangesWidget::clearCache()
190{
191
192 for (auto it = mInternalCache.begin(); it != mInternalCache.end();)
193 {
194 if (!it.value().keep)
195 {
196 if (it.value().item)
197 delete it.value().item;
198
199 it = mInternalCache.erase(it);
200 }
201 else
202 ++it;
203 }
204}
205
206void CommitChangesWidget::insertFiles(const RevisionFiles &files, QListWidget *fileList)
207{
208 for (auto &cachedItem : mInternalCache) // Move to prepareCache
209 cachedItem.keep = false;
210
211 for (auto i = 0; i < files.count(); ++i)
212 {
213 const auto fileName = files.getFile(i);
214 const auto isUnknown = files.statusCmp(i, RevisionFiles::UNKNOWN);
215 const auto isInIndex = files.statusCmp(i, RevisionFiles::IN_INDEX);
216 const auto isConflict = files.statusCmp(i, RevisionFiles::CONFLICT);
217 const auto isPartiallyCached = files.statusCmp(i, RevisionFiles::PARTIALLY_CACHED);
218 const auto staged = isInIndex && !isUnknown && !isConflict;
219 const auto untrackedFile = !isInIndex && isUnknown;
220 auto wip = QString("%1-%2").arg(fileName, ui->stagedFilesList->objectName());
221
222 if (staged || isPartiallyCached)
223 {
224 if (!mInternalCache.contains(wip))
225 {
226 const auto color = getColorForFile(files, i);
227 const auto itemPair = fillFileItemInfo(fileName, isConflict, untrackedFile, QString(":/icons/remove"),
228 color, ui->stagedFilesList);
229 connect(itemPair.second, &FileWidget::clicked, this, [this, item = itemPair.first]() { resetFile(item); });
230
231 ui->stagedFilesList->setItemWidget(itemPair.first, itemPair.second);
232 itemPair.first->setSizeHint(itemPair.second->sizeHint());
233 }
234 else
235 mInternalCache[wip].keep = true;
236 }
237
238 if (!staged)
239 {
240 wip = QString("%1-%2").arg(fileName, fileList->objectName());
241 if (!mInternalCache.contains(wip))
242 {
243 // Item configuration
244 auto color = getColorForFile(files, i);
245
246 // If the item is not new but the color is green this is not correct.
247 // It means that the file was partially staged so the color backs to default.
248 if (!files.statusCmp(i, RevisionFiles::NEW) && color == GitQlientStyles::getGreen())
249 color = GitQlientStyles::getTextColor();
250
251 const auto itemPair
252 = fillFileItemInfo(fileName, isConflict, untrackedFile, QString(":/icons/add"), color, fileList);
253
254 connect(itemPair.second, &FileWidget::clicked, this,
255 [this, item = itemPair.first]() { addFileToCommitList(item); });
256
257 fileList->setItemWidget(itemPair.first, itemPair.second);
258 itemPair.first->setSizeHint(itemPair.second->sizeHint());
259 }
260 else
261 mInternalCache[wip].keep = true;
262 }
263 }
264}
265
266QPair<QListWidgetItem *, FileWidget *> CommitChangesWidget::fillFileItemInfo(const QString &file, bool isConflict,
267 bool isUntracked, const QString &icon,
268 const QColor &color, QListWidget *parent)
269{
270 auto modName = file;
271 auto item = new QListWidgetItem(parent);
272
273 item->setData(GitQlientRole::U_FullPath, file);
274
275 if (isConflict)
276 {
277 modName = QString(file + " (conflicts)");
278
279 item->setData(GitQlientRole::U_IsConflict, isConflict);
280 }
281
282 item->setData(GitQlientRole::U_ListRole, QVariant::fromValue(parent));
283 item->setData(GitQlientRole::U_IsUntracked, isUntracked);
284 item->setToolTip(modName);
285
286 QFontMetrics metrix(item->font());
287 const auto clippedText = metrix.elidedText(modName, Qt::ElideMiddle, width() - 10);
288
289 const auto fileWidget = new FileWidget(icon, clippedText, this);
290 fileWidget->setTextColor(color);
291 fileWidget->setToolTip(modName);
292
293 mInternalCache.insert(QString("%1-%2").arg(file, parent->objectName()), { true, item });
294
295 return qMakePair(item, fileWidget);
296}
297
298void CommitChangesWidget::addAllFilesToCommitList()
299{
300 QStringList files;
301
302 for (auto i = ui->unstagedFilesList->count() - 1; i >= 0; --i)
303 files += addFileToCommitList(ui->unstagedFilesList->item(i), false);
304
305 const auto git = QScopedPointer<GitLocal>(new GitLocal(mGit));
306
307 if (const auto ret = git->markFilesAsResolved(files); ret.success)
308 {
309 QScopedPointer<GitWip> git(new GitWip(mGit, mCache));
310 git->updateWip();
311 }
312
313 ui->applyActionBtn->setEnabled(ui->stagedFilesList->count() > 0);
314}
315
316void CommitChangesWidget::requestDiff(const QString &fileName)
317{
318 const auto isCached = qobject_cast<StagedFilesList *>(sender()) == ui->stagedFilesList;
319 emit signalShowDiff(CommitInfo::ZERO_SHA, mCache->commitInfo(CommitInfo::ZERO_SHA).firstParent(), fileName,
320 isCached);
321}
322
323QString CommitChangesWidget::addFileToCommitList(QListWidgetItem *item, bool updateGit)
324{
325 const auto fileList = qvariant_cast<QListWidget *>(item->data(GitQlientRole::U_ListRole));
326 const auto fileWidget = qobject_cast<FileWidget *>(fileList->itemWidget(item));
327 const auto fileName = fileWidget->toolTip().remove(tr("(conflicts)")).trimmed();
328
329 if (updateGit)
330 {
331 const auto git = QScopedPointer<GitLocal>(new GitLocal(mGit));
332
333 if (const auto ret = git->stageFile(fileName); ret.success)
334 {
335 QScopedPointer<GitWip> git(new GitWip(mGit, mCache));
336 git->updateWip();
337 }
338 }
339
340 const auto row = fileList->row(item);
341 fileList->removeItemWidget(item);
342 fileList->takeItem(row);
343
344 const auto newKey = QString("%1-%2").arg(fileName, ui->stagedFilesList->objectName());
345
346 if (!mInternalCache.contains(newKey))
347 {
348 const auto wip = mInternalCache.take(QString("%1-%2").arg(fileName, fileList->objectName()));
349 mInternalCache.insert(newKey, wip);
350
351 QFontMetrics metrix(item->font());
352 const auto clippedText = metrix.elidedText(fileName, Qt::ElideMiddle, width() - 10);
353
354 const auto newFileWidget = new FileWidget(":/icons/remove", clippedText, this);
355 newFileWidget->setTextColor(fileWidget->getTextColor());
356 newFileWidget->setToolTip(fileName);
357
358 connect(newFileWidget, &FileWidget::clicked, this, [this, item]() { removeFileFromCommitList(item); });
359
360 ui->stagedFilesList->addItem(item);
361 ui->stagedFilesList->setItemWidget(item, newFileWidget);
362
363 if (item->data(GitQlientRole::U_IsConflict).toBool())
364 {
365 newFileWidget->setText(fileName);
366 }
367 }
368
369 delete fileWidget;
370
371 ui->applyActionBtn->setEnabled(true);
372
373 return fileName;
374}
375
376void CommitChangesWidget::revertAllChanges()
377{
378 auto needsUpdate = false;
379
380 for (auto i = ui->unstagedFilesList->count() - 1; i >= 0; --i)
381 {
382 QScopedPointer<GitLocal> git(new GitLocal(mGit));
383 needsUpdate |= git->checkoutFile(ui->unstagedFilesList->takeItem(i)->toolTip());
384 }
385
386 if (needsUpdate)
387 emit signalCheckoutPerformed();
388}
389
390void CommitChangesWidget::removeFileFromCommitList(QListWidgetItem *item)
391{
392 if (item->flags() & Qt::ItemIsSelectable)
393 {
394 const auto itemOriginalList = qvariant_cast<QListWidget *>(item->data(GitQlientRole::U_ListRole));
395 const auto row = ui->stagedFilesList->row(item);
396 const auto fileWidget = qobject_cast<FileWidget *>(ui->stagedFilesList->itemWidget(item));
397 const auto fileName = fileWidget->toolTip();
398
399 const auto wip = mInternalCache.take(QString("%1-%2").arg(fileName, ui->stagedFilesList->objectName()));
400 mInternalCache.insert(QString("%1-%2").arg(fileName, itemOriginalList->objectName()), wip);
401
402 QFontMetrics metrix(item->font());
403 const auto clippedText = metrix.elidedText(fileName, Qt::ElideMiddle, width() - 10);
404
405 const auto newFileWidget = new FileWidget(":/icons/add", clippedText, this);
406 newFileWidget->setTextColor(fileWidget->getTextColor());
407 newFileWidget->setToolTip(fileName);
408
409 connect(newFileWidget, &FileWidget::clicked, this, [this, item]() { addFileToCommitList(item); });
410
411 QScopedPointer<GitLocal> git = QScopedPointer<GitLocal>(new GitLocal(mGit));
412 git->resetFile(fileName);
413
414 if (item->data(GitQlientRole::U_IsConflict).toBool())
415 {
416 newFileWidget->setText(fileName + tr(" (conflicts)"));
417 }
418
419 delete fileWidget;
420
421 ui->stagedFilesList->removeItemWidget(item);
422 const auto item = ui->stagedFilesList->takeItem(row);
423
424 itemOriginalList->addItem(item);
425 itemOriginalList->setItemWidget(item, newFileWidget);
426
427 ui->applyActionBtn->setDisabled(ui->stagedFilesList->count() == 0);
428 }
429}
430
431QStringList CommitChangesWidget::getFiles()
432{
433 QStringList selFiles;
434 const auto totalItems = ui->stagedFilesList->count();
435
436 for (auto i = 0; i < totalItems; ++i)
437 {
438 const auto fileWidget = static_cast<FileWidget *>(ui->stagedFilesList->itemWidget(ui->stagedFilesList->item(i)));
439 selFiles.append(fileWidget->toolTip());
440 }
441
442 return selFiles;
443}
444
445bool CommitChangesWidget::checkMsg(QString &msg)
446{
447 const auto title = ui->leCommitTitle->text();
448
449 if (title.isEmpty())
450 QMessageBox::warning(this, "Commit changes", "Please, add a title.");
451
452 msg = title;
453
454 if (!ui->teDescription->toPlainText().isEmpty())
455 {
456 auto description = QString("\n\n%1").arg(ui->teDescription->toPlainText());
457 description.remove(QRegExp("(^|\\n)\\s*#[^\\n]*")); // strip comments
458 msg += description;
459 }
460
461 msg.replace(QRegExp("[ \\t\\r\\f\\v]+\\n"), "\n"); // strip line trailing cruft
462 msg = msg.trimmed();
463
464 if (msg.isEmpty())
465 {
466 QMessageBox::warning(this, "Commit changes", "Please, add a title.");
467 return false;
468 }
469
470 msg = QString("%1\n%2\n")
471 .arg(msg.section('\n', 0, 0, QString::SectionIncludeTrailingSep), msg.section('\n', 1).trimmed());
472
473 return true;
474}
475
476void CommitChangesWidget::updateCounter(const QString &text)
477{
478 ui->lCounter->setText(QString::number(mTitleMaxLength - text.count()));
479}
480
481bool CommitChangesWidget::hasConflicts()
482{
483 for (const auto &iter : qAsConst(mInternalCache))
484 if (iter.item->data(GitQlientRole::U_IsConflict).toBool())
485 return true;
486
487 return false;
488}
489
490void CommitChangesWidget::clear()
491{
492 ui->unstagedFilesList->clear();
493 ui->stagedFilesList->clear();
494 mInternalCache.clear();
495 ui->leCommitTitle->clear();
496 ui->teDescription->clear();
497 ui->applyActionBtn->setEnabled(false);
498}
499
500void CommitChangesWidget::clearStaged()
501{
502 ui->stagedFilesList->clear();
503
504 const auto end = mInternalCache.end();
505 for (auto it = mInternalCache.begin(); it != end;)
506 {
507 if (it.key().contains(QString("-%1").arg(ui->stagedFilesList->objectName())))
508 it = mInternalCache.erase(it);
509 else
510 ++it;
511 }
512
513 ui->applyActionBtn->setEnabled(false);
514}
515
516void CommitChangesWidget::setCommitTitleMaxLength()
517{
518 mTitleMaxLength = GitQlientSettings().globalValue("commitTitleMaxLength", mTitleMaxLength).toInt();
519
520 ui->lCounter->setText(QString::number(mTitleMaxLength));
521 ui->leCommitTitle->setMaxLength(mTitleMaxLength);
522 updateCounter(ui->leCommitTitle->text());
523}
524
525void CommitChangesWidget::showUnstagedMenu(const QPoint &pos)
526{
527 const auto item = ui->unstagedFilesList->itemAt(pos);
528
529 if (item)
530 {
531 const auto fileName = item->toolTip();
532 const auto contextMenu = new UnstagedMenu(mGit, fileName, this);
533 connect(contextMenu, &UnstagedMenu::signalEditFile, this, &CommitChangesWidget::signalEditFile);
534 connect(contextMenu, &UnstagedMenu::signalShowDiff, this, &CommitChangesWidget::requestDiff);
535 connect(contextMenu, &UnstagedMenu::signalCommitAll, this, &CommitChangesWidget::addAllFilesToCommitList);
536 connect(contextMenu, &UnstagedMenu::signalRevertAll, this, &CommitChangesWidget::revertAllChanges);
537 connect(contextMenu, &UnstagedMenu::changeReverted, this, &CommitChangesWidget::changeReverted);
538 connect(contextMenu, &UnstagedMenu::signalCheckedOut, this, &CommitChangesWidget::signalCheckoutPerformed);
539 connect(contextMenu, &UnstagedMenu::signalShowFileHistory, this, &CommitChangesWidget::signalShowFileHistory);
540 connect(contextMenu, &UnstagedMenu::signalStageFile, this, [this, item] { addFileToCommitList(item); });
541 connect(contextMenu, &UnstagedMenu::deleteUntracked, this, &CommitChangesWidget::deleteUntrackedFiles);
542
543 const auto parentPos = ui->unstagedFilesList->mapToParent(pos);
544 contextMenu->popup(mapToGlobal(parentPos));
545 }
546}
547