diff --git a/src/app_init.rs b/src/app_init.rs index 17e1096..797a1bc 100644 --- a/src/app_init.rs +++ b/src/app_init.rs @@ -54,7 +54,7 @@ pub async fn initialize_app( &config, ); app.dispatch(GlimEvent::ProjectsFetch); - if config == GlimConfig::default() { + if config.gitlab_url.is_empty() || config.gitlab_token.is_empty() { app.dispatch(GlimEvent::ConfigOpen); } diff --git a/src/client/api.rs b/src/client/api.rs index 9990e17..c82a1da 100644 --- a/src/client/api.rs +++ b/src/client/api.rs @@ -13,8 +13,8 @@ use super::{ error::{ClientError, Result}, }; use crate::{ - domain::{JobDto, PipelineDto, ProjectDto}, - id::{JobId, PipelineId, ProjectId}, + domain::{JobDto, MrDto, NoteDto, PipelineDto, ProjectDto}, + id::{JobId, MrIid, PipelineId, ProjectId}, }; /// Pure HTTP client for GitLab API @@ -124,6 +124,78 @@ impl GitlabApi { Ok(body.into()) } + /// Get merge request associated with a commit SHA + pub async fn get_mr_for_commit( + &self, + project_id: ProjectId, + sha: &str, + ) -> Result> { + let url = { + let config = self.config.read().unwrap(); + format_compact!( + "{}/projects/{}/repository/commits/{}/merge_requests", + config.base_url, + project_id, + sha + ) + }; + let mrs: Vec = self.get_json(&url).await?; + Ok(mrs.into_iter().find(|mr| mr.state == "opened")) + } + + /// Get notes (comments) for a merge request + pub async fn get_mr_notes( + &self, + project_id: ProjectId, + mr_iid: MrIid, + ) -> Result> { + let url = { + let config = self.config.read().unwrap(); + format_compact!( + "{}/projects/{}/merge_requests/{}/notes?per_page=100&sort=asc", + config.base_url, + project_id, + mr_iid + ) + }; + self.get_json(&url).await + } + + /// Post a note (comment) on a merge request + pub async fn post_mr_note( + &self, + project_id: ProjectId, + mr_iid: MrIid, + body: &str, + ) -> Result { + let url = { + let config = self.config.read().unwrap(); + format_compact!( + "{}/projects/{}/merge_requests/{}/notes", + config.base_url, + project_id, + mr_iid + ) + }; + let payload = serde_json::json!({ "body": body }); + let payload_str = serde_json::to_string(&payload) + .map_err(|e| ClientError::json_parse(url.as_str(), "Failed to serialize payload", e))?; + let (client_clone, private_token) = { + let client = self.client.read().unwrap(); + let private_token = self.config.read().unwrap().private_token.clone(); + (client.clone(), private_token) + }; + let response = client_clone + .post(url.as_str()) + .header("PRIVATE-TOKEN", private_token.as_str()) + .header("Content-Type", "application/json") + .body(payload_str) + .send() + .await + .map_err(ClientError::Http)?; + self.handle_response(response).await + } + /// Update configuration pub fn update_config(&self, config: ClientConfig) -> Result<()> { config.validate()?; @@ -285,6 +357,10 @@ impl GitlabApi { url.push_str("&membership=true"); } + if let Some(level) = query.min_access_level { + url.push_str(&format_compact!("&min_access_level={}", level)); + } + url.push_str(&format_compact!("&per_page={}", query.per_page)); url diff --git a/src/client/config.rs b/src/client/config.rs index 213f5b5..19879c7 100644 --- a/src/client/config.rs +++ b/src/client/config.rs @@ -3,7 +3,7 @@ use std::{path::PathBuf, time::Duration}; use chrono::{DateTime, Utc}; -use compact_str::CompactString; +use compact_str::{format_compact, CompactString}; use super::error::{ClientError, Result}; use crate::glim_app::GlimConfig; @@ -72,6 +72,8 @@ pub struct ProjectQuery { pub archived: bool, /// Only include projects where user is a member pub membership: bool, + /// Minimum access level for the authenticated user (10=Guest, 20=Reporter, 30=Developer, 40=Maintainer, 50=Owner) + pub min_access_level: Option, /// Search in namespaces pub search_namespaces: bool, } @@ -225,7 +227,8 @@ impl ClientConfig { per_page: self.request.per_page, include_statistics: true, archived: false, - membership: true, + membership: false, + min_access_level: Some(10), search_namespaces: true, ..Default::default() } @@ -242,7 +245,13 @@ impl ClientConfig { impl From for ClientConfig { fn from(config: GlimConfig) -> Self { - Self::new(config.gitlab_url, config.gitlab_token).with_search_filter(config.search_filter) + let url = config.gitlab_url.trim_end_matches('/'); + let base_url: CompactString = if url.ends_with("/api/v4") { + url.into() + } else { + format_compact!("{}/api/v4", url) + }; + Self::new(base_url, config.gitlab_token).with_search_filter(config.search_filter) } } @@ -360,11 +369,53 @@ mod tests { }; let client_config = ClientConfig::from(glim_config); - assert_eq!(client_config.base_url, "https://gitlab.example.com"); + assert_eq!(client_config.base_url, "https://gitlab.example.com/api/v4"); assert_eq!(client_config.private_token, "test-token"); assert_eq!(client_config.search_filter, Some("test".into())); } + #[test] + fn test_from_glim_config_url_normalization() { + // Bare URL without /api/v4 should be normalized + let config = GlimConfig { + gitlab_url: "https://gitlab.example.com".into(), + gitlab_token: "test-token".into(), + search_filter: None, + log_level: None, + animations: false, + }; + assert_eq!( + ClientConfig::from(config).base_url, + "https://gitlab.example.com/api/v4" + ); + + // URL already ending with /api/v4 should not be doubled + let config = GlimConfig { + gitlab_url: "https://gitlab.example.com/api/v4".into(), + gitlab_token: "test-token".into(), + search_filter: None, + log_level: None, + animations: false, + }; + assert_eq!( + ClientConfig::from(config).base_url, + "https://gitlab.example.com/api/v4" + ); + + // Trailing slash should be stripped before normalization + let config = GlimConfig { + gitlab_url: "https://gitlab.example.com/".into(), + gitlab_token: "test-token".into(), + search_filter: None, + log_level: None, + animations: false, + }; + assert_eq!( + ClientConfig::from(config).base_url, + "https://gitlab.example.com/api/v4" + ); + } + #[test] fn test_default_queries() { let config = ClientConfig::new("https://gitlab.com", "token") diff --git a/src/client/service.rs b/src/client/service.rs index fb31787..1853e83 100644 --- a/src/client/service.rs +++ b/src/client/service.rs @@ -11,10 +11,13 @@ use super::{ config::ClientConfig, error::{ClientError, Result}, }; +use compact_str::CompactString; + use crate::{ dispatcher::Dispatcher, + domain::MrView, event::{GlimEvent, IntoGlimEvent}, - id::{JobId, PipelineId, ProjectId}, + id::{JobId, MrIid, PipelineId, ProjectId}, result::GlimError::{self, GeneralError}, }; @@ -264,6 +267,64 @@ impl GitlabService { } }); } + + /// Spawn an async task to fetch the MR associated with a commit SHA + pub fn spawn_fetch_mr(&self, project_id: ProjectId, sha: CompactString) { + let api = self.api.clone(); + let sender = self.sender.clone(); + self.handle.spawn(async move { + match api.get_mr_for_commit(project_id, &sha).await { + Ok(Some(mr)) => { + let mr_view = MrView::from_dto(mr, project_id); + sender.dispatch(GlimEvent::MrLoaded(project_id, Box::new(mr_view))); + }, + Ok(None) => { + // No open MR found - silently ignore + }, + Err(e) => { + error!(error = %e, "Failed to fetch MR for commit"); + let glim_error = crate::result::GlimError::from(&e); + sender.dispatch(GlimEvent::AppError(glim_error)); + }, + } + }); + } + + /// Spawn an async task to fetch notes for a merge request + pub fn spawn_fetch_mr_notes(&self, project_id: ProjectId, mr_iid: MrIid) { + let api = self.api.clone(); + let sender = self.sender.clone(); + self.handle.spawn(async move { + match api.get_mr_notes(project_id, mr_iid).await { + Ok(notes) => { + sender.dispatch(GlimEvent::MrNotesLoaded(project_id, mr_iid, notes)); + }, + Err(e) => { + error!(error = %e, "Failed to fetch MR notes"); + let glim_error = crate::result::GlimError::from(&e); + sender.dispatch(GlimEvent::AppError(glim_error)); + }, + } + }); + } + + /// Spawn an async task to post a note on a merge request + pub fn spawn_post_mr_note(&self, project_id: ProjectId, mr_iid: MrIid, body: CompactString) { + let api = self.api.clone(); + let sender = self.sender.clone(); + self.handle.spawn(async move { + match api.post_mr_note(project_id, mr_iid, &body).await { + Ok(_) => { + sender.dispatch(GlimEvent::MrNotePosted(project_id, mr_iid)); + }, + Err(e) => { + error!(error = %e, "Failed to post MR note"); + let glim_error = crate::result::GlimError::from(&e); + sender.dispatch(GlimEvent::AppError(glim_error)); + }, + } + }); + } } // Convert ClientError to the application's GlimError type diff --git a/src/client/tests/mod.rs b/src/client/tests/mod.rs index 3eb6efd..082d138 100644 --- a/src/client/tests/mod.rs +++ b/src/client/tests/mod.rs @@ -43,7 +43,7 @@ pub fn sample_project_dto() -> ProjectDto { id: ProjectId::new(123), path_with_namespace: "group/project".into(), description: Some("Test project".into()), - default_branch: "main".into(), + default_branch: Some("main".into()), ssh_url_to_repo: "git@gitlab.example.com:group/project.git".into(), web_url: "https://gitlab.example.com/group/project".into(), last_activity_at: DateTime::parse_from_rfc3339("2023-01-01T00:00:00Z") @@ -65,6 +65,7 @@ pub fn sample_pipeline_dto() -> PipelineDto { status: crate::domain::PipelineStatus::Success, source: crate::domain::PipelineSource::Push, branch: "main".into(), + sha: "abc123def456".into(), web_url: "https://gitlab.example.com/group/project/-/pipelines/456".into(), created_at: DateTime::parse_from_rfc3339("2023-01-01T00:00:00Z") .unwrap() @@ -81,10 +82,10 @@ pub fn sample_job_dto() -> JobDto { id: JobId::new(789), name: "test-job".into(), stage: "test".into(), - commit: CommitDto { + commit: Some(CommitDto { title: "Test commit".into(), author_name: "Test Author".into(), - }, + }), status: crate::domain::PipelineStatus::Success, created_at: DateTime::parse_from_rfc3339("2023-01-01T00:00:00Z") .unwrap() diff --git a/src/config.rs b/src/config.rs index ec89aa0..2d06443 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,7 +9,7 @@ use crate::{ pub fn default_config_path() -> PathBuf { if let Some(dirs) = BaseDirs::new() { - dirs.config_dir().join("glim.toml") + dirs.home_dir().join(".config").join("glim").join("glim.toml") } else { PathBuf::from("glim.toml") } diff --git a/src/domain.rs b/src/domain.rs index 85ab4a8..42afa14 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -9,7 +9,7 @@ use ratatui::{ use serde::Deserialize; use crate::{ - id::{JobId, PipelineId, ProjectId}, + id::{JobId, MrIid, PipelineId, ProjectId}, theme::theme, ui::{format_duration, widget::text_from}, }; @@ -41,6 +41,7 @@ pub struct Pipeline { pub updated_at: DateTime, pub jobs: Option>, pub commit: Option, + pub sha: CompactString, } #[derive(Clone, Debug)] @@ -69,10 +70,12 @@ pub struct ProjectDto { pub id: ProjectId, pub path_with_namespace: CompactString, pub description: Option, - pub default_branch: CompactString, + #[serde(default)] + pub default_branch: Option, pub ssh_url_to_repo: CompactString, pub web_url: CompactString, pub last_activity_at: DateTime, + #[serde(default)] pub statistics: StatisticsDto, } @@ -85,7 +88,9 @@ pub struct StatisticsDto { #[derive(Debug, Clone, Default, Deserialize)] pub struct CommitDto { + #[serde(default)] pub title: CompactString, + #[serde(default)] pub author_name: CompactString, } @@ -94,7 +99,8 @@ pub struct JobDto { pub id: JobId, pub name: CompactString, pub stage: CompactString, - pub commit: CommitDto, + #[serde(default)] + pub commit: Option, pub status: PipelineStatus, pub created_at: DateTime, pub started_at: Option>, @@ -113,6 +119,8 @@ pub struct PipelineDto { pub web_url: CompactString, pub created_at: DateTime, pub updated_at: DateTime, + #[serde(default)] + pub sha: CompactString, } #[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Deserialize)] @@ -154,6 +162,8 @@ pub enum PipelineSource { Trigger, Web, Webide, + #[serde(other)] + Unknown, } impl PipelineSource { @@ -174,6 +184,7 @@ impl PipelineSource { PipelineSource::Trigger => "trigger", PipelineSource::Web => "web", PipelineSource::Webide => "web ide", + PipelineSource::Unknown => "unknown", } .into() } @@ -278,7 +289,7 @@ impl From for Project { id: p.id, description: p.description, path: p.path_with_namespace, - default_branch: p.default_branch, + default_branch: p.default_branch.unwrap_or_default(), ssh_git_url: p.ssh_url_to_repo, url: p.web_url, last_activity_at: p.last_activity_at, @@ -363,6 +374,7 @@ impl From for Pipeline { updated_at: p.updated_at, jobs: None, commit: None, + sha: p.sha, } } } @@ -444,7 +456,7 @@ impl Pipeline { _ => self .jobs .as_ref() - .and_then(|jobs| jobs.iter().map(|j| j.finished_at).max().unwrap()), + .and_then(|jobs| jobs.iter().map(|j| j.finished_at).max().flatten()), } } } @@ -562,3 +574,89 @@ impl IconRepresentable for Pipeline { .unwrap_or(self.status.icon()) } } + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct MrAuthorDto { + #[serde(default)] + pub username: CompactString, + #[serde(default)] + pub name: CompactString, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct MrDto { + pub iid: MrIid, + #[serde(default)] + pub title: CompactString, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub state: CompactString, + #[serde(default)] + pub author: MrAuthorDto, + #[serde(default)] + pub web_url: CompactString, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct NoteDto { + #[serde(default)] + pub id: u64, + #[serde(default)] + pub body: CompactString, + #[serde(default)] + pub author: MrAuthorDto, + #[serde(default)] + pub created_at: Option>, + #[serde(default)] + pub system: bool, +} + +#[derive(Clone, Debug)] +pub struct MrView { + pub iid: MrIid, + pub project_id: ProjectId, + pub title: CompactString, + pub description: Option, + pub state: CompactString, + pub author_username: CompactString, + pub web_url: CompactString, + pub notes: Vec, +} + +#[derive(Clone, Debug)] +pub struct MrNote { + pub id: u64, + pub body: CompactString, + pub author_username: CompactString, + pub created_at: Option>, + pub is_atlantis: bool, +} + +impl MrView { + pub fn from_dto(dto: MrDto, project_id: ProjectId) -> Self { + Self { + iid: dto.iid, + project_id, + title: dto.title, + description: dto.description, + state: dto.state, + author_username: dto.author.username, + web_url: dto.web_url, + notes: Vec::new(), + } + } +} + +impl From for MrNote { + fn from(n: NoteDto) -> Self { + let is_atlantis = n.author.username == "atlantis"; + Self { + id: n.id, + body: n.body, + author_username: n.author.username, + created_at: n.created_at, + is_atlantis, + } + } +} diff --git a/src/effect_registry.rs b/src/effect_registry.rs index 3f655c2..2f89c0c 100644 --- a/src/effect_registry.rs +++ b/src/effect_registry.rs @@ -66,6 +66,8 @@ pub enum FxId { ConfigPopup, /// Global screen glitch effects Glitch, + /// MR view popup dialog effects + MrViewPopup, /// Notification message effects Notification, /// Pipeline actions popup dialog effects @@ -146,6 +148,7 @@ impl EffectRegistry { ProjectDetailsClose => self.register_close_popup(FxId::ProjectDetailsPopup), PipelineActionsClose => self.register_close_popup(FxId::PipelineActionsPopup), ConfigClose => self.register_close_popup(FxId::ConfigPopup), + MrViewClose => self.register_close_popup(FxId::MrViewPopup), ConfigUpdate(config) => self.animations_enabled = config.animations, _ => (), } diff --git a/src/event.rs b/src/event.rs index 54abf9e..6d8e26c 100644 --- a/src/event.rs +++ b/src/event.rs @@ -6,9 +6,9 @@ use tracing::Level; use crate::{ dispatcher::Dispatcher, - domain::{JobDto, PipelineDto, Project, ProjectDto}, + domain::{JobDto, MrView, NoteDto, PipelineDto, Project, ProjectDto}, glim_app::GlimConfig, - id::{JobId, PipelineId, ProjectId}, + id::{JobId, MrIid, PipelineId, ProjectId}, result, }; @@ -59,6 +59,30 @@ pub enum GlimEvent { ProjectsLoaded(Vec), ScreenCapture, ScreenCaptureToClipboard(String), + MrViewOpen(ProjectId, PipelineId), + MrViewClose, + MrLoaded(ProjectId, Box), + MrNotFound(ProjectId, PipelineId), + MrNotesFetch(ProjectId, MrIid), + MrNotesLoaded(ProjectId, MrIid, Vec), + MrNotePost(ProjectId, MrIid, CompactString), + MrNotePosted(ProjectId, MrIid), + MrAtlantisAction(ProjectId, MrIid, AtlantisAction), +} + +#[derive(Debug, Clone)] +pub enum AtlantisAction { + Plan, + Apply, +} + +impl AtlantisAction { + pub fn comment_body(&self) -> &'static str { + match self { + AtlantisAction::Plan => "atlantis plan", + AtlantisAction::Apply => "atlantis apply", + } + } } impl GlimEvent { @@ -108,6 +132,15 @@ impl GlimEvent { GlimEvent::ProjectsLoaded(_) => "ProjectsLoaded", GlimEvent::ScreenCapture => "ScreenCapture", GlimEvent::ScreenCaptureToClipboard(_) => "ScreenCaptureToClipboard", + GlimEvent::MrViewOpen(_, _) => "MrViewOpen", + GlimEvent::MrViewClose => "MrViewClose", + GlimEvent::MrLoaded(_, _) => "MrLoaded", + GlimEvent::MrNotFound(_, _) => "MrNotFound", + GlimEvent::MrNotesFetch(_, _) => "MrNotesFetch", + GlimEvent::MrNotesLoaded(_, _, _) => "MrNotesLoaded", + GlimEvent::MrNotePost(_, _, _) => "MrNotePost", + GlimEvent::MrNotePosted(_, _) => "MrNotePosted", + GlimEvent::MrAtlantisAction(_, _, _) => "MrAtlantisAction", } } } diff --git a/src/glim_app.rs b/src/glim_app.rs index 5847f0b..4695e5a 100644 --- a/src/glim_app.rs +++ b/src/glim_app.rs @@ -39,8 +39,10 @@ pub struct GlimApp { #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct GlimConfig { /// The URL of the GitLab instance + #[serde(default)] pub gitlab_url: CompactString, /// The Personal Access Token to authenticate with GitLab + #[serde(default)] pub gitlab_token: CompactString, /// Filter applied to the projects list pub search_filter: Option, @@ -196,6 +198,56 @@ impl GlimApp { .spawn_fetch_jobs(project_id, pipeline_id) }, + // MR view events + GlimEvent::MrViewOpen(project_id, pipeline_id) => { + let sha = self.project_store + .find(project_id) + .and_then(|p| p.pipeline(pipeline_id)) + .map(|p| p.sha.clone()) + .unwrap_or_default(); + + if sha.is_empty() { + self.dispatch(GlimEvent::MrNotFound(project_id, pipeline_id)); + } else { + self.gitlab.spawn_fetch_mr(project_id, pipeline_id, sha); + } + }, + GlimEvent::MrNotFound(_, _) => { + self.dispatch(GlimEvent::AppError(GlimError::GeneralError( + "No open MR found for this pipeline".into(), + ))); + }, + GlimEvent::MrLoaded(project_id, mr) => { + self.gitlab.spawn_fetch_mr_notes(project_id, mr.iid); + }, + GlimEvent::MrNotePost(project_id, mr_iid, body) => { + self.gitlab.spawn_post_mr_note(project_id, mr_iid, body.clone()); + }, + GlimEvent::MrNotePosted(project_id, mr_iid) => { + self.gitlab.spawn_fetch_mr_notes(project_id, mr_iid); + }, + GlimEvent::MrAtlantisAction(project_id, mr_iid, action) => { + let body: compact_str::CompactString = action.comment_body().into(); + self.dispatch(GlimEvent::MrNotePost(project_id, mr_iid, body)); + }, + + // pipeline operations + GlimEvent::PipelineRetry(project_id, pipeline_id) => { + debug!(project_id = %project_id, pipeline_id = %pipeline_id, "Retrying pipeline"); + self.gitlab + .spawn_retry_pipeline(project_id, pipeline_id); + }, + GlimEvent::PipelineCancel(project_id, pipeline_id) => { + debug!(project_id = %project_id, pipeline_id = %pipeline_id, "Cancelling pipeline"); + self.gitlab + .spawn_cancel_pipeline(project_id, pipeline_id); + }, + GlimEvent::PipelineDelete(project_id, pipeline_id) => { + debug!(project_id = %project_id, pipeline_id = %pipeline_id, "Deleting pipeline"); + self.gitlab + .spawn_delete_pipeline(project_id, pipeline_id); + }, + // configuration GlimEvent::ConfigUpdate(config) => { let client_config = ClientConfig::from(config.clone()) diff --git a/src/id.rs b/src/id.rs index 2a0c6d7..9b975d0 100644 --- a/src/id.rs +++ b/src/id.rs @@ -2,33 +2,44 @@ use serde::{Deserialize, Deserializer}; #[derive(Debug, Default, Clone, Copy, Eq, PartialEq, PartialOrd, Ord)] pub struct JobId { - value: u32, + value: u64, } #[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash)] pub struct ProjectId { - value: u32, + value: u64, } #[derive(Debug, Default, Clone, Copy, Eq, PartialEq)] pub struct PipelineId { - value: u32, + value: u64, +} + +#[derive(Debug, Default, Clone, Copy, Eq, PartialEq)] +pub struct MrIid { + value: u64, } impl ProjectId { - pub fn new(id: u32) -> Self { + pub fn new(id: u64) -> Self { Self { value: id } } } impl PipelineId { - pub fn new(id: u32) -> Self { + pub fn new(id: u64) -> Self { Self { value: id } } } impl JobId { - pub fn new(id: u32) -> Self { + pub fn new(id: u64) -> Self { + Self { value: id } + } +} + +impl MrIid { + pub fn new(id: u64) -> Self { Self { value: id } } } @@ -38,7 +49,7 @@ impl<'de> Deserialize<'de> for ProjectId { where D: Deserializer<'de>, { - let id = u32::deserialize(deserializer)?; + let id = u64::deserialize(deserializer)?; Ok(ProjectId::new(id)) } } @@ -48,7 +59,7 @@ impl<'de> Deserialize<'de> for PipelineId { where D: Deserializer<'de>, { - let id = u32::deserialize(deserializer)?; + let id = u64::deserialize(deserializer)?; Ok(PipelineId::new(id)) } } @@ -58,11 +69,21 @@ impl<'de> Deserialize<'de> for JobId { where D: Deserializer<'de>, { - let id = u32::deserialize(deserializer)?; + let id = u64::deserialize(deserializer)?; Ok(JobId::new(id)) } } +impl<'de> Deserialize<'de> for MrIid { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let id = u64::deserialize(deserializer)?; + Ok(MrIid::new(id)) + } +} + impl std::fmt::Display for ProjectId { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!(f, "{}", self.value) @@ -80,3 +101,9 @@ impl std::fmt::Display for JobId { write!(f, "{}", self.value) } } + +impl std::fmt::Display for MrIid { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{}", self.value) + } +} diff --git a/src/input/multiplexer.rs b/src/input/multiplexer.rs index 331207b..7416b56 100644 --- a/src/input/multiplexer.rs +++ b/src/input/multiplexer.rs @@ -3,7 +3,9 @@ use std::sync::mpsc::Sender; use crate::{ event::GlimEvent, input::{ - processor::{ConfigProcessor, PipelineActionsProcessor, ProjectDetailsProcessor}, + processor::{ + ConfigProcessor, MrViewProcessor, PipelineActionsProcessor, ProjectDetailsProcessor, + }, InputProcessor, }, ui::StatefulWidgets, @@ -56,6 +58,15 @@ impl InputMultiplexer { }, GlimEvent::ConfigClose => self.pop_processor(), + // MR view + GlimEvent::MrViewOpen(project_id, _pipeline_id) => { + self.push(Box::new(MrViewProcessor::new( + self.sender.clone(), + *project_id, + ))); + }, + GlimEvent::MrViewClose => self.pop_processor(), + _ => (), } diff --git a/src/input/processor/mod.rs b/src/input/processor/mod.rs index c5ad06d..47975fe 100644 --- a/src/input/processor/mod.rs +++ b/src/input/processor/mod.rs @@ -1,9 +1,11 @@ mod config; +mod mr_view; mod normal; mod pipeline_actions; mod project_details; pub use config::*; +pub use mr_view::*; pub use normal::*; pub use pipeline_actions::*; pub use project_details::*; diff --git a/src/input/processor/mr_view.rs b/src/input/processor/mr_view.rs new file mode 100644 index 0000000..1122cc3 --- /dev/null +++ b/src/input/processor/mr_view.rs @@ -0,0 +1,142 @@ +use std::sync::mpsc::Sender; + +use compact_str::ToCompactString; +use crossterm::event::KeyCode; +use tui_input::backend::crossterm::EventHandler; +use tui_input::Input; + +use crate::{ + dispatcher::Dispatcher, + event::{AtlantisAction, GlimEvent}, + id::{MrIid, ProjectId}, + input::InputProcessor, + ui::StatefulWidgets, +}; + +pub struct MrViewProcessor { + sender: Sender, + project_id: ProjectId, + mr_iid: Option, +} + +impl MrViewProcessor { + pub fn new(sender: Sender, project_id: ProjectId) -> Self { + Self { sender, project_id, mr_iid: None } + } +} + +impl InputProcessor for MrViewProcessor { + fn apply(&mut self, event: &GlimEvent, ui: &mut StatefulWidgets) { + match event { + GlimEvent::MrLoaded(_, mr) => { + self.mr_iid = Some(mr.iid); + }, + GlimEvent::InputKey(key) => { + if let Some(state) = ui.mr_view.as_mut() { + use crate::ui::popup::MrViewMode; + match state.mode.clone() { + MrViewMode::Scrolling => match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + self.dispatch(GlimEvent::MrViewClose); + }, + KeyCode::Char('j') | KeyCode::Down => state.scroll_down(), + KeyCode::Char('k') | KeyCode::Up => state.scroll_up(), + KeyCode::Tab | KeyCode::Char('i') => { + state.mode = MrViewMode::Composing; + }, + KeyCode::Char('a') => { + if let Some(selected_idx) = state.list_state.selected() { + if let Some(mr) = state.mr.as_ref() { + if mr + .notes + .get(selected_idx) + .map(|n| n.is_atlantis) + .unwrap_or(false) + { + state.mode = MrViewMode::AtlantisAction { + note_idx: selected_idx, + selected: 0, + }; + } + } + } + }, + KeyCode::Char('w') => { + if let Some(mr) = state.mr.as_ref() { + let _ = open::that(mr.web_url.as_str()); + } + }, + _ => {}, + }, + MrViewMode::Composing => match key.code { + KeyCode::Esc => { + state.mode = MrViewMode::Scrolling; + }, + KeyCode::Enter => { + if let Some(mr_iid) = self.mr_iid { + let body: compact_str::CompactString = + state.comment_input.value().to_compact_string(); + if !body.is_empty() { + self.dispatch(GlimEvent::MrNotePost( + self.project_id, + mr_iid, + body, + )); + state.comment_input = Input::default(); + } + } + state.mode = MrViewMode::Scrolling; + }, + _ => { + state.comment_input.handle_event( + &crossterm::event::Event::Key(*key), + ); + }, + }, + MrViewMode::AtlantisAction { note_idx, selected } => { + match key.code { + KeyCode::Esc => { + state.mode = MrViewMode::Scrolling; + }, + KeyCode::Left | KeyCode::Char('h') => { + state.mode = + MrViewMode::AtlantisAction { note_idx, selected: 0 }; + }, + KeyCode::Right | KeyCode::Char('l') => { + state.mode = + MrViewMode::AtlantisAction { note_idx, selected: 1 }; + }, + KeyCode::Enter => { + if let Some(mr_iid) = self.mr_iid { + let action = if selected == 0 { + AtlantisAction::Plan + } else { + AtlantisAction::Apply + }; + self.dispatch(GlimEvent::MrAtlantisAction( + self.project_id, + mr_iid, + action, + )); + } + state.mode = MrViewMode::Scrolling; + }, + _ => {}, + } + }, + } + } + }, + _ => {}, + } + } + + fn on_push(&self) {} + fn on_pop(&self) {} +} + +impl Dispatcher for MrViewProcessor { + fn dispatch(&self, event: GlimEvent) { + self.sender.dispatch(event); + } +} diff --git a/src/input/processor/pipeline_actions.rs b/src/input/processor/pipeline_actions.rs index 4d3c720..731bbdb 100644 --- a/src/input/processor/pipeline_actions.rs +++ b/src/input/processor/pipeline_actions.rs @@ -27,29 +27,29 @@ impl PipelineActionsProcessor { KeyCode::Char('j') => ui.handle_pipeline_action_selection(1), KeyCode::Enter => { let state = ui.pipeline_actions.as_ref().unwrap(); - if let Some(action) = state + let action = state .list_state .selected() - .map(|idx| state.copy_selected_action(idx)) - { + .map(|idx| state.copy_selected_action(idx)); + + // Close BEFORE dispatching the action so PipelineActionsClose + // pops PipelineActionsProcessor, not any processor the action pushes. + self.sender.dispatch(GlimEvent::PipelineActionsClose); + if let Some(action) = action { self.sender.dispatch(action) } - - self.sender - .dispatch(GlimEvent::PipelineActionsClose) }, KeyCode::Char('o') => { let state = ui.pipeline_actions.as_ref().unwrap(); - if let Some(action) = state + let action = state .list_state .selected() - .map(|idx| state.copy_selected_action(idx)) - { + .map(|idx| state.copy_selected_action(idx)); + + self.sender.dispatch(GlimEvent::PipelineActionsClose); + if let Some(action) = action { self.sender.dispatch(action) } - - self.sender - .dispatch(GlimEvent::PipelineActionsClose) }, KeyCode::F(12) => self.sender.dispatch(GlimEvent::ScreenCapture), _ => (), diff --git a/src/input/processor/project_details.rs b/src/input/processor/project_details.rs index 126779e..1a67820 100644 --- a/src/input/processor/project_details.rs +++ b/src/input/processor/project_details.rs @@ -47,6 +47,13 @@ impl ProjectDetailsProcessor { self.selected.unwrap(), )) }, + KeyCode::Char('m') if self.selected.is_some() => { + self.sender + .dispatch(GlimEvent::MrViewOpen( + self.project_id, + self.selected.unwrap(), + )) + }, KeyCode::F(12) => self.sender.dispatch(GlimEvent::ScreenCapture), _ => (), } diff --git a/src/main.rs b/src/main.rs index 3a535d1..f6309fd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -53,13 +53,37 @@ fn main() -> Result<()> { let debug = std::env::var("GLIM_DEBUG").is_ok(); - let config = if config_path.exists() { + let mut config = if config_path.exists() { confy::load_path(&config_path) .map_err(|e| crate::result::GlimError::config_load_error(config_path.clone(), e))? } else { GlimConfig::default() }; + // Allow env vars to override config file values + if let Ok(token) = std::env::var("GITLAB_TOKEN") { + config.gitlab_token = token.into(); + } + if let Ok(url) = std::env::var("GITLAB_URL") { + config.gitlab_url = url.into(); + } + + // Validate that credentials are available from some source + if config.gitlab_token.is_empty() { + eprintln!("Error: GitLab token is not configured.\n"); + eprintln!("Set it via environment variable: export GITLAB_TOKEN="); + eprintln!("Or add to config file ({}):", config_path.display()); + eprintln!(" gitlab_token = \"\""); + exit(1); + } + if config.gitlab_url.is_empty() { + eprintln!("Error: GitLab URL is not configured.\n"); + eprintln!("Set it via environment variable: export GITLAB_URL=https://gitlab.com"); + eprintln!("Or add to config file ({}):", config_path.display()); + eprintln!(" gitlab_url = \"https://gitlab.com\""); + exit(1); + } + // Create a shared runtime for async operations let rt = tokio::runtime::Runtime::new().map_err(|e| { crate::result::GlimError::GeneralError(format!("Failed to create runtime: {e}").into()) diff --git a/src/rendering.rs b/src/rendering.rs index db21524..d373886 100644 --- a/src/rendering.rs +++ b/src/rendering.rs @@ -12,7 +12,7 @@ use crate::{ glim_app::GlimApp, theme::theme, ui::{ - popup::{ConfigPopup, PipelineActionsPopup, ProjectDetailsPopup}, + popup::{ConfigPopup, MrViewPopup, PipelineActionsPopup, ProjectDetailsPopup}, widget::{Notification, ProjectsTable}, StatefulWidgets, }, @@ -84,6 +84,25 @@ fn render_popups( if let Some(notification) = &mut widget_states.notice { f.render_stateful_widget(Notification::new(), area, notification); } + + if let Some(mr_state) = widget_states.mr_view.as_mut() { + let popup_area = area.inner(ratatui::layout::Margin::new(3, 1)); + f.render_stateful_widget(MrViewPopup, popup_area, mr_state); + + if matches!(mr_state.mode, crate::ui::popup::MrViewMode::Composing) { + // Calculate input area position to set cursor + // Layout is: 4 header + min(5) notes + 3 input = inner area + let inner = popup_area.inner(ratatui::layout::Margin::new(1, 1)); + let input_area = Rect { + x: inner.x, + y: inner.bottom().saturating_sub(3), + width: inner.width, + height: 3, + }; + let (cx, cy) = mr_state.cursor_position(input_area); + f.set_cursor_position((cx, cy)); + } + } } fn render_effects( diff --git a/src/stores.rs b/src/stores.rs index 9fa2530..38ffefd 100644 --- a/src/stores.rs +++ b/src/stores.rs @@ -6,7 +6,7 @@ use tracing::{debug, info, instrument, warn}; use crate::{ dispatcher::Dispatcher, - domain::{Job, Pipeline, Project}, + domain::{Commit, Job, Pipeline, Project}, event::GlimEvent, id::ProjectId, }; @@ -35,12 +35,13 @@ impl ProjectStore { // requests jobs for pipelines that have not been loaded yet GlimEvent::ProjectDetailsOpen(id) => { debug!(project_id = %id, "Opening project details and requesting missing jobs"); - let project = self.find(*id).unwrap(); - project - .recent_pipelines() - .into_iter() - .filter(|p| p.jobs.is_none()) - .for_each(|p| self.dispatch(GlimEvent::JobsFetch(project.id, p.id))); + if let Some(project) = self.find(*id) { + project + .recent_pipelines() + .into_iter() + .filter(|p| p.jobs.is_none()) + .for_each(|p| self.dispatch(GlimEvent::JobsFetch(project.id, p.id))); + } }, // updates the projects in the store @@ -62,13 +63,16 @@ impl ProjectStore { self.sorted = self.projects_sorted_by_last_activity(); if first_projects { - self.dispatch(GlimEvent::ProjectSelected(self.sorted.first().unwrap().id)); + if let Some(first) = self.sorted.first() { + self.dispatch(GlimEvent::ProjectSelected(first.id)); + } } }, // updates the pipelines for a project GlimEvent::PipelinesLoaded(pipelines) => { - let project_id = pipelines[0].project_id; + let Some(first) = pipelines.first() else { return }; + let project_id = first.project_id; debug!(project_id = %project_id, pipeline_count = pipelines.len(), "Processing received pipelines"); let sender = self.sender.clone(); @@ -100,14 +104,13 @@ impl ProjectStore { let sender = self.sender.clone(); if let Some(project) = self.find_mut(*project_id) { project.update_jobs(*pipeline_id, jobs); - // todo: ugly, fix - project.update_commit( - *pipeline_id, - job_dtos - .first() - .map(|j| j.commit.clone().into()) - .unwrap(), - ); + if let Some(commit) = job_dtos + .first() + .and_then(|j| j.commit.clone()) + .map(Commit::from) + { + project.update_commit(*pipeline_id, commit); + } sender.dispatch(GlimEvent::ProjectUpdated(Box::new(project.clone()))) } @@ -231,6 +234,31 @@ pub fn log_event(event: &GlimEvent) { "Job log downloaded successfully" ) }, + GlimEvent::MrViewOpen(project_id, pipeline_id) => { + debug!(project_id = %project_id, pipeline_id = %pipeline_id, "Opening MR view") + }, + GlimEvent::MrViewClose => debug!("Closing MR view"), + GlimEvent::MrLoaded(project_id, mr) => { + debug!(project_id = %project_id, mr_iid = %mr.iid, "MR loaded") + }, + GlimEvent::MrNotFound(project_id, pipeline_id) => { + debug!(project_id = %project_id, pipeline_id = %pipeline_id, "No MR found for pipeline") + }, + GlimEvent::MrNotesFetch(project_id, mr_iid) => { + debug!(project_id = %project_id, mr_iid = %mr_iid, "Fetching MR notes") + }, + GlimEvent::MrNotesLoaded(project_id, mr_iid, notes) => { + debug!(project_id = %project_id, mr_iid = %mr_iid, count = notes.len(), "MR notes loaded") + }, + GlimEvent::MrNotePost(project_id, mr_iid, _body) => { + debug!(project_id = %project_id, mr_iid = %mr_iid, "Posting MR note") + }, + GlimEvent::MrNotePosted(project_id, mr_iid) => { + info!(project_id = %project_id, mr_iid = %mr_iid, "MR note posted") + }, + GlimEvent::MrAtlantisAction(project_id, mr_iid, _action) => { + info!(project_id = %project_id, mr_iid = %mr_iid, "Triggering Atlantis action") + }, GlimEvent::ConfigOpen => debug!("Displaying configuration"), GlimEvent::ConfigApply => info!("Applying new configuration"), GlimEvent::ConfigUpdate(_) => debug!("Updating configuration"), diff --git a/src/ui/popup/mod.rs b/src/ui/popup/mod.rs index bbd92e1..89e5dc3 100644 --- a/src/ui/popup/mod.rs +++ b/src/ui/popup/mod.rs @@ -1,8 +1,10 @@ mod config_popup; +mod mr_view_popup; mod pipeline_actions_popup; mod project_details_popup; mod utility; pub use config_popup::*; +pub use mr_view_popup::*; pub use pipeline_actions_popup::*; pub use project_details_popup::*; diff --git a/src/ui/popup/mr_view_popup.rs b/src/ui/popup/mr_view_popup.rs new file mode 100644 index 0000000..ebac432 --- /dev/null +++ b/src/ui/popup/mr_view_popup.rs @@ -0,0 +1,287 @@ +use chrono::Local; +use ratatui::{ + buffer::Buffer, + layout::{Constraint, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span, Text}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph, StatefulWidget, Widget}, +}; +use tui_input::Input; + +use crate::{ + domain::{MrNote, MrView}, + id::ProjectId, + theme::theme, +}; + +#[derive(Debug, Clone, PartialEq)] +pub enum MrViewMode { + Scrolling, + Composing, + AtlantisAction { note_idx: usize, selected: usize }, +} + +pub struct MrViewState { + pub project_id: ProjectId, + pub mr: Option, + pub loading: bool, + pub list_state: ListState, + pub mode: MrViewMode, + pub comment_input: Input, +} + +impl MrViewState { + pub fn new(project_id: ProjectId) -> Self { + Self { + project_id, + mr: None, + loading: true, + list_state: ListState::default(), + mode: MrViewMode::Scrolling, + comment_input: Input::default(), + } + } + + pub fn set_mr(&mut self, mr: MrView) { + self.mr = Some(mr); + self.loading = false; + } + + pub fn set_notes(&mut self, notes: Vec) { + if let Some(mr) = self.mr.as_mut() { + mr.notes = notes; + if !mr.notes.is_empty() { + self.list_state.select(Some(0)); + } + } + } + + pub fn scroll_up(&mut self) { + if let Some(mr) = self.mr.as_ref() { + if mr.notes.is_empty() { + return; + } + let i = self.list_state.selected().unwrap_or(0); + let new = if i == 0 { mr.notes.len() - 1 } else { i - 1 }; + self.list_state.select(Some(new)); + } + } + + pub fn scroll_down(&mut self) { + if let Some(mr) = self.mr.as_ref() { + if mr.notes.is_empty() { + return; + } + let i = self.list_state.selected().unwrap_or(0); + let new = (i + 1) % mr.notes.len(); + self.list_state.select(Some(new)); + } + } + + pub fn selected_note(&self) -> Option<&MrNote> { + self.mr.as_ref().and_then(|mr| { + self.list_state + .selected() + .and_then(|i| mr.notes.get(i)) + }) + } + + pub fn cursor_position(&self, input_area: Rect) -> (u16, u16) { + let x = input_area.x + 1 + self.comment_input.visual_cursor() as u16; + let y = input_area.y + 1; + (x.min(input_area.right().saturating_sub(1)), y) + } +} + +pub struct MrViewPopup; + +impl StatefulWidget for MrViewPopup { + type State = MrViewState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Merge Request "); + let inner = block.inner(area); + block.render(area, buf); + + if state.loading || state.mr.is_none() { + let loading = Paragraph::new("Loading MR...").style(theme().project_name); + loading.render(inner, buf); + return; + } + + let mr = state.mr.as_ref().unwrap(); + + let [header_area, notes_area, input_area] = Layout::vertical([ + Constraint::Length(4), + Constraint::Min(5), + Constraint::Length(3), + ]) + .areas(inner); + + render_mr_header(mr, header_area, buf); + render_notes(mr, notes_area, buf, &mut state.list_state, &state.mode); + render_input(state, input_area, buf); + } +} + +fn render_mr_header(mr: &MrView, area: Rect, buf: &mut Buffer) { + let state_style = match mr.state.as_str() { + "opened" => Style::default().fg(ratatui::style::Color::Green), + "merged" => Style::default().fg(ratatui::style::Color::Magenta), + _ => Style::default().fg(ratatui::style::Color::Red), + }; + + let lines = vec![ + Line::from(vec![ + Span::styled(format!("!{} ", mr.iid), theme().pipeline_branch), + Span::styled(mr.title.as_str(), theme().project_name), + ]), + Line::from(vec![ + Span::raw("State: "), + Span::styled(mr.state.as_str(), state_style), + Span::raw(" Author: @"), + Span::styled(mr.author_username.as_str(), theme().pipeline_job), + ]), + Line::from(vec![ + Span::raw("URL: "), + Span::styled(mr.web_url.as_str(), theme().pipeline_branch), + ]), + Line::from(vec![Span::styled( + format!( + "{} note(s) — j/k scroll Tab compose a atlantis w open Esc close", + mr.notes.len() + ), + theme().date, + )]), + ]; + + Paragraph::new(lines).render(area, buf); +} + +fn render_notes( + mr: &MrView, + area: Rect, + buf: &mut Buffer, + list_state: &mut ListState, + mode: &MrViewMode, +) { + let block = Block::default() + .borders(Borders::TOP) + .title(format!(" Comments ({}) ", mr.notes.len())); + let inner = block.inner(area); + block.render(area, buf); + + let notes: Vec = mr + .notes + .iter() + .enumerate() + .map(|(idx, note)| { + let is_atlantis_actions = matches!( + mode, + MrViewMode::AtlantisAction { note_idx, .. } if *note_idx == idx + ); + + let mut lines: Vec = Vec::new(); + + let date_str = note + .created_at + .map(|dt| dt.with_timezone(&Local).format("%d %b %H:%M").to_string()) + .unwrap_or_default(); + + if note.is_atlantis { + lines.push(Line::from(vec![ + Span::styled( + "[ATLANTIS] ", + Style::default() + .fg(ratatui::style::Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!("@{} {}", note.author_username, date_str), + theme().date, + ), + ])); + } else { + lines.push(Line::from(vec![ + Span::styled( + format!("@{} ", note.author_username), + theme().pipeline_job, + ), + Span::styled(date_str, theme().date), + ])); + } + + for body_line in note.body.lines().take(8) { + lines.push(Line::from(Span::raw(body_line.to_string()))); + } + + if note.is_atlantis { + let (plan_style, apply_style) = if is_atlantis_actions { + match mode { + MrViewMode::AtlantisAction { selected, .. } => { + if *selected == 0 { + ( + Style::default() + .fg(ratatui::style::Color::Black) + .bg(ratatui::style::Color::Yellow), + Style::default().fg(ratatui::style::Color::Yellow), + ) + } else { + ( + Style::default().fg(ratatui::style::Color::Yellow), + Style::default() + .fg(ratatui::style::Color::Black) + .bg(ratatui::style::Color::Yellow), + ) + } + }, + _ => ( + Style::default().fg(ratatui::style::Color::Yellow), + Style::default().fg(ratatui::style::Color::Yellow), + ), + } + } else { + ( + Style::default().fg(ratatui::style::Color::Yellow), + Style::default().fg(ratatui::style::Color::Yellow), + ) + }; + + lines.push(Line::from(vec![ + Span::styled("[ atlantis plan ]", plan_style), + Span::raw(" "), + Span::styled("[ atlantis apply ]", apply_style), + ])); + } + + lines.push(Line::from("")); + + ListItem::new(Text::from(lines)) + }) + .collect(); + + let list = List::new(notes).highlight_style(Style::default().add_modifier(Modifier::BOLD)); + + StatefulWidget::render(list, inner, buf, list_state); +} + +fn render_input(state: &MrViewState, area: Rect, buf: &mut Buffer) { + let title = match state.mode { + MrViewMode::Composing => " New Comment (Enter submit Esc cancel) ", + _ => " New Comment (Tab to compose) ", + }; + + let input_style = match state.mode { + MrViewMode::Composing => Style::default().fg(ratatui::style::Color::Yellow), + _ => Style::default().fg(ratatui::style::Color::DarkGray), + }; + + let block = Block::default().borders(Borders::ALL).title(title); + let inner = block.inner(area); + block.render(area, buf); + + let text = state.comment_input.value(); + Paragraph::new(text).style(input_style).render(inner, buf); +} diff --git a/src/ui/stateful_widgets.rs b/src/ui/stateful_widgets.rs index baeb59a..d15845c 100644 --- a/src/ui/stateful_widgets.rs +++ b/src/ui/stateful_widgets.rs @@ -6,13 +6,13 @@ use tachyonfx::{Duration, RefRect}; use crate::{ dispatcher::Dispatcher, - domain::Project, + domain::{MrNote, Project}, effect_registry::EffectRegistry, event::GlimEvent, glim_app::{GlimApp, GlimConfig, Modulo}, id::PipelineId, ui::{ - popup::{ConfigPopupState, PipelineActionsPopupState, ProjectDetailsPopupState}, + popup::{ConfigPopupState, MrViewState, PipelineActionsPopupState, ProjectDetailsPopupState}, widget::NotificationState, }, }; @@ -24,6 +24,7 @@ pub struct StatefulWidgets { pub config_popup_state: Option, pub project_details: Option, pub pipeline_actions: Option, + pub mr_view: Option, pub notice: Option, pub filter_input_active: bool, pub filter_input_text: CompactString, @@ -41,6 +42,7 @@ impl StatefulWidgets { config_popup_state: None, project_details: None, pipeline_actions: None, + mr_view: None, notice: None, filter_input_active: false, filter_input_text: CompactString::default(), @@ -81,6 +83,25 @@ impl StatefulWidgets { }, GlimEvent::ConfigClose => self.config_popup_state = None, + GlimEvent::MrViewOpen(project_id, _pipeline_id) => { + self.mr_view = Some(MrViewState::new(*project_id)); + }, + GlimEvent::MrViewClose => { + self.mr_view = None; + }, + GlimEvent::MrLoaded(_project_id, mr) => { + if let Some(state) = self.mr_view.as_mut() { + state.set_mr(*mr.clone()); + } + }, + GlimEvent::MrNotesLoaded(_project_id, _mr_iid, note_dtos) => { + if let Some(state) = self.mr_view.as_mut() { + let notes: Vec = + note_dtos.iter().cloned().map(MrNote::from).collect(); + state.set_notes(notes); + } + }, + GlimEvent::FilterMenuShow => self.show_filter_input(), GlimEvent::FilterMenuClose => self.close_filter_input(), GlimEvent::FilterInputChar(c) => self.add_filter_char(c),