// SPDX-License-Identifier: LGPL-3.0-or-later OR MIT #include "codex/DiffViewer.h" #include "codex/ui/UiStyle.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace codexui::codex { namespace { constexpr int RepositoryRefreshDelayMs = 120; constexpr int RepositoryPollingIntervalMs = 2000; struct DiffMark { qreal position = 0; QColor color; }; class DiffScrollBar final : public QScrollBar { public: explicit DiffScrollBar(QWidget *parent = nullptr) : QScrollBar(Qt::Vertical, parent) {} void setMarks(std::vector nextMarks) { marks = std::move(nextMarks); update(); } protected: void paintEvent(QPaintEvent *event) override { QScrollBar::paintEvent(event); if (marks.empty()) return; QStyleOptionSlider option; initStyleOption(&option); const QRect groove = style()->subControlRect( QStyle::CC_ScrollBar, &option, QStyle::SC_ScrollBarGroove, this); if (!groove.isValid()) return; QPainter painter(this); painter.setPen(Qt::NoPen); const int width = std::min(4, groove.width()); for (const DiffMark &mark : marks) { painter.setBrush(mark.color); const int y = groove.top() + qRound(mark.position * std::max(0, groove.height() - 2)); painter.drawRoundedRect(groove.right() - width + 1, y, width, 2, 1, 1); } } private: std::vector marks; }; class DiffHighlighter final : public QSyntaxHighlighter { public: explicit DiffHighlighter(QTextDocument *document) : QSyntaxHighlighter(document) {} protected: void highlightBlock(const QString &text) override { QTextCharFormat format; if (text.startsWith(QStringLiteral("@@"))) { format.setForeground(QColor(QString::fromLatin1(UiStyle::diffHunkAccent))); format.setBackground( QColor(QString::fromLatin1(UiStyle::diffHunkSurface))); format.setFontWeight(QFont::DemiBold); } else if (text.startsWith(QLatin1Char('+')) && !text.startsWith(QStringLiteral("+++"))) { format.setForeground(QColor(QString::fromLatin1(UiStyle::diffAdditionText))); format.setBackground(QColor(QString::fromLatin1(UiStyle::diffAdditionSurface))); } else if (text.startsWith(QLatin1Char('-')) && !text.startsWith(QStringLiteral("---"))) { format.setForeground(QColor(QString::fromLatin1(UiStyle::diffDeletionText))); format.setBackground(QColor(QString::fromLatin1(UiStyle::diffDeletionSurface))); } else if (text.startsWith(QStringLiteral("diff --git")) || text.startsWith(QStringLiteral("---")) || text.startsWith(QStringLiteral("+++")) || text.startsWith(QStringLiteral("Binary files")) || text.startsWith(QStringLiteral("GIT binary patch"))) { format.setForeground(QColor(QString::fromLatin1(UiStyle::strongText))); format.setFontWeight(QFont::DemiBold); } else { return; } setFormat(0, text.size(), format); } }; QLabel *label(QString value, const char *kind) { auto *result = new QLabel(std::move(value)); result->setProperty("kind", kind); result->setWordWrap(true); result->setTextInteractionFlags(Qt::TextSelectableByMouse); return result; } QLineEdit *fileTitleView(const QString &objectName, const char *kind) { auto *view = new QLineEdit; view->setObjectName(objectName); view->setProperty("kind", kind); view->setReadOnly(true); view->setAccessibleName(QStringLiteral("Selected file")); view->setPlaceholderText(QStringLiteral("Select a changed file")); view->setMinimumWidth(0); view->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed); return view; } void setFileTitle(QLineEdit &view, const QString &value) { if (view.text() == value) return; view.setText(value); view.setCursorPosition(0); } QPlainTextEdit *diffView(const QString &objectName, const QString &accessibleName) { auto *view = new QPlainTextEdit; view->setObjectName(objectName); view->setAccessibleName(accessibleName); view->setProperty("kind", "infoViewer"); view->setReadOnly(true); view->setLineWrapMode(QPlainTextEdit::NoWrap); view->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); auto *scrollBar = new DiffScrollBar(view); view->setVerticalScrollBar(scrollBar); new DiffHighlighter(view->document()); QObject::connect(view, &QPlainTextEdit::textChanged, view, [view, scrollBar] { std::vector marks; const int blockCount = view->document()->blockCount(); for (QTextBlock block = view->document()->begin(); block.isValid(); block = block.next()) { const QString text = block.text(); QColor color; if (text.startsWith(QStringLiteral("@@"))) color = QColor(QString::fromLatin1(UiStyle::diffHunkAccent)); else if (text.startsWith(QLatin1Char('+')) && !text.startsWith(QStringLiteral("+++"))) color = QColor(QString::fromLatin1(UiStyle::diffAdditionMarker)); else if (text.startsWith(QLatin1Char('-')) && !text.startsWith(QStringLiteral("---"))) color = QColor(QString::fromLatin1(UiStyle::diffDeletionMarker)); if (!color.isValid()) continue; const qreal position = blockCount > 1 ? static_cast(block.blockNumber()) / static_cast(blockCount - 1) : 0; marks.push_back({position, color}); } scrollBar->setMarks(std::move(marks)); }); return view; } GitDiffScope scopeValue(const QComboBox *scope) { return static_cast(scope->currentData().toInt()); } QString scopeName(GitDiffScope scope) { switch (scope) { case GitDiffScope::Staged: return QStringLiteral("Staged changes"); case GitDiffScope::Uncommitted: return QStringLiteral("All changes since HEAD"); default: return QStringLiteral("Unstaged changes"); } } QString repositoryName(const QString &root) { const QString name = QFileInfo(QDir::cleanPath(root)).fileName(); return name.isEmpty() ? root : name; } QString fileTitle(const GitDiffFile &file, bool includeRepository = false) { QString result = !file.previousPath.isEmpty() && file.previousPath != file.path ? QStringLiteral("%1 → %2").arg(file.previousPath, file.path) : file.path; if (includeRepository) result = QStringLiteral("%1 / %2").arg(repositoryName(file.repositoryRoot), result); return result; } QString repositorySummary(const GitDiffSnapshot &snapshot) { return snapshot.repositoryRoots.size() > 1 ? QStringLiteral("%1 repositories") .arg(snapshot.repositoryRoots.size()) : snapshot.repositoryRoots.isEmpty() ? QString{} : snapshot.repositoryRoots.front(); } QString settingsBase(const QString &threadId) { return QStringLiteral("diff/threads/%1") .arg( QString::fromLatin1(QCryptographicHash::hash( threadId.toUtf8(), QCryptographicHash::Sha256) .toHex())); } QStringList stringListSetting(const QString &key) { const QVariant stored = QSettings().value(key); QStringList result = stored.toStringList(); if (result.isEmpty()) { const QString scalar = stored.toString(); if (!scalar.isEmpty()) result.push_back(scalar); } result.removeDuplicates(); return result; } struct SideBySideText { QString left; QString right; }; struct TextViewportState { int position = 0; int anchor = 0; int vertical = 0; int horizontal = 0; }; TextViewportState textViewportState(const QPlainTextEdit &view) { const QTextCursor cursor = view.textCursor(); return {cursor.position(), cursor.anchor(), view.verticalScrollBar()->value(), view.horizontalScrollBar()->value()}; } void restoreTextViewport(QPlainTextEdit &view, const TextViewportState &state) { const int maximum = std::max(0, view.document()->characterCount() - 1); QTextCursor cursor(view.document()); cursor.setPosition(std::clamp(state.anchor, 0, maximum)); cursor.setPosition(std::clamp(state.position, 0, maximum), QTextCursor::KeepAnchor); view.setTextCursor(cursor); view.verticalScrollBar()->setValue(state.vertical); view.horizontalScrollBar()->setValue(state.horizontal); } void setDocumentText(QPlainTextEdit &view, const QString &text) { if (view.toPlainText() != text) view.setPlainText(text); } QString sideLine(QChar marker, int number, const QString &content) { return QStringLiteral("%1%2 │ %3") .arg(marker) .arg(number > 0 ? QString::number(number).rightJustified(6) : QString(6, QLatin1Char(' '))) .arg(content); } SideBySideText sideBySide(const QString &patch) { QStringList left; QStringList right; const QStringList lines = patch.split(QLatin1Char('\n')); static const QRegularExpression hunk( QStringLiteral(R"(^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@)")); int oldLine = 0; int newLine = 0; for (qsizetype index = 0; index < lines.size();) { const QString &line = lines[index]; const QRegularExpressionMatch match = hunk.match(line); if (match.hasMatch()) { oldLine = match.captured(1).toInt(); newLine = match.captured(2).toInt(); left << line; right << line; ++index; continue; } if (line.startsWith(QLatin1Char('-')) && !line.startsWith(QStringLiteral("---"))) { QStringList removed; QStringList added; while (index < lines.size() && lines[index].startsWith(QLatin1Char('-')) && !lines[index].startsWith(QStringLiteral("---"))) removed << lines[index++].mid(1); while (index < lines.size() && lines[index].startsWith(QLatin1Char('+')) && !lines[index].startsWith(QStringLiteral("+++"))) added << lines[index++].mid(1); const qsizetype count = std::max(removed.size(), added.size()); for (qsizetype row = 0; row < count; ++row) { const bool hasOld = row < removed.size(); const bool hasNew = row < added.size(); left << sideLine(hasOld ? QLatin1Char('-') : QLatin1Char(' '), hasOld ? oldLine++ : 0, hasOld ? removed[row] : QString{}); right << sideLine(hasNew ? QLatin1Char('+') : QLatin1Char(' '), hasNew ? newLine++ : 0, hasNew ? added[row] : QString{}); } continue; } if (line.startsWith(QLatin1Char('+')) && !line.startsWith(QStringLiteral("+++"))) { left << sideLine(QLatin1Char(' '), 0, {}); right << sideLine(QLatin1Char('+'), newLine++, line.mid(1)); } else if (line.startsWith(QLatin1Char(' ')) && oldLine > 0 && newLine > 0) { left << sideLine(QLatin1Char(' '), oldLine++, line.mid(1)); right << sideLine(QLatin1Char(' '), newLine++, line.mid(1)); } else { left << line; right << line; } ++index; } return {left.join(QLatin1Char('\n')), right.join(QLatin1Char('\n'))}; } QPushButton *modeButton(const QString &text) { auto *button = new QPushButton(text); button->setCheckable(true); button->setProperty("kind", "segment"); button->setFixedHeight(30); return button; } } // namespace class GitDiffReviewWindow final : public QDialog { public: explicit GitDiffReviewWindow(std::function requestRefresh, QWidget *parent = nullptr) : QDialog(parent), requestRefresh(std::move(requestRefresh)) { setWindowTitle(QStringLiteral("Change Review")); setAttribute(Qt::WA_DeleteOnClose); setWindowModality(Qt::NonModal); resize(1200, 780); auto *root = new QVBoxLayout(this); root->setContentsMargins(14, 14, 14, 14); root->setSpacing(10); auto *header = new QHBoxLayout; title = fileTitleView(QStringLiteral("codexReviewSelectedFile"), "heading"); subtitle = label({}, "meta"); auto *titles = new QVBoxLayout; titles->setSpacing(1); titles->addWidget(title); titles->addWidget(subtitle); header->addLayout(titles, 1); unified = modeButton(QStringLiteral("Unified")); split = modeButton(QStringLiteral("Side by side")); auto *layoutModes = new QButtonGroup(this); layoutModes->setExclusive(true); layoutModes->addButton(unified); layoutModes->addButton(split); header->addWidget(unified); header->addWidget(split); header->addSpacing(8); compact = modeButton(QStringLiteral("Compact")); expanded = modeButton(QStringLiteral("Expanded")); auto *contextModes = new QButtonGroup(this); contextModes->setExclusive(true); contextModes->addButton(compact); contextModes->addButton(expanded); header->addWidget(compact); header->addWidget(expanded); root->addLayout(header); auto *body = new QSplitter; reviewFiles = new QListWidget; reviewFiles->setAccessibleName(QStringLiteral("Review files")); reviewFiles->setMinimumWidth(230); reviewFiles->setMaximumWidth(420); body->addWidget(reviewFiles); views = new QStackedWidget; unifiedView = diffView(QStringLiteral("codexReviewUnified"), QStringLiteral("Unified diff")); views->addWidget(unifiedView); auto *sides = new QSplitter; leftView = diffView(QStringLiteral("codexReviewBefore"), QStringLiteral("Before changes")); rightView = diffView(QStringLiteral("codexReviewAfter"), QStringLiteral("After changes")); sides->addWidget(leftView); sides->addWidget(rightView); sides->setSizes({600, 600}); views->addWidget(sides); body->addWidget(views); body->setStretchFactor(1, 1); root->addWidget(body, 1); connect(reviewFiles, &QListWidget::currentRowChanged, this, [this] { renderSelected(); }); connect(unified, &QPushButton::clicked, this, [this] { views->setCurrentIndex(0); QSettings().setValue(QStringLiteral("diff/layout"), QStringLiteral("unified")); renderSelected(); }); connect(split, &QPushButton::clicked, this, [this] { views->setCurrentIndex(1); QSettings().setValue(QStringLiteral("diff/layout"), QStringLiteral("side-by-side")); renderSelected(); }); connect(compact, &QPushButton::clicked, this, [this] { context = GitDiffContext::Compact; QSettings().setValue(QStringLiteral("diff/context"), QStringLiteral("compact")); this->requestRefresh(); }); connect(expanded, &QPushButton::clicked, this, [this] { context = GitDiffContext::Expanded; QSettings().setValue(QStringLiteral("diff/context"), QStringLiteral("expanded")); this->requestRefresh(); }); connect(leftView->verticalScrollBar(), &QScrollBar::valueChanged, rightView->verticalScrollBar(), &QScrollBar::setValue); connect(rightView->verticalScrollBar(), &QScrollBar::valueChanged, leftView->verticalScrollBar(), &QScrollBar::setValue); connect(leftView->horizontalScrollBar(), &QScrollBar::valueChanged, rightView->horizontalScrollBar(), &QScrollBar::setValue); connect(rightView->horizontalScrollBar(), &QScrollBar::valueChanged, leftView->horizontalScrollBar(), &QScrollBar::setValue); const QSettings settings; const bool side = settings.value(QStringLiteral("diff/layout"), QStringLiteral("unified")) == QStringLiteral("side-by-side"); unified->setChecked(!side); split->setChecked(side); views->setCurrentIndex(side ? 1 : 0); const bool full = settings.value(QStringLiteral("diff/context"), QStringLiteral("compact")) == QStringLiteral("expanded"); compact->setChecked(!full); expanded->setChecked(full); context = full ? GitDiffContext::Expanded : GitDiffContext::Compact; } [[nodiscard]] GitDiffContext requestedContext() const noexcept { return context; } void setLoading(bool loading) { if (loading && (!snapshot || snapshot->files.empty())) subtitle->setText(QStringLiteral("Loading repository changes…")); } void setSnapshot(const GitDiffSnapshot &value, QString preferredPath) { const bool selectionChanged = requestedPath != preferredPath; requestedPath = std::move(preferredPath); if (snapshot && *snapshot == value) { if (selectionChanged) { const auto selected = std::ranges::find_if( value.files, [this](const GitDiffFile &file) { return file.absolutePath == requestedPath; }); if (selected != value.files.end()) reviewFiles->setCurrentRow( static_cast(selected - value.files.begin())); } return; } apply(value); } private: void apply(const GitDiffSnapshot &value) { snapshot = value; subtitle->setText(value.error.isEmpty() ? QStringLiteral("%1 | %2") .arg(scopeName(value.scope), repositorySummary(value)) : value.error); reviewFiles->clear(); int selected = -1; for (std::size_t index = 0; index < value.files.size(); ++index) { const GitDiffFile &file = value.files[index]; auto *item = new QListWidgetItem( QStringLiteral("%1\n%2 +%3 −%4") .arg(fileTitle(file, value.repositoryRoots.size() > 1), file.status) .arg(file.additions) .arg(file.deletions)); item->setToolTip(file.absolutePath); reviewFiles->addItem(item); if (file.absolutePath == requestedPath) selected = static_cast(index); } if (!value.files.empty()) reviewFiles->setCurrentRow(selected >= 0 ? selected : 0); else { title->clear(); unifiedView->setPlainText(value.error.isEmpty() ? QStringLiteral("No file changes") : value.error); leftView->clear(); rightView->clear(); } } void renderSelected() { const int index = reviewFiles->currentRow(); if (!snapshot || index < 0 || static_cast(index) >= snapshot->files.size()) return; const GitDiffFile &file = snapshot->files[static_cast(index)]; const bool sameFile = requestedPath == file.absolutePath; const TextViewportState unifiedState = textViewportState(*unifiedView); const TextViewportState leftState = textViewportState(*leftView); const TextViewportState rightState = textViewportState(*rightView); requestedPath = file.absolutePath; setFileTitle(*title, fileTitle(file, snapshot->repositoryRoots.size() > 1)); const QString content = file.patch.isEmpty() ? QStringLiteral("No textual patch is available for this file.") : file.patch; if (views->currentIndex() == 0) { setDocumentText(*leftView, {}); setDocumentText(*rightView, {}); setDocumentText(*unifiedView, content); if (sameFile) restoreTextViewport(*unifiedView, unifiedState); else unifiedView->moveCursor(QTextCursor::Start); return; } setDocumentText(*unifiedView, {}); const SideBySideText sides = sideBySide(content); setDocumentText(*leftView, sides.left); setDocumentText(*rightView, sides.right); if (sameFile) { restoreTextViewport(*leftView, leftState); restoreTextViewport(*rightView, rightState); } else { leftView->moveCursor(QTextCursor::Start); rightView->moveCursor(QTextCursor::Start); } } std::function requestRefresh; std::optional snapshot; QString requestedPath; GitDiffContext context = GitDiffContext::Compact; QLineEdit *title = nullptr; QLabel *subtitle = nullptr; QListWidget *reviewFiles = nullptr; QStackedWidget *views = nullptr; QPlainTextEdit *unifiedView = nullptr; QPlainTextEdit *leftView = nullptr; QPlainTextEdit *rightView = nullptr; QPushButton *unified = nullptr; QPushButton *split = nullptr; QPushButton *compact = nullptr; QPushButton *expanded = nullptr; }; DiffViewer::DiffViewer(QWidget *parent) : QWidget(parent) { provider = new GitDiffProvider(this); fileWatcher = new QFileSystemWatcher(this); refreshTimer = new QTimer(this); refreshTimer->setSingleShot(true); refreshTimer->setInterval(RepositoryRefreshDelayMs); repositoryTimer = new QTimer(this); repositoryTimer->setInterval(RepositoryPollingIntervalMs); auto *root = new QVBoxLayout(this); root->setContentsMargins(0, 0, 0, 0); root->setSpacing(8); auto *filters = new QHBoxLayout; filters->setContentsMargins(10, 10, 10, 0); filters->setSpacing(8); repositories = new UiStyle::ChevronComboBox; repositories->setObjectName(QStringLiteral("codexDiffRepository")); repositories->addItem(QStringLiteral("Repository"), QString{}); repositories->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed); filters->addWidget(repositories, 1); hiddenRepositories = new QPushButton(QStringLiteral("Hidden")); hiddenRepositories->setObjectName( QStringLiteral("codexDiffHiddenRepositories")); hiddenRepositories->setProperty("kind", "segment"); hiddenRepositories->setProperty("comboPeer", true); hiddenRepositories->setCheckable(true); hiddenRepositories->setChecked( QSettings() .value(QStringLiteral("diff/includeHiddenRepositories"), false) .toBool()); hiddenRepositories->setToolTip( QStringLiteral("Also include hidden repositories")); filters->addWidget(hiddenRepositories); scope = new UiStyle::ChevronComboBox; scope->setObjectName(QStringLiteral("codexDiffScope")); scope->addItem(QStringLiteral("Unstaged"), static_cast(GitDiffScope::Unstaged)); scope->addItem(QStringLiteral("Staged"), static_cast(GitDiffScope::Staged)); scope->addItem(QStringLiteral("Since HEAD"), static_cast(GitDiffScope::Uncommitted)); const int savedScope = QSettings().value(QStringLiteral("diff/scope"), 0).toInt(); scope->setCurrentIndex(std::clamp(savedScope, 0, scope->count() - 1)); scope->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed); filters->addWidget(scope, 1); root->addLayout(filters); files = new QListWidget; files->setAccessibleName(QStringLiteral("Changed files")); files->setObjectName(QStringLiteral("codexDiffFiles")); files->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); files->setMaximumHeight(170); auto *fileList = new QVBoxLayout; fileList->setContentsMargins(10, 0, 10, 0); fileList->addWidget(files); root->addLayout(fileList); auto *fileSummary = new QHBoxLayout; fileSummary->setContentsMargins(10, 0, 10, 0); fileSummary->setSpacing(8); summary = label(QStringLiteral("No changes"), "meta"); summary->setObjectName(QStringLiteral("codexDiffSummary")); authority = label({}, "meta"); authority->setWordWrap(false); authority->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); truncationSummary = label({}, "attentionSection"); additionSummary = label({}, "diffAdditionMeta"); deletionSummary = label({}, "diffDeletionMeta"); fileSummary->addWidget(summary); fileSummary->addWidget(authority, 1); fileSummary->addWidget(truncationSummary); fileSummary->addWidget(additionSummary); fileSummary->addWidget(deletionSummary); root->addLayout(fileSummary); auto *previewDivider = new QFrame; previewDivider->setProperty("kind", "standardDivider"); previewDivider->setFixedHeight(1); root->addWidget(previewDivider); auto *previewHeader = new QGridLayout; previewHeader->setContentsMargins(10, 0, 10, 0); previewHeader->setColumnStretch(0, 1); selectedFile = fileTitleView(QStringLiteral("codexDiffSelectedFile"), "title"); previewHeader->addWidget(selectedFile, 0, 0, 1, 2); copyButton = new QPushButton(QStringLiteral("Copy")); copyButton->setProperty("kind", "subtle"); copyButton->setFixedHeight(28); reviewButton = new QPushButton(QStringLiteral("Open review")); reviewButton->setProperty("comboPeer", true); previewHeader->addWidget(copyButton, 1, 0, Qt::AlignRight); previewHeader->addWidget(reviewButton, 1, 1); root->addLayout(previewHeader); diff = diffView(QStringLiteral("codexDiffText"), QStringLiteral("Changes diff")); diff->setPlaceholderText(QStringLiteral("Select a changed file.")); auto *diffArea = new QVBoxLayout; diffArea->setContentsMargins(10, 0, 10, 10); diffArea->addWidget(diff); root->addLayout(diffArea, 1); connect(refreshTimer, &QTimer::timeout, this, [this] { provider->request(workspace, repositoryCandidates(), changedPaths, selectedRepository, hiddenRepositories->isChecked(), scopeValue(scope), reviewWindow ? reviewWindow->requestedContext() : GitDiffContext::Compact); }); connect(provider, &GitDiffProvider::loadingChanged, this, [this](bool loading) { if (loading && !snapshot) { summary->setText(QStringLiteral("Loading changes…")); } if (reviewWindow) reviewWindow->setLoading(loading); }); connect(provider, &GitDiffProvider::snapshotReady, this, [this](const GitDiffSnapshot &value) { applySnapshot(value); }); connect(fileWatcher, &QFileSystemWatcher::fileChanged, this, [this](const QString &) { refreshRepository(); }); connect(fileWatcher, &QFileSystemWatcher::directoryChanged, this, [this](const QString &) { refreshRepository(); }); connect(repositoryTimer, &QTimer::timeout, this, [this] { if (isVisible()) provider->request(workspace, repositoryCandidates(), changedPaths, selectedRepository, hiddenRepositories->isChecked(), scopeValue(scope), reviewWindow ? reviewWindow->requestedContext() : GitDiffContext::Compact); }); connect(repositories, &QComboBox::currentIndexChanged, this, [this](int) { selectedRepository = repositories->currentData().toString(); if (!threadId.isEmpty()) QSettings().setValue(settingsBase(threadId) + QStringLiteral("/selected"), selectedRepository); refreshRepository(); }); connect(hiddenRepositories, &QPushButton::toggled, this, [this](bool value) { QSettings().setValue(QStringLiteral("diff/includeHiddenRepositories"), value); refreshRepository(); }); connect(scope, &QComboBox::currentIndexChanged, this, [this](int index) { QSettings().setValue(QStringLiteral("diff/scope"), index); refreshRepository(); }); connect(files, &QListWidget::currentRowChanged, this, [this] { showSelectedFile(); }); connect(files, &QListWidget::itemDoubleClicked, this, [this](QListWidgetItem *) { openReview(); }); connect(copyButton, &QPushButton::clicked, this, [this] { if (!diff->toPlainText().isEmpty()) QApplication::clipboard()->setText(diff->toPlainText()); }); connect(reviewButton, &QPushButton::clicked, this, [this] { openReview(); }); copyButton->setEnabled(false); reviewButton->setEnabled(false); } void DiffViewer::setRepositoryContext(QString nextThreadId, QString nextWorkspace, QStringList nextCommandDirectories, QStringList nextChangedPaths) { if (!nextWorkspace.isEmpty()) nextWorkspace = QDir::cleanPath(std::move(nextWorkspace)); nextCommandDirectories.removeDuplicates(); nextChangedPaths.removeDuplicates(); if (threadId == nextThreadId && workspace == nextWorkspace && commandDirectories == nextCommandDirectories && changedPaths == nextChangedPaths) return; refreshTimer->stop(); provider->cancel(); const bool changedThread = threadId != nextThreadId; threadId = std::move(nextThreadId); workspace = std::move(nextWorkspace); commandDirectories = std::move(nextCommandDirectories); changedPaths = std::move(nextChangedPaths); if (changedThread) { const QString base = settingsBase(threadId); persistedRepositoryRoots = stringListSetting(base + QStringLiteral("/roots")); selectedRepository = QSettings().value(base + QStringLiteral("/selected")).toString(); } snapshot.reset(); updateFileWatches(); files->clear(); diff->clear(); refreshRepository(); } void DiffViewer::showEvent(QShowEvent *event) { QWidget::showEvent(event); repositoryTimer->start(); } void DiffViewer::hideEvent(QHideEvent *event) { repositoryTimer->stop(); QWidget::hideEvent(event); } const GitDiffSnapshot &DiffViewer::currentSnapshot() const noexcept { static const GitDiffSnapshot empty; return snapshot ? *snapshot : empty; } void DiffViewer::refreshRepository() { refreshTimer->start(); } QStringList DiffViewer::repositoryCandidates() const { QStringList result = commandDirectories; result.append(persistedRepositoryRoots); result.removeDuplicates(); return result; } QString DiffViewer::selectedPath() const { const int index = files->currentRow(); if (!snapshot || index < 0 || static_cast(index) >= snapshot->files.size()) return {}; return snapshot->files[static_cast(index)].absolutePath; } void DiffViewer::applySnapshot(const GitDiffSnapshot &value) { if (snapshot && *snapshot == value) { updateFileWatches(); if (reviewWindow) reviewWindow->setSnapshot(value, selectedPath()); return; } const QString previous = selectedPath(); const TextViewportState previousViewport = textViewportState(*diff); snapshot = value; updateFileWatches(); if (!threadId.isEmpty() && !value.repositoryRoots.isEmpty() && persistedRepositoryRoots != value.repositoryRoots) { persistedRepositoryRoots = value.repositoryRoots; QSettings settings; settings.setValue(settingsBase(threadId) + QStringLiteral("/roots"), persistedRepositoryRoots); } { const QSignalBlocker blocked(repositories); repositories->clear(); if (value.repositoryRoots.size() > 1) repositories->addItem(QStringLiteral("All repositories"), QString{}); for (const QString &root : value.repositoryRoots) { repositories->addItem(repositoryName(root), root); repositories->setItemData(repositories->count() - 1, root, Qt::ToolTipRole); } int selectedIndex = repositories->findData(selectedRepository); if (selectedIndex < 0) selectedIndex = 0; repositories->setCurrentIndex(selectedIndex); selectedRepository = repositories->currentData().toString(); } files->clear(); int additions = 0; int deletions = 0; int selected = -1; for (std::size_t index = 0; index < value.files.size(); ++index) { const GitDiffFile &file = value.files[index]; additions += file.additions; deletions += file.deletions; auto *item = new QListWidgetItem( QStringLiteral("%1 %2 +%3 −%4") .arg(fileTitle(file, value.repositoryRoots.size() > 1), file.status) .arg(file.additions) .arg(file.deletions)); item->setToolTip(file.absolutePath); files->addItem(item); if (file.absolutePath == previous) selected = static_cast(index); } if (!value.error.isEmpty()) { summary->setText(QStringLiteral("Changes unavailable")); authority->setText(value.error); authority->setToolTip(value.error); truncationSummary->clear(); additionSummary->clear(); deletionSummary->clear(); } else { summary->setText( value.files.empty() ? QStringLiteral("No changes") : value.files.size() == 1 ? QStringLiteral("1 changed file") : QStringLiteral("%1 changed files").arg(value.files.size())); authority->clear(); authority->setToolTip(QString{}); truncationSummary->setText( value.truncated ? QStringLiteral("Display truncated") : QString{}); additionSummary->setText( value.files.empty() ? QString{} : QStringLiteral("+%1").arg(additions)); deletionSummary->setText( value.files.empty() ? QString{} : QStringLiteral("−%1").arg(deletions)); } if (!value.files.empty()) { files->setCurrentRow(selected >= 0 ? selected : 0); if (selected >= 0) restoreTextViewport(*diff, previousViewport); } else { selectedFile->clear(); diff->setPlainText(value.error); } copyButton->setEnabled(!value.files.empty()); reviewButton->setEnabled(!value.files.empty()); if (reviewWindow) reviewWindow->setSnapshot(value, selectedPath()); } void DiffViewer::updateFileWatches() { QStringList desired; QSet desiredSet; const auto retain = [&desired, &desiredSet](const QString &path) { if (!path.isEmpty() && !desiredSet.contains(path)) { desired.push_back(path); desiredSet.insert(path); } }; if (snapshot) { for (const GitDiffFile &file : snapshot->files) { const QFileInfo info(file.absolutePath); if (info.exists()) retain(info.absoluteFilePath()); const QString parent = info.absolutePath(); if (QFileInfo(parent).isDir()) retain(parent); } } const QStringList existing = fileWatcher->files() + fileWatcher->directories(); const QSet existingSet(existing.begin(), existing.end()); QStringList removed; for (const QString &path : existing) { if (!desiredSet.contains(path)) removed.push_back(path); } if (!removed.isEmpty()) fileWatcher->removePaths(removed); QStringList added; for (const QString &path : desired) { if (!existingSet.contains(path)) added.push_back(path); } if (!added.isEmpty()) fileWatcher->addPaths(added); } void DiffViewer::showSelectedFile() { const int index = files->currentRow(); if (!snapshot || index < 0 || static_cast(index) >= snapshot->files.size()) { selectedFile->clear(); diff->clear(); return; } const GitDiffFile &file = snapshot->files[static_cast(index)]; setFileTitle(*selectedFile, fileTitle(file, snapshot->repositoryRoots.size() > 1)); const QString content = file.patch.isEmpty() ? QStringLiteral("No textual patch is available for this file.") : file.patch; if (diff->toPlainText() == content) return; diff->setPlainText(content); diff->moveCursor(QTextCursor::Start); } void DiffViewer::openReview() { if (selectedPath().isEmpty()) return; if (!reviewWindow) { reviewWindow = new GitDiffReviewWindow( [this] { refreshRepository(); }, window()); connect(reviewWindow, &QObject::destroyed, refreshTimer, qOverload<>(&QTimer::start)); } if (snapshot) reviewWindow->setSnapshot(*snapshot, selectedPath()); reviewWindow->show(); reviewWindow->raise(); reviewWindow->activateWindow(); } } // namespace codexui::codex