LibWebView: Add about:history to view, query and mangage browser history

This adds a WebUI to view the local browsing history, with controls to
search and delete entries. The APIs used to search history are paginated
to prevent excessive query sizes.
This commit is contained in:
Timothy Flynn 2026-04-28 14:21:17 -04:00 committed by Andreas Kling
parent e9fb8afb21
commit 496c88d0c9
10 changed files with 1092 additions and 0 deletions

View file

@ -0,0 +1,569 @@
<!doctype html>
<html lang="en">
<head>
<title>History</title>
<link rel="icon" type="image/png" href="resource://icons/48x48/app-browser.png" />
<link rel="stylesheet" type="text/css" href="resource://ladybird/ladybird.css" />
<link rel="stylesheet" type="text/css" href="resource://ladybird/about-pages/webui.css" />
<style>
.history-status {
color: var(--secondary-text-color);
font-size: 13px;
}
.history-content {
display: flex;
flex-direction: column;
gap: 18px;
}
.history-empty {
color: var(--secondary-text-color);
padding: 36px 12px;
text-align: center;
font-size: 14px;
}
.history-groups {
display: flex;
flex-direction: column;
gap: 18px;
}
.history-group {
border-top: 1px solid var(--separator-color);
padding-top: 18px;
}
.history-group:first-child {
border-top: none;
padding-top: 0;
}
.history-group-title {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
}
.history-entry-list {
display: flex;
flex-direction: column;
}
.history-entry {
display: flex;
align-items: center;
gap: 14px;
border-radius: 8px;
padding: 10px 8px;
}
.history-entry:hover {
background-color: var(--item-hover-color);
}
.history-entry + .history-entry {
margin-top: 4px;
}
.entry-favicon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 8px;
overflow: hidden;
background-color: var(--icon-background-color);
color: var(--icon-color);
font-size: 13px;
font-weight: 700;
text-transform: uppercase;
}
.entry-favicon img {
width: 100%;
height: 100%;
}
.entry-main {
display: flex;
flex: 1;
align-items: center;
justify-content: space-between;
gap: 16px;
min-width: 0;
}
.entry-text {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.entry-title {
color: inherit;
font-size: 14px;
font-weight: 600;
text-decoration: none;
}
.entry-title:hover {
text-decoration: underline;
}
.entry-url {
color: var(--secondary-text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
.entry-meta {
color: var(--secondary-text-color);
flex-shrink: 0;
text-align: right;
font-size: 12px;
white-space: nowrap;
}
.entry-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
position: relative;
}
.history-actions {
display: flex;
justify-content: center;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
@media (max-width: 640px) {
.history-entry {
align-items: flex-start;
}
.entry-main {
flex-direction: column;
align-items: flex-start;
}
.entry-meta {
text-align: left;
}
.entry-actions {
width: 100%;
justify-content: flex-end;
}
}
</style>
</head>
<body>
<header>
<picture>
<source srcset="resource://icons/128x128/app-browser.png" media="(prefers-color-scheme: dark)" />
<img src="resource://icons/128x128/app-browser-dark.png" />
</picture>
<h1>History</h1>
</header>
<div class="search-container">
<label class="visually-hidden" for="history-search">Search history</label>
<svg
class="search-icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<circle cx="11" cy="11" r="7" />
<line x1="16" y1="16" x2="22" y2="22" />
</svg>
<input
id="history-search"
type="search"
class="search-input"
placeholder="Search history"
spellcheck="false"
/>
</div>
<div class="card">
<div class="card-body history-content">
<div id="history-status" class="history-status">Loading history...</div>
<div id="history-empty" class="history-empty hidden"></div>
<div id="history-groups" class="history-groups"></div>
<div id="history-actions" class="history-actions hidden">
<button id="load-more" class="secondary-button">Load more</button>
</div>
</div>
</div>
<script type="module">
const PAGE_SIZE = 50;
const SEARCH_DEBOUNCE_DELAY = 200;
const searchInput = document.querySelector("#history-search");
const historyStatus = document.querySelector("#history-status");
const historyEmpty = document.querySelector("#history-empty");
const historyGroups = document.querySelector("#history-groups");
const historyActions = document.querySelector("#history-actions");
const loadMoreButton = document.querySelector("#load-more");
const timeFormatter = new Intl.DateTimeFormat([], { timeStyle: "short" });
const dateFormatter = new Intl.DateTimeFormat([], {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
let searchTimer = null;
let latestRequestId = 0;
let openMenu = null;
let state = {
entries: [],
hasMore: false,
isLoading: false,
query: "",
};
function pluralize(count, singular, plural = `${singular}s`) {
return `${count} ${count === 1 ? singular : plural}`;
}
function startOfDay(date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
function historyDayLabel(timestamp) {
const entryDate = new Date(timestamp);
const today = startOfDay(new Date());
const entryDay = startOfDay(entryDate);
const dayDelta = Math.round((today.getTime() - entryDay.getTime()) / 86400000);
if (dayDelta === 0) return "Today";
if (dayDelta === 1) return "Yesterday";
return dateFormatter.format(entryDate);
}
function entryTimeLabel(entry) {
const timeLabel = timeFormatter.format(new Date(entry.lastVisitedTime));
if (entry.visitCount <= 1) return timeLabel;
return `${timeLabel} - ${pluralize(entry.visitCount, "visit")}`;
}
function faviconFallbackLabel(entry) {
const source = entry.title || entry.siteKey || entry.url;
const firstAlphaNumeric = Array.from(source).find(character => /[a-z0-9]/i.test(character));
return firstAlphaNumeric ? firstAlphaNumeric.toUpperCase() : "?";
}
function createElement(tagName, properties = {}, children = []) {
const element = document.createElement(tagName);
Object.assign(element, properties);
element.append(...children);
return element;
}
function createFavicon(entry) {
if (entry.faviconBase64Png) {
return createElement("div", { className: "entry-favicon" }, [
createElement("img", {
src: `data:image/png;base64,${entry.faviconBase64Png}`,
alt: "",
}),
]);
}
return createElement("div", {
className: "entry-favicon",
textContent: faviconFallbackLabel(entry),
});
}
function refreshHistory() {
state.entries = [];
state.hasMore = false;
render();
requestHistory(0);
}
function removeEntry(entry) {
const name = entry.title || entry.url;
if (!window.confirm(`Remove ${name} from browsing history?`)) return;
ladybird.sendMessage("removeHistoryEntry", { url: entry.url });
refreshHistory();
}
function forgetSite(entry) {
if (!entry.siteKey) return;
if (!window.confirm(`Forget all history from ${entry.siteKey}?`)) return;
ladybird.sendMessage("forgetHistorySite", { url: entry.url });
refreshHistory();
}
function closeOpenMenu() {
if (!openMenu) return;
openMenu.dropdown.classList.add("hidden");
openMenu.toggle.setAttribute("aria-expanded", "false");
openMenu = null;
}
function createEntryMenu(entry, title) {
const toggle = createElement("button", {
className: "menu-toggle",
title: "History entry options",
innerHTML: `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="12" cy="5" r="2" />
<circle cx="12" cy="12" r="2" />
<circle cx="12" cy="19" r="2" />
</svg>
`,
});
toggle.setAttribute("aria-label", `Actions for ${title}`);
toggle.setAttribute("aria-haspopup", "menu");
toggle.setAttribute("aria-expanded", "false");
const forgetButton = createElement("button", {
className: "menu-item",
textContent: "Forget site",
onclick: event => {
event.preventDefault();
closeOpenMenu();
forgetSite(entry);
},
});
forgetButton.setAttribute("role", "menuitem");
if (!entry.siteKey) forgetButton.classList.add("hidden");
const removeButton = createElement("button", {
className: "menu-item",
textContent: "Remove from history",
onclick: event => {
event.preventDefault();
closeOpenMenu();
removeEntry(entry);
},
});
removeButton.setAttribute("role", "menuitem");
const dropdown = createElement("div", { className: "menu-dropdown hidden" }, [
forgetButton,
removeButton,
]);
dropdown.setAttribute("role", "menu");
const menu = createElement("div", { className: "entry-actions" }, [toggle, dropdown]);
toggle.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
if (openMenu?.dropdown === dropdown) {
closeOpenMenu();
return;
}
closeOpenMenu();
dropdown.classList.remove("hidden");
toggle.setAttribute("aria-expanded", "true");
openMenu = { dropdown, toggle, menu };
});
return menu;
}
function createEntryRow(entry) {
const title = entry.title || entry.url;
return createElement("div", { className: "history-entry" }, [
createFavicon(entry),
createElement("div", { className: "entry-main" }, [
createElement("div", { className: "entry-text" }, [
createElement("a", {
className: "entry-title",
href: entry.url,
textContent: title,
}),
createElement("div", {
className: "entry-url",
textContent: entry.url,
title: entry.url,
}),
]),
createElement("div", { className: "entry-meta", textContent: entryTimeLabel(entry) }),
]),
createEntryMenu(entry, title),
]);
}
function renderEntries() {
closeOpenMenu();
historyGroups.replaceChildren();
let currentGroup = null;
let currentList = null;
state.entries.forEach(entry => {
const groupLabel = historyDayLabel(entry.lastVisitedTime);
if (groupLabel !== currentGroup) {
currentGroup = groupLabel;
currentList = createElement("div", { className: "history-entry-list" });
historyGroups.append(
createElement("section", { className: "history-group" }, [
createElement("h2", { className: "history-group-title", textContent: groupLabel }),
currentList,
])
);
}
currentList.append(createEntryRow(entry));
});
}
function renderStatus() {
if (state.isLoading && state.entries.length === 0) {
historyStatus.textContent = "Loading history...";
return;
}
if (state.query) {
if (state.isLoading) {
historyStatus.textContent = `Searching for "${state.query}"...`;
return;
}
historyStatus.textContent =
state.entries.length === 0
? `No results for "${state.query}".`
: `${pluralize(state.entries.length, "result")} loaded for "${state.query}".`;
return;
}
if (state.isLoading) {
historyStatus.textContent = "Loading more history...";
return;
}
historyStatus.textContent =
state.entries.length === 0
? "No browsing history yet."
: `${pluralize(state.entries.length, "entry", "entries")} loaded.`;
}
function renderEmptyState() {
const shouldShowEmpty = !state.isLoading && state.entries.length === 0;
historyEmpty.classList.toggle("hidden", !shouldShowEmpty);
if (!shouldShowEmpty) return;
historyEmpty.textContent = state.query
? "No history entries match this search."
: "Pages you visit will show up here, grouped by day.";
}
function renderLoadMore() {
const shouldShowLoadMore = state.hasMore || state.isLoading;
historyActions.classList.toggle("hidden", !shouldShowLoadMore);
loadMoreButton.disabled = state.isLoading;
loadMoreButton.textContent = state.isLoading ? "Loading..." : "Load more";
}
function render() {
renderStatus();
renderEntries();
renderEmptyState();
renderLoadMore();
}
function requestHistory(offset) {
const requestId = ++latestRequestId;
const query = searchInput.value.trim();
offset = offset || state.entries.length;
state.isLoading = true;
state.query = query;
render();
ladybird.sendMessage("loadHistoryEntries", {
requestId,
query,
offset,
limit: PAGE_SIZE,
});
}
function scheduleSearch() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => refreshHistory(), SEARCH_DEBOUNCE_DELAY);
}
searchInput.addEventListener("input", scheduleSearch);
searchInput.addEventListener("search", scheduleSearch);
loadMoreButton.addEventListener("click", () => {
if (!state.isLoading) requestHistory();
});
document.addEventListener("click", event => {
if (!openMenu || openMenu.menu.contains(event.target)) return;
closeOpenMenu();
});
document.addEventListener("keydown", event => {
if (event.key === "Escape") closeOpenMenu();
});
document.addEventListener("WebUILoaded", () => {
requestHistory(0);
});
document.addEventListener("WebUIMessage", event => {
if (event.detail.name !== "loadHistoryEntries") return;
const data = event.detail.data;
if (data.requestId !== latestRequestId) return;
state.isLoading = false;
state.hasMore = Boolean(data.hasMore);
state.query = data.query || "";
if (data.offset === 0) {
state.entries = data.entries;
} else {
state.entries = state.entries.concat(data.entries);
}
render();
});
</script>
</body>
</html>

View file

@ -14,6 +14,7 @@ namespace URL {
#define ENUMERATE_INTERNAL_URLS \
__URL_ENUMERATE(bookmarks) \
__URL_ENUMERATE(history) \
__URL_ENUMERATE(newtab) \
__URL_ENUMERATE(processes) \
__URL_ENUMERATE(settings) \

View file

@ -33,6 +33,7 @@ set(SOURCES
WorkerProcessManager.cpp
WebUI.cpp
WebUI/BookmarksUI.cpp
WebUI/HistoryUI.cpp
WebUI/ProcessesUI.cpp
WebUI/SettingsUI.cpp
WebUI/VersionUI.cpp

View file

@ -214,8 +214,41 @@ ErrorOr<NonnullOwnPtr<HistoryStore>> HistoryStore::create(Database::Database& da
url ASC
LIMIT ?4;
)#"sv));
statements.list_entries = TRY(database.prepare_statement(R"#(
SELECT url, title, visit_count, last_visited_time, favicon
FROM (
SELECT
url,
title,
visit_count,
last_visited_time,
COALESCE(favicon, '') AS favicon,
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 ((?1 = '' AND ?2 = '')
OR (?1 != '' AND INSTR(LOWER(title), LOWER(?1)) > 0)
OR (?2 != '' AND INSTR(LOWER(searchable_url), LOWER(?2)) > 0))
ORDER BY last_visited_time DESC, url ASC
LIMIT ?3 OFFSET ?4;
)#"sv));
statements.clear_entries = TRY(database.prepare_statement("DELETE FROM History;"sv));
statements.delete_entry = TRY(database.prepare_statement("DELETE FROM History WHERE url = ?;"sv));
statements.delete_entries_accessed_since = TRY(database.prepare_statement("DELETE FROM History WHERE last_visited_time >= ?;"sv));
statements.all_urls = TRY(database.prepare_statement("SELECT url FROM History;"sv));
return adopt_own(*new HistoryStore { adopt_own<StorageImpl>(*new PersistedStorage { database, move(statements) }) });
}
@ -421,6 +454,28 @@ Vector<HistoryEntry> HistoryStore::autocomplete_entries(StringView query, size_t
return entries;
}
Vector<HistoryEntry> HistoryStore::list_entries(StringView query, size_t offset, size_t limit)
{
if (m_is_disabled || limit == 0)
return {};
auto title_query = query.trim_whitespace();
auto url_query = autocomplete_url_query(title_query);
auto entries = m_storage->list_entries(title_query, url_query, offset, limit);
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] {} history page entries for '{}' (title_query='{}', url_query='{}', offset={}, limit={}): {}",
m_storage->name(),
title_query,
title_query,
url_query,
offset,
limit,
log_history_entries(entries));
return entries;
}
void HistoryStore::clear()
{
if (m_is_disabled)
@ -431,6 +486,65 @@ void HistoryStore::clear()
m_recently_closed_entries.clear();
}
void HistoryStore::remove_entry_for_url(URL::URL const& url)
{
if (m_is_disabled)
return;
auto normalized_url = normalize_url(url);
if (!normalized_url.has_value())
return;
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Removing history entry for '{}'", *normalized_url);
m_storage->remove_entry_for_url(*normalized_url);
}
static Optional<String> site_key_for_history_entry(URL::URL const& url)
{
if (!url.host().has_value() || url.host()->is_empty_host())
return {};
if (auto registrable_domain = url.host()->registrable_domain(); registrable_domain.has_value())
return registrable_domain.release_value();
return url.serialized_host();
}
static bool history_entry_matches_site_key(StringView entry_url, StringView site_key)
{
auto parsed_url = URL::Parser::basic_parse(entry_url);
if (!parsed_url.has_value())
return false;
auto const& host = parsed_url->host();
if (!host.has_value() || host->is_empty_host())
return false;
auto serialized_host = parsed_url->serialized_host();
auto serialized_host_view = serialized_host.bytes_as_string_view();
if (serialized_host_view.equals_ignoring_ascii_case(site_key))
return true;
return serialized_host_view.length() > site_key.length()
&& serialized_host_view.ends_with(site_key, CaseSensitivity::CaseInsensitive)
&& serialized_host_view[serialized_host_view.length() - site_key.length() - 1] == '.';
}
void HistoryStore::remove_entries_for_same_site(URL::URL const& url)
{
if (m_is_disabled)
return;
auto site_key = site_key_for_history_entry(url);
if (!site_key.has_value()) {
remove_entry_for_url(url);
return;
}
dbgln_if(WEBVIEW_HISTORY_DEBUG, "[History] Removing history entries for site '{}'", *site_key);
m_storage->remove_entries_for_same_site(*site_key);
}
void HistoryStore::remove_entries_accessed_since(UnixDateTime since)
{
if (m_is_disabled)
@ -515,11 +629,70 @@ Vector<HistoryEntry> HistoryStore::TransientStorage::autocomplete_entries(String
return entries;
}
static bool matches_history_page_query(HistoryEntry const& entry, StringView title_query, StringView url_query)
{
if (title_query.is_empty() && url_query.is_empty())
return true;
auto searchable_url = autocomplete_searchable_url(entry.url.bytes_as_string_view());
if (!url_query.is_empty() && searchable_url.contains(url_query, CaseSensitivity::CaseInsensitive))
return true;
return !title_query.is_empty()
&& entry.title.has_value()
&& entry.title->contains(title_query, CaseSensitivity::CaseInsensitive);
}
static void sort_entries_for_history_page(Vector<HistoryEntry const*>& matches)
{
quick_sort(matches, [](auto const* left, auto const* right) {
if (left->last_visited_time != right->last_visited_time)
return left->last_visited_time > right->last_visited_time;
return left->url < right->url;
});
}
Vector<HistoryEntry> HistoryStore::TransientStorage::list_entries(StringView title_query, StringView url_query, size_t offset, size_t limit)
{
Vector<HistoryEntry const*> matches;
for (auto const& entry : m_entries) {
if (matches_history_page_query(entry.value, title_query, url_query))
matches.append(&entry.value);
}
sort_entries_for_history_page(matches);
Vector<HistoryEntry> entries;
if (offset >= matches.size())
return entries;
auto end = min(matches.size(), offset + limit);
entries.ensure_capacity(end - offset);
for (size_t i = offset; i < end; ++i)
entries.unchecked_append(*matches[i]);
return entries;
}
void HistoryStore::TransientStorage::clear()
{
m_entries.clear();
}
void HistoryStore::TransientStorage::remove_entry_for_url(String const& url)
{
m_entries.remove(url);
}
void HistoryStore::TransientStorage::remove_entries_for_same_site(StringView site_key)
{
m_entries.remove_all_matching([&](auto const&, auto const& entry) {
return history_entry_matches_site_key(entry.url, site_key);
});
}
void HistoryStore::TransientStorage::remove_entries_accessed_since(UnixDateTime since)
{
m_entries.remove_all_matching([&](auto const&, auto const& entry) {
@ -616,11 +789,61 @@ Vector<HistoryEntry> HistoryStore::PersistedStorage::autocomplete_entries(String
return entries;
}
Vector<HistoryEntry> HistoryStore::PersistedStorage::list_entries(StringView title_query, StringView url_query, size_t offset, size_t limit)
{
Vector<HistoryEntry> entries;
entries.ensure_capacity(limit);
auto title_query_string = MUST(String::from_utf8(title_query));
auto url_query_string = MUST(String::from_utf8(url_query));
m_database.execute_statement(
m_statements.list_entries,
[&](auto statement_id) {
auto title = m_database.result_column<String>(statement_id, 1);
auto favicon = m_database.result_column<String>(statement_id, 4);
entries.append(HistoryEntry {
.url = m_database.result_column<String>(statement_id, 0),
.title = title.is_empty() ? Optional<String> {} : Optional<String> { move(title) },
.favicon_base64_png = favicon.is_empty() ? Optional<String> {} : Optional<String> { move(favicon) },
.visit_count = m_database.result_column<u64>(statement_id, 2),
.last_visited_time = m_database.result_column<UnixDateTime>(statement_id, 3),
});
},
title_query_string,
url_query_string,
static_cast<i64>(limit),
static_cast<i64>(offset));
return entries;
}
void HistoryStore::PersistedStorage::clear()
{
m_database.execute_statement(m_statements.clear_entries, {});
}
void HistoryStore::PersistedStorage::remove_entry_for_url(String const& url)
{
m_database.execute_statement(m_statements.delete_entry, {}, url);
}
void HistoryStore::PersistedStorage::remove_entries_for_same_site(StringView site_key)
{
Vector<String> urls_to_remove;
m_database.execute_statement(
m_statements.all_urls,
[&](auto statement_id) {
auto url = m_database.result_column<String>(statement_id, 0);
if (history_entry_matches_site_key(url.bytes_as_string_view(), site_key))
urls_to_remove.append(move(url));
});
for (auto const& url : urls_to_remove)
remove_entry_for_url(url);
}
void HistoryStore::PersistedStorage::remove_entries_accessed_since(UnixDateTime since)
{
m_database.execute_statement(m_statements.delete_entries_accessed_since, {}, since);

View file

@ -57,8 +57,11 @@ public:
Optional<HistoryEntry> entry_for_url(URL::URL const&);
Vector<HistoryEntry> autocomplete_entries(StringView query, size_t limit = 8);
Vector<HistoryEntry> list_entries(StringView query = {}, size_t offset = 0, size_t limit = 50);
void clear();
void remove_entry_for_url(URL::URL const&);
void remove_entries_for_same_site(URL::URL const&);
void remove_entries_accessed_since(UnixDateTime since);
private:
@ -68,8 +71,11 @@ private:
Database::StatementID update_favicon { 0 };
Database::StatementID get_entry { 0 };
Database::StatementID search_entries { 0 };
Database::StatementID list_entries { 0 };
Database::StatementID clear_entries { 0 };
Database::StatementID delete_entry { 0 };
Database::StatementID delete_entries_accessed_since { 0 };
Database::StatementID all_urls { 0 };
};
class StorageImpl {
@ -84,8 +90,11 @@ private:
virtual Optional<HistoryEntry> entry_for_url(String const& url) = 0;
virtual Vector<HistoryEntry> autocomplete_entries(StringView title_query, StringView url_query, size_t limit) = 0;
virtual Vector<HistoryEntry> list_entries(StringView title_query, StringView url_query, size_t offset, size_t limit) = 0;
virtual void clear() = 0;
virtual void remove_entry_for_url(String const& url) = 0;
virtual void remove_entries_for_same_site(StringView site_key) = 0;
virtual void remove_entries_accessed_since(UnixDateTime since) = 0;
};
@ -101,8 +110,11 @@ private:
virtual Optional<HistoryEntry> entry_for_url(String const& url) override;
virtual Vector<HistoryEntry> autocomplete_entries(StringView title_query, StringView url_query, size_t limit) override;
virtual Vector<HistoryEntry> list_entries(StringView title_query, StringView url_query, size_t offset, size_t limit) override;
virtual void clear() override;
virtual void remove_entry_for_url(String const& url) override;
virtual void remove_entries_for_same_site(StringView site_key) override;
virtual void remove_entries_accessed_since(UnixDateTime since) override;
private:
@ -122,8 +134,11 @@ private:
virtual Optional<HistoryEntry> entry_for_url(String const& url) override;
virtual Vector<HistoryEntry> autocomplete_entries(StringView title_query, StringView url_query, size_t limit) override;
virtual Vector<HistoryEntry> list_entries(StringView title_query, StringView url_query, size_t offset, size_t limit) override;
virtual void clear() override;
virtual void remove_entry_for_url(String const& url) override;
virtual void remove_entries_for_same_site(StringView site_key) override;
virtual void remove_entries_accessed_since(UnixDateTime since) override;
private:

View file

@ -9,6 +9,7 @@
#include <LibWebView/WebContentClient.h>
#include <LibWebView/WebUI.h>
#include <LibWebView/WebUI/BookmarksUI.h>
#include <LibWebView/WebUI/HistoryUI.h>
#include <LibWebView/WebUI/ProcessesUI.h>
#include <LibWebView/WebUI/SettingsUI.h>
#include <LibWebView/WebUI/VersionUI.h>
@ -35,6 +36,8 @@ ErrorOr<RefPtr<WebUI>> WebUI::create(WebContentClient& client, u64 page_id, Stri
if (host == "bookmarks"sv)
web_ui = TRY(create_web_ui<BookmarksUI>(client, page_id, move(host)));
else if (host == "history"sv)
web_ui = TRY(create_web_ui<HistoryUI>(client, page_id, move(host)));
else if (host == "processes"sv)
web_ui = TRY(create_web_ui<ProcessesUI>(client, page_id, move(host)));
else if (host == "settings"sv)

View file

@ -0,0 +1,130 @@
/*
* Copyright (c) 2026, Tim Flynn <trflynn89@ladybird.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <AK/JsonArray.h>
#include <AK/JsonObject.h>
#include <LibURL/Parser.h>
#include <LibWebView/Application.h>
#include <LibWebView/HistoryStore.h>
#include <LibWebView/WebUI/HistoryUI.h>
#include <algorithm>
namespace WebView {
static constexpr size_t DEFAULT_HISTORY_PAGE_SIZE = 50;
static constexpr size_t MAX_HISTORY_PAGE_SIZE = 100;
static Optional<String> site_key_for_entry(HistoryEntry const& entry)
{
auto parsed_url = URL::Parser::basic_parse(entry.url);
if (!parsed_url.has_value())
return {};
if (!parsed_url->host().has_value() || parsed_url->host()->is_empty_host())
return {};
if (auto registrable_domain = parsed_url->host()->registrable_domain(); registrable_domain.has_value())
return registrable_domain.release_value();
return parsed_url->serialized_host();
}
static JsonObject serialize_history_entry(HistoryEntry const& entry)
{
JsonObject serialized;
serialized.set("url"sv, entry.url);
serialized.set("title"sv, entry.title.value_or(String {}));
serialized.set("faviconBase64Png"sv, entry.favicon_base64_png.value_or(String {}));
serialized.set("visitCount"sv, entry.visit_count);
serialized.set("lastVisitedTime"sv, entry.last_visited_time.milliseconds_since_epoch());
serialized.set("siteKey"sv, site_key_for_entry(entry).value_or(String {}));
return serialized;
}
void HistoryUI::register_interfaces()
{
register_interface("loadHistoryEntries"sv, [this](auto const& data) {
load_history_entries(data);
});
register_interface("removeHistoryEntry"sv, [this](auto const& data) {
remove_history_entry(data);
});
register_interface("forgetHistorySite"sv, [this](auto const& data) {
forget_history_site(data);
});
}
void HistoryUI::load_history_entries(JsonValue const& data)
{
if (!data.is_object())
return;
auto const& object = data.as_object();
auto offset = object.get_integer<size_t>("offset"sv).value_or(0);
auto limit = object.get_integer<size_t>("limit"sv);
auto request_id = object.get_integer<i64>("requestId"sv).value_or(0);
auto query = object.get_string("query"sv).value_or(String {});
if (limit.has_value())
limit = min(*limit, MAX_HISTORY_PAGE_SIZE);
else
limit = DEFAULT_HISTORY_PAGE_SIZE;
auto entries = Application::history_store().list_entries(query, offset, *limit + 1);
auto has_more = entries.size() > *limit;
if (has_more)
entries.resize(*limit);
JsonArray serialized_entries;
serialized_entries.ensure_capacity(entries.size());
for (auto const& entry : entries)
serialized_entries.must_append(serialize_history_entry(entry));
JsonObject result;
result.set("requestId"sv, request_id);
result.set("query"sv, query);
result.set("offset"sv, offset);
result.set("hasMore"sv, has_more);
result.set("entries"sv, move(serialized_entries));
async_send_message("loadHistoryEntries"sv, move(result));
}
void HistoryUI::remove_history_entry(JsonValue const& data)
{
if (!data.is_object())
return;
auto url = data.as_object().get_string("url"sv);
if (!url.has_value())
return;
auto parsed_url = URL::Parser::basic_parse(*url);
if (!parsed_url.has_value())
return;
Application::history_store().remove_entry_for_url(*parsed_url);
}
void HistoryUI::forget_history_site(JsonValue const& data)
{
if (!data.is_object())
return;
auto url = data.as_object().get_string("url"sv);
if (!url.has_value())
return;
auto parsed_url = URL::Parser::basic_parse(*url);
if (!parsed_url.has_value())
return;
Application::history_store().remove_entries_for_same_site(*parsed_url);
}
}

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2026, Tim Flynn <trflynn89@ladybird.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#pragma once
#include <LibWebView/WebUI.h>
namespace WebView {
class HistoryUI final : public WebUI {
WEB_UI(HistoryUI);
private:
virtual void register_interfaces() override;
void load_history_entries(JsonValue const&);
void remove_history_entry(JsonValue const&);
void forget_history_site(JsonValue const&);
};
}

View file

@ -87,6 +87,59 @@ static void expect_history_autocomplete_entries_include_metadata(WebView::Histor
EXPECT_EQ(entries[0].last_visited_time, UnixDateTime::from_seconds_since_epoch(20));
}
static void expect_history_page_entries_are_paginated_and_searchable(WebView::HistoryStore& store)
{
store.record_visit(parse_url("https://www.alpha.example.com/path"sv), "Alpha docs"_string, UnixDateTime::from_seconds_since_epoch(10));
store.record_visit(parse_url("https://beta.example.com/"sv), "Beta page"_string, UnixDateTime::from_seconds_since_epoch(30));
store.record_visit(parse_url("https://gamma.example.com/search"sv), "Gamma search"_string, UnixDateTime::from_seconds_since_epoch(20));
store.record_visit(parse_url("https://docs.ladybird.dev/"sv), "Ladybird docs"_string, UnixDateTime::from_seconds_since_epoch(40));
auto paginated_entries = store.list_entries({}, 1, 2);
VERIFY(paginated_entries.size() == 2);
EXPECT_EQ(paginated_entries[0].url, "https://beta.example.com/"_string);
EXPECT_EQ(paginated_entries[1].url, "https://gamma.example.com/search"_string);
auto url_search_entries = store.list_entries("https://www.alpha"sv, 0, 10);
VERIFY(url_search_entries.size() == 1);
EXPECT_EQ(url_search_entries[0].url, "https://www.alpha.example.com/path"_string);
auto title_search_entries = store.list_entries("docs"sv, 0, 10);
VERIFY(title_search_entries.size() == 2);
EXPECT_EQ(title_search_entries[0].url, "https://docs.ladybird.dev/"_string);
EXPECT_EQ(title_search_entries[1].url, "https://www.alpha.example.com/path"_string);
}
static void expect_history_entries_can_be_removed(WebView::HistoryStore& store)
{
auto example_url = parse_url("https://example.com/"sv);
auto other_url = parse_url("https://other.example.com/"sv);
store.record_visit(example_url, "Example"_string, UnixDateTime::from_seconds_since_epoch(10));
store.record_visit(other_url, "Other"_string, UnixDateTime::from_seconds_since_epoch(20));
store.remove_entry_for_url(example_url);
EXPECT(!store.entry_for_url(example_url).has_value());
EXPECT(store.entry_for_url(other_url).has_value());
}
static void expect_history_entries_for_same_site_can_be_removed(WebView::HistoryStore& store)
{
auto example_url = parse_url("https://www.example.com/"sv);
auto subdomain_url = parse_url("https://docs.example.com/guide"sv);
auto other_url = parse_url("https://ladybird.dev/"sv);
store.record_visit(example_url, "Example"_string, UnixDateTime::from_seconds_since_epoch(10));
store.record_visit(subdomain_url, "Docs"_string, UnixDateTime::from_seconds_since_epoch(20));
store.record_visit(other_url, "Ladybird"_string, UnixDateTime::from_seconds_since_epoch(30));
store.remove_entries_for_same_site(example_url);
EXPECT(!store.entry_for_url(example_url).has_value());
EXPECT(!store.entry_for_url(subdomain_url).has_value());
EXPECT(store.entry_for_url(other_url).has_value());
}
TEST_CASE(record_and_lookup_history_entries)
{
auto store = WebView::HistoryStore::create();
@ -271,6 +324,12 @@ TEST_CASE(history_autocomplete_entries_include_metadata)
expect_history_autocomplete_entries_include_metadata(*store);
}
TEST_CASE(history_page_entries_are_paginated_and_searchable)
{
auto store = WebView::HistoryStore::create();
expect_history_page_entries_are_paginated_and_searchable(*store);
}
TEST_CASE(non_browsable_urls_are_not_recorded)
{
auto store = WebView::HistoryStore::create();
@ -321,6 +380,18 @@ TEST_CASE(history_entries_accessed_since_can_be_removed)
EXPECT(!store->entry_for_url(newer_url).has_value());
}
TEST_CASE(history_entries_can_be_removed)
{
auto store = WebView::HistoryStore::create();
expect_history_entries_can_be_removed(*store);
}
TEST_CASE(history_entries_for_same_site_can_be_removed)
{
auto store = WebView::HistoryStore::create();
expect_history_entries_for_same_site_can_be_removed(*store);
}
TEST_CASE(persisted_history_survives_reopen)
{
auto database_directory = ByteString::formatted(
@ -457,3 +528,57 @@ TEST_CASE(persisted_history_autocomplete_entries_include_metadata)
expect_history_autocomplete_entries_include_metadata(*store);
}
TEST_CASE(persisted_history_page_entries_are_paginated_and_searchable)
{
auto database_directory = ByteString::formatted(
"{}/ladybird-history-store-page-list-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_page_entries_are_paginated_and_searchable(*store);
}
TEST_CASE(persisted_history_entries_can_be_removed)
{
auto database_directory = ByteString::formatted(
"{}/ladybird-history-store-remove-entry-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_entries_can_be_removed(*store);
}
TEST_CASE(persisted_history_entries_for_same_site_can_be_removed)
{
auto database_directory = ByteString::formatted(
"{}/ladybird-history-store-remove-site-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_entries_for_same_site_can_be_removed(*store);
}

View file

@ -32,6 +32,7 @@ list(TRANSFORM INTERNAL_RESOURCES PREPEND "${LADYBIRD_SOURCE_DIR}/Base/res/ladyb
set(ABOUT_PAGES
about.html
bookmarks.html
history.html
newtab.html
processes.html
settings.html