From 54f14609f4ea07cc727fca42828c41def52b9234 Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Sun, 12 Apr 2026 00:30:35 +0200 Subject: [PATCH] LibWebView: Add a SQL history store Add a HistoryStore abstraction with transient and persisted backends, normalize recorded URLs, and skip non-browsable schemes. Cover lookup and persistence in TestHistoryStore so history-driven features can share one backend. --- AK/Debug.h.in | 4 + Libraries/LibWebView/CMakeLists.txt | 1 + Libraries/LibWebView/Forward.h | 1 + Libraries/LibWebView/HistoryStore.cpp | 383 ++++++++++++++++++++++++++ Libraries/LibWebView/HistoryStore.h | 94 +++++++ Meta/CMake/all_the_debug_macros.cmake | 1 + Tests/LibWebView/CMakeLists.txt | 3 +- Tests/LibWebView/TestHistoryStore.cpp | 110 ++++++++ 8 files changed, 596 insertions(+), 1 deletion(-) create mode 100644 Libraries/LibWebView/HistoryStore.cpp create mode 100644 Libraries/LibWebView/HistoryStore.h create mode 100644 Tests/LibWebView/TestHistoryStore.cpp diff --git a/AK/Debug.h.in b/AK/Debug.h.in index e8c83f10868..a734278ca4a 100644 --- a/AK/Debug.h.in +++ b/AK/Debug.h.in @@ -178,6 +178,10 @@ # cmakedefine01 LINE_EDITOR_DEBUG #endif +#ifndef WEBVIEW_HISTORY_DEBUG +# cmakedefine01 WEBVIEW_HISTORY_DEBUG +#endif + #ifndef LZW_DEBUG # cmakedefine01 LZW_DEBUG #endif diff --git a/Libraries/LibWebView/CMakeLists.txt b/Libraries/LibWebView/CMakeLists.txt index e3630f39a2b..82cd93591e8 100644 --- a/Libraries/LibWebView/CMakeLists.txt +++ b/Libraries/LibWebView/CMakeLists.txt @@ -11,6 +11,7 @@ set(SOURCES DOMNodeProperties.cpp FileDownloader.cpp HeadlessWebView.cpp + HistoryStore.cpp HelperProcess.cpp Menu.cpp Mutation.cpp diff --git a/Libraries/LibWebView/Forward.h b/Libraries/LibWebView/Forward.h index af64f7b7b16..0a6c391ab86 100644 --- a/Libraries/LibWebView/Forward.h +++ b/Libraries/LibWebView/Forward.h @@ -17,6 +17,7 @@ class Application; class Autocomplete; class BookmarkStore; class CookieJar; +class HistoryStore; class Menu; class OutOfProcessWebView; class ProcessManager; diff --git a/Libraries/LibWebView/HistoryStore.cpp b/Libraries/LibWebView/HistoryStore.cpp new file mode 100644 index 00000000000..e85bd901ae9 --- /dev/null +++ b/Libraries/LibWebView/HistoryStore.cpp @@ -0,0 +1,383 @@ +/* + * Copyright (c) 2026-present, the Ladybird developers. + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include +#include + +namespace WebView { + +static constexpr auto DEFAULT_AUTOCOMPLETE_SUGGESTION_LIMIT = 8uz; + +static bool matches_query(HistoryEntry const& entry, StringView query) +{ + if (entry.url.contains(query, CaseSensitivity::CaseInsensitive)) + return true; + + return entry.title.has_value() + && entry.title->contains(query, CaseSensitivity::CaseInsensitive); +} + +static u8 match_rank(HistoryEntry const& entry, StringView query) +{ + auto 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)) + return 2; + if (entry.title.has_value() && entry.title->starts_with_bytes(query, CaseSensitivity::CaseInsensitive)) + return 3; + return 4; +} + +static void sort_matching_entries(Vector& matches, StringView query) +{ + quick_sort(matches, [&](auto const* left, auto const* right) { + auto left_rank = match_rank(*left, query); + auto right_rank = match_rank(*right, query); + if (left_rank != right_rank) + return left_rank < right_rank; + + if (left->visit_count != right->visit_count) + return left->visit_count > right->visit_count; + + if (left->last_visited_time != right->last_visited_time) + return left->last_visited_time > right->last_visited_time; + + return left->url < right->url; + }); +} + +ErrorOr> HistoryStore::create(Database::Database& database) +{ + Statements statements {}; + + auto create_history_table = TRY(database.prepare_statement(R"#( + CREATE TABLE IF NOT EXISTS History ( + url TEXT PRIMARY KEY, + title TEXT NOT NULL, + visit_count INTEGER NOT NULL, + last_visited_time INTEGER NOT NULL + ); + )#"sv)); + database.execute_statement(create_history_table, {}); + + auto create_last_visited_index = TRY(database.prepare_statement(R"#( + CREATE INDEX IF NOT EXISTS HistoryLastVisitedTimeIndex + ON History(last_visited_time DESC); + )#"sv)); + database.execute_statement(create_last_visited_index, {}); + + statements.upsert_entry = TRY(database.prepare_statement(R"#( + INSERT INTO History (url, title, visit_count, last_visited_time) + VALUES (?, ?, 1, ?) + ON CONFLICT(url) DO UPDATE SET + title = CASE + WHEN excluded.title != '' THEN excluded.title + ELSE History.title + END, + visit_count = History.visit_count + 1, + last_visited_time = excluded.last_visited_time; + )#"sv)); + statements.update_title = TRY(database.prepare_statement(R"#( + UPDATE History + SET title = ? + WHERE url = ?; + )#"sv)); + statements.get_entry = TRY(database.prepare_statement(R"#( + SELECT title, visit_count, last_visited_time + FROM History + WHERE url = ?; + )#"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 + 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 + END, + visit_count DESC, + last_visited_time DESC, + url ASC + LIMIT ?; + )#"sv)); + statements.clear_entries = TRY(database.prepare_statement("DELETE FROM History;"sv)); + statements.delete_entries_accessed_since = TRY(database.prepare_statement("DELETE FROM History WHERE last_visited_time >= ?;"sv)); + + return adopt_own(*new HistoryStore { PersistedStorage { database, statements } }); +} + +NonnullOwnPtr HistoryStore::create() +{ + return adopt_own(*new HistoryStore { OptionalNone {} }); +} + +NonnullOwnPtr HistoryStore::create_disabled() +{ + dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Opening disabled history store"); + + return adopt_own(*new HistoryStore { OptionalNone {}, true }); +} + +HistoryStore::HistoryStore(Optional persisted_storage, bool is_disabled) + : m_persisted_storage(move(persisted_storage)) + , m_is_disabled(is_disabled) +{ +} + +HistoryStore::~HistoryStore() = default; + +Optional HistoryStore::normalize_url(URL::URL const& url) +{ + if (url.scheme().is_empty()) + return {}; + + if (url.scheme().is_one_of("about"sv, "data"sv)) + return {}; + + auto normalized_url = url.serialize(URL::ExcludeFragment::Yes); + if (normalized_url.is_empty()) + return {}; + + return normalized_url; +} + +void HistoryStore::record_visit(URL::URL const& url, Optional title, UnixDateTime visited_at) +{ + if (m_is_disabled) + return; + + auto normalized_url = normalize_url(url); + if (!normalized_url.has_value()) + return; + + if (m_persisted_storage.has_value()) + m_persisted_storage->record_visit(*normalized_url, title, visited_at); + else + m_transient_storage.record_visit(normalized_url.release_value(), move(title), visited_at); +} + +void HistoryStore::update_title(URL::URL const& url, String const& title) +{ + if (m_is_disabled) + return; + + if (title.is_empty()) + return; + + auto normalized_url = normalize_url(url); + if (!normalized_url.has_value()) + return; + + if (m_persisted_storage.has_value()) + m_persisted_storage->update_title(*normalized_url, title); + else + m_transient_storage.update_title(*normalized_url, title); +} + +Optional HistoryStore::entry_for_url(URL::URL const& url) +{ + if (m_is_disabled) + return {}; + + auto normalized_url = normalize_url(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); +} + +Vector HistoryStore::autocomplete_suggestions(StringView query, size_t limit) +{ + if (m_is_disabled) + return {}; + + auto trimmed_query = query.trim_whitespace(); + if (trimmed_query.is_empty()) + 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); +} + +void HistoryStore::clear() +{ + if (m_is_disabled) + return; + if (m_persisted_storage.has_value()) + m_persisted_storage->clear(); + else + m_transient_storage.clear(); +} + +void HistoryStore::remove_entries_accessed_since(UnixDateTime since) +{ + if (m_is_disabled) + return; + if (m_persisted_storage.has_value()) + m_persisted_storage->remove_entries_accessed_since(since); + else + m_transient_storage.remove_entries_accessed_since(since); +} + +void HistoryStore::TransientStorage::record_visit(String url, Optional title, UnixDateTime visited_at) +{ + auto entry = m_entries.find(url); + if (entry == m_entries.end()) { + auto new_entry = HistoryEntry { + .url = url, + .title = move(title), + .visit_count = 1, + .last_visited_time = visited_at, + }; + m_entries.set( + move(url), + move(new_entry)); + return; + } + + entry->value.visit_count++; + entry->value.last_visited_time = visited_at; + if (title.has_value() && !title->is_empty()) + entry->value.title = move(title); +} + +void HistoryStore::TransientStorage::update_title(String const& url, String title) +{ + auto entry = m_entries.find(url); + if (entry == m_entries.end()) + return; + + entry->value.title = move(title); +} + +Optional HistoryStore::TransientStorage::entry_for_url(String const& url) +{ + auto entry = m_entries.get(url); + if (!entry.has_value()) + return {}; + + return *entry; +} + +Vector HistoryStore::TransientStorage::autocomplete_suggestions(StringView query, size_t limit) +{ + Vector matches; + + for (auto const& entry : m_entries) { + if (matches_query(entry.value, query)) + matches.append(&entry.value); + } + + sort_matching_entries(matches, query); + + Vector suggestions; + suggestions.ensure_capacity(min(limit, matches.size())); + + for (size_t i = 0; i < matches.size() && i < limit; ++i) + suggestions.unchecked_append(matches[i]->url); + + return suggestions; +} + +void HistoryStore::TransientStorage::clear() +{ + m_entries.clear(); +} + +void HistoryStore::TransientStorage::remove_entries_accessed_since(UnixDateTime since) +{ + m_entries.remove_all_matching([&](auto const&, auto const& entry) { + return entry.last_visited_time >= since; + }); +} + +void HistoryStore::PersistedStorage::record_visit(String const& url, Optional const& title, UnixDateTime visited_at) +{ + database.execute_statement( + statements.upsert_entry, + {}, + url, + title.value_or(String {}), + visited_at); +} + +void HistoryStore::PersistedStorage::update_title(String const& url, String const& title) +{ + database.execute_statement( + statements.update_title, + {}, + title, + url); +} + +Optional HistoryStore::PersistedStorage::entry_for_url(String const& url) +{ + Optional entry; + + database.execute_statement( + statements.get_entry, + [&](auto statement_id) { + auto title = database.result_column(statement_id, 0); + + entry = HistoryEntry { + .url = url, + .title = title.is_empty() ? Optional {} : Optional { move(title) }, + .visit_count = database.result_column(statement_id, 1), + .last_visited_time = database.result_column(statement_id, 2), + }; + }, + url); + + return entry; +} + +Vector HistoryStore::PersistedStorage::autocomplete_suggestions(StringView query, size_t limit) +{ + Vector suggestions; + suggestions.ensure_capacity(min(limit, DEFAULT_AUTOCOMPLETE_SUGGESTION_LIMIT)); + + database.execute_statement( + statements.search_entries, + [&](auto statement_id) { + suggestions.append(database.result_column(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)), + static_cast(limit)); + + return suggestions; +} + +void HistoryStore::PersistedStorage::clear() +{ + database.execute_statement(statements.clear_entries, {}); +} + +void HistoryStore::PersistedStorage::remove_entries_accessed_since(UnixDateTime since) +{ + database.execute_statement(statements.delete_entries_accessed_since, {}, since); +} + +} diff --git a/Libraries/LibWebView/HistoryStore.h b/Libraries/LibWebView/HistoryStore.h new file mode 100644 index 00000000000..70ce8914b4b --- /dev/null +++ b/Libraries/LibWebView/HistoryStore.h @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2026-present, the Ladybird developers. + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace WebView { + +struct WEBVIEW_API HistoryEntry { + String url; + Optional title; + u64 visit_count { 0 }; + UnixDateTime last_visited_time; +}; + +class WEBVIEW_API HistoryStore { + AK_MAKE_NONCOPYABLE(HistoryStore); + AK_MAKE_NONMOVABLE(HistoryStore); + +public: + static ErrorOr> create(Database::Database&); + static NonnullOwnPtr create(); + static NonnullOwnPtr create_disabled(); + + ~HistoryStore(); + + void record_visit(URL::URL const&, Optional title = {}, UnixDateTime visited_at = UnixDateTime::now()); + void update_title(URL::URL const&, String const& title); + + Optional entry_for_url(URL::URL const&); + Vector autocomplete_suggestions(StringView query, size_t limit = 8); + + void clear(); + void remove_entries_accessed_since(UnixDateTime since); + +private: + struct Statements { + Database::StatementID upsert_entry { 0 }; + Database::StatementID update_title { 0 }; + Database::StatementID get_entry { 0 }; + Database::StatementID search_entries { 0 }; + Database::StatementID clear_entries { 0 }; + Database::StatementID delete_entries_accessed_since { 0 }; + }; + + class TransientStorage { + public: + void record_visit(String url, Optional title, UnixDateTime visited_at); + void update_title(String const& url, String title); + + Optional entry_for_url(String const& url); + Vector autocomplete_suggestions(StringView query, size_t limit); + + void clear(); + void remove_entries_accessed_since(UnixDateTime since); + + private: + HashMap m_entries; + }; + + struct PersistedStorage { + void record_visit(String const& url, Optional const& title, UnixDateTime visited_at); + void update_title(String const& url, String const& title); + + Optional entry_for_url(String const& url); + Vector autocomplete_suggestions(StringView query, size_t limit); + + void clear(); + void remove_entries_accessed_since(UnixDateTime since); + + Database::Database& database; + Statements statements; + }; + + explicit HistoryStore(Optional, bool is_disabled = false); + static Optional normalize_url(URL::URL const&); + + Optional m_persisted_storage; + TransientStorage m_transient_storage; + bool m_is_disabled { false }; +}; + +} diff --git a/Meta/CMake/all_the_debug_macros.cmake b/Meta/CMake/all_the_debug_macros.cmake index beb7c7a0b75..419131a9668 100644 --- a/Meta/CMake/all_the_debug_macros.cmake +++ b/Meta/CMake/all_the_debug_macros.cmake @@ -36,6 +36,7 @@ set(JOB_DEBUG ON) set(JS_BYTECODE_DEBUG ON) set(JS_MODULE_DEBUG ON) set(LEXER_DEBUG ON) +set(WEBVIEW_HISTORY_DEBUG ON) set(LIBWEB_CSS_ANIMATION_DEBUG ON) set(LIBWEB_CSS_DEBUG ON) set(LIBWEB_WASM_DEBUG ON) diff --git a/Tests/LibWebView/CMakeLists.txt b/Tests/LibWebView/CMakeLists.txt index e9854fbbb9f..12fcd1eeb85 100644 --- a/Tests/LibWebView/CMakeLists.txt +++ b/Tests/LibWebView/CMakeLists.txt @@ -1,7 +1,8 @@ set(TEST_SOURCES + TestHistoryStore.cpp TestWebViewURL.cpp ) foreach(source IN LISTS TEST_SOURCES) - ladybird_test("${source}" LibWebView LIBS LibWebView LibURL) + ladybird_test("${source}" LibWebView LIBS LibDatabase LibWebView LibURL) endforeach() diff --git a/Tests/LibWebView/TestHistoryStore.cpp b/Tests/LibWebView/TestHistoryStore.cpp new file mode 100644 index 00000000000..df2792f7b98 --- /dev/null +++ b/Tests/LibWebView/TestHistoryStore.cpp @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2026-present, the Ladybird developers. + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static URL::URL parse_url(StringView url) +{ + auto parsed_url = URL::Parser::basic_parse(url); + VERIFY(parsed_url.has_value()); + return parsed_url.release_value(); +} + +TEST_CASE(record_and_lookup_history_entries) +{ + auto store = WebView::HistoryStore::create(); + + auto visited_at = UnixDateTime::from_seconds_since_epoch(1234); + store->record_visit(parse_url("https://example.com/path#fragment"sv), "Example page"_string, visited_at); + store->record_visit(parse_url("https://example.com/path"sv), {}, visited_at); + + auto entry = store->entry_for_url(parse_url("https://example.com/path"sv)); + VERIFY(entry.has_value()); + + EXPECT_EQ(entry->url, "https://example.com/path"_string); + EXPECT_EQ(entry->title, Optional { "Example page"_string }); + EXPECT_EQ(entry->visit_count, 2u); + EXPECT_EQ(entry->last_visited_time, visited_at); +} + +TEST_CASE(history_autocomplete_prefers_url_prefix_then_recency) +{ + auto store = WebView::HistoryStore::create(); + + store->record_visit(parse_url("https://beta.example.com/"sv), "Alpha reference"_string, UnixDateTime::from_seconds_since_epoch(10)); + store->record_visit(parse_url("https://alpha.example.com/"sv), "Something else"_string, UnixDateTime::from_seconds_since_epoch(20)); + store->record_visit(parse_url("https://docs.example.com/"sv), "Alpha docs"_string, UnixDateTime::from_seconds_since_epoch(30)); + + auto suggestions = store->autocomplete_suggestions("alpha"sv, 8); + + VERIFY(suggestions.size() == 3); + EXPECT_EQ(suggestions[0], "https://alpha.example.com/"_string); + EXPECT_EQ(suggestions[1], "https://docs.example.com/"_string); + EXPECT_EQ(suggestions[2], "https://beta.example.com/"_string); +} + +TEST_CASE(non_browsable_urls_are_not_recorded) +{ + auto store = WebView::HistoryStore::create(); + + store->record_visit(parse_url("about:blank"sv)); + store->record_visit(parse_url("data:text/plain,hello"sv)); + + EXPECT(!store->entry_for_url(parse_url("about:blank"sv)).has_value()); + EXPECT(!store->entry_for_url(parse_url("data:text/plain,hello"sv)).has_value()); +} + +TEST_CASE(disabled_history_store_ignores_updates) +{ + auto store = WebView::HistoryStore::create_disabled(); + auto url = parse_url("https://example.com/"sv); + + store->record_visit(url, "Example"_string, UnixDateTime::from_seconds_since_epoch(10)); + store->update_title(url, "Example title"_string); + store->remove_entries_accessed_since(UnixDateTime::from_seconds_since_epoch(0)); + store->clear(); + + EXPECT(!store->entry_for_url(url).has_value()); + EXPECT(store->autocomplete_suggestions("example"sv, 8).is_empty()); +} +TEST_CASE(persisted_history_survives_reopen) +{ + auto database_directory = ByteString::formatted( + "{}/ladybird-history-store-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)); + store->record_visit(parse_url("https://persist.example.com/"sv), "Persisted title"_string, UnixDateTime::from_seconds_since_epoch(77)); + } + + { + auto database = TRY_OR_FAIL(Database::Database::create(database_directory, "HistoryStore"sv)); + auto store = TRY_OR_FAIL(WebView::HistoryStore::create(*database)); + + auto entry = store->entry_for_url(parse_url("https://persist.example.com/"sv)); + VERIFY(entry.has_value()); + + EXPECT_EQ(entry->title, Optional { "Persisted title"_string }); + EXPECT_EQ(entry->visit_count, 1u); + EXPECT_EQ(entry->last_visited_time, UnixDateTime::from_seconds_since_epoch(77)); + } +}