2671 lines
110 KiB
C
2671 lines
110 KiB
C
/*
|
||
* 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;
|
||
}
|