git: Add ability to clone remote repositories from Zed (#35606)
This PR adds preliminary git clone support through using the new `GitClone` action. This works with SSH connections too. - [x] Get backend working - [x] Add a UI to interact with this Future follow-ups: - Polish the UI - Have the path select prompt say "Select Repository clone target" instead of “Open” - Use Zed path prompt if the user has that as a setting - Add support for cloning from a user's GitHub repositories directly Release Notes: - Add the ability to clone remote git repositories through the `git: Clone` action --------- Co-authored-by: hpmcdona <hayden_mcdonald@brown.edu>
This commit is contained in:
parent
12084b6677
commit
62270b33c2
9 changed files with 310 additions and 8 deletions
|
@ -12,7 +12,7 @@ use gpui::BackgroundExecutor;
|
||||||
use gpui::Global;
|
use gpui::Global;
|
||||||
use gpui::ReadGlobal as _;
|
use gpui::ReadGlobal as _;
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
use util::command::new_std_command;
|
use util::command::{new_smol_command, new_std_command};
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use std::os::fd::{AsFd, AsRawFd};
|
use std::os::fd::{AsFd, AsRawFd};
|
||||||
|
@ -134,6 +134,7 @@ pub trait Fs: Send + Sync {
|
||||||
fn home_dir(&self) -> Option<PathBuf>;
|
fn home_dir(&self) -> Option<PathBuf>;
|
||||||
fn open_repo(&self, abs_dot_git: &Path) -> Option<Arc<dyn GitRepository>>;
|
fn open_repo(&self, abs_dot_git: &Path) -> Option<Arc<dyn GitRepository>>;
|
||||||
fn git_init(&self, abs_work_directory: &Path, fallback_branch_name: String) -> Result<()>;
|
fn git_init(&self, abs_work_directory: &Path, fallback_branch_name: String) -> Result<()>;
|
||||||
|
async fn git_clone(&self, repo_url: &str, abs_work_directory: &Path) -> Result<()>;
|
||||||
fn is_fake(&self) -> bool;
|
fn is_fake(&self) -> bool;
|
||||||
async fn is_case_sensitive(&self) -> Result<bool>;
|
async fn is_case_sensitive(&self) -> Result<bool>;
|
||||||
|
|
||||||
|
@ -839,6 +840,23 @@ impl Fs for RealFs {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn git_clone(&self, repo_url: &str, abs_work_directory: &Path) -> Result<()> {
|
||||||
|
let output = new_smol_command("git")
|
||||||
|
.current_dir(abs_work_directory)
|
||||||
|
.args(&["clone", repo_url])
|
||||||
|
.output()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"git clone failed: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn is_fake(&self) -> bool {
|
fn is_fake(&self) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
@ -2352,6 +2370,10 @@ impl Fs for FakeFs {
|
||||||
smol::block_on(self.create_dir(&abs_work_directory_path.join(".git")))
|
smol::block_on(self.create_dir(&abs_work_directory_path.join(".git")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn git_clone(&self, _repo_url: &str, _abs_work_directory: &Path) -> Result<()> {
|
||||||
|
anyhow::bail!("Git clone is not supported in fake Fs")
|
||||||
|
}
|
||||||
|
|
||||||
fn is_fake(&self) -> bool {
|
fn is_fake(&self) -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
|
@ -93,6 +93,8 @@ actions!(
|
||||||
Init,
|
Init,
|
||||||
/// Opens all modified files in the editor.
|
/// Opens all modified files in the editor.
|
||||||
OpenModifiedFiles,
|
OpenModifiedFiles,
|
||||||
|
/// Clones a repository.
|
||||||
|
Clone,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -2081,6 +2081,99 @@ impl GitPanel {
|
||||||
.detach_and_log_err(cx);
|
.detach_and_log_err(cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn git_clone(&mut self, repo: String, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let path = cx.prompt_for_paths(gpui::PathPromptOptions {
|
||||||
|
files: false,
|
||||||
|
directories: true,
|
||||||
|
multiple: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let workspace = self.workspace.clone();
|
||||||
|
|
||||||
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
|
let mut paths = path.await.ok()?.ok()??;
|
||||||
|
let mut path = paths.pop()?;
|
||||||
|
let repo_name = repo
|
||||||
|
.split(std::path::MAIN_SEPARATOR_STR)
|
||||||
|
.last()?
|
||||||
|
.strip_suffix(".git")?
|
||||||
|
.to_owned();
|
||||||
|
|
||||||
|
let fs = this.read_with(cx, |this, _| this.fs.clone()).ok()?;
|
||||||
|
|
||||||
|
let prompt_answer = match fs.git_clone(&repo, path.as_path()).await {
|
||||||
|
Ok(_) => cx.update(|window, cx| {
|
||||||
|
window.prompt(
|
||||||
|
PromptLevel::Info,
|
||||||
|
"Git Clone",
|
||||||
|
None,
|
||||||
|
&["Add repo to project", "Open repo in new project"],
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
Err(e) => {
|
||||||
|
this.update(cx, |this: &mut GitPanel, cx| {
|
||||||
|
let toast = StatusToast::new(e.to_string(), cx, |this, _| {
|
||||||
|
this.icon(ToastIcon::new(IconName::XCircle).color(Color::Error))
|
||||||
|
.dismiss_button(true)
|
||||||
|
});
|
||||||
|
|
||||||
|
this.workspace
|
||||||
|
.update(cx, |workspace, cx| {
|
||||||
|
workspace.toggle_status_toast(toast, cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
})
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
path.push(repo_name);
|
||||||
|
match prompt_answer.await.ok()? {
|
||||||
|
0 => {
|
||||||
|
workspace
|
||||||
|
.update(cx, |workspace, cx| {
|
||||||
|
workspace
|
||||||
|
.project()
|
||||||
|
.update(cx, |project, cx| {
|
||||||
|
project.create_worktree(path.as_path(), true, cx)
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
workspace
|
||||||
|
.update(cx, move |workspace, cx| {
|
||||||
|
workspace::open_new(
|
||||||
|
Default::default(),
|
||||||
|
workspace.app_state().clone(),
|
||||||
|
cx,
|
||||||
|
move |workspace, _, cx| {
|
||||||
|
cx.activate(true);
|
||||||
|
workspace
|
||||||
|
.project()
|
||||||
|
.update(cx, |project, cx| {
|
||||||
|
project.create_worktree(&path, true, cx)
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.detach();
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(())
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn git_init(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
pub(crate) fn git_init(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let worktrees = self
|
let worktrees = self
|
||||||
.project
|
.project
|
||||||
|
|
|
@ -3,21 +3,25 @@ use std::any::Any;
|
||||||
use ::settings::Settings;
|
use ::settings::Settings;
|
||||||
use command_palette_hooks::CommandPaletteFilter;
|
use command_palette_hooks::CommandPaletteFilter;
|
||||||
use commit_modal::CommitModal;
|
use commit_modal::CommitModal;
|
||||||
use editor::{Editor, actions::DiffClipboardWithSelectionData};
|
use editor::{Editor, EditorElement, EditorStyle, actions::DiffClipboardWithSelectionData};
|
||||||
mod blame_ui;
|
mod blame_ui;
|
||||||
use git::{
|
use git::{
|
||||||
repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus},
|
repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus},
|
||||||
status::{FileStatus, StatusCode, UnmergedStatus, UnmergedStatusCode},
|
status::{FileStatus, StatusCode, UnmergedStatus, UnmergedStatusCode},
|
||||||
};
|
};
|
||||||
use git_panel_settings::GitPanelSettings;
|
use git_panel_settings::GitPanelSettings;
|
||||||
use gpui::{Action, App, Context, FocusHandle, Window, actions};
|
use gpui::{
|
||||||
|
Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, TextStyle,
|
||||||
|
Window, actions,
|
||||||
|
};
|
||||||
use onboarding::GitOnboardingModal;
|
use onboarding::GitOnboardingModal;
|
||||||
use project_diff::ProjectDiff;
|
use project_diff::ProjectDiff;
|
||||||
|
use theme::ThemeSettings;
|
||||||
use ui::prelude::*;
|
use ui::prelude::*;
|
||||||
use workspace::Workspace;
|
use workspace::{ModalView, Workspace};
|
||||||
use zed_actions;
|
use zed_actions;
|
||||||
|
|
||||||
use crate::text_diff_view::TextDiffView;
|
use crate::{git_panel::GitPanel, text_diff_view::TextDiffView};
|
||||||
|
|
||||||
mod askpass_modal;
|
mod askpass_modal;
|
||||||
pub mod branch_picker;
|
pub mod branch_picker;
|
||||||
|
@ -169,6 +173,19 @@ pub fn init(cx: &mut App) {
|
||||||
panel.git_init(window, cx);
|
panel.git_init(window, cx);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
workspace.register_action(|workspace, _action: &git::Clone, window, cx| {
|
||||||
|
let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
workspace.toggle_modal(window, cx, |window, cx| {
|
||||||
|
GitCloneModal::show(panel, window, cx)
|
||||||
|
});
|
||||||
|
|
||||||
|
// panel.update(cx, |panel, cx| {
|
||||||
|
// panel.git_clone(window, cx);
|
||||||
|
// });
|
||||||
|
});
|
||||||
workspace.register_action(|workspace, _: &git::OpenModifiedFiles, window, cx| {
|
workspace.register_action(|workspace, _: &git::OpenModifiedFiles, window, cx| {
|
||||||
open_modified_files(workspace, window, cx);
|
open_modified_files(workspace, window, cx);
|
||||||
});
|
});
|
||||||
|
@ -613,3 +630,98 @@ impl Component for GitStatusIcon {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct GitCloneModal {
|
||||||
|
panel: Entity<GitPanel>,
|
||||||
|
repo_input: Entity<Editor>,
|
||||||
|
focus_handle: FocusHandle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GitCloneModal {
|
||||||
|
pub fn show(panel: Entity<GitPanel>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let repo_input = cx.new(|cx| {
|
||||||
|
let mut editor = Editor::single_line(window, cx);
|
||||||
|
editor.set_placeholder_text("Enter repository", cx);
|
||||||
|
editor
|
||||||
|
});
|
||||||
|
let focus_handle = repo_input.focus_handle(cx);
|
||||||
|
|
||||||
|
window.focus(&focus_handle);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
panel,
|
||||||
|
repo_input,
|
||||||
|
focus_handle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_editor(&self, window: &Window, cx: &App) -> impl IntoElement {
|
||||||
|
let settings = ThemeSettings::get_global(cx);
|
||||||
|
let theme = cx.theme();
|
||||||
|
|
||||||
|
let text_style = TextStyle {
|
||||||
|
color: cx.theme().colors().text,
|
||||||
|
font_family: settings.buffer_font.family.clone(),
|
||||||
|
font_features: settings.buffer_font.features.clone(),
|
||||||
|
font_size: settings.buffer_font_size(cx).into(),
|
||||||
|
font_weight: settings.buffer_font.weight,
|
||||||
|
line_height: relative(settings.buffer_line_height.value()),
|
||||||
|
background_color: Some(theme.colors().editor_background),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let element = EditorElement::new(
|
||||||
|
&self.repo_input,
|
||||||
|
EditorStyle {
|
||||||
|
background: theme.colors().editor_background,
|
||||||
|
local_player: theme.players().local(),
|
||||||
|
text: text_style,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
div()
|
||||||
|
.rounded_md()
|
||||||
|
.p_1()
|
||||||
|
.border_1()
|
||||||
|
.border_color(theme.colors().border_variant)
|
||||||
|
.when(
|
||||||
|
self.repo_input
|
||||||
|
.focus_handle(cx)
|
||||||
|
.contains_focused(window, cx),
|
||||||
|
|this| this.border_color(theme.colors().border_focused),
|
||||||
|
)
|
||||||
|
.child(element)
|
||||||
|
.bg(theme.colors().editor_background)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Focusable for GitCloneModal {
|
||||||
|
fn focus_handle(&self, _: &App) -> FocusHandle {
|
||||||
|
self.focus_handle.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for GitCloneModal {
|
||||||
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
div()
|
||||||
|
.size_full()
|
||||||
|
.w(rems(34.))
|
||||||
|
.elevation_3(cx)
|
||||||
|
.child(self.render_editor(window, cx))
|
||||||
|
.on_action(cx.listener(|_, _: &menu::Cancel, _, cx| {
|
||||||
|
cx.emit(DismissEvent);
|
||||||
|
}))
|
||||||
|
.on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
|
||||||
|
let repo = this.repo_input.read(cx).text(cx);
|
||||||
|
this.panel.update(cx, |panel, cx| {
|
||||||
|
panel.git_clone(repo, window, cx);
|
||||||
|
});
|
||||||
|
cx.emit(DismissEvent);
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventEmitter<DismissEvent> for GitCloneModal {}
|
||||||
|
|
||||||
|
impl ModalView for GitCloneModal {}
|
||||||
|
|
|
@ -987,7 +987,7 @@ pub struct InlayHintSettings {
|
||||||
/// Default: false
|
/// Default: false
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
/// Global switch to toggle inline values on and off.
|
/// Global switch to toggle inline values on and off when debugging.
|
||||||
///
|
///
|
||||||
/// Default: true
|
/// Default: true
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
|
|
|
@ -441,6 +441,7 @@ impl GitStore {
|
||||||
client.add_entity_request_handler(Self::handle_blame_buffer);
|
client.add_entity_request_handler(Self::handle_blame_buffer);
|
||||||
client.add_entity_message_handler(Self::handle_update_repository);
|
client.add_entity_message_handler(Self::handle_update_repository);
|
||||||
client.add_entity_message_handler(Self::handle_remove_repository);
|
client.add_entity_message_handler(Self::handle_remove_repository);
|
||||||
|
client.add_entity_request_handler(Self::handle_git_clone);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_local(&self) -> bool {
|
pub fn is_local(&self) -> bool {
|
||||||
|
@ -1464,6 +1465,45 @@ impl GitStore {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn git_clone(
|
||||||
|
&self,
|
||||||
|
repo: String,
|
||||||
|
path: impl Into<Arc<std::path::Path>>,
|
||||||
|
cx: &App,
|
||||||
|
) -> Task<Result<()>> {
|
||||||
|
let path = path.into();
|
||||||
|
match &self.state {
|
||||||
|
GitStoreState::Local { fs, .. } => {
|
||||||
|
let fs = fs.clone();
|
||||||
|
cx.background_executor()
|
||||||
|
.spawn(async move { fs.git_clone(&repo, &path).await })
|
||||||
|
}
|
||||||
|
GitStoreState::Ssh {
|
||||||
|
upstream_client,
|
||||||
|
upstream_project_id,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
let request = upstream_client.request(proto::GitClone {
|
||||||
|
project_id: upstream_project_id.0,
|
||||||
|
abs_path: path.to_string_lossy().to_string(),
|
||||||
|
remote_repo: repo,
|
||||||
|
});
|
||||||
|
|
||||||
|
cx.background_spawn(async move {
|
||||||
|
let result = request.await?;
|
||||||
|
|
||||||
|
match result.success {
|
||||||
|
true => Ok(()),
|
||||||
|
false => Err(anyhow!("Git Clone failed")),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
GitStoreState::Remote { .. } => {
|
||||||
|
Task::ready(Err(anyhow!("Git Clone isn't supported for remote users")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_update_repository(
|
async fn handle_update_repository(
|
||||||
this: Entity<Self>,
|
this: Entity<Self>,
|
||||||
envelope: TypedEnvelope<proto::UpdateRepository>,
|
envelope: TypedEnvelope<proto::UpdateRepository>,
|
||||||
|
@ -1550,6 +1590,22 @@ impl GitStore {
|
||||||
Ok(proto::Ack {})
|
Ok(proto::Ack {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn handle_git_clone(
|
||||||
|
this: Entity<Self>,
|
||||||
|
envelope: TypedEnvelope<proto::GitClone>,
|
||||||
|
cx: AsyncApp,
|
||||||
|
) -> Result<proto::GitCloneResponse> {
|
||||||
|
let path: Arc<Path> = PathBuf::from(envelope.payload.abs_path).into();
|
||||||
|
let repo_name = envelope.payload.remote_repo;
|
||||||
|
let result = cx
|
||||||
|
.update(|cx| this.read(cx).git_clone(repo_name, path, cx))?
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(proto::GitCloneResponse {
|
||||||
|
success: result.is_ok(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_fetch(
|
async fn handle_fetch(
|
||||||
this: Entity<Self>,
|
this: Entity<Self>,
|
||||||
envelope: TypedEnvelope<proto::Fetch>,
|
envelope: TypedEnvelope<proto::Fetch>,
|
||||||
|
|
|
@ -202,6 +202,16 @@ message GitInit {
|
||||||
string fallback_branch_name = 3;
|
string fallback_branch_name = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message GitClone {
|
||||||
|
uint64 project_id = 1;
|
||||||
|
string abs_path = 2;
|
||||||
|
string remote_repo = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GitCloneResponse {
|
||||||
|
bool success = 1;
|
||||||
|
}
|
||||||
|
|
||||||
message CheckForPushedCommits {
|
message CheckForPushedCommits {
|
||||||
uint64 project_id = 1;
|
uint64 project_id = 1;
|
||||||
reserved 2;
|
reserved 2;
|
||||||
|
|
|
@ -399,7 +399,10 @@ message Envelope {
|
||||||
GetDefaultBranchResponse get_default_branch_response = 360;
|
GetDefaultBranchResponse get_default_branch_response = 360;
|
||||||
|
|
||||||
GetCrashFiles get_crash_files = 361;
|
GetCrashFiles get_crash_files = 361;
|
||||||
GetCrashFilesResponse get_crash_files_response = 362; // current max
|
GetCrashFilesResponse get_crash_files_response = 362;
|
||||||
|
|
||||||
|
GitClone git_clone = 363;
|
||||||
|
GitCloneResponse git_clone_response = 364; // current max
|
||||||
}
|
}
|
||||||
|
|
||||||
reserved 87 to 88;
|
reserved 87 to 88;
|
||||||
|
|
|
@ -316,6 +316,8 @@ messages!(
|
||||||
(PullWorkspaceDiagnostics, Background),
|
(PullWorkspaceDiagnostics, Background),
|
||||||
(GetDefaultBranch, Background),
|
(GetDefaultBranch, Background),
|
||||||
(GetDefaultBranchResponse, Background),
|
(GetDefaultBranchResponse, Background),
|
||||||
|
(GitClone, Background),
|
||||||
|
(GitCloneResponse, Background)
|
||||||
);
|
);
|
||||||
|
|
||||||
request_messages!(
|
request_messages!(
|
||||||
|
@ -484,6 +486,7 @@ request_messages!(
|
||||||
(GetDocumentDiagnostics, GetDocumentDiagnosticsResponse),
|
(GetDocumentDiagnostics, GetDocumentDiagnosticsResponse),
|
||||||
(PullWorkspaceDiagnostics, Ack),
|
(PullWorkspaceDiagnostics, Ack),
|
||||||
(GetDefaultBranch, GetDefaultBranchResponse),
|
(GetDefaultBranch, GetDefaultBranchResponse),
|
||||||
|
(GitClone, GitCloneResponse)
|
||||||
);
|
);
|
||||||
|
|
||||||
entity_messages!(
|
entity_messages!(
|
||||||
|
@ -615,7 +618,8 @@ entity_messages!(
|
||||||
LogToDebugConsole,
|
LogToDebugConsole,
|
||||||
GetDocumentDiagnostics,
|
GetDocumentDiagnostics,
|
||||||
PullWorkspaceDiagnostics,
|
PullWorkspaceDiagnostics,
|
||||||
GetDefaultBranch
|
GetDefaultBranch,
|
||||||
|
GitClone
|
||||||
);
|
);
|
||||||
|
|
||||||
entity_messages!(
|
entity_messages!(
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue