browser history and navigation

This commit is contained in:
Face
2025-09-06 16:58:24 +03:00
parent 6f70032aab
commit 47c7b4bfaa
16 changed files with 592 additions and 137 deletions

View File

@@ -0,0 +1,91 @@
extends Node
const HISTORY_FILE_PATH = "user://browser_history.json"
const MAX_HISTORY_ENTRIES = 1000
func get_history_data() -> Array:
var history_file = FileAccess.open(HISTORY_FILE_PATH, FileAccess.READ)
if not history_file:
return []
var json_string = history_file.get_as_text()
history_file.close()
var json = JSON.new()
var parse_result = json.parse(json_string)
if parse_result != OK:
return []
var history_data = json.data
if not history_data is Array:
return []
return history_data
func save_history_data(history_data: Array):
var history_file = FileAccess.open(HISTORY_FILE_PATH, FileAccess.WRITE)
if not history_file:
push_error("Failed to open history file for writing")
return
var json_string = JSON.stringify(history_data)
history_file.store_string(json_string)
history_file.close()
func add_entry(url: String, title: String, icon_url: String = ""):
if url.is_empty():
return
var history_data = get_history_data()
var timestamp = Time.get_datetime_string_from_system()
var existing_index = -1
for i in range(history_data.size()):
if history_data[i].url == url:
existing_index = i
break
var entry = {
"url": url,
"title": title,
"timestamp": timestamp,
"icon_url": icon_url
}
if existing_index >= 0:
history_data.remove_at(existing_index)
history_data.insert(0, entry)
if history_data.size() > MAX_HISTORY_ENTRIES:
history_data = history_data.slice(0, MAX_HISTORY_ENTRIES)
save_history_data(history_data)
func remove_entry(url: String):
var history_data = get_history_data()
for i in range(history_data.size() - 1, -1, -1):
if history_data[i].url == url:
history_data.remove_at(i)
save_history_data(history_data)
func clear_all():
save_history_data([])
func search_history(query: String) -> Array:
var history_data = get_history_data()
var results = []
query = query.to_lower()
for entry in history_data:
var title = entry.get("title", "").to_lower()
var url = entry.get("url", "").to_lower()
if title.contains(query) or url.contains(query):
results.append(entry)
return results

View File

@@ -0,0 +1 @@
uid://c8wiwde042pkx

View File

@@ -0,0 +1,4 @@
extends Control
func _on_close_button_pressed():
Engine.get_main_loop().current_scene._toggle_dev_tools()

View File

@@ -0,0 +1 @@
uid://21a6ds271vmb

View File

@@ -1,10 +1,31 @@
extends Button
const HISTORY = preload("res://Scenes/BrowserMenus/history.tscn")
@onready var tab_container: TabManager = $"../../TabContainer"
@onready var main: Main = $"../../../"
var history_scene: PopupPanel = null
func _on_pressed() -> void:
%OptionsMenu.show()
func _input(_event: InputEvent) -> void:
if _event is InputEventKey and _event.pressed and _event.ctrl_pressed:
if _event.keycode == KEY_N:
if _event.shift_pressed:
# CTRL+SHIFT+N - New incognito window
_on_options_menu_id_pressed(2)
get_viewport().set_input_as_handled()
else:
# CTRL+N - New window
_on_options_menu_id_pressed(1)
get_viewport().set_input_as_handled()
elif _event.keycode == KEY_H:
# CTRL+H - History
_on_options_menu_id_pressed(4)
get_viewport().set_input_as_handled()
func _on_options_menu_id_pressed(id: int) -> void:
if id == 0: # new tab
tab_container.create_tab()
@@ -14,4 +35,21 @@ func _on_options_menu_id_pressed(id: int) -> void:
# TODO: handle incognito
OS.create_process(OS.get_executable_path(), ["--incognito"])
if id == 4: # history
modulate = Constants.SECONDARY_COLOR
show_history()
if id == 10: # exit
get_tree().quit()
func show_history() -> void:
if history_scene == null:
history_scene = HISTORY.instantiate()
history_scene.navigate_to_url.connect(main.navigate_to_url)
main.add_child(history_scene)
history_scene.connect("popup_hide", _on_history_closed)
else:
history_scene.load_history()
history_scene.show()
func _on_history_closed() -> void:
if history_scene:
history_scene.hide()

View File

@@ -41,6 +41,8 @@ var dev_tools_visible: bool = false
var lua_apis: Array[LuaAPI] = []
var current_url: String = ""
var has_content: bool = false
var navigation_history: Array[String] = []
var history_index: int = -1
func _ready():
add_to_group("tabs")
@@ -70,8 +72,11 @@ func update_icon_from_url(icon_url: String) -> void:
if icon_url.is_empty():
const GLOBE_ICON = preload("res://Assets/Icons/globe.svg")
set_icon(GLOBE_ICON)
remove_meta("original_icon_url")
return
set_meta("original_icon_url", icon_url)
var icon_resource = await Network.fetch_image(icon_url)
if is_instance_valid(self) and icon_resource:
@@ -252,3 +257,39 @@ func get_dev_tools_console() -> DevToolsConsole:
if dev_tools and dev_tools.has_method("get_console"):
return dev_tools.get_console()
return null
func add_to_navigation_history(url: String) -> void:
if url.is_empty():
return
# If we're not at the end of history, remove everything after current position
if history_index < navigation_history.size() - 1:
navigation_history = navigation_history.slice(0, history_index + 1)
# Don't add duplicate consecutive entries
if navigation_history.is_empty() or navigation_history[-1] != url:
navigation_history.append(url)
history_index = navigation_history.size() - 1
func can_go_back() -> bool:
return history_index > 0
func can_go_forward() -> bool:
return history_index < navigation_history.size() - 1
func go_back() -> String:
if can_go_back():
history_index -= 1
return navigation_history[history_index]
return ""
func go_forward() -> String:
if can_go_forward():
history_index += 1
return navigation_history[history_index]
return ""
func get_current_history_url() -> String:
if history_index >= 0 and history_index < navigation_history.size():
return navigation_history[history_index]
return ""

View File

@@ -205,6 +205,8 @@ func set_active_tab(index: int) -> void:
main.current_domain = ""
main.search_bar.text = ""
main.search_bar.grab_focus()
main.update_navigation_buttons()
func create_tab() -> void:
var index = tabs.size();

View File

@@ -65,3 +65,21 @@ static func resolve_url(base_url: String, relative_url: String) -> String:
result += "/" + "/".join(final_path_parts)
return result
static func extract_domain(url: String) -> String:
if url.is_empty():
return ""
var clean_url = url
if clean_url.begins_with("gurt://"):
clean_url = clean_url.substr(7)
elif clean_url.begins_with("https://"):
clean_url = clean_url.substr(8)
elif clean_url.begins_with("http://"):
clean_url = clean_url.substr(7)
var slash_pos = clean_url.find("/")
if slash_pos != -1:
clean_url = clean_url.substr(0, slash_pos)
return clean_url

View File

@@ -1,16 +1,20 @@
extends MarginContainer
extends PopupPanel
signal navigate_to_url(url: String)
@onready var history_entry_container: VBoxContainer = $Main/PanelContainer2/ScrollContainer/HistoryEntryContainer
@onready var delete_menu: PanelContainer = $Main/DeleteMenu
@onready var line_edit: LineEdit = $Main/LineEdit
@onready var entries_label: RichTextLabel = $Main/DeleteMenu/HBoxContainer/RichTextLabel
@onready var cancel_button: Button = $Main/DeleteMenu/HBoxContainer/CancelButton
@onready var delete_button: Button = $Main/DeleteMenu/HBoxContainer/DeleteButton
var toggled_entries = []
var history_entry_scene = preload("res://Scenes/BrowserMenus/history_entry.tscn")
func _ready():
for entry in history_entry_container.get_children():
entry.connect("checkbox_toggle", history_toggle.bind(entry))
delete_button.pressed.connect(_on_delete_button_pressed)
line_edit.text_changed.connect(_on_search_text_changed)
load_history()
func history_toggle(toggled: bool, entry) -> void:
print('toggling ', entry, ' to :', toggled)
@@ -37,3 +41,80 @@ func _on_cancel_button_pressed() -> void:
delete_menu.hide()
line_edit.show()
func _on_delete_button_pressed() -> void:
var urls_to_delete = []
for entry in toggled_entries:
if entry.has_meta("history_url"):
urls_to_delete.append(entry.get_meta("history_url"))
for url in urls_to_delete:
remove_history_entry(url)
var entries_to_remove = toggled_entries.duplicate()
toggled_entries.clear()
for entry in entries_to_remove:
history_entry_container.remove_child(entry)
entry.queue_free()
delete_menu.hide()
line_edit.show()
func _on_search_text_changed(search_text: String) -> void:
filter_history_entries(search_text)
func load_history():
var history_data = BrowserHistory.get_history_data()
var existing_entries = history_entry_container.get_children()
var needs_update = existing_entries.size() != history_data.size()
if not needs_update and history_data.size() > 0 and existing_entries.size() > 0:
var first_entry = existing_entries[0]
if first_entry.has_meta("history_url"):
var stored_url = first_entry.get_meta("history_url")
if stored_url != history_data[0].url:
needs_update = true
if needs_update:
clear_displayed_entries()
for entry in history_data:
add_history_entry_to_display(entry.url, entry.title, entry.timestamp, entry.icon_url)
show()
func clear_displayed_entries():
for child in history_entry_container.get_children():
child.queue_free()
func add_history_entry_to_display(url: String, title_: String, timestamp: String, icon_url: String = ""):
var entry_instance = history_entry_scene.instantiate()
history_entry_container.add_child(entry_instance)
entry_instance.setup_entry(url, title_, timestamp, icon_url)
entry_instance.connect("checkbox_toggle", history_toggle.bind(entry_instance))
entry_instance.connect("entry_clicked", _on_entry_clicked)
entry_instance.set_meta("history_url", url)
func filter_history_entries(search_text: String):
if search_text.is_empty():
# Show all entries
for child in history_entry_container.get_children():
child.visible = true
return
# Filter existing entries by showing/hiding them
var query = search_text.to_lower()
for child in history_entry_container.get_children():
if child.has_method("get_title") and child.has_method("get_url"):
var title_ = child.get_title().to_lower()
var url = child.get_url().to_lower()
child.visible = title_.contains(query) or url.contains(query)
else:
child.visible = false
func remove_history_entry(url: String):
BrowserHistory.remove_entry(url)
func _on_entry_clicked(url: String):
navigate_to_url.emit(url)

View File

@@ -1,10 +1,84 @@
extends HBoxContainer
signal checkbox_toggle
signal entry_clicked(url: String)
@onready var check_box: CheckBox = $CheckBox
@onready var time_label: RichTextLabel = $RichTextLabel
@onready var icon: TextureRect = $TextureRect
@onready var title_label: RichTextLabel = $RichTextLabel2
@onready var domain_label: RichTextLabel = $DomainLabel
var entry_url: String = ""
var entry_title: String = ""
func reset() -> void:
check_box.set_pressed_no_signal(false)
func _on_check_box_toggled(toggled_on: bool) -> void:
checkbox_toggle.emit(toggled_on)
func setup_entry(url: String, title: String, timestamp: String, icon_url: String = ""):
entry_url = url
entry_title = title
title_label.text = title if not title.is_empty() else url
var domain = URLUtils.extract_domain(url)
if domain.is_empty():
domain = url
domain_label.text = domain
var datetime_dict = Time.get_datetime_dict_from_datetime_string(timestamp, false)
if datetime_dict.has("hour") and datetime_dict.has("minute"):
var hour = datetime_dict.hour
var minute = datetime_dict.minute
var am_pm = "AM"
if hour == 0:
hour = 12
elif hour > 12:
hour -= 12
am_pm = "PM"
elif hour == 12:
am_pm = "PM"
time_label.text = "%d:%02d%s" % [hour, minute, am_pm]
else:
time_label.text = ""
if not icon_url.is_empty():
_load_icon(icon_url)
else:
const GLOBE_ICON = preload("res://Assets/Icons/globe.svg")
icon.texture = GLOBE_ICON
func _load_icon(icon_url: String):
if icon_url.is_empty():
const GLOBE_ICON = preload("res://Assets/Icons/globe.svg")
icon.texture = GLOBE_ICON
return
icon.texture = null
var icon_resource = await Network.fetch_image(icon_url)
if is_instance_valid(self) and icon_resource:
icon.texture = icon_resource
elif is_instance_valid(self):
const GLOBE_ICON = preload("res://Assets/Icons/globe.svg")
icon.texture = GLOBE_ICON
func get_title() -> String:
return entry_title
func get_url() -> String:
return entry_url
func _ready():
title_label.gui_input.connect(_on_title_clicked)
domain_label.gui_input.connect(_on_title_clicked)
func _on_title_clicked(event: InputEvent):
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
entry_clicked.emit(entry_url)

View File

@@ -4,6 +4,9 @@ extends Control
@onready var website_container: Control = %WebsiteContainer
@onready var tab_container: TabManager = $VBoxContainer/TabContainer
@onready var search_bar: LineEdit = $VBoxContainer/HBoxContainer/LineEdit
@onready var back_button: Button = $VBoxContainer/HBoxContainer/BackButton
@onready var forward_button: Button = $VBoxContainer/HBoxContainer/ForwardButton
@onready var refresh_button: Button = $VBoxContainer/HBoxContainer/RefreshButton
const LOADER_CIRCLE = preload("res://Assets/Icons/loader-circle.svg")
const AUTO_SIZING_FLEX_CONTAINER = preload("res://Scripts/AutoSizingFlexContainer.gd")
@@ -63,6 +66,7 @@ func _ready():
original_scroll.visible = false
call_deferred("render")
call_deferred("update_navigation_buttons")
func _input(_event: InputEvent) -> void:
if Input.is_action_just_pressed("DevTools"):
@@ -87,7 +91,7 @@ func handle_link_click(meta: Variant) -> void:
else:
OS.shell_open(resolved_url)
func _on_search_submitted(url: String) -> void:
func _on_search_submitted(url: String, add_to_history: bool = true) -> void:
print("Search submitted: ", url)
search_bar.release_focus()
@@ -102,11 +106,11 @@ func _on_search_submitted(url: String) -> void:
if not gurt_url.begins_with("gurt://"):
gurt_url = "gurt://" + gurt_url
await fetch_gurt_content_async(gurt_url, tab, url)
await fetch_gurt_content_async(gurt_url, tab, url, add_to_history)
else:
print("Non-GURT URL entered: ", url)
func fetch_gurt_content_async(gurt_url: String, tab: Tab, original_url: String) -> void:
func fetch_gurt_content_async(gurt_url: String, tab: Tab, original_url: String, add_to_history: bool = true) -> void:
main_navigation_request = NetworkManager.start_request(gurt_url, "GET", false)
main_navigation_request.type = NetworkRequest.RequestType.DOC
network_start_time = Time.get_ticks_msec()
@@ -121,7 +125,7 @@ func fetch_gurt_content_async(gurt_url: String, tab: Tab, original_url: String)
var result = thread.wait_to_finish()
_handle_gurt_result(result, tab, original_url, gurt_url)
_handle_gurt_result(result, tab, original_url, gurt_url, add_to_history)
func _perform_gurt_request_threaded(request_data: Dictionary) -> Dictionary:
var gurt_url: String = request_data.gurt_url
@@ -149,7 +153,7 @@ func _perform_gurt_request_threaded(request_data: Dictionary) -> Dictionary:
return {"success": true, "html_bytes": response.body}
func _handle_gurt_result(result: Dictionary, tab: Tab, original_url: String, gurt_url: String) -> void:
func _handle_gurt_result(result: Dictionary, tab: Tab, original_url: String, gurt_url: String, add_to_history: bool = true) -> void:
if not result.success:
print("GURT request failed: ", result.error)
handle_gurt_error(result.error, tab)
@@ -173,6 +177,11 @@ func _handle_gurt_result(result: Dictionary, tab: Tab, original_url: String, gur
main_navigation_request = null
tab.stop_loading()
if add_to_history:
add_to_history(gurt_url, tab)
else:
update_navigation_buttons()
func handle_gurt_error(error_message: String, tab: Tab) -> void:
var error_html = GurtProtocol.create_error_page(error_message)
@@ -301,6 +310,9 @@ func render_content(html_bytes: PackedByteArray) -> void:
var icon = parser.get_icon()
tab.update_icon_from_url(icon)
if not icon.is_empty():
tab.set_meta("parsed_icon_url", icon)
var body = parser.find_first("body")
if body:
@@ -719,9 +731,12 @@ func reload_current_page() -> void:
if not current_domain.is_empty():
_on_search_submitted(current_domain)
func navigate_to_url(url: String) -> void:
var resolved_url = resolve_url(url)
_on_search_submitted(resolved_url)
func navigate_to_url(url: String, add_to_history: bool = true) -> void:
if url.begins_with("gurt://"):
_on_search_submitted(url, add_to_history)
else:
var resolved_url = resolve_url(url)
_on_search_submitted(resolved_url, add_to_history)
func update_search_bar_from_current_domain() -> void:
if not search_bar.has_focus() and not current_domain.is_empty():
@@ -746,3 +761,54 @@ func get_dev_tools_console() -> DevToolsConsole:
if active_tab:
return active_tab.get_dev_tools_console()
return null
func add_to_history(url: String, tab: Tab, add_to_navigation: bool = true):
if url.is_empty():
return
var title = "New Tab"
var icon_url = ""
if tab:
if add_to_navigation:
tab.add_to_navigation_history(url)
if tab.button and tab.button.text:
title = tab.button.text
if tab.has_meta("parsed_icon_url"):
icon_url = tab.get_meta("parsed_icon_url")
var clean_url = url
if clean_url.begins_with("gurt://"):
clean_url = clean_url.substr(7)
BrowserHistory.add_entry(clean_url, title, icon_url)
update_navigation_buttons()
func _on_back_button_pressed() -> void:
var active_tab = get_active_tab()
if active_tab and active_tab.can_go_back():
var url = active_tab.go_back()
if not url.is_empty():
navigate_to_url(url, false)
func _on_forward_button_pressed() -> void:
var active_tab = get_active_tab()
if active_tab and active_tab.can_go_forward():
var url = active_tab.go_forward()
if not url.is_empty():
navigate_to_url(url, false)
func _on_refresh_button_pressed() -> void:
reload_current_page()
func update_navigation_buttons() -> void:
var active_tab = get_active_tab()
if active_tab:
back_button.disabled = not active_tab.can_go_back()
forward_button.disabled = not active_tab.can_go_forward()
else:
back_button.disabled = true
forward_button.disabled = true