store history using sqlite, add suggestion with open
This commit is contained in:
@@ -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
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user