• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

IJHack / QtPass / 35232092867

17 Sep 2026 02:13PM UTC coverage: 76.864% (+0.9%) from 75.981%
35232092867

push

github

web-flow
ConfigDialog: a profile list with a form instead of the table (#1815)

* ConfigDialog: a profile list with a form instead of the table

The Profiles tab was a QTableWidget with three editable columns, Add and
Delete under it, and the profile's Git flags edited through the global
checkboxes on the Settings tab for whichever row happened to be selected.
Sorting the table while typing moved rows under the editor, and two rows
with the same name silently collapsed into one on save.

The tab is now a QListWidget of names next to a form: name, store path
with a folder picker, signing key (placeholder says it is a fingerprint,
which is what verification needs) and the profile's own Use Git / push /
pull boxes, which show the global values until clicked. The dialog keeps
the profiles as a QList of (name, Profile) so a profile can pass through
an empty or duplicate name while it is typed; validate() paints such rows
red in the list, puts the reason in the field's tooltip and keeps OK
disabled. On accept the active profile's own Git flags replace the global
ones, as switching to it does.

tst_configdialog: the sort regression test becomes a plain add test, plus
the form round trip (edits land in getProfiles(), switching profiles
keeps them apart, untouched Git boxes leave the optional unset) and the OK
gating for empty name, duplicate name, empty path and deletion. The three
new labels join the buddy check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JuQsrHonihp1nARE7bzstc

* Profiles: keep designer tooltips, follow the active profile through edits

Review follow-ups:

- validate() replaced the form fields' designer tooltips with the problem
  text and left them empty once the problem was gone; the originals are
  cached and put back. loadProfileForm() validates, so the tooltips of a
  freshly selected profile are right without an edit.
- The active profile scalar did not move with a rename and survive... (continued)

133 of 138 new or added lines in 1 file covered. (96.38%)

31 existing lines in 4 files now uncovered.

6073 of 7901 relevant lines covered (76.86%)

75.63 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

81.11
/src/nativegrep.cpp
1
// SPDX-FileCopyrightText: 2026 Anne Jan Brouwer
2
// SPDX-License-Identifier: GPL-3.0-or-later
3
#include "nativegrep.h"
4
#include "executor.h"
5
#include <QDir>
6
#include <QDirIterator>
7
#include <QElapsedTimer>
8
#include <QPointer>
9
#include <QProcess>
10
#include <QRegularExpression>
11
#include <QThread>
12
#include <utility>
13

14
NativeGrep::NativeGrep(QObject *parent) : QObject(parent) {}
76 ✔
15

16
NativeGrep::~NativeGrep() {
76 ✔
17
  static constexpr int kThreadTimeoutMs = 5000;
18
  cancel();
76 ✔
19
  QElapsedTimer elapsed;
76 ✔
20
  elapsed.start();
76 ✔
21
  for (const Worker &w : std::as_const(m_workers)) {
76 ✔
UNCOV
22
    if (w.thread && w.thread->isRunning()) {
×
23
      const int remaining =
24
          kThreadTimeoutMs - static_cast<int>(elapsed.elapsed());
×
25
      if (remaining > 0)
×
26
        w.thread->wait(remaining);
×
27
    }
28
  }
29
}
76 ✔
30

31
/**
32
 * @brief Decrypt one .gpg file and return lines matching rx.
33
 */
34
auto NativeGrep::matchFile(const QProcessEnvironment &env,
10 ✔
35
                           const QString &gpgExe, const QString &filePath,
36
                           const QRegularExpression &rx,
37
                           const std::atomic_bool *cancel) -> QStringList {
38
  QString translatedPath = filePath;
39
  if (gpgExe.startsWith(QStringLiteral("wsl "))) {
20 ✔
40
    QString wslPath;
×
41
    const int wrc = Executor::executeBlocking(
×
42
        QStringLiteral("wsl"),
×
43
        Executor::wslExecArgs(QStringLiteral("wslpath"), {filePath}), &wslPath);
×
44
    const QString translated = wslPath.trimmed();
45
    if (wrc == 0 && !translated.isEmpty())
×
46
      translatedPath = translated;
×
47
  }
48
  QString plaintext;
10 ✔
49
  // The QProcess overload is the one that takes the cancel flag; it polls
50
  // the flag while waiting and terminates (then kills) gpg from this thread,
51
  // the one that owns the process.
52
  QProcess gpg;
10 ✔
53
  gpg.setProcessEnvironment(env);
10 ✔
54
  const int rc =
55
      Executor::executeBlocking(gpg, gpgExe,
90 ✔
56
                                {"-d", "--quiet", "--yes", "--no-encrypt-to",
57
                                 "--batch", "--use-agent", translatedPath},
58
                                QString(), &plaintext, nullptr, cancel);
10 ✔
59
  if (rc != 0 || plaintext.isEmpty())
10 ✔
60
    return {};
4 ✔
61
  QStringList matches;
6 ✔
62
  for (const QString &line : plaintext.split('\n')) {
30 ✔
63
    QString candidate = line;
64
    if (candidate.endsWith('\r'))
18 ✔
65
      candidate.chop(1);
×
66
    const QString t = candidate.trimmed();
67
    if (!t.isEmpty() && candidate.contains(rx))
18 ✔
68
      matches << t;
69
  }
70
  return matches;
71
}
20 ✔
72

73
/**
74
 * @brief Walk the store, decrypt every .gpg file, collect matches.
75
 */
76
auto NativeGrep::scanStore(const QProcessEnvironment &env,
6 ✔
77
                           const QString &gpgExe, const QString &storeDir,
78
                           const QRegularExpression &rx,
79
                           const std::atomic_bool *cancel)
80
    -> QList<QPair<QString, QStringList>> {
81
  QList<QPair<QString, QStringList>> results;
6 ✔
82
  QDirIterator it(storeDir, QStringList() << "*.gpg", QDir::Files,
12 ✔
83
                  QDirIterator::Subdirectories);
6 ✔
84
  while (it.hasNext()) {
15 ✔
85
    if (QThread::currentThread()->isInterruptionRequested() ||
9 ✔
86
        (cancel != nullptr && cancel->load()))
9 ✔
87
      return {};
×
88
    const QString filePath = it.next();
9 ✔
89
    const QStringList matches = matchFile(env, gpgExe, filePath, rx, cancel);
9 ✔
90
    if (!matches.isEmpty()) {
9 ✔
91
      QString entry = QDir(storeDir).relativeFilePath(filePath);
6 ✔
92
      if (entry.endsWith(QLatin1String(".gpg")))
6 ✔
93
        entry.chop(4);
6 ✔
94
      results.append({entry, matches});
6 ✔
95
    }
96
  }
97
  return results;
98
}
6 ✔
99

100
/**
101
 * @brief Start a search on a worker thread.
102
 *
103
 * Results are emitted on the owner's thread via QMetaObject::invokeMethod. A
104
 * sequence counter discards results from superseded searches; the previous
105
 * search is asked to stop but not waited for, since blocking the UI thread
106
 * while gpg decrypts would freeze the interface.
107
 */
108
void NativeGrep::search(const QString &pattern, bool caseInsensitive,
6 ✔
109
                        const QString &gpgExe, const QString &storeDir,
110
                        const QProcessEnvironment &env) {
111
  cancel();
6 ✔
112

113
  // Advance the sequence before any early return so in-flight workers from the
114
  // previous query fail the seq check and cannot publish stale results.
115
  const int seq = ++m_seq;
6 ✔
116

117
  // Use trimmed() rather than isEmpty(): a whitespace-only string is a valid
118
  // regex that matches every non-empty line, which is almost never intentional
119
  // and would decrypt the entire store.
120
  //
121
  // Both early returns post finished() via Qt::QueuedConnection so that the
122
  // signal is always delivered asynchronously after search() returns, matching
123
  // the contract of the threaded path.
124
  if (pattern.trimmed().isEmpty()) {
6 ✔
125
    QMetaObject::invokeMethod(
×
126
        this,
127
        [this, seq]() {
×
128
          if (m_seq == seq)
×
129
            emit finished({});
×
130
        },
×
131
        Qt::QueuedConnection);
132
    return;
1 ✔
133
  }
134

135
  const QRegularExpression rx(
136
      pattern, caseInsensitive ? QRegularExpression::CaseInsensitiveOption
137
                               : QRegularExpression::PatternOptions{});
12 ✔
138
  if (!rx.isValid()) {
6 ✔
139
    QMetaObject::invokeMethod(
1 ✔
140
        this,
141
        [this, seq]() {
1 ✔
142
          if (m_seq == seq)
1 ✔
143
            emit finished({});
2 ✔
144
        },
1 ✔
145
        Qt::QueuedConnection);
146
    return;
147
  }
148
  QPointer<NativeGrep> self(this);
149

150
  auto emitResults = [self, seq](QList<QPair<QString, QStringList>> results) {
10 ✔
151
    if (!self)
5 ✔
152
      return;
153
    QMetaObject::invokeMethod(
5 ✔
154
        self,
155
        [self, seq, results = std::move(results)]() {
15 ✔
156
          if (self && self->m_seq == seq)
10 ✔
157
            emit self->finished(results);
5 ✔
158
        },
5 ✔
159
        Qt::QueuedConnection);
160
  };
5 ✔
161

162
  auto cancelFlag = std::make_shared<std::atomic_bool>(false);
5 ✔
163
  QThread *thread = QThread::create([gpgExe, storeDir, env, rx, cancelFlag,
10 ✔
164
                                     emitResults = std::move(emitResults)]() {
165
    std::move(emitResults)(
5 ✔
166
        scanStore(env, gpgExe, storeDir, rx, cancelFlag.get()));
5 ✔
167
  });
5 ✔
168

169
  m_workers.append({thread, cancelFlag});
10 ✔
170
  connect(thread, &QThread::finished, thread, &QObject::deleteLater);
5 ✔
171
  connect(thread, &QThread::finished, this, [this, thread]() {
5 ✔
172
    m_workers.removeIf(
5 ✔
173
        [thread](const Worker &w) { return w.thread == thread; });
5 ✔
174
  });
175
  thread->start();
5 ✔
176
}
11 ✔
177

178
void NativeGrep::cancel() {
158 ✔
179
  for (const Worker &w : std::as_const(m_workers)) {
159 ✔
180
    w.cancel->store(true);
181
    if (w.thread && w.thread->isRunning())
1 ✔
182
      w.thread->requestInterruption();
1 ✔
183
  }
184
}
158 ✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc