store history using sqlite, add suggestion with open

This commit is contained in:
2026-10-15 23:22:32 +02:00
parent a841178cf7
commit 13c1488b0e
12 changed files with 435 additions and 5 deletions
+3 -1
View File
@@ -19,7 +19,7 @@ nix run . -- example.com
Run `./build/att` from inside `nix develop`. The shell hook exports `QT_PLUGIN_PATH`, `QML2_IMPORT_PATH` and `QTWEBENGINEPROCESS_PATH`, because `wrapQtAppsHook` only wraps *installed* binaries. Run outside the dev shell, the binary can't find Qt's plugins. Run `./build/att` from inside `nix develop`. The shell hook exports `QT_PLUGIN_PATH`, `QML2_IMPORT_PATH` and `QTWEBENGINEPROCESS_PATH`, because `wrapQtAppsHook` only wraps *installed* binaries. Run outside the dev shell, the binary can't find Qt's plugins.
`package.nix` uses a `lib.fileset` source: `CMakeLists.txt`, `src/` and the listed `adblock/` files. Every new source file must also be added to `qt_add_executable` in `CMakeLists.txt`. The crate's cargo dependencies are vendored from `adblock/Cargo.lock` (`importCargoLock` + `cargoSetupHook`) for the offline sandbox build, so commit `Cargo.lock` changes along with `Cargo.toml` ones. `package.nix` uses a `lib.fileset` source: `CMakeLists.txt`, `src/` and the listed `adblock/` files. Every new source file must also be added to `qt_add_executable` in `CMakeLists.txt`, and `git add`ed: the flake only sees tracked files, so `nix build` fails on untracked sources (`nix build path:.` works around that). The crate's cargo dependencies are vendored from `adblock/Cargo.lock` (`importCargoLock` + `cargoSetupHook`) for the offline sandbox build, so commit `Cargo.lock` changes along with `Cargo.toml` ones.
The only tests are the Rust crate's (`nix develop -c sh -c 'cd adblock && cargo test'`). There is no linter. The dev shell includes `clang-tools` (clang-format, clangd) and `gdb`. The only tests are the Rust crate's (`nix develop -c sh -c 'cd adblock && cargo test'`). There is no linter. The dev shell includes `clang-tools` (clang-format, clangd) and `gdb`.
@@ -49,6 +49,8 @@ The only tests are the Rust crate's (`nix develop -c sh -c 'cd adblock && cargo
- `AdBlocker` (`src/adblock.cpp`) is the profile's `QWebEngineUrlRequestInterceptor`. `interceptRequest` may run off the GUI thread, so it reads the engine through a mutex-guarded `shared_ptr` that `update()` swaps. - `AdBlocker` (`src/adblock.cpp`) is the profile's `QWebEngineUrlRequestInterceptor`. `interceptRequest` may run off the GUI thread, so it reads the engine through a mutex-guarded `shared_ptr` that `update()` swaps.
- Lists are compiled on a `QtConcurrent` worker and cached as `adblock-cache.dat` in AppDataLocation. A cache that fails to deserialize (e.g. after an adblock-rust upgrade) triggers a re-download. - Lists are compiled on a `QtConcurrent` worker and cached as `adblock-cache.dat` in AppDataLocation. A cache that fails to deserialize (e.g. after an adblock-rust upgrade) triggers a re-download.
- `main.cpp` creates `AdBlocker` *after* the profile, so it is destroyed after it. - `main.cpp` creates `AdBlocker` *after* the profile, so it is destroyed after it.
- **History** (`History`, `src/history.cpp`) is SQLite through QtSql, run synchronously on the GUI thread with WAL. `visits` is an append-only log; `urls` (one row per URL, upserted) is what completion searches. Visits are recorded on `loadFinished(true)`, not `urlChanged`. URLs are stored as `toDisplayString()` with the fragment removed, so a `titleChanged` after an in-page jump still updates the right row.
- **`:open` completion:** `BrowserWindow::updateCompletion` runs on the command line's `textEdited` and on entering command mode. It parses `:open`/`:o` and shows `History::search` results in `CompletionView`, which never takes focus. `CommandLine::event` intercepts Tab/Backtab, because `QWidget::event` would otherwise turn them into focus changes. Picking an entry uses `setCommandText`, which doesn't emit `textEdited`, so it doesn't re-filter.
- The UI stylesheet is the `kStyleSheet` string in `src/main.cpp`. Widgets are styled by `objectName` and by dynamic properties: `StatusBar` toggles `error` and re-polishes the label to pick up the change. - The UI stylesheet is the `kStyleSheet` string in `src/main.cpp`. Widgets are styled by `objectName` and by dynamic properties: `StatusBar` toggles `error` and re-polishes the label to pick up the change.
There are no tabs yet. `createWindow` isn't implemented, so `target=_blank` and `window.open` go nowhere. To work around this, hint mode rewrites link targets to `_self`. There are no tabs yet. `createWindow` isn't implemented, so `target=_blank` and `window.open` go nowhere. To work around this, hint mode rewrites link targets to `_self`.
+6 -1
View File
@@ -12,7 +12,7 @@ set(CMAKE_AUTOMOC ON)
include(GNUInstallDirs) include(GNUInstallDirs)
find_package(Qt6 6.5 REQUIRED COMPONENTS Core Gui Widgets Network Concurrent WebEngineCore WebEngineWidgets) find_package(Qt6 6.5 REQUIRED COMPONENTS Core Gui Widgets Network Concurrent Sql WebEngineCore WebEngineWidgets)
find_package(Corrosion REQUIRED) find_package(Corrosion REQUIRED)
qt_standard_project_setup() qt_standard_project_setup()
@@ -34,10 +34,14 @@ qt_add_executable(att
src/browserwindow.h src/browserwindow.h
src/commandline.cpp src/commandline.cpp
src/commandline.h src/commandline.h
src/completionview.cpp
src/completionview.h
src/gpuworkaround.cpp src/gpuworkaround.cpp
src/gpuworkaround.h src/gpuworkaround.h
src/hints.cpp src/hints.cpp
src/hints.h src/hints.h
src/history.cpp
src/history.h
src/statusbar.cpp src/statusbar.cpp
src/statusbar.h src/statusbar.h
src/urlutil.cpp src/urlutil.cpp
@@ -53,6 +57,7 @@ target_link_libraries(att PRIVATE
Qt6::Widgets Qt6::Widgets
Qt6::Network Qt6::Network
Qt6::Concurrent Qt6::Concurrent
Qt6::Sql
Qt6::WebEngineCore Qt6::WebEngineCore
Qt6::WebEngineWidgets Qt6::WebEngineWidgets
) )
+14 -2
View File
@@ -67,6 +67,11 @@ Like qutebrowser, keys mean different things depending on the mode:
The command line keeps a history (`Up`/`Down`) and aborts on `Escape`, `Ctrl-c`, The command line keeps a history (`Up`/`Down`) and aborts on `Escape`, `Ctrl-c`,
or if you delete the leading `:`. or if you delete the leading `:`.
While typing `:open` (or `:o`), a completion list of previously visited pages
appears above it, most recent first. Every word you type must appear in the URL
or the title. `Tab` / `Shift-Tab` cycle through the list and put the selected URL
on the command line; `Enter` opens it.
## Hint mode ## Hint mode
`f` labels every clickable element in the viewport with home-row letters `f` labels every clickable element in the viewport with home-row letters
@@ -82,7 +87,12 @@ shadow DOM yet.
## Browsing data ## Browsing data
Cookies, local storage and history are kept in a persistent QtWebEngine profile Browsing history is kept in `~/.local/share/att/att/history.sqlite` (tables
`visits`, one row per page load, and `urls`, one row per URL for completion).
A page is recorded once it finishes loading, so redirect hops and in-page
`#anchor` jumps don't create entries. `:history-clear` empties it.
Cookies, local storage and Chromium's own state are kept in a persistent QtWebEngine profile
under `~/.local/share/att/att/QtWebEngine/default` (the cache is in under `~/.local/share/att/att/QtWebEngine/default` (the cache is in
`~/.cache/att/att/`). Session cookies are saved too, like qutebrowser's `~/.cache/att/att/`). Session cookies are saved too, like qutebrowser's
`content.cookies.store`, so you stay logged in across restarts. Delete that `content.cookies.store`, so you stay logged in across restarts. Delete that
@@ -104,7 +114,7 @@ on, for when a site breaks.
## Commands ## Commands
`:open <url|query>` (alias `:o`), `:quit` (alias `:q`), `:reload`, `:back`, `:open <url|query>` (alias `:o`), `:quit` (alias `:q`), `:reload`, `:back`,
`:forward`, `:stop`, `:adblock-update`, `:adblock-toggle`. `:forward`, `:stop`, `:history-clear`, `:adblock-update`, `:adblock-toggle`.
`:open` accepts a full URL, a bare host (`example.com` → `https://example.com`), `:open` accepts a full URL, a bare host (`example.com` → `https://example.com`),
an existing local path, or free text, which goes to DuckDuckGo. an existing local path, or free text, which goes to DuckDuckGo.
@@ -118,6 +128,8 @@ an existing local path, or free text, which goes to DuckDuckGo.
| `src/main.cpp` | `QApplication`, CLI parsing, stylesheet | | `src/main.cpp` | `QApplication`, CLI parsing, stylesheet |
| `src/browserwindow` | the window, mode state machine, key dispatch, commands | | `src/browserwindow` | the window, mode state machine, key dispatch, commands |
| `src/commandline` | the `:` prompt | | `src/commandline` | the `:` prompt |
| `src/history` | SQLite browsing history |
| `src/completionview`| the `:open` completion list |
| `src/statusbar` | mode / URL / progress line | | `src/statusbar` | mode / URL / progress line |
| `src/adblock` | request interceptor around the adblock engine | | `src/adblock` | request interceptor around the adblock engine |
| `adblock/` | Rust crate: C API over adblock-rust, built by Corrosion | | `adblock/` | Rust crate: C API over adblock-rust, built by Corrosion |
+58 -1
View File
@@ -2,6 +2,7 @@
#include "adblock.h" #include "adblock.h"
#include "commandline.h" #include "commandline.h"
#include "completionview.h"
#include "hints.h" #include "hints.h"
#include "statusbar.h" #include "statusbar.h"
#include "urlutil.h" #include "urlutil.h"
@@ -11,8 +12,10 @@
#include <QEvent> #include <QEvent>
#include <QKeyEvent> #include <QKeyEvent>
#include <QMouseEvent> #include <QMouseEvent>
#include <QRegularExpression>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QWebEnginePage> #include <QWebEnginePage>
#include <QWebEngineProfile>
#include <QWebEngineScript> #include <QWebEngineScript>
#include <QWebEngineView> #include <QWebEngineView>
#include <QWidget> #include <QWidget>
@@ -21,6 +24,14 @@ namespace {
constexpr int kScrollStep = 60; constexpr int kScrollStep = 60;
constexpr double kZoomStep = 0.1; constexpr double kZoomStep = 0.1;
constexpr int kCompletionLimit = 100;
// ":open <query>" or ":o <query>"; captures the prefix and the query.
const QRegularExpression &openCommand()
{
static const QRegularExpression re(QStringLiteral("^(:(?:open|o) +)(.*)$"));
return re;
}
} // namespace } // namespace
@@ -31,6 +42,7 @@ BrowserWindow::BrowserWindow(QWebEngineProfile *profile, AdBlocker *adBlocker,
, m_view(new QWebEngineView(profile, this)) , m_view(new QWebEngineView(profile, this))
, m_status(new StatusBar(this)) , m_status(new StatusBar(this))
, m_commandLine(new CommandLine(this)) , m_commandLine(new CommandLine(this))
, m_completion(new CompletionView(this))
{ {
setupUi(); setupUi();
connectView(); connectView();
@@ -42,6 +54,8 @@ BrowserWindow::BrowserWindow(QWebEngineProfile *profile, AdBlocker *adBlocker,
connect(m_commandLine, &CommandLine::cancelled, this, [this] { connect(m_commandLine, &CommandLine::cancelled, this, [this] {
setMode(Mode::Normal); setMode(Mode::Normal);
}); });
connect(m_commandLine, &QLineEdit::textEdited, this, &BrowserWindow::updateCompletion);
connect(m_commandLine, &CommandLine::completionCycled, this, &BrowserWindow::cycleCompletion);
connect(m_adBlocker, &AdBlocker::message, m_status, &StatusBar::showMessage); connect(m_adBlocker, &AdBlocker::message, m_status, &StatusBar::showMessage);
qApp->installEventFilter(this); qApp->installEventFilter(this);
@@ -61,6 +75,7 @@ void BrowserWindow::setupUi()
layout->setContentsMargins(0, 0, 0, 0); layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(0); layout->setSpacing(0);
layout->addWidget(m_view, 1); layout->addWidget(m_view, 1);
layout->addWidget(m_completion);
layout->addWidget(m_status); layout->addWidget(m_status);
layout->addWidget(m_commandLine); layout->addWidget(m_commandLine);
setCentralWidget(central); setCentralWidget(central);
@@ -80,11 +95,17 @@ void BrowserWindow::connectView()
connect(m_view, &QWebEngineView::titleChanged, this, [this](const QString &title) { connect(m_view, &QWebEngineView::titleChanged, this, [this](const QString &title) {
setWindowTitle(title.isEmpty() ? QStringLiteral("att") setWindowTitle(title.isEmpty() ? QStringLiteral("att")
: QStringLiteral("%1 - att").arg(title)); : QStringLiteral("%1 - att").arg(title));
m_history.updateTitle(m_view->url(), title);
}); });
connect(m_view, &QWebEngineView::loadFinished, this, [this](bool ok) { connect(m_view, &QWebEngineView::loadFinished, this, [this](bool ok) {
m_status->setProgress(100); m_status->setProgress(100);
if (!ok) if (!ok) {
m_status->showMessage(QStringLiteral("Failed to load page"), true); m_status->showMessage(QStringLiteral("Failed to load page"), true);
return;
}
// Here rather than on urlChanged, which also fires for every redirect
// hop and in-page #anchor jump.
m_history.addVisit(m_view->url(), m_view->title());
}); });
} }
@@ -96,6 +117,9 @@ void BrowserWindow::setMode(Mode mode)
m_mode = mode; m_mode = mode;
m_pendingKeys.clear(); m_pendingKeys.clear();
if (mode != Mode::Command)
m_completion->setEntries({});
switch (mode) { switch (mode) {
case Mode::Normal: case Mode::Normal:
m_status->setMode(QString()); m_status->setMode(QString());
@@ -109,6 +133,7 @@ void BrowserWindow::setMode(Mode mode)
break; break;
case Mode::Command: case Mode::Command:
m_status->hide(); m_status->hide();
updateCompletion();
break; break;
case Mode::Hint: case Mode::Hint:
m_status->setMode(QStringLiteral("hint")); m_status->setMode(QStringLiteral("hint"));
@@ -363,6 +388,14 @@ void BrowserWindow::runCommand(const QString &command)
m_view->forward(); m_view->forward();
} else if (name == QLatin1String("stop")) { } else if (name == QLatin1String("stop")) {
m_view->stop(); m_view->stop();
} else if (name == QLatin1String("history-clear")) {
if (m_history.clear()) {
// Also forget Chromium's own record, which colours visited links.
m_view->page()->profile()->clearAllVisitedLinks();
m_status->showMessage(QStringLiteral("History cleared"));
} else {
m_status->showMessage(QStringLiteral("Could not clear history"), true);
}
} else if (name == QLatin1String("adblock-update")) { } else if (name == QLatin1String("adblock-update")) {
m_adBlocker->update(); m_adBlocker->update();
} else if (name == QLatin1String("adblock-toggle")) { } else if (name == QLatin1String("adblock-toggle")) {
@@ -374,6 +407,30 @@ void BrowserWindow::runCommand(const QString &command)
} }
} }
void BrowserWindow::updateCompletion()
{
const QRegularExpressionMatch match = openCommand().match(m_commandLine->text());
if (!match.hasMatch()) {
m_completion->setEntries({});
return;
}
const QStringList words = match.captured(2).split(QLatin1Char(' '), Qt::SkipEmptyParts);
m_completion->setEntries(m_history.search(words, kCompletionLimit));
}
void BrowserWindow::cycleCompletion(int step)
{
if (!m_completion->isVisible())
return;
const QString url = m_completion->cycle(step);
if (url.isEmpty())
return;
// Keep whichever of ":o " / ":open " the user typed.
const QRegularExpressionMatch match = openCommand().match(m_commandLine->text());
const QString prefix = match.hasMatch() ? match.captured(1) : QStringLiteral(":open ");
m_commandLine->setCommandText(prefix + url);
}
void BrowserWindow::openUrl(const QString &input) void BrowserWindow::openUrl(const QString &input)
{ {
const QUrl url = urlutil::fromUserInput(input); const QUrl url = urlutil::fromUserInput(input);
+7
View File
@@ -1,5 +1,7 @@
#pragma once #pragma once
#include "history.h"
#include <QMainWindow> #include <QMainWindow>
#include <QString> #include <QString>
#include <QUrl> #include <QUrl>
@@ -7,6 +9,7 @@
class AdBlocker; class AdBlocker;
class CommandLine; class CommandLine;
class CompletionView;
class StatusBar; class StatusBar;
class QWebEngineProfile; class QWebEngineProfile;
class QWebEngineView; class QWebEngineView;
@@ -45,6 +48,8 @@ private:
void handleHintResult(const QVariantMap &result); void handleHintResult(const QVariantMap &result);
void clickAt(const QPointF &pos); void clickAt(const QPointF &pos);
void runCommand(const QString &command); void runCommand(const QString &command);
void updateCompletion();
void cycleCompletion(int step);
void openUrl(const QString &input); void openUrl(const QString &input);
void scrollBy(int dx, int dy); void scrollBy(int dx, int dy);
void scrollPages(double factor); void scrollPages(double factor);
@@ -55,6 +60,8 @@ private:
QWebEngineView *m_view; QWebEngineView *m_view;
StatusBar *m_status; StatusBar *m_status;
CommandLine *m_commandLine; CommandLine *m_commandLine;
CompletionView *m_completion;
History m_history;
Mode m_mode = Mode::Normal; Mode m_mode = Mode::Normal;
QString m_pendingKeys; QString m_pendingKeys;
+23
View File
@@ -1,5 +1,6 @@
#include "commandline.h" #include "commandline.h"
#include <QEvent>
#include <QKeyEvent> #include <QKeyEvent>
#include <QSignalBlocker> #include <QSignalBlocker>
@@ -27,6 +28,26 @@ void CommandLine::openWith(const QString &text)
end(false); end(false);
} }
void CommandLine::setCommandText(const QString &text)
{
setTextSilently(text);
end(false);
}
bool CommandLine::event(QEvent *event)
{
// QWidget::event turns Tab into a focus change before keyPressEvent would
// ever see it.
if (event->type() == QEvent::KeyPress) {
const int key = static_cast<QKeyEvent *>(event)->key();
if (key == Qt::Key_Tab || key == Qt::Key_Backtab) {
emit completionCycled(key == Qt::Key_Tab ? 1 : -1);
return true;
}
}
return QLineEdit::event(event);
}
void CommandLine::keyPressEvent(QKeyEvent *event) void CommandLine::keyPressEvent(QKeyEvent *event)
{ {
switch (event->key()) { switch (event->key()) {
@@ -96,6 +117,8 @@ void CommandLine::recallHistory(int direction)
setTextSilently(pos == m_history.size() ? QStringLiteral(":") setTextSilently(pos == m_history.size() ? QStringLiteral(":")
: QLatin1Char(':') + m_history.at(pos)); : QLatin1Char(':') + m_history.at(pos));
end(false); end(false);
// Recalling is an edit as far as completion is concerned.
emit textEdited(text());
} }
void CommandLine::cancel() void CommandLine::cancel()
+6
View File
@@ -14,13 +14,19 @@ public:
// text should include the leading ":", e.g. ":open ". // text should include the leading ":", e.g. ":open ".
void openWith(const QString &text); void openWith(const QString &text);
// Replaces the text without emitting textEdited, e.g. when a completion
// is picked.
void setCommandText(const QString &text);
signals: signals:
// Emitted with the command only, i.e. without the leading ":". // Emitted with the command only, i.e. without the leading ":".
void commandEntered(const QString &command); void commandEntered(const QString &command);
void cancelled(); void cancelled();
// Tab (1) / Shift-Tab (-1).
void completionCycled(int step);
protected: protected:
bool event(QEvent *event) override;
void keyPressEvent(QKeyEvent *event) override; void keyPressEvent(QKeyEvent *event) override;
void focusOutEvent(QFocusEvent *event) override; void focusOutEvent(QFocusEvent *event) override;
+66
View File
@@ -0,0 +1,66 @@
#include "completionview.h"
#include <QHeaderView>
namespace {
constexpr int kMaxVisibleRows = 12;
} // namespace
CompletionView::CompletionView(QWidget *parent)
: QTreeWidget(parent)
{
setObjectName(QStringLiteral("completion"));
setFocusPolicy(Qt::NoFocus);
setColumnCount(2);
setHeaderHidden(true);
setRootIsDecorated(false);
setUniformRowHeights(true);
setFrameShape(QFrame::NoFrame);
setSelectionMode(QAbstractItemView::SingleSelection);
setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
setTextElideMode(Qt::ElideRight);
header()->setStretchLastSection(false);
header()->setSectionResizeMode(QHeaderView::Stretch);
hide();
}
void CompletionView::setEntries(const QList<History::Entry> &entries)
{
clear();
if (entries.isEmpty()) {
hide();
return;
}
QList<QTreeWidgetItem *> items;
items.reserve(entries.size());
for (const History::Entry &entry : entries) {
auto *item = new QTreeWidgetItem({entry.url, entry.title});
item->setToolTip(0, entry.url);
items.append(item);
}
addTopLevelItems(items);
const int rows = qMin(int(entries.size()), kMaxVisibleRows);
setFixedHeight(rows * sizeHintForRow(0) + 2 * frameWidth());
show();
}
QString CompletionView::cycle(int step)
{
const int count = topLevelItemCount();
if (count == 0)
return {};
QTreeWidgetItem *current = currentItem();
int row = current && current->isSelected() ? indexOfTopLevelItem(current) + step
: (step > 0 ? 0 : count - 1);
row = ((row % count) + count) % count;
QTreeWidgetItem *item = topLevelItem(row);
setCurrentItem(item);
scrollToItem(item);
return item->text(0);
}
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include "history.h"
#include <QTreeWidget>
// The list above the command line offering history entries for ":open", with
// URL and title columns, like qutebrowser's completion widget. It never takes
// focus: keys stay with the CommandLine, which drives it via cycle().
class CompletionView : public QTreeWidget
{
Q_OBJECT
public:
explicit CompletionView(QWidget *parent = nullptr);
// Shows the entries with nothing selected, or hides the view if empty.
void setEntries(const QList<History::Entry> &entries);
// Moves the selection step rows (wrapping) and returns the selected URL.
QString cycle(int step);
};
+173
View File
@@ -0,0 +1,173 @@
#include "history.h"
#include <QDateTime>
#include <QDir>
#include <QSqlError>
#include <QSqlQuery>
#include <QStandardPaths>
#include <QVariant>
namespace {
constexpr auto kConnection = "history";
// LIKE treats % and _ as wildcards; escape them so they match literally.
QString likePattern(const QString &word)
{
QString escaped = word;
escaped.replace(QLatin1Char('\\'), QLatin1String("\\\\"));
escaped.replace(QLatin1Char('%'), QLatin1String("\\%"));
escaped.replace(QLatin1Char('_'), QLatin1String("\\_"));
return QLatin1Char('%') + escaped + QLatin1Char('%');
}
QString storedUrl(const QUrl &url)
{
// The same readable form the status bar shows: it is what completion
// displays and what the user types to match against. Pretty-decoding keeps
// escapes that matter, like %2F, so the URL still round-trips.
//
// Without the #fragment: anchors on one page are one entry, and a title
// set after an in-page jump still finds the row its load created.
return url.adjusted(QUrl::RemoveFragment).toDisplayString();
}
} // namespace
History::History()
{
const QString dir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
QDir().mkpath(dir);
m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), QString::fromLatin1(kConnection));
m_db.setDatabaseName(dir + QStringLiteral("/history.sqlite"));
if (!m_db.open()) {
qWarning("history: cannot open database: %s", qPrintable(m_db.lastError().text()));
return;
}
m_open = exec(QStringLiteral("PRAGMA journal_mode=WAL"))
&& exec(QStringLiteral("PRAGMA synchronous=NORMAL"))
&& exec(QStringLiteral("CREATE TABLE IF NOT EXISTS visits ("
" url TEXT NOT NULL,"
" title TEXT NOT NULL,"
" at INTEGER NOT NULL)"))
&& exec(QStringLiteral("CREATE TABLE IF NOT EXISTS urls ("
" url TEXT PRIMARY KEY,"
" title TEXT NOT NULL,"
" last_visit INTEGER NOT NULL,"
" visits INTEGER NOT NULL DEFAULT 1)"))
&& exec(QStringLiteral("CREATE INDEX IF NOT EXISTS urls_last_visit"
" ON urls(last_visit DESC)"));
}
History::~History()
{
m_db.close();
m_db = QSqlDatabase();
QSqlDatabase::removeDatabase(QString::fromLatin1(kConnection));
}
bool History::isRecordable(const QUrl &url)
{
const QString scheme = url.scheme();
return scheme == QLatin1String("https") || scheme == QLatin1String("http")
|| scheme == QLatin1String("file");
}
void History::addVisit(const QUrl &url, const QString &title)
{
if (!m_open || !isRecordable(url))
return;
const QString u = storedUrl(url);
const qint64 now = QDateTime::currentSecsSinceEpoch();
m_db.transaction();
QSqlQuery visit(m_db);
visit.prepare(QStringLiteral("INSERT INTO visits (url, title, at) VALUES (?, ?, ?)"));
visit.addBindValue(u);
visit.addBindValue(title);
visit.addBindValue(now);
QSqlQuery upsert(m_db);
upsert.prepare(QStringLiteral(
"INSERT INTO urls (url, title, last_visit) VALUES (?, ?, ?)"
" ON CONFLICT(url) DO UPDATE SET"
" title = CASE WHEN excluded.title != '' THEN excluded.title ELSE title END,"
" last_visit = excluded.last_visit,"
" visits = visits + 1"));
upsert.addBindValue(u);
upsert.addBindValue(title);
upsert.addBindValue(now);
if (visit.exec() && upsert.exec()) {
m_db.commit();
} else {
qWarning("history: cannot record visit: %s",
qPrintable((visit.lastError().isValid() ? visit : upsert).lastError().text()));
m_db.rollback();
}
}
void History::updateTitle(const QUrl &url, const QString &title)
{
if (!m_open || title.isEmpty() || !isRecordable(url))
return;
QSqlQuery query(m_db);
query.prepare(QStringLiteral("UPDATE urls SET title = ? WHERE url = ?"));
query.addBindValue(title);
query.addBindValue(storedUrl(url));
query.exec();
}
QList<History::Entry> History::search(const QStringList &words, int limit) const
{
QList<Entry> entries;
if (!m_open)
return entries;
QString sql = QStringLiteral("SELECT url, title FROM urls");
for (qsizetype i = 0; i < words.size(); ++i) {
sql += i == 0 ? QStringLiteral(" WHERE ") : QStringLiteral(" AND ");
sql += QStringLiteral("(url LIKE ? ESCAPE '\\' OR title LIKE ? ESCAPE '\\')");
}
sql += QStringLiteral(" ORDER BY last_visit DESC LIMIT ?");
QSqlQuery query(m_db);
query.setForwardOnly(true);
query.prepare(sql);
for (const QString &word : words) {
const QString pattern = likePattern(word);
query.addBindValue(pattern);
query.addBindValue(pattern);
}
query.addBindValue(limit);
if (!query.exec()) {
qWarning("history: search failed: %s", qPrintable(query.lastError().text()));
return entries;
}
while (query.next())
entries.append({query.value(0).toString(), query.value(1).toString()});
return entries;
}
bool History::clear()
{
if (!m_open)
return false;
return exec(QStringLiteral("DELETE FROM visits")) && exec(QStringLiteral("DELETE FROM urls"))
&& exec(QStringLiteral("VACUUM"));
}
bool History::exec(const QString &sql)
{
QSqlQuery query(m_db);
if (query.exec(sql))
return true;
qWarning("history: %s: %s", qPrintable(sql), qPrintable(query.lastError().text()));
return false;
}
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include <QList>
#include <QSqlDatabase>
#include <QString>
#include <QStringList>
#include <QUrl>
// Browsing history in history.sqlite under AppDataLocation. Two tables:
// "visits" is an append-only log of every page load; "urls" holds one row per
// URL with its latest title and visit time, and is what completion searches.
//
// Queries run synchronously on the GUI thread. That is fine for SQLite at
// browser-history sizes, with WAL so a write never waits on a full fsync.
class History
{
public:
struct Entry {
QString url;
QString title;
};
History();
~History();
History(const History &) = delete;
History &operator=(const History &) = delete;
bool isOpen() const { return m_open; }
void addVisit(const QUrl &url, const QString &title);
// Pages often set their title after the load finished.
void updateTitle(const QUrl &url, const QString &title);
// Most recently visited first. Every word must appear in the URL or the
// title, case-insensitively.
QList<Entry> search(const QStringList &words, int limit) const;
bool clear();
// Whether a URL belongs in history at all: no internal or data pages.
static bool isRecordable(const QUrl &url);
private:
bool exec(const QString &sql);
QSqlDatabase m_db;
bool m_open = false;
};
+8
View File
@@ -27,6 +27,14 @@ constexpr auto kStyleSheet = R"(
QLabel#statusProgress { QLabel#statusProgress {
color: #8a8a8a; color: #8a8a8a;
} }
QTreeWidget#completion {
background-color: #262626;
color: #d8d8d8;
font-family: monospace;
border-top: 1px solid #3a3a3a;
selection-background-color: #e8c000;
selection-color: #000000;
}
QLineEdit#commandLine { QLineEdit#commandLine {
padding: 1px 4px; padding: 1px 4px;
selection-background-color: #445566; selection-background-color: #445566;