edit predictions: Preview while holding modifier mode (#24316)
This PR adds a new `inline_completions.inline_preview` config which can be set to `auto` (current behavior) or to `when_holding_modifier`. When set to the latter, instead of showing edit prediction previews inline in the buffer, we'll show it in a popover (even when there's no LSP completion) so your isn't constantly moving as completions arrive. https://github.com/user-attachments/assets/3615d151-3633-4ee4-98b9-66ee0aa735b8 Release Notes: - N/A --------- Co-authored-by: Danilo <danilo@zed.dev>
This commit is contained in:
parent
b4d8b1be3f
commit
8ed8b4d2ec
8 changed files with 113 additions and 60 deletions
|
@ -783,7 +783,14 @@
|
||||||
"**/*.cert",
|
"**/*.cert",
|
||||||
"**/*.crt",
|
"**/*.crt",
|
||||||
"**/secrets.yml"
|
"**/secrets.yml"
|
||||||
]
|
],
|
||||||
|
// When to show edit predictions previews in buffer.
|
||||||
|
// This setting takes two possible values:
|
||||||
|
// 1. Display inline when there are no language server completions available.
|
||||||
|
// "inline_preview": "auto"
|
||||||
|
// 2. Display inline when holding modifier key (alt by default).
|
||||||
|
// "inline_preview": "when_holding_modifier"
|
||||||
|
"inline_preview": "auto"
|
||||||
},
|
},
|
||||||
// Settings specific to journaling
|
// Settings specific to journaling
|
||||||
"journal": {
|
"journal": {
|
||||||
|
|
|
@ -459,7 +459,7 @@ impl ContextEditor {
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
if self.editor.read(cx).has_active_completions_menu() {
|
if self.editor.read(cx).has_visible_completions_menu() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -169,7 +169,6 @@ pub struct CompletionsMenu {
|
||||||
resolve_completions: bool,
|
resolve_completions: bool,
|
||||||
show_completion_documentation: bool,
|
show_completion_documentation: bool,
|
||||||
last_rendered_range: Rc<RefCell<Option<Range<usize>>>>,
|
last_rendered_range: Rc<RefCell<Option<Range<usize>>>>,
|
||||||
pub previewing_inline_completion: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CompletionsMenu {
|
impl CompletionsMenu {
|
||||||
|
@ -200,7 +199,6 @@ impl CompletionsMenu {
|
||||||
scroll_handle: UniformListScrollHandle::new(),
|
scroll_handle: UniformListScrollHandle::new(),
|
||||||
resolve_completions: true,
|
resolve_completions: true,
|
||||||
last_rendered_range: RefCell::new(None).into(),
|
last_rendered_range: RefCell::new(None).into(),
|
||||||
previewing_inline_completion: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -257,7 +255,6 @@ impl CompletionsMenu {
|
||||||
resolve_completions: false,
|
resolve_completions: false,
|
||||||
show_completion_documentation: false,
|
show_completion_documentation: false,
|
||||||
last_rendered_range: RefCell::new(None).into(),
|
last_rendered_range: RefCell::new(None).into(),
|
||||||
previewing_inline_completion: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -410,12 +407,8 @@ impl CompletionsMenu {
|
||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.entries.borrow().is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn visible(&self) -> bool {
|
pub fn visible(&self) -> bool {
|
||||||
!self.is_empty() && !self.previewing_inline_completion
|
!self.entries.borrow().is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn origin(&self) -> ContextMenuOrigin {
|
fn origin(&self) -> ContextMenuOrigin {
|
||||||
|
@ -709,10 +702,6 @@ impl CompletionsMenu {
|
||||||
// This keeps the display consistent when y_flipped.
|
// This keeps the display consistent when y_flipped.
|
||||||
self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
|
self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_previewing_inline_completion(&mut self, value: bool) {
|
|
||||||
self.previewing_inline_completion = value;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
|
@ -97,8 +97,8 @@ use language::{
|
||||||
language_settings::{self, all_language_settings, language_settings, InlayHintSettings},
|
language_settings::{self, all_language_settings, language_settings, InlayHintSettings},
|
||||||
markdown, point_from_lsp, AutoindentMode, BracketPair, Buffer, Capability, CharKind, CodeLabel,
|
markdown, point_from_lsp, AutoindentMode, BracketPair, Buffer, Capability, CharKind, CodeLabel,
|
||||||
CompletionDocumentation, CursorShape, Diagnostic, EditPreview, HighlightedText, IndentKind,
|
CompletionDocumentation, CursorShape, Diagnostic, EditPreview, HighlightedText, IndentKind,
|
||||||
IndentSize, Language, OffsetRangeExt, Point, Selection, SelectionGoal, TextObject,
|
IndentSize, InlineCompletionPreviewMode, Language, OffsetRangeExt, Point, Selection,
|
||||||
TransactionId, TreeSitterOptions,
|
SelectionGoal, TextObject, TransactionId, TreeSitterOptions,
|
||||||
};
|
};
|
||||||
use language::{point_to_lsp, BufferRow, CharClassifier, Runnable, RunnableRange};
|
use language::{point_to_lsp, BufferRow, CharClassifier, Runnable, RunnableRange};
|
||||||
use linked_editing_ranges::refresh_linked_ranges;
|
use linked_editing_ranges::refresh_linked_ranges;
|
||||||
|
@ -693,6 +693,7 @@ pub struct Editor {
|
||||||
show_inline_completions: bool,
|
show_inline_completions: bool,
|
||||||
show_inline_completions_override: Option<bool>,
|
show_inline_completions_override: Option<bool>,
|
||||||
menu_inline_completions_policy: MenuInlineCompletionsPolicy,
|
menu_inline_completions_policy: MenuInlineCompletionsPolicy,
|
||||||
|
previewing_inline_completion: bool,
|
||||||
inlay_hint_cache: InlayHintCache,
|
inlay_hint_cache: InlayHintCache,
|
||||||
next_inlay_id: usize,
|
next_inlay_id: usize,
|
||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
|
@ -1384,6 +1385,7 @@ impl Editor {
|
||||||
inline_completion_provider: None,
|
inline_completion_provider: None,
|
||||||
active_inline_completion: None,
|
active_inline_completion: None,
|
||||||
stale_inline_completion_in_menu: None,
|
stale_inline_completion_in_menu: None,
|
||||||
|
previewing_inline_completion: false,
|
||||||
inlay_hint_cache: InlayHintCache::new(inlay_hint_settings),
|
inlay_hint_cache: InlayHintCache::new(inlay_hint_settings),
|
||||||
|
|
||||||
gutter_hovered: false,
|
gutter_hovered: false,
|
||||||
|
@ -4662,6 +4664,18 @@ impl Editor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn inline_completion_preview_mode(&self, cx: &App) -> language::InlineCompletionPreviewMode {
|
||||||
|
let cursor = self.selections.newest_anchor().head();
|
||||||
|
|
||||||
|
self.buffer
|
||||||
|
.read(cx)
|
||||||
|
.text_anchor_for_position(cursor, cx)
|
||||||
|
.map(|(buffer, _)| {
|
||||||
|
all_language_settings(buffer.read(cx).file(), cx).inline_completions_preview_mode()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
fn should_show_inline_completions_in_buffer(
|
fn should_show_inline_completions_in_buffer(
|
||||||
&self,
|
&self,
|
||||||
buffer: &Entity<Buffer>,
|
buffer: &Entity<Buffer>,
|
||||||
|
@ -5009,11 +5023,28 @@ impl Editor {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_previewing_inline_completion(&self) -> bool {
|
/// Returns true when we're displaying the inline completion popover below the cursor
|
||||||
matches!(
|
/// like we are not previewing and the LSP autocomplete menu is visible
|
||||||
self.context_menu.borrow().as_ref(),
|
/// or we are in `when_holding_modifier` mode.
|
||||||
Some(CodeContextMenu::Completions(menu)) if !menu.is_empty() && menu.previewing_inline_completion
|
pub fn inline_completion_visible_in_cursor_popover(
|
||||||
)
|
&self,
|
||||||
|
has_completion: bool,
|
||||||
|
cx: &App,
|
||||||
|
) -> bool {
|
||||||
|
if self.previewing_inline_completion
|
||||||
|
|| !self.show_inline_completions_in_menu(cx)
|
||||||
|
|| !self.should_show_inline_completions(cx)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.has_visible_completions_menu() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
has_completion
|
||||||
|
&& self.inline_completion_preview_mode(cx)
|
||||||
|
== InlineCompletionPreviewMode::WhenHoldingModifier
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_inline_completion_preview(
|
fn update_inline_completion_preview(
|
||||||
|
@ -5022,13 +5053,13 @@ impl Editor {
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
// Moves jump directly with a preview step
|
// Moves jump directly without a preview step
|
||||||
|
|
||||||
if self
|
if self
|
||||||
.active_inline_completion
|
.active_inline_completion
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or(true, |c| c.is_move())
|
.map_or(true, |c| c.is_move())
|
||||||
{
|
{
|
||||||
|
self.previewing_inline_completion = false;
|
||||||
cx.notify();
|
cx.notify();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -5037,20 +5068,7 @@ impl Editor {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut menu_borrow = self.context_menu.borrow_mut();
|
self.previewing_inline_completion = modifiers.alt;
|
||||||
|
|
||||||
let Some(CodeContextMenu::Completions(completions_menu)) = menu_borrow.as_mut() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
if completions_menu.is_empty()
|
|
||||||
|| completions_menu.previewing_inline_completion == modifiers.alt
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
completions_menu.set_previewing_inline_completion(modifiers.alt);
|
|
||||||
drop(menu_borrow);
|
|
||||||
self.update_visible_inline_completion(window, cx);
|
self.update_visible_inline_completion(window, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5146,7 +5164,7 @@ impl Editor {
|
||||||
snapshot,
|
snapshot,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if !show_in_menu || !self.has_active_completions_menu() {
|
if !self.inline_completion_visible_in_cursor_popover(true, cx) {
|
||||||
if edits
|
if edits
|
||||||
.iter()
|
.iter()
|
||||||
.all(|(range, _)| range.to_offset(&multibuffer).is_empty())
|
.all(|(range, _)| range.to_offset(&multibuffer).is_empty())
|
||||||
|
@ -5180,7 +5198,7 @@ impl Editor {
|
||||||
|
|
||||||
let display_mode = if all_edits_insertions_or_deletions(&edits, &multibuffer) {
|
let display_mode = if all_edits_insertions_or_deletions(&edits, &multibuffer) {
|
||||||
if provider.show_tab_accept_marker() {
|
if provider.show_tab_accept_marker() {
|
||||||
EditDisplayMode::TabAccept(self.is_previewing_inline_completion())
|
EditDisplayMode::TabAccept(self.previewing_inline_completion)
|
||||||
} else {
|
} else {
|
||||||
EditDisplayMode::Inline
|
EditDisplayMode::Inline
|
||||||
}
|
}
|
||||||
|
@ -5443,10 +5461,12 @@ impl Editor {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn context_menu_visible(&self) -> bool {
|
pub fn context_menu_visible(&self) -> bool {
|
||||||
self.context_menu
|
!self.previewing_inline_completion
|
||||||
.borrow()
|
&& self
|
||||||
.as_ref()
|
.context_menu
|
||||||
.map_or(false, |menu| menu.visible())
|
.borrow()
|
||||||
|
.as_ref()
|
||||||
|
.map_or(false, |menu| menu.visible())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn context_menu_origin(&self) -> Option<ContextMenuOrigin> {
|
fn context_menu_origin(&self) -> Option<ContextMenuOrigin> {
|
||||||
|
@ -5848,9 +5868,7 @@ impl Editor {
|
||||||
self.completion_tasks.clear();
|
self.completion_tasks.clear();
|
||||||
let context_menu = self.context_menu.borrow_mut().take();
|
let context_menu = self.context_menu.borrow_mut().take();
|
||||||
self.stale_inline_completion_in_menu.take();
|
self.stale_inline_completion_in_menu.take();
|
||||||
if context_menu.is_some() {
|
self.update_visible_inline_completion(window, cx);
|
||||||
self.update_visible_inline_completion(window, cx);
|
|
||||||
}
|
|
||||||
context_menu
|
context_menu
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -14438,10 +14456,11 @@ impl Editor {
|
||||||
Some(gpui::Point::new(source_x, source_y))
|
Some(gpui::Point::new(source_x, source_y))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn has_active_completions_menu(&self) -> bool {
|
pub fn has_visible_completions_menu(&self) -> bool {
|
||||||
self.context_menu.borrow().as_ref().map_or(false, |menu| {
|
!self.previewing_inline_completion
|
||||||
menu.visible() && matches!(menu, CodeContextMenu::Completions(_))
|
&& self.context_menu.borrow().as_ref().map_or(false, |menu| {
|
||||||
})
|
menu.visible() && matches!(menu, CodeContextMenu::Completions(_))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn register_addon<T: Addon>(&mut self, instance: T) {
|
pub fn register_addon<T: Addon>(&mut self, instance: T) {
|
||||||
|
|
|
@ -3109,7 +3109,10 @@ impl EditorElement {
|
||||||
|
|
||||||
{
|
{
|
||||||
let editor = self.editor.read(cx);
|
let editor = self.editor.read(cx);
|
||||||
if editor.has_active_completions_menu() && editor.show_inline_completions_in_menu(cx) {
|
if editor.inline_completion_visible_in_cursor_popover(
|
||||||
|
editor.has_active_inline_completion(),
|
||||||
|
cx,
|
||||||
|
) {
|
||||||
height_above_menu +=
|
height_above_menu +=
|
||||||
editor.edit_prediction_cursor_popover_height() + POPOVER_Y_PADDING;
|
editor.edit_prediction_cursor_popover_height() + POPOVER_Y_PADDING;
|
||||||
edit_prediction_popover_visible = true;
|
edit_prediction_popover_visible = true;
|
||||||
|
@ -3615,7 +3618,12 @@ impl EditorElement {
|
||||||
const PADDING_X: Pixels = Pixels(24.);
|
const PADDING_X: Pixels = Pixels(24.);
|
||||||
const PADDING_Y: Pixels = Pixels(2.);
|
const PADDING_Y: Pixels = Pixels(2.);
|
||||||
|
|
||||||
let active_inline_completion = self.editor.read(cx).active_inline_completion.as_ref()?;
|
let editor = self.editor.read(cx);
|
||||||
|
let active_inline_completion = editor.active_inline_completion.as_ref()?;
|
||||||
|
|
||||||
|
if editor.inline_completion_visible_in_cursor_popover(true, cx) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
match &active_inline_completion.completion {
|
match &active_inline_completion.completion {
|
||||||
InlineCompletion::Move { target, .. } => {
|
InlineCompletion::Move { target, .. } => {
|
||||||
|
@ -3682,7 +3690,7 @@ impl EditorElement {
|
||||||
display_mode,
|
display_mode,
|
||||||
snapshot,
|
snapshot,
|
||||||
} => {
|
} => {
|
||||||
if self.editor.read(cx).has_active_completions_menu() {
|
if self.editor.read(cx).has_visible_completions_menu() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -158,7 +158,7 @@ impl Editor {
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
if self.pending_rename.is_some() || self.has_active_completions_menu() {
|
if self.pending_rename.is_some() || self.has_visible_completions_menu() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -21,6 +21,7 @@ mod toolchain;
|
||||||
pub mod buffer_tests;
|
pub mod buffer_tests;
|
||||||
pub mod markdown;
|
pub mod markdown;
|
||||||
|
|
||||||
|
pub use crate::language_settings::InlineCompletionPreviewMode;
|
||||||
use crate::language_settings::SoftWrap;
|
use crate::language_settings::SoftWrap;
|
||||||
use anyhow::{anyhow, Context as _, Result};
|
use anyhow::{anyhow, Context as _, Result};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
|
@ -214,6 +214,19 @@ pub struct InlineCompletionSettings {
|
||||||
pub provider: InlineCompletionProvider,
|
pub provider: InlineCompletionProvider,
|
||||||
/// A list of globs representing files that edit predictions should be disabled for.
|
/// A list of globs representing files that edit predictions should be disabled for.
|
||||||
pub disabled_globs: Vec<GlobMatcher>,
|
pub disabled_globs: Vec<GlobMatcher>,
|
||||||
|
/// When to show edit predictions previews in buffer.
|
||||||
|
pub inline_preview: InlineCompletionPreviewMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mode in which edit predictions should be displayed.
|
||||||
|
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum InlineCompletionPreviewMode {
|
||||||
|
/// Display inline when there are no language server completions available.
|
||||||
|
#[default]
|
||||||
|
Auto,
|
||||||
|
/// Display inline when holding modifier key (alt by default).
|
||||||
|
WhenHoldingModifier,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The settings for all languages.
|
/// The settings for all languages.
|
||||||
|
@ -406,6 +419,9 @@ pub struct InlineCompletionSettingsContent {
|
||||||
/// A list of globs representing files that edit predictions should be disabled for.
|
/// A list of globs representing files that edit predictions should be disabled for.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub disabled_globs: Option<Vec<String>>,
|
pub disabled_globs: Option<Vec<String>>,
|
||||||
|
/// When to show edit predictions previews in buffer.
|
||||||
|
#[serde(default)]
|
||||||
|
pub inline_preview: InlineCompletionPreviewMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The settings for enabling/disabling features.
|
/// The settings for enabling/disabling features.
|
||||||
|
@ -890,6 +906,11 @@ impl AllLanguageSettings {
|
||||||
self.language(None, language.map(|l| l.name()).as_ref(), cx)
|
self.language(None, language.map(|l| l.name()).as_ref(), cx)
|
||||||
.show_inline_completions
|
.show_inline_completions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the edit predictions preview mode for the given language and path.
|
||||||
|
pub fn inline_completions_preview_mode(&self) -> InlineCompletionPreviewMode {
|
||||||
|
self.inline_completions.inline_preview
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn merge_with_editorconfig(settings: &mut LanguageSettings, cfg: &EditorconfigProperties) {
|
fn merge_with_editorconfig(settings: &mut LanguageSettings, cfg: &EditorconfigProperties) {
|
||||||
|
@ -987,6 +1008,12 @@ impl settings::Settings for AllLanguageSettings {
|
||||||
.features
|
.features
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|f| f.inline_completion_provider);
|
.and_then(|f| f.inline_completion_provider);
|
||||||
|
let mut inline_completions_preview = default_value
|
||||||
|
.inline_completions
|
||||||
|
.as_ref()
|
||||||
|
.map(|inline_completions| inline_completions.inline_preview)
|
||||||
|
.ok_or_else(Self::missing_default)?;
|
||||||
|
|
||||||
let mut completion_globs: HashSet<&String> = default_value
|
let mut completion_globs: HashSet<&String> = default_value
|
||||||
.inline_completions
|
.inline_completions
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
@ -1017,12 +1044,13 @@ impl settings::Settings for AllLanguageSettings {
|
||||||
{
|
{
|
||||||
inline_completion_provider = Some(provider);
|
inline_completion_provider = Some(provider);
|
||||||
}
|
}
|
||||||
if let Some(globs) = user_settings
|
|
||||||
.inline_completions
|
if let Some(inline_completions) = user_settings.inline_completions.as_ref() {
|
||||||
.as_ref()
|
inline_completions_preview = inline_completions.inline_preview;
|
||||||
.and_then(|f| f.disabled_globs.as_ref())
|
|
||||||
{
|
if let Some(disabled_globs) = inline_completions.disabled_globs.as_ref() {
|
||||||
completion_globs.extend(globs.iter());
|
completion_globs.extend(disabled_globs.iter());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A user's global settings override the default global settings and
|
// A user's global settings override the default global settings and
|
||||||
|
@ -1075,6 +1103,7 @@ impl settings::Settings for AllLanguageSettings {
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|g| Some(globset::Glob::new(g).ok()?.compile_matcher()))
|
.filter_map(|g| Some(globset::Glob::new(g).ok()?.compile_matcher()))
|
||||||
.collect(),
|
.collect(),
|
||||||
|
inline_preview: inline_completions_preview,
|
||||||
},
|
},
|
||||||
defaults,
|
defaults,
|
||||||
languages,
|
languages,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue