MatchModel.cpp 34.1 KB
Newer Older
1 2
/***************************************************************************
 *   This file is part of Kate build plugin
Kåre Särs's avatar
Kåre Särs committed
3
 *   SPDX-FileCopyrightText: 2021 Kåre Särs <kare.sars@iki.fi>
4 5 6 7 8 9 10 11 12 13 14
 *
 *   SPDX-License-Identifier: LGPL-2.0-or-later
 ***************************************************************************/

#include "MatchModel.h"
#include <KLocalizedString>
#include <QDebug>
#include <QTimer>
#include <QRegularExpression>
#include <QFileInfo>
#include <QDir>
Kåre Särs's avatar
Kåre Särs committed
15
#include <algorithm> // std::count_if
16

17 18
#include <ktexteditor/movinginterface.h>
#include <ktexteditor/movingrange.h>
19

20 21
static const quintptr InfoItemId = 0xFFFFFFFF;
static const quintptr FileItemId = 0x7FFFFFFF;
22

Kåre Särs's avatar
Kåre Särs committed
23 24 25 26 27 28 29 30 31
// Model indexes
// - (0, 0, InfoItemId) (row, column, internalId)
//   | - (0, 0, FileItemId)
//   |    | - (0, 0, 0)
//   |    | - (1, 0, 0)
//   | - (1, 0, FileItemId)
//   |    | - (0, 0, 1)
//   |    | - (1, 0, 1)

32 33 34 35 36 37 38 39 40 41 42 43
static QUrl localFileDirUp(const QUrl &url)
{
    if (!url.isLocalFile())
        return url;

    // else go up
    return QUrl::fromLocalFile(QFileInfo(url.toLocalFile()).dir().absolutePath());
}

MatchModel::MatchModel(QObject *parent)
    : QAbstractItemModel(parent)
{
Kåre Särs's avatar
Kåre Särs committed
44
    m_infoUpdateTimer.setInterval(100); // FIXME why does this delay not work?
45 46
    m_infoUpdateTimer.setSingleShot(true);
    connect(&m_infoUpdateTimer, &QTimer::timeout, this, [this]() {
Kåre Särs's avatar
Kåre Särs committed
47
        dataChanged(createIndex(0, 0, InfoItemId), createIndex(0, 0, InfoItemId));
48
    });
49
}
50 51 52 53

MatchModel::~MatchModel() {}

void MatchModel::setDocumentManager(KTextEditor::Application *manager)
54
{
55 56
    m_docManager = manager;
    connect(m_docManager, &KTextEditor::Application::documentWillBeDeleted, this, &MatchModel::cancelReplace);
57 58
}

Kåre Särs's avatar
Kåre Särs committed
59 60 61
void MatchModel::setSearchPlace(MatchModel::SearchPlaces searchPlace)
{
    m_searchPlace = searchPlace;
62
    if (!m_infoUpdateTimer.isActive()) m_infoUpdateTimer.start();
Kåre Särs's avatar
Kåre Särs committed
63 64
}

Kåre Särs's avatar
Kåre Särs committed
65 66 67 68 69 70 71
void MatchModel::setFileListUpdate(const QString &path)
{
    m_lastSearchPath = path;
    m_searchState = Preparing;
    if (!m_infoUpdateTimer.isActive()) m_infoUpdateTimer.start();
}

Kåre Särs's avatar
Kåre Särs committed
72 73 74
void MatchModel::setSearchState(MatchModel::SearchState searchState)
{
    m_searchState = searchState;
75
    if (!m_infoUpdateTimer.isActive()) m_infoUpdateTimer.start();
76 77 78 79 80 81 82 83
    if (m_searchState == SearchDone) {
        beginResetModel();
        std::sort(m_matchFiles.begin(), m_matchFiles.end(), [](const MatchFile &l, const MatchFile &r) { return l.fileUrl < r.fileUrl; });
        for (int i=0; i<m_matchFiles.size(); ++i) {
            m_matchFileIndexHash[m_matchFiles[i].fileUrl] = i;
        }
        endResetModel();
    }
Kåre Särs's avatar
Kåre Särs committed
84 85 86 87 88
}

void MatchModel::setBaseSearchPath(const QString &baseSearchPath)
{
    m_resultBaseDir = baseSearchPath;
89
    if (!m_infoUpdateTimer.isActive()) m_infoUpdateTimer.start();
Kåre Särs's avatar
Kåre Särs committed
90 91 92 93 94
}

void MatchModel::setProjectName(const QString &projectName)
{
    m_projectName = projectName;
95
    if (!m_infoUpdateTimer.isActive()) m_infoUpdateTimer.start();
Kåre Särs's avatar
Kåre Särs committed
96 97
}

98 99 100 101 102 103 104 105 106 107
void MatchModel::clear()
{
    beginResetModel();
    m_matchFiles.clear();
    m_matchFileIndexHash.clear();
    endResetModel();
}

/** This function returns the row index of the specified file.
 * If the file does not exist in the model, the file will be added to the model. */
108
int MatchModel::matchFileRow(const QUrl& fileUrl) const
109 110 111 112 113
{
    return m_matchFileIndexHash.value(fileUrl, -1);
}

/** This function is used to add a match to a new file */
Kåre Särs's avatar
Kåre Särs committed
114
void MatchModel::addMatches(const QUrl &fileUrl, const QVector<KateSearchMatch> &searchMatches)
115
{
Kåre Särs's avatar
Kåre Särs committed
116
    m_lastMatchUrl = fileUrl;
Kåre Särs's avatar
Kåre Särs committed
117
    m_searchState = Searching;
118 119 120 121 122 123 124 125
    // update match/search info
    if (!m_infoUpdateTimer.isActive()) m_infoUpdateTimer.start();

    if (m_matchFiles.isEmpty()) {
        beginInsertRows(QModelIndex(), 0, 0);
        endInsertRows();
    }

Kåre Särs's avatar
Kåre Särs committed
126 127 128 129
    if (searchMatches.isEmpty()) {
        return;
    }

130 131 132 133
    int fileIndex = matchFileRow(fileUrl);
    if (fileIndex == -1) {
        fileIndex = m_matchFiles.size();
        m_matchFileIndexHash.insert(fileUrl, fileIndex);
134
        beginInsertRows(createIndex(0,0,InfoItemId), fileIndex, fileIndex);
135 136 137 138 139 140
        // We are always starting the insert at the end, so we could optimize by delaying/grouping the signaling of the updates
        m_matchFiles.append(MatchFile());
        m_matchFiles[fileIndex].fileUrl = fileUrl;
        endInsertRows();
    }

Kåre Särs's avatar
Kåre Särs committed
141
    int matchIndex = m_matchFiles[fileIndex].matches.size();
142
    beginInsertRows(createIndex(fileIndex, 0 , FileItemId), matchIndex, matchIndex + searchMatches.size()-1);
143
    m_matchFiles[fileIndex].matches += searchMatches;
144 145 146
    endInsertRows();
}

147
void MatchModel::setMatchColors(const QString &foreground, const QString &background, const QString &replaceBackground)
148 149 150
{
    m_foregroundColor = foreground;
    m_searchBackgroundColor = background;
151
    m_replaceHighlightColor = replaceBackground;
152 153
}

154
KateSearchMatch *MatchModel::matchFromIndex(const QModelIndex &matchIndex)
155 156 157 158 159
{
    if (!isMatch(matchIndex)) {
        qDebug() << "Not a valid match index";
        return nullptr;
    }
160

161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177
    int fileRow = matchIndex.internalId();
    int matchRow = matchIndex.row();

    return &m_matchFiles[fileRow].matches[matchRow];
}

KTextEditor::Range MatchModel::matchRange(const QModelIndex &matchIndex) const
{
    if (!isMatch(matchIndex)) {
        qDebug() << "Not a valid match index";
        return KTextEditor::Range();
    }
    int fileRow = matchIndex.internalId();
    int matchRow = matchIndex.row();
    return m_matchFiles[fileRow].matches[matchRow].range;
}

178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224
const QVector<KateSearchMatch> &MatchModel::fileMatches(const QUrl& fileUrl) const
{
    static const QVector<KateSearchMatch> EmptyDummy;

    int row = matchFileRow(fileUrl);
    if (row < 0 || row >= m_matchFiles.size()) {
        return EmptyDummy;
    }
    return m_matchFiles[row].matches;
}

void MatchModel::updateMatchRanges(const QVector<KTextEditor::MovingRange *> &ranges)
{
    if (ranges.isEmpty()) {
        return;
    }

    const QUrl &fileUrl = ranges.first()->document()->url();
    // NOTE: we assume there are only ranges for one document in the provided ranges
    // NOTE: we also assume the document is not deleted as we clear the ranges when the document is deleted

    int fileRow = matchFileRow(fileUrl);
    if (fileRow < 0 || fileRow >= m_matchFiles.size()) {
        //qDebug() << "No such results" << fileRow << fileUrl;
        return; // No such document in the results
    }

    QVector<KateSearchMatch> &matches = m_matchFiles[fileRow].matches;

    if (ranges.size() != matches.size()) {
        // The sizes do not match so we cannot match the ranges easily.. abort
        qDebug() << ranges.size() << "!=" << matches.size();
        return;
    }

    if (ranges.size() > 1000) {
        // if we have > 1000 matches in a file it could get slow to update it all the time
        return;
    }

    for (int i=0; i<ranges.size(); ++i) {
        matches[i].range = ranges[i]->toRange();
    }
    QModelIndex rootFileIndex = index(fileRow, 0, createIndex(0, 0, InfoItemId));
    dataChanged(index(0, 0, rootFileIndex), index(matches.count()-1, 0, rootFileIndex));
}

225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346

/** This function is used to replace a match */
bool MatchModel::replaceMatch(KTextEditor::Document *doc, const QModelIndex &matchIndex, const QRegularExpression &regExp, const QString &replaceString)
{
    if (!doc) {
        qDebug() << "No doc";
        return false;
    }

    Match *matchItem = matchFromIndex(matchIndex);

    if (!matchItem) {
        qDebug() << "Not a valid index";
        return false;
    }

    // don't replace an already replaced item
    if (!matchItem->replaceText.isEmpty()) {
        // qDebug() << "not replacing already replaced item";
        return false;
    }

    // Check that the text has not been modified and still matches + get captures for the replace
    QString matchLines = doc->text(matchItem->range);
    QRegularExpressionMatch match = regExp.match(matchLines);
    if (match.capturedStart() != 0) {
        qDebug() << matchLines << "Does not match" << regExp.pattern();
        return false;
    }

    // Modify the replace string according to this match
    QString replaceText = replaceString;
    replaceText.replace(QLatin1String("\\\\"), QLatin1String("¤Search&Replace¤"));

    // allow captures \0 .. \9
    for (int j = qMin(9, match.lastCapturedIndex()); j >= 0; --j) {
        QString captureLX = QStringLiteral("\\L\\%1").arg(j);
        QString captureUX = QStringLiteral("\\U\\%1").arg(j);
        QString captureX = QStringLiteral("\\%1").arg(j);
        replaceText.replace(captureLX, match.captured(j).toLower());
        replaceText.replace(captureUX, match.captured(j).toUpper());
        replaceText.replace(captureX, match.captured(j));
    }

    // allow captures \{0} .. \{9999999}...
    for (int j = match.lastCapturedIndex(); j >= 0; --j) {
        QString captureLX = QStringLiteral("\\L\\{%1}").arg(j);
        QString captureUX = QStringLiteral("\\U\\{%1}").arg(j);
        QString captureX = QStringLiteral("\\{%1}").arg(j);
        replaceText.replace(captureLX, match.captured(j).toLower());
        replaceText.replace(captureUX, match.captured(j).toUpper());
        replaceText.replace(captureX, match.captured(j));
    }

    replaceText.replace(QLatin1String("\\n"), QLatin1String("\n"));
    replaceText.replace(QLatin1String("\\t"), QLatin1String("\t"));
    replaceText.replace(QLatin1String("¤Search&Replace¤"), QLatin1String("\\"));

    // Replace the string
    doc->replaceText(matchItem->range, replaceText);

    // update the range
    int newEndLine = matchItem->range.start().line() + replaceText.count(QLatin1Char('\n'));
    int lastNL = replaceText.lastIndexOf(QLatin1Char('\n'));
    int newEndColumn = lastNL == -1 ? matchItem->range.start().column() + replaceText.length() : replaceText.length() - lastNL - 1;
    matchItem->range.setEnd(KTextEditor::Cursor{newEndLine, newEndColumn});

    matchItem->replaceText = replaceText;
    return true;
}

/** This function is used to replace a match */
bool MatchModel::replaceSingleMatch(KTextEditor::Document *doc, const QModelIndex &matchIndex, const QRegularExpression &regExp, const QString &replaceString)
{
    if (!doc) {
        qDebug() << "No doc";
        return false;
    }

    if (!isMatch(matchIndex)) {
        qDebug() << "This should not be possible";
        return false;
    }

    if (matchIndex.internalId() == InfoItemId || matchIndex.internalId() == FileItemId) {
        qDebug() << "You cannot replace a file or the info item";
        return false;
    }

    // Create a vector of moving ranges for updating the tree-view after replace
    QVector<KTextEditor::MovingRange *> matchRanges;
    KTextEditor::MovingInterface *miface = qobject_cast<KTextEditor::MovingInterface *>(doc);

    // Only add items after "matchIndex"
    int fileRow = matchIndex.internalId();
    int matchRow = matchIndex.row();

    QVector<Match> &matches = m_matchFiles[fileRow].matches;

    for (int i = matchRow+1; i < matches.size(); ++i) {
        KTextEditor::MovingRange *mr = miface->newMovingRange(matches[i].range);
        matchRanges.append(mr);
    }

    // The first range in the vector is for this match
    if (!replaceMatch(doc, matchIndex, regExp, replaceString)) {
        return false;
    }

    // Update the items after the matchIndex
    for (int i = matchRow+1; i < matches.size(); ++i) {
        Q_ASSERT(!matchRanges.isEmpty());
        KTextEditor::MovingRange *mr = matchRanges.takeFirst();
        matches[i].range = mr->toRange();
        delete mr;
    }
    Q_ASSERT(matchRanges.isEmpty());

    dataChanged(createIndex(matchRow, 0, fileRow), createIndex(matches.size()-1, 0, fileRow));

    return true;
}
347

348 349 350 351 352 353
void MatchModel::doReplaceNextMatch()
{
    Q_ASSERT(m_docManager);

    if (m_cancelReplace || m_replaceFile >= m_matchFiles.size()) {
        m_replaceFile = -1;
354
        Q_EMIT replaceDone();
355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442
        return;
    }

    // NOTE The document managers signal documentWillBeDeleted() must be connected to
    // cancelReplace(). A closed file could lead to a crash if it is not handled.
    // this is now done in setDocumentManager()

    MatchFile &matchFile = m_matchFiles[m_replaceFile];

    if (matchFile.checkState == Qt::Unchecked) {
        m_replaceFile++;
        QTimer::singleShot(0, this, &MatchModel::doReplaceNextMatch);
        return;
    }

    KTextEditor::Document *doc;
    doc = m_docManager->findUrl(matchFile.fileUrl);
    if (!doc) {
        doc = m_docManager->openUrl(matchFile.fileUrl);
    }

    if (!doc) {
        qDebug() << "Failed to open the document" << matchFile.fileUrl;
        m_replaceFile++;
        QTimer::singleShot(0, this, &MatchModel::doReplaceNextMatch);
        return;
    }

    if (doc->url() != matchFile.fileUrl) {
        qDebug() << "url differences" << matchFile.fileUrl << doc->url();
        matchFile.fileUrl = doc->url();
    }

    auto &matches = matchFile.matches;

    // Create a vector of moving ranges for updating the matches after replace
    QVector<KTextEditor::MovingRange *> matchRanges;
    matchRanges.reserve(matches.size());
    KTextEditor::MovingInterface *miface = qobject_cast<KTextEditor::MovingInterface *>(doc);
    for (const auto &match: qAsConst(matches)) {
        matchRanges.append(miface->newMovingRange(match.range));
    }

    // Make one transaction for the whole replace to speed up things
    // and get all replacements in one "undo"
    KTextEditor::Document::EditingTransaction transaction(doc);

    for (int i = 0; i < matches.size(); ++i) {
        if (matches[i].checked) {
            replaceMatch(doc, createIndex(i, 0, m_replaceFile), m_regExp, m_replaceText);
        }
        // The document has been modified -> make sure the next match has the correct range
        if (i < matches.size()-1) {
            matches[i+1].range = matchRanges[i+1]->toRange();
        }
    }

    dataChanged(createIndex(0, 0, m_replaceFile), createIndex(matches.size()-1, 0, m_replaceFile));

    // free our moving ranges
    qDeleteAll(matchRanges);

    m_replaceFile++;
    QTimer::singleShot(0, this, &MatchModel::doReplaceNextMatch);
}


/** Initiate a replace of all matches that have been checked */
void MatchModel::replaceChecked(const QRegularExpression &regExp, const QString &replaceString)
{
    Q_ASSERT(m_docManager != nullptr);
    if (m_replaceFile != -1) {
        Q_ASSERT(m_replaceFile != -1);
        return; // already replacing
    }

    m_replaceFile = 0;
    m_regExp = regExp;
    m_replaceText = replaceString;
    m_cancelReplace = false;
    doReplaceNextMatch();
}

void MatchModel::cancelReplace()
{
    m_replaceFile = -1;
    m_cancelReplace = true;
}
443

444 445 446 447
static QString nbsFormated(int number, int width)
{
    QString str = QString::number(number);
    int strWidth = str.size();
448
    str.reserve(width);
449 450 451 452 453 454 455
    while (strWidth < width) {
        str = QStringLiteral("&nbsp;") + str;
        strWidth++;
    }
    return str;
}

456 457
QString MatchModel::infoHtmlString() const
{
458
    if (m_matchFiles.isEmpty() && m_searchState == SearchDone && m_lastMatchUrl.isEmpty()) {
459 460 461 462 463 464 465
        return QString();
    }

    int matchesTotal = 0;
    int checkedTotal = 0;
    for (const auto &matchFile: qAsConst(m_matchFiles)) {
        matchesTotal += matchFile.matches.size();
466
        checkedTotal += std::count_if(matchFile.matches.begin(), matchFile.matches.end(), [](const KateSearchMatch &match) {return match.checked;} );
467 468
    }

Kåre Särs's avatar
Kåre Särs committed
469 470 471 472 473 474 475 476 477
    if (m_searchState == Preparing) {

        if (m_lastSearchPath.size() >= 73) {
            return i18n("<b><i>Generating file list: ...%1</i></b>", m_lastSearchPath.right(70));
        } else {
            return i18n("<b><i>Generating file list: ...%1</i></b>", m_lastSearchPath);
        }
    }

478
    if (m_searchState == Searching) {
Kåre Särs's avatar
Kåre Särs committed
479
        QString searchUrl = m_lastMatchUrl.toDisplayString(QUrl::PreferLocalFile);
480 481

        if (searchUrl.size() > 73) {
Yunhe Guo's avatar
Yunhe Guo committed
482
            return i18np("<b><i>One match found, searching: ...%2</i></b>", "<b><i>%1 matches found, searching: ...%2</i></b>", matchesTotal, searchUrl.right(70));
483
        } else {
Yunhe Guo's avatar
Yunhe Guo committed
484
            return i18np("<b><i>One match found, searching: %2</i></b>", "<b><i>%1 matches found, searching: %2</i></b>", matchesTotal, searchUrl);
485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524
        }
    }

    QString checkedStr = i18np("One checked", "%1 checked", checkedTotal);

    switch (m_searchPlace) {
        case CurrentFile:
            return i18np("<b><i>One match (%2) found in file</i></b>", "<b><i>%1 matches (%2) found in current file</i></b>", matchesTotal, checkedStr);
        case MatchModel::OpenFiles:
            return i18np("<b><i>One match (%2) found in open files</i></b>", "<b><i>%1 matches (%2) found in open files</i></b>", matchesTotal, checkedStr);
            break;
        case MatchModel::Folder:
            return i18np("<b><i>One match (%3) found in folder %2</i></b>", "<b><i>%1 matches (%3) found in folder %2</i></b>", matchesTotal, m_resultBaseDir, checkedStr);
            break;
        case MatchModel::Project: {
            return i18np("<b><i>One match (%4) found in project %2 (%3)</i></b>", "<b><i>%1 matches (%4) found in project %2 (%3)</i></b>", matchesTotal, m_projectName, m_resultBaseDir, checkedStr);
            break;
        }
        case MatchModel::AllProjects: // "in Open Projects"
            return i18np("<b><i>One match (%3) found in all open projects (common parent: %2)</i></b>", "<b><i>%1 matches (%3) found in all open projects (common parent: %2)</i></b>", matchesTotal, m_resultBaseDir, checkedStr);
            break;
    }

    return QString();
}


QString MatchModel::fileToHtmlString(const MatchFile &matchFile) const
{
    QString path = matchFile.fileUrl.isLocalFile() ? localFileDirUp(matchFile.fileUrl).path() : matchFile.fileUrl.url();
    if (!path.isEmpty() && !path.endsWith(QLatin1Char('/'))) {
        path += QLatin1Char('/');
    }

    QString tmpStr = QStringLiteral("%1<b>%2: %3</b>").arg(path, matchFile.fileUrl.fileName()).arg(matchFile.matches.size());

    return tmpStr;
}


525 526
QString MatchModel::matchToHtmlString(const Match &match) const
{
527 528 529 530 531
    QString pre = match.preMatchStr;
    if (match.preMatchStr.size() == PreContextLen) {
        pre.replace(0, 3, QLatin1String("..."));
    }
    pre = pre.toHtmlEscaped();
532

533 534
    QString matchStr = match.matchStr.toHtmlEscaped();;

535
    QString replaceStr = match.replaceText.toHtmlEscaped();
536

537 538 539
    if (!replaceStr.isEmpty()) {
        matchStr = QLatin1String("<i><s>") + matchStr + QLatin1String("</s></i> ");
    }
540
    matchStr = QStringLiteral("<span style=\"background-color:%1; color:%2;\">%3</span>")
541
    .arg(m_searchBackgroundColor, m_foregroundColor, matchStr);
542 543 544

    if (!replaceStr.isEmpty()) {
        matchStr += QStringLiteral("<span style=\"background-color:%1; color:%2;\">%3</span>")
545
        .arg(m_replaceHighlightColor, m_foregroundColor, replaceStr);
546
    }
547

548 549 550
    matchStr.replace(QLatin1Char('\n'), QStringLiteral("\\n"));
    matchStr.replace(QLatin1Char('\t'), QStringLiteral("\\t"));

551 552 553 554 555 556 557 558 559
    QString post = match.postMatchStr;
    int nlIndex = post.indexOf(QLatin1Char('\n'));
    if (nlIndex != -1) {
        post = post.mid(0, nlIndex);
    }
    if (post.size() == PostContextLen) {
        post.replace(PostContextLen-3, 3, QLatin1String("..."));
    }
    post = post.toHtmlEscaped();
560 561

    // (line:col)[space][space] ...Line text pre [highlighted match] Line text post....
562 563
    QString displayText = QStringLiteral("<span style=\"color:%1;\">&nbsp;<b>%2:%3</b></span>&nbsp;")
    .arg(m_foregroundColor)
564 565
    .arg(nbsFormated(match.range.start().line() + 1, 3))
    .arg(nbsFormated(match.range.start().column() + 1, 3)) + pre + matchStr + post;
566 567 568 569

    return displayText;
}

570 571 572

QString MatchModel::infoToPlainText() const
{
Kåre Särs's avatar
Kåre Särs committed
573
    if (m_matchFiles.isEmpty() && m_searchState == SearchDone) {
574 575 576 577 578 579 580
        return QString();
    }

    int matchesTotal = 0;
    int checkedTotal = 0;
    for (const auto &matchFile: qAsConst(m_matchFiles)) {
        matchesTotal += matchFile.matches.size();
581
        checkedTotal += std::count_if(matchFile.matches.begin(), matchFile.matches.end(), [](const KateSearchMatch &match) {return match.checked;} );
582 583
    }

Kåre Särs's avatar
Kåre Särs committed
584 585 586 587 588 589 590 591 592
    if (m_searchState == Preparing) {

        if (m_lastSearchPath.size() >= 73) {
            return i18n("Generating file list: ...%1", m_lastSearchPath.right(70));
        } else {
            return i18n("Generating file list: ...%1", m_lastSearchPath);
        }
    }

593
    if (m_searchState == Searching) {
Kåre Särs's avatar
Kåre Särs committed
594
        QString searchUrl = m_lastMatchUrl.toDisplayString(QUrl::PreferLocalFile);
595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627

        if (searchUrl.size() > 73) {
            return i18np("One match found, searching: ...%2", "%1 matches found, searching: ...%2", matchesTotal, searchUrl.right(70));
        } else {
            return i18np("One match found, searching: %2", "%1 matches found, searching: %2", matchesTotal, searchUrl);
        }
    }

    QString checkedStr = i18np("One checked", "%1 checked", checkedTotal);

    switch (m_searchPlace) {
        case CurrentFile:
            return i18np("One match (%2) found in file", "%1 matches (%2) found in current file", matchesTotal, checkedStr);
        case MatchModel::OpenFiles:
            return i18np("One match (%2) found in open files", "%1 matches (%2) found in open files", matchesTotal, checkedStr);
            break;
        case MatchModel::Folder:
            return i18np("One match (%3) found in folder %2", "%1 matches (%3) found in folder %2", matchesTotal, m_resultBaseDir, checkedStr);
            break;
        case MatchModel::Project: {
            return i18np("One match (%4) found in project %2 (%3)", "%1 matches (%4) found in project %2 (%3)", matchesTotal, m_projectName, m_resultBaseDir, checkedStr);
            break;
        }
        case MatchModel::AllProjects: // "in Open Projects"
            return i18np("One match (%3) found in all open projects (common parent: %2)", "%1 matches (%3) found in all open projects (common parent: %2)", matchesTotal, m_resultBaseDir, checkedStr);
            break;
    }

    return QString();
}


QString MatchModel::fileToPlainText(const MatchFile &matchFile) const
628 629 630 631 632 633
{
    QString path = matchFile.fileUrl.isLocalFile() ? localFileDirUp(matchFile.fileUrl).path() : matchFile.fileUrl.url();
    if (!path.isEmpty() && !path.endsWith(QLatin1Char('/'))) {
        path += QLatin1Char('/');
    }

634
    QString tmpStr = QStringLiteral("%1%2: %3").arg(path, matchFile.fileUrl.fileName()).arg(matchFile.matches.size());
635 636 637 638 639

    return tmpStr;
}


640 641 642 643 644 645 646 647 648 649 650 651 652 653
QString MatchModel::matchToPlainText(const Match &match) const
{
    QString pre =match.preMatchStr;

    QString matchStr = match.matchStr;
    matchStr.replace(QLatin1Char('\n'), QStringLiteral("\\n"));

    QString replaceStr = match.replaceText;
    if (!replaceStr.isEmpty()) {
        matchStr = QLatin1String("----") + matchStr + QLatin1String("----");
        matchStr += QLatin1String("++++") + replaceStr + QLatin1String("++++");
    }
    QString post = match.postMatchStr;

654 655 656 657 658
    matchStr.replace(QLatin1Char('\n'), QStringLiteral("\\n"));
    matchStr.replace(QLatin1Char('\t'), QStringLiteral("\\t"));
    replaceStr.replace(QLatin1Char('\n'), QStringLiteral("\\n"));
    replaceStr.replace(QLatin1Char('\t'), QStringLiteral("\\t"));

659
    // (line:col)[space][space] ...Line text pre [highlighted match] Line text post....
660
    QString displayText = QStringLiteral("%1:%2: ")
661 662 663 664 665 666
    .arg(match.range.start().line() + 1, 3)
    .arg(match.range.start().column() + 1, 3) + pre + matchStr + post;
    return displayText;
}


667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790
bool MatchModel::isMatch(const QModelIndex &itemIndex) const
{
    if (!itemIndex.isValid()) return false;
    if (itemIndex.internalId() == InfoItemId) return false;
    if (itemIndex.internalId() == FileItemId) return false;

    return true;
}

QModelIndex MatchModel::fileIndex(const QUrl &url) const
{
    int row = matchFileRow(url);
    if (row == -1) return QModelIndex();
    return createIndex(row, 0, FileItemId);
}

QModelIndex MatchModel::firstMatch() const
{
    if (m_matchFiles.isEmpty()) return QModelIndex();

    return createIndex(0, 0, static_cast<quintptr>(0));
}

QModelIndex MatchModel::lastMatch() const
{
    if (m_matchFiles.isEmpty()) return QModelIndex();
    const MatchFile &matchFile = m_matchFiles.constLast();
    return createIndex(matchFile.matches.size()-1, 0, m_matchFiles.size()-1);
}

QModelIndex MatchModel::firstFileMatch(const QUrl &url) const
{
    int row = matchFileRow(url);
    if (row == -1) return QModelIndex();

    // if a file is in the vector it has a match
    return createIndex(0, 0, row);
}

QModelIndex MatchModel::closestMatchAfter(const QUrl &url, const KTextEditor::Cursor &cursor) const
{
    int row = matchFileRow(url);
    if (row < 0) return QModelIndex();
    if (row >= m_matchFiles.size()) return QModelIndex();
    if (!cursor.isValid()) return QModelIndex();

    // if a file is in the vector it has a match
    const MatchFile &matchFile = m_matchFiles[row];

    int i=0;
    for (; i<matchFile.matches.size()-1; ++i) {
        if (matchFile.matches[i].range.end() >= cursor) {
            break;
        }
    }

    return createIndex(i, 0, row);
}

QModelIndex MatchModel::closestMatchBefore(const QUrl &url, const KTextEditor::Cursor &cursor) const
{
    int row = matchFileRow(url);
    if (row < 0) return QModelIndex();
    if (row >= m_matchFiles.size()) return QModelIndex();
    if (!cursor.isValid()) return QModelIndex();

    // if a file is in the vector it has a match
    const MatchFile &matchFile = m_matchFiles[row];

    int i=matchFile.matches.size()-1;
    for (; i>=0; --i) {
        if (matchFile.matches[i].range.start() <= cursor) {
            break;
        }
    }

    return createIndex(i, 0, row);
}

QModelIndex MatchModel::nextMatch(const QModelIndex &itemIndex) const
{
    if (!itemIndex.isValid()) return firstMatch();

    int fileRow = itemIndex.internalId() < FileItemId ? itemIndex.internalId() : itemIndex.row();
    if (fileRow < 0 || fileRow >= m_matchFiles.size()) {
        return QModelIndex();
    }

    int matchRow = itemIndex.internalId() < FileItemId ? itemIndex.row() : 0;
    matchRow++;
    if (matchRow >= m_matchFiles[fileRow].matches.size()) {
        fileRow++;
        matchRow = 0;
    }

    if (fileRow >= m_matchFiles.size()) {
        fileRow = 0;
    }
    return createIndex(matchRow, 0, fileRow);
}

QModelIndex MatchModel::prevMatch(const QModelIndex &itemIndex) const
{
    if (!itemIndex.isValid()) return lastMatch();

    int fileRow = itemIndex.internalId() < FileItemId ? itemIndex.internalId() : itemIndex.row();
    if (fileRow < 0 || fileRow >= m_matchFiles.size()) {
        return QModelIndex();
    }

    int matchRow = itemIndex.internalId() < FileItemId ? itemIndex.row() : 0;
    matchRow--;
    if (matchRow < 0) {
        fileRow--;
    }
    if (fileRow < 0) {
        fileRow = m_matchFiles.size()-1;
    }
    if (matchRow < 0) {
        matchRow = m_matchFiles[fileRow].matches.size()-1;
    }
    return createIndex(matchRow, 0, fileRow);
}

791 792 793 794 795 796 797 798 799
QVariant MatchModel::data(const QModelIndex &index, int role) const
{
    if (!index.isValid())
        return QVariant();

    if (index.column() < 0 || index.column() > 1) {
        return QVariant();
    }

800 801 802 803 804 805 806
    int fileRow = index.internalId() == InfoItemId ? -1 : index.internalId() == FileItemId ? index.row() : (int)index.internalId();
    int matchRow = index.internalId() == InfoItemId || index.internalId() == FileItemId ? -1 : index.row();

    if (fileRow == -1) {
        // Info Item
        switch (role) {
            case Qt::DisplayRole:
Kåre Särs's avatar
Kåre Särs committed
807
                return infoHtmlString();
808 809
            case PlainTextRole:
                return infoToPlainText();
810 811 812
            case Qt::CheckStateRole:
                return m_infoCheckState;
        }
813 814 815 816 817 818 819 820 821 822 823 824
        return QVariant();
    }

    if (fileRow < 0 || fileRow >= m_matchFiles.size()) {
        qDebug() << "Should be a file (or the info item in the near future)" << fileRow;
        return QVariant();
    }

    if (matchRow < 0) {
        // File item
        switch (role) {
            case Qt::DisplayRole:
825
                return fileToHtmlString(m_matchFiles[fileRow]);
826 827
            case Qt::CheckStateRole:
                return m_matchFiles[fileRow].checkState;
828 829
            case FileUrlRole:
                return m_matchFiles[fileRow].fileUrl;
830 831
            case PlainTextRole:
                return fileToPlainText(m_matchFiles[fileRow]);
832 833 834 835 836 837 838 839 840 841
        }
    }
    else if (matchRow < m_matchFiles[fileRow].matches.size()) {
        // Match
        const Match &match = m_matchFiles[fileRow].matches[matchRow];
        switch (role) {
            case Qt::DisplayRole:
                return matchToHtmlString(match);
            case Qt::CheckStateRole:
                return match.checked ? Qt::Checked : Qt::Unchecked;
842 843 844
            case FileUrlRole:
                return m_matchFiles[fileRow].fileUrl;
            case StartLineRole:
845
                return match.range.start().line();
846
            case StartColumnRole:
847
                return match.range.start().column();
848
            case EndLineRole:
849
                return match.range.end().line();
850
            case EndColumnRole:
851
                return match.range.end().column();
852 853 854 855 856 857 858 859 860 861
            case PreMatchRole:
                return match.preMatchStr;
            case MatchRole:
                return match.matchStr;
            case PostMatchRole:
                return match.postMatchStr;
            case ReplacedRole:
                return !match.replaceText.isEmpty();
            case ReplaceTextRole:
                return match.replaceText;
862 863
            case PlainTextRole:
                return matchToPlainText(match);
864 865 866 867 868 869 870 871 872 873 874
        }
    }
    else {
        qDebug() << "bad index";
        return QVariant();
    }


    return QVariant();
}

875 876 877 878 879 880 881 882 883 884 885 886 887 888
bool MatchModel::setFileChecked(int fileRow, bool checked)
{
    if (fileRow < 0 || fileRow >= m_matchFiles.size()) return false;
    QVector<Match> &matches = m_matchFiles[fileRow].matches;
    for (int i = 0; i < matches.size(); ++i) {
        matches[i].checked = checked;
    }
    m_matchFiles[fileRow].checkState = checked ? Qt::Checked : Qt::Unchecked;
    QModelIndex rootFileIndex = index(fileRow, 0, createIndex(0, 0, InfoItemId));
    dataChanged(index(0, 0, rootFileIndex), index(matches.count()-1, 0, rootFileIndex), QVector<int>(Qt::CheckStateRole));
    dataChanged(rootFileIndex, rootFileIndex, QVector<int>(Qt::CheckStateRole));
    return true;
}

889 890 891 892 893 894 895 896 897 898

bool MatchModel::setData(const QModelIndex &itemIndex, const QVariant &, int role)
{
    if (role != Qt::CheckStateRole)
        return false;
    if (!itemIndex.isValid())
        return false;
    if (itemIndex.column() != 0)
        return false;

899 900 901 902 903
    // Check/un-check the File Item and it's children
    if (itemIndex.internalId() == InfoItemId) {
        bool checked = m_infoCheckState != Qt::Checked;
        for (int i=0; i<m_matchFiles.size(); ++i) {
            setFileChecked(i, checked);
904
        }
905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928
        m_infoCheckState = checked ? Qt::Checked : Qt::Unchecked;
        QModelIndex infoIndex = createIndex(0, 0, InfoItemId);
        dataChanged(infoIndex, infoIndex, QVector<int>(Qt::CheckStateRole));
        return true;
    }


    if (itemIndex.internalId() == FileItemId) {
        int fileRrow = itemIndex.row();
        if (fileRrow < 0 || fileRrow >= m_matchFiles.size()) return false;
        bool checked = m_matchFiles[fileRrow].checkState != Qt::Checked; // we toggle the current value
        setFileChecked(fileRrow, checked);

        // compare file items
        Qt::CheckState checkState = m_matchFiles[0].checkState;
        for (int i=1; i<m_matchFiles.size(); ++i) {
            if (checkState != m_matchFiles[i].checkState) {
                checkState = Qt::PartiallyChecked;
                break;
            }
        }
        m_infoCheckState = checkState;
        QModelIndex infoIndex = createIndex(0, 0, InfoItemId);
        dataChanged(infoIndex, infoIndex, QVector<int>(Qt::CheckStateRole));
929 930 931 932 933 934 935 936 937 938 939 940 941 942 943
        return true;
    }

    int rootRow = itemIndex.internalId();
    if (rootRow < 0 || rootRow >= m_matchFiles.size())
        return false;

    int row = itemIndex.row();
    QVector<Match> &matches = m_matchFiles[rootRow].matches;
    if (row < 0 || row >= matches.size())
        return false;

    // we toggle the current value
    matches[row].checked = !matches[row].checked;

944
    int checkedCount = std::count_if(matches.begin(), matches.end(), [](const KateSearchMatch &match) {return match.checked;});
945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977

    if (checkedCount == matches.size()) {
        m_matchFiles[rootRow].checkState = Qt::Checked;
    }
    else if (checkedCount == 0) {
        m_matchFiles[rootRow].checkState = Qt::Unchecked;
    }
    else {
        m_matchFiles[rootRow].checkState = Qt::PartiallyChecked;
    }

    QModelIndex rootFileIndex = index(rootRow, 0);
    dataChanged(rootFileIndex, rootFileIndex, QVector<int>(Qt::CheckStateRole));
    dataChanged(index(row, 0, rootFileIndex), index(row, 0, rootFileIndex), QVector<int>(Qt::CheckStateRole));
    return true;
}

Qt::ItemFlags MatchModel::flags(const QModelIndex &index) const
{
    if (!index.isValid()) {
        return Qt::NoItemFlags;
    }

    if (index.column() == 0) {
        return Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsUserCheckable;
    }

    return Qt::ItemIsEnabled | Qt::ItemIsSelectable;
}

int MatchModel::rowCount(const QModelIndex &parent) const
{
    if (!parent.isValid()) {
978 979 980
        return (m_matchFiles.isEmpty() &&
        m_searchState == SearchDone &&
        m_lastMatchUrl.isEmpty()) ? 0 : 1;
981 982 983
    }

    if (parent.internalId() == InfoItemId) {
984 985 986
        return m_matchFiles.size();
    }

987 988
    if (parent.internalId() != FileItemId) {
        // matches do not have children
989 990 991
        return 0;
    }

992
    // If we get here parent.internalId() == FileItemId
993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007
    int row = parent.row();
    if (row < 0 || row >= m_matchFiles.size()) {
        return 0;
    }

    return m_matchFiles[row].matches.size();
}

int MatchModel::columnCount(const QModelIndex &) const
{
    return 1;
}

QModelIndex MatchModel::index(int row, int column, const QModelIndex &parent) const
{
1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020
    // Create the Info Item
    if (!parent.isValid()) {
        return createIndex(0, 0, InfoItemId);
    }

    // File Item
    if (parent.internalId() == InfoItemId) {
        return createIndex(row, column, FileItemId);
    }

    // Match Item
    if (parent.internalId() == FileItemId) {
        return createIndex(row, column, parent.row());
1021
    }
1022 1023 1024

    // Parent is a match which does not have children
    return QModelIndex();
1025 1026 1027 1028
}

QModelIndex MatchModel::parent(const QModelIndex &child) const
{
1029
    if (child.internalId() == InfoItemId) {
1030 1031
        return QModelIndex();
    }
1032 1033 1034 1035 1036 1037

    if (child.internalId() == FileItemId) {
        return createIndex(0, 0, InfoItemId);
    }

    return createIndex(child.internalId(), 0, FileItemId);
1038
}