Files
sovereign_browser/src/tab_manager.c

2671 lines
110 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* tab_manager.c — multi-tab management for sovereign_browser
*
* Each tab is a GtkBox page containing a per-tab toolbar (hamburger menu +
* URL entry) and a WebKitWebView. All webviews share the same
* WebKitWebContext so the sovereign:// bridge and security settings apply
* to every tab automatically.
*/
#include "tab_manager.h"
#include "settings.h"
#include "nostr_inject.h"
#include "history.h"
#include "bookmarks.h"
#include "search.h"
#include "version.h"
#include "agent_snapshot.h" /* agent_js_eval_sync() for clear-and-reload */
#include "db.h"
#include "agent_chat.h" /* agent_chat_route_input() for ";" URL-bar shortcut */
#include <string.h>
#include <strings.h> /* strcasecmp */
#include <stdlib.h>
#include <libsoup/soup.h>
#include <gdk-pixbuf/gdk-pixbuf.h>
/* Portable case-insensitive substring search (replaces GNU strcasestr). */
static const char *ci_strstr(const char *haystack, const char *needle) {
if (haystack == NULL || needle == NULL) return NULL;
if (*needle == '\0') return haystack;
size_t nlen = strlen(needle);
for (const char *p = haystack; *p; p++) {
if (strncasecmp(p, needle, nlen) == 0) return p;
}
return NULL;
}
/* ── External callbacks defined in main.c ─────────────────────────── *
* These handle identity-related menu actions that need access to the
* global app_state_t (signer, pubkey, method). main.c owns that state.
*/
/* Proxy functions defined in main.c that wrap the app_state_t-aware
* callbacks so they match GTK signal handler signatures. */
extern void app_menu_switch_identity_proxy(GtkMenuItem *item, gpointer data);
extern void app_menu_lock_session_proxy(GtkMenuItem *item, gpointer data);
extern void app_menu_logout_proxy(GtkMenuItem *item, gpointer data);
extern void app_menu_security_strip_proxy(GtkCheckMenuItem *item, gpointer data);
extern void app_menu_fips_proxy(GtkMenuItem *item, gpointer data);
extern void app_menu_nostr_sign_proxy(GtkMenuItem *item, gpointer data);
extern void app_menu_about_proxy(GtkMenuItem *item, gpointer data);
extern void on_menu_settings(GtkMenuItem *item, gpointer data);
extern void on_menu_profile(GtkMenuItem *item, gpointer data);
extern void on_menu_agent(GtkMenuItem *item, gpointer data);
/* Key press handler defined in main.c — connected to each webview so
* keyboard shortcuts are caught before WebKit consumes the event. */
extern gboolean on_key_press(GtkWidget *widget, GdkEventKey *event,
gpointer data);
/* ── Static state ─────────────────────────────────────────────────── */
static GtkWidget *g_notebook = NULL;
static WebKitWebContext *g_ctx = NULL;
static GtkWindow *g_window = NULL;
/* Active window/notebook for MCP get_active_webview().
* When a new window gets focus, these are updated so MCP tools operate
* on the focused window's webview. Defaults to the main window/notebook. */
static GtkWindow *g_active_window = NULL;
static GtkWidget *g_active_notebook = NULL;
/* Target notebook for the next tab_create() call.
*
* When non-NULL, tab_create()/tab_manager_new_tab() add the new tab to
* this notebook instead of g_notebook. Used by tab_manager_new_window()
* to place the first tab into the newly created window's notebook.
*
* When NULL, tab_manager_new_tab() falls back to g_active_notebook (the
* focused window's notebook) so Ctrl+T / "New Tab" opens in the active
* window rather than always the main window. */
static GtkWidget *g_target_notebook = NULL;
/* Related view for the next tab_create() call.
*
* When non-NULL, tab_create() creates the webview with
* webkit_web_view_new_with_related_view() so it shares the parent's
* WebProcess and window features (avoids the WindowFeatures assertion
* crash). Used by tab_manager_new_window(). */
static WebKitWebView *g_target_related_view = NULL;
/* Dynamic array of tab_info_t pointers, indexed by notebook page number. */
static tab_info_t **g_tabs = NULL;
static int g_tab_count = 0;
static int g_tab_cap = 0;
/* ── Forward declarations ─────────────────────────────────────────── */
static tab_info_t *tab_create(const char *url);
static GtkWidget *build_tab_label(tab_info_t *tab);
static GtkWidget *tab_find_notebook(GtkWidget *page);
static int tab_array_add(tab_info_t *tab);
static int tab_array_find(tab_info_t *tab);
static GtkWidget *build_hamburger_menu(tab_info_t *tab);
static char *normalize_url(const char *input);
static void on_tab_close_clicked_proxy_new(GtkMenuItem *item, gpointer data);
static void on_avatar_clicked(GtkButton *btn, gpointer data);
static GtkWidget *tab_manager_new_window(const char *url,
WebKitWebView *related_view);
static gboolean on_window_focus_in(GtkWidget *widget,
GdkEventFocus *event,
gpointer user_data);
static void on_aux_window_destroy(GtkWidget *widget,
gpointer user_data);
/* Forward declarations for signal handlers used by tab_manager_new_window
* (which is defined before these handlers in the file). */
static void on_load_changed(WebKitWebView *webview,
WebKitLoadEvent load_event,
gpointer user_data);
static gboolean on_load_failed(WebKitWebView *webview,
WebKitLoadEvent load_event,
gchar *failing_uri,
GError *error,
gpointer data);
static gboolean on_webview_context_menu(WebKitWebView *webview,
WebKitContextMenu *context_menu,
GdkEvent *event,
WebKitHitTestResult *hit_test,
gpointer user_data);
/* ── URL bar completion (search dropdown) ─────────────────────────── *
* Each tab's URL entry has a GtkEntryCompletion backed by a GtkListStore.
* The list store has these columns:
*/
enum {
COMPLETION_COL_DISPLAY = 0, /* visible text in the dropdown */
COMPLETION_COL_URL, /* the URL to navigate to (or NULL for
search suggestions — in that case
the display text is the search query) */
COMPLETION_COL_IS_DIRECT, /* TRUE = direct link, FALSE = search
suggestion */
COMPLETION_COL_COUNT
};
/* Per-tab completion state, attached to the GtkEntry via g_object_set_data. */
typedef struct {
GtkListStore *store;
guint suggest_req_id; /* active async suggestion request, 0=none */
char *last_query; /* last query we fetched suggestions for */
} completion_state_t;
static void on_url_changed(GtkEditable *editable, gpointer user_data);
static gboolean on_completion_match_selected(GtkEntryCompletion *completion,
GtkTreeModel *model,
GtkTreeIter *iter,
gpointer user_data);
static void on_suggestions_received(char **suggestions, gpointer user_data);
static void completion_state_free(completion_state_t *cs);
static void rebuild_completion(const char *query, completion_state_t *cs);
static gboolean on_url_key_press(GtkWidget *widget, GdkEventKey *event,
gpointer user_data);
/* ── URL helper (same logic as main.c's normalize_url) ────────────── *
* If the input looks like a URL (has a scheme, a dot with no spaces,
* is localhost, or is an about: page), it's normalized to a full URL.
* Otherwise, it's treated as a search query and sent to the active
* search engine.
*/
static char *normalize_url(const char *input) {
if (input == NULL || input[0] == '\0') {
return NULL;
}
/* If it looks like a URL, normalize it. */
if (search_is_url(input)) {
if (strstr(input, "://") != NULL) {
return g_strdup(input);
}
/* Allow about: URLs (e.g. about:blank) without a scheme prefix. */
if (strncmp(input, "about:", 6) == 0) {
return g_strdup(input);
}
/* sovereign:// internal pages. */
if (strncmp(input, "sovereign://", 12) == 0) {
return g_strdup(input);
}
return g_strdup_printf("https://%s", input);
}
/* Not a URL — treat as a search query. */
return search_build_search_url(input);
}
/* ── URL bar completion (search dropdown) ─────────────────────────── */
#define COMPLETION_MAX_DIRECT 8 /* max history/bookmark results */
#define COMPLETION_MAX_SUGGEST 8 /* max search engine suggestions */
#define COMPLETION_MIN_KEY_LEN 1 /* min chars before dropdown appears */
static void completion_state_free(completion_state_t *cs) {
if (cs == NULL) return;
if (cs->store) g_object_unref(cs->store);
if (cs->suggest_req_id) search_suggest_cancel(cs->suggest_req_id);
g_free(cs->last_query);
g_free(cs);
}
/*
* Extract a short display label from a URL for the dropdown.
* Strips the scheme and leading "www." for readability.
* Returns a newly allocated string.
*/
static char *url_display_label(const char *url, const char *title) {
if (title && title[0] != '\0') {
/* If we have a title, show "title — domain". */
const char *domain = url;
if (strncmp(domain, "https://", 8) == 0) domain += 8;
else if (strncmp(domain, "http://", 7) == 0) domain += 7;
if (strncmp(domain, "www.", 4) == 0) domain += 4;
/* Truncate domain at first / */
const char *slash = strchr(domain, '/');
int dlen = slash ? (int)(slash - domain) : (int)strlen(domain);
return g_strdup_printf("%s — %.*s", title, dlen, domain);
}
/* No title — just show the URL with scheme stripped. */
const char *label = url;
if (strncmp(label, "https://", 8) == 0) label += 8;
else if (strncmp(label, "http://", 7) == 0) label += 7;
return g_strdup(label);
}
/*
* Rebuild the completion list store for the given query.
* Called on every keystroke. Populates direct links (history + bookmarks)
* synchronously, then fires an async request for search engine suggestions.
*/
static void rebuild_completion(const char *query, completion_state_t *cs) {
if (cs == NULL || cs->store == NULL) return;
gtk_list_store_clear(cs->store);
if (query == NULL || query[0] == '\0') return;
/* Cancel any pending suggestion request. */
if (cs->suggest_req_id) {
search_suggest_cancel(cs->suggest_req_id);
cs->suggest_req_id = 0;
}
g_free(cs->last_query);
cs->last_query = g_strdup(query);
/* ── Direct links from history ────────────────────────────────── */
char **urls = NULL;
char **titles = NULL;
int count = 0;
db_history_search(query, &urls, &titles, &count, COMPLETION_MAX_DIRECT);
for (int i = 0; i < count; i++) {
char *label = url_display_label(urls[i], titles[i]);
GtkTreeIter iter;
gtk_list_store_append(cs->store, &iter);
gtk_list_store_set(cs->store, &iter,
COMPLETION_COL_DISPLAY, label,
COMPLETION_COL_URL, urls[i],
COMPLETION_COL_IS_DIRECT, TRUE,
-1);
g_free(label);
g_free(urls[i]);
g_free(titles[i]);
}
g_free(urls);
g_free(titles);
/* ── Direct links from bookmarks ──────────────────────────────── */
int dir_count = 0;
const bookmark_dir_t *dirs = bookmarks_get_dirs(&dir_count);
int bookmark_added = 0;
for (int d = 0; d < dir_count && bookmark_added < COMPLETION_MAX_DIRECT; d++) {
for (int b = 0; b < dirs[d].count && bookmark_added < COMPLETION_MAX_DIRECT; b++) {
const bookmark_t *bm = &dirs[d].bookmarks[b];
/* Case-insensitive substring search. */
if ((bm->url && ci_strstr(bm->url, query)) ||
(bm->title && ci_strstr(bm->title, query))) {
char *label = url_display_label(bm->url, bm->title);
GtkTreeIter iter;
gtk_list_store_append(cs->store, &iter);
gtk_list_store_set(cs->store, &iter,
COMPLETION_COL_DISPLAY, label,
COMPLETION_COL_URL, bm->url,
COMPLETION_COL_IS_DIRECT, TRUE,
-1);
g_free(label);
bookmark_added++;
}
}
}
/* ── Domain heuristic ─────────────────────────────────────────── *
* If the query has no spaces and no dots, offer to navigate directly
* to <query>.org and <query>.com. This helps first-time visits to
* known domains without going through the search engine. */
if (strchr(query, ' ') == NULL && strchr(query, '.') == NULL &&
strlen(query) > 0 && strlen(query) < 100) {
/* Only add if we don't already have a direct link matching. */
gboolean have_match = FALSE;
GtkTreeIter iter;
if (gtk_tree_model_get_iter_first(GTK_TREE_MODEL(cs->store), &iter)) {
do {
gchar *row_url = NULL;
gtk_tree_model_get(GTK_TREE_MODEL(cs->store), &iter,
COMPLETION_COL_URL, &row_url, -1);
if (row_url) {
/* Check if the domain part matches the query. */
const char *domain = row_url;
if (strncmp(domain, "https://", 8) == 0) domain += 8;
else if (strncmp(domain, "http://", 7) == 0) domain += 7;
if (strncmp(domain, "www.", 4) == 0) domain += 4;
if (strncasecmp(domain, query, strlen(query)) == 0) {
have_match = TRUE;
}
g_free(row_url);
}
} while (!have_match &&
gtk_tree_model_iter_next(GTK_TREE_MODEL(cs->store), &iter));
}
if (!have_match) {
char *org_url = g_strdup_printf("https://%s.org", query);
char *org_label = g_strdup_printf("🌐 Go to %s.org", query);
gtk_list_store_append(cs->store, &iter);
gtk_list_store_set(cs->store, &iter,
COMPLETION_COL_DISPLAY, org_label,
COMPLETION_COL_URL, org_url,
COMPLETION_COL_IS_DIRECT, TRUE,
-1);
g_free(org_url);
g_free(org_label);
char *com_url = g_strdup_printf("https://%s.com", query);
char *com_label = g_strdup_printf("🌐 Go to %s.com", query);
gtk_list_store_append(cs->store, &iter);
gtk_list_store_set(cs->store, &iter,
COMPLETION_COL_DISPLAY, com_label,
COMPLETION_COL_URL, com_url,
COMPLETION_COL_IS_DIRECT, TRUE,
-1);
g_free(com_url);
g_free(com_label);
}
}
/* ── Fire async search engine suggestions ─────────────────────── *
* The suggestions are appended to the store when the async callback
* fires (on_suggestions_received). We only fetch if the query doesn't
* look like a URL (no point suggesting searches for "wikipedia.org"). */
if (!search_is_url(query)) {
cs->suggest_req_id = search_suggest_fetch_async(
query, on_suggestions_received, cs);
}
}
/*
* Callback for async search engine suggestions.
* Appends the suggestions to the completion store. The user_data is
* the completion_state_t. We check that the query hasn't changed since
* we sent the request (stale results are discarded).
*/
static void on_suggestions_received(char **suggestions, gpointer user_data) {
completion_state_t *cs = (completion_state_t *)user_data;
if (cs == NULL || cs->store == NULL) return;
/* Mark the request as completed. */
cs->suggest_req_id = 0;
if (suggestions == NULL) return;
/* Append search suggestions after the direct links.
* For search suggestions, the URL column stores the search query
* itself (not a URL). The is_direct flag is FALSE, so
* on_completion_match_selected knows to build a search URL from it. */
for (int i = 0; suggestions[i] != NULL && i < COMPLETION_MAX_SUGGEST; i++) {
char *label = g_strdup_printf("🔍 %s", suggestions[i]);
GtkTreeIter iter;
gtk_list_store_append(cs->store, &iter);
gtk_list_store_set(cs->store, &iter,
COMPLETION_COL_DISPLAY, label,
COMPLETION_COL_URL, suggestions[i],
COMPLETION_COL_IS_DIRECT, FALSE,
-1);
g_free(label);
}
}
/*
* Called when the user types in the URL entry.
* Rebuilds the completion dropdown.
*/
static void on_url_changed(GtkEditable *editable, gpointer user_data) {
(void)user_data;
/* Only rebuild the completion dropdown when the user is actively
* typing. If the entry doesn't have focus, the text was set
* programmatically (e.g. by a navigation event updating the URL
* bar) and we shouldn't show the dropdown. */
if (!gtk_widget_has_focus(GTK_WIDGET(editable))) return;
const char *text = gtk_entry_get_text(GTK_ENTRY(editable));
completion_state_t *cs = (completion_state_t *)
g_object_get_data(G_OBJECT(editable), "completion-state");
if (cs == NULL) return;
rebuild_completion(text, cs);
}
/*
* Key press handler for the URL entry.
*
* When the completion dropdown is visible, Tab navigates down and
* Shift+Tab navigates up through the suggestions (like most browsers).
* Without this, Tab would move focus away from the URL bar.
*
* When the dropdown is not visible, Tab behaves normally (moves focus).
*/
static gboolean on_url_key_press(GtkWidget *widget, GdkEventKey *event,
gpointer user_data) {
(void)user_data;
/* Only intercept Tab when the completion popup is visible. */
if (event->keyval != GDK_KEY_Tab && event->keyval != GDK_KEY_ISO_Left_Tab)
return FALSE;
GtkEntry *entry = GTK_ENTRY(widget);
GtkEntryCompletion *completion = gtk_entry_get_completion(entry);
if (completion == NULL) return FALSE;
/* Check if the completion popup is visible. GtkEntryCompletion
* doesn't have a direct "is popup visible" API, but we can check
* if there's a completion selection by looking at the tree model.
* If the store has items, we intercept Tab to navigate. */
completion_state_t *cs = (completion_state_t *)
g_object_get_data(G_OBJECT(entry), "completion-state");
if (cs == NULL || cs->store == NULL) return FALSE;
GtkTreeIter iter;
if (!gtk_tree_model_get_iter_first(GTK_TREE_MODEL(cs->store), &iter))
return FALSE; /* No items in the store — let Tab do its normal thing. */
/* Synthesize a Down or Up arrow key event and forward it to the
* entry. GtkEntryCompletion intercepts arrow keys to navigate
* the dropdown selection. */
GdkEventKey *synth = (GdkEventKey *)gdk_event_new(GDK_KEY_PRESS);
synth->window = g_object_ref(event->window);
synth->send_event = TRUE;
synth->time = event->time;
synth->state = 0; /* No modifiers for the arrow key. */
synth->keyval = (event->state & GDK_SHIFT_MASK)
? GDK_KEY_Up
: GDK_KEY_Down;
synth->length = 0;
synth->string = NULL;
synth->hardware_keycode = 0;
synth->group = 0;
/* Forward the synthetic event to the entry widget. */
gtk_main_do_event((GdkEvent *)synth);
gdk_event_free((GdkEvent *)synth);
return TRUE; /* We handled the Tab — don't move focus. */
}
/*
* Called when the user selects an item from the completion dropdown.
* Navigates to the selected URL (direct link) or builds a search URL
* (for search suggestions).
*/
static gboolean on_completion_match_selected(GtkEntryCompletion *completion,
GtkTreeModel *model,
GtkTreeIter *iter,
gpointer user_data) {
(void)completion;
tab_info_t *tab = (tab_info_t *)user_data;
gchar *display = NULL;
gchar *url = NULL;
gboolean is_direct = FALSE;
gtk_tree_model_get(model, iter,
COMPLETION_COL_DISPLAY, &display,
COMPLETION_COL_URL, &url,
COMPLETION_COL_IS_DIRECT, &is_direct,
-1);
char *navigate_url = NULL;
if (is_direct && url != NULL) {
/* Direct link — navigate to the URL. */
navigate_url = g_strdup(url);
} else if (url != NULL) {
/* Search suggestion — the URL column stores the search query.
* Build a search URL from it. */
navigate_url = search_build_search_url(url);
} else {
/* Fallback: no URL stored. Use the display text as the query. */
navigate_url = search_build_search_url(display);
}
if (navigate_url) {
webkit_web_view_load_uri(tab->webview, navigate_url);
gtk_entry_set_text(GTK_ENTRY(tab->url_entry), navigate_url);
g_free(navigate_url);
}
g_free(display);
g_free(url);
return TRUE; /* We handled it — don't let GTK do default behavior. */
}
/* ── Tab label widget ─────────────────────────────────────────────── */
static void on_tab_close_clicked(GtkButton *btn, gpointer user_data) {
tab_info_t *tab = (tab_info_t *)user_data;
(void)btn;
/* Look up the tab's index in the global g_tabs array by pointer.
* This works for tabs in any window's notebook (the index returned
* by gtk_notebook_page_num only matches g_tabs for the main window). */
int index = tab_array_find(tab);
if (index >= 0) {
tab_manager_close_tab(index);
}
}
static gboolean on_tab_label_button_press(GtkWidget *widget,
GdkEventButton *event,
gpointer user_data) {
(void)widget;
tab_info_t *tab = (tab_info_t *)user_data;
/* Look up the tab's index in the global g_tabs array by pointer. */
int index = tab_array_find(tab);
if (index < 0) return FALSE;
const browser_settings_t *s = settings_get();
/* Middle-click to close. */
if (event->button == 2 && s->middle_click_close) {
tab_manager_close_tab(index);
return TRUE;
}
/* Right-click context menu. */
if (event->button == 3) {
GtkWidget *menu = gtk_menu_new();
GtkWidget *item_new = gtk_menu_item_new_with_label("New Tab");
g_signal_connect(item_new, "activate",
G_CALLBACK(on_tab_close_clicked_proxy_new), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_new);
GtkWidget *item_close = gtk_menu_item_new_with_label("Close Tab");
g_signal_connect_swapped(item_close, "activate",
G_CALLBACK(tab_manager_close_tab),
GINT_TO_POINTER(index));
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_close);
GtkWidget *item_close_others =
gtk_menu_item_new_with_label("Close Other Tabs");
g_signal_connect_swapped(item_close_others, "activate",
G_CALLBACK(tab_manager_close_others),
GINT_TO_POINTER(index));
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_close_others);
GtkWidget *item_close_right =
gtk_menu_item_new_with_label("Close Tabs to the Right");
g_signal_connect_swapped(item_close_right, "activate",
G_CALLBACK(tab_manager_close_to_right),
GINT_TO_POINTER(index));
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_close_right);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
GtkWidget *item_dup = gtk_menu_item_new_with_label("Duplicate Tab");
g_signal_connect_swapped(item_dup, "activate",
G_CALLBACK(tab_manager_duplicate),
GINT_TO_POINTER(index));
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_dup);
GtkWidget *item_reload = gtk_menu_item_new_with_label("Reload Tab");
g_signal_connect_swapped(item_reload, "activate",
G_CALLBACK(tab_manager_reload),
GINT_TO_POINTER(index));
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_reload);
gtk_widget_show_all(menu);
gtk_menu_popup_at_pointer(GTK_MENU(menu), (GdkEvent *)event);
return TRUE;
}
return FALSE;
}
/* Proxy callback for "New Tab" in the context menu — just calls new_tab. */
static void on_tab_close_clicked_proxy_new(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_manager_new_tab(NULL);
}
/* ── Favicon handling ─────────────────────────────────────────────── *
* WebKitGTK has a built-in favicon database (WebKitFaviconDatabase) that
* automatically fetches and caches favicons as pages load. It must be
* enabled once on the WebKitWebContext via
* webkit_web_context_set_favicon_database_directory() (done in main.c).
* Once enabled, the webview emits "notify::favicon" when the favicon is
* ready, and webkit_web_view_get_favicon() returns a cairo_surface_t.
*/
static void on_favicon_changed(WebKitWebView *webview, GParamSpec *pspec,
gpointer user_data) {
tab_info_t *tab = (tab_info_t *)user_data;
(void)pspec;
cairo_surface_t *favicon = webkit_web_view_get_favicon(webview);
if (favicon == NULL) return;
int fav_w = cairo_image_surface_get_width(favicon);
int fav_h = cairo_image_surface_get_height(favicon);
if (fav_w <= 0 || fav_h <= 0) return;
GdkPixbuf *raw = gdk_pixbuf_get_from_surface(favicon, 0, 0, fav_w, fav_h);
if (raw == NULL) return;
GdkPixbuf *pixbuf;
if (fav_w > 16 || fav_h > 16) {
pixbuf = gdk_pixbuf_scale_simple(raw, 16, 16, GDK_INTERP_BILINEAR);
g_object_unref(raw);
} else {
pixbuf = raw;
}
gtk_image_set_from_pixbuf(GTK_IMAGE(tab->favicon), pixbuf);
g_object_unref(pixbuf);
g_print("[favicon] Set favicon (%dx%d) for %s\n",
fav_w, fav_h, tab->current_url);
}
/* ── Navigation policy decision ────────────────────────────────────── *
* Handles target="_blank" links and middle-click links by opening them
* in a new tab instead of ignoring them. WebKit fires the "decide-policy"
* signal with a WebKitNavigationPolicyDecision when a link is clicked.
* If the navigation action is a link click with a target that would open
* a new view (WEBKIT_NAVIGATION_TYPE_LINK_CLICKED with a non-current
* browser action), we open a new tab and ignore the default decision.
*/
static gboolean on_decide_policy(WebKitWebView *webview,
WebKitPolicyDecision *decision,
WebKitPolicyDecisionType type,
gpointer user_data) {
(void)webview;
(void)user_data;
if (type != WEBKIT_POLICY_DECISION_TYPE_NAVIGATION_ACTION)
return FALSE;
WebKitNavigationPolicyDecision *nav_decision =
WEBKIT_NAVIGATION_POLICY_DECISION(decision);
WebKitNavigationAction *action =
webkit_navigation_policy_decision_get_navigation_action(nav_decision);
if (action == NULL)
return FALSE;
/* Only intercept link clicks, not form submissions or reloads. */
WebKitNavigationType nav_type =
webkit_navigation_action_get_navigation_type(action);
if (nav_type != WEBKIT_NAVIGATION_TYPE_LINK_CLICKED)
return FALSE;
/* Check if this is a new-tab request (target="_blank" or
* middle-click with modifier). webkit_navigation_action_get_request()
* gives us the URI. If the decision's frame name is not the main
* frame, or the user clicked with middle button / Ctrl, open a tab. */
guint button = webkit_navigation_action_get_mouse_button(action);
GdkModifierType mods = webkit_navigation_action_get_modifiers(action);
/* Middle-click or Ctrl+click opens a new tab. */
if (button == 2 || (mods & GDK_CONTROL_MASK)) {
WebKitURIRequest *request = webkit_navigation_action_get_request(action);
const char *uri = webkit_uri_request_get_uri(request);
if (uri && uri[0]) {
tab_manager_new_tab(uri);
}
webkit_policy_decision_ignore(decision);
return TRUE;
}
/* Check for target="_blank" — WebKit requests a new view for these.
* The navigation policy decision has a "frame name" that is non-NULL
* when a target is specified. We can't easily get the frame name from
* the API, but WebKit will call "create" on the webview for new
* windows. That's handled separately by the "create" signal.
* For now, let the default navigation proceed. */
return FALSE;
}
/* ── New webview creation (target="_blank") ────────────────────────── *
* When a page requests a new window (target="_blank", window.open()),
* WebKit fires the "create" signal on the webview. We create a new tab
* with the requested URI and return its webview.
*/
static GtkWidget *on_create_webview(WebKitWebView *webview,
WebKitNavigationAction *action,
gpointer user_data) {
(void)user_data;
WebKitURIRequest *request = webkit_navigation_action_get_request(action);
const char *uri = webkit_uri_request_get_uri(request);
/* When a page requests a new window (target="_blank", window.open()),
* WebKit fires the "create" signal. We create a real GtkWindow with
* its own webview (created with webkit_web_view_new_with_related_view
* so it shares the parent's WebProcess) and return that webview.
*
* Using webkit_web_view_new_with_related_view() shares the WebProcess
* and the related view's window features, which avoids the previous
* std::optional<WindowFeatures> assertion crash that occurred when
* returning a webview created with webkit_web_view_new_with_context().
*
* If new window creation fails, fall back to opening a tab. */
if (uri && uri[0]) {
GtkWidget *new_wv = tab_manager_new_window(uri, webview);
if (new_wv != NULL) {
return new_wv;
}
/* Fallback: open in a new tab in the main window. */
tab_manager_new_tab(uri);
}
return NULL;
}
/* ── New window creation (real GtkWindow for target="_blank") ──────── *
* Creates a new top-level GtkWindow with its own GtkNotebook and a
* single webview. The webview is created with
* webkit_web_view_new_with_related_view(related_view) when a parent
* webview is available, so it shares the parent's WebProcess and
* window features — this avoids the std::optional<WindowFeatures>
* assertion crash that occurred with webkit_web_view_new_with_context().
*
* The new window's webview gets the same setup as a regular tab:
* WebKitSettings, nostr_inject_setup, key-press-event, decide-policy,
* create, load-changed, etc. The window does NOT quit the app when
* closed — only the main window (in main.c) does that.
*
* Returns the new WebKitWebView widget, or NULL on failure.
*/
/* focus-in-event handler for auxiliary windows: updates the global
* active window/notebook pointers so MCP get_active_webview() resolves
* to the focused window's webview. */
static gboolean on_window_focus_in(GtkWidget *widget,
GdkEventFocus *event,
gpointer user_data) {
(void)event;
GtkWidget *notebook = GTK_WIDGET(user_data);
if (widget == NULL || notebook == NULL) return FALSE;
g_active_window = GTK_WINDOW(widget);
g_active_notebook = notebook;
g_print("[windows] Active window changed to %p (notebook %p)\n",
(void *)g_active_window, (void *)g_active_notebook);
return FALSE;
}
/* destroy handler for auxiliary windows: if this was the active window,
* fall back to the main window/notebook so MCP keeps working. Does NOT
* quit the app — only the main window's destroy handler does that. */
static void on_aux_window_destroy(GtkWidget *widget, gpointer user_data) {
(void)user_data;
if (g_active_window == GTK_WINDOW(widget)) {
g_active_window = g_window;
g_active_notebook = g_notebook;
g_print("[windows] Active window closed, reverting to main window\n");
}
g_print("[windows] Auxiliary window destroyed: %p\n", (void *)widget);
}
static GtkWidget *tab_manager_new_window(const char *url,
WebKitWebView *related_view) {
if (g_ctx == NULL) return NULL;
/* Create the top-level window. */
GtkWidget *window = gtk_window_new(GTK_WINDOW_TOPLEVEL);
gtk_window_set_title(GTK_WINDOW(window), "sovereign browser");
gtk_window_set_default_size(GTK_WINDOW(window), 1024, 768);
/* Create a notebook for this window. It holds the full tab
* infrastructure (toolbar, URL entry, tab label, favicon, etc.)
* built by tab_create(), so the new window looks and behaves like
* the main window. */
GtkWidget *notebook = gtk_notebook_new();
gtk_notebook_set_scrollable(GTK_NOTEBOOK(notebook), FALSE);
gtk_notebook_set_show_border(GTK_NOTEBOOK(notebook), FALSE);
gtk_notebook_set_show_tabs(GTK_NOTEBOOK(notebook), TRUE);
gtk_container_add(GTK_CONTAINER(window), notebook);
/* Build a full tab via tab_create(). Set g_target_notebook so the
* tab is added to this window's notebook (not the main notebook),
* and g_target_related_view so the webview is created with
* webkit_web_view_new_with_related_view() (shares the parent's
* WebProcess and window features, avoiding the WindowFeatures
* assertion crash). Restore both globals afterwards. */
g_target_notebook = notebook;
g_target_related_view = (related_view != NULL &&
WEBKIT_IS_WEB_VIEW(related_view))
? related_view : NULL;
tab_info_t *tab = tab_create(url);
g_target_related_view = NULL;
g_target_notebook = NULL;
if (tab == NULL) {
gtk_widget_destroy(window);
return NULL;
}
/* Register the tab in the global g_tabs array and add it to the
* new window's notebook. tab_create() already loaded the URL and
* wired all per-tab signals (load-changed, decide-policy, create,
* context-menu, favicon, url-entry, etc.) with the tab_info_t as
* user_data, so the new window's tab gets the same behavior as a
* main-window tab. */
int index = tab_array_add(tab);
if (index < 0) {
g_free(tab);
gtk_widget_destroy(window);
return NULL;
}
const browser_settings_t *s = settings_get();
int page_num = gtk_notebook_append_page(GTK_NOTEBOOK(notebook),
tab->page, tab->tab_label);
gtk_notebook_set_tab_reorderable(GTK_NOTEBOOK(notebook), tab->page,
s->tab_drag_reorder);
/* Show the tab widgets before switching to it. */
gtk_widget_show_all(tab->page);
gtk_widget_show_all(tab->tab_label);
/* Re-hide the sidebar container after show_all (see
* tab_manager_new_tab for the rationale — Issue 2). */
if (tab->sidebar_container) {
gtk_widget_hide(tab->sidebar_container);
}
gtk_notebook_set_current_page(GTK_NOTEBOOK(notebook), page_num);
/* Window lifecycle: focus-in updates the active window/notebook
* pointers; destroy reverts to the main window but does NOT quit. */
g_signal_connect(window, "focus-in-event",
G_CALLBACK(on_window_focus_in), notebook);
g_signal_connect(window, "destroy",
G_CALLBACK(on_aux_window_destroy), NULL);
/* Show everything and present the window. */
gtk_widget_show_all(window);
gtk_window_present(GTK_WINDOW(window));
/* This new window is now the active window. */
g_active_window = GTK_WINDOW(window);
g_active_notebook = notebook;
g_print("[windows] Created new window %p (tab %d) for %s\n",
(void *)window, index, url ? url : "(none)");
return GTK_WIDGET(tab->webview);
}
static GtkWidget *build_tab_label(tab_info_t *tab) {
GtkWidget *box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
/* Make the tab label expand and fill so tabs divide the available
* space evenly across the tab strip. */
gtk_widget_set_hexpand(box, TRUE);
gtk_widget_set_halign(box, GTK_ALIGN_FILL);
gtk_widget_set_size_request(box, 120, -1); /* min width for readability */
/* Favicon — starts empty (no placeholder icon). Updated via the
* notify::favicon signal when a real favicon is available. */
tab->favicon = gtk_image_new();
gtk_widget_set_valign(tab->favicon, GTK_ALIGN_CENTER);
gtk_box_pack_start(GTK_BOX(box), tab->favicon, FALSE, FALSE, 0);
/* Title label — expands to fill, ellipsizes if too long. */
tab->title_label = gtk_label_new("New Tab");
gtk_label_set_ellipsize(GTK_LABEL(tab->title_label), PANGO_ELLIPSIZE_END);
gtk_label_set_max_width_chars(GTK_LABEL(tab->title_label), 20);
gtk_widget_set_hexpand(tab->title_label, TRUE);
gtk_widget_set_valign(tab->title_label, GTK_ALIGN_CENTER);
gtk_box_pack_start(GTK_BOX(box), tab->title_label, TRUE, TRUE, 0);
/* Close button. */
const browser_settings_t *s = settings_get();
if (s->show_tab_close_buttons) {
GtkWidget *close_btn = gtk_button_new();
gtk_button_set_relief(GTK_BUTTON(close_btn), GTK_RELIEF_NONE);
gtk_button_set_image(GTK_BUTTON(close_btn),
gtk_image_new_from_icon_name("window-close-symbolic",
GTK_ICON_SIZE_MENU));
gtk_widget_set_tooltip_text(close_btn, "Close tab");
g_signal_connect(close_btn, "clicked",
G_CALLBACK(on_tab_close_clicked), tab);
gtk_box_pack_start(GTK_BOX(box), close_btn, FALSE, FALSE, 0);
}
/* Connect button-press for middle-click and right-click on the label. */
gtk_widget_add_events(box, GDK_BUTTON_PRESS_MASK);
g_signal_connect(box, "button-press-event",
G_CALLBACK(on_tab_label_button_press), tab);
gtk_widget_show_all(box);
return box;
}
/* ── Per-tab signal handlers ──────────────────────────────────────── */
static void on_url_activate(GtkEntry *entry, gpointer user_data) {
tab_info_t *tab = (tab_info_t *)user_data;
const char *text = gtk_entry_get_text(entry);
/* Agent chat shortcut: "; <message>" routes to the embedded agent. */
if (text[0] == ';') {
const char *msg = text + 1;
/* Skip leading whitespace after the semicolon. */
while (*msg == ' ' || *msg == '\t') msg++;
agent_chat_route_input(*msg ? msg : NULL);
/* Clear the URL bar after sending to agent. */
gtk_entry_set_text(entry, "");
return;
}
char *url = normalize_url(text);
if (url != NULL) {
webkit_web_view_load_uri(tab->webview, url);
g_free(url);
}
}
/* Back button — navigate to the previous page in history. */
static void on_back_clicked(GtkButton *btn, gpointer user_data) {
(void)btn;
tab_info_t *tab = (tab_info_t *)user_data;
if (tab && tab->webview && webkit_web_view_can_go_back(tab->webview)) {
webkit_web_view_go_back(tab->webview);
}
}
/* Forward button — navigate to the next page in history. */
static void on_forward_clicked(GtkButton *btn, gpointer user_data) {
(void)btn;
tab_info_t *tab = (tab_info_t *)user_data;
if (tab && tab->webview && webkit_web_view_can_go_forward(tab->webview)) {
webkit_web_view_go_forward(tab->webview);
}
}
/* Refresh button — left-click: normal reload. */
static void on_refresh_clicked(GtkButton *btn, gpointer user_data) {
(void)btn;
tab_info_t *tab = (tab_info_t *)user_data;
if (tab && tab->webview) {
webkit_web_view_reload(tab->webview);
}
}
/* Hard reload menu item — bypasses cache. */
static void on_hard_reload(GtkMenuItem *item, gpointer user_data) {
(void)item;
tab_info_t *tab = (tab_info_t *)user_data;
if (tab && tab->webview) {
webkit_web_view_reload_bypass_cache(tab->webview);
}
}
/* Clear site data + hard reload menu item. */
static void on_clear_and_reload(GtkMenuItem *item, gpointer user_data) {
(void)item;
tab_info_t *tab = (tab_info_t *)user_data;
if (!tab || !tab->webview) return;
/* Clear localStorage and sessionStorage via JS. */
const char *clear_js =
"(function(){try{localStorage.clear();}catch(e){}"
"try{sessionStorage.clear();}catch(e){}"
"return 'ok';})()";
char *result = agent_js_eval_sync(tab->webview, clear_js, 3000);
g_free(result);
/* Hard reload to bypass cache. */
webkit_web_view_reload_bypass_cache(tab->webview);
}
/* Refresh button — right-click: show dropdown with hard reload options. */
static gboolean on_refresh_button_press(GtkWidget *widget,
GdkEventButton *event,
gpointer user_data) {
tab_info_t *tab = (tab_info_t *)user_data;
(void)widget;
if (event->button == 3) { /* Right-click */
GtkWidget *menu = gtk_menu_new();
GtkWidget *item_reload = gtk_menu_item_new_with_label("Reload");
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_reload);
g_signal_connect(item_reload, "activate",
G_CALLBACK(on_refresh_clicked), tab);
GtkWidget *item_hard = gtk_menu_item_new_with_label(
"Hard reload (bypass cache)");
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_hard);
g_signal_connect(item_hard, "activate",
G_CALLBACK(on_hard_reload), tab);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
GtkWidget *item_clear = gtk_menu_item_new_with_label(
"Clear site data + hard reload");
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_clear);
g_signal_connect(item_clear, "activate",
G_CALLBACK(on_clear_and_reload), tab);
gtk_widget_show_all(menu);
gtk_menu_popup_at_pointer(GTK_MENU(menu), (GdkEvent *)event);
return TRUE; /* Suppress default button-press handling */
}
return FALSE; /* Let left-click propagate to "clicked" signal */
}
static void on_load_changed(WebKitWebView *webview,
WebKitLoadEvent load_event,
gpointer user_data) {
tab_info_t *tab = (tab_info_t *)user_data;
/* tab is NULL for webviews in auxiliary (new) windows, which don't
* have a tab_info_t. Skip the tab-UI updates (URL bar, favicon, tab
* title) but still record history on load-finished below. */
if (load_event == WEBKIT_LOAD_COMMITTED) {
if (tab == NULL) return;
const gchar *uri = webkit_web_view_get_uri(webview);
if (uri != NULL) {
/* Clear the favicon — the new page's favicon (if any) will
* arrive via the notify::favicon signal. This prevents a
* stale favicon from the previous page lingering. */
gtk_image_clear(GTK_IMAGE(tab->favicon));
/* Show an empty URL bar for blank pages so the user can
* immediately type a URL. Keep current_url as the real URI
* (about:blank) so session save/duplicate still work. */
if (strcmp(uri, "about:blank") == 0) {
gtk_entry_set_text(GTK_ENTRY(tab->url_entry), "");
} else {
gtk_entry_set_text(GTK_ENTRY(tab->url_entry), uri);
}
snprintf(tab->current_url, sizeof(tab->current_url), "%s", uri);
/* Set a temporary title from the URL host while the page loads.
* This ensures the tab shows something meaningful immediately,
* not just "New Tab" or "Loading…". */
const char *host = strstr(uri, "://");
if (host) host += 3;
else host = uri;
/* Strip path — just show the domain. */
char host_buf[256];
snprintf(host_buf, sizeof(host_buf), "%s", host);
char *slash = strchr(host_buf, '/');
if (slash) *slash = '\0';
if (host_buf[0]) {
int index = tab_array_find(tab);
if (index >= 0) {
tab_manager_set_title(index, host_buf);
}
}
}
} else if (load_event == WEBKIT_LOAD_FINISHED) {
const gchar *title = webkit_web_view_get_title(webview);
const gchar *uri = webkit_web_view_get_uri(webview);
g_print("[loaded] %s -- title: %s\n",
uri ? uri : "(null)",
(title && title[0]) ? title : "(none)");
/* Update tab title (works for tabs in any window's notebook). */
if (tab != NULL && title && title[0]) {
int index = tab_array_find(tab);
if (index >= 0) {
tab_manager_set_title(index, title);
}
}
/* Add to history (with title for the Recents submenu tooltip).
* This applies to both main-window tabs and auxiliary windows. */
if (uri != NULL && uri[0] != '\0') {
history_add_titled(uri, (title && title[0]) ? title : NULL);
}
}
}
static gboolean on_load_failed(WebKitWebView *webview,
WebKitLoadEvent load_event,
gchar *failing_uri,
GError *error,
gpointer data) {
(void)webview;
(void)load_event;
(void)data;
g_print("[failed] %s -- %s\n",
failing_uri ? failing_uri : "(null)",
error ? error->message : "(unknown)");
return FALSE;
}
/* ── Web view context menu (right-click) ──────────────────────────── */
/* Callback for "Open Link in New Tab" / "Open Page in New Tab". */
static void on_context_open_in_new_tab(GSimpleAction *action,
GVariant *parameter,
gpointer user_data) {
(void)action;
(void)parameter;
char *url = (char *)user_data;
if (url && url[0]) {
tab_manager_new_tab(url);
}
g_free(url);
}
/* Context menu signal handler for the webview. Appends "Open in New Tab"
* to the default WebKit context menu — either "Open Link in New Tab" if
* right-clicking a link, or "Open Page in New Tab" if right-clicking the
* page background. */
static gboolean on_webview_context_menu(WebKitWebView *webview,
WebKitContextMenu *context_menu,
GdkEvent *event,
WebKitHitTestResult *hit_test,
gpointer user_data) {
(void)webview;
(void)event;
(void)user_data;
/* Determine what was clicked. */
const char *url = NULL;
const char *label = NULL;
int is_link = 0;
if (webkit_hit_test_result_context_is_link(hit_test)) {
url = webkit_hit_test_result_get_link_uri(hit_test);
label = "Open Link in New Tab";
is_link = 1;
} else if (!webkit_hit_test_result_context_is_image(hit_test) &&
!webkit_hit_test_result_context_is_media(hit_test) &&
!webkit_hit_test_result_context_is_editable(hit_test) &&
!webkit_hit_test_result_context_is_selection(hit_test) &&
!webkit_hit_test_result_context_is_scrollbar(hit_test)) {
/* Not a link/image/media/editable/selection/scrollbar — it's the
* page background (document context). */
url = webkit_web_view_get_uri(webview);
label = "Open Page in New Tab";
}
if (url == NULL || url[0] == '\0') {
return FALSE; /* Let the default menu show without our item. */
}
/* Build our custom "Open in New Tab" item. */
GSimpleAction *action = g_simple_action_new("open-in-new-tab", NULL);
char *url_copy = g_strdup(url);
g_signal_connect_data(action, "activate",
G_CALLBACK(on_context_open_in_new_tab), url_copy,
(GClosureNotify)g_free, 0);
WebKitContextMenuItem *item =
webkit_context_menu_item_new_from_gaction(G_ACTION(action), label,
NULL);
/* For links: WebKit's default menu starts with "Open Link" (pos 0)
* then "Open Link in New Window" (pos 1). Insert "Open Link in New
* Tab" at position 1 — between "Open Link" and "Open Link in New
* Window" — so the order is:
* Open Link
* Open Link in New Tab
* Open Link in New Window
*
* For page background: insert at position 0 (top). */
int insert_pos = is_link ? 1 : 0;
webkit_context_menu_insert(context_menu, item, insert_pos);
/* The action is owned by the menu item now. g_object_unref is safe
* because the WebKitContextMenuItem holds its own ref. */
g_object_unref(action);
return FALSE; /* Let WebKit show the menu. */
}
/* ── Hamburger menu callbacks (webview-specific) ──────────────────── */
static void on_menu_reload(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_reload_bypass_cache(tab->webview);
}
}
static void on_menu_stop(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_stop_loading(tab->webview);
}
}
/* ── Inspector window position/size persistence ────────────────────── *
* When the inspector detaches into its own window, we track the window's
* position and size via configure-event and save to settings. On the
* next show, we restore the saved geometry. */
static gboolean on_inspector_window_configure(GtkWidget *widget,
GdkEventConfigure *event,
gpointer user_data) {
(void)widget;
(void)user_data;
browser_settings_t *bs = settings_get_mutable();
bs->inspector_x = event->x;
bs->inspector_y = event->y;
bs->inspector_w = event->width;
bs->inspector_h = event->height;
settings_save();
return FALSE;
}
/* Find the detached inspector's toplevel GtkWindow and hook configure-event.
* Called when the inspector's WebView is realized (after detach). */
static void on_inspector_webview_realize(GtkWidget *widget,
gpointer user_data) {
(void)user_data;
GtkWidget *toplevel = gtk_widget_get_toplevel(widget);
if (toplevel && GTK_IS_WINDOW(toplevel)) {
/* Restore saved geometry if we have it. */
const browser_settings_t *bs = settings_get();
if (bs->inspector_w > 0 && bs->inspector_h > 0) {
gtk_window_resize(GTK_WINDOW(toplevel),
bs->inspector_w, bs->inspector_h);
}
if (bs->inspector_x >= 0 && bs->inspector_y >= 0) {
gtk_window_move(GTK_WINDOW(toplevel),
bs->inspector_x, bs->inspector_y);
}
/* Track future changes. */
g_signal_connect(toplevel, "configure-event",
G_CALLBACK(on_inspector_window_configure), NULL);
}
}
static void on_inspector_detach(WebKitWebInspector *inspector,
gpointer user_data) {
(void)user_data;
WebKitWebViewBase *insp_view = webkit_web_inspector_get_web_view(inspector);
if (insp_view) {
GtkWidget *w = GTK_WIDGET(insp_view);
/* Hook realize to catch the detached window. */
g_signal_connect(w, "realize",
G_CALLBACK(on_inspector_webview_realize), NULL);
}
}
/* Track whether the inspector is currently shown for the active tab.
* We use a per-tab flag stored in a static hash (inspector state is
* per-webview, but we track it globally for the toggle). */
static gboolean g_inspector_visible = FALSE;
void tab_manager_toggle_inspector(void) {
tab_info_t *tab = tab_manager_get_active();
if (!tab || !tab->webview) return;
WebKitWebInspector *insp = webkit_web_view_get_inspector(tab->webview);
if (!insp) return;
/* Connect signals once (idempotent — WebKitWebInspector is per-webview
* and persists for the webview's lifetime). We check a g_object data
* flag to avoid connecting multiple times. */
if (g_object_get_data(G_OBJECT(insp), "sovereign-inspector-hooked") == NULL) {
g_signal_connect(insp, "detach",
G_CALLBACK(on_inspector_detach), NULL);
g_object_set_data(G_OBJECT(insp), "sovereign-inspector-hooked",
GINT_TO_POINTER(1));
}
if (g_inspector_visible) {
webkit_web_inspector_close(insp);
g_inspector_visible = FALSE;
} else {
webkit_web_inspector_show(insp);
g_inspector_visible = TRUE;
}
}
static void on_menu_inspector(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_manager_toggle_inspector();
}
static void on_menu_toggle_sidebar(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_manager_toggle_sidebar();
}
static void on_menu_open_file(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
if (g_window == NULL) return;
GtkWidget *dialog = gtk_file_chooser_dialog_new(
"Open File", g_window, GTK_FILE_CHOOSER_ACTION_OPEN,
"_Cancel", GTK_RESPONSE_CANCEL,
"_Open", GTK_RESPONSE_ACCEPT, NULL);
GtkFileFilter *filter_html = gtk_file_filter_new();
gtk_file_filter_set_name(filter_html, "HTML files");
gtk_file_filter_add_pattern(filter_html, "*.html");
gtk_file_filter_add_pattern(filter_html, "*.htm");
gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(dialog), filter_html);
GtkFileFilter *filter_all = gtk_file_filter_new();
gtk_file_filter_set_name(filter_all, "All files");
gtk_file_filter_add_pattern(filter_all, "*");
gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(dialog), filter_all);
if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT) {
char *filename = gtk_file_chooser_get_filename(GTK_FILE_CHOOSER(dialog));
char *uri = g_strdup_printf("file://%s", filename);
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_load_uri(tab->webview, uri);
}
g_free(uri);
g_free(filename);
}
gtk_widget_destroy(dialog);
}
static void on_menu_history_item(GtkMenuItem *item, gpointer data) {
(void)data;
tab_info_t *tab = tab_manager_get_active();
if (tab == NULL || tab->webview == NULL || item == NULL) return;
/* The full URL is attached as "history-url" data (the label may be
* truncated for display). */
const char *url = g_object_get_data(G_OBJECT(item), "history-url");
if (url && url[0]) {
char *normalized = normalize_url(url);
if (normalized) {
webkit_web_view_load_uri(tab->webview, normalized);
g_free(normalized);
}
}
}
static void on_menu_history_clear(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
history_clear();
g_print("[history] cleared\n");
}
static void on_history_menu_show(GtkWidget *menu, gpointer user_data) {
(void)user_data;
/* Remove all existing items. */
GList *children = gtk_container_get_children(GTK_CONTAINER(menu));
for (GList *l = children; l != NULL; l = l->next) {
gtk_widget_destroy(GTK_WIDGET(l->data));
}
g_list_free(children);
/* Rebuild with current history. */
int hcount = history_count();
if (hcount == 0) {
GtkWidget *empty = gtk_menu_item_new_with_label("(no recent pages)");
gtk_widget_set_sensitive(empty, FALSE);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), empty);
} else {
int show = hcount < 20 ? hcount : 20;
for (int i = 0; i < show; i++) {
char *url = history_get(i);
if (url == NULL) break;
char label[80];
if (strlen(url) > 75) {
snprintf(label, sizeof(label), "%.72s...", url);
} else {
snprintf(label, sizeof(label), "%s", url);
}
GtkWidget *hitem = gtk_menu_item_new_with_label(label);
gtk_widget_set_tooltip_text(hitem, url);
/* Attach the URL to the item so the callback can retrieve it
* and so it's freed when the item is destroyed. */
g_object_set_data_full(G_OBJECT(hitem), "history-url", url,
(GDestroyNotify)g_free);
g_signal_connect(hitem, "activate",
G_CALLBACK(on_menu_history_item), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), hitem);
}
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
GtkWidget *clear_item = gtk_menu_item_new_with_label("Clear Recents");
g_signal_connect(clear_item, "activate",
G_CALLBACK(on_menu_history_clear), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), clear_item);
}
gtk_widget_show_all(menu);
}
/* ── Bookmark handlers ─────────────────────────────────────────────── */
/* Called when a bookmark is clicked in the submenu — navigate to it. */
static void on_bookmark_item_clicked(GtkMenuItem *item, gpointer data) {
(void)item;
const char *url = (const char *)data;
if (url == NULL) return;
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_load_uri(tab->webview, url);
}
}
/* Called when "Manage Bookmarks…" is clicked in the submenu. */
static void on_manage_bookmarks_clicked(GtkMenuItem *item, gpointer data) {
(void)item;
(void)data;
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_load_uri(tab->webview, "sovereign://bookmarks");
} else {
tab_manager_new_tab("sovereign://bookmarks");
}
}
/* Wrapper for g_free to match GClosureNotify signature (avoids
* -Wcast-function-type warning from g_signal_connect_data). */
static void closure_notify_g_free(gpointer data, GClosure *closure) {
(void)closure;
g_free(data);
}
/* Rebuild the bookmarks submenu each time it's shown (like Recents). */
static void on_bookmarks_menu_show(GtkWidget *menu, gpointer user_data) {
(void)user_data;
/* Remove existing items. */
GList *children = gtk_container_get_children(GTK_CONTAINER(menu));
for (GList *l = children; l != NULL; l = l->next) {
gtk_widget_destroy(GTK_WIDGET(l->data));
}
g_list_free(children);
/* Get current bookmarks. */
int dir_count = 0;
const bookmark_dir_t *dirs = bookmarks_get_dirs(&dir_count);
int total_bookmarks = 0;
for (int i = 0; i < dir_count; i++) {
total_bookmarks += dirs[i].count;
}
if (total_bookmarks == 0) {
GtkWidget *empty = gtk_menu_item_new_with_label("(no bookmarks)");
gtk_widget_set_sensitive(empty, FALSE);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), empty);
} else {
/* Show bookmarks grouped by directory (up to 15 total). */
int shown = 0;
for (int i = 0; i < dir_count && shown < 15; i++) {
const bookmark_dir_t *dir = &dirs[i];
if (dir->count == 0) continue;
/* Directory label (non-clickable). */
char label[160];
snprintf(label, sizeof(label), "— %s —", dir->name);
GtkWidget *dir_item = gtk_menu_item_new_with_label(label);
gtk_widget_set_sensitive(dir_item, FALSE);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), dir_item);
for (int j = 0; j < dir->count && shown < 15; j++) {
const bookmark_t *bm = &dir->bookmarks[j];
/* Use the title if available, otherwise the URL. */
const char *label_text = bm->title[0] ? bm->title : bm->url;
char short_label[80];
if (strlen(label_text) > 75) {
snprintf(short_label, sizeof(short_label), "%.72s...",
label_text);
} else {
snprintf(short_label, sizeof(short_label), "%s",
label_text);
}
GtkWidget *bm_item = gtk_menu_item_new_with_label(short_label);
gtk_widget_set_tooltip_text(bm_item, bm->url);
/* Store the URL in a g_strdup'd string attached to the item. */
char *url_copy = g_strdup(bm->url);
g_signal_connect_data(bm_item, "activate",
G_CALLBACK(on_bookmark_item_clicked), url_copy,
(GClosureNotify)closure_notify_g_free, 0);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), bm_item);
shown++;
}
}
}
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
GtkWidget *manage_item =
gtk_menu_item_new_with_label("Manage Bookmarks…");
g_signal_connect(manage_item, "activate",
G_CALLBACK(on_manage_bookmarks_clicked), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), manage_item);
gtk_widget_show_all(menu);
}
/* Called when the bookmark button (toolbar) is clicked.
* Shows a dialog to pick or create a directory, then bookmarks the
* current page. */
static void on_bookmark_clicked(GtkButton *btn, gpointer user_data) {
(void)btn;
tab_info_t *tab = (tab_info_t *)user_data;
if (tab == NULL || tab->webview == NULL) return;
/* Get the current URL and title. */
const gchar *url = webkit_web_view_get_uri(tab->webview);
if (url == NULL || url[0] == '\0') {
g_print("[bookmarks] No URL to bookmark\n");
return;
}
/* Skip sovereign:// pages (can't bookmark internal pages). */
if (strncmp(url, "sovereign://", 12) == 0) {
g_print("[bookmarks] Cannot bookmark internal pages\n");
return;
}
/* Get the page title. */
const gchar *title = webkit_web_view_get_title(tab->webview);
if (title == NULL) title = "";
/* Build the directory picker dialog. */
GtkWidget *dialog = gtk_dialog_new_with_buttons(
"Bookmark Page", g_window,
GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT,
"_Cancel", GTK_RESPONSE_CANCEL,
"_Add", GTK_RESPONSE_ACCEPT,
NULL);
gtk_window_set_default_size(GTK_WINDOW(dialog), 350, 150);
GtkWidget *content = gtk_dialog_get_content_area(GTK_DIALOG(dialog));
gtk_container_set_border_width(GTK_CONTAINER(content), 12);
/* Show the URL being bookmarked. */
char *url_esc = g_markup_escape_text(url, -1);
char *title_esc = g_markup_escape_text(title, -1);
char *info = g_strdup_printf(
"<b>%s</b>\n<span size='small' color='#888'>%s</span>",
title_esc[0] ? title_esc : "(untitled)", url_esc);
GtkWidget *lbl = gtk_label_new(NULL);
gtk_label_set_markup(GTK_LABEL(lbl), info);
gtk_label_set_line_wrap(GTK_LABEL(lbl), TRUE);
gtk_widget_set_halign(lbl, GTK_ALIGN_START);
gtk_box_pack_start(GTK_BOX(content), lbl, FALSE, FALSE, 8);
/* Directory combo box. */
GtkWidget *dir_label = gtk_label_new("Directory:");
gtk_widget_set_halign(dir_label, GTK_ALIGN_START);
gtk_box_pack_start(GTK_BOX(content), dir_label, FALSE, FALSE, 4);
GtkWidget *combo = gtk_combo_box_text_new_with_entry();
int dir_count = 0;
const bookmark_dir_t *dirs = bookmarks_get_dirs(&dir_count);
for (int i = 0; i < dir_count; i++) {
gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(combo),
dirs[i].name);
}
/* Default to "General" if it exists, otherwise the first directory. */
int default_idx = 0;
for (int i = 0; i < dir_count; i++) {
if (strcmp(dirs[i].name, "General") == 0) { default_idx = i; break; }
}
gtk_combo_box_set_active(GTK_COMBO_BOX(combo), default_idx);
gtk_box_pack_start(GTK_BOX(content), combo, FALSE, FALSE, 4);
g_free(url_esc);
g_free(title_esc);
g_free(info);
gtk_widget_show_all(dialog);
gint response = gtk_dialog_run(GTK_DIALOG(dialog));
if (response == GTK_RESPONSE_ACCEPT) {
/* Get the selected/entered directory name. */
const char *dir_name = gtk_combo_box_text_get_active_text(
GTK_COMBO_BOX_TEXT(combo));
if (dir_name == NULL || dir_name[0] == '\0') {
dir_name = "General";
}
char *dir_copy = g_strdup(dir_name);
char *url_copy = g_strdup(url);
char *title_copy = g_strdup(title);
/* Add the bookmark. This encrypts and publishes in the caller's
* thread — for now that's the main thread (the publish is
* synchronous and may block briefly). A future improvement would
* run this in a background thread. */
int rc = bookmarks_add(dir_copy, url_copy, title_copy);
if (rc == 0) {
g_print("[bookmarks] Bookmarked '%s' to '%s'\n", url_copy,
dir_copy);
} else {
g_printerr("[bookmarks] Failed to bookmark (no signer?)\n");
}
g_free(dir_copy);
g_free(url_copy);
g_free(title_copy);
}
gtk_widget_destroy(dialog);
}
/* ── Hamburger menu builder ───────────────────────────────────────── */
static GtkWidget *build_hamburger_menu(tab_info_t *tab) {
(void)tab;
GtkWidget *menu = gtk_menu_new();
/* Navigation group. */
GtkWidget *item_open = gtk_menu_item_new_with_label("Open File…");
GtkWidget *item_reload = gtk_menu_item_new_with_label("Reload");
GtkWidget *item_stop = gtk_menu_item_new_with_label("Stop");
g_signal_connect(item_open, "activate", G_CALLBACK(on_menu_open_file), NULL);
g_signal_connect(item_reload, "activate", G_CALLBACK(on_menu_reload), NULL);
g_signal_connect(item_stop, "activate", G_CALLBACK(on_menu_stop), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_open);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_reload);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_stop);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
/* Recents submenu. */
GtkWidget *history_menu = gtk_menu_new();
g_signal_connect(history_menu, "show", G_CALLBACK(on_history_menu_show), NULL);
GtkWidget *item_history = gtk_menu_item_new_with_label("Recents");
gtk_menu_item_set_submenu(GTK_MENU_ITEM(item_history), history_menu);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_history);
/* Bookmarks submenu. */
GtkWidget *bookmarks_menu = gtk_menu_new();
g_signal_connect(bookmarks_menu, "show",
G_CALLBACK(on_bookmarks_menu_show), NULL);
GtkWidget *item_bookmarks = gtk_menu_item_new_with_label("Bookmarks");
gtk_menu_item_set_submenu(GTK_MENU_ITEM(item_bookmarks), bookmarks_menu);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_bookmarks);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
/* Identity group — delegated to main.c (app_state_t). */
GtkWidget *item_switch = gtk_menu_item_new_with_label("Switch Identity…");
GtkWidget *item_lock = gtk_menu_item_new_with_label("Lock Session");
GtkWidget *item_logout = gtk_menu_item_new_with_label("Logout");
g_signal_connect(item_switch, "activate",
G_CALLBACK(app_menu_switch_identity_proxy), g_window);
g_signal_connect(item_lock, "activate",
G_CALLBACK(app_menu_lock_session_proxy), NULL);
g_signal_connect(item_logout, "activate",
G_CALLBACK(app_menu_logout_proxy), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_switch);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_lock);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_logout);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
/* Roadmap group. */
GtkWidget *item_security =
gtk_check_menu_item_new_with_label("Security strip (SOP/CORS/certs)");
gtk_check_menu_item_set_active(GTK_CHECK_MENU_ITEM(item_security), FALSE);
g_signal_connect(item_security, "toggled",
G_CALLBACK(app_menu_security_strip_proxy), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_security);
GtkWidget *item_fips = gtk_menu_item_new_with_label("FIPS URI scheme…");
GtkWidget *item_nostr = gtk_menu_item_new_with_label("Nostr signing status");
g_signal_connect(item_fips, "activate",
G_CALLBACK(app_menu_fips_proxy), NULL);
g_signal_connect(item_nostr, "activate",
G_CALLBACK(app_menu_nostr_sign_proxy), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_fips);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_nostr);
gtk_menu_shell_append(GTK_MENU_SHELL(menu),
gtk_separator_menu_item_new());
GtkWidget *item_inspector = gtk_menu_item_new_with_label("Toggle Inspector");
g_signal_connect(item_inspector, "activate",
G_CALLBACK(on_menu_inspector), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_inspector);
GtkWidget *item_sidebar = gtk_menu_item_new_with_label("Toggle Agent Sidebar");
g_signal_connect(item_sidebar, "activate",
G_CALLBACK(on_menu_toggle_sidebar), NULL);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_sidebar);
GtkWidget *item_profile = gtk_menu_item_new_with_label("Profile");
g_signal_connect(item_profile, "activate",
G_CALLBACK(on_menu_profile), g_window);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_profile);
GtkWidget *item_agent = gtk_menu_item_new_with_label("Agent Setup…");
g_signal_connect(item_agent, "activate",
G_CALLBACK(on_menu_agent), g_window);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_agent);
GtkWidget *item_settings = gtk_menu_item_new_with_label("Settings…");
g_signal_connect(item_settings, "activate",
G_CALLBACK(on_menu_settings), g_window);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_settings);
GtkWidget *item_about = gtk_menu_item_new_with_label("About");
g_signal_connect(item_about, "activate",
G_CALLBACK(app_menu_about_proxy), g_window);
gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_about);
gtk_widget_show_all(menu);
GtkWidget *button = gtk_menu_button_new();
gtk_menu_button_set_popup(GTK_MENU_BUTTON(button), menu);
gtk_button_set_image(
GTK_BUTTON(button),
gtk_image_new_from_icon_name("open-menu-symbolic",
GTK_ICON_SIZE_BUTTON));
gtk_widget_set_tooltip_text(button, "Menu");
gtk_widget_set_name(button, "hamburger-btn");
gtk_widget_set_size_request(button, 28, 28); /* fixed square */
return button;
}
/* ── Tab array management ─────────────────────────────────────────── */
static int tab_array_add(tab_info_t *tab) {
if (g_tab_count >= g_tab_cap) {
int new_cap = g_tab_cap == 0 ? 8 : g_tab_cap * 2;
tab_info_t **new_arr = g_realloc(g_tabs, new_cap * sizeof(tab_info_t *));
if (new_arr == NULL) return -1;
g_tabs = new_arr;
g_tab_cap = new_cap;
}
g_tabs[g_tab_count] = tab;
return g_tab_count++;
}
static void tab_array_remove(int index) {
if (index < 0 || index >= g_tab_count) return;
/* Free the tab_info_t. */
tab_info_t *tab = g_tabs[index];
if (tab) {
/* The webview and widgets are destroyed by GtkNotebook when the
* page is removed. We just free the struct. */
g_free(tab);
}
/* Shift remaining entries down. */
for (int i = index; i < g_tab_count - 1; i++) {
g_tabs[i] = g_tabs[i + 1];
}
g_tab_count--;
}
/* Find the index of a tab in the global g_tabs array by pointer.
*
* The g_tabs array is a flat list of all open tabs across all windows.
* Notebook page numbers only match g_tabs indices for the main window;
* auxiliary windows have their own page numbering. Internal handlers
* (close button, tab label clicks, load-changed title updates) should
* use this to look up the g_tabs index rather than gtk_notebook_page_num,
* which returns the page number within a single notebook. */
static int tab_array_find(tab_info_t *tab) {
if (tab == NULL) return -1;
for (int i = 0; i < g_tab_count; i++) {
if (g_tabs[i] == tab) return i;
}
return -1;
}
/* ── Tab creation ─────────────────────────────────────────────────── */
/* Return the notebook that the next new tab should be added to.
*
* Priority:
* 1. g_target_notebook — explicitly set by tab_manager_new_window()
* to place the first tab into the new window's notebook.
* 2. g_active_notebook — the currently focused window's notebook, so
* Ctrl+T / "New Tab" opens in the active window.
* 3. g_notebook — the main window's notebook (fallback). */
static GtkWidget *get_effective_notebook(void) {
if (g_target_notebook != NULL) return g_target_notebook;
if (g_active_notebook != NULL) return g_active_notebook;
return g_notebook;
}
/* Find the GtkNotebook that contains the given tab page widget.
*
* Tabs can live in either the main window's notebook (g_notebook) or an
* auxiliary window's notebook (g_active_notebook when it differs). This
* checks the active notebook first, then the main notebook, and returns
* whichever one actually contains the page (gtk_notebook_page_num >= 0).
*
* Returns NULL if the page is not found in either notebook. */
static GtkWidget *tab_find_notebook(GtkWidget *page) {
if (page == NULL) return NULL;
if (g_active_notebook != NULL && g_active_notebook != g_notebook) {
if (gtk_notebook_page_num(GTK_NOTEBOOK(g_active_notebook), page) >= 0)
return g_active_notebook;
}
if (g_notebook != NULL) {
if (gtk_notebook_page_num(GTK_NOTEBOOK(g_notebook), page) >= 0)
return g_notebook;
}
return NULL;
}
static tab_info_t *tab_create(const char *url) {
const browser_settings_t *s = settings_get();
tab_info_t *tab = g_new0(tab_info_t, 1);
if (tab == NULL) return NULL;
/* Determine the URL to load. */
const char *load_url = url;
char *default_url = NULL;
if (load_url == NULL || load_url[0] == '\0') {
load_url = s->new_tab_url;
}
default_url = normalize_url(load_url);
if (default_url == NULL) {
default_url = g_strdup(load_url);
}
snprintf(tab->current_url, sizeof(tab->current_url), "%s", default_url);
snprintf(tab->title, sizeof(tab->title), "Loading…");
/* Create the webview. Prefer a related view (set by
* tab_manager_new_window) so the new webview shares the parent's
* WebProcess and window features — this avoids the
* std::optional<WindowFeatures> assertion crash that occurs with
* webkit_web_view_new_with_context() for target="_blank" windows.
* Fall back to the shared context for normal new tabs. */
if (g_target_related_view != NULL &&
WEBKIT_IS_WEB_VIEW(g_target_related_view)) {
tab->webview = WEBKIT_WEB_VIEW(
webkit_web_view_new_with_related_view(g_target_related_view));
} else {
tab->webview = WEBKIT_WEB_VIEW(webkit_web_view_new_with_context(g_ctx));
}
/* Enable developer extras + security settings (same as original main.c). */
WebKitSettings *settings = webkit_web_view_get_settings(tab->webview);
webkit_settings_set_enable_developer_extras(settings, TRUE);
webkit_settings_set_enable_javascript(settings, TRUE);
webkit_settings_set_javascript_can_open_windows_automatically(settings, TRUE);
webkit_settings_set_allow_file_access_from_file_urls(settings, TRUE);
webkit_settings_set_allow_universal_access_from_file_urls(settings, TRUE);
webkit_settings_set_allow_modal_dialogs(settings, TRUE);
/* Inject window.nostr into this webview. */
nostr_inject_setup(tab->webview);
/* Connect the key-press handler to the webview so browser-level
* shortcuts (Ctrl+T, Ctrl+Shift+I, etc.) are caught even when the
* webview has focus. We connect AFTER so the webview still gets
* normal key events for text input, but our handler can intercept
* recognized shortcuts. */
g_signal_connect(G_OBJECT(tab->webview), "key-press-event",
G_CALLBACK(on_key_press), NULL);
/* Build the per-tab page: toolbar on top, webview below. */
tab->page = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
GtkWidget *toolbar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_set_margin_top(toolbar, 4);
gtk_widget_set_margin_bottom(toolbar, 4);
gtk_widget_set_margin_start(toolbar, 4);
gtk_widget_set_margin_end(toolbar, 4);
gtk_box_pack_start(GTK_BOX(tab->page), toolbar, FALSE, FALSE, 0);
tab->hamburger = build_hamburger_menu(tab);
gtk_box_pack_start(GTK_BOX(toolbar), tab->hamburger, FALSE, FALSE, 0);
/* Refresh button — left-click reloads, right-click shows a menu
* with hard reload options (bypass cache, clear cookies+reload, etc.). */
GtkWidget *refresh_btn = gtk_button_new();
gtk_button_set_relief(GTK_BUTTON(refresh_btn), GTK_RELIEF_NONE);
gtk_button_set_image(GTK_BUTTON(refresh_btn),
gtk_image_new_from_icon_name("view-refresh-symbolic",
GTK_ICON_SIZE_MENU));
gtk_widget_set_tooltip_text(refresh_btn,
"Reload page (right-click for hard reload options)");
g_signal_connect(refresh_btn, "clicked",
G_CALLBACK(on_refresh_clicked), tab);
g_signal_connect(refresh_btn, "button-press-event",
G_CALLBACK(on_refresh_button_press), tab);
gtk_box_pack_start(GTK_BOX(toolbar), refresh_btn, FALSE, FALSE, 0);
/* Back button — navigate to previous page. */
GtkWidget *back_btn = gtk_button_new();
gtk_button_set_relief(GTK_BUTTON(back_btn), GTK_RELIEF_NONE);
gtk_button_set_image(GTK_BUTTON(back_btn),
gtk_image_new_from_icon_name("go-previous-symbolic",
GTK_ICON_SIZE_MENU));
gtk_widget_set_tooltip_text(back_btn, "Go back");
g_signal_connect(back_btn, "clicked",
G_CALLBACK(on_back_clicked), tab);
gtk_box_pack_start(GTK_BOX(toolbar), back_btn, FALSE, FALSE, 0);
/* Forward button — navigate to next page. */
GtkWidget *forward_btn = gtk_button_new();
gtk_button_set_relief(GTK_BUTTON(forward_btn), GTK_RELIEF_NONE);
gtk_button_set_image(GTK_BUTTON(forward_btn),
gtk_image_new_from_icon_name("go-next-symbolic",
GTK_ICON_SIZE_MENU));
gtk_widget_set_tooltip_text(forward_btn, "Go forward");
g_signal_connect(forward_btn, "clicked",
G_CALLBACK(on_forward_clicked), tab);
gtk_box_pack_start(GTK_BOX(toolbar), forward_btn, FALSE, FALSE, 0);
tab->url_entry = gtk_entry_new();
/* Show an empty URL bar for new-tab pages (about:blank) so the user
* can immediately type a URL. For real URLs, show the URL. */
if (default_url && strstr(default_url, "about:blank") != NULL) {
gtk_entry_set_text(GTK_ENTRY(tab->url_entry), "");
} else {
gtk_entry_set_text(GTK_ENTRY(tab->url_entry), default_url);
}
gtk_box_pack_start(GTK_BOX(toolbar), tab->url_entry, TRUE, TRUE, 0);
/* ── URL bar completion (search dropdown) ────────────────────── *
* Create a GtkListStore and GtkEntryCompletion for this tab's URL
* entry. The store is populated on each keystroke with direct links
* (history + bookmarks + domain heuristic) and async search engine
* suggestions. */
completion_state_t *cs = g_new0(completion_state_t, 1);
cs->store = gtk_list_store_new(COMPLETION_COL_COUNT,
G_TYPE_STRING, /* display */
G_TYPE_STRING, /* url */
G_TYPE_BOOLEAN);/* is_direct*/
cs->suggest_req_id = 0;
cs->last_query = NULL;
GtkEntryCompletion *completion = gtk_entry_completion_new();
gtk_entry_completion_set_model(completion,
GTK_TREE_MODEL(cs->store));
gtk_entry_completion_set_text_column(completion,
COMPLETION_COL_DISPLAY);
gtk_entry_completion_set_minimum_key_length(completion,
COMPLETION_MIN_KEY_LEN);
/* Use a custom match function so all rows in the store are shown
* (we already filtered them in rebuild_completion). The default
* match function would re-filter by the display column, which we
* don't want. */
gtk_entry_completion_set_match_func(completion,
(GtkEntryCompletionMatchFunc)gtk_true, NULL, NULL);
g_signal_connect(completion, "match-selected",
G_CALLBACK(on_completion_match_selected), tab);
gtk_entry_set_completion(GTK_ENTRY(tab->url_entry), completion);
g_object_unref(completion);
/* Store the completion state on the entry for retrieval in
* on_url_changed. Free it when the entry is destroyed. */
g_object_set_data_full(G_OBJECT(tab->url_entry), "completion-state",
cs, (GDestroyNotify)completion_state_free);
/* Bookmark button — right of the URL entry. Opens a directory picker
* dialog to bookmark the current page. */
GtkWidget *bookmark_btn = gtk_button_new();
gtk_button_set_relief(GTK_BUTTON(bookmark_btn), GTK_RELIEF_NONE);
gtk_button_set_image(GTK_BUTTON(bookmark_btn),
gtk_image_new_from_icon_name("user-bookmarks-symbolic",
GTK_ICON_SIZE_MENU));
gtk_widget_set_tooltip_text(bookmark_btn, "Bookmark this page");
g_signal_connect(bookmark_btn, "clicked",
G_CALLBACK(on_bookmark_clicked), tab);
gtk_box_pack_start(GTK_BOX(toolbar), bookmark_btn, FALSE, FALSE, 0);
/* Ensure the webview expands vertically to fill the available space.
* Without this, WebKitGTK may only allocate 1px of height on some
* display servers, resulting in a blank page. */
gtk_widget_set_vexpand(GTK_WIDGET(tab->webview), TRUE);
gtk_widget_set_hexpand(GTK_WIDGET(tab->webview), TRUE);
/* Build a horizontal GtkPaned: left = sidebar container, right =
* main webview. Both children are always packed (no add/remove
* which caused a crash). When hidden, the position is set to 0
* and the sidebar widget is hidden — the divider disappears at
* position 0. When shown, the position is set to 280 and the
* sidebar is shown — the divider appears and is resizable. */
tab->paned = gtk_paned_new(GTK_ORIENTATION_HORIZONTAL);
gtk_widget_set_vexpand(tab->paned, TRUE);
gtk_widget_set_hexpand(tab->paned, TRUE);
/* Sidebar container — packed as first (left) pane. Hidden by
* default. No size request (the paned position controls width). */
tab->sidebar_container = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_paned_pack1(GTK_PANED(tab->paned), tab->sidebar_container,
FALSE, FALSE);
gtk_widget_hide(tab->sidebar_container);
tab->sidebar_visible = FALSE;
tab->sidebar_webview = NULL;
/* Main webview — packed as second (right) pane. */
gtk_paned_pack2(GTK_PANED(tab->paned), GTK_WIDGET(tab->webview),
TRUE, TRUE);
/* Position 0 = sidebar gets no space (hidden by default). */
gtk_paned_set_position(GTK_PANED(tab->paned), 0);
gtk_box_pack_start(GTK_BOX(tab->page), tab->paned, TRUE, TRUE, 0);
/* Build the tab label. */
tab->tab_label = build_tab_label(tab);
/* Wire signals. */
g_signal_connect(tab->url_entry, "activate",
G_CALLBACK(on_url_activate), tab);
g_signal_connect(tab->url_entry, "changed",
G_CALLBACK(on_url_changed), tab);
/* key-press-event must be connected BEFORE the default handler so
* we can intercept Tab before GtkEntry processes it. */
g_signal_connect(tab->url_entry, "key-press-event",
G_CALLBACK(on_url_key_press), tab);
g_signal_connect(tab->webview, "load-changed",
G_CALLBACK(on_load_changed), tab);
g_signal_connect(tab->webview, "load-failed",
G_CALLBACK(on_load_failed), NULL);
g_signal_connect(tab->webview, "notify::favicon",
G_CALLBACK(on_favicon_changed), tab);
g_signal_connect(tab->webview, "context-menu",
G_CALLBACK(on_webview_context_menu), tab);
g_signal_connect(tab->webview, "decide-policy",
G_CALLBACK(on_decide_policy), tab);
g_signal_connect(tab->webview, "create",
G_CALLBACK(on_create_webview), tab);
/* Load the URL. */
webkit_web_view_load_uri(tab->webview, default_url);
g_free(default_url);
return tab;
}
/* ── User avatar (far left of tab bar) ────────────────────────────── *
* Shows the user's Nostr profile picture as a circular avatar on the
* far left of the tab bar, matching the size/shape of the hamburger
* menu button. Falls back to a default avatar icon if no picture is
* available or the download fails.
*/
static GtkWidget *g_avatar = NULL;
/* Scale a pixbuf to fill the given size, center-cropping to preserve
* aspect ratio (like CSS object-fit: cover), then round the corners
* to match the button's border-radius. Returns a new pixbuf (caller
* must unref). */
#define AVATAR_BORDER_RADIUS 4
static GdkPixbuf *make_fitted_pixbuf(GdkPixbuf *src, int size) {
if (src == NULL) return NULL;
int w = gdk_pixbuf_get_width(src);
int h = gdk_pixbuf_get_height(src);
if (w <= 0 || h <= 0) return NULL;
/* Scale so the smaller dimension fills `size`, then center-crop
* the larger dimension. This is object-fit: cover. */
double scale = (double)size / (w < h ? w : h);
int scaled_w = (int)(w * scale + 0.5);
int scaled_h = (int)(h * scale + 0.5);
if (scaled_w < size) scaled_w = size;
if (scaled_h < size) scaled_h = size;
GdkPixbuf *scaled = gdk_pixbuf_scale_simple(src, scaled_w, scaled_h,
GDK_INTERP_BILINEAR);
if (scaled == NULL) return NULL;
/* Center-crop to size×size. */
int xoff = (scaled_w - size) / 2;
int yoff = (scaled_h - size) / 2;
GdkPixbuf *cropped = gdk_pixbuf_new_subpixbuf(scaled, xoff, yoff,
size, size);
g_object_unref(scaled);
if (cropped == NULL) return NULL;
/* Round the corners using a cairo rounded-rectangle clip, matching
* the button's border-radius (AVATAR_BORDER_RADIUS). */
cairo_surface_t *surface = cairo_image_surface_create(
CAIRO_FORMAT_ARGB32, size, size);
cairo_t *cr = cairo_create(surface);
/* Draw a rounded-rectangle path and clip. */
double r = AVATAR_BORDER_RADIUS;
cairo_new_path(cr);
cairo_arc(cr, size - r, r, r, -G_PI_2, 0); /* top-right */
cairo_arc(cr, size - r, size - r, r, 0, G_PI_2); /* bottom-right */
cairo_arc(cr, r, size - r, r, G_PI_2, G_PI); /* bottom-left */
cairo_arc(cr, r, r, r, G_PI, 1.5 * G_PI); /* top-left */
cairo_close_path(cr);
cairo_clip(cr);
/* Paint the pixbuf onto the clipped surface. */
gdk_cairo_set_source_pixbuf(cr, cropped, 0, 0);
cairo_paint(cr);
cairo_destroy(cr);
/* Convert the surface back to a pixbuf. */
GdkPixbuf *result = gdk_pixbuf_get_from_surface(surface, 0, 0,
size, size);
cairo_surface_destroy(surface);
g_object_unref(cropped);
return result;
}
/* Thread data for avatar download. */
typedef struct {
char *url;
int size;
} avatar_fetch_t;
/* Idle callback to set the avatar pixbuf on the widget. */
static gboolean avatar_set_pixbuf_idle(gpointer data) {
GdkPixbuf *pixbuf = (GdkPixbuf *)data;
if (g_avatar && pixbuf) {
gtk_image_set_from_pixbuf(GTK_IMAGE(g_avatar), pixbuf);
g_object_unref(pixbuf);
}
return G_SOURCE_REMOVE;
}
/* Reworked fetch thread that uses the idle callback properly. */
static gpointer avatar_fetch_thread_v2(gpointer data) {
avatar_fetch_t *af = (avatar_fetch_t *)data;
GdkPixbuf *pixbuf = NULL;
if (g_str_has_prefix(af->url, "file://")) {
const char *path = af->url + 7;
pixbuf = gdk_pixbuf_new_from_file_at_size(path, af->size,
af->size, NULL);
} else if (g_str_has_prefix(af->url, "http://") ||
g_str_has_prefix(af->url, "https://")) {
SoupSession *session = soup_session_new();
SoupMessage *msg = soup_message_new("GET", af->url);
GBytes *bytes = soup_session_send_and_read(session, msg, NULL, NULL);
if (bytes != NULL) {
gsize len = 0;
const guchar *data_ptr = g_bytes_get_data(bytes, &len);
if (data_ptr && len > 0) {
GInputStream *stream = g_memory_input_stream_new_from_data(
data_ptr, len, NULL);
pixbuf = gdk_pixbuf_new_from_stream_at_scale(
stream, af->size, af->size, TRUE, NULL, NULL);
g_object_unref(stream);
}
g_bytes_unref(bytes);
}
g_object_unref(msg);
g_object_unref(session);
}
if (pixbuf != NULL) {
GdkPixbuf *fitted = make_fitted_pixbuf(pixbuf, af->size);
g_object_unref(pixbuf);
if (fitted != NULL) {
g_idle_add(avatar_set_pixbuf_idle, fitted);
}
}
g_free(af->url);
g_free(af);
return NULL;
}
/* Set the avatar from the user's pubkey. Queries the kind 0 profile
* from SQLite and starts a background download of the picture.
* Called from main.c after login. */
void tab_manager_set_avatar(const char *pubkey_hex) {
if (g_avatar == NULL || pubkey_hex == NULL || pubkey_hex[0] == '\0') {
if (g_avatar) {
gtk_image_set_from_icon_name(GTK_IMAGE(g_avatar),
"avatar-default-symbolic",
GTK_ICON_SIZE_BUTTON);
}
return;
}
/* Query the kind 0 profile from SQLite. */
cJSON *kind0 = db_get_latest_event(pubkey_hex, 0);
if (kind0 == NULL) {
gtk_image_set_from_icon_name(GTK_IMAGE(g_avatar),
"avatar-default-symbolic",
GTK_ICON_SIZE_BUTTON);
return;
}
const char *picture = NULL;
cJSON *content = cJSON_GetObjectItemCaseSensitive(kind0, "content");
if (cJSON_IsString(content) && content->valuestring[0]) {
cJSON *meta = cJSON_Parse(content->valuestring);
if (meta) {
cJSON *pic = cJSON_GetObjectItemCaseSensitive(meta, "picture");
if (cJSON_IsString(pic) && pic->valuestring[0]) {
picture = g_strdup(pic->valuestring);
}
cJSON_Delete(meta);
}
}
cJSON_Delete(kind0);
if (picture == NULL) {
gtk_image_set_from_icon_name(GTK_IMAGE(g_avatar),
"avatar-default-symbolic",
GTK_ICON_SIZE_BUTTON);
return;
}
/* Start a background thread to download and process the avatar. */
avatar_fetch_t *af = g_new(avatar_fetch_t, 1);
af->url = g_strdup(picture);
af->size = 28; /* match the fixed button size (28x28) */
g_thread_new("avatar-fetch", avatar_fetch_thread_v2, af);
g_free((char *)picture);
}
/* ── Avatar button — opens the profile page ───────────────────────── */
static void on_avatar_clicked(GtkButton *btn, gpointer data) {
(void)btn;
(void)data;
/* Open sovereign://profile in the active tab, or a new tab if
* none exists. */
tab_info_t *tab = tab_manager_get_active();
if (tab && tab->webview) {
webkit_web_view_load_uri(tab->webview, "sovereign://profile");
} else {
tab_manager_new_tab("sovereign://profile");
}
}
/* ── New tab button ───────────────────────────────────────────────── */
static void on_new_tab_clicked(GtkButton *btn, gpointer data) {
(void)btn;
(void)data;
tab_manager_new_tab(NULL);
}
/* ── Public API ───────────────────────────────────────────────────── */
void tab_manager_init(GtkContainer *parent,
WebKitWebContext *ctx,
GtkWindow *window) {
g_ctx = ctx;
g_window = window;
g_notebook = gtk_notebook_new();
/* Disable scrolling so tabs share the available width evenly instead
* of showing a scrollbar when there are many tabs. */
gtk_notebook_set_scrollable(GTK_NOTEBOOK(g_notebook), FALSE);
gtk_notebook_set_show_border(GTK_NOTEBOOK(g_notebook), FALSE);
gtk_notebook_set_show_tabs(GTK_NOTEBOOK(g_notebook), TRUE);
/* New-tab button as an action widget at the end of the tab strip. */
GtkWidget *new_btn = gtk_button_new();
gtk_button_set_relief(GTK_BUTTON(new_btn), GTK_RELIEF_NONE);
gtk_button_set_image(GTK_BUTTON(new_btn),
gtk_image_new_from_icon_name("tab-new-symbolic",
GTK_ICON_SIZE_BUTTON));
gtk_widget_set_tooltip_text(new_btn, "New tab (Ctrl+T)");
g_signal_connect(new_btn, "clicked", G_CALLBACK(on_new_tab_clicked), NULL);
gtk_widget_show_all(new_btn);
gtk_notebook_set_action_widget(GTK_NOTEBOOK(g_notebook), new_btn,
GTK_PACK_END);
/* User avatar as an action widget at the start (far left) of the
* tab strip. Uses a GtkButton with GTK_RELIEF_NORMAL to match the
* hamburger menu button's visible border. margin_start matches the
* toolbar's left margin (4px) so the avatar's left edge aligns
* horizontally with the hamburger button's left edge.
* The inner GtkImage (g_avatar) is updated via tab_manager_set_avatar(). */
g_avatar = gtk_image_new_from_icon_name("avatar-default-symbolic",
GTK_ICON_SIZE_BUTTON);
GtkWidget *avatar_btn = gtk_button_new();
gtk_button_set_relief(GTK_BUTTON(avatar_btn), GTK_RELIEF_NORMAL);
gtk_button_set_image(GTK_BUTTON(avatar_btn), g_avatar);
gtk_widget_set_tooltip_text(avatar_btn, "Your profile");
gtk_widget_set_valign(avatar_btn, GTK_ALIGN_CENTER);
gtk_widget_set_margin_start(avatar_btn, 4);
gtk_widget_set_name(avatar_btn, "avatar-btn");
gtk_widget_set_size_request(avatar_btn, 28, 28); /* fixed square, matches hamburger */
g_signal_connect(avatar_btn, "clicked",
G_CALLBACK(on_avatar_clicked), NULL);
gtk_widget_show_all(avatar_btn);
gtk_notebook_set_action_widget(GTK_NOTEBOOK(g_notebook), avatar_btn,
GTK_PACK_START);
/* CSS: remove internal padding from both buttons so their contents
* fill edge-to-edge. Force both to a fixed square size (28x28) and
* restore rounded corners so the avatar image is clipped to the
* button's rounded shape. Sizes are also set via
* gtk_widget_set_size_request() in C. */
GtkCssProvider *css = gtk_css_provider_new();
gtk_css_provider_load_from_data(css,
"#avatar-btn, #hamburger-btn {"
" padding: 0px;"
" min-width: 28px; min-height: 28px;"
" border-radius: 4px;"
"}"
"#avatar-btn image, #hamburger-btn image {"
" padding: 0px; margin: 0px;"
"}",
-1, NULL);
gtk_style_context_add_provider_for_screen(
gdk_screen_get_default(),
GTK_STYLE_PROVIDER(css),
GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
g_object_unref(css);
gtk_container_add(parent, g_notebook);
/* The main window/notebook are the default active window/notebook
* for MCP get_active_webview(). Updated when auxiliary windows
* gain focus (see on_window_focus_in). */
g_active_window = window;
g_active_notebook = g_notebook;
tab_manager_apply_settings();
}
void tab_manager_apply_settings(void) {
if (g_notebook == NULL) return;
const browser_settings_t *s = settings_get();
gtk_notebook_set_tab_pos(GTK_NOTEBOOK(g_notebook), s->tab_bar_position);
/* Apply drag reorder to all existing tabs. */
for (int i = 0; i < g_tab_count; i++) {
if (g_tabs[i] && g_tabs[i]->page) {
gtk_notebook_set_tab_reorderable(GTK_NOTEBOOK(g_notebook),
g_tabs[i]->page,
s->tab_drag_reorder);
}
}
}
int tab_manager_new_tab(const char *url) {
const browser_settings_t *s = settings_get();
if (g_tab_count >= s->max_tabs) {
g_print("[tabs] Maximum tab count (%d) reached\n", s->max_tabs);
return -1;
}
tab_info_t *tab = tab_create(url);
if (tab == NULL) return -1;
int index = tab_array_add(tab);
if (index < 0) {
g_free(tab);
return -1;
}
/* Add to the effective notebook (active window's notebook, or the
* main notebook as fallback). This makes Ctrl+T / "New Tab" open in
* the focused window instead of always the main window. */
GtkWidget *nb = get_effective_notebook();
int page_num = gtk_notebook_append_page(GTK_NOTEBOOK(nb),
tab->page, tab->tab_label);
gtk_notebook_set_tab_reorderable(GTK_NOTEBOOK(nb), tab->page,
s->tab_drag_reorder);
/* Show the tab widgets before switching to it — the page must be
* realized/mapped before it can become the current page and receive
* focus. */
gtk_widget_show_all(tab->page);
gtk_widget_show_all(tab->tab_label);
/* gtk_widget_show_all() recursively shows all children, including
* the sidebar container that tab_create() explicitly hid. Re-hide
* the sidebar so it stays hidden by default — it should only appear
* when the user toggles it via Ctrl+Shift+A, the menu item, or the
* ";" URL-bar shortcut. (Issue 2: sidebar visible on startup.) */
if (tab->sidebar_container) {
gtk_widget_hide(tab->sidebar_container);
}
/* Switch to the new tab (now that it's visible). */
gtk_notebook_set_current_page(GTK_NOTEBOOK(nb), page_num);
/* Give the URL entry keyboard focus so the user can immediately
* type a URL. Select all text so typing replaces the current URL.
* Use gtk_widget_grab_focus() on the entry to ensure it receives
* keyboard input. */
gtk_widget_grab_focus(tab->url_entry);
gtk_editable_select_region(GTK_EDITABLE(tab->url_entry), 0, -1);
g_print("[tabs] Created tab %d, total: %d\n", page_num, g_tab_count);
return page_num;
}
void tab_manager_close_tab(int index) {
if (index < 0 || index >= g_tab_count) return;
tab_info_t *tab = g_tabs[index];
if (tab == NULL) return;
/* Find the notebook this tab belongs to — it may be the main
* window's notebook or an auxiliary window's notebook. */
GtkWidget *nb = tab_find_notebook(tab->page);
if (nb == NULL) nb = g_notebook; /* fallback */
/* Remove from notebook (this destroys the page widget). */
gtk_notebook_remove_page(GTK_NOTEBOOK(nb), index);
/* Remove from our array. */
tab_array_remove(index);
g_print("[tabs] Closed tab %d, remaining: %d\n", index, g_tab_count);
/* If this was an auxiliary window's last tab, close that window.
* The destroy handler (on_aux_window_destroy) reverts the active
* window pointer to the main window. We detect "aux window" by
* checking that the notebook we removed from is not g_notebook and
* now has zero pages. */
if (nb != g_notebook &&
gtk_notebook_get_n_pages(GTK_NOTEBOOK(nb)) == 0) {
GtkWidget *toplevel = gtk_widget_get_toplevel(nb);
if (toplevel != NULL && GTK_IS_WINDOW(toplevel)) {
gtk_window_close(GTK_WINDOW(toplevel));
}
}
/* If the main window has no tabs left, quit the app. */
if (g_tab_count == 0) {
g_print("[tabs] Last tab closed, exiting.\n");
if (g_window) {
gtk_window_close(g_window);
}
}
}
void tab_manager_close_active(void) {
/* Resolve the active tab by widget pointer (works across windows),
* then look up its g_tabs index. Using tab_manager_get_active_index()
* directly would return the active notebook's page number, which only
* matches the g_tabs index for the main window. */
tab_info_t *tab = tab_manager_get_active();
int index = tab_array_find(tab);
if (index >= 0) {
tab_manager_close_tab(index);
}
}
int tab_manager_get_active_index(void) {
/* Use the active window's notebook (defaults to g_notebook via
* get_effective_notebook). The returned page number is only a valid
* g_tabs index for the main window; callers that need the tab_info_t
* should use tab_manager_get_active(), which resolves by widget
* pointer to support auxiliary windows. */
GtkWidget *nb = get_effective_notebook();
if (nb == NULL) return -1;
return gtk_notebook_get_current_page(GTK_NOTEBOOK(nb));
}
void tab_manager_switch_to(int index) {
if (g_notebook == NULL) return;
if (index < 0 || index >= g_tab_count) return;
gtk_notebook_set_current_page(GTK_NOTEBOOK(g_notebook), index);
}
tab_info_t *tab_manager_get_active(void) {
/* Resolve the active tab by looking at the active notebook's current
* page widget and finding the matching tab_info_t in g_tabs by
* pointer. This works across multiple windows: the notebook page
* number only matches the g_tabs index for the main window, so we
* can't rely on it for auxiliary windows. */
GtkWidget *nb = get_effective_notebook();
if (nb == NULL) return NULL;
gint page = gtk_notebook_get_current_page(GTK_NOTEBOOK(nb));
if (page < 0) return NULL;
GtkWidget *page_widget = gtk_notebook_get_nth_page(GTK_NOTEBOOK(nb), page);
if (page_widget == NULL) return NULL;
for (int i = 0; i < g_tab_count; i++) {
if (g_tabs[i] && g_tabs[i]->page == page_widget) return g_tabs[i];
}
return NULL;
}
tab_info_t *tab_manager_get(int index) {
if (index < 0 || index >= g_tab_count) return NULL;
return g_tabs[index];
}
int tab_manager_count(void) {
return g_tab_count;
}
void tab_manager_set_title(int index, const char *title) {
if (index < 0 || index >= g_tab_count) return;
tab_info_t *tab = g_tabs[index];
if (tab == NULL || tab->title_label == NULL) return;
snprintf(tab->title, sizeof(tab->title), "%s",
(title && title[0]) ? title : "(Untitled)");
gtk_label_set_text(GTK_LABEL(tab->title_label), tab->title);
}
const char *tab_manager_get_url(int index) {
if (index < 0 || index >= g_tab_count) return NULL;
tab_info_t *tab = g_tabs[index];
if (tab == NULL) return NULL;
return tab->current_url;
}
void tab_manager_next(void) {
if (g_notebook == NULL || g_tab_count == 0) return;
int cur = gtk_notebook_get_current_page(GTK_NOTEBOOK(g_notebook));
int next = (cur + 1) % g_tab_count;
gtk_notebook_set_current_page(GTK_NOTEBOOK(g_notebook), next);
}
void tab_manager_prev(void) {
if (g_notebook == NULL || g_tab_count == 0) return;
int cur = gtk_notebook_get_current_page(GTK_NOTEBOOK(g_notebook));
int prev = (cur - 1 + g_tab_count) % g_tab_count;
gtk_notebook_set_current_page(GTK_NOTEBOOK(g_notebook), prev);
}
void tab_manager_close_others(int index) {
if (index < 0 || index >= g_tab_count) return;
/* Close tabs to the right first (indices shift as we close). */
tab_manager_close_to_right(index);
/* Close tabs to the left (close from the start so indices stay valid). */
while (index > 0) {
tab_manager_close_tab(0);
index--;
}
}
void tab_manager_close_to_right(int index) {
if (index < 0 || index >= g_tab_count) return;
/* Close from the end so indices don't shift. */
while (g_tab_count > index + 1) {
tab_manager_close_tab(g_tab_count - 1);
}
}
void tab_manager_close_all(void) {
/* Close from the highest index down to 0 so indices stay valid. */
while (g_tab_count > 0) {
tab_manager_close_tab(g_tab_count - 1);
}
}
void tab_manager_duplicate(int index) {
if (index < 0 || index >= g_tab_count) return;
tab_info_t *tab = g_tabs[index];
if (tab == NULL) return;
tab_manager_new_tab(tab->current_url);
}
void tab_manager_reload(int index) {
if (index < 0 || index >= g_tab_count) return;
tab_info_t *tab = g_tabs[index];
if (tab && tab->webview) {
webkit_web_view_reload_bypass_cache(tab->webview);
}
}
/* ── Agent chat sidebar (split view) ─────────────────────────────── */
#define SIDEBAR_DEFAULT_WIDTH 280
#define AGENT_CHAT_URL_STR "sovereign://agents/chat"
/* Lazily create the sidebar webview for a tab. The webview shares the
* same WebKitWebContext as the main webview so the sovereign:// scheme
* works. It is packed into the sidebar container (the left pane of the
* GtkPaned). Called the first time the sidebar is shown for a tab. */
static void sidebar_create_webview(tab_info_t *tab) {
if (tab == NULL || tab->sidebar_webview != NULL) return;
if (g_ctx == NULL) return;
tab->sidebar_webview = WEBKIT_WEB_VIEW(
webkit_web_view_new_with_context(g_ctx));
/* Match the main webview's settings so JS and the sovereign://
* bridge work. */
WebKitSettings *st = webkit_web_view_get_settings(tab->sidebar_webview);
webkit_settings_set_enable_developer_extras(st, TRUE);
webkit_settings_set_enable_javascript(st, TRUE);
webkit_settings_set_allow_file_access_from_file_urls(st, TRUE);
webkit_settings_set_allow_universal_access_from_file_urls(st, TRUE);
/* Inject window.nostr so the chat page's NIP-07 shim works. */
nostr_inject_setup(tab->sidebar_webview);
/* The sidebar webview should NOT create new windows/tabs — links
* in the chat page should navigate within the sidebar, not open
* new browser tabs. We let the default navigation happen inside
* the sidebar webview. */
gtk_widget_set_vexpand(GTK_WIDGET(tab->sidebar_webview), TRUE);
gtk_widget_set_hexpand(GTK_WIDGET(tab->sidebar_webview), TRUE);
/* Pack into the sidebar container. */
gtk_box_pack_start(GTK_BOX(tab->sidebar_container),
GTK_WIDGET(tab->sidebar_webview), TRUE, TRUE, 0);
/* Load the chat page. */
webkit_web_view_load_uri(tab->sidebar_webview, AGENT_CHAT_URL_STR);
}
void tab_manager_toggle_sidebar(void) {
tab_info_t *tab = tab_manager_get_active();
if (tab == NULL || tab->paned == NULL) return;
if (tab->sidebar_visible) {
/* Hide the sidebar. Set position to 0 so the sidebar gets no
* space and the divider disappears. Keep the webview alive. */
gtk_paned_set_position(GTK_PANED(tab->paned), 0);
gtk_widget_hide(tab->sidebar_container);
tab->sidebar_visible = FALSE;
} else {
/* Show the sidebar. Create the webview lazily on first show.
* Set position to 280 so the sidebar gets space and the
* divider appears (resizable). */
if (tab->sidebar_webview == NULL) {
sidebar_create_webview(tab);
}
gtk_paned_set_position(GTK_PANED(tab->paned),
SIDEBAR_DEFAULT_WIDTH);
gtk_widget_show_all(tab->sidebar_container);
tab->sidebar_visible = TRUE;
}
}
WebKitWebView *tab_manager_get_main_webview(void) {
tab_info_t *tab = tab_manager_get_active();
if (tab == NULL) return NULL;
/* Always return the main webview, never the sidebar. */
return tab->webview;
}
gboolean tab_manager_sidebar_visible(void) {
tab_info_t *tab = tab_manager_get_active();
if (tab == NULL) return FALSE;
return tab->sidebar_visible;
}