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

IJHack / QtPass / 34832253743

14 Sep 2026 10:15AM UTC coverage: 65.229% (+0.1%) from 65.09%
34832253743

push

github

web-flow
Backport: seed new-folder .gpg-id from parent recipients (#1709) (#1714)

* fix: seed new-folder .gpg-id from parent recipients (#1682) (#1688)

(cherry picked from commit a82adcde2)

* Follow-up to #1688: never seed an unsigned .gpg-id, test the seeding (#1695)

#1688 made "Add folder" copy the parent's recipients into the new
folder's .gpg-id, but it did so unconditionally. With passSigningKey
configured that file has no .gpg-id.sig, so ImitatePass::Insert
(verifyGpgIdFile) and real pass (PASSWORD_STORE_SIGNING_KEY) refuse
every insert into the folder until UsersDialog re-inits it. The write
was also inlined in a private slot behind QInputDialog, so #1682 had no
regression test.

Move the write into Pass::seedGpgIdFile(newDir, passStore): it resolves
the inherited recipients from newDir/.gpg-id (so a trailing separator
on newDir is harmless), refuses to overwrite an existing file, writes
nothing when no recipient is inherited, and keeps the QSaveFile +
owner-only permissions from #1688. MainWindow::addFolder calls it only
when no signing key is configured; with one, the folder is left without
its own .gpg-id and inherits the parent's signed list, which encrypts to
the same recipients. Add tst_util cases for the copy (comment lines and
an email recipient in the parent), the trailing separator, the
no-recipients refusal and the no-overwrite guard.

Backport to main (#1709): also drop the <QTextStream> include #1688 added
to mainwindow.cpp, which this commit leaves unused (2.0-nazomer removed
it later; it was also mis-sorted for clang-format).

Claude-Session: https://claude.ai/code/session_01JuQsrHonihp1nARE7bzstc

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit a8bed36bc)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

14 of 18 new or added lines in 2 files covered. (77.78%)

1 existing line in 1 file now uncovered.

4735 of 7259 relevant lines covered (65.23%)

61.05 hits per line

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

35.17
/src/mainwindow.cpp
1
// SPDX-FileCopyrightText: 2014 Anne Jan Brouwer
2
// SPDX-License-Identifier: GPL-3.0-or-later
3
#include "mainwindow.h"
4

5
#ifdef QT_DEBUG
6
#include "debughelper.h"
7
#endif
8

9
#include "configdialog.h"
10
#include "enums.h"
11
#include "executor.h"
12
#include "exportpublickeydialog.h"
13
#include "filecontent.h"
14
#include "passworddialog.h"
15
#include "passworddisplaypanel.h"
16
#include "pathvalidator.h"
17
#include "qpushbuttonasqrcode.h"
18
#include "qpushbuttonshowpassword.h"
19
#include "qpushbuttonwithclipboard.h"
20
#include "qtcompat.h"
21
#include "qtpass.h"
22
#include "qtpasssettings.h"
23
#include "templateio.h"
24
#include "totp.h"
25
#include "trayicon.h"
26
#include "ui_mainwindow.h"
27
#include "usersdialog.h"
28
#include "util.h"
29
#include <QApplication>
30
#include <QCloseEvent>
31
#include <QDesktopServices>
32
#include <QDialog>
33
#include <QDirIterator>
34
#include <QDockWidget>
35
#include <QFileInfo>
36
#include <QHBoxLayout>
37
#include <QInputDialog>
38
#include <QLabel>
39
#include <QLineEdit>
40
#include <QMenu>
41
#include <QMessageBox>
42
#include <QPushButton>
43
#include <QScrollBar>
44
#include <QShortcut>
45
#include <QTextCursor>
46
#include <QTextEdit>
47
#include <QTimer>
48
#include <QToolButton>
49
#include <QTreeWidget>
50
#include <QUrl>
51
#include <utility>
52

53
/**
54
 * @brief MainWindow::MainWindow handles all of the main functionality and also
55
 * the main window.
56
 * @param searchText for searching from cli
57
 * @param parent pointer
58
 */
59
MainWindow::MainWindow(const QString &searchText, QWidget *parent)
21 ✔
60
    : QMainWindow(parent), ui(new Ui::MainWindow) {
21 ✔
61
#ifdef __APPLE__
62
  // extra treatment for mac os
63
  // see http://doc.qt.io/qt-5/qkeysequence.html#qt_set_sequence_auto_mnemonic
64
  qt_set_sequence_auto_mnemonic(true);
65
#endif
66
  ui->setupUi(this);
21 ✔
67

68
  m_qtPass = new QtPass(this);
21 ✔
69

70
  // register shortcut ctrl/cmd + Q to close the main window
71
  new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Q), this, SLOT(close()));
21 ✔
72
  // register shortcut ctrl/cmd + C to copy the currently selected password
73
  new QShortcut(QKeySequence(QKeySequence::StandardKey::Copy), this,
42 ✔
74
                SLOT(copyPasswordFromTreeview()));
42 ✔
75

76
  model.setNameFilters(QStringList() << "*.gpg");
63 ✔
77
  model.setNameFilterDisables(false);
21 ✔
78

79
  /*
80
   * I added this to solve Windows bug but now on GNU/Linux the main folder,
81
   * if hidden, disappear
82
   *
83
   * model.setFilter(QDir::NoDot);
84
   */
85

86
  QString passStore = QtPassSettings::getPassStore(Util::findPasswordStore());
21 ✔
87

88
  QModelIndex rootDir = model.setRootPath(passStore);
21 ✔
89
  model.fetchMore(rootDir);
21 ✔
90

91
  proxyModel.setModelAndStore(&model, passStore);
21 ✔
92
  proxyModel.setPass(QtPassSettings::getPass());
21 ✔
93
  selectionModel.reset(new QItemSelectionModel(&proxyModel));
21 ✔
94

95
  ui->treeView->setModel(&proxyModel);
21 ✔
96
  ui->treeView->setRootIndex(proxyModel.mapFromSource(rootDir));
21 ✔
97
  ui->treeView->setColumnHidden(1, true);
21 ✔
98
  ui->treeView->setColumnHidden(2, true);
21 ✔
99
  ui->treeView->setColumnHidden(3, true);
21 ✔
100
  ui->treeView->setHeaderHidden(true);
21 ✔
101
  ui->treeView->setIndentation(15);
21 ✔
102
  ui->treeView->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded);
21 ✔
103
  ui->treeView->setContextMenuPolicy(Qt::CustomContextMenu);
21 ✔
104
  ui->treeView->header()->setSectionResizeMode(0, QHeaderView::Stretch);
21 ✔
105
  ui->treeView->sortByColumn(0, Qt::AscendingOrder);
21 ✔
106
  connect(ui->treeView, &QWidget::customContextMenuRequested, this,
21 ✔
107
          &MainWindow::showContextMenu);
21 ✔
108
  connect(ui->treeView, &DeselectableTreeView::emptyClicked, this,
21 ✔
109
          &MainWindow::deselect);
21 ✔
110

111
  {
112
    const AppSettings s = QtPassSettings::load();
21 ✔
113
    if (s.useMonospace) {
21 ✔
114
      QFont monospace("Monospace");
×
115
      monospace.setStyleHint(QFont::Monospace);
×
116
      ui->textBrowser->setFont(monospace);
×
117
    }
×
118
    if (s.noLineWrapping) {
21 ✔
119
      ui->textBrowser->setLineWrapMode(QTextBrowser::NoWrap);
×
120
    }
121
    clearPanelTimer.setInterval(MS_PER_SECOND * s.autoclearPanelSeconds);
21 ✔
122
  }
21 ✔
123
  ui->textBrowser->setOpenExternalLinks(true);
21 ✔
124
  ui->textBrowser->setContextMenuPolicy(Qt::CustomContextMenu);
21 ✔
125
  connect(ui->textBrowser, &QWidget::customContextMenuRequested, this,
21 ✔
126
          &MainWindow::showBrowserContextMenu);
21 ✔
127

128
  updateProfileBox();
21 ✔
129

130
  m_displayPanel = new PasswordDisplayPanel(
21 ✔
131
      ui->gridLayout, ui->verticalLayoutPassword, this, this);
21 ✔
132
  connect(m_displayPanel, &PasswordDisplayPanel::copyRequested, m_qtPass,
21 ✔
133
          &QtPass::copyTextToClipboard);
21 ✔
134
  connect(m_displayPanel, &PasswordDisplayPanel::qrRequested, m_qtPass,
21 ✔
135
          &QtPass::showTextAsQRCode);
21 ✔
136

137
  QtPassSettings::getPass()->updateEnv();
21 ✔
138
  clearPanelTimer.setSingleShot(true);
21 ✔
139
  connect(&clearPanelTimer, &QTimer::timeout, this, [this]() { clearPanel(); });
21 ✔
140

141
  searchTimer.setInterval(350);
21 ✔
142
  searchTimer.setSingleShot(true);
21 ✔
143

144
  connect(&searchTimer, &QTimer::timeout, this, &MainWindow::onTimeoutSearch);
21 ✔
145

146
  // Install the search-box key filter once, not on every setUiElementsEnabled
147
  // call.
148
  ui->lineEdit->installEventFilter(this);
21 ✔
149
  ui->toolBar->installEventFilter(this);
21 ✔
150

151
  // Safety net: if a backend operation disables the UI but never signals
152
  // completion, re-enable after a timeout so the window can't get stuck.
153
  m_uiWatchdog.setSingleShot(true);
21 ✔
154
  m_uiWatchdog.setInterval(UiWatchdogMs);
21 ✔
155
  connect(&m_uiWatchdog, &QTimer::timeout, this, [this]() {
21 ✔
156
    showStatusMessage(tr("Operation timed out; re-enabling interface."));
×
157
    // Drop any in-flight OTP request so a late finishedShow cannot be mistaken
158
    // for the answer to it.
159
    cancelOtpRequest();
×
160
    setUiElementsEnabled(true);
×
161
  });
×
162

163
  initToolBarButtons();
21 ✔
164
  initStatusBar();
21 ✔
165
  initProcessOutputPanel();
21 ✔
166

167
  connect(QtPassSettings::getPass(), &Pass::finishedAnyWithPid, this,
21 ✔
168
          [this](const QString &out, const QString &err, Enums::PROCESS pid) {
42 ✔
169
            // Never route potentially-secret output through the panel:
170
            // - PASS_SHOW / PASS_OTP_GENERATE go via dedicated signals to
171
            //   the main text browser (which clears on a timer).
172
            // - PASS_GREP returns lines from password files; #252 must
173
            //   not leak those into a long-lived panel.
174
            // - PASS_INSERT's stdin is the password; stdout normally
175
            //   carries gpg/git progress only, but exclude defensively
176
            //   in case a future code path uses --echo or similar.
177
            if (isSensitiveProcess(pid)) {
×
178
              return;
179
            }
180
            if (!out.isEmpty()) {
×
181
              onProcessOutput(out, false, pid);
×
182
            }
183
            if (!err.isEmpty()) {
×
184
              onProcessOutput(err, true, pid);
×
185
            }
186
          });
187

188
  ui->lineEdit->setClearButtonEnabled(true);
21 ✔
189
  updateGrepButtonVisibility();
21 ✔
190

191
  setUiElementsEnabled(true);
21 ✔
192

193
  ui->lineEdit->setText(searchText);
21 ✔
194

195
  if (!m_qtPass->init()) {
21 ✔
196
    // no working config so this should just quit
197
    QApplication::quit();
×
198
    return;
199
  }
200

201
  // Initial focus is handled in showEvent() once the window is actually
202
  // mapped. Scheduling it here via a 10 ms QTimer was racy: if the timer
203
  // fires while the window has not yet been realised — e.g. an
204
  // ActivationChange queued by main()'s `activateWindow()` call before
205
  // `show()`, or a nested QDialog::exec() inside init() — the
206
  // QLineEdit's internal text engine hasn't been wired up and
207
  // selectAll() segfaults inside Qt (see #1187, #1188).
208
}
×
209

210
MainWindow::~MainWindow() { delete m_qtPass; }
82 ✔
211

212
/**
213
 * @brief MainWindow::focusInput selects any text (if applicable) in the search
214
 * box and sets focus to it. Allows for easy searching, called at application
215
 * start and when receiving empty message in MainWindow::messageAvailable when
216
 * compiled with SINGLE_APP=1 (default).
217
 */
218
void MainWindow::focusInput() {
8 ✔
219
  // Resolve the QLineEdit through the live widget tree rather than the
220
  // cached `ui->lineEdit` pointer.
221
  //
222
  // On a fresh-config first launch the constructor calls
223
  // `m_qtPass->init()` → `MainWindow::config()`, and `config()`'s
224
  // `applyWindowFlagsSettings()` does `setWindowFlags(...)` + `show()`
225
  // on the main window. `setWindowFlags` on a top-level widget rebuilds
226
  // the native window via `setParent(nullptr, flags)`; under Qt 6.11
227
  // we observed the QLineEdit attached to the centralWidget gets
228
  // destroyed in that rebuild while `ui->lineEdit` still holds its old
229
  // address — leading to a SIGSEGV inside `QWidget::testAttribute`
230
  // (called from `QLineEdit::isVisible` / `selectAll`). `findChild<>()`
231
  // walks the current hierarchy and returns null cleanly when the
232
  // widget is gone, so `focusInput` becomes a safe no-op instead of a
233
  // use-after-free.
234
  if (!isVisible()) {
8 ✔
235
    return;
236
  }
237
  auto *lineEdit = findChild<QLineEdit *>(QStringLiteral("lineEdit"));
8 ✔
238
  if (lineEdit == nullptr || !lineEdit->isVisible()) {
8 ✔
239
    return;
240
  }
241
  lineEdit->selectAll();
8 ✔
242
  lineEdit->setFocus();
8 ✔
243
  // Only mark the first-show focus pulse as done once it's actually
244
  // landed; setting it eagerly in showEvent() would consume the
245
  // one-shot if focusInput returned early (mid-rebuild widget state)
246
  // and we'd never retry.
247
  m_firstShowCompleted = true;
8 ✔
248
}
249

250
/**
251
 * @brief MainWindow::changeEvent sets focus to the search box on activation
252
 * and re-derives palette-dependent styling on a theme change.
253
 * @param event
254
 */
255
void MainWindow::changeEvent(QEvent *event) {
31 ✔
256
  QWidget::changeEvent(event);
31 ✔
257
  if (event->type() == QEvent::PaletteChange && m_displayPanel != nullptr) {
31 ✔
258
    // Desktop switched light/dark (e.g. KDE day/night). Top-level widgets
259
    // receive PaletteChange for that (ApplicationPaletteChange goes to the
260
    // QApplication object). Styling that bakes palette colours into
261
    // stylesheets must be re-derived by hand.
262
    m_displayPanel->refreshPalette();
5 ✔
263
  }
264
  if (event->type() == QEvent::ActivationChange && isActiveWindow() &&
31 ✔
265
      isVisible()) {
266
    // Defer one event-loop tick so the synchronous activation dispatch
267
    // chain (`QApplicationPrivate::setActiveWindow` → `notify_helper`)
268
    // unwinds before we touch widget state — calling `focusInput()`
269
    // inline from this stack has segfaulted in past iterations because
270
    // mid-rebuild ui state isn't fully wired up yet.
271
    QMetaObject::invokeMethod(this, &MainWindow::focusInput,
4 ✔
272
                              Qt::QueuedConnection);
273
  }
274
}
31 ✔
275

276
/**
277
 * @brief First-show hook: run the initial focusInput() pulse once the
278
 *        window is actually mapped. The widget's internal data is fully
279
 *        initialised by this point, so QLineEdit::selectAll() is safe.
280
 * @param event Show event passed to the base class.
281
 */
282
void MainWindow::showEvent(QShowEvent *event) {
4 ✔
283
  QMainWindow::showEvent(event);
4 ✔
284
  if (m_firstShowCompleted) {
4 ✔
285
    return;
286
  }
287
  // Queue the focus pulse for the next event-loop tick so the platform
288
  // map round-trip and any pending widget rebuilds (e.g. setWindowFlags
289
  // from the config wizard path) settle before we look up the line
290
  // edit. The `m_firstShowCompleted` latch is set inside focusInput()
291
  // *after* it actually focuses, so a transient failed lookup just
292
  // re-queues on the next show rather than silently dropping.
293
  QMetaObject::invokeMethod(this, &MainWindow::focusInput,
4 ✔
294
                            Qt::QueuedConnection);
295
}
296

297
/**
298
 * @brief MainWindow::initToolBarButtons init main ToolBar and connect actions
299
 */
300
void MainWindow::initToolBarButtons() {
21 ✔
301
  connect(ui->actionAddPassword, &QAction::triggered, this,
21 ✔
302
          &MainWindow::addPassword);
21 ✔
303
  connect(ui->actionAddFolder, &QAction::triggered, this,
21 ✔
304
          &MainWindow::addFolder);
21 ✔
305
  connect(ui->actionEdit, &QAction::triggered, this, &MainWindow::onEdit);
21 ✔
306
  connect(ui->actionDelete, &QAction::triggered, this, &MainWindow::onDelete);
21 ✔
307
  connect(ui->actionPush, &QAction::triggered, this, &MainWindow::onPush);
21 ✔
308
  connect(ui->actionUpdate, &QAction::triggered, this, &MainWindow::onUpdate);
21 ✔
309
  connect(ui->actionUsers, &QAction::triggered, this, &MainWindow::onUsers);
21 ✔
310
  connect(ui->actionConfig, &QAction::triggered, this, &MainWindow::onConfig);
21 ✔
311
  connect(ui->actionOtp, &QAction::triggered, this, &MainWindow::onOtp);
21 ✔
312

313
  ui->actionAddPassword->setIcon(
21 ✔
314
      QIcon::fromTheme("document-new", QIcon(":/icons/document-new.svg")));
84 ✔
315
  ui->actionAddFolder->setIcon(
21 ✔
316
      QIcon::fromTheme("folder-new", QIcon(":/icons/folder-new.svg")));
84 ✔
317
  ui->actionEdit->setIcon(QIcon::fromTheme(
21 ✔
318
      "document-properties", QIcon(":/icons/document-properties.svg")));
63 ✔
319
  ui->actionDelete->setIcon(
21 ✔
320
      QIcon::fromTheme("edit-delete", QIcon(":/icons/edit-delete.svg")));
84 ✔
321
  ui->actionPush->setIcon(
21 ✔
322
      QIcon::fromTheme("go-up", QIcon(":/icons/go-top.svg")));
84 ✔
323
  ui->actionUpdate->setIcon(
21 ✔
324
      QIcon::fromTheme("go-down", QIcon(":/icons/go-bottom.svg")));
84 ✔
325
  ui->actionUsers->setIcon(QIcon::fromTheme(
21 ✔
326
      "x-office-address-book", QIcon(":/icons/x-office-address-book.svg")));
63 ✔
327
  ui->actionConfig->setIcon(QIcon::fromTheme(
21 ✔
328
      "applications-system", QIcon(":/icons/applications-system.svg")));
42 ✔
329
}
21 ✔
330

331
/**
332
 * @brief MainWindow::initStatusBar init statusBar with default message and logo
333
 */
334
void MainWindow::initStatusBar() {
21 ✔
335
  ui->statusBar->showMessage(tr("Welcome to QtPass %1").arg(VERSION), 2000);
42 ✔
336

337
  QPixmap logo = QPixmap::fromImage(QImage(":/artwork/icon.svg"))
42 ✔
338
                     .scaledToHeight(statusBar()->height());
21 ✔
339
  auto *logoApp = new QLabel(statusBar());
21 ✔
340
  logoApp->setPixmap(logo);
21 ✔
341
  statusBar()->addPermanentWidget(logoApp);
21 ✔
342
}
21 ✔
343

344
/**
345
 * @brief Build the process-output panel as a bottom QDockWidget.
346
 *
347
 * The panel is constructed programmatically rather than declared in
348
 * mainwindow.ui: uic only places QMainWindow's top-level children into
349
 * the centralWidget / statusBar / menuBar / toolBars / dock-widget
350
 * slots, and the previous home (statusBar()->addPermanentWidget()) made
351
 * an 80–150 px tall QTextEdit sit inside what is otherwise a thin
352
 * status row. A QDockWidget at the bottom dock area is the conventional
353
 * place for an IDE-style output console, and it gives users
354
 * detach/move for free.
355
 */
356
void MainWindow::initProcessOutputPanel() {
21 ✔
357
  m_processOutputWidget = new QWidget;
21 ✔
358
  m_processOutputWidget->setObjectName(QStringLiteral("processOutputWidget"));
42 ✔
359
  auto *outputLayout = new QHBoxLayout(m_processOutputWidget);
21 ✔
360
  outputLayout->setObjectName(QStringLiteral("processOutputLayout"));
42 ✔
361
  outputLayout->setContentsMargins(0, 0, 0, 0);
21 ✔
362
  m_clearOutputButton = new QToolButton(m_processOutputWidget);
21 ✔
363
  m_clearOutputButton->setObjectName(QStringLiteral("clearOutputButton"));
42 ✔
364
  m_clearOutputButton->setText(tr("Clear"));
21 ✔
365
  m_clearOutputButton->setToolTip(tr("Clear output"));
21 ✔
366
  outputLayout->addWidget(m_clearOutputButton);
21 ✔
367
  m_processOutputEdit = new QTextEdit(m_processOutputWidget);
21 ✔
368
  m_processOutputEdit->setObjectName(QStringLiteral("processOutputEdit"));
42 ✔
369
  m_processOutputEdit->setReadOnly(true);
21 ✔
370
  m_processOutputEdit->setAcceptRichText(false);
21 ✔
371
  outputLayout->addWidget(m_processOutputEdit);
21 ✔
372

373
  m_processOutputDock = new QDockWidget(tr("Process Output"), this);
21 ✔
374
  m_processOutputDock->setObjectName(QStringLiteral("processOutputDock"));
42 ✔
375
  m_processOutputDock->setFeatures(QDockWidget::DockWidgetMovable |
21 ✔
376
                                   QDockWidget::DockWidgetFloatable);
377
  m_processOutputDock->setAllowedAreas(Qt::BottomDockWidgetArea |
21 ✔
378
                                       Qt::TopDockWidgetArea);
379
  m_processOutputDock->setWidget(m_processOutputWidget);
21 ✔
380
  addDockWidget(Qt::BottomDockWidgetArea, m_processOutputDock);
21 ✔
381
  // setVisible after addDockWidget so our explicit preference wins
382
  // even if QMainWindow applies any cached state when the dock is
383
  // attached. restoreWindow() runs before this method (it's called
384
  // from the QtPass ctor, which is constructed at the top of the
385
  // MainWindow ctor), so the saved layout has already been processed
386
  // by the time we get here.
387
  m_processOutputDock->setVisible(QtPassSettings::isShowProcessOutput());
21 ✔
388

389
  connect(m_clearOutputButton, &QToolButton::clicked, this,
21 ✔
390
          &MainWindow::on_clearOutputButton_clicked);
21 ✔
391

392
  // Hysteresis: while the user is actively dragging the slider, don't
393
  // touch m_autoScroll on every tick — a brief overshoot at maximum
394
  // would silently re-arm auto-scroll without an explicit release. Only
395
  // commit on slider release. Wheel/keyboard scroll never sets
396
  // isSliderDown(), so they still update immediately.
397
  connect(m_processOutputEdit->verticalScrollBar(), &QScrollBar::valueChanged,
21 ✔
398
          this, [this]() {
21 ✔
399
            auto *sb = m_processOutputEdit->verticalScrollBar();
×
400
            if (sb->isSliderDown())
×
401
              return;
402
            m_autoScroll = sb->value() >= sb->maximum();
×
403
          });
404
  connect(m_processOutputEdit->verticalScrollBar(), &QScrollBar::sliderReleased,
21 ✔
405
          this, [this]() {
21 ✔
406
            auto *sb = m_processOutputEdit->verticalScrollBar();
×
407
            m_autoScroll = sb->value() >= sb->maximum();
×
408
          });
×
409
}
21 ✔
410

411
auto MainWindow::getCurrentTreeViewIndex() -> QModelIndex {
1 ✔
412
  return ui->treeView->currentIndex();
1 ✔
413
}
414

415
void MainWindow::cleanKeygenDialog() {
1 ✔
416
  if (m_keyGenDialog != nullptr) {
1 ✔
417
    m_keyGenDialog->close();
×
418
  }
419
  m_keyGenDialog = nullptr;
420
}
1 ✔
421

422
/**
423
 * @brief Displays the given text in the main window text browser, optionally
424
 * marking it as an error and/or rendering it as HTML.
425
 * @example
426
 * MainWindow window;
427
 * window.flashText("Operation completed.", false, false);
428
 *
429
 * @param const QString &text - The text content to display.
430
 * @param const bool isError - If true, sets the text color to red before
431
 * displaying the text.
432
 * @param const bool isHtml - If true, treats the text as HTML and appends it to
433
 * the existing HTML content.
434
 * @return void - No return value.
435
 */
436
void MainWindow::flashText(const QString &text, const bool isError,
9 ✔
437
                           const bool isHtml) {
438
  if (isError) {
9 ✔
439
    ui->textBrowser->setTextColor(Qt::red);
2 ✔
440
  }
441

442
  if (isHtml) {
9 ✔
443
    QString _text = text;
444
    if (!ui->textBrowser->toPlainText().isEmpty()) {
12 ✔
445
      _text = ui->textBrowser->toHtml() + _text;
2 ✔
446
    }
447
    ui->textBrowser->setHtml(_text);
6 ✔
448
  } else {
449
    ui->textBrowser->setText(text);
3 ✔
450
  }
451
}
9 ✔
452

453
/**
454
 * @brief MainWindow::config pops up the configuration screen and handles all
455
 * inter-window communication
456
 */
457
void MainWindow::applyTextBrowserSettings() {
×
458
  const AppSettings s = QtPassSettings::load();
×
459
  if (s.useMonospace) {
×
460
    QFont monospace("Monospace");
×
461
    monospace.setStyleHint(QFont::Monospace);
×
462
    ui->textBrowser->setFont(monospace);
×
463
  } else {
×
464
    ui->textBrowser->setFont(QFont());
×
465
  }
466

467
  if (s.noLineWrapping) {
×
468
    ui->textBrowser->setLineWrapMode(QTextBrowser::NoWrap);
×
469
  } else {
470
    ui->textBrowser->setLineWrapMode(QTextBrowser::WidgetWidth);
×
471
  }
472
}
×
473

474
void MainWindow::applyWindowFlagsSettings() {
×
475
  if (QtPassSettings::isAlwaysOnTop()) {
×
476
    Qt::WindowFlags flags = windowFlags();
477
    this->setWindowFlags(flags | Qt::WindowStaysOnTopHint);
×
478
  } else {
479
    this->setWindowFlags(Qt::Window);
×
480
  }
481
  this->show();
×
482
}
×
483

484
/**
485
 * @brief Opens and processes the application configuration dialog, then applies
486
 * any accepted settings.
487
 * @example
488
 * config();
489
 *
490
 * @return void - This function does not return a value.
491
 */
492
void MainWindow::config() {
×
493
  QScopedPointer<ConfigDialog> d(new ConfigDialog(this));
×
494
  d->setModal(true);
×
495
  // Automatically default to pass if it's available
496
  if (m_qtPass->isFreshStart() &&
×
497
      QFile(QtPassSettings::getPassExecutable()).exists()) {
×
498
    QtPassSettings::setUsePass(true);
×
499
  }
500

501
  if (m_qtPass->isFreshStart()) {
×
502
    d->wizard(); // run initial setup wizard for first-time configuration
×
503
  }
504
  if (d->exec()) {
×
505
    if (d->result() == QDialog::Accepted) {
×
506
      applyTextBrowserSettings();
×
507
      applyWindowFlagsSettings();
×
508

509
      updateProfileBox();
×
510
      const AppSettings s = QtPassSettings::load();
×
511
      proxyModel.setStore(s.passStore);
×
512
      ui->treeView->setRootIndex(proxyModel.rootIndexFor(s.passStore));
×
513
      deselect();
×
514
      ui->treeView->setCurrentIndex(QModelIndex());
×
515

516
      if (m_qtPass->isFreshStart() && !Util::configIsValid(s)) {
×
517
        config();
×
518
        return;
519
      }
520
      Pass *activePass = QtPassSettings::getPass();
×
521
      activePass->updateEnv();
×
522
      proxyModel.setPass(activePass);
×
523
      clearPanelTimer.setInterval(MS_PER_SECOND * s.autoclearPanelSeconds);
×
524
      m_qtPass->setClipboardTimer();
×
525

526
      updateGitButtonVisibility();
×
527
      updateOtpButtonVisibility();
×
528
      updateGrepButtonVisibility();
×
529
      updateProcessOutputVisibility();
×
530
      if (s.useTrayIcon && m_tray == nullptr) {
×
531
        initTrayIcon();
×
532
      } else if (!s.useTrayIcon && m_tray != nullptr) {
×
533
        destroyTrayIcon();
×
534
      }
535
    }
×
536

537
    m_qtPass->setFreshStart(false);
×
538
  }
539
}
×
540

541
/**
542
 * @brief MainWindow::onUpdate do a git pull
543
 */
544
void MainWindow::onUpdate(bool block) {
×
545
  ui->statusBar->showMessage(tr("Updating password-store"), 2000);
×
546
  if (block) {
×
547
    QtPassSettings::getPass()->GitPull_b();
×
548
  } else {
549
    QtPassSettings::getPass()->GitPull();
×
550
  }
551
}
×
552

553
/**
554
 * @brief MainWindow::onPush do a git push
555
 */
556
void MainWindow::onPush() {
×
557
  if (QtPassSettings::isUseGit()) {
×
558
    ui->statusBar->showMessage(tr("Updating password-store"), 2000);
×
559
    QtPassSettings::getPass()->GitPush();
×
560
  }
561
}
×
562

563
/**
564
 * @brief MainWindow::getFile get the selected file path
565
 * @param index
566
 * @param forPass returns relative path without '.gpg' extension
567
 * @return path
568
 * @return
569
 */
570
auto MainWindow::getFile(const QModelIndex &index, bool forPass) -> QString {
3 ✔
571
  if (!index.isValid() ||
3 ✔
572
      !model.fileInfo(proxyModel.mapToSource(index)).isFile()) {
6 ✔
573
    return {};
574
  }
575
  QString filePath = model.filePath(proxyModel.mapToSource(index));
3 ✔
576
  if (forPass) {
3 ✔
577
    filePath = QDir(QtPassSettings::getPassStore()).relativeFilePath(filePath);
9 ✔
578
    filePath.replace(Util::endsWithGpg(), "");
6 ✔
579
  }
580
  return filePath;
581
}
582

583
/**
584
 * @brief MainWindow::on_treeView_clicked read the selected password file
585
 * @param index
586
 */
587
void MainWindow::on_treeView_clicked(const QModelIndex &index) {
2 ✔
588
  bool cleared = ui->treeView->currentIndex().flags() == Qt::NoItemFlags;
2 ✔
589
  m_currentDir = Util::getDir(ui->treeView->currentIndex(), false, model,
4 ✔
590
                              proxyModel, QtPassSettings::getPassStore());
4 ✔
591
  QString file = getFile(index, true);
2 ✔
592
  ui->passwordName->setText(file);
2 ✔
593
  if (!file.isEmpty() && !cleared) {
2 ✔
594
    // Remember what the panel is about to show, so onOtp() can tell whether the
595
    // code it can see belongs to the entry that is currently selected.
596
    m_shownFile = file;
2 ✔
597
    QtPassSettings::getPass()->Show(file);
4 ✔
598
  } else {
599
    m_shownFile.clear();
×
600
    clearPanel(false);
×
601
    ui->actionEdit->setEnabled(false);
×
602
    ui->actionDelete->setEnabled(true);
×
603
  }
604
}
2 ✔
605

606
/**
607
 * @brief MainWindow::on_treeView_doubleClicked when doubleclicked on
608
 * TreeViewItem, open the edit Window
609
 * @param index
610
 */
611
void MainWindow::on_treeView_doubleClicked(const QModelIndex &index) {
1 ✔
612
  QFileInfo fileOrFolder =
613
      model.fileInfo(proxyModel.mapToSource(ui->treeView->currentIndex()));
1 ✔
614

615
  if (fileOrFolder.isFile()) {
1 ✔
616
    editPassword(getFile(index, true));
2 ✔
617
  }
618
}
1 ✔
619

620
/**
621
 * @brief MainWindow::deselect clear the selection, password and copy buffer
622
 */
623
void MainWindow::deselect() {
1 ✔
624
  m_currentDir = "";
1 ✔
625
  m_shownFile.clear();
1 ✔
626
  cancelOtpRequest();
1 ✔
627
  m_qtPass->clearClipboard();
1 ✔
628
  ui->treeView->clearSelection();
1 ✔
629
  ui->actionEdit->setEnabled(false);
1 ✔
630
  ui->actionDelete->setEnabled(false);
1 ✔
631
  ui->passwordName->setText("");
1 ✔
632
  clearPanel(false);
1 ✔
633
}
1 ✔
634

635
void MainWindow::executeWrapperStarted() {
5 ✔
636
  m_displayPanel->clear();
5 ✔
637
  ui->textBrowser->clear();
5 ✔
638
  setUiElementsEnabled(false);
5 ✔
639
  clearPanelTimer.stop();
5 ✔
640
  if (QtPassSettings::isShowProcessOutput()) {
5 ✔
641
    m_processOutputDock->setVisible(true);
×
642
  }
643
}
5 ✔
644

645
/**
646
 * @brief Handles displaying parsed password entry content in the main window.
647
 * @example
648
 * void result = MainWindow::passShowHandler(p_output);
649
 * // Updates the UI with parsed fields and emits
650
 * passShowHandlerFinished(output)
651
 *
652
 * @param p_output - The raw output text containing the password entry data.
653
 * @return void - This function does not return a value.
654
 */
655
void MainWindow::passShowHandler(const QString &p_output) {
3 ✔
656
  const AppSettings s = QtPassSettings::load();
3 ✔
657
  QStringList templ =
658
      s.useTemplate ? s.passTemplate.split("\n") : QStringList();
3 ✔
659
  bool allFields = s.useTemplate && s.templateAllFields;
3 ✔
660
  FileContent fileContent = FileContent::parse(p_output, templ, allFields);
3 ✔
661
  QString output = p_output;
662
  // Display variant: empty when the password line is itself an otpauth URI, so
663
  // the shared secret is neither rendered nor copied to the clipboard.
664
  QString password = fileContent.getPasswordForDisplay();
3 ✔
665

666
  // set clipped text
667
  //
668
  // Skipped for an OTP request: the user asked for a one-time code, not the
669
  // password, and writing both in one event-loop turn can leave the Windows
670
  // clipboard empty (two OleSetClipboard calls back to back).
671
  if (!m_otpRequestPending) {
3 ✔
672
    m_qtPass->setClippedText(password, p_output);
3 ✔
673
  }
674

675
  // first clear the current view:
676
  m_displayPanel->clear();
3 ✔
677

678
  // show what is needed:
679
  if (s.hideContent) {
3 ✔
680
    output = "***" + tr("Content hidden") + "***";
×
681
  } else if (!s.displayAsIs) {
3 ✔
682
    m_displayPanel->displayFields(password, fileContent.getNamedValues(), s,
6 ✔
683
                                  s.useOtp ? fileContent.getOtpUri()
3 ✔
684
                                           : QString());
685
    output = fileContent.getRemainingDataForDisplay();
6 ✔
686
  }
687

688
  if (s.useAutoclearPanel) {
3 ✔
689
    clearPanelTimer.start();
×
690
  }
691

692
  emit passShowHandlerFinished(output);
3 ✔
693
  setUiElementsEnabled(true);
3 ✔
694
}
6 ✔
695

696
/**
697
 * @brief Generates a one-time password from a decrypted entry and copies it.
698
 *
699
 * Connected as a one-shot to Pass::finishedShow by onOtp(). passShowHandler is
700
 * connected first, so by the time this runs the panel has already been
701
 * repainted and the UI re-enabled.
702
 *
703
 * @param p_output - The decrypted entry content.
704
 * @return void - This function does not return a value.
705
 */
706
void MainWindow::otpFromFileToClipboard(const QString &p_output) {
×
707
  disconnectSingleShot(QtPassSettings::getPass(), &Pass::finishedShow, this,
×
708
                       &MainWindow::otpFromFileToClipboard);
709
  // A failed decrypt never fires finishedShow, and Qt::SingleShotConnection
710
  // only self-disconnects when it does fire, so a connection armed by an
711
  // earlier failed request can still be live here. Ignore it rather than
712
  // hijacking an unrelated entry's decrypted content.
713
  if (!m_otpRequestPending) {
×
714
    return;
×
715
  }
716
  // finishedShow carries no request identity, so make sure this decrypt is the
717
  // one we asked for and not a tree click that happened to land first.
718
  if (m_otpRequestFile != getFile(ui->treeView->currentIndex(), true)) {
×
719
    cancelOtpRequest();
×
720
    setUiElementsEnabled(true);
×
721
    return;
×
722
  }
723
  m_otpRequestPending = false;
×
724
  m_otpRequestFile.clear();
×
725

726
  if (p_output.isEmpty()) {
×
727
    // Distinguish "could not read the entry" from "entry has no OTP".
728
    flashText(tr("Could not decrypt this password entry"), true);
×
729
    setUiElementsEnabled(true);
×
730
    return;
×
731
  }
732

733
  // passShowHandler is connected first, so it has already repainted the panel
734
  // for this same finishedShow. When it rendered the OTP row the current code
735
  // is derived and cached, so reuse it instead of re-loading settings and
736
  // re-parsing p_output (mirrors onOtp()'s fast path). Falls through to a fresh
737
  // parse when no OTP row is shown (hideContent / displayAsIs / no OTP field).
738
  const QString shown = m_displayPanel->currentOtpCode();
×
739
  if (!shown.isEmpty()) {
×
740
    m_qtPass->copyTextToClipboard(shown);
×
741
    showStatusMessage(tr("OTP code copied to clipboard"));
×
742
    setUiElementsEnabled(true);
×
743
    return;
744
  }
745

746
  const AppSettings s = QtPassSettings::load();
×
747
  // Parse with the same template settings passShowHandler uses, so an OTP
748
  // field is recognised identically in both paths.
749
  const QStringList templ =
750
      s.useTemplate ? s.passTemplate.split("\n") : QStringList();
×
751
  const bool allFields = s.useTemplate && s.templateAllFields;
×
752
  const FileContent fileContent =
753
      FileContent::parse(p_output, templ, allFields);
×
754

755
  const std::optional<Totp::Settings> settings =
756
      Totp::parse(fileContent.getOtpUri());
×
757
  if (settings.has_value()) {
×
758
    m_qtPass->copyTextToClipboard(Totp::generateNow(*settings));
×
759
    showStatusMessage(tr("OTP code copied to clipboard"));
×
760
  } else {
761
    flashText(tr("No OTP code found in this password entry"), true);
×
762
  }
763
  setUiElementsEnabled(true);
×
764
}
×
765

766
/**
767
 * @brief MainWindow::clearPanel hide the information from shoulder surfers
768
 */
769
void MainWindow::clearPanel(bool notify) {
1 ✔
770
  m_displayPanel->clear();
1 ✔
771
  const bool grepWasVisible = ui->grepResultsList->isVisible();
1 ✔
772
  ui->grepResultsList->clear();
1 ✔
773
  if (grepWasVisible) {
1 ✔
774
    ui->grepResultsList->setVisible(false);
×
775
    ui->treeView->setVisible(true);
×
776
    if (m_grep.inGrepMode()) {
×
777
      m_grep.clearGrepMode();
778
      ui->grepButton->blockSignals(true);
×
779
      ui->grepButton->setChecked(false);
×
780
      ui->grepButton->blockSignals(false);
×
781
      ui->lineEdit->blockSignals(true);
×
782
      ui->lineEdit->clear();
×
783
      ui->lineEdit->blockSignals(false);
×
784
      ui->lineEdit->setPlaceholderText(tr("Search Password"));
×
785
    }
786
  }
787
  if (notify) {
1 ✔
788
    QString output = "***" + tr("Password and Content hidden") + "***";
×
789
    ui->textBrowser->setHtml(output);
×
790
  } else {
791
    ui->textBrowser->setHtml("");
2 ✔
792
  }
793
}
1 ✔
794

795
/**
796
 * @brief MainWindow::setUiElementsEnabled enable or disable the relevant UI
797
 * elements
798
 * @param state
799
 */
800
void MainWindow::setUiElementsEnabled(bool state) {
34 ✔
801
  // Arm the watchdog while the UI is disabled; disarm once re-enabled.
802
  if (state) {
34 ✔
803
    m_uiWatchdog.stop();
27 ✔
804
  } else {
805
    m_uiWatchdog.start();
7 ✔
806
  }
807
  ui->treeView->setEnabled(state);
34 ✔
808
  ui->lineEdit->setEnabled(state);
34 ✔
809
  ui->actionAddPassword->setEnabled(state);
34 ✔
810
  ui->actionAddFolder->setEnabled(state);
34 ✔
811
  ui->actionUsers->setEnabled(state);
34 ✔
812
  ui->actionConfig->setEnabled(state);
34 ✔
813
  // is a file selected?
814
  state &= ui->treeView->currentIndex().isValid();
68 ✔
815
  ui->actionDelete->setEnabled(state);
34 ✔
816
  ui->actionEdit->setEnabled(state);
34 ✔
817
  updateGitButtonVisibility();
34 ✔
818
  // `state` is now "UI enabled AND a file is selected", which is exactly when
819
  // generating an OTP makes sense.
820
  updateOtpButtonVisibility(state);
34 ✔
821
}
34 ✔
822

823
/**
824
 * @brief Restores the main window geometry, state, position, size, and
825
 * tray/icon settings from saved application settings.
826
 * @example
827
 * MainWindow window;
828
 * window.restoreWindow();
829
 *
830
 * @return void - This function does not return a value.
831
 */
832
void MainWindow::restoreWindow() {
21 ✔
833
  QByteArray geometry = QtPassSettings::getGeometry(saveGeometry());
21 ✔
834
  restoreGeometry(geometry);
21 ✔
835
  QByteArray savestate = QtPassSettings::getSavestate(saveState());
21 ✔
836
  restoreState(savestate);
21 ✔
837
  QPoint position = QtPassSettings::getPos(pos());
21 ✔
838
  move(position);
21 ✔
839
  QSize newSize = QtPassSettings::getSize(size());
21 ✔
840
  resize(newSize);
21 ✔
841
  const AppSettings s = QtPassSettings::load();
21 ✔
842
  if (s.maximized) {
21 ✔
843
    showMaximized();
×
844
  }
845

846
  if (s.alwaysOnTop) {
21 ✔
847
    Qt::WindowFlags flags = windowFlags();
848
    setWindowFlags(flags | Qt::WindowStaysOnTopHint);
×
849
    show();
×
850
  }
851

852
  if (s.useTrayIcon && m_tray == nullptr) {
21 ✔
853
    initTrayIcon();
×
854
    if (s.startMinimized) {
×
855
      // since we are still in constructor, can't directly hide
856
      QTimer::singleShot(10, this, SLOT(hide()));
×
857
    }
858
  } else if (!s.useTrayIcon && m_tray != nullptr) {
21 ✔
859
    destroyTrayIcon();
×
860
  }
861
}
42 ✔
862

863
/**
864
 * @brief MainWindow::on_configButton_clicked run Mainwindow::config
865
 */
866
void MainWindow::onConfig() { config(); }
×
867

868
/**
869
 * @brief Executes when the string in the search box changes, collapses the
870
 * TreeView
871
 * @param arg1
872
 */
873
void MainWindow::on_lineEdit_textChanged(const QString &arg1) {
×
874
  if (m_grep.inGrepMode())
×
875
    return;
876
  ui->statusBar->showMessage(tr("Looking for: %1").arg(arg1), 1000);
×
877
  ui->treeView->expandAll();
×
878
  clearPanel(false);
×
879
  ui->passwordName->setText("");
×
880
  ui->actionEdit->setEnabled(false);
×
881
  ui->actionDelete->setEnabled(false);
×
882
  searchTimer.start();
×
883
}
884

885
/**
886
 * @brief MainWindow::onTimeoutSearch Fired when search is finished or too much
887
 * time from two keypresses is elapsed
888
 */
889
void MainWindow::onTimeoutSearch() {
×
890
  QString query = ui->lineEdit->text();
×
891

892
  if (query.isEmpty()) {
×
893
    ui->treeView->collapseAll();
×
894
    deselect();
×
895
  }
896

897
  query.replace(QStringLiteral(" "), ".*");
×
898
  QRegularExpression regExp(query, QRegularExpression::CaseInsensitiveOption);
×
899
  if (!regExp.isValid())
×
900
    return;
901
  proxyModel.setFilterRegularExpression(regExp);
×
902
  ui->treeView->setRootIndex(
×
903
      proxyModel.rootIndexFor(QtPassSettings::getPassStore()));
×
904

905
  if (proxyModel.rowCount() > 0 && !query.isEmpty()) {
×
906
    selectFirstFile();
×
907
  } else {
908
    ui->actionEdit->setEnabled(false);
×
909
    ui->actionDelete->setEnabled(false);
×
910
  }
911
}
×
912

913
/**
914
 * @brief MainWindow::on_lineEdit_returnPressed get searching
915
 *
916
 * Select the first possible file in the tree
917
 */
918
void MainWindow::on_lineEdit_returnPressed() {
×
919
#ifdef QT_DEBUG
920
  dbg() << "on_lineEdit_returnPressed" << proxyModel.rowCount();
921
#endif
922

923
  if (m_grep.inGrepMode()) {
×
924
    const QString query = ui->lineEdit->text();
×
925
    if (!query.isEmpty()) {
×
926
      ui->grepResultsList->clear();
×
927
      ui->statusBar->showMessage(tr("Searching…"));
×
928
      if (m_grep.beginSearch()) {
929
        QApplication::setOverrideCursor(Qt::WaitCursor);
×
930
      }
931
      QtPassSettings::getPass()->Grep(query, ui->grepCaseButton->isChecked());
×
932
    } else {
933
      if (m_grep.cancelSearch()) {
934
        QApplication::restoreOverrideCursor();
×
935
      }
936
      ui->grepResultsList->clear();
×
937
      ui->grepResultsList->setVisible(false);
×
938
      ui->treeView->setVisible(true);
×
939
    }
940
    return;
941
  }
942

943
  if (proxyModel.rowCount() > 0) {
×
944
    selectFirstFile();
×
945
    on_treeView_clicked(ui->treeView->currentIndex());
×
946
  }
947
}
948

949
/**
950
 * @brief Toggle grep (content search) mode.
951
 */
952
void MainWindow::on_grepButton_toggled(bool checked) {
×
953
  const AppSettings s = QtPassSettings::load();
×
954
  if (checked) {
×
955
    m_grep.enterGrepMode();
956
    ui->lineEdit->setPlaceholderText(tr("Search content (regex)"));
×
957
    // The regex dialect depends on the backend (see Pass::Grep): the pass
958
    // backend uses POSIX BRE via `pass grep`, the native backend uses PCRE.
959
    ui->lineEdit->setToolTip(
×
960
        s.usePass
×
961
            ? tr("Content search uses POSIX basic regular expressions "
×
962
                 "(pass grep).")
963
            : tr("Content search uses Perl-compatible regular expressions "
964
                 "(PCRE)."));
965
    ui->lineEdit->clear();
×
966
    searchTimer.stop();
×
967
    proxyModel.setFilterRegularExpression(QRegularExpression());
×
968
    ui->treeView->setRootIndex(proxyModel.rootIndexFor(s.passStore));
×
969
    ui->grepResultsList->setVisible(false);
×
970
    // Keep treeView visible until results arrive
971
  } else {
972
    if (m_grep.leaveGrepMode()) {
973
      QApplication::restoreOverrideCursor();
×
974
    }
975
    searchTimer.stop();
×
976
    ui->lineEdit->blockSignals(true);
×
977
    ui->lineEdit->clear();
×
978
    ui->lineEdit->blockSignals(false);
×
979
    ui->lineEdit->setPlaceholderText(tr("Search Password"));
×
980
    ui->lineEdit->setToolTip(QString());
×
981
    ui->grepResultsList->clear();
×
982
    ui->grepResultsList->setVisible(false);
×
983
    ui->treeView->setVisible(true);
×
984
    proxyModel.setFilterRegularExpression(QRegularExpression());
×
985
    ui->treeView->setRootIndex(proxyModel.rootIndexFor(s.passStore));
×
986
  }
987
}
×
988

989
/**
990
 * @brief Display grep results in grepResultsList.
991
 */
992
void MainWindow::onGrepFinished(
×
993
    const QList<QPair<QString, QStringList>> &results) {
994
  const GrepSearchController::FinishOutcome outcome = m_grep.finishSearch();
995
  if (outcome.restoreCursor) {
×
996
    QApplication::restoreOverrideCursor();
×
997
  }
998
  // Re-enable the UI before the discard check so a cancelled search can never
999
  // leave controls disabled.
1000
  setUiElementsEnabled(true);
×
1001
  if (outcome.discard) {
×
1002
    return;
×
1003
  }
1004
  if (!m_grep.inGrepMode())
×
1005
    return;
1006
  ui->grepResultsList->clear();
×
1007
  if (results.isEmpty()) {
×
1008
    ui->statusBar->showMessage(tr("No matches found."), 3000);
×
1009
    ui->grepResultsList->setVisible(false);
×
1010
    ui->treeView->setVisible(true);
×
1011
    return;
×
1012
  }
1013
  const AppSettings s = QtPassSettings::load();
×
1014
  const bool hideContent = s.hideContent;
×
1015
  int totalLines = 0;
1016
  for (const auto &pair : results) {
×
1017
    auto *entryItem = new QTreeWidgetItem(ui->grepResultsList);
×
1018
    entryItem->setText(0, pair.first);
×
1019
    entryItem->setData(0, Qt::UserRole, pair.first);
×
1020
    for (const QString &line : pair.second) {
×
1021
      auto *lineItem = new QTreeWidgetItem(entryItem);
×
1022
      lineItem->setText(0, hideContent ? "***" + tr("Content hidden") + "***"
×
1023
                                       : line);
1024
      lineItem->setData(0, Qt::UserRole, pair.first);
×
1025
      ++totalLines;
×
1026
    }
1027
  }
1028
  ui->grepResultsList->expandAll();
×
1029
  ui->treeView->setVisible(false);
×
1030
  ui->grepResultsList->setVisible(true);
×
1031
  ui->statusBar->showMessage(
×
1032
      tr("Found %n match(es)", nullptr, totalLines) + " " +
×
1033
          tr("in %n entr(ies).", nullptr, static_cast<int>(results.size())),
×
1034
      3000);
1035
  if (s.useAutoclearPanel)
×
1036
    clearPanelTimer.start();
×
1037
}
×
1038

1039
/**
1040
 * @brief Navigate to the password entry when a grep result is clicked.
1041
 */
1042
void MainWindow::on_grepResultsList_itemClicked(QTreeWidgetItem *item,
×
1043
                                                int /*column*/) {
1044
  const AppSettings s = QtPassSettings::load();
×
1045
  const QString entry = item->data(0, Qt::UserRole).toString();
×
1046
  if (entry.isEmpty())
×
1047
    return;
1048
  const QString fullPath =
1049
      QDir::cleanPath(QDir(s.passStore).filePath(entry + ".gpg"));
×
1050
  QModelIndex srcIndex = model.index(fullPath);
×
1051
  if (!srcIndex.isValid())
1052
    return;
1053
  QModelIndex proxyIndex = proxyModel.mapFromSource(srcIndex);
×
1054
  if (!proxyIndex.isValid())
1055
    return;
1056
  ui->treeView->setCurrentIndex(proxyIndex);
×
1057
  on_treeView_clicked(proxyIndex);
×
1058
  if (s.hideContent || s.useAutoclearPanel)
×
1059
    ui->grepResultsList->clear();
×
1060
  ui->grepResultsList->setVisible(false);
×
1061
  ui->treeView->setVisible(true);
×
1062
  ui->treeView->scrollTo(proxyIndex);
×
1063
  ui->treeView->setFocus();
×
1064
}
×
1065

1066
/**
1067
 * @brief MainWindow::selectFirstFile select the first possible file in the
1068
 * tree
1069
 */
1070
void MainWindow::selectFirstFile() {
×
1071
  QModelIndex index = proxyModel.rootIndexFor(QtPassSettings::getPassStore());
×
1072
  index = firstFile(index);
×
1073
  ui->treeView->setCurrentIndex(index);
×
1074
}
×
1075

1076
/**
1077
 * @brief MainWindow::firstFile return location of first possible file
1078
 * @param parentIndex
1079
 * @return QModelIndex
1080
 */
1081
auto MainWindow::firstFile(QModelIndex parentIndex) -> QModelIndex {
×
1082
  int numRows = proxyModel.rowCount(parentIndex);
×
1083
  for (int row = 0; row < numRows; ++row) {
×
1084
    QModelIndex index = proxyModel.index(row, 0, parentIndex);
×
1085
    if (model.fileInfo(proxyModel.mapToSource(index)).isFile()) {
×
1086
      return index;
×
1087
    }
1088
    if (proxyModel.hasChildren(index)) {
×
1089
      QModelIndex childFile = firstFile(index);
×
1090
      if (childFile.isValid())
1091
        return childFile;
×
1092
    }
1093
  }
1094
  return QModelIndex();
1095
}
1096

1097
/**
1098
 * @brief MainWindow::confirmPathInStore reject paths that resolve outside
1099
 * the password store and warn the user.
1100
 *
1101
 * Used before file/folder creation, move, and rename to stop user-typed
1102
 * names like "../../etc/passwd" or absolute paths from escaping the
1103
 * configured store root via the input dialogs.
1104
 *
1105
 * @param candidate Absolute candidate path to validate.
1106
 * @return true if the path is inside the password store; false otherwise (a
1107
 * warning dialog is shown in that case).
1108
 */
1109
auto MainWindow::confirmPathInStore(const QString &candidate) -> bool {
×
1110
  if (PathValidator::isPathInStore(QtPassSettings::getPassStore(), candidate)) {
×
1111
    return true;
1112
  }
1113
  QMessageBox::warning(this, tr("Invalid name"),
×
1114
                       tr("That name would resolve outside the password "
×
1115
                          "store. Please choose a different name."));
1116
  return false;
×
1117
}
1118

1119
/**
1120
 * @brief MainWindow::setPassword open passworddialog
1121
 * @param file which pgp file
1122
 * @param isNew insert (not update)
1123
 */
1124
void MainWindow::setPassword(const QString &file, bool isNew) {
1 ✔
1125
  const AppSettings s = QtPassSettings::load();
1 ✔
1126
  PasswordDialog d(QtPassSettings::getPass(), s, file, isNew, this);
1 ✔
1127

1128
  if (isNew) {
1 ✔
1129
    const QString storePath = s.passStore;
1130
    QString folder = Util::getDir(ui->treeView->currentIndex(), false, model,
×
1131
                                  proxyModel, s.passStore);
×
1132
    if (folder.isEmpty()) {
×
1133
      folder = storePath;
×
1134
    }
1135
    QHash<QString, QStringList> templates =
1136
        TemplateIO::readTemplates(storePath);
×
1137
    if (!templates.isEmpty()) {
1138
      QString defaultTemplate =
1139
          TemplateIO::getFolderTemplate(folder, storePath);
×
1140
      d.setAvailableTemplates(templates, defaultTemplate);
×
1141
      new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_T), &d,
×
1142
                    [&d]() { d.cycleTemplate(); });
×
1143
    }
1144
  }
×
1145

1146
  if (!d.exec()) {
1 ✔
1147
    ui->treeView->setFocus();
×
1148
  }
1149
}
1 ✔
1150

1151
/**
1152
 * @brief MainWindow::addPassword add a new password by showing a
1153
 * number of dialogs.
1154
 */
1155
void MainWindow::addPassword() {
×
1156
  const QString passStore = QtPassSettings::load().passStore;
×
1157
  bool ok;
1158
  QString dir = Util::getDir(ui->treeView->currentIndex(), true, model,
×
1159
                             proxyModel, passStore);
×
1160
  QString file = QInputDialog::getText(
1161
      this, tr("New file"),
×
1162
      tr("New password file: \n(Will be placed in %1 )")
×
1163
          .arg(passStore + Util::getDir(ui->treeView->currentIndex(), true,
×
1164
                                        model, proxyModel, passStore)),
1165
      QLineEdit::Normal, "", &ok);
×
1166
  if (!ok || file.isEmpty()) {
×
1167
    return;
1168
  }
1169
  file = dir + file;
×
1170
  if (!confirmPathInStore(passStore + file)) {
×
1171
    return;
1172
  }
1173
  setPassword(file);
×
1174
}
1175

1176
/**
1177
 * @brief MainWindow::onDelete remove password, if you are
1178
 * sure.
1179
 */
1180
void MainWindow::onDelete() {
×
1181
  QModelIndex currentIndex = ui->treeView->currentIndex();
×
1182
  if (!currentIndex.isValid()) {
1183
    // This fixes https://github.com/IJHack/QtPass/issues/556
1184
    // Otherwise the entire password directory would be deleted if
1185
    // nothing is selected in the tree view.
1186
    return;
×
1187
  }
1188

1189
  QFileInfo fileOrFolder =
1190
      model.fileInfo(proxyModel.mapToSource(ui->treeView->currentIndex()));
×
1191
  QString file = "";
×
1192
  bool isDir = false;
1193

1194
  if (fileOrFolder.isFile()) {
×
1195
    file = getFile(ui->treeView->currentIndex(), true);
×
1196
  } else {
1197
    file = Util::getDir(ui->treeView->currentIndex(), true, model, proxyModel,
×
1198
                        QtPassSettings::getPassStore());
×
1199
    isDir = true;
1200
  }
1201

1202
  QString dirMessage = tr(" and the whole content?");
1203
  if (isDir) {
×
1204
    QDirIterator it(model.rootPath() + QDir::separator() + file,
×
1205
                    QDirIterator::Subdirectories);
×
1206
    bool okDir = true;
1207
    while (it.hasNext() && okDir) {
×
1208
      it.next();
×
1209
      if (QFileInfo(it.filePath()).isFile()) {
×
1210
        if (QFileInfo(it.filePath()).suffix() != "gpg") {
×
1211
          okDir = false;
1212
          dirMessage = tr(" and the whole content? <br><strong>Attention: "
×
1213
                          "there are unexpected files in the given folder, "
1214
                          "check them before continue.</strong>");
1215
        }
1216
      }
1217
    }
1218
  }
×
1219

1220
  if (QMessageBox::question(
×
1221
          this, isDir ? tr("Delete folder?") : tr("Delete password?"),
×
1222
          tr("Are you sure you want to delete %1%2?")
×
1223
              .arg(QDir::separator() + file, isDir ? dirMessage : "?"),
×
1224
          QMessageBox::Yes | QMessageBox::No) != QMessageBox::Yes) {
1225
    return;
1226
  }
1227

1228
  QtPassSettings::getPass()->Remove(file, isDir);
×
1229
}
×
1230

1231
/**
1232
 * @brief MainWindow::cancelOtpRequest abandon an in-flight OTP request.
1233
 *
1234
 * Connected to Pass::processErrorExit, and called from deselect(). A failed
1235
 * decrypt never emits finishedShow, and the error handler re-enables the UI —
1236
 * which stops the watchdog that was the only other thing clearing the flag.
1237
 * Left set, it permanently suppressed passShowHandler's copy-on-select and let
1238
 * the still-armed one-shot claim the next unrelated decrypt.
1239
 *
1240
 * The same failed decrypt would otherwise leave m_passwordCopyPending stuck
1241
 * (its only other reset is passwordFromFileToClipboard, which never runs when
1242
 * finishedShow is not emitted), permanently blocking Ctrl+C, so clear it here
1243
 * too. Unlike otpFromFileToClipboard, that slot has no pending-flag guard, so
1244
 * its single-shot connection must be torn down here as well — otherwise a
1245
 * connection left armed by the failed decrypt would claim the next unrelated
1246
 * finishedShow and copy the wrong entry to the clipboard. disconnectSingleShot
1247
 * is a no-op on Qt 6 (the never-fired connection persists until it emits), so
1248
 * disconnect unconditionally.
1249
 */
1250
void MainWindow::cancelOtpRequest() {
1 ✔
1251
  m_otpRequestPending = false;
1 ✔
1252
  m_otpRequestFile.clear();
1 ✔
1253
  disconnect(QtPassSettings::getPass(), &Pass::finishedShow, this,
1 ✔
1254
             &MainWindow::passwordFromFileToClipboard);
1255
  m_passwordCopyPending = false;
1 ✔
1256
}
1 ✔
1257

1258
/**
1259
 * @brief MainWindow::onOtp generate the selected entry's OTP code and copy it.
1260
 *
1261
 * Decrypts the entry once and derives the code in-process, so this works with
1262
 * either backend and on every platform. The code itself is produced by
1263
 * otpFromFileToClipboard once Pass::finishedShow arrives.
1264
 */
1265
void MainWindow::onOtp() {
×
1266
  QString file = getFile(ui->treeView->currentIndex(), true);
×
1267
  if (file.isEmpty()) {
×
1268
    flashText(tr("No password selected for OTP generation"), true);
×
1269
    return;
×
1270
  }
1271
  if (!QtPassSettings::isUseOtp()) {
×
1272
    // Normally unreachable (the action is hidden), but never fail silently.
1273
    flashText(tr("No OTP code found in this password entry"), true);
×
1274
    return;
×
1275
  }
1276

1277
  // Fast path: the panel already shows a live code for the selected entry, so
1278
  // there is nothing to decrypt. This also keeps the clipboard write single —
1279
  // going through Show() would let passShowHandler copy the password first, and
1280
  // two QClipboard::setMimeData calls in one event-loop turn can leave the
1281
  // Windows clipboard empty.
1282
  //
1283
  // Only when the panel is showing *this* entry: arrow-key navigation and
1284
  // right-clicking move the tree's currentIndex without emitting
1285
  // QTreeView::clicked, so the visible code can belong to a different account.
1286
  const QString shown =
1287
      (m_shownFile == file) ? m_displayPanel->currentOtpCode() : QString();
×
1288
  if (!shown.isEmpty()) {
×
1289
    m_qtPass->copyTextToClipboard(shown);
×
1290
    showStatusMessage(tr("OTP code copied to clipboard"));
×
1291
    return;
1292
  }
1293

1294
  // Fallback: no OTP row on screen, so decrypt once. The flag stops
1295
  // passShowHandler putting the password on the clipboard for a request that
1296
  // only asked for a code.
1297
  m_otpRequestPending = true;
×
1298
  m_otpRequestFile = file;
×
1299
  setUiElementsEnabled(false);
×
1300
  connectSingleShot(QtPassSettings::getPass(), &Pass::finishedShow, this,
×
1301
                    &MainWindow::otpFromFileToClipboard);
1302
  // passShowHandler repaints the panel for this Show too, so keep the marker in
1303
  // step or a second request would decrypt again instead of taking the fast
1304
  // path. Safe to set now: executeWrapperStarted() clears the panel on every
1305
  // command, so a failed decrypt leaves currentOtpCode() empty.
1306
  m_shownFile = file;
×
1307
  QtPassSettings::getPass()->Show(file);
×
1308
}
1309

1310
/**
1311
 * @brief MainWindow::onEdit try and edit (selected) password.
1312
 */
1313
void MainWindow::onEdit() {
×
1314
  QString file = getFile(ui->treeView->currentIndex(), true);
×
1315
  editPassword(file);
×
1316
}
×
1317

1318
/**
1319
 * @brief MainWindow::userDialog see MainWindow::onUsers()
1320
 * @param dir folder to edit users for.
1321
 */
1322
void MainWindow::userDialog(const QString &dir) {
×
1323
  if (!dir.isEmpty()) {
×
1324
    m_currentDir = dir;
×
1325
  }
1326
  onUsers();
×
1327
}
×
1328

1329
/**
1330
 * @brief MainWindow::onUsers edit users for the current
1331
 * folder,
1332
 * gets lists and opens UserDialog.
1333
 */
1334
void MainWindow::onUsers() {
×
1335
  QString dir = m_currentDir.isEmpty()
1336
                    ? Util::getDir(ui->treeView->currentIndex(), false, model,
×
1337
                                   proxyModel, QtPassSettings::getPassStore())
×
1338
                    : m_currentDir;
×
1339

1340
  UsersDialog d(QtPassSettings::getPass(), QtPassSettings::load(), dir, this);
×
1341
  if (!d.exec()) {
×
1342
    ui->treeView->setFocus();
×
1343
  }
1344
}
×
1345

1346
/**
1347
 * @brief MainWindow::messageAvailable we have some text/message/search to do.
1348
 * @param message
1349
 */
1350
void MainWindow::messageAvailable(const QString &message) {
×
1351
  show();
×
1352
  raise();
×
1353
  if (message.isEmpty()) {
×
1354
    focusInput();
×
1355
  } else {
1356
    ui->treeView->expandAll();
×
1357
    ui->lineEdit->setText(message);
×
1358
    on_lineEdit_returnPressed();
×
1359
  }
1360
}
×
1361

1362
/**
1363
 * @brief MainWindow::generateKeyPair internal gpg keypair generator . .
1364
 * @param batch
1365
 * @param keygenWindow
1366
 */
1367
void MainWindow::generateKeyPair(const QString &batch, QDialog *keygenWindow) {
×
1368
  m_keyGenDialog = keygenWindow;
1369
  emit generateGPGKeyPair(batch);
×
1370
}
×
1371

1372
/**
1373
 * @brief MainWindow::updateProfileBox update the list of profiles, optionally
1374
 * select a more appropriate one to view too
1375
 */
1376
void MainWindow::updateProfileBox() {
21 ✔
1377
  QHash<QString, QHash<QString, QString>> profiles =
1378
      QtPassSettings::getProfiles();
21 ✔
1379

1380
  if (profiles.isEmpty()) {
1381
    ui->profileWidget->hide();
21 ✔
1382
  } else {
1383
    ui->profileWidget->show();
×
1384
    ui->profileBox->setEnabled(profiles.size() > 1);
×
1385
    ui->profileBox->clear();
×
1386
    QHashIterator<QString, QHash<QString, QString>> i(profiles);
×
1387
    while (i.hasNext()) {
×
1388
      i.next();
1389
      if (!i.key().isEmpty()) {
×
1390
        ui->profileBox->addItem(i.key());
×
1391
      }
1392
    }
1393
    ui->profileBox->model()->sort(0);
×
1394
  }
1395
  int index = ui->profileBox->findText(QtPassSettings::getProfile());
42 ✔
1396
  if (index != -1) { //  -1 for not found
21 ✔
1397
    ui->profileBox->setCurrentIndex(index);
×
1398
  }
1399
}
21 ✔
1400

1401
/**
1402
 * @brief MainWindow::on_profileBox_currentIndexChanged make sure we show the
1403
 * correct "profile"
1404
 * @param name
1405
 */
1406
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
1407
void MainWindow::on_profileBox_currentIndexChanged(const QString &name) {
1408
#else
1409
/**
1410
 * @brief Handles changes to the selected profile in the profile combo box.
1411
 * @details Ignores the event during a fresh start or when the selected profile
1412
 * matches the current profile. Otherwise, it clears the password field, updates
1413
 * the active profile and related settings, refreshes the environment, and
1414
 * resets the tree view and action states to reflect the newly selected profile.
1415
 *
1416
 * @param name - The newly selected profile name.
1417
 * @return void - This function does not return a value.
1418
 *
1419
 */
1420
void MainWindow::on_profileBox_currentTextChanged(const QString &name) {
×
1421
#endif
1422
  if (m_qtPass->isFreshStart() || name == QtPassSettings::getProfile()) {
×
1423
    return;
×
1424
  }
1425

1426
  ui->lineEdit->clear();
×
1427

1428
  const QHash<QString, QString> prof =
1429
      QtPassSettings::getProfiles().value(name);
×
1430
  AppSettings s = QtPassSettings::load();
×
1431
  s.activeProfile = name;
×
1432
  s.passStore = prof.value("path");
×
1433
  s.passSigningKey = prof.value("signingKey");
×
1434
  QtPassSettings::save(s);
×
1435
  ui->statusBar->showMessage(tr("Profile changed to %1").arg(name), 2000);
×
1436

1437
  QtPassSettings::getPass()->updateEnv();
×
1438

1439
  const QString passStore = QtPassSettings::getPassStore();
×
1440
  proxyModel.setStore(passStore);
×
1441
  ui->treeView->setRootIndex(proxyModel.rootIndexFor(passStore));
×
1442
  deselect();
×
1443
  ui->treeView->setCurrentIndex(QModelIndex());
×
1444
}
×
1445

1446
/**
1447
 * @brief MainWindow::initTrayIcon show a nice tray icon on systems that
1448
 * support
1449
 * it
1450
 */
1451
void MainWindow::initTrayIcon() {
×
1452
  m_tray = new TrayIcon(this);
×
1453
  if (!m_tray->getIsAllocated()) {
×
1454
    destroyTrayIcon();
×
1455
  }
1456
}
×
1457

1458
/**
1459
 * @brief MainWindow::destroyTrayIcon remove that pesky tray icon
1460
 */
1461
void MainWindow::destroyTrayIcon() {
×
1462
  delete m_tray;
×
1463
  m_tray = nullptr;
×
1464
}
×
1465

1466
/**
1467
 * @brief MainWindow::closeEvent hide or quit
1468
 * @param event
1469
 */
1470
void MainWindow::closeEvent(QCloseEvent *event) {
×
1471
  if (QtPassSettings::isHideOnClose()) {
×
1472
    this->hide();
×
1473
    event->ignore();
1474
  } else {
1475
    m_qtPass->clearClipboard();
×
1476

1477
    QtPassSettings::setGeometry(saveGeometry());
×
1478
    QtPassSettings::setSavestate(saveState());
×
1479
    QtPassSettings::setMaximized(isMaximized());
×
1480
    if (!isMaximized()) {
×
1481
      QtPassSettings::setPos(pos());
×
1482
      QtPassSettings::setSize(size());
×
1483
    }
1484
    event->accept();
1485
    // A visible QSystemTrayIcon keeps the application alive after the last
1486
    // window closes, so quitOnLastWindowClosed never fires and the window
1487
    // merely vanishes into the tray. Quit explicitly so closing the window
1488
    // actually exits when "hide on close" is disabled.
1489
    QApplication::quit();
×
1490
  }
1491
}
×
1492

1493
/**
1494
 * @brief MainWindow::eventFilter filter out some events and focus the
1495
 * treeview
1496
 * @param obj
1497
 * @param event
1498
 * @return
1499
 */
1500
auto MainWindow::eventFilter(QObject *obj, QEvent *event) -> bool {
694 ✔
1501
  if (obj == ui->toolBar && event->type() == QEvent::PaletteChange) {
694 ✔
1502
    // KDE's Breeze style gives top toolbars a "header" palette of its own.
1503
    // After a runtime light/dark switch it recomputes that palette from a
1504
    // cached kdeglobals and stamps the previous theme's colours back on, so
1505
    // the toolbar stays dark on a light window (or vice versa) with matching
1506
    // invisible icons. A legitimate header tint is close to the window
1507
    // colour; a stale theme is not. When the two disagree, drop the imposed
1508
    // palette so the toolbar follows the application palette again. Deferred
1509
    // so we never re-enter the style while it is still applying its palette.
1510
    QTimer::singleShot(0, this, &MainWindow::dropStaleToolBarPalette);
6 ✔
1511
  }
1512
  if (obj == ui->lineEdit && event->type() == QEvent::KeyPress) {
694 ✔
1513
    auto *key = dynamic_cast<QKeyEvent *>(event);
×
1514
    if (key != nullptr && key->key() == Qt::Key_Down) {
×
1515
      ui->treeView->setFocus();
×
1516
    }
1517
  }
1518
  return QObject::eventFilter(obj, event);
694 ✔
1519
}
1520

1521
/**
1522
 * @brief Reset the toolbar palette when a style left it in the wrong theme.
1523
 *
1524
 * Compares the lightness of the toolbar's Window colour with the application
1525
 * palette; a difference above kStaleToolBarLightness means the toolbar shows
1526
 * the other theme. See the PaletteChange branch in eventFilter().
1527
 */
1528
void MainWindow::dropStaleToolBarPalette() {
3 ✔
1529
  if (!ui->toolBar->testAttribute(Qt::WA_SetPalette)) {
3 ✔
1530
    return;
1531
  }
1532
  constexpr int kStaleToolBarLightness = 64;
1533
  const int barLightness =
1534
      ui->toolBar->palette().color(QPalette::Window).lightness();
4 ✔
1535
  const int appLightness =
1536
      QApplication::palette().color(QPalette::Window).lightness();
4 ✔
1537
  if (qAbs(barLightness - appLightness) > kStaleToolBarLightness) {
2 ✔
1538
    ui->toolBar->setPalette(QPalette());
1 ✔
1539
    // The style also paints the tools-area background on the main window
1540
    // from the same stale palette, and the toolbar is transparent by default;
1541
    // paint our own background so the reset is actually visible.
1542
    ui->toolBar->setAutoFillBackground(true);
1 ✔
1543
  }
1544
}
1545

1546
/**
1547
 * @brief MainWindow::keyPressEvent did anyone press return, enter or escape?
1548
 * @param event
1549
 */
1550
void MainWindow::keyPressEvent(QKeyEvent *event) {
×
1551
  switch (event->key()) {
×
1552
  case Qt::Key_Delete:
×
1553
    onDelete();
×
1554
    break;
×
1555
  case Qt::Key_Return:
×
1556
  case Qt::Key_Enter:
1557
    if (proxyModel.rowCount() > 0) {
×
1558
      on_treeView_clicked(ui->treeView->currentIndex());
×
1559
    }
1560
    break;
1561
  case Qt::Key_Escape:
×
1562
    ui->lineEdit->clear();
×
1563
    break;
×
1564
  default:
1565
    break;
1566
  }
1567
}
×
1568

1569
/**
1570
 * @brief MainWindow::showContextMenu show us the (file or folder) context
1571
 * menu
1572
 * @param pos
1573
 */
1574
void MainWindow::showContextMenu(const QPoint &pos) {
×
1575
  const AppSettings s = QtPassSettings::load();
×
1576
  QModelIndex index = ui->treeView->indexAt(pos);
×
1577
  bool selected = true;
1578
  if (!index.isValid()) {
1579
    ui->treeView->clearSelection();
×
1580
    ui->actionDelete->setEnabled(false);
×
1581
    ui->actionEdit->setEnabled(false);
×
1582
    m_currentDir = "";
×
1583
    selected = false;
1584
  }
1585

1586
  ui->treeView->setCurrentIndex(index);
×
1587

1588
  QPoint globalPos = ui->treeView->viewport()->mapToGlobal(pos);
×
1589

1590
  QFileInfo fileOrFolder =
1591
      model.fileInfo(proxyModel.mapToSource(ui->treeView->currentIndex()));
×
1592

1593
  QMenu contextMenu;
×
1594
  if (!selected || fileOrFolder.isDir()) {
×
1595
    QAction *openFolder =
1596
        contextMenu.addAction(tr("Open folder with file manager"));
×
1597
    QAction *addFolder = contextMenu.addAction(tr("Add folder"));
×
1598
    QAction *addPassword = contextMenu.addAction(tr("Add password"));
×
1599
    QAction *users = contextMenu.addAction(tr("Users"));
×
1600
    connect(openFolder, &QAction::triggered, this, &MainWindow::openFolder);
×
1601
    connect(addFolder, &QAction::triggered, this, &MainWindow::addFolder);
×
1602
    connect(addPassword, &QAction::triggered, this, &MainWindow::addPassword);
×
1603
    connect(users, &QAction::triggered, this, &MainWindow::onUsers);
×
1604
  } else if (fileOrFolder.isFile()) {
×
1605
    QAction *edit = contextMenu.addAction(tr("Edit"));
×
1606
    connect(edit, &QAction::triggered, this, &MainWindow::onEdit);
×
1607
  }
1608
  if (selected) {
×
1609
    contextMenu.addSeparator();
×
1610
    if (fileOrFolder.isDir()) {
×
1611
      QAction *renameFolder = contextMenu.addAction(tr("Rename folder"));
×
1612
      connect(renameFolder, &QAction::triggered, this,
×
1613
              &MainWindow::renameFolder);
×
1614
    } else if (fileOrFolder.isFile()) {
×
1615
      QAction *renamePassword = contextMenu.addAction(tr("Rename password"));
×
1616
      connect(renamePassword, &QAction::triggered, this,
×
1617
              &MainWindow::renamePassword);
×
1618
    }
1619
    QAction *deleteItem = contextMenu.addAction(tr("Delete"));
×
1620
    connect(deleteItem, &QAction::triggered, this, &MainWindow::onDelete);
×
1621
    if (fileOrFolder.isDir()) {
×
1622
      QString dirPath = QDir::cleanPath(Util::getDir(
×
1623
          ui->treeView->currentIndex(), false, model, proxyModel, s.passStore));
×
1624

1625
      auto *shareMenu = new QMenu(tr("Share"), &contextMenu);
×
1626
      contextMenu.addMenu(shareMenu);
×
1627

1628
      QString gpgIdPath = Pass::getGpgIdPath(dirPath, s.passStore);
×
1629
      bool gpgIdExists = !gpgIdPath.isEmpty() && QFile(gpgIdPath).exists();
×
1630

1631
      const QString exePath = s.usePass ? s.passExecutable : s.gpgExecutable;
×
1632
      bool gpgAvailable = !exePath.isEmpty() && (exePath.startsWith("wsl ") ||
×
1633
                                                 QFile(exePath).exists());
×
1634

1635
      QAction *reencrypt = shareMenu->addAction(tr("Re-encrypt all passwords"));
×
1636
      reencrypt->setEnabled(gpgIdExists && gpgAvailable);
×
1637
      connect(reencrypt, &QAction::triggered, this,
×
1638
              [this, dirPath]() { reencryptPath(dirPath); });
×
1639

1640
      QAction *exportKey = shareMenu->addAction(tr("Export my public key..."));
×
1641
      exportKey->setEnabled(gpgAvailable);
×
1642
      connect(exportKey, &QAction::triggered, this,
×
1643
              &MainWindow::exportPublicKey);
×
1644

1645
      QAction *addRecipientAction =
1646
          shareMenu->addAction(tr("Add recipient..."));
×
1647
      addRecipientAction->setEnabled(gpgIdExists && gpgAvailable);
×
1648
      connect(addRecipientAction, &QAction::triggered, this,
×
1649
              [this, dirPath]() { addRecipient(dirPath); });
×
1650

1651
      QAction *shareHelp = shareMenu->addAction(tr("What is this?"));
×
1652
      connect(shareHelp, &QAction::triggered, this, &MainWindow::showShareHelp);
×
1653
    }
1654
  }
1655
  contextMenu.exec(globalPos);
×
1656
}
×
1657

1658
/**
1659
 * @brief MainWindow::showBrowserContextMenu show us the context menu in
1660
 * password window
1661
 * @param pos
1662
 */
1663
void MainWindow::showBrowserContextMenu(const QPoint &pos) {
×
1664
  QMenu *contextMenu = ui->textBrowser->createStandardContextMenu(pos);
×
1665
  // createStandardContextMenu() parents the menu to textBrowser. A stylesheet
1666
  // on the browser (it used to carry "background: palette(base)") cascades to
1667
  // the child QMenu and breaks its opaque native background. Reparent to the
1668
  // main window so the menu paints solid regardless of browser styling.
1669
  contextMenu->setParent(this, contextMenu->windowFlags());
×
1670
  QPoint globalPos = ui->textBrowser->viewport()->mapToGlobal(pos);
×
1671

1672
  contextMenu->exec(globalPos);
×
1673
  delete contextMenu;
×
1674
}
×
1675

1676
/**
1677
 * @brief MainWindow::openFolder open the folder in the default file manager
1678
 */
1679
void MainWindow::openFolder() {
×
1680
  QString dir = Util::getDir(ui->treeView->currentIndex(), false, model,
×
1681
                             proxyModel, QtPassSettings::getPassStore());
×
1682

1683
  QString path = QDir::toNativeSeparators(dir);
×
1684
  QDesktopServices::openUrl(QUrl::fromLocalFile(path));
×
1685
}
×
1686

1687
/**
1688
 * @brief MainWindow::addFolder add a new folder to store passwords in
1689
 */
1690
void MainWindow::addFolder() {
×
1691
  const AppSettings s = QtPassSettings::load();
×
1692
  bool ok;
1693
  QString dir = Util::getDir(ui->treeView->currentIndex(), false, model,
×
1694
                             proxyModel, s.passStore);
×
1695
  QString newdir = QInputDialog::getText(
1696
      this, tr("New file"),
×
1697
      tr("New Folder: \n(Will be placed in %1 )")
×
1698
          .arg(s.passStore + Util::getDir(ui->treeView->currentIndex(), true,
×
1699
                                          model, proxyModel, s.passStore)),
1700
      QLineEdit::Normal, "", &ok);
×
1701
  if (!ok || newdir.isEmpty()) {
×
1702
    return;
1703
  }
1704
  newdir.prepend(dir);
1705
  if (!confirmPathInStore(newdir)) {
×
1706
    return;
1707
  }
1708
  if (!QDir().mkdir(newdir)) {
×
1709
    QMessageBox::warning(this, tr("Error"),
×
1710
                         tr("Failed to create folder: %1").arg(newdir));
×
1711
    return;
×
1712
  }
1713
  // A .gpg-id only counts when it is signed once a signing key is configured:
1714
  // ImitatePass::verifyGpgIdFile and pass (PASSWORD_STORE_SIGNING_KEY) both
1715
  // refuse to encrypt into a folder whose .gpg-id has no matching
1716
  // .gpg-id.sig. Signing here would need the signing secret key and a
1717
  // passphrase prompt just to create a folder, so in that case leave the
1718
  // folder without its own .gpg-id: it inherits the parent's signed list,
1719
  // which encrypts to the same recipients. UsersDialog (Init) is the place to
1720
  // give it a distinct, signed list.
NEW
1721
  const bool signingConfigured = !s.passSigningKey.trimmed().isEmpty();
×
NEW
1722
  if (s.addGPGId && !signingConfigured) {
×
1723
    // Seed the new folder's .gpg-id from the recipients that are currently
1724
    // in effect for its parent. The previous implementation walked
1725
    // listKeys("", true) and wrote every key whose `enabled` flag was set,
1726
    // but that flag is only toggled inside UsersDialog, so the loop wrote
1727
    // nothing and left a zero-byte .gpg-id that shadowed the parent's
1728
    // recipients (see #1682).
NEW
1729
    if (!Pass::seedGpgIdFile(newdir, s.passStore)) {
×
1730
      QMessageBox::warning(
×
1731
          this, tr("Error"),
×
1732
          tr("Failed to create .gpg-id file in: %1").arg(newdir));
×
UNCOV
1733
      return;
×
1734
    }
1735
  }
1736
}
×
1737

1738
/**
1739
 * @brief MainWindow::renameFolder rename an existing folder
1740
 */
1741
void MainWindow::renameFolder() {
×
1742
  bool ok;
1743
  QString srcDir =
1744
      QDir::cleanPath(Util::getDir(ui->treeView->currentIndex(), false, model,
×
1745
                                   proxyModel, QtPassSettings::getPassStore()));
×
1746
  QString srcDirName = QDir(srcDir).dirName();
×
1747
  QString newName =
1748
      QInputDialog::getText(this, tr("Rename file"), tr("Rename Folder To: "),
×
1749
                            QLineEdit::Normal, srcDirName, &ok);
×
1750
  if (!ok || newName.isEmpty()) {
×
1751
    return;
1752
  }
1753
  QString destDir = srcDir;
1754
  destDir.replace(srcDir.lastIndexOf(srcDirName), srcDirName.length(), newName);
×
1755
  if (!confirmPathInStore(destDir)) {
×
1756
    return;
1757
  }
1758
  QtPassSettings::getPass()->Move(srcDir, destDir, false);
×
1759
}
1760

1761
/**
1762
 * @brief MainWindow::editPassword read password and open edit window via
1763
 * MainWindow::onEdit()
1764
 */
1765
void MainWindow::editPassword(const QString &file) {
1 ✔
1766
  if (!file.isEmpty()) {
1 ✔
1767
    const AppSettings s = QtPassSettings::load();
1 ✔
1768
    if (s.useGit && s.autoPull) {
1 ✔
1769
      onUpdate(true);
×
1770
    }
1771
    setPassword(file, false);
1 ✔
1772
  }
1 ✔
1773
}
1 ✔
1774

1775
/**
1776
 * @brief MainWindow::renamePassword rename an existing password
1777
 */
1778
void MainWindow::renamePassword() {
×
1779
  bool ok;
1780
  QString file = getFile(ui->treeView->currentIndex(), false);
×
1781
  QString filePath = QFileInfo(file).path();
×
1782
  QString fileName = QFileInfo(file).fileName();
×
1783
  if (fileName.endsWith(".gpg", Qt::CaseInsensitive)) {
×
1784
    fileName.chop(4);
×
1785
  }
1786

1787
  QString newName =
1788
      QInputDialog::getText(this, tr("Rename file"), tr("Rename File To: "),
×
1789
                            QLineEdit::Normal, fileName, &ok);
×
1790
  if (!ok || newName.isEmpty()) {
×
1791
    return;
1792
  }
1793
  QString newFile = QDir(filePath).filePath(newName);
×
1794
  if (!confirmPathInStore(newFile)) {
×
1795
    return;
1796
  }
1797
  QtPassSettings::getPass()->Move(file, newFile, false);
×
1798
}
1799

1800
/**
1801
 * @brief Copies the password of the selected file from the tree view to the
1802
 * clipboard.
1803
 * @example
1804
 * MainWindow::copyPasswordFromTreeview();
1805
 *
1806
 * @return void - This function does not return a value.
1807
 */
1808
void MainWindow::copyPasswordFromTreeview() {
×
1809
  QFileInfo fileOrFolder =
1810
      model.fileInfo(proxyModel.mapToSource(ui->treeView->currentIndex()));
×
1811

1812
  if (fileOrFolder.isFile()) {
×
1813
    // finishedShow carries no request identity, so allow only one copy request
1814
    // in flight: otherwise a second Ctrl+C on a different entry would re-arm
1815
    // the single-shot slot and the first decrypt to finish would copy the wrong
1816
    // entry while the later request is silently dropped. See
1817
    // m_passwordCopyPending.
1818
    if (m_passwordCopyPending) {
×
1819
      return;
×
1820
    }
1821
    m_passwordCopyPending = true;
×
1822
    QString file = getFile(ui->treeView->currentIndex(), true);
×
1823
    connectSingleShot(QtPassSettings::getPass(), &Pass::finishedShow, this,
×
1824
                      &MainWindow::passwordFromFileToClipboard);
1825
    // This Show repaints the panel as well; see onOtp().
1826
    m_shownFile = file;
×
1827
    QtPassSettings::getPass()->Show(file);
×
1828
  }
1829
}
×
1830

1831
void MainWindow::passwordFromFileToClipboard(const QString &text) {
2 ✔
1832
  disconnectSingleShot(QtPassSettings::getPass(), &Pass::finishedShow, this,
2 ✔
1833
                       &MainWindow::passwordFromFileToClipboard);
1834
  m_passwordCopyPending = false;
2 ✔
1835
  const QStringList tokens = text.split('\n');
2 ✔
1836
  if (tokens.isEmpty()) {
2 ✔
1837
    return;
1838
  }
1839
  // An entry created by `pass otp insert` has the otpauth:// URI as its first
1840
  // line. That URI carries the shared TOTP secret (the seed, not a code), so
1841
  // copying it to the clipboard would leak 2FA material. Skip it, matching the
1842
  // display path (FileContent::getPasswordForDisplay / PasswordDisplayPanel).
1843
  if (FileContent::isOtpUriValue(tokens[0])) {
2 ✔
1844
    flashText(tr("This entry holds an OTP secret, not a password"), true);
1 ✔
1845
    return;
1 ✔
1846
  }
1847
  m_qtPass->copyTextToClipboard(tokens[0]);
1 ✔
1848
}
1849

1850
/**
1851
 * @brief Displays message in status bar
1852
 *
1853
 * @param msg     text to be displayed
1854
 * @param timeout time for which msg shall be visible
1855
 */
1856
void MainWindow::showStatusMessage(const QString &msg, int timeout) {
4 ✔
1857
  ui->statusBar->showMessage(msg, timeout);
4 ✔
1858
}
4 ✔
1859

1860
/**
1861
 * @brief MainWindow::reencryptPath re-encrypt all passwords in a directory
1862
 * @param dir Directory path to re-encrypt
1863
 */
1864
void MainWindow::reencryptPath(const QString &dir) {
×
1865
  QDir checkDir(dir);
×
1866
  if (!checkDir.exists()) {
×
1867
    QMessageBox::critical(this, tr("Error"),
×
1868
                          tr("Directory does not exist: %1").arg(dir));
×
1869
    return;
×
1870
  }
1871

1872
  int ret = QMessageBox::question(
×
1873
      this, tr("Re-encrypt passwords"),
×
1874
      tr("Re-encrypt all passwords in %1?\n\n"
×
1875
         "This will re-encrypt ALL password files in this folder "
1876
         "using the current recipients defined in .gpg-id.\n\n"
1877
         "This may rewrite many files and cannot be undone easily.\n\n"
1878
         "Continue?")
1879
          .arg(QDir(dir).dirName()),
×
1880
      QMessageBox::Yes | QMessageBox::No);
1881

1882
  if (ret != QMessageBox::Yes)
×
1883
    return;
1884

1885
  // Disable preemptively. ImitatePass::reencryptPath emits
1886
  // startReencryptPath asynchronously and the slot would re-run this,
1887
  // but setEnabled(false) is idempotent so the duplicate is harmless.
1888
  startReencryptPath();
×
1889

1890
  QtPassSettings::getImitatePass()->reencryptPath(
×
1891
      QDir::cleanPath(QDir(dir).absolutePath()));
×
1892
}
×
1893

1894
/**
1895
 * @brief MainWindow::startReencryptPath disable ui elements and treeview
1896
 */
1897
void MainWindow::startReencryptPath() {
×
1898
  setUiElementsEnabled(false);
×
1899
  ui->treeView->setDisabled(true);
×
1900
}
×
1901

1902
/**
1903
 * @brief MainWindow::endReencryptPath re-enable ui elements
1904
 */
1905
void MainWindow::endReencryptPath() { setUiElementsEnabled(true); }
×
1906

1907
/**
1908
 * @brief MainWindow::exportPublicKey export the configured signing key in
1909
 *        ASCII-armored form via gpg and show it in ExportPublicKeyDialog.
1910
 *
1911
 * Falls back to a help dialog when no signing key is configured or gpg is
1912
 * unavailable, so the user still gets actionable guidance.
1913
 */
1914
void MainWindow::exportPublicKey() {
×
1915
  const AppSettings s = QtPassSettings::load();
×
1916
  const QString identity = s.passSigningKey;
1917
  if (identity.isEmpty()) {
×
1918
    QMessageBox::information(
×
1919
        this, tr("Export Public Key"),
×
1920
        tr("<h3>Export Your Public Key</h3>"
×
1921
           "<p>No signing key is configured. Set one in QtPass Settings "
1922
           "&gt; GPG keys, or run this in a terminal:</p>"
1923
           "<pre>gpg --armor --export --output my_key.asc &lt;your-key-id"
1924
           "&gt;</pre>"
1925
           "<p>Then send the file to your teammates.</p>"));
1926
    return;
×
1927
  }
1928
  QString gpgExe = s.gpgExecutable;
1929
  if (gpgExe.isEmpty()) {
×
1930
    gpgExe = QStringLiteral("gpg");
×
1931
  }
1932
  QStringList args = {"--armor", "--export"};
×
1933
  args.append(identity.split(' ', Qt::SkipEmptyParts));
×
1934
  QString stdOut;
×
1935
  QString stdErr;
×
1936
  int exitCode = Executor::executeBlocking(gpgExe, args, &stdOut, &stdErr);
×
1937
  if (exitCode != 0 || stdOut.isEmpty()) {
×
1938
    QMessageBox::warning(this, tr("Export Public Key"),
×
1939
                         tr("Could not export public key for %1.\n\n%2")
×
1940
                             .arg(identity, stdErr.isEmpty()
×
1941
                                                ? tr("No output from gpg.")
×
1942
                                                : stdErr));
1943
    return;
1944
  }
1945
  ExportPublicKeyDialog dialog(identity, stdOut, this);
×
1946
  dialog.exec();
×
1947
}
×
1948

1949
/**
1950
 * @brief MainWindow::addRecipient open the recipient management dialog for
1951
 *        the supplied directory.
1952
 * @param dir Folder whose .gpg-id should be edited.
1953
 *
1954
 * Delegates to UsersDialog so users can tick/untick keys from their
1955
 * keyring as recipients of the folder; importing a foreign key into the
1956
 * keyring still has to happen via gpg (or QtPass settings) first.
1957
 */
1958
void MainWindow::addRecipient(const QString &dir) {
×
1959
  UsersDialog d(QtPassSettings::getPass(), QtPassSettings::load(), dir, this);
×
1960
  d.exec();
×
1961
}
×
1962

1963
/**
1964
 * @brief MainWindow::showShareHelp show help about GPG sharing
1965
 */
1966
void MainWindow::showShareHelp() {
×
1967
  QMessageBox::information(
×
1968
      this, tr("Sharing Passwords with GPG"),
×
1969
      tr("<h3>Sharing Passwords with GPG</h3>"
×
1970
         "<p>To share passwords with other users:</p>"
1971
         "<ol>"
1972
         "<li><b>Export your public key</b> and send it to teammates</li>"
1973
         "<li><b>Import teammates' public keys</b> into your GPG keyring</li>"
1974
         "<li><b>Re-encrypt passwords</b> so all recipients can decrypt "
1975
         "them</li>"
1976
         "</ol>"
1977
         "<p>Only people who have a matching secret key can decrypt the "
1978
         "passwords.</p>"
1979
         "<p><b>Tip:</b> Use the same GPG key for all shared folders.</p>"
1980
         "<p>See the FAQ for more details.</p>"));
1981
}
×
1982

1983
void MainWindow::updateGitButtonVisibility() {
34 ✔
1984
  const AppSettings s = QtPassSettings::load();
34 ✔
1985
  if (!s.useGit || (s.gitExecutable.isEmpty() && s.passExecutable.isEmpty())) {
34 ✔
1986
    enableGitButtons(false);
34 ✔
1987
  } else {
1988
    enableGitButtons(true);
×
1989
  }
1990
}
34 ✔
1991

1992
void MainWindow::updateOtpButtonVisibility(bool uiEnabled) {
34 ✔
1993
  // No platform gating any more: codes are generated in-process, so this works
1994
  // on Windows and macOS and with either backend. It used to be hidden there
1995
  // because the pass-otp extension is Unix-only.
1996
  //
1997
  // Kept visible but disabled when OTP is off, as it was before: hiding it
1998
  // removed the only discoverable trace of the feature, and since an OTP field
1999
  // is suppressed from the panel unconditionally (its value is a secret), a
2000
  // user with the setting off would see nothing at all where their OTP data
2001
  // used to be.
2002
  //
2003
  // Enablement must honour uiEnabled: this is called from
2004
  // setUiElementsEnabled(), and ignoring the argument re-enabled the action
2005
  // while a decrypt was in flight, letting the user stack Show calls.
2006
  ui->actionOtp->setVisible(true);
34 ✔
2007
  ui->actionOtp->setEnabled(QtPassSettings::isUseOtp() && uiEnabled);
34 ✔
2008
}
34 ✔
2009

2010
void MainWindow::updateGrepButtonVisibility() {
21 ✔
2011
  const bool enabled = QtPassSettings::isUseGrepSearch();
21 ✔
2012
  ui->grepButton->setVisible(enabled);
21 ✔
2013
  ui->grepCaseButton->setVisible(enabled);
21 ✔
2014
  if (!enabled && m_grep.inGrepMode()) {
21 ✔
2015
    ui->grepButton->setChecked(false);
×
2016
  }
2017
}
21 ✔
2018

2019
void MainWindow::enableGitButtons(const bool &state) {
34 ✔
2020
  // Following GNOME guidelines is preferable disable buttons instead of hide
2021
  ui->actionPush->setEnabled(state);
34 ✔
2022
  ui->actionUpdate->setEnabled(state);
34 ✔
2023
}
34 ✔
2024

2025
/**
2026
 * @brief MainWindow::critical critical message popup wrapper.
2027
 * @param title
2028
 * @param msg
2029
 */
2030
void MainWindow::critical(const QString &title, const QString &msg) {
×
2031
  QMessageBox::critical(this, title, msg);
×
2032
}
×
2033

2034
/**
2035
 * @brief Appends processed command output to the output panel.
2036
 *
2037
 * Appends text to the process output text edit, with per-line numbering,
2038
 * optional command prefix, and color coding for errors vs. success.
2039
 * Handles auto-scrolling and line limits.
2040
 *
2041
 * @param output The raw output text from the command.
2042
 * @param isError true if this is error output (stderr).
2043
 * @param linePrefix Optional command name to prefix each line with.
2044
 */
2045
void MainWindow::appendProcessOutput(const QString &output, bool isError,
2 ✔
2046
                                     const QString &linePrefix) {
2047
  if (!QtPassSettings::isShowProcessOutput()) {
2 ✔
2048
    return;
1 ✔
2049
  }
2050

2051
  QStringList lines = output.split('\n', Qt::SkipEmptyParts);
1 ✔
2052
  for (QString &line : lines) {
2 ✔
2053
    // Right-trim only: remove trailing CR and whitespace, preserve leading
2054
    // indentation
2055
    line.remove('\r');
1 ✔
2056
    while (!line.isEmpty() && line.back().isSpace()) {
2 ✔
2057
      line.chop(1);
×
2058
    }
2059
    if (line.isEmpty()) {
1 ✔
2060
      continue;
×
2061
    }
2062

2063
    m_outputCounter++;
1 ✔
2064
    QString lineNumber = QString::number(m_outputCounter);
1 ✔
2065

2066
    QColor textColor =
2067
        isError ? QColor(Qt::red)
1 ✔
2068
                : m_processOutputEdit->palette().color(QPalette::Text);
2 ✔
2069
    QString colorHex = textColor.name();
1 ✔
2070
    // Apply the optional prefix per line so multi-line output stays
2071
    // attributed to its command (e.g. all 3 lines of a `git push` show
2072
    // "git push: ..." rather than only the first).
2073
    QString prefixed =
2074
        linePrefix.isEmpty() ? line : linePrefix + QStringLiteral(": ") + line;
2 ✔
2075
    QString coloredOutput =
2076
        QString("<span style=\"color: %1;\">%2: %3</span>")
1 ✔
2077
            .arg(colorHex, lineNumber, prefixed.toHtmlEscaped());
2 ✔
2078

2079
    m_processOutputEdit->append(coloredOutput);
1 ✔
2080
  }
2081

2082
  limitOutputLines();
1 ✔
2083

2084
  if (m_autoScroll) {
1 ✔
2085
    m_processOutputEdit->verticalScrollBar()->setValue(
2 ✔
2086
        m_processOutputEdit->verticalScrollBar()->maximum());
1 ✔
2087
  }
2088
}
2089

2090
/**
2091
 * @brief Handles process output from the Pass executor.
2092
 *
2093
 * Called when any non-sensitive process completes. Filters out password-
2094
 * related commands (pass show, insert, etc.) and delegates to
2095
 * appendProcessOutput.
2096
 *
2097
 * @param output The stdout/stderr text from the process.
2098
 * @param isError true if this is error output (stderr).
2099
 * @param pid The process ID identifying which command ran.
2100
 */
2101
void MainWindow::onProcessOutput(const QString &output, bool isError,
2 ✔
2102
                                 Enums::PROCESS pid) {
2103
  appendProcessOutput(output, isError, getProcessName(pid));
2 ✔
2104
}
2 ✔
2105

2106
/**
2107
 * @brief Maps a process ID to its human-readable command name.
2108
 *
2109
 * Returns static strings for git/pass commands that appear in output.
2110
 * Password-related commands return empty (they are filtered).
2111
 *
2112
 * @param pid The process ID to look up.
2113
 * @return QString with command name, or empty if filtered.
2114
 */
2115
auto MainWindow::getProcessName(Enums::PROCESS pid) -> QString {
2 ✔
2116
  switch (pid) {
2 ✔
2117
  case Enums::GIT_INIT:
×
2118
    return QStringLiteral("git init"); // no-tr
×
2119
  case Enums::GIT_ADD:
×
2120
    return QStringLiteral("git add"); // no-tr
×
2121
  case Enums::GIT_COMMIT:
×
2122
    return QStringLiteral("git commit"); // no-tr
×
2123
  case Enums::GIT_RM:
×
2124
    return QStringLiteral("git rm"); // no-tr
×
2125
  case Enums::GIT_PULL:
×
2126
    return QStringLiteral("git pull"); // no-tr
×
2127
  case Enums::GIT_PUSH:
×
2128
    return QStringLiteral("git push"); // no-tr
×
2129
  case Enums::GIT_MOVE:
×
2130
    return QStringLiteral("git mv"); // no-tr
×
2131
  case Enums::GIT_COPY:
×
2132
    // ImitatePass::Copy literally invokes `git cp` (a git-extras
2133
    // subcommand), so the label matches what's run. Stock-git users
2134
    // without git-extras will see the underlying "'cp' is not a git
2135
    // command" failure surfaced in the process output panel.
2136
    return QStringLiteral("git cp"); // no-tr
×
2137
  case Enums::PASS_INSERT:
×
2138
    return QStringLiteral("pass insert"); // no-tr
×
2139
  case Enums::PASS_REMOVE:
×
2140
    return QStringLiteral("pass rm"); // no-tr
×
2141
  case Enums::PASS_INIT:
×
2142
    return QStringLiteral("pass init"); // no-tr
×
2143
  case Enums::PASS_MOVE:
×
2144
    return QStringLiteral("pass mv"); // no-tr
×
2145
  case Enums::PASS_COPY:
×
2146
    return QStringLiteral("pass cp"); // no-tr
×
2147
  case Enums::PASS_GREP:
×
2148
    return QStringLiteral("pass grep"); // no-tr
×
2149
  case Enums::GPG_GENKEYS:
×
2150
    return QStringLiteral("gpg --gen-key"); // no-tr
×
2151
  case Enums::PASS_SHOW:
2152
  case Enums::PASS_OTP_GENERATE:
2153
  case Enums::PROCESS_COUNT:
2154
  case Enums::INVALID:
2155
    break;
2156
  }
2157
  return {};
2158
}
2159

2160
/**
2161
 * @brief Checks if a process ID represents a sensitive operation whose
2162
 * output should not be shown in the process output panel.
2163
 *
2164
 * Password-related commands (pass show, OTP generate, grep, insert)
2165
 * display their output in other UI areas, so we skip them here.
2166
 *
2167
 * @param pid The process ID to check.
2168
 * @return true if the process is sensitive and should be filtered.
2169
 */
2170
auto MainWindow::isSensitiveProcess(Enums::PROCESS pid) -> bool {
×
2171
  switch (pid) {
×
2172
  case Enums::PASS_SHOW:
2173
  case Enums::PASS_OTP_GENERATE:
2174
  case Enums::PASS_GREP:
2175
  case Enums::PASS_INSERT:
2176
    return true;
2177
  case Enums::GIT_INIT:
2178
  case Enums::GIT_ADD:
2179
  case Enums::GIT_COMMIT:
2180
  case Enums::GIT_RM:
2181
  case Enums::GIT_PULL:
2182
  case Enums::GIT_PUSH:
2183
  case Enums::GIT_MOVE:
2184
  case Enums::GIT_COPY:
2185
  case Enums::PASS_REMOVE:
2186
  case Enums::PASS_INIT:
2187
  case Enums::PASS_MOVE:
2188
  case Enums::PASS_COPY:
2189
  case Enums::GPG_GENKEYS:
2190
  case Enums::PROCESS_COUNT:
2191
  case Enums::INVALID:
2192
    break;
2193
  }
2194
  return false;
×
2195
}
2196

2197
/**
2198
 * @brief Updates the visibility of the process output panel.
2199
 *
2200
 * Shows or hides the process output widget based on the user's
2201
 * showProcessOutput setting.
2202
 */
2203
void MainWindow::updateProcessOutputVisibility() {
×
2204
  m_processOutputDock->setVisible(QtPassSettings::isShowProcessOutput());
×
2205
}
×
2206

2207
/**
2208
 * @brief Limits the output panel to max lines, trimming old excess.
2209
 *
2210
 * Removes the oldest lines when the document exceeds MaxOutputLines (1000).
2211
 * Called after each append to prevent unbounded growth.
2212
 */
2213
void MainWindow::limitOutputLines() {
1 ✔
2214
  QTextDocument *doc = m_processOutputEdit->document();
1 ✔
2215
  int excess = doc->blockCount() - MaxOutputLines;
1 ✔
2216
  if (excess <= 0) {
1 ✔
2217
    return;
1 ✔
2218
  }
2219

2220
  QTextCursor cursor(doc);
×
2221
  cursor.movePosition(QTextCursor::Start);
×
2222
  cursor.movePosition(QTextCursor::NextBlock, QTextCursor::KeepAnchor, excess);
×
2223
  cursor.removeSelectedText();
×
2224
}
×
2225

2226
/**
2227
 * @brief Clears the process output panel.
2228
 *
2229
 * Clears all output, resets the line counter, and re-enables auto-scroll.
2230
 */
2231
void MainWindow::on_clearOutputButton_clicked() {
×
2232
  m_processOutputEdit->clear();
×
2233
  m_outputCounter = 0;
×
2234
  m_autoScroll = true;
×
2235
}
×
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