mirror of
https://github.com/LadybirdBrowser/ladybird
synced 2026-04-25 17:25:08 +02:00
LibWebView: Add history-backed location autocomplete
Teach LibWebView autocomplete to query HistoryStore before falling back to remote engines and move the wiring out of the AppKit frontend. Refine matching so scheme and www. boilerplate do not dominate results, short title and substring queries stay quiet, and history tracing can explain what the ranking code is doing.
This commit is contained in:
committed by
Andreas Kling
parent
54f14609f4
commit
fe2cab9270
Notes:
github-actions[bot]
2026-04-16 19:03:28 +00:00
Author: https://github.com/awesomekling Commit: https://github.com/LadybirdBrowser/ladybird/commit/fe2cab9270f Pull-request: https://github.com/LadybirdBrowser/ladybird/pull/8933 Reviewed-by: https://github.com/trflynn89
@@ -20,6 +20,7 @@
|
||||
#include <LibWebView/CookieJar.h>
|
||||
#include <LibWebView/HeadlessWebView.h>
|
||||
#include <LibWebView/HelperProcess.h>
|
||||
#include <LibWebView/HistoryStore.h>
|
||||
#include <LibWebView/Menu.h>
|
||||
#include <LibWebView/ProcessType.h>
|
||||
#include <LibWebView/URL.h>
|
||||
@@ -463,10 +464,19 @@ ErrorOr<void> Application::launch_services()
|
||||
auto database_path = ByteString::formatted("{}/Ladybird", Core::StandardPaths::user_data_directory());
|
||||
|
||||
m_database = TRY(Database::Database::create(database_path, "Ladybird"sv));
|
||||
m_history_database = TRY(Database::Database::create(database_path, "History"sv));
|
||||
|
||||
if (auto history_database_path = m_history_database->database_path(); history_database_path.has_value())
|
||||
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] SQL history is enabled, using {}", history_database_path->string());
|
||||
|
||||
m_cookie_jar = TRY(CookieJar::create(*m_database));
|
||||
m_history_store = TRY(HistoryStore::create(*m_history_database));
|
||||
m_storage_jar = TRY(StorageJar::create(*m_database));
|
||||
} else {
|
||||
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] SQL history is disabled, disabling browsing history");
|
||||
|
||||
m_cookie_jar = CookieJar::create();
|
||||
m_history_store = HistoryStore::create_disabled();
|
||||
m_storage_jar = StorageJar::create();
|
||||
}
|
||||
|
||||
@@ -824,6 +834,13 @@ void Application::clear_browsing_data(ClearBrowsingDataOptions const& options)
|
||||
}
|
||||
}
|
||||
|
||||
void Application::clear_history()
|
||||
{
|
||||
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Clearing browsing history");
|
||||
|
||||
m_history_store->clear();
|
||||
}
|
||||
|
||||
void Application::initialize_actions()
|
||||
{
|
||||
auto debug_request = [this](auto request) {
|
||||
|
||||
@@ -63,9 +63,11 @@ public:
|
||||
static ImageDecoderClient::Client& image_decoder_client() { return *the().m_image_decoder_client; }
|
||||
|
||||
static BookmarkStore& bookmark_store() { return the().m_bookmark_store; }
|
||||
static HistoryStore& history_store() { return *the().m_history_store; }
|
||||
void update_bookmark_action_for_current_web_view();
|
||||
void bookmarks_changed(Badge<ApplicationBookmarkStoreObserver>);
|
||||
void show_bookmarks_bar_changed(Badge<ApplicationSettingsObserver>);
|
||||
void clear_history();
|
||||
|
||||
virtual void show_bookmark_context_menu(Gfx::IntPoint, Optional<BookmarkItem const&>, [[maybe_unused]] Optional<String const&> target_folder_id) { }
|
||||
|
||||
@@ -257,6 +259,7 @@ private:
|
||||
|
||||
BookmarkStore m_bookmark_store;
|
||||
OwnPtr<ApplicationBookmarkStoreObserver> m_bookmark_store_observer;
|
||||
OwnPtr<HistoryStore> m_history_store;
|
||||
|
||||
Main::Arguments m_arguments;
|
||||
BrowserOptions m_browser_options;
|
||||
@@ -270,6 +273,7 @@ private:
|
||||
bool m_has_queued_task_to_launch_spare_web_content_process { false };
|
||||
|
||||
RefPtr<Database::Database> m_database;
|
||||
RefPtr<Database::Database> m_history_database;
|
||||
OwnPtr<CookieJar> m_cookie_jar;
|
||||
OwnPtr<StorageJar> m_storage_jar;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* SPDX-License-Identifier: BSD-2-Clause
|
||||
*/
|
||||
|
||||
#include <AK/Debug.h>
|
||||
#include <AK/Find.h>
|
||||
#include <LibCore/EventLoop.h>
|
||||
#include <LibRequests/Request.h>
|
||||
@@ -15,6 +16,8 @@
|
||||
#include <LibWeb/MimeSniff/MimeType.h>
|
||||
#include <LibWebView/Application.h>
|
||||
#include <LibWebView/Autocomplete.h>
|
||||
#include <LibWebView/HistoryDebug.h>
|
||||
#include <LibWebView/HistoryStore.h>
|
||||
|
||||
namespace WebView {
|
||||
|
||||
@@ -41,6 +44,18 @@ Optional<AutocompleteEngine const&> find_autocomplete_engine_by_name(StringView
|
||||
Autocomplete::Autocomplete() = default;
|
||||
Autocomplete::~Autocomplete() = default;
|
||||
|
||||
static Vector<String> merge_suggestions(Vector<String> history_suggestions, Vector<String> remote_suggestions)
|
||||
{
|
||||
history_suggestions.ensure_capacity(history_suggestions.size() + remote_suggestions.size());
|
||||
|
||||
for (auto& suggestion : remote_suggestions) {
|
||||
if (!history_suggestions.contains_slow(suggestion))
|
||||
history_suggestions.unchecked_append(move(suggestion));
|
||||
}
|
||||
|
||||
return history_suggestions;
|
||||
}
|
||||
|
||||
void Autocomplete::query_autocomplete_engine(String query)
|
||||
{
|
||||
if (m_request) {
|
||||
@@ -48,41 +63,60 @@ void Autocomplete::query_autocomplete_engine(String query)
|
||||
m_request.clear();
|
||||
}
|
||||
|
||||
auto trimmed_query = query.bytes_as_string_view().trim_whitespace();
|
||||
if (trimmed_query.is_empty() || trimmed_query.starts_with(file_url_prefix)) {
|
||||
invoke_autocomplete_query_complete({});
|
||||
auto trimmed_query = MUST(String::from_utf8(query.bytes_as_string_view().trim_whitespace()));
|
||||
m_query = move(query);
|
||||
|
||||
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Autocomplete query='{}' trimmed='{}'", m_query, trimmed_query);
|
||||
|
||||
m_history_suggestions = Application::history_store().autocomplete_suggestions(trimmed_query);
|
||||
|
||||
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] History autocomplete suggestions for '{}': {}", trimmed_query, history_log_suggestions(m_history_suggestions));
|
||||
|
||||
invoke_autocomplete_query_complete(m_history_suggestions);
|
||||
|
||||
if (trimmed_query.is_empty()) {
|
||||
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Skipping remote autocomplete for empty query");
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmed_query.starts_with_bytes(file_url_prefix)) {
|
||||
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Skipping remote autocomplete for file URL query '{}'", trimmed_query);
|
||||
return;
|
||||
}
|
||||
|
||||
auto engine = Application::settings().autocomplete_engine();
|
||||
if (!engine.has_value()) {
|
||||
invoke_autocomplete_query_complete({});
|
||||
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Skipping remote autocomplete because no engine is configured");
|
||||
return;
|
||||
}
|
||||
|
||||
auto url_string = MUST(String::formatted(engine->query_url, URL::percent_encode(query)));
|
||||
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Fetching remote autocomplete suggestions from {} for '{}'", engine->name, m_query);
|
||||
|
||||
auto url_string = MUST(String::formatted(engine->query_url, URL::percent_encode(m_query)));
|
||||
auto url = URL::Parser::basic_parse(url_string);
|
||||
|
||||
if (!url.has_value()) {
|
||||
invoke_autocomplete_query_complete({});
|
||||
if (!url.has_value())
|
||||
return;
|
||||
}
|
||||
|
||||
m_request = Application::request_server_client().start_request("GET"sv, *url);
|
||||
m_query = move(query);
|
||||
|
||||
m_request->set_buffered_request_finished_callback(
|
||||
[this, engine = engine.release_value()](u64, Requests::RequestTimingInfo const&, Optional<Requests::NetworkError> const& network_error, HTTP::HeaderList const& response_headers, Optional<u32> response_code, Optional<String> const& reason_phrase, ReadonlyBytes payload) {
|
||||
[this, engine = engine.release_value(), query = m_query](u64, Requests::RequestTimingInfo const&, Optional<Requests::NetworkError> const& network_error, HTTP::HeaderList const& response_headers, Optional<u32> response_code, Optional<String> const& reason_phrase, ReadonlyBytes payload) {
|
||||
Core::deferred_invoke([this]() { m_request.clear(); });
|
||||
|
||||
if (m_query != query) {
|
||||
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Discarding stale remote autocomplete response for '{}' while current query is '{}'", query, m_query);
|
||||
return;
|
||||
}
|
||||
|
||||
if (network_error.has_value()) {
|
||||
warnln("Unable to fetch autocomplete suggestions: {}", Requests::network_error_to_string(*network_error));
|
||||
invoke_autocomplete_query_complete({});
|
||||
invoke_autocomplete_query_complete(m_history_suggestions);
|
||||
return;
|
||||
}
|
||||
if (response_code.has_value() && *response_code >= 400) {
|
||||
warnln("Received error response code {} from autocomplete engine: {}", *response_code, reason_phrase);
|
||||
invoke_autocomplete_query_complete({});
|
||||
invoke_autocomplete_query_complete(m_history_suggestions);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -90,9 +124,17 @@ void Autocomplete::query_autocomplete_engine(String query)
|
||||
|
||||
if (auto result = received_autocomplete_respsonse(engine, content_type, payload); result.is_error()) {
|
||||
warnln("Unable to handle autocomplete response: {}", result.error());
|
||||
invoke_autocomplete_query_complete({});
|
||||
invoke_autocomplete_query_complete(m_history_suggestions);
|
||||
} else {
|
||||
invoke_autocomplete_query_complete(result.release_value());
|
||||
auto remote_suggestions = result.release_value();
|
||||
|
||||
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Remote autocomplete suggestions for '{}': {}", query, history_log_suggestions(remote_suggestions));
|
||||
|
||||
auto merged_suggestions = merge_suggestions(m_history_suggestions, move(remote_suggestions));
|
||||
|
||||
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Merged autocomplete suggestions for '{}': {}", query, history_log_suggestions(merged_suggestions));
|
||||
|
||||
invoke_autocomplete_query_complete(move(merged_suggestions));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -208,6 +250,8 @@ ErrorOr<Vector<String>> Autocomplete::received_autocomplete_respsonse(Autocomple
|
||||
|
||||
void Autocomplete::invoke_autocomplete_query_complete(Vector<String> suggestions) const
|
||||
{
|
||||
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Delivering {} autocomplete suggestion(s)", suggestions.size());
|
||||
|
||||
if (on_autocomplete_query_complete)
|
||||
on_autocomplete_query_complete(move(suggestions));
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ private:
|
||||
void invoke_autocomplete_query_complete(Vector<String> suggestions) const;
|
||||
|
||||
String m_query;
|
||||
Vector<String> m_history_suggestions;
|
||||
RefPtr<Requests::Request> m_request;
|
||||
};
|
||||
|
||||
|
||||
20
Libraries/LibWebView/HistoryDebug.h
Normal file
20
Libraries/LibWebView/HistoryDebug.h
Normal file
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (c) 2026-present, the Ladybird developers.
|
||||
*
|
||||
* SPDX-License-Identifier: BSD-2-Clause
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <AK/ByteString.h>
|
||||
#include <AK/String.h>
|
||||
#include <AK/Vector.h>
|
||||
|
||||
namespace WebView {
|
||||
|
||||
inline ByteString history_log_suggestions(Vector<String> const& suggestions)
|
||||
{
|
||||
return ByteString::formatted("[{}]", ByteString::join(", "sv, suggestions));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,43 +6,99 @@
|
||||
|
||||
#include <AK/Debug.h>
|
||||
#include <AK/QuickSort.h>
|
||||
#include <AK/Utf8View.h>
|
||||
#include <LibDatabase/Database.h>
|
||||
#include <LibURL/URL.h>
|
||||
#include <LibWebView/HistoryDebug.h>
|
||||
#include <LibWebView/HistoryStore.h>
|
||||
|
||||
namespace WebView {
|
||||
|
||||
static constexpr auto DEFAULT_AUTOCOMPLETE_SUGGESTION_LIMIT = 8uz;
|
||||
static constexpr size_t MINIMUM_TITLE_AUTOCOMPLETE_QUERY_LENGTH = 3;
|
||||
|
||||
static bool matches_query(HistoryEntry const& entry, StringView query)
|
||||
static Optional<StringView> url_without_scheme(StringView url)
|
||||
{
|
||||
if (entry.url.contains(query, CaseSensitivity::CaseInsensitive))
|
||||
auto scheme_separator = url.find("://"sv);
|
||||
if (!scheme_separator.has_value())
|
||||
return {};
|
||||
|
||||
return url.substring_view(*scheme_separator + 3);
|
||||
}
|
||||
|
||||
static StringView autocomplete_searchable_url(StringView url)
|
||||
{
|
||||
auto stripped_url = url_without_scheme(url).value_or(url);
|
||||
if (stripped_url.starts_with("www."sv, CaseSensitivity::CaseInsensitive))
|
||||
stripped_url = stripped_url.substring_view(4);
|
||||
|
||||
return stripped_url;
|
||||
}
|
||||
|
||||
static StringView autocomplete_url_query(StringView query)
|
||||
{
|
||||
auto stripped_query = url_without_scheme(query).value_or(query);
|
||||
if (stripped_query.starts_with("www."sv, CaseSensitivity::CaseInsensitive))
|
||||
stripped_query = stripped_query.substring_view(4);
|
||||
|
||||
return stripped_query;
|
||||
}
|
||||
|
||||
static StringView autocomplete_title_query(StringView query)
|
||||
{
|
||||
if (Utf8View { query }.length() < MINIMUM_TITLE_AUTOCOMPLETE_QUERY_LENGTH)
|
||||
return {};
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
static StringView autocomplete_url_contains_query(StringView query)
|
||||
{
|
||||
// Non-prefix URL matches get noisy very quickly, so only enable them
|
||||
// once the user has typed enough to disambiguate path fragments.
|
||||
if (Utf8View { query }.length() < MINIMUM_TITLE_AUTOCOMPLETE_QUERY_LENGTH)
|
||||
return {};
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
static bool matches_query(HistoryEntry const& entry, StringView title_query, StringView url_query)
|
||||
{
|
||||
auto searchable_url = autocomplete_searchable_url(entry.url.bytes_as_string_view());
|
||||
if (!url_query.is_empty() && searchable_url.starts_with(url_query, CaseSensitivity::CaseInsensitive))
|
||||
return true;
|
||||
|
||||
return entry.title.has_value()
|
||||
&& entry.title->contains(query, CaseSensitivity::CaseInsensitive);
|
||||
auto url_contains_query = autocomplete_url_contains_query(url_query);
|
||||
if (!url_contains_query.is_empty() && searchable_url.contains(url_contains_query, CaseSensitivity::CaseInsensitive))
|
||||
return true;
|
||||
|
||||
return !title_query.is_empty()
|
||||
&& entry.title.has_value()
|
||||
&& entry.title->contains(title_query, CaseSensitivity::CaseInsensitive);
|
||||
}
|
||||
|
||||
static u8 match_rank(HistoryEntry const& entry, StringView query)
|
||||
static u8 match_rank(HistoryEntry const& entry, StringView title_query, StringView url_query)
|
||||
{
|
||||
auto url = entry.url.bytes_as_string_view();
|
||||
auto searchable_url = autocomplete_searchable_url(entry.url.bytes_as_string_view());
|
||||
|
||||
if (entry.url.equals_ignoring_ascii_case(query))
|
||||
return 0;
|
||||
if (url.starts_with(query, CaseSensitivity::CaseInsensitive))
|
||||
return 1;
|
||||
if (auto scheme_separator = url.find("://"sv); scheme_separator.has_value() && url.substring_view(*scheme_separator + 3).starts_with(query, CaseSensitivity::CaseInsensitive))
|
||||
if (!url_query.is_empty()) {
|
||||
if (searchable_url.equals_ignoring_ascii_case(url_query))
|
||||
return 0;
|
||||
if (searchable_url.starts_with(url_query, CaseSensitivity::CaseInsensitive))
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!title_query.is_empty() && entry.title.has_value() && entry.title->starts_with_bytes(title_query, CaseSensitivity::CaseInsensitive))
|
||||
return 2;
|
||||
if (entry.title.has_value() && entry.title->starts_with_bytes(query, CaseSensitivity::CaseInsensitive))
|
||||
return 3;
|
||||
return 4;
|
||||
|
||||
return 3;
|
||||
}
|
||||
|
||||
static void sort_matching_entries(Vector<HistoryEntry const*>& matches, StringView query)
|
||||
static void sort_matching_entries(Vector<HistoryEntry const*>& matches, StringView title_query, StringView url_query)
|
||||
{
|
||||
quick_sort(matches, [&](auto const* left, auto const* right) {
|
||||
auto left_rank = match_rank(*left, query);
|
||||
auto right_rank = match_rank(*right, query);
|
||||
auto left_rank = match_rank(*left, title_query, url_query);
|
||||
auto right_rank = match_rank(*right, title_query, url_query);
|
||||
if (left_rank != right_rank)
|
||||
return left_rank < right_rank;
|
||||
|
||||
@@ -58,6 +114,11 @@ static void sort_matching_entries(Vector<HistoryEntry const*>& matches, StringVi
|
||||
|
||||
ErrorOr<NonnullOwnPtr<HistoryStore>> HistoryStore::create(Database::Database& database)
|
||||
{
|
||||
if (auto database_path = database.database_path(); database_path.has_value())
|
||||
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Opening persisted history store at {}", database_path->string());
|
||||
else
|
||||
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Opening memory-backed persisted history store");
|
||||
|
||||
Statements statements {};
|
||||
|
||||
auto create_history_table = TRY(database.prepare_statement(R"#(
|
||||
@@ -99,16 +160,37 @@ ErrorOr<NonnullOwnPtr<HistoryStore>> HistoryStore::create(Database::Database& da
|
||||
)#"sv));
|
||||
statements.search_entries = TRY(database.prepare_statement(R"#(
|
||||
SELECT url
|
||||
FROM History
|
||||
WHERE INSTR(LOWER(url), LOWER(?)) > 0
|
||||
OR INSTR(LOWER(title), LOWER(?)) > 0
|
||||
FROM (
|
||||
SELECT
|
||||
url,
|
||||
title,
|
||||
visit_count,
|
||||
last_visited_time,
|
||||
CASE
|
||||
WHEN LOWER(CASE
|
||||
WHEN INSTR(url, '://') > 0 THEN SUBSTR(url, INSTR(url, '://') + 3)
|
||||
ELSE url
|
||||
END) LIKE 'www.%'
|
||||
THEN SUBSTR(CASE
|
||||
WHEN INSTR(url, '://') > 0 THEN SUBSTR(url, INSTR(url, '://') + 3)
|
||||
ELSE url
|
||||
END, 5)
|
||||
ELSE CASE
|
||||
WHEN INSTR(url, '://') > 0 THEN SUBSTR(url, INSTR(url, '://') + 3)
|
||||
ELSE url
|
||||
END
|
||||
END AS searchable_url
|
||||
FROM History
|
||||
)
|
||||
WHERE ((? != '' AND LOWER(searchable_url) LIKE LOWER(?) || '%')
|
||||
OR (? != '' AND INSTR(LOWER(searchable_url), LOWER(?)) > 0)
|
||||
OR (? != '' AND INSTR(LOWER(title), LOWER(?)) > 0))
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN LOWER(url) = LOWER(?) THEN 0
|
||||
WHEN LOWER(url) LIKE LOWER(?) || '%' THEN 1
|
||||
WHEN INSTR(LOWER(url), '://' || LOWER(?)) > 0 THEN 2
|
||||
WHEN LOWER(title) LIKE LOWER(?) || '%' THEN 3
|
||||
ELSE 4
|
||||
WHEN ? != '' AND LOWER(searchable_url) = LOWER(?) THEN 0
|
||||
WHEN ? != '' AND LOWER(searchable_url) LIKE LOWER(?) || '%' THEN 1
|
||||
WHEN ? != '' AND LOWER(title) LIKE LOWER(?) || '%' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
visit_count DESC,
|
||||
last_visited_time DESC,
|
||||
@@ -123,6 +205,8 @@ ErrorOr<NonnullOwnPtr<HistoryStore>> HistoryStore::create(Database::Database& da
|
||||
|
||||
NonnullOwnPtr<HistoryStore> HistoryStore::create()
|
||||
{
|
||||
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Opening transient history store");
|
||||
|
||||
return adopt_own(*new HistoryStore { OptionalNone {} });
|
||||
}
|
||||
|
||||
@@ -143,15 +227,21 @@ HistoryStore::~HistoryStore() = default;
|
||||
|
||||
Optional<String> HistoryStore::normalize_url(URL::URL const& url)
|
||||
{
|
||||
if (url.scheme().is_empty())
|
||||
if (url.scheme().is_empty()) {
|
||||
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Skipping history entry without a scheme: {}", url);
|
||||
return {};
|
||||
}
|
||||
|
||||
if (url.scheme().is_one_of("about"sv, "data"sv))
|
||||
if (url.scheme().is_one_of("about"sv, "data"sv)) {
|
||||
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Skipping non-browsable history URL: {}", url);
|
||||
return {};
|
||||
}
|
||||
|
||||
auto normalized_url = url.serialize(URL::ExcludeFragment::Yes);
|
||||
if (normalized_url.is_empty())
|
||||
if (normalized_url.is_empty()) {
|
||||
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Skipping history entry with an empty normalized URL: {}", url);
|
||||
return {};
|
||||
}
|
||||
|
||||
return normalized_url;
|
||||
}
|
||||
@@ -165,6 +255,12 @@ void HistoryStore::record_visit(URL::URL const& url, Optional<String> title, Uni
|
||||
if (!normalized_url.has_value())
|
||||
return;
|
||||
|
||||
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Recording visit in {} store: url='{}' title='{}' visited_at={}",
|
||||
m_persisted_storage.has_value() ? "SQL"sv : "transient"sv,
|
||||
*normalized_url,
|
||||
title.has_value() ? title->bytes_as_string_view() : "<none>"sv,
|
||||
visited_at.seconds_since_epoch());
|
||||
|
||||
if (m_persisted_storage.has_value())
|
||||
m_persisted_storage->record_visit(*normalized_url, title, visited_at);
|
||||
else
|
||||
@@ -176,13 +272,20 @@ void HistoryStore::update_title(URL::URL const& url, String const& title)
|
||||
if (m_is_disabled)
|
||||
return;
|
||||
|
||||
if (title.is_empty())
|
||||
if (title.is_empty()) {
|
||||
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Ignoring empty history title update for {}", url);
|
||||
return;
|
||||
}
|
||||
|
||||
auto normalized_url = normalize_url(url);
|
||||
if (!normalized_url.has_value())
|
||||
return;
|
||||
|
||||
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Updating history title in {} store: url='{}' title='{}'",
|
||||
m_persisted_storage.has_value() ? "SQL"sv : "transient"sv,
|
||||
*normalized_url,
|
||||
title);
|
||||
|
||||
if (m_persisted_storage.has_value())
|
||||
m_persisted_storage->update_title(*normalized_url, title);
|
||||
else
|
||||
@@ -198,9 +301,21 @@ Optional<HistoryEntry> HistoryStore::entry_for_url(URL::URL const& url)
|
||||
if (!normalized_url.has_value())
|
||||
return {};
|
||||
|
||||
if (m_persisted_storage.has_value())
|
||||
return m_persisted_storage->entry_for_url(*normalized_url);
|
||||
return m_transient_storage.entry_for_url(*normalized_url);
|
||||
auto entry = m_persisted_storage.has_value()
|
||||
? m_persisted_storage->entry_for_url(*normalized_url)
|
||||
: m_transient_storage.entry_for_url(*normalized_url);
|
||||
|
||||
if (entry.has_value()) {
|
||||
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Found history entry for '{}': title='{}' visits={} last_visited={}",
|
||||
entry->url,
|
||||
entry->title.has_value() ? entry->title->bytes_as_string_view() : "<none>"sv,
|
||||
entry->visit_count,
|
||||
entry->last_visited_time.seconds_since_epoch());
|
||||
} else {
|
||||
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] No history entry found for '{}'", *normalized_url);
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
Vector<String> HistoryStore::autocomplete_suggestions(StringView query, size_t limit)
|
||||
@@ -209,18 +324,35 @@ Vector<String> HistoryStore::autocomplete_suggestions(StringView query, size_t l
|
||||
return {};
|
||||
|
||||
auto trimmed_query = query.trim_whitespace();
|
||||
if (trimmed_query.is_empty())
|
||||
if (trimmed_query.is_empty()) {
|
||||
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] History autocomplete query is empty after trimming");
|
||||
return {};
|
||||
}
|
||||
|
||||
if (m_persisted_storage.has_value())
|
||||
return m_persisted_storage->autocomplete_suggestions(trimmed_query, limit);
|
||||
return m_transient_storage.autocomplete_suggestions(trimmed_query, limit);
|
||||
auto title_query = autocomplete_title_query(trimmed_query);
|
||||
auto url_query = autocomplete_url_query(trimmed_query);
|
||||
|
||||
auto suggestions = m_persisted_storage.has_value()
|
||||
? m_persisted_storage->autocomplete_suggestions(title_query, url_query, limit)
|
||||
: m_transient_storage.autocomplete_suggestions(title_query, url_query, limit);
|
||||
|
||||
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] {} history autocomplete suggestions for '{}' (title_query='{}', url_query='{}', limit={}): {}",
|
||||
m_persisted_storage.has_value() ? "SQL"sv : "Transient"sv,
|
||||
trimmed_query,
|
||||
title_query,
|
||||
url_query,
|
||||
limit,
|
||||
history_log_suggestions(suggestions));
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
void HistoryStore::clear()
|
||||
{
|
||||
if (m_is_disabled)
|
||||
return;
|
||||
|
||||
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Clearing {} history store", m_persisted_storage.has_value() ? "SQL"sv : "transient"sv);
|
||||
if (m_persisted_storage.has_value())
|
||||
m_persisted_storage->clear();
|
||||
else
|
||||
@@ -231,6 +363,10 @@ void HistoryStore::remove_entries_accessed_since(UnixDateTime since)
|
||||
{
|
||||
if (m_is_disabled)
|
||||
return;
|
||||
|
||||
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Removing {} history entries accessed since {}",
|
||||
m_persisted_storage.has_value() ? "SQL"sv : "transient"sv,
|
||||
since.seconds_since_epoch());
|
||||
if (m_persisted_storage.has_value())
|
||||
m_persisted_storage->remove_entries_accessed_since(since);
|
||||
else
|
||||
@@ -277,16 +413,16 @@ Optional<HistoryEntry> HistoryStore::TransientStorage::entry_for_url(String cons
|
||||
return *entry;
|
||||
}
|
||||
|
||||
Vector<String> HistoryStore::TransientStorage::autocomplete_suggestions(StringView query, size_t limit)
|
||||
Vector<String> HistoryStore::TransientStorage::autocomplete_suggestions(StringView title_query, StringView url_query, size_t limit)
|
||||
{
|
||||
Vector<HistoryEntry const*> matches;
|
||||
|
||||
for (auto const& entry : m_entries) {
|
||||
if (matches_query(entry.value, query))
|
||||
if (matches_query(entry.value, title_query, url_query))
|
||||
matches.append(&entry.value);
|
||||
}
|
||||
|
||||
sort_matching_entries(matches, query);
|
||||
sort_matching_entries(matches, title_query, url_query);
|
||||
|
||||
Vector<String> suggestions;
|
||||
suggestions.ensure_capacity(min(limit, matches.size()));
|
||||
@@ -349,22 +485,31 @@ Optional<HistoryEntry> HistoryStore::PersistedStorage::entry_for_url(String cons
|
||||
return entry;
|
||||
}
|
||||
|
||||
Vector<String> HistoryStore::PersistedStorage::autocomplete_suggestions(StringView query, size_t limit)
|
||||
Vector<String> HistoryStore::PersistedStorage::autocomplete_suggestions(StringView title_query, StringView url_query, size_t limit)
|
||||
{
|
||||
Vector<String> suggestions;
|
||||
suggestions.ensure_capacity(min(limit, DEFAULT_AUTOCOMPLETE_SUGGESTION_LIMIT));
|
||||
auto url_query_string = MUST(String::from_utf8(url_query));
|
||||
auto title_query_string = MUST(String::from_utf8(title_query));
|
||||
auto url_contains_query_string = MUST(String::from_utf8(autocomplete_url_contains_query(url_query)));
|
||||
|
||||
database.execute_statement(
|
||||
statements.search_entries,
|
||||
[&](auto statement_id) {
|
||||
suggestions.append(database.result_column<String>(statement_id, 0));
|
||||
},
|
||||
MUST(String::from_utf8(query)),
|
||||
MUST(String::from_utf8(query)),
|
||||
MUST(String::from_utf8(query)),
|
||||
MUST(String::from_utf8(query)),
|
||||
MUST(String::from_utf8(query)),
|
||||
MUST(String::from_utf8(query)),
|
||||
url_query_string,
|
||||
url_query_string,
|
||||
url_contains_query_string,
|
||||
url_contains_query_string,
|
||||
title_query_string,
|
||||
title_query_string,
|
||||
url_query_string,
|
||||
url_query_string,
|
||||
url_query_string,
|
||||
url_query_string,
|
||||
title_query_string,
|
||||
title_query_string,
|
||||
static_cast<i64>(limit));
|
||||
|
||||
return suggestions;
|
||||
|
||||
@@ -60,7 +60,7 @@ private:
|
||||
void update_title(String const& url, String title);
|
||||
|
||||
Optional<HistoryEntry> entry_for_url(String const& url);
|
||||
Vector<String> autocomplete_suggestions(StringView query, size_t limit);
|
||||
Vector<String> autocomplete_suggestions(StringView title_query, StringView url_query, size_t limit);
|
||||
|
||||
void clear();
|
||||
void remove_entries_accessed_since(UnixDateTime since);
|
||||
@@ -74,7 +74,7 @@ private:
|
||||
void update_title(String const& url, String const& title);
|
||||
|
||||
Optional<HistoryEntry> entry_for_url(String const& url);
|
||||
Vector<String> autocomplete_suggestions(StringView query, size_t limit);
|
||||
Vector<String> autocomplete_suggestions(StringView title_query, StringView url_query, size_t limit);
|
||||
|
||||
void clear();
|
||||
void remove_entries_accessed_since(UnixDateTime since);
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
* SPDX-License-Identifier: BSD-2-Clause
|
||||
*/
|
||||
|
||||
#include <AK/Debug.h>
|
||||
#include <LibHTTP/Cookie/ParsedCookie.h>
|
||||
#include <LibIPC/TransportHandle.h>
|
||||
#include <LibWebView/Application.h>
|
||||
#include <LibWebView/CookieJar.h>
|
||||
#include <LibWebView/HelperProcess.h>
|
||||
#include <LibWebView/HistoryStore.h>
|
||||
#include <LibWebView/SourceHighlighter.h>
|
||||
#include <LibWebView/ViewImplementation.h>
|
||||
#include <LibWebView/WebContentClient.h>
|
||||
@@ -18,6 +20,18 @@ namespace WebView {
|
||||
|
||||
HashTable<WebContentClient*> WebContentClient::s_clients;
|
||||
|
||||
static Optional<String> history_title(Utf16String const& title, URL::URL const& url)
|
||||
{
|
||||
if (title.is_empty())
|
||||
return {};
|
||||
|
||||
auto title_utf8 = title.to_utf8();
|
||||
if (title_utf8 == url.serialize() || title_utf8 == url.serialize(URL::ExcludeFragment::Yes))
|
||||
return {};
|
||||
|
||||
return title_utf8;
|
||||
}
|
||||
|
||||
WebContentClient::WebContentClient(NonnullOwnPtr<IPC::Transport> transport, ViewImplementation& view)
|
||||
: IPC::ConnectionToServer<WebContentClientEndpoint, WebContentServerEndpoint>(*this, move(transport))
|
||||
{
|
||||
@@ -128,6 +142,14 @@ void WebContentClient::did_finish_loading(u64 page_id, URL::URL url)
|
||||
|
||||
if (auto view = view_for_page_id(page_id); view.has_value()) {
|
||||
view->set_url({}, url);
|
||||
auto title = history_title(view->title(), url);
|
||||
|
||||
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Load finished for page {} at '{}' with title '{}'",
|
||||
page_id,
|
||||
url,
|
||||
title.has_value() ? title->bytes_as_string_view() : "<none>"sv);
|
||||
|
||||
Application::history_store().record_visit(url, move(title));
|
||||
|
||||
if (view->on_load_finish)
|
||||
view->on_load_finish(url);
|
||||
@@ -205,6 +227,17 @@ void WebContentClient::did_change_title(u64 page_id, Utf16String title)
|
||||
process->set_title(title);
|
||||
|
||||
if (auto view = view_for_page_id(page_id); view.has_value()) {
|
||||
if (!title.is_empty()) {
|
||||
auto title_utf8 = title.to_utf8();
|
||||
|
||||
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Title changed for page {} at '{}' to '{}'",
|
||||
page_id,
|
||||
view->url(),
|
||||
title_utf8);
|
||||
|
||||
Application::history_store().update_title(view->url(), title_utf8);
|
||||
}
|
||||
|
||||
if (title.is_empty())
|
||||
title = Utf16String::from_utf8(view->url().serialize());
|
||||
|
||||
|
||||
@@ -21,6 +21,54 @@ static URL::URL parse_url(StringView url)
|
||||
return parsed_url.release_value();
|
||||
}
|
||||
|
||||
static void populate_history_for_url_autocomplete_tests(WebView::HistoryStore& store)
|
||||
{
|
||||
store.record_visit(parse_url("https://www.google.com/"sv), {}, UnixDateTime::from_seconds_since_epoch(30));
|
||||
store.record_visit(parse_url("https://x.com/"sv), {}, UnixDateTime::from_seconds_since_epoch(20));
|
||||
store.record_visit(parse_url("https://github.com/LadybirdBrowser/ladybird"sv), {}, UnixDateTime::from_seconds_since_epoch(10));
|
||||
}
|
||||
|
||||
static void expect_history_autocomplete_ignores_url_boilerplate(WebView::HistoryStore& store)
|
||||
{
|
||||
populate_history_for_url_autocomplete_tests(store);
|
||||
|
||||
EXPECT(store.autocomplete_suggestions("https://"sv, 8).is_empty());
|
||||
EXPECT(store.autocomplete_suggestions("https://www."sv, 8).is_empty());
|
||||
EXPECT(store.autocomplete_suggestions("www."sv, 8).is_empty());
|
||||
|
||||
auto git_suggestions = store.autocomplete_suggestions("git"sv, 8);
|
||||
VERIFY(git_suggestions.size() == 1);
|
||||
EXPECT_EQ(git_suggestions[0], "https://github.com/LadybirdBrowser/ladybird"_string);
|
||||
|
||||
auto https_goo_suggestions = store.autocomplete_suggestions("https://goo"sv, 8);
|
||||
VERIFY(https_goo_suggestions.size() == 1);
|
||||
EXPECT_EQ(https_goo_suggestions[0], "https://www.google.com/"_string);
|
||||
}
|
||||
|
||||
static void expect_history_autocomplete_requires_three_characters_for_title_matches(WebView::HistoryStore& store)
|
||||
{
|
||||
store.record_visit(parse_url("https://example.com/"sv), "Foo bar baz wip wap wop"_string, UnixDateTime::from_seconds_since_epoch(10));
|
||||
|
||||
EXPECT(store.autocomplete_suggestions("w"sv, 8).is_empty());
|
||||
EXPECT(store.autocomplete_suggestions("wi"sv, 8).is_empty());
|
||||
|
||||
auto suggestions = store.autocomplete_suggestions("wip"sv, 8);
|
||||
VERIFY(suggestions.size() == 1);
|
||||
EXPECT_EQ(suggestions[0], "https://example.com/"_string);
|
||||
}
|
||||
|
||||
static void expect_history_autocomplete_requires_three_characters_for_non_prefix_url_matches(WebView::HistoryStore& store)
|
||||
{
|
||||
store.record_visit(parse_url("https://example.com/wip-path"sv), "Example"_string, UnixDateTime::from_seconds_since_epoch(10));
|
||||
|
||||
EXPECT(store.autocomplete_suggestions("w"sv, 8).is_empty());
|
||||
EXPECT(store.autocomplete_suggestions("wi"sv, 8).is_empty());
|
||||
|
||||
auto suggestions = store.autocomplete_suggestions("wip"sv, 8);
|
||||
VERIFY(suggestions.size() == 1);
|
||||
EXPECT_EQ(suggestions[0], "https://example.com/wip-path"_string);
|
||||
}
|
||||
|
||||
TEST_CASE(record_and_lookup_history_entries)
|
||||
{
|
||||
auto store = WebView::HistoryStore::create();
|
||||
@@ -54,6 +102,49 @@ TEST_CASE(history_autocomplete_prefers_url_prefix_then_recency)
|
||||
EXPECT_EQ(suggestions[2], "https://beta.example.com/"_string);
|
||||
}
|
||||
|
||||
TEST_CASE(history_autocomplete_trims_whitespace)
|
||||
{
|
||||
auto store = WebView::HistoryStore::create();
|
||||
|
||||
store->record_visit(parse_url("https://ladybird.dev/"sv), "Ladybird"_string, UnixDateTime::from_seconds_since_epoch(10));
|
||||
|
||||
auto suggestions = store->autocomplete_suggestions(" ladybird "sv, 8);
|
||||
|
||||
VERIFY(suggestions.size() == 1);
|
||||
EXPECT_EQ(suggestions[0], "https://ladybird.dev/"_string);
|
||||
}
|
||||
|
||||
TEST_CASE(history_autocomplete_ignores_www_prefix_for_host_matches)
|
||||
{
|
||||
auto store = WebView::HistoryStore::create();
|
||||
|
||||
store->record_visit(parse_url("https://www.google.com/"sv), "Google"_string, UnixDateTime::from_seconds_since_epoch(20));
|
||||
store->record_visit(parse_url("https://www.goodreads.com/"sv), "Goodreads"_string, UnixDateTime::from_seconds_since_epoch(10));
|
||||
|
||||
auto suggestions = store->autocomplete_suggestions("goo"sv, 8);
|
||||
|
||||
VERIFY(suggestions.size() == 2);
|
||||
EXPECT_EQ(suggestions[0], "https://www.google.com/"_string);
|
||||
EXPECT_EQ(suggestions[1], "https://www.goodreads.com/"_string);
|
||||
}
|
||||
|
||||
TEST_CASE(history_autocomplete_ignores_scheme_and_www_boilerplate_prefixes)
|
||||
{
|
||||
auto store = WebView::HistoryStore::create();
|
||||
expect_history_autocomplete_ignores_url_boilerplate(*store);
|
||||
}
|
||||
|
||||
TEST_CASE(history_autocomplete_requires_three_characters_for_title_matches)
|
||||
{
|
||||
auto store = WebView::HistoryStore::create();
|
||||
expect_history_autocomplete_requires_three_characters_for_title_matches(*store);
|
||||
}
|
||||
|
||||
TEST_CASE(history_autocomplete_requires_three_characters_for_non_prefix_url_matches)
|
||||
{
|
||||
auto store = WebView::HistoryStore::create();
|
||||
expect_history_autocomplete_requires_three_characters_for_non_prefix_url_matches(*store);
|
||||
}
|
||||
TEST_CASE(non_browsable_urls_are_not_recorded)
|
||||
{
|
||||
auto store = WebView::HistoryStore::create();
|
||||
@@ -108,3 +199,58 @@ TEST_CASE(persisted_history_survives_reopen)
|
||||
EXPECT_EQ(entry->last_visited_time, UnixDateTime::from_seconds_since_epoch(77));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE(persisted_history_autocomplete_ignores_scheme_and_www_boilerplate_prefixes)
|
||||
{
|
||||
auto database_directory = ByteString::formatted(
|
||||
"{}/ladybird-history-store-autocomplete-test-{}",
|
||||
Core::StandardPaths::tempfile_directory(),
|
||||
generate_random_uuid());
|
||||
TRY_OR_FAIL(Core::Directory::create(database_directory, Core::Directory::CreateDirectories::Yes));
|
||||
|
||||
auto cleanup = ScopeGuard([&] {
|
||||
MUST(FileSystem::remove(database_directory, FileSystem::RecursionMode::Allowed));
|
||||
});
|
||||
|
||||
auto database = TRY_OR_FAIL(Database::Database::create(database_directory, "HistoryStore"sv));
|
||||
auto store = TRY_OR_FAIL(WebView::HistoryStore::create(*database));
|
||||
|
||||
expect_history_autocomplete_ignores_url_boilerplate(*store);
|
||||
}
|
||||
|
||||
TEST_CASE(persisted_history_autocomplete_requires_three_characters_for_title_matches)
|
||||
{
|
||||
auto database_directory = ByteString::formatted(
|
||||
"{}/ladybird-history-store-title-autocomplete-test-{}",
|
||||
Core::StandardPaths::tempfile_directory(),
|
||||
generate_random_uuid());
|
||||
TRY_OR_FAIL(Core::Directory::create(database_directory, Core::Directory::CreateDirectories::Yes));
|
||||
|
||||
auto cleanup = ScopeGuard([&] {
|
||||
MUST(FileSystem::remove(database_directory, FileSystem::RecursionMode::Allowed));
|
||||
});
|
||||
|
||||
auto database = TRY_OR_FAIL(Database::Database::create(database_directory, "HistoryStore"sv));
|
||||
auto store = TRY_OR_FAIL(WebView::HistoryStore::create(*database));
|
||||
|
||||
expect_history_autocomplete_requires_three_characters_for_title_matches(*store);
|
||||
}
|
||||
|
||||
TEST_CASE(persisted_history_autocomplete_requires_three_characters_for_non_prefix_url_matches)
|
||||
{
|
||||
auto database_directory = ByteString::formatted(
|
||||
"{}/ladybird-history-store-url-autocomplete-test-{}",
|
||||
Core::StandardPaths::tempfile_directory(),
|
||||
generate_random_uuid());
|
||||
TRY_OR_FAIL(Core::Directory::create(database_directory, Core::Directory::CreateDirectories::Yes));
|
||||
|
||||
auto cleanup = ScopeGuard([&] {
|
||||
MUST(FileSystem::remove(database_directory, FileSystem::RecursionMode::Allowed));
|
||||
});
|
||||
|
||||
auto database = TRY_OR_FAIL(Database::Database::create(database_directory, "HistoryStore"sv));
|
||||
auto store = TRY_OR_FAIL(WebView::HistoryStore::create(*database));
|
||||
|
||||
expect_history_autocomplete_requires_three_characters_for_non_prefix_url_matches(*store);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,9 +231,7 @@
|
||||
|
||||
- (void)clearHistory:(id)sender
|
||||
{
|
||||
for (TabController* controller in self.managed_tabs) {
|
||||
[controller clearHistory];
|
||||
}
|
||||
WebView::Application::the().clear_history();
|
||||
}
|
||||
|
||||
- (NSMenuItem*)createApplicationMenu
|
||||
|
||||
@@ -28,8 +28,6 @@
|
||||
- (void)onEnterFullscreenWindow;
|
||||
- (void)onExitFullscreenWindow;
|
||||
|
||||
- (void)clearHistory;
|
||||
|
||||
- (void)focusLocationToolbarItem;
|
||||
|
||||
@end
|
||||
|
||||
@@ -196,11 +196,6 @@ static NSString* const TOOLBAR_TAB_OVERVIEW_IDENTIFIER = @"ToolbarTabOverviewIde
|
||||
}
|
||||
}
|
||||
|
||||
- (void)clearHistory
|
||||
{
|
||||
// FIXME: Reimplement clearing history using WebContent's history.
|
||||
}
|
||||
|
||||
- (void)focusLocationToolbarItem
|
||||
{
|
||||
[self.window makeFirstResponder:self.location_toolbar_item.view];
|
||||
|
||||
Reference in New Issue
Block a user