mirror of
https://github.com/vee1e/mitmproxy.git
synced 2026-09-01 18:27:18 +00:00
[web] options: make help and err permanently visible, improve perf
This commit is contained in:
parent
21b3f9c029
commit
babd967eb8
10 changed files with 260 additions and 338 deletions
121
web/src/js/components/Modal/Option.jsx
Normal file
121
web/src/js/components/Modal/Option.jsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import React, { Component } from "react"
|
||||
import PropTypes from "prop-types"
|
||||
import { connect } from "react-redux"
|
||||
import { update as updateOptions } from "../../ducks/options"
|
||||
import { Key } from "../../utils"
|
||||
|
||||
const stopPropagation = e => {
|
||||
if (e.keyCode !== Key.ESC) {
|
||||
e.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
BooleanOption.PropTypes = {
|
||||
value: PropTypes.bool.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
}
|
||||
function BooleanOption({ value, onChange, ...props }) {
|
||||
return (
|
||||
<input type="checkbox"
|
||||
checked={value}
|
||||
onChange={e => onChange(e.target.checked)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
StringOption.PropTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
}
|
||||
function StringOption({ value, onChange, ...props }) {
|
||||
return (
|
||||
<input type="text"
|
||||
value={value || ""}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
NumberOption.PropTypes = {
|
||||
value: PropTypes.number.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
}
|
||||
function NumberOption({ value, onChange, ...props }) {
|
||||
return (
|
||||
<input type="number"
|
||||
value={value}
|
||||
onChange={(e) => onChange(parseInt(e.target.value))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
ChoicesOption.PropTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
}
|
||||
function ChoicesOption({ value, onChange, choices, ...props }) {
|
||||
return (
|
||||
<select
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
selected={value}
|
||||
{...props}
|
||||
>
|
||||
{ choices.map(
|
||||
choice => (
|
||||
<option key={choice} value={choice}>{choice}</option>
|
||||
)
|
||||
)}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
StringSequenceOption.PropTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
}
|
||||
function StringSequenceOption({ value, onChange, ...props }) {
|
||||
const height = Math.max(value.length, 1)
|
||||
return <textarea
|
||||
rows={height}
|
||||
value={value.join("\n")}
|
||||
onChange={e => onChange(e.target.value.split("\n"))}
|
||||
{...props}
|
||||
/>
|
||||
}
|
||||
|
||||
const Options = {
|
||||
"bool": BooleanOption,
|
||||
"str": StringOption,
|
||||
"int": NumberOption,
|
||||
"optional str": StringOption,
|
||||
"sequence of str": StringSequenceOption,
|
||||
}
|
||||
|
||||
function PureOption({ choices, type, value, onChange }) {
|
||||
if (choices) {
|
||||
return <ChoicesOption
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
choices={choices}
|
||||
onKeyDown={stopPropagation}
|
||||
/>
|
||||
}
|
||||
const Opt = Options[type]
|
||||
return <Opt
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onKeyDown={stopPropagation}
|
||||
/>
|
||||
}
|
||||
export default connect(
|
||||
(state, { name }) => ({
|
||||
...state.options[name],
|
||||
...state.ui.optionsEditor[name]
|
||||
}),
|
||||
(dispatch, { name }) => ({
|
||||
onChange: value => dispatch(updateOptions(name, value))
|
||||
})
|
||||
)(PureOption)
|
||||
|
|
@ -1,257 +0,0 @@
|
|||
import React, { Component } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { connect } from 'react-redux'
|
||||
import classnames from 'classnames'
|
||||
import { update as updateOptions } from '../../ducks/options'
|
||||
|
||||
PureBooleanOption.PropTypes = {
|
||||
value: PropTypes.bool.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
function PureBooleanOption({ value, onChange, ...props}) {
|
||||
return (
|
||||
<input type="checkbox"
|
||||
checked={value}
|
||||
onChange={onChange}
|
||||
onMouseOver={props.onMouseEnter}
|
||||
onMouseLeave={props.onMouseLeave}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
PureStringOption.PropTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
function PureStringOption( { value, onChange, ...props }) {
|
||||
let onKeyDown = (e) => {e.stopPropagation()}
|
||||
return (
|
||||
<div className={classnames('input-group', {'has-error': props.error})}>
|
||||
<input type="text"
|
||||
value={value}
|
||||
className='form-control'
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={props.onFocus}
|
||||
onBlur={props.onBlur}
|
||||
onMouseOver={props.onMouseEnter}
|
||||
onMouseLeave={props.onMouseLeave}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
PureNumberOption.PropTypes = {
|
||||
value: PropTypes.number.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
function PureNumberOption( {value, onChange, ...props }) {
|
||||
let onKeyDown = (e) => {e.stopPropagation()}
|
||||
|
||||
return (
|
||||
<input type="number"
|
||||
className="form-control"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={props.onFocus}
|
||||
onBlur={props.onBlur}
|
||||
onMouseOver={props.onMouseEnter}
|
||||
onMouseLeave={props.onMouseLeave}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
PureChoicesOption.PropTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
function PureChoicesOption( { value, onChange, name, choices, ...props}) {
|
||||
return (
|
||||
<select
|
||||
name={name}
|
||||
className="form-control"
|
||||
onChange={onChange}
|
||||
selected={value}
|
||||
onFocus={props.onFocus}
|
||||
onBlur={props.onBlur}
|
||||
onMouseOver={props.onMouseEnter}
|
||||
onMouseLeave={props.onMouseLeave}
|
||||
>
|
||||
{ choices.map((choice, index) => (
|
||||
<option key={index} value={choice}> {choice} </option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
class PureStringSequenceOption extends Component {
|
||||
constructor(props, context) {
|
||||
super(props, context)
|
||||
this.state = { height: 1, focus: false, value: this.props.value}
|
||||
|
||||
this.onFocus = this.onFocus.bind(this)
|
||||
this.onBlur = this.onBlur.bind(this)
|
||||
this.onKeyDown = this.onKeyDown.bind(this)
|
||||
this.onChange = this.onChange.bind(this)
|
||||
}
|
||||
|
||||
onFocus() {
|
||||
this.setState( {focus: true, height: 3 })
|
||||
this.props.onFocus()
|
||||
}
|
||||
|
||||
onBlur() {
|
||||
this.setState( {focus: false, height: 1})
|
||||
this.props.onBlur()
|
||||
}
|
||||
|
||||
onKeyDown(e) {
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
onChange(e) {
|
||||
const value = e.target.value.split("\n")
|
||||
this.props.onChange(e)
|
||||
this.setState({ value })
|
||||
}
|
||||
|
||||
render() {
|
||||
const {height, value} = this.state
|
||||
const {error, onMouseEnter, onMouseLeave} = this.props
|
||||
return (
|
||||
<div className={classnames('input-group', {'has-error': error})}>
|
||||
<textarea
|
||||
rows={height}
|
||||
value={value}
|
||||
className="form-control"
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const OptionTypes = {
|
||||
bool: PureBooleanOption,
|
||||
str: PureStringOption,
|
||||
int: PureNumberOption,
|
||||
"optional str": PureStringOption,
|
||||
"sequence of str": PureStringSequenceOption,
|
||||
}
|
||||
|
||||
class OptionMaster extends Component {
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context)
|
||||
this.state = {
|
||||
option: this.props.option,
|
||||
name: this.props.name,
|
||||
mousefocus: false,
|
||||
focus: false,
|
||||
error: false,
|
||||
}
|
||||
if (props.option.choices) {
|
||||
this.WrappedComponent = PureChoicesOption
|
||||
} else {
|
||||
this.WrappedComponent = OptionTypes[props.option.type]
|
||||
}
|
||||
this.onChange = this.onChange.bind(this)
|
||||
this.onMouseEnter = this.onMouseEnter.bind(this)
|
||||
this.onMouseLeave = this.onMouseLeave.bind(this)
|
||||
this.onFocus = this.onFocus.bind(this)
|
||||
this.onBlur = this.onBlur.bind(this)
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
this.setState({ option: nextProps.option })
|
||||
}
|
||||
|
||||
onChange(e) {
|
||||
const { option, name } = this.state
|
||||
const { updateOptions } = this.props
|
||||
switch (option.type) {
|
||||
case 'bool' :
|
||||
updateOptions({[name]: !option.value})
|
||||
break
|
||||
case 'int':
|
||||
updateOptions({[name]: parseInt(e.target.value)})
|
||||
break
|
||||
case 'sequence of str':
|
||||
const value = e.target.value.split('\n')
|
||||
updateOptions({[name]: value})
|
||||
break
|
||||
default:
|
||||
updateOptions({[name]: e.target.value})
|
||||
}
|
||||
}
|
||||
|
||||
onMouseEnter() {
|
||||
this.setState({ mousefocus: true })
|
||||
}
|
||||
|
||||
onMouseLeave() {
|
||||
this.setState({ mousefocus: false })
|
||||
}
|
||||
|
||||
onFocus() {
|
||||
this.setState({ focus: true })
|
||||
}
|
||||
|
||||
onBlur() {
|
||||
this.setState({ focus: false })
|
||||
}
|
||||
|
||||
render() {
|
||||
const { name, children, client_options } = this.props
|
||||
const { option, focus, mousefocus } = this.state
|
||||
const WrappedComponent = this.WrappedComponent
|
||||
let error = (name in client_options) ? client_options[name].error : false,
|
||||
value = (name in client_options) ? client_options[name].value : option.value
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-8">
|
||||
{name}
|
||||
</div>
|
||||
<div className="col-sm-4">
|
||||
<WrappedComponent
|
||||
children={children}
|
||||
value={value}
|
||||
onChange={this.onChange}
|
||||
name={name}
|
||||
choices={option.choices}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
error={error}
|
||||
/>
|
||||
{(focus || mousefocus) && (
|
||||
<div className="tooltip tooltip-bottom" role="tooltip" style={{opacity: 1}}>
|
||||
<div className="tooltip-inner">
|
||||
{option.help}
|
||||
</div>
|
||||
</div>)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
client_options: state.ui.option
|
||||
}),
|
||||
{
|
||||
updateOptions
|
||||
}
|
||||
)(OptionMaster)
|
||||
|
|
@ -1,7 +1,22 @@
|
|||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import * as modalAction from '../../ducks/ui/modal'
|
||||
import Option from './OptionMaster'
|
||||
import React, { Component } from "react"
|
||||
import { connect } from "react-redux"
|
||||
import * as modalAction from "../../ducks/ui/modal"
|
||||
import Option from "./Option"
|
||||
|
||||
function PureOptionHelp({help}){
|
||||
return <div className="small text-muted">{help}</div>;
|
||||
}
|
||||
const OptionHelp = connect((state, {name}) => ({
|
||||
help: state.options[name].help,
|
||||
}))(PureOptionHelp);
|
||||
|
||||
function PureOptionError({error}){
|
||||
if(!error) return null;
|
||||
return <div className="small text-danger">{error}</div>;
|
||||
}
|
||||
const OptionError = connect((state, {name}) => ({
|
||||
error: state.ui.optionsEditor[name] && state.ui.optionsEditor[name].error
|
||||
}))(PureOptionError);
|
||||
|
||||
class PureOptionModal extends Component {
|
||||
|
||||
|
|
@ -28,18 +43,20 @@ class PureOptionModal extends Component {
|
|||
|
||||
<div className="modal-body">
|
||||
<div className="container-fluid">
|
||||
{
|
||||
Object.keys(options).sort()
|
||||
.map((key, index) => {
|
||||
let option = options[key];
|
||||
return (
|
||||
<Option
|
||||
key={index}
|
||||
name={key}
|
||||
option={option}
|
||||
/>)
|
||||
})
|
||||
}
|
||||
{
|
||||
options.map(name =>
|
||||
<div key={name} className="row">
|
||||
<div className="col-xs-6">
|
||||
{name}
|
||||
<OptionHelp name={name}/>
|
||||
</div>
|
||||
<div className="col-xs-6">
|
||||
<Option name={name}/>
|
||||
<OptionError name={name}/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -52,7 +69,7 @@ class PureOptionModal extends Component {
|
|||
|
||||
export default connect(
|
||||
state => ({
|
||||
options: state.options
|
||||
options: Object.keys(state.options)
|
||||
}),
|
||||
{
|
||||
hideModal: modalAction.hideModal,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export const ConnectionState = {
|
||||
INIT: Symbol("init"),
|
||||
FETCHING: Symbol("fetching"), // WebSocket is established, but still startFetching resources.
|
||||
FETCHING: Symbol("fetching"), // WebSocket is established, but still fetching resources.
|
||||
ESTABLISHED: Symbol("established"),
|
||||
ERROR: Symbol("error"),
|
||||
OFFLINE: Symbol("offline"), // indicates that there is no live (websocket) backend.
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
import { fetchApi } from '../utils'
|
||||
import * as optionActions from './ui/option'
|
||||
import { fetchApi } from "../utils"
|
||||
import * as optionsEditorActions from "./ui/optionsEditor"
|
||||
import _ from "lodash"
|
||||
|
||||
export const RECEIVE = 'OPTIONS_RECEIVE'
|
||||
export const UPDATE = 'OPTIONS_UPDATE'
|
||||
export const RECEIVE = 'OPTIONS_RECEIVE'
|
||||
export const UPDATE = 'OPTIONS_UPDATE'
|
||||
export const REQUEST_UPDATE = 'REQUEST_UPDATE'
|
||||
export const UNKNOWN_CMD = 'OPTIONS_UNKNOWN_CMD'
|
||||
|
||||
const defaultState = {
|
||||
|
||||
}
|
||||
const defaultState = {}
|
||||
|
||||
export default function reducer(state = defaultState, action) {
|
||||
switch (action.type) {
|
||||
|
|
@ -27,18 +25,23 @@ export default function reducer(state = defaultState, action) {
|
|||
}
|
||||
}
|
||||
|
||||
export function update(options) {
|
||||
|
||||
let sendUpdate = (option, value, dispatch) => {
|
||||
fetchApi.put('/options', { [option]: value }).then(response => {
|
||||
if (response.status === 200) {
|
||||
dispatch(optionsEditorActions.updateSuccess(option))
|
||||
} else {
|
||||
response.text().then(error => {
|
||||
dispatch(optionsEditorActions.updateError(option, error))
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
sendUpdate = _.throttle(sendUpdate, 700, { leading: true, trailing: true })
|
||||
|
||||
export function update(option, value) {
|
||||
return dispatch => {
|
||||
let option = Object.keys(options)[0]
|
||||
dispatch({ type: optionActions.OPTION_UPDATE_START, option, value: options[option] })
|
||||
fetchApi.put('/options', options).then(response => {
|
||||
if (response.status === 200) {
|
||||
dispatch({ type: optionActions.OPTION_UPDATE_SUCCESS, option})
|
||||
} else {
|
||||
response.text().then( text => {
|
||||
dispatch({type: optionActions.OPTION_UPDATE_ERROR, error: text, option})
|
||||
})
|
||||
}
|
||||
})
|
||||
dispatch(optionsEditorActions.startUpdate(option, value))
|
||||
sendUpdate(option, value, dispatch);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { fetchApi } from '../utils'
|
|||
export const RECEIVE = 'SETTINGS_RECEIVE'
|
||||
export const UPDATE = 'SETTINGS_UPDATE'
|
||||
export const REQUEST_UPDATE = 'REQUEST_UPDATE'
|
||||
export const UNKNOWN_CMD = 'SETTINGS_UNKNOWN_CMD'
|
||||
|
||||
const defaultState = {
|
||||
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import { combineReducers } from 'redux'
|
|||
import flow from './flow'
|
||||
import header from './header'
|
||||
import modal from './modal'
|
||||
import option from './option'
|
||||
import optionsEditor from './optionsEditor'
|
||||
|
||||
// TODO: Just move ducks/ui/* into ducks/?
|
||||
export default combineReducers({
|
||||
flow,
|
||||
header,
|
||||
modal,
|
||||
option,
|
||||
optionsEditor,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Key } from "../../utils"
|
||||
import { selectTab } from "./flow"
|
||||
import * as flowsActions from "../flows"
|
||||
import * as modalActions from "./modal"
|
||||
|
||||
|
||||
export function onKeyDown(e) {
|
||||
|
|
@ -46,7 +47,11 @@ export function onKeyDown(e) {
|
|||
break
|
||||
|
||||
case Key.ESC:
|
||||
dispatch(flowsActions.select(null))
|
||||
if(getState().ui.modal.activeModal){
|
||||
dispatch(modalActions.hideModal())
|
||||
} else {
|
||||
dispatch(flowsActions.select(null))
|
||||
}
|
||||
break
|
||||
|
||||
case Key.LEFT: {
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
export const OPTION_UPDATE_START = 'UI_OPTION_UPDATE_START'
|
||||
export const OPTION_UPDATE_SUCCESS = 'UI_OPTION_UPDATE_SUCCESS'
|
||||
export const OPTION_UPDATE_ERROR = 'UI_OPTION_UPDATE_ERROR'
|
||||
|
||||
const defaultState = {
|
||||
/* optionName -> {isUpdating, value (client-side), error} */
|
||||
}
|
||||
|
||||
export default function reducer(state = defaultState, action) {
|
||||
switch (action.type) {
|
||||
case OPTION_UPDATE_START:
|
||||
return {
|
||||
...state,
|
||||
[action.option]: {
|
||||
isUpdate: true,
|
||||
value: action.value,
|
||||
error: false,
|
||||
}
|
||||
}
|
||||
|
||||
case OPTION_UPDATE_SUCCESS:
|
||||
let s = {...state}
|
||||
delete s[action.option]
|
||||
return s
|
||||
|
||||
case OPTION_UPDATE_ERROR:
|
||||
return {
|
||||
...state,
|
||||
[action.option]: {
|
||||
...state[action.option],
|
||||
isUpdating: false,
|
||||
error: action.error
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
73
web/src/js/ducks/ui/optionsEditor.js
Normal file
73
web/src/js/ducks/ui/optionsEditor.js
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { HIDE_MODAL } from "./modal"
|
||||
|
||||
export const OPTION_UPDATE_START = 'UI_OPTION_UPDATE_START'
|
||||
export const OPTION_UPDATE_SUCCESS = 'UI_OPTION_UPDATE_SUCCESS'
|
||||
export const OPTION_UPDATE_ERROR = 'UI_OPTION_UPDATE_ERROR'
|
||||
|
||||
const defaultState = {
|
||||
/* optionName -> {isUpdating, value (client-side), error} */
|
||||
}
|
||||
|
||||
export default function reducer(state = defaultState, action) {
|
||||
switch (action.type) {
|
||||
case OPTION_UPDATE_START:
|
||||
return {
|
||||
...state,
|
||||
[action.option]: {
|
||||
isUpdate: true,
|
||||
value: action.value,
|
||||
error: false,
|
||||
}
|
||||
}
|
||||
|
||||
case OPTION_UPDATE_SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
[action.option]: undefined
|
||||
}
|
||||
|
||||
case OPTION_UPDATE_ERROR:
|
||||
let val = state[action.option].value;
|
||||
if (typeof(val) === "boolean") {
|
||||
// If a boolean option errs, reset it to its previous state to be less confusing.
|
||||
// Example: Start mitmweb, check "add_upstream_certs_to_client_chain".
|
||||
val = !val;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
[action.option]: {
|
||||
value: val,
|
||||
isUpdating: false,
|
||||
error: action.error
|
||||
}
|
||||
}
|
||||
|
||||
case HIDE_MODAL:
|
||||
return {}
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
export function startUpdate(option, value) {
|
||||
return {
|
||||
type: OPTION_UPDATE_START,
|
||||
option,
|
||||
value,
|
||||
}
|
||||
}
|
||||
export function updateSuccess(option) {
|
||||
return {
|
||||
type: OPTION_UPDATE_SUCCESS,
|
||||
option,
|
||||
}
|
||||
}
|
||||
|
||||
export function updateError(option, error) {
|
||||
return {
|
||||
type: OPTION_UPDATE_ERROR,
|
||||
option,
|
||||
error,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue