git.lucas.co / cce-browser
web browser (Servo)
git clone https://git.lucas.co/cce-browser.git

commite71598421f32a9716a6b49591f8bf921bafae0ae
parent54d0f1cd50
authorLucas Galante <[email protected]>
date2026-08-11 11:27
Add a history page (cce://history)

Completed page loads append to ~/.local/state/cce/browser/history.tsv
(XDG state dir; tab-separated ts/url/title, fields sanitized) and to an
in-memory store. A custom cce: protocol registered on ServoBuilder
serves cce://history as a real page rendered by Servo — dark-themed,
newest-first, entries are plain links, timestamps localized by a tiny
on-page script, plus a clear action (cce://history/clear). Ctrl+H opens
it in a new tab; the URL bar accepts cce:// URLs and maps about:history.

Recording rides the load-complete transition; TabSignals.loading became
Option<bool> because syncing the bool default swallowed the transition
when URL/title signals pumped before the first load-status callback.

Live-verified: visit recorded to the TSV, Ctrl+H opens History, the
entry renders with a localized timestamp, and clicking it navigates.

Co-Authored-By: Claude Fable 5 <[email protected]>

 Cargo.toml     |   1 +
 src/history.rs | 199 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/main.rs    |  15 ++++-
 src/webview.rs |  30 ++++++++-
 4 files changed, 241 insertions(+), 4 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index 36c409f..4a6d013 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,6 +9,7 @@ calloop = "0.13.0"
 wayland-client = { version = "0.31", features = ["system"] }
 servo = "0.4"
 url = "2"
+http = "1"
 dpi = "0.1"
 euclid = "0.22"
 rustls = { version = "0.23", features = ["aws-lc-rs"] }
diff --git a/src/history.rs b/src/history.rs
new file mode 100644
index 0000000..d71464f
--- /dev/null
+++ b/src/history.rs
@@ -0,0 +1,199 @@
+//! Visit history: an append-only TSV log under the XDG state dir
+//! (`~/.local/state/cce/browser/history.tsv`), an in-memory copy for
+//! rendering, and the `cce:` protocol handler that serves it back as a
+//! real page — `cce://history` is fetched through Servo's network stack
+//! and rendered like any other page, so entries are ordinary links.
+//!
+//! The handler runs on Servo's fetch threads, hence the `Arc<Mutex<_>>`
+//! store shared with the main thread's recorder.
+
+use std::fs::{self, OpenOptions};
+use std::future::Future;
+use std::io::Write;
+use std::path::PathBuf;
+use std::pin::Pin;
+use std::sync::Mutex;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use servo::protocol_handler::{
+    DoneChannel, FetchContext, HttpStatus, NetworkError, ProtocolHandler, Request, Response,
+    ResponseBody, ResourceFetchTiming,
+};
+use std::sync::Arc;
+
+/// Render at most this many entries on the history page.
+const RENDER_CAP: usize = 500;
+
+#[derive(Clone)]
+struct Entry {
+    ts: u64,
+    url: String,
+    title: String,
+}
+
+pub struct History {
+    entries: Mutex<Vec<Entry>>,
+    path: PathBuf,
+}
+
+fn state_path() -> PathBuf {
+    let base = match std::env::var("XDG_STATE_HOME") {
+        Ok(x) if !x.is_empty() => PathBuf::from(x),
+        _ => PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".local/state"),
+    };
+    base.join("cce").join("browser").join("history.tsv")
+}
+
+/// One-line-safe field: the TSV log separates with tabs and newlines.
+fn sanitize(s: &str) -> String {
+    s.replace(['\t', '\n', '\r'], " ")
+}
+
+fn html_escape(s: &str) -> String {
+    s.replace('&', "&amp;")
+        .replace('<', "&lt;")
+        .replace('>', "&gt;")
+        .replace('"', "&quot;")
+}
+
+impl History {
+    /// Load the log from the state dir (missing file = empty history).
+    pub fn load() -> Self {
+        let path = state_path();
+        let mut entries = Vec::new();
+        if let Ok(text) = fs::read_to_string(&path) {
+            for line in text.lines() {
+                let mut parts = line.splitn(3, '\t');
+                if let (Some(ts), Some(url), Some(title)) =
+                    (parts.next(), parts.next(), parts.next())
+                {
+                    if let Ok(ts) = ts.parse() {
+                        entries.push(Entry { ts, url: url.to_string(), title: title.to_string() });
+                    }
+                }
+            }
+        }
+        Self { entries: Mutex::new(entries), path }
+    }
+
+    /// Record a completed page load. Internal pages and immediate
+    /// duplicates (reload spam) are skipped.
+    pub fn record(&self, url: &str, title: &str) {
+        if url.starts_with("cce:") || url == "about:blank" {
+            return;
+        }
+        let mut entries = self.entries.lock().unwrap();
+        if entries.last().is_some_and(|last| last.url == url) {
+            return;
+        }
+        let ts = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .map(|d| d.as_secs())
+            .unwrap_or(0);
+        let entry = Entry { ts, url: sanitize(url), title: sanitize(title) };
+        if let Some(dir) = self.path.parent() {
+            let _ = fs::create_dir_all(dir);
+        }
+        if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&self.path) {
+            let _ = writeln!(f, "{}\t{}\t{}", entry.ts, entry.url, entry.title);
+        }
+        entries.push(entry);
+    }
+
+    pub fn clear(&self) {
+        self.entries.lock().unwrap().clear();
+        let _ = fs::write(&self.path, "");
+    }
+
+    /// The history page markup: newest first, timestamps localized by a
+    /// tiny script on the page itself.
+    fn html(&self) -> String {
+        let entries = self.entries.lock().unwrap();
+        let mut rows = String::new();
+        for e in entries.iter().rev().take(RENDER_CAP) {
+            let title = if e.title.trim().is_empty() { &e.url } else { &e.title };
+            rows.push_str(&format!(
+                "<div class=e><span class=w data-ts=\"{}\"></span>\
+                 <a href=\"{}\">{}</a><span class=u>{}</span></div>\n",
+                e.ts,
+                html_escape(&e.url),
+                html_escape(title),
+                html_escape(&e.url),
+            ));
+        }
+        let count = entries.len();
+        let body = if count == 0 {
+            "<p class=empty>No history yet.</p>".to_string()
+        } else {
+            rows
+        };
+        format!(
+            "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>History</title><style>\
+             :root{{color-scheme:dark}}\
+             body{{background:#1a1b1d;color:#dcdce1;font-family:sans-serif;margin:0;padding:28px 36px}}\
+             h1{{font-size:20px;font-weight:600;margin:0 0 4px}}\
+             .meta{{color:#8a8c92;font-size:13px;margin-bottom:20px}}\
+             .meta a{{color:#7fa3d4;text-decoration:none;margin-left:12px}}\
+             .e{{display:flex;gap:14px;padding:7px 10px;border-radius:8px;align-items:baseline}}\
+             .e:hover{{background:#232427}}\
+             .w{{color:#8a8c92;font-size:12px;min-width:11em}}\
+             .e a{{color:#dcdce1;text-decoration:none;white-space:nowrap;overflow:hidden;\
+                   text-overflow:ellipsis;max-width:40%}}\
+             .e a:hover{{color:#9fc1ea}}\
+             .u{{color:#6f7177;font-size:12px;white-space:nowrap;overflow:hidden;\
+                 text-overflow:ellipsis;flex:1}}\
+             .empty{{color:#8a8c92}}\
+             </style></head><body>\
+             <h1>History</h1>\
+             <div class=meta>{count} entries<a href=\"cce://history/clear\">clear</a></div>\
+             {body}\
+             <script>for(const el of document.querySelectorAll('[data-ts]')){{\
+             const d=new Date(1000*+el.dataset.ts);\
+             el.textContent=d.toLocaleDateString()+'  '+\
+             d.toLocaleTimeString([],{{hour:'2-digit',minute:'2-digit'}});}}</script>\
+             </body></html>"
+        )
+    }
+}
+
+/// `cce:` scheme: internal pages served straight out of the app.
+pub struct CceProtocol {
+    pub history: Arc<History>,
+}
+
+impl ProtocolHandler for CceProtocol {
+    fn load(
+        &self,
+        request: &mut Request,
+        _done_chan: &mut DoneChannel,
+        _context: &FetchContext,
+    ) -> Pin<Box<dyn Future<Output = Response> + Send>> {
+        let url = request.current_url();
+        let page = url.as_str().trim_start_matches("cce://").trim_end_matches('/');
+        let body = match page {
+            "history" => Some(self.history.html()),
+            "history/clear" => {
+                self.history.clear();
+                Some(self.history.html())
+            }
+            _ => None,
+        };
+        let response = match body {
+            Some(html) => {
+                let mut response =
+                    Response::new(url, ResourceFetchTiming::new(request.timing_type()));
+                *response.body.lock() = ResponseBody::Done(html.into_bytes());
+                response.headers.insert(
+                    http::header::CONTENT_TYPE,
+                    http::HeaderValue::from_static("text/html; charset=utf-8"),
+                );
+                response.status = HttpStatus::default();
+                response
+            }
+            None => Response::network_error(NetworkError::ResourceLoadError(
+                format!("no such cce: page: {page}"),
+            )),
+        };
+        Box::pin(std::future::ready(response))
+    }
+}
diff --git a/src/main.rs b/src/main.rs
index 0172bd2..b1fa847 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -6,6 +6,7 @@
 //! reload / URL field). Input over the page area is translated into Servo
 //! input events; the URL bar is a small hand-rolled line editor.
 
+mod history;
 mod webview;
 
 use url::Url;
@@ -165,8 +166,11 @@ fn parse_url_input(input: &str) -> Option<Url> {
     if s.is_empty() {
         return None;
     }
+    if s.eq_ignore_ascii_case("about:history") {
+        return Url::parse("cce://history").ok();
+    }
     if let Ok(u) = Url::parse(s) {
-        if matches!(u.scheme(), "http" | "https" | "file" | "data" | "about") {
+        if matches!(u.scheme(), "http" | "https" | "file" | "data" | "about" | "cce") {
             return Some(u);
         }
     }
@@ -538,6 +542,15 @@ impl Application for BrowserApp {
                     *needs_rebuild = true;
                     return self.close_tab(self.host.active_index());
                 }
+                Key::Character(c) if c == "h" => {
+                    if let Ok(url) = Url::parse("cce://history") {
+                        self.host.open_tab(url);
+                        self.url_focused = false;
+                        self.sync_page_state();
+                        *needs_rebuild = true;
+                    }
+                    return None;
+                }
                 Key::Named(NamedKey::Tab) if count > 1 => {
                     let cur = self.host.active_index();
                     let next = if event.shift { (cur + count - 1) % count } else { (cur + 1) % count };
diff --git a/src/webview.rs b/src/webview.rs
index bd11ac4..295872c 100644
--- a/src/webview.rs
+++ b/src/webview.rs
@@ -23,8 +23,10 @@ use servo::{
     SoftwareRenderingContext, WebView, WebViewBuilder, WebViewDelegate, WebViewId, WheelDelta,
     WheelEvent, WheelMode,
 };
+use servo::protocol_handler::ProtocolRegistry;
 use url::Url;
 
+use crate::history::{CceProtocol, History};
 use crate::Message;
 
 /// Delegate-observed signals for one webview, polled by the app after each
@@ -34,7 +36,10 @@ struct TabSignals {
     frame_ready: bool,
     title: Option<String>,
     url: Option<Url>,
-    loading: bool,
+    /// None until Servo reports a load status — the sync must not mistake
+    /// the default for "finished loading" (that swallows the completion
+    /// transition history recording depends on).
+    loading: Option<bool>,
 }
 
 #[derive(Default)]
@@ -77,7 +82,7 @@ impl WebViewDelegate for Delegate {
     }
 
     fn notify_load_status_changed(&self, webview: WebView, status: LoadStatus) {
-        self.with_tab(&webview, |t| t.loading = status != LoadStatus::Complete);
+        self.with_tab(&webview, |t| t.loading = Some(status != LoadStatus::Complete));
     }
 
     fn request_create_new(&self, _parent_webview: WebView, request: CreateNewWebViewRequest) {
@@ -123,6 +128,7 @@ pub struct ServoHost {
     context: Rc<SoftwareRenderingContext>,
     shared: Rc<HostShared>,
     delegate: Rc<Delegate>,
+    history: std::sync::Arc<History>,
     tabs: Vec<Tab>,
     active: usize,
     size_px: (u32, u32),
@@ -142,8 +148,15 @@ impl ServoHost {
             .make_current()
             .expect("make software rendering context current");
 
+        let history = std::sync::Arc::new(History::load());
+        let mut protocols = ProtocolRegistry::default();
+        if let Err(e) = protocols.register("cce", CceProtocol { history: history.clone() }) {
+            log::error!("failed to register cce: protocol: {e:?}");
+        }
+
         let servo = ServoBuilder::default()
             .event_loop_waker(Box::new(Waker(wake.clone())))
+            .protocol_registry(protocols)
             .build();
 
         let shared = Rc::new(HostShared::default());
@@ -160,6 +173,7 @@ impl ServoHost {
             context,
             shared,
             delegate,
+            history,
             tabs: Vec::new(),
             // Sentinel so the first open_tab's activate() does the full
             // show/focus/resize dance instead of early-returning on 0 == 0.
@@ -299,7 +313,17 @@ impl ServoHost {
                 if let Some(sig) = per.get_mut(&tab.webview.id()) {
                     tab.title = sig.title.clone();
                     tab.url = sig.url.clone();
-                    tab.loading = sig.loading;
+                    if let Some(loading) = sig.loading.take() {
+                        let was_loading = tab.loading;
+                        tab.loading = loading;
+                        // Load-complete transition: log the visit.
+                        if was_loading && !loading {
+                            if let Some(url) = &tab.url {
+                                self.history
+                                    .record(url.as_str(), tab.title.as_deref().unwrap_or(""));
+                            }
+                        }
+                    }
                     if std::mem::take(&mut sig.frame_ready) && i == self.active {
                         active_frame = true;
                     }