mirror of
https://github.com/LadybirdBrowser/ladybird.git
synced 2025-12-08 06:09:58 +00:00
LibWebView+UI: Generate the application debug menu
By migrating the debug menu to LibWebView, the AppKit and Qt UIs are now in sync - the AppKit UI was previously missing some actions. Further, this inadvertently fixes bugs around applying debug settings to new web views, especially across site-isolated processes. We were previously not applying settings appropriately; this now "just works" in the LibWebView infra.
This commit is contained in:
parent
5d8d9b337a
commit
9c99c48f47
Notes:
github-actions[bot]
2025-09-11 18:25:07 +00:00
Author: https://github.com/trflynn89
Commit: 9c99c48f47
Pull-request: https://github.com/LadybirdBrowser/ladybird/pull/6062
15 changed files with 212 additions and 632 deletions
|
|
@ -5,7 +5,6 @@
|
|||
*/
|
||||
|
||||
#include <LibWebView/Application.h>
|
||||
#include <LibWebView/CookieJar.h>
|
||||
|
||||
#import <Application/ApplicationDelegate.h>
|
||||
#import <Interface/InfoBar.h>
|
||||
|
|
@ -13,7 +12,6 @@
|
|||
#import <Interface/Menu.h>
|
||||
#import <Interface/Tab.h>
|
||||
#import <Interface/TabController.h>
|
||||
#import <LibWebView/UserAgent.h>
|
||||
#import <Utilities/Conversions.h>
|
||||
|
||||
#if !__has_feature(objc_arc)
|
||||
|
|
@ -25,7 +23,6 @@
|
|||
Web::CSS::PreferredColorScheme m_preferred_color_scheme;
|
||||
Web::CSS::PreferredContrast m_preferred_contrast;
|
||||
Web::CSS::PreferredMotion m_preferred_motion;
|
||||
ByteString m_navigator_compatibility_mode;
|
||||
}
|
||||
|
||||
@property (nonatomic, strong) NSMutableArray<TabController*>* managed_tabs;
|
||||
|
|
@ -69,7 +66,6 @@
|
|||
m_preferred_color_scheme = Web::CSS::PreferredColorScheme::Auto;
|
||||
m_preferred_contrast = Web::CSS::PreferredContrast::Auto;
|
||||
m_preferred_motion = Web::CSS::PreferredMotion::Auto;
|
||||
m_navigator_compatibility_mode = "chrome";
|
||||
|
||||
// Reduce the tooltip delay, as the default delay feels quite long.
|
||||
[[NSUserDefaults standardUserDefaults] setObject:@100 forKey:@"NSInitialToolTipDelay"];
|
||||
|
|
@ -166,6 +162,32 @@
|
|||
activateTab:Web::HTML::ActivateTab::Yes];
|
||||
}
|
||||
|
||||
- (void)openSettings:(id)sender
|
||||
{
|
||||
[self createNewTab:URL::URL::about("settings"_string)
|
||||
fromTab:self.active_tab
|
||||
activateTab:Web::HTML::ActivateTab::Yes];
|
||||
}
|
||||
|
||||
- (void)openTaskManager:(id)sender
|
||||
{
|
||||
[self createNewTab:URL::URL::about("processes"_string)
|
||||
fromTab:self.active_tab
|
||||
activateTab:Web::HTML::ActivateTab::Yes];
|
||||
}
|
||||
|
||||
- (void)openLocation:(id)sender
|
||||
{
|
||||
auto* current_tab = [NSApp keyWindow];
|
||||
|
||||
if (![current_tab isKindOfClass:[Tab class]]) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto* controller = (TabController*)[current_tab windowController];
|
||||
[controller focusLocationToolbarItem];
|
||||
}
|
||||
|
||||
- (nonnull TabController*)createNewTab:(Web::HTML::ActivateTab)activate_tab
|
||||
fromTab:(nullable Tab*)tab
|
||||
{
|
||||
|
|
@ -209,14 +231,6 @@
|
|||
}
|
||||
|
||||
[self.managed_tabs addObject:controller];
|
||||
[controller onCreateNewTab];
|
||||
}
|
||||
|
||||
- (void)openSettings:(id)sender
|
||||
{
|
||||
[self createNewTab:URL::URL::about("settings"_string)
|
||||
fromTab:self.active_tab
|
||||
activateTab:Web::HTML::ActivateTab::Yes];
|
||||
}
|
||||
|
||||
- (void)closeCurrentTab:(id)sender
|
||||
|
|
@ -276,25 +290,6 @@
|
|||
activeTab:self.active_tab];
|
||||
}
|
||||
|
||||
- (void)openTaskManager:(id)sender
|
||||
{
|
||||
[self createNewTab:URL::URL::about("processes"_string)
|
||||
fromTab:self.active_tab
|
||||
activateTab:Web::HTML::ActivateTab::Yes];
|
||||
}
|
||||
|
||||
- (void)openLocation:(id)sender
|
||||
{
|
||||
auto* current_tab = [NSApp keyWindow];
|
||||
|
||||
if (![current_tab isKindOfClass:[Tab class]]) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto* controller = (TabController*)[current_tab windowController];
|
||||
[controller focusLocationToolbarItem];
|
||||
}
|
||||
|
||||
- (void)setAutoPreferredColorScheme:(id)sender
|
||||
{
|
||||
m_preferred_color_scheme = Web::CSS::PreferredColorScheme::Auto;
|
||||
|
|
@ -394,16 +389,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
- (void)dumpCookies:(id)sender
|
||||
{
|
||||
WebView::Application::cookie_jar().dump_cookies();
|
||||
}
|
||||
|
||||
- (void)clearAllCookies:(id)sender
|
||||
{
|
||||
WebView::Application::cookie_jar().clear_all_cookies();
|
||||
}
|
||||
|
||||
- (NSMenuItem*)createApplicationMenu
|
||||
{
|
||||
auto* menu = [[NSMenuItem alloc] init];
|
||||
|
|
@ -619,107 +604,10 @@
|
|||
- (NSMenuItem*)createDebugMenu
|
||||
{
|
||||
auto* menu = [[NSMenuItem alloc] init];
|
||||
auto* submenu = [[NSMenu alloc] initWithTitle:@"Debug"];
|
||||
|
||||
[submenu addItem:[[NSMenuItem alloc] initWithTitle:@"Dump DOM Tree"
|
||||
action:@selector(dumpDOMTree:)
|
||||
keyEquivalent:@""]];
|
||||
[submenu addItem:[[NSMenuItem alloc] initWithTitle:@"Dump Layout Tree"
|
||||
action:@selector(dumpLayoutTree:)
|
||||
keyEquivalent:@""]];
|
||||
[submenu addItem:[[NSMenuItem alloc] initWithTitle:@"Dump Paint Tree"
|
||||
action:@selector(dumpPaintTree:)
|
||||
keyEquivalent:@""]];
|
||||
[submenu addItem:[[NSMenuItem alloc] initWithTitle:@"Dump Stacking Context Tree"
|
||||
action:@selector(dumpStackingContextTree:)
|
||||
keyEquivalent:@""]];
|
||||
[submenu addItem:[[NSMenuItem alloc] initWithTitle:@"Dump Display List"
|
||||
action:@selector(dumpDisplayList:)
|
||||
keyEquivalent:@""]];
|
||||
[submenu addItem:[[NSMenuItem alloc] initWithTitle:@"Dump Style Sheets"
|
||||
action:@selector(dumpStyleSheets:)
|
||||
keyEquivalent:@""]];
|
||||
[submenu addItem:[[NSMenuItem alloc] initWithTitle:@"Dump All Resolved Styles"
|
||||
action:@selector(dumpAllResolvedStyles:)
|
||||
keyEquivalent:@""]];
|
||||
[submenu addItem:[[NSMenuItem alloc] initWithTitle:@"Dump CSS Errors"
|
||||
action:@selector(dumpCSSErrors:)
|
||||
keyEquivalent:@""]];
|
||||
[submenu addItem:[[NSMenuItem alloc] initWithTitle:@"Dump History"
|
||||
action:@selector(dumpHistory:)
|
||||
keyEquivalent:@""]];
|
||||
[submenu addItem:[[NSMenuItem alloc] initWithTitle:@"Dump Cookies"
|
||||
action:@selector(dumpCookies:)
|
||||
keyEquivalent:@""]];
|
||||
[submenu addItem:[[NSMenuItem alloc] initWithTitle:@"Dump Local Storage"
|
||||
action:@selector(dumpLocalStorage:)
|
||||
keyEquivalent:@""]];
|
||||
[submenu addItem:[NSMenuItem separatorItem]];
|
||||
|
||||
[submenu addItem:[[NSMenuItem alloc] initWithTitle:@"Show Line Box Borders"
|
||||
action:@selector(toggleLineBoxBorders:)
|
||||
keyEquivalent:@""]];
|
||||
[submenu addItem:[NSMenuItem separatorItem]];
|
||||
|
||||
[submenu addItem:[[NSMenuItem alloc] initWithTitle:@"Collect Garbage"
|
||||
action:@selector(collectGarbage:)
|
||||
keyEquivalent:@""]];
|
||||
[submenu addItem:[[NSMenuItem alloc] initWithTitle:@"Dump GC Graph"
|
||||
action:@selector(dumpGCGraph:)
|
||||
keyEquivalent:@""]];
|
||||
[submenu addItem:[[NSMenuItem alloc] initWithTitle:@"Clear Cache"
|
||||
action:@selector(clearCache:)
|
||||
keyEquivalent:@""]];
|
||||
[submenu addItem:[[NSMenuItem alloc] initWithTitle:@"Clear All Cookies"
|
||||
action:@selector(clearAllCookies:)
|
||||
keyEquivalent:@""]];
|
||||
|
||||
[submenu addItem:[NSMenuItem separatorItem]];
|
||||
|
||||
auto* spoof_user_agent_menu = [[NSMenu alloc] init];
|
||||
auto add_user_agent = [spoof_user_agent_menu](ByteString name) {
|
||||
[spoof_user_agent_menu addItem:[[NSMenuItem alloc] initWithTitle:Ladybird::string_to_ns_string(name)
|
||||
action:@selector(setUserAgentSpoof:)
|
||||
keyEquivalent:@""]];
|
||||
};
|
||||
|
||||
add_user_agent("Disabled");
|
||||
for (auto const& userAgent : WebView::user_agents)
|
||||
add_user_agent(userAgent.key);
|
||||
|
||||
auto* spoof_user_agent_menu_item = [[NSMenuItem alloc] initWithTitle:@"Spoof User Agent"
|
||||
action:nil
|
||||
keyEquivalent:@""];
|
||||
[spoof_user_agent_menu_item setSubmenu:spoof_user_agent_menu];
|
||||
|
||||
[submenu addItem:spoof_user_agent_menu_item];
|
||||
|
||||
auto* navigator_compatibility_mode_menu = [[NSMenu alloc] init];
|
||||
auto add_navigator_compatibility_mode = [navigator_compatibility_mode_menu](ByteString name) {
|
||||
[navigator_compatibility_mode_menu addItem:[[NSMenuItem alloc] initWithTitle:Ladybird::string_to_ns_string(name)
|
||||
action:@selector(setNavigatorCompatibilityMode:)
|
||||
keyEquivalent:@""]];
|
||||
};
|
||||
add_navigator_compatibility_mode("Chrome");
|
||||
add_navigator_compatibility_mode("Gecko");
|
||||
add_navigator_compatibility_mode("WebKit");
|
||||
|
||||
auto* navigator_compatibility_mode_menu_item = [[NSMenuItem alloc] initWithTitle:@"Navigator Compatibility Mode"
|
||||
action:nil
|
||||
keyEquivalent:@""];
|
||||
[navigator_compatibility_mode_menu_item setSubmenu:navigator_compatibility_mode_menu];
|
||||
|
||||
[submenu addItem:navigator_compatibility_mode_menu_item];
|
||||
[submenu addItem:[NSMenuItem separatorItem]];
|
||||
|
||||
[submenu addItem:[[NSMenuItem alloc] initWithTitle:@"Enable Scripting"
|
||||
action:@selector(toggleScripting:)
|
||||
keyEquivalent:@""]];
|
||||
[submenu addItem:[[NSMenuItem alloc] initWithTitle:@"Block Pop-ups"
|
||||
action:@selector(togglePopupBlocking:)
|
||||
keyEquivalent:@""]];
|
||||
|
||||
auto* submenu = Ladybird::create_application_menu(WebView::Application::the().debug_menu());
|
||||
[menu setSubmenu:submenu];
|
||||
|
||||
return menu;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -80,6 +80,4 @@
|
|||
- (void)resetZoom;
|
||||
- (float)zoomLevel;
|
||||
|
||||
- (void)debugRequest:(ByteString const&)request argument:(ByteString const&)argument;
|
||||
|
||||
@end
|
||||
|
|
|
|||
|
|
@ -241,11 +241,6 @@ struct HideCursor {
|
|||
m_web_view_bridge->set_preferred_motion(motion);
|
||||
}
|
||||
|
||||
- (void)debugRequest:(ByteString const&)request argument:(ByteString const&)argument
|
||||
{
|
||||
m_web_view_bridge->debug_request(request, argument);
|
||||
}
|
||||
|
||||
#pragma mark - Private methods
|
||||
|
||||
static void copy_data_to_clipboard(StringView data, NSPasteboardType pasteboard_type)
|
||||
|
|
|
|||
|
|
@ -13,14 +13,6 @@
|
|||
|
||||
@class Tab;
|
||||
|
||||
struct TabSettings {
|
||||
BOOL should_show_line_box_borders { NO };
|
||||
BOOL scripting_enabled { YES };
|
||||
BOOL block_popups { YES };
|
||||
ByteString user_agent_name { "Disabled"sv };
|
||||
ByteString navigator_compatibility_mode { "chrome"sv };
|
||||
};
|
||||
|
||||
@interface TabController : NSWindowController <NSWindowDelegate>
|
||||
|
||||
- (instancetype)init;
|
||||
|
|
@ -34,14 +26,8 @@ struct TabSettings {
|
|||
|
||||
- (void)onURLChange:(URL::URL const&)url;
|
||||
|
||||
- (void)onCreateNewTab;
|
||||
|
||||
- (void)clearHistory;
|
||||
|
||||
- (void)setPopupBlocking:(BOOL)block_popups;
|
||||
- (void)setScripting:(BOOL)enabled;
|
||||
- (void)debugRequest:(ByteString const&)request argument:(ByteString const&)argument;
|
||||
|
||||
- (void)focusLocationToolbarItem;
|
||||
|
||||
@end
|
||||
|
|
|
|||
|
|
@ -4,11 +4,9 @@
|
|||
* SPDX-License-Identifier: BSD-2-Clause
|
||||
*/
|
||||
|
||||
#include <LibWeb/Loader/UserAgent.h>
|
||||
#include <LibWebView/Application.h>
|
||||
#include <LibWebView/Autocomplete.h>
|
||||
#include <LibWebView/URL.h>
|
||||
#include <LibWebView/UserAgent.h>
|
||||
#include <LibWebView/ViewImplementation.h>
|
||||
|
||||
#import <Application/ApplicationDelegate.h>
|
||||
|
|
@ -54,8 +52,6 @@ static NSString* const TOOLBAR_TAB_OVERVIEW_IDENTIFIER = @"ToolbarTabOverviewIde
|
|||
{
|
||||
u64 m_page_index;
|
||||
|
||||
TabSettings m_settings;
|
||||
|
||||
OwnPtr<WebView::Autocomplete> m_autocomplete;
|
||||
}
|
||||
|
||||
|
|
@ -105,14 +101,6 @@ static NSString* const TOOLBAR_TAB_OVERVIEW_IDENTIFIER = @"ToolbarTabOverviewIde
|
|||
|
||||
m_page_index = 0;
|
||||
|
||||
m_settings = {
|
||||
.scripting_enabled = WebView::Application::browser_options().disable_scripting == WebView::DisableScripting::Yes ? NO : YES,
|
||||
.block_popups = WebView::Application::browser_options().allow_popups == WebView::AllowPopups::Yes ? NO : YES,
|
||||
};
|
||||
|
||||
if (auto const& user_agent_preset = WebView::Application::web_content_options().user_agent_preset; user_agent_preset.has_value())
|
||||
m_settings.user_agent_name = *user_agent_preset;
|
||||
|
||||
self.autocomplete = [[Autocomplete alloc] init:self withToolbarItem:self.location_toolbar_item];
|
||||
m_autocomplete = make<WebView::Autocomplete>();
|
||||
|
||||
|
|
@ -163,12 +151,6 @@ static NSString* const TOOLBAR_TAB_OVERVIEW_IDENTIFIER = @"ToolbarTabOverviewIde
|
|||
[self.window makeFirstResponder:[self tab].web_view];
|
||||
}
|
||||
|
||||
- (void)onCreateNewTab
|
||||
{
|
||||
[self setPopupBlocking:m_settings.block_popups];
|
||||
[self setScripting:m_settings.scripting_enabled];
|
||||
}
|
||||
|
||||
- (void)zoomIn:(id)sender
|
||||
{
|
||||
[[[self tab] web_view] zoomIn];
|
||||
|
|
@ -192,11 +174,6 @@ static NSString* const TOOLBAR_TAB_OVERVIEW_IDENTIFIER = @"ToolbarTabOverviewIde
|
|||
// FIXME: Reimplement clearing history using WebContent's history.
|
||||
}
|
||||
|
||||
- (void)debugRequest:(ByteString const&)request argument:(ByteString const&)argument
|
||||
{
|
||||
[[[self tab] web_view] debugRequest:request argument:argument];
|
||||
}
|
||||
|
||||
- (void)focusLocationToolbarItem
|
||||
{
|
||||
[self.window makeFirstResponder:self.location_toolbar_item.view];
|
||||
|
|
@ -291,124 +268,6 @@ static NSString* const TOOLBAR_TAB_OVERVIEW_IDENTIFIER = @"ToolbarTabOverviewIde
|
|||
[[self.zoom_toolbar_item view] setHidden:zoom_button_hidden];
|
||||
}
|
||||
|
||||
- (void)dumpDOMTree:(id)sender
|
||||
{
|
||||
[self debugRequest:"dump-dom-tree" argument:""];
|
||||
}
|
||||
|
||||
- (void)dumpDisplayList:(id)sender
|
||||
{
|
||||
[self debugRequest:"dump-display-list" argument:""];
|
||||
}
|
||||
|
||||
- (void)dumpLayoutTree:(id)sender
|
||||
{
|
||||
[self debugRequest:"dump-layout-tree" argument:""];
|
||||
}
|
||||
|
||||
- (void)dumpPaintTree:(id)sender
|
||||
{
|
||||
[self debugRequest:"dump-paint-tree" argument:""];
|
||||
}
|
||||
|
||||
- (void)dumpStackingContextTree:(id)sender
|
||||
{
|
||||
[self debugRequest:"dump-stacking-context-tree" argument:""];
|
||||
}
|
||||
|
||||
- (void)dumpStyleSheets:(id)sender
|
||||
{
|
||||
[self debugRequest:"dump-style-sheets" argument:""];
|
||||
}
|
||||
|
||||
- (void)dumpAllResolvedStyles:(id)sender
|
||||
{
|
||||
[self debugRequest:"dump-all-resolved-styles" argument:""];
|
||||
}
|
||||
|
||||
- (void)dumpCSSErrors:(id)sender
|
||||
{
|
||||
[self debugRequest:"dump-all-css-errors" argument:""];
|
||||
}
|
||||
|
||||
- (void)dumpHistory:(id)sender
|
||||
{
|
||||
[self debugRequest:"dump-session-history" argument:""];
|
||||
}
|
||||
|
||||
- (void)dumpLocalStorage:(id)sender
|
||||
{
|
||||
[self debugRequest:"dump-local-storage" argument:""];
|
||||
}
|
||||
|
||||
- (void)toggleLineBoxBorders:(id)sender
|
||||
{
|
||||
m_settings.should_show_line_box_borders = !m_settings.should_show_line_box_borders;
|
||||
[self debugRequest:"set-line-box-borders" argument:m_settings.should_show_line_box_borders ? "on" : "off"];
|
||||
}
|
||||
|
||||
- (void)collectGarbage:(id)sender
|
||||
{
|
||||
[self debugRequest:"collect-garbage" argument:""];
|
||||
}
|
||||
|
||||
- (void)dumpGCGraph:(id)sender
|
||||
{
|
||||
auto& view_impl = [[[self tab] web_view] view];
|
||||
auto gc_graph_path = view_impl.dump_gc_graph();
|
||||
warnln("\033[33;1mDumped GC-graph into {}\033[0m", gc_graph_path);
|
||||
}
|
||||
|
||||
- (void)clearCache:(id)sender
|
||||
{
|
||||
[self debugRequest:"clear-cache" argument:""];
|
||||
}
|
||||
|
||||
- (void)toggleScripting:(id)sender
|
||||
{
|
||||
m_settings.scripting_enabled = !m_settings.scripting_enabled;
|
||||
[self setScripting:m_settings.scripting_enabled];
|
||||
}
|
||||
|
||||
- (void)setScripting:(BOOL)enabled
|
||||
{
|
||||
[self debugRequest:"scripting" argument:enabled ? "on" : "off"];
|
||||
}
|
||||
|
||||
- (void)togglePopupBlocking:(id)sender
|
||||
{
|
||||
m_settings.block_popups = !m_settings.block_popups;
|
||||
[self setPopupBlocking:m_settings.block_popups];
|
||||
}
|
||||
|
||||
- (void)setPopupBlocking:(BOOL)block_popups
|
||||
{
|
||||
[self debugRequest:"block-pop-ups" argument:block_popups ? "on" : "off"];
|
||||
}
|
||||
|
||||
- (void)setUserAgentSpoof:(NSMenuItem*)sender
|
||||
{
|
||||
ByteString const user_agent_name = [[sender title] UTF8String];
|
||||
ByteString user_agent = "";
|
||||
if (user_agent_name == "Disabled"sv) {
|
||||
user_agent = Web::default_user_agent;
|
||||
} else {
|
||||
user_agent = WebView::user_agents.get(user_agent_name).value();
|
||||
}
|
||||
m_settings.user_agent_name = user_agent_name;
|
||||
|
||||
[self debugRequest:"spoof-user-agent" argument:user_agent];
|
||||
[self debugRequest:"clear-cache" argument:""]; // clear the cache to ensure requests are re-done with the new user agent
|
||||
}
|
||||
|
||||
- (void)setNavigatorCompatibilityMode:(NSMenuItem*)sender
|
||||
{
|
||||
ByteString const compatibility_mode = [[[sender title] lowercaseString] UTF8String];
|
||||
m_settings.navigator_compatibility_mode = compatibility_mode;
|
||||
|
||||
[self debugRequest:"navigator-compatibility-mode" argument:compatibility_mode];
|
||||
}
|
||||
|
||||
#pragma mark - Properties
|
||||
|
||||
- (NSButton*)create_button:(NSImageName)image
|
||||
|
|
@ -605,23 +464,6 @@ static NSString* const TOOLBAR_TAB_OVERVIEW_IDENTIFIER = @"ToolbarTabOverviewIde
|
|||
[[[self tab] web_view] handleDisplayRefreshRateChange];
|
||||
}
|
||||
|
||||
- (BOOL)validateMenuItem:(NSMenuItem*)item
|
||||
{
|
||||
if ([item action] == @selector(toggleLineBoxBorders:)) {
|
||||
[item setState:m_settings.should_show_line_box_borders ? NSControlStateValueOn : NSControlStateValueOff];
|
||||
} else if ([item action] == @selector(toggleScripting:)) {
|
||||
[item setState:m_settings.scripting_enabled ? NSControlStateValueOn : NSControlStateValueOff];
|
||||
} else if ([item action] == @selector(togglePopupBlocking:)) {
|
||||
[item setState:m_settings.block_popups ? NSControlStateValueOn : NSControlStateValueOff];
|
||||
} else if ([item action] == @selector(setUserAgentSpoof:)) {
|
||||
[item setState:(m_settings.user_agent_name == [[item title] UTF8String]) ? NSControlStateValueOn : NSControlStateValueOff];
|
||||
} else if ([item action] == @selector(setNavigatorCompatibilityMode:)) {
|
||||
[item setState:(m_settings.navigator_compatibility_mode == [[[item title] lowercaseString] UTF8String]) ? NSControlStateValueOn : NSControlStateValueOff];
|
||||
}
|
||||
|
||||
return YES;
|
||||
}
|
||||
|
||||
#pragma mark - NSToolbarDelegate
|
||||
|
||||
- (NSToolbarItem*)toolbar:(NSToolbar*)toolbar
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue