diff --git a/Cargo.lock b/Cargo.lock index a7eb358ddb..b52108977a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2573,6 +2573,34 @@ dependencies = [ "workspace", ] +[[package]] +name = "diagnostics2" +version = "0.1.0" +dependencies = [ + "anyhow", + "client2", + "collections", + "editor2", + "futures 0.3.28", + "gpui2", + "language2", + "log", + "lsp2", + "postage", + "project2", + "schemars", + "serde", + "serde_derive", + "serde_json", + "settings2", + "smallvec", + "theme2", + "ui2", + "unindent", + "util", + "workspace2", +] + [[package]] name = "diff" version = "0.1.13" diff --git a/Cargo.toml b/Cargo.toml index 6b29b18127..f66cfae280 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ members = [ "crates/refineable", "crates/refineable/derive_refineable", "crates/diagnostics", + "crates/diagnostics2", "crates/drag_and_drop", "crates/editor", "crates/feature_flags", diff --git a/crates/diagnostics2/Cargo.toml b/crates/diagnostics2/Cargo.toml new file mode 100644 index 0000000000..45d4048942 --- /dev/null +++ b/crates/diagnostics2/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "diagnostics2" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/diagnostics.rs" +doctest = false + +[dependencies] +collections = { path = "../collections" } +editor = { package = "editor2", path = "../editor2" } +gpui = { package = "gpui2", path = "../gpui2" } +ui = { package = "ui2", path = "../ui2" } +language = { package = "language2", path = "../language2" } +lsp = { package = "lsp2", path = "../lsp2" } +project = { package = "project2", path = "../project2" } +settings = { package = "settings2", path = "../settings2" } +theme = { package = "theme2", path = "../theme2" } +util = { path = "../util" } +workspace = { package = "workspace2", path = "../workspace2" } + +log.workspace = true +anyhow.workspace = true +futures.workspace = true +schemars.workspace = true +serde.workspace = true +serde_derive.workspace = true +smallvec.workspace = true +postage.workspace = true + +[dev-dependencies] +client = { package = "client2", path = "../client2", features = ["test-support"] } +editor = { package = "editor2", path = "../editor2", features = ["test-support"] } +language = { package = "language2", path = "../language2", features = ["test-support"] } +lsp = { package = "lsp2", path = "../lsp2", features = ["test-support"] } +gpui = { package = "gpui2", path = "../gpui2", features = ["test-support"] } +workspace = { package = "workspace2", path = "../workspace2", features = ["test-support"] } +theme = { package = "theme2", path = "../theme2", features = ["test-support"] } + +serde_json.workspace = true +unindent.workspace = true diff --git a/crates/diagnostics2/src/diagnostics.rs b/crates/diagnostics2/src/diagnostics.rs new file mode 100644 index 0000000000..d9a417b60a --- /dev/null +++ b/crates/diagnostics2/src/diagnostics.rs @@ -0,0 +1,1653 @@ +pub mod items; +mod project_diagnostics_settings; +mod toolbar_controls; + +use anyhow::{Context as _, Result}; +use collections::{HashMap, HashSet}; +use editor::{ + diagnostic_block_renderer, + display_map::{BlockDisposition, BlockId, BlockProperties, BlockStyle, RenderBlock}, + highlight_diagnostic_message, + scroll::autoscroll::Autoscroll, + Editor, EditorEvent, ExcerptId, ExcerptRange, MultiBuffer, ToOffset, +}; +use futures::future::try_join_all; +use gpui::{ + actions, div, AnyElement, AnyView, AppContext, Component, Context, Div, EventEmitter, + FocusHandle, Model, ParentComponent, Render, SharedString, Styled, Subscription, Task, View, + ViewContext, VisualContext, WeakView, +}; +use language::{ + Anchor, Bias, Buffer, Diagnostic, DiagnosticEntry, DiagnosticSeverity, Point, Selection, + SelectionGoal, +}; +use lsp::LanguageServerId; +use project::{DiagnosticSummary, Project, ProjectPath}; +use project_diagnostics_settings::ProjectDiagnosticsSettings; +use settings::Settings; +use std::{ + any::{Any, TypeId}, + borrow::Cow, + cmp::Ordering, + mem, + ops::Range, + path::PathBuf, + sync::Arc, +}; +use theme::ThemeSettings; +pub use toolbar_controls::ToolbarControls; +use ui::Label; +use util::TryFutureExt; +use workspace::{ + item::{BreadcrumbText, Item, ItemEvent, ItemHandle}, + ItemNavHistory, Pane, ToolbarItemLocation, Workspace, +}; + +actions!(Deploy, ToggleWarnings); + +const CONTEXT_LINE_COUNT: u32 = 1; + +pub fn init(cx: &mut AppContext) { + ProjectDiagnosticsSettings::register(cx); + // todo!() + // cx.add_action(ProjectDiagnosticsEditor::deploy); + // cx.add_action(ProjectDiagnosticsEditor::toggle_warnings); + // items::init(cx); +} + +struct ProjectDiagnosticsEditor { + project: Model, + workspace: WeakView, + focus_handle: FocusHandle, + editor: View, + summary: DiagnosticSummary, + excerpts: Model, + path_states: Vec, + paths_to_update: HashMap>, + current_diagnostics: HashMap>, + include_warnings: bool, + _subscriptions: Vec, +} + +struct PathState { + path: ProjectPath, + diagnostic_groups: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +struct Jump { + path: ProjectPath, + position: Point, + anchor: Anchor, +} + +struct DiagnosticGroupState { + language_server_id: LanguageServerId, + primary_diagnostic: DiagnosticEntry, + primary_excerpt_ix: usize, + excerpts: Vec, + blocks: HashSet, + block_count: usize, +} + +impl EventEmitter for ProjectDiagnosticsEditor {} + +impl Render for ProjectDiagnosticsEditor { + type Element = Div; + + fn render(&mut self, cx: &mut ViewContext) -> Self::Element { + div().size_full().bg(gpui::red()) + } +} + +// impl View for ProjectDiagnosticsEditor { +// fn ui_name() -> &'static str { +// "ProjectDiagnosticsEditor" +// } + +// fn render(&mut self, cx: &mut ViewContext) -> AnyElement { +// if self.path_states.is_empty() { +// let theme = &theme::current(cx).project_diagnostics; +// PaneBackdrop::new( +// cx.view_id(), +// Label::new("No problems in workspace", theme.empty_message.clone()) +// .aligned() +// .contained() +// .with_style(theme.container) +// .into_any(), +// ) +// .into_any() +// } else { +// ChildView::new(&self.editor, cx).into_any() +// } +// } + +// fn focus_in(&mut self, _: AnyView, cx: &mut ViewContext) { +// if cx.is_self_focused() && !self.path_states.is_empty() { +// cx.focus(&self.editor); +// } +// } + +// fn debug_json(&self, cx: &AppContext) -> serde_json::Value { +// let project = self.project.read(cx); +// json!({ +// "project": json!({ +// "language_servers": project.language_server_statuses().collect::>(), +// "summary": project.diagnostic_summary(cx), +// }), +// "summary": self.summary, +// "paths_to_update": self.paths_to_update.iter().map(|(server_id, paths)| +// (server_id.0, paths.into_iter().map(|path| path.path.to_string_lossy()).collect::>()) +// ).collect::>(), +// "current_diagnostics": self.current_diagnostics.iter().map(|(server_id, paths)| +// (server_id.0, paths.into_iter().map(|path| path.path.to_string_lossy()).collect::>()) +// ).collect::>(), +// "paths_states": self.path_states.iter().map(|state| +// json!({ +// "path": state.path.path.to_string_lossy(), +// "groups": state.diagnostic_groups.iter().map(|group| +// json!({ +// "block_count": group.blocks.len(), +// "excerpt_count": group.excerpts.len(), +// }) +// ).collect::>(), +// }) +// ).collect::>(), +// }) +// } +// } + +impl ProjectDiagnosticsEditor { + fn new( + project_handle: Model, + workspace: WeakView, + cx: &mut ViewContext, + ) -> Self { + let project_event_subscription = + cx.subscribe(&project_handle, |this, _, event, cx| match event { + project::Event::DiskBasedDiagnosticsFinished { language_server_id } => { + log::debug!("Disk based diagnostics finished for server {language_server_id}"); + this.update_excerpts(Some(*language_server_id), cx); + } + project::Event::DiagnosticsUpdated { + language_server_id, + path, + } => { + log::debug!("Adding path {path:?} to update for server {language_server_id}"); + this.paths_to_update + .entry(*language_server_id) + .or_default() + .insert(path.clone()); + if this.editor.read(cx).selections.all::(cx).is_empty() + && !this.is_dirty(cx) + { + this.update_excerpts(Some(*language_server_id), cx); + } + } + _ => {} + }); + + let excerpts = cx.build_model(|cx| MultiBuffer::new(project_handle.read(cx).replica_id())); + let editor = cx.build_view(|cx| { + let mut editor = + Editor::for_multibuffer(excerpts.clone(), Some(project_handle.clone()), cx); + editor.set_vertical_scroll_margin(5, cx); + editor + }); + let editor_event_subscription = + cx.subscribe(&editor, |this, _editor, event: &EditorEvent, cx| { + Self::emit_item_event_for_editor_event(event, cx); + if event == &EditorEvent::Focused && this.path_states.is_empty() { + cx.focus(&this.focus_handle); + } + }); + + let project = project_handle.read(cx); + let summary = project.diagnostic_summary(cx); + let mut this = Self { + project: project_handle, + summary, + workspace, + excerpts, + focus_handle: cx.focus_handle(), + editor, + path_states: Default::default(), + paths_to_update: HashMap::default(), + include_warnings: ProjectDiagnosticsSettings::get_global(cx).include_warnings, + current_diagnostics: HashMap::default(), + _subscriptions: vec![project_event_subscription, editor_event_subscription], + }; + this.update_excerpts(None, cx); + this + } + + fn emit_item_event_for_editor_event(event: &EditorEvent, cx: &mut ViewContext) { + match event { + EditorEvent::Closed => cx.emit(ItemEvent::CloseItem), + EditorEvent::Saved | EditorEvent::TitleChanged => { + cx.emit(ItemEvent::UpdateTab); + cx.emit(ItemEvent::UpdateBreadcrumbs); + } + EditorEvent::Reparsed => { + cx.emit(ItemEvent::UpdateBreadcrumbs); + } + EditorEvent::SelectionsChanged { local } if *local => { + cx.emit(ItemEvent::UpdateBreadcrumbs); + } + EditorEvent::DirtyChanged => { + cx.emit(ItemEvent::UpdateTab); + } + EditorEvent::BufferEdited => { + cx.emit(ItemEvent::Edit); + cx.emit(ItemEvent::UpdateBreadcrumbs); + } + _ => {} + } + } + + fn deploy(workspace: &mut Workspace, _: &Deploy, cx: &mut ViewContext) { + if let Some(existing) = workspace.item_of_type::(cx) { + workspace.activate_item(&existing, cx); + } else { + let workspace_handle = cx.view().downgrade(); + let diagnostics = cx.build_view(|cx| { + ProjectDiagnosticsEditor::new(workspace.project().clone(), workspace_handle, cx) + }); + workspace.add_item(Box::new(diagnostics), cx); + } + } + + fn toggle_warnings(&mut self, _: &ToggleWarnings, cx: &mut ViewContext) { + self.include_warnings = !self.include_warnings; + self.paths_to_update = self.current_diagnostics.clone(); + self.update_excerpts(None, cx); + cx.notify(); + } + + fn update_excerpts( + &mut self, + language_server_id: Option, + cx: &mut ViewContext, + ) { + log::debug!("Updating excerpts for server {language_server_id:?}"); + let mut paths_to_recheck = HashSet::default(); + let mut new_summaries: HashMap> = self + .project + .read(cx) + .diagnostic_summaries(cx) + .fold(HashMap::default(), |mut summaries, (path, server_id, _)| { + summaries.entry(server_id).or_default().insert(path); + summaries + }); + let mut old_diagnostics = if let Some(language_server_id) = language_server_id { + new_summaries.retain(|server_id, _| server_id == &language_server_id); + self.paths_to_update.retain(|server_id, paths| { + if server_id == &language_server_id { + paths_to_recheck.extend(paths.drain()); + false + } else { + true + } + }); + let mut old_diagnostics = HashMap::default(); + if let Some(new_paths) = new_summaries.get(&language_server_id) { + if let Some(old_paths) = self + .current_diagnostics + .insert(language_server_id, new_paths.clone()) + { + old_diagnostics.insert(language_server_id, old_paths); + } + } else { + if let Some(old_paths) = self.current_diagnostics.remove(&language_server_id) { + old_diagnostics.insert(language_server_id, old_paths); + } + } + old_diagnostics + } else { + paths_to_recheck.extend(self.paths_to_update.drain().flat_map(|(_, paths)| paths)); + mem::replace(&mut self.current_diagnostics, new_summaries.clone()) + }; + for (server_id, new_paths) in new_summaries { + match old_diagnostics.remove(&server_id) { + Some(mut old_paths) => { + paths_to_recheck.extend( + new_paths + .into_iter() + .filter(|new_path| !old_paths.remove(new_path)), + ); + paths_to_recheck.extend(old_paths); + } + None => paths_to_recheck.extend(new_paths), + } + } + paths_to_recheck.extend(old_diagnostics.into_iter().flat_map(|(_, paths)| paths)); + + if paths_to_recheck.is_empty() { + log::debug!("No paths to recheck for language server {language_server_id:?}"); + return; + } + log::debug!( + "Rechecking {} paths for language server {:?}", + paths_to_recheck.len(), + language_server_id + ); + let project = self.project.clone(); + cx.spawn(|this, mut cx| { + async move { + let _: Vec<()> = try_join_all(paths_to_recheck.into_iter().map(|path| { + let mut cx = cx.clone(); + let project = project.clone(); + let this = this.clone(); + async move { + let buffer = project + .update(&mut cx, |project, cx| project.open_buffer(path.clone(), cx))? + .await + .with_context(|| format!("opening buffer for path {path:?}"))?; + this.update(&mut cx, |this, cx| { + this.populate_excerpts(path, language_server_id, buffer, cx); + }) + .context("missing project")?; + anyhow::Ok(()) + } + })) + .await + .context("rechecking diagnostics for paths")?; + + this.update(&mut cx, |this, cx| { + this.summary = this.project.read(cx).diagnostic_summary(cx); + cx.emit(ItemEvent::UpdateTab); + cx.emit(ItemEvent::UpdateBreadcrumbs); + })?; + anyhow::Ok(()) + } + .log_err() + }) + .detach(); + } + + fn populate_excerpts( + &mut self, + path: ProjectPath, + language_server_id: Option, + buffer: Model, + cx: &mut ViewContext, + ) { + let was_empty = self.path_states.is_empty(); + let snapshot = buffer.read(cx).snapshot(); + let path_ix = match self.path_states.binary_search_by_key(&&path, |e| &e.path) { + Ok(ix) => ix, + Err(ix) => { + self.path_states.insert( + ix, + PathState { + path: path.clone(), + diagnostic_groups: Default::default(), + }, + ); + ix + } + }; + + let mut prev_excerpt_id = if path_ix > 0 { + let prev_path_last_group = &self.path_states[path_ix - 1] + .diagnostic_groups + .last() + .unwrap(); + prev_path_last_group.excerpts.last().unwrap().clone() + } else { + ExcerptId::min() + }; + + let path_state = &mut self.path_states[path_ix]; + let mut groups_to_add = Vec::new(); + let mut group_ixs_to_remove = Vec::new(); + let mut blocks_to_add = Vec::new(); + let mut blocks_to_remove = HashSet::default(); + let mut first_excerpt_id = None; + let max_severity = if self.include_warnings { + DiagnosticSeverity::WARNING + } else { + DiagnosticSeverity::ERROR + }; + let excerpts_snapshot = self.excerpts.update(cx, |excerpts, excerpts_cx| { + let mut old_groups = path_state.diagnostic_groups.iter().enumerate().peekable(); + let mut new_groups = snapshot + .diagnostic_groups(language_server_id) + .into_iter() + .filter(|(_, group)| { + group.entries[group.primary_ix].diagnostic.severity <= max_severity + }) + .peekable(); + loop { + let mut to_insert = None; + let mut to_remove = None; + let mut to_keep = None; + match (old_groups.peek(), new_groups.peek()) { + (None, None) => break, + (None, Some(_)) => to_insert = new_groups.next(), + (Some((_, old_group)), None) => { + if language_server_id.map_or(true, |id| id == old_group.language_server_id) + { + to_remove = old_groups.next(); + } else { + to_keep = old_groups.next(); + } + } + (Some((_, old_group)), Some((_, new_group))) => { + let old_primary = &old_group.primary_diagnostic; + let new_primary = &new_group.entries[new_group.primary_ix]; + match compare_diagnostics(old_primary, new_primary, &snapshot) { + Ordering::Less => { + if language_server_id + .map_or(true, |id| id == old_group.language_server_id) + { + to_remove = old_groups.next(); + } else { + to_keep = old_groups.next(); + } + } + Ordering::Equal => { + to_keep = old_groups.next(); + new_groups.next(); + } + Ordering::Greater => to_insert = new_groups.next(), + } + } + } + + if let Some((language_server_id, group)) = to_insert { + let mut group_state = DiagnosticGroupState { + language_server_id, + primary_diagnostic: group.entries[group.primary_ix].clone(), + primary_excerpt_ix: 0, + excerpts: Default::default(), + blocks: Default::default(), + block_count: 0, + }; + let mut pending_range: Option<(Range, usize)> = None; + let mut is_first_excerpt_for_group = true; + for (ix, entry) in group.entries.iter().map(Some).chain([None]).enumerate() { + let resolved_entry = entry.map(|e| e.resolve::(&snapshot)); + if let Some((range, start_ix)) = &mut pending_range { + if let Some(entry) = resolved_entry.as_ref() { + if entry.range.start.row + <= range.end.row + 1 + CONTEXT_LINE_COUNT * 2 + { + range.end = range.end.max(entry.range.end); + continue; + } + } + + let excerpt_start = + Point::new(range.start.row.saturating_sub(CONTEXT_LINE_COUNT), 0); + let excerpt_end = snapshot.clip_point( + Point::new(range.end.row + CONTEXT_LINE_COUNT, u32::MAX), + Bias::Left, + ); + let excerpt_id = excerpts + .insert_excerpts_after( + prev_excerpt_id, + buffer.clone(), + [ExcerptRange { + context: excerpt_start..excerpt_end, + primary: Some(range.clone()), + }], + excerpts_cx, + ) + .pop() + .unwrap(); + + prev_excerpt_id = excerpt_id.clone(); + first_excerpt_id.get_or_insert_with(|| prev_excerpt_id.clone()); + group_state.excerpts.push(excerpt_id.clone()); + let header_position = (excerpt_id.clone(), language::Anchor::MIN); + + if is_first_excerpt_for_group { + is_first_excerpt_for_group = false; + let mut primary = + group.entries[group.primary_ix].diagnostic.clone(); + primary.message = + primary.message.split('\n').next().unwrap().to_string(); + group_state.block_count += 1; + blocks_to_add.push(BlockProperties { + position: header_position, + height: 2, + style: BlockStyle::Sticky, + render: diagnostic_header_renderer(primary), + disposition: BlockDisposition::Above, + }); + } + + for entry in &group.entries[*start_ix..ix] { + let mut diagnostic = entry.diagnostic.clone(); + if diagnostic.is_primary { + group_state.primary_excerpt_ix = group_state.excerpts.len() - 1; + diagnostic.message = + entry.diagnostic.message.split('\n').skip(1).collect(); + } + + if !diagnostic.message.is_empty() { + group_state.block_count += 1; + blocks_to_add.push(BlockProperties { + position: (excerpt_id.clone(), entry.range.start), + height: diagnostic.message.matches('\n').count() as u8 + 1, + style: BlockStyle::Fixed, + render: diagnostic_block_renderer(diagnostic, true), + disposition: BlockDisposition::Below, + }); + } + } + + pending_range.take(); + } + + if let Some(entry) = resolved_entry { + pending_range = Some((entry.range.clone(), ix)); + } + } + + groups_to_add.push(group_state); + } else if let Some((group_ix, group_state)) = to_remove { + excerpts.remove_excerpts(group_state.excerpts.iter().copied(), excerpts_cx); + group_ixs_to_remove.push(group_ix); + blocks_to_remove.extend(group_state.blocks.iter().copied()); + } else if let Some((_, group)) = to_keep { + prev_excerpt_id = group.excerpts.last().unwrap().clone(); + first_excerpt_id.get_or_insert_with(|| prev_excerpt_id.clone()); + } + } + + excerpts.snapshot(excerpts_cx) + }); + + self.editor.update(cx, |editor, cx| { + editor.remove_blocks(blocks_to_remove, None, cx); + let block_ids = editor.insert_blocks( + blocks_to_add.into_iter().map(|block| { + let (excerpt_id, text_anchor) = block.position; + BlockProperties { + position: excerpts_snapshot.anchor_in_excerpt(excerpt_id, text_anchor), + height: block.height, + style: block.style, + render: block.render, + disposition: block.disposition, + } + }), + Some(Autoscroll::fit()), + cx, + ); + + let mut block_ids = block_ids.into_iter(); + for group_state in &mut groups_to_add { + group_state.blocks = block_ids.by_ref().take(group_state.block_count).collect(); + } + }); + + for ix in group_ixs_to_remove.into_iter().rev() { + path_state.diagnostic_groups.remove(ix); + } + path_state.diagnostic_groups.extend(groups_to_add); + path_state.diagnostic_groups.sort_unstable_by(|a, b| { + let range_a = &a.primary_diagnostic.range; + let range_b = &b.primary_diagnostic.range; + range_a + .start + .cmp(&range_b.start, &snapshot) + .then_with(|| range_a.end.cmp(&range_b.end, &snapshot)) + }); + + if path_state.diagnostic_groups.is_empty() { + self.path_states.remove(path_ix); + } + + self.editor.update(cx, |editor, cx| { + let groups; + let mut selections; + let new_excerpt_ids_by_selection_id; + if was_empty { + groups = self.path_states.first()?.diagnostic_groups.as_slice(); + new_excerpt_ids_by_selection_id = [(0, ExcerptId::min())].into_iter().collect(); + selections = vec![Selection { + id: 0, + start: 0, + end: 0, + reversed: false, + goal: SelectionGoal::None, + }]; + } else { + groups = self.path_states.get(path_ix)?.diagnostic_groups.as_slice(); + new_excerpt_ids_by_selection_id = + editor.change_selections(Some(Autoscroll::fit()), cx, |s| s.refresh()); + selections = editor.selections.all::(cx); + } + + // If any selection has lost its position, move it to start of the next primary diagnostic. + let snapshot = editor.snapshot(cx); + for selection in &mut selections { + if let Some(new_excerpt_id) = new_excerpt_ids_by_selection_id.get(&selection.id) { + let group_ix = match groups.binary_search_by(|probe| { + probe + .excerpts + .last() + .unwrap() + .cmp(new_excerpt_id, &snapshot.buffer_snapshot) + }) { + Ok(ix) | Err(ix) => ix, + }; + if let Some(group) = groups.get(group_ix) { + let offset = excerpts_snapshot + .anchor_in_excerpt( + group.excerpts[group.primary_excerpt_ix].clone(), + group.primary_diagnostic.range.start, + ) + .to_offset(&excerpts_snapshot); + selection.start = offset; + selection.end = offset; + } + } + } + editor.change_selections(None, cx, |s| { + s.select(selections); + }); + Some(()) + }); + + if self.path_states.is_empty() { + if self.editor.focus_handle(cx).is_focused(cx) { + cx.focus(&self.focus_handle); + } + } else if self.focus_handle.is_focused(cx) { + let focus_handle = self.editor.focus_handle(cx); + cx.focus(&focus_handle); + } + cx.notify(); + } +} + +impl Item for ProjectDiagnosticsEditor { + fn focus_handle(&self) -> FocusHandle { + self.focus_handle.clone() + } + + fn deactivated(&mut self, cx: &mut ViewContext) { + self.editor.update(cx, |editor, cx| editor.deactivated(cx)); + } + + fn navigate(&mut self, data: Box, cx: &mut ViewContext) -> bool { + self.editor + .update(cx, |editor, cx| editor.navigate(data, cx)) + } + + fn tab_tooltip_text(&self, _: &AppContext) -> Option { + Some("Project Diagnostics".into()) + } + + fn tab_content(&self, _detail: Option, cx: &AppContext) -> AnyElement { + render_summary(&self.summary) + } + + fn for_each_project_item( + &self, + cx: &AppContext, + f: &mut dyn FnMut(gpui::EntityId, &dyn project::Item), + ) { + self.editor.for_each_project_item(cx, f) + } + + fn is_singleton(&self, _: &AppContext) -> bool { + false + } + + fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut ViewContext) { + self.editor.update(cx, |editor, _| { + editor.set_nav_history(Some(nav_history)); + }); + } + + fn clone_on_split( + &self, + _workspace_id: workspace::WorkspaceId, + cx: &mut ViewContext, + ) -> Option> + where + Self: Sized, + { + Some(cx.build_view(|cx| { + ProjectDiagnosticsEditor::new(self.project.clone(), self.workspace.clone(), cx) + })) + } + + fn is_dirty(&self, cx: &AppContext) -> bool { + self.excerpts.read(cx).is_dirty(cx) + } + + fn has_conflict(&self, cx: &AppContext) -> bool { + self.excerpts.read(cx).has_conflict(cx) + } + + fn can_save(&self, _: &AppContext) -> bool { + true + } + + fn save(&mut self, project: Model, cx: &mut ViewContext) -> Task> { + self.editor.save(project, cx) + } + + fn save_as( + &mut self, + _: Model, + _: PathBuf, + _: &mut ViewContext, + ) -> Task> { + unreachable!() + } + + fn reload(&mut self, project: Model, cx: &mut ViewContext) -> Task> { + self.editor.reload(project, cx) + } + + fn act_as_type<'a>( + &'a self, + type_id: TypeId, + self_handle: &'a View, + _: &'a AppContext, + ) -> Option { + if type_id == TypeId::of::() { + Some(self_handle.to_any()) + } else if type_id == TypeId::of::() { + Some(self.editor.to_any()) + } else { + None + } + } + + fn breadcrumb_location(&self) -> ToolbarItemLocation { + ToolbarItemLocation::PrimaryLeft { flex: None } + } + + fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option> { + self.editor.breadcrumbs(theme, cx) + } + + fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext) { + self.editor + .update(cx, |editor, cx| editor.added_to_workspace(workspace, cx)); + } + + fn serialized_item_kind() -> Option<&'static str> { + Some("diagnostics") + } + + fn deserialize( + project: Model, + workspace: WeakView, + _workspace_id: workspace::WorkspaceId, + _item_id: workspace::ItemId, + cx: &mut ViewContext, + ) -> Task>> { + Task::ready(Ok(cx.build_view(|cx| Self::new(project, workspace, cx)))) + } +} + +fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock { + let (message, highlights) = highlight_diagnostic_message(Vec::new(), &diagnostic.message); + Arc::new(move |cx| { + let settings = ThemeSettings::get_global(cx); + div().render() + // let font_size = (style.text_scale_factor * settings.buffer_font_size(cx)).round(); + // let icon = if diagnostic.severity == DiagnosticSeverity::ERROR { + // Svg::new("icons/error.svg").with_color(theme.error_diagnostic.message.text.color) + // } else { + // Svg::new("icons/warning.svg").with_color(theme.warning_diagnostic.message.text.color) + // }; + + // Flex::row() + // .with_child( + // icon.constrained() + // .with_width(icon_width) + // .aligned() + // .contained() + // .with_margin_right(cx.gutter_padding), + // ) + // .with_children(diagnostic.source.as_ref().map(|source| { + // Label::new( + // format!("{source}: "), + // style.source.label.clone().with_font_size(font_size), + // ) + // .contained() + // .with_style(style.message.container) + // .aligned() + // })) + // .with_child( + // Label::new( + // message.clone(), + // style.message.label.clone().with_font_size(font_size), + // ) + // .with_highlights(highlights.clone()) + // .contained() + // .with_style(style.message.container) + // .aligned(), + // ) + // .with_children(diagnostic.code.clone().map(|code| { + // Label::new(code, style.code.text.clone().with_font_size(font_size)) + // .contained() + // .with_style(style.code.container) + // .aligned() + // })) + // .contained() + // .with_style(style.container) + // .with_padding_left(cx.gutter_padding) + // .with_padding_right(cx.gutter_padding) + // .expanded() + // .into_any_named("diagnostic header") + }) +} + +pub(crate) fn render_summary(summary: &DiagnosticSummary) -> AnyElement { + if summary.error_count == 0 && summary.warning_count == 0 { + Label::new("No problems").render() + } else { + div() + .bg(gpui::red()) + .child(Label::new("TODO Show warnings/errors")) + .render() + // Flex::row() + // .with_child( + // Svg::new("icons/error.svg") + // .with_color(text_style.color) + // .constrained() + // .with_width(icon_width) + // .aligned() + // .contained() + // .with_margin_right(icon_spacing), + // ) + // .with_child( + // Label::new( + // summary.error_count.to_string(), + // LabelStyle { + // text: text_style.clone(), + // highlight_text: None, + // }, + // ) + // .aligned(), + // ) + // .with_child( + // Svg::new("icons/warning.svg") + // .with_color(text_style.color) + // .constrained() + // .with_width(icon_width) + // .aligned() + // .contained() + // .with_margin_left(summary_spacing) + // .with_margin_right(icon_spacing), + // ) + // .with_child( + // Label::new( + // summary.warning_count.to_string(), + // LabelStyle { + // text: text_style.clone(), + // highlight_text: None, + // }, + // ) + // .aligned(), + // ) + // .into_any() + } +} + +fn compare_diagnostics( + lhs: &DiagnosticEntry, + rhs: &DiagnosticEntry, + snapshot: &language::BufferSnapshot, +) -> Ordering { + lhs.range + .start + .to_offset(snapshot) + .cmp(&rhs.range.start.to_offset(snapshot)) + .then_with(|| { + lhs.range + .end + .to_offset(snapshot) + .cmp(&rhs.range.end.to_offset(snapshot)) + }) + .then_with(|| lhs.diagnostic.message.cmp(&rhs.diagnostic.message)) +} + +#[cfg(test)] +mod tests { + use super::*; + use editor::{ + display_map::{BlockContext, TransformBlock}, + DisplayPoint, + }; + use gpui::{px, TestAppContext, WindowContext}; + use language::{Diagnostic, DiagnosticEntry, DiagnosticSeverity, PointUtf16, Unclipped}; + use project::FakeFs; + use serde_json::json; + use settings::SettingsStore; + use unindent::Unindent as _; + + #[gpui::test] + async fn test_diagnostics(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/test", + json!({ + "consts.rs": " + const a: i32 = 'a'; + const b: i32 = c; + " + .unindent(), + + "main.rs": " + fn main() { + let x = vec![]; + let y = vec![]; + a(x); + b(y); + // comment 1 + // comment 2 + c(y); + d(x); + } + " + .unindent(), + }), + ) + .await; + + let language_server_id = LanguageServerId(0); + let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; + let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let workspace = window.root(cx); + + // Create some diagnostics + project.update(cx, |project, cx| { + project + .update_diagnostic_entries( + language_server_id, + PathBuf::from("/test/main.rs"), + None, + vec![ + DiagnosticEntry { + range: Unclipped(PointUtf16::new(1, 8))..Unclipped(PointUtf16::new(1, 9)), + diagnostic: Diagnostic { + message: + "move occurs because `x` has type `Vec`, which does not implement the `Copy` trait" + .to_string(), + severity: DiagnosticSeverity::INFORMATION, + is_primary: false, + is_disk_based: true, + group_id: 1, + ..Default::default() + }, + }, + DiagnosticEntry { + range: Unclipped(PointUtf16::new(2, 8))..Unclipped(PointUtf16::new(2, 9)), + diagnostic: Diagnostic { + message: + "move occurs because `y` has type `Vec`, which does not implement the `Copy` trait" + .to_string(), + severity: DiagnosticSeverity::INFORMATION, + is_primary: false, + is_disk_based: true, + group_id: 0, + ..Default::default() + }, + }, + DiagnosticEntry { + range: Unclipped(PointUtf16::new(3, 6))..Unclipped(PointUtf16::new(3, 7)), + diagnostic: Diagnostic { + message: "value moved here".to_string(), + severity: DiagnosticSeverity::INFORMATION, + is_primary: false, + is_disk_based: true, + group_id: 1, + ..Default::default() + }, + }, + DiagnosticEntry { + range: Unclipped(PointUtf16::new(4, 6))..Unclipped(PointUtf16::new(4, 7)), + diagnostic: Diagnostic { + message: "value moved here".to_string(), + severity: DiagnosticSeverity::INFORMATION, + is_primary: false, + is_disk_based: true, + group_id: 0, + ..Default::default() + }, + }, + DiagnosticEntry { + range: Unclipped(PointUtf16::new(7, 6))..Unclipped(PointUtf16::new(7, 7)), + diagnostic: Diagnostic { + message: "use of moved value\nvalue used here after move".to_string(), + severity: DiagnosticSeverity::ERROR, + is_primary: true, + is_disk_based: true, + group_id: 0, + ..Default::default() + }, + }, + DiagnosticEntry { + range: Unclipped(PointUtf16::new(8, 6))..Unclipped(PointUtf16::new(8, 7)), + diagnostic: Diagnostic { + message: "use of moved value\nvalue used here after move".to_string(), + severity: DiagnosticSeverity::ERROR, + is_primary: true, + is_disk_based: true, + group_id: 1, + ..Default::default() + }, + }, + ], + cx, + ) + .unwrap(); + }); + + // Open the project diagnostics view while there are already diagnostics. + let view = window.build_view(cx, |cx| { + ProjectDiagnosticsEditor::new(project.clone(), workspace.downgrade(), cx) + }); + + view.next_notification(cx).await; + view.update(cx, |view, cx| { + assert_eq!( + editor_blocks(&view.editor, cx), + [ + (0, "path header block".into()), + (2, "diagnostic header".into()), + (15, "collapsed context".into()), + (16, "diagnostic header".into()), + (25, "collapsed context".into()), + ] + ); + assert_eq!( + view.editor.update(cx, |editor, cx| editor.display_text(cx)), + concat!( + // + // main.rs + // + "\n", // filename + "\n", // padding + // diagnostic group 1 + "\n", // primary message + "\n", // padding + " let x = vec![];\n", + " let y = vec![];\n", + "\n", // supporting diagnostic + " a(x);\n", + " b(y);\n", + "\n", // supporting diagnostic + " // comment 1\n", + " // comment 2\n", + " c(y);\n", + "\n", // supporting diagnostic + " d(x);\n", + "\n", // context ellipsis + // diagnostic group 2 + "\n", // primary message + "\n", // padding + "fn main() {\n", + " let x = vec![];\n", + "\n", // supporting diagnostic + " let y = vec![];\n", + " a(x);\n", + "\n", // supporting diagnostic + " b(y);\n", + "\n", // context ellipsis + " c(y);\n", + " d(x);\n", + "\n", // supporting diagnostic + "}" + ) + ); + + // Cursor is at the first diagnostic + view.editor.update(cx, |editor, cx| { + assert_eq!( + editor.selections.display_ranges(cx), + [DisplayPoint::new(12, 6)..DisplayPoint::new(12, 6)] + ); + }); + }); + + // Diagnostics are added for another earlier path. + project.update(cx, |project, cx| { + project.disk_based_diagnostics_started(language_server_id, cx); + project + .update_diagnostic_entries( + language_server_id, + PathBuf::from("/test/consts.rs"), + None, + vec![DiagnosticEntry { + range: Unclipped(PointUtf16::new(0, 15))..Unclipped(PointUtf16::new(0, 15)), + diagnostic: Diagnostic { + message: "mismatched types\nexpected `usize`, found `char`".to_string(), + severity: DiagnosticSeverity::ERROR, + is_primary: true, + is_disk_based: true, + group_id: 0, + ..Default::default() + }, + }], + cx, + ) + .unwrap(); + project.disk_based_diagnostics_finished(language_server_id, cx); + }); + + view.next_notification(cx).await; + view.update(cx, |view, cx| { + assert_eq!( + editor_blocks(&view.editor, cx), + [ + (0, "path header block".into()), + (2, "diagnostic header".into()), + (7, "path header block".into()), + (9, "diagnostic header".into()), + (22, "collapsed context".into()), + (23, "diagnostic header".into()), + (32, "collapsed context".into()), + ] + ); + assert_eq!( + view.editor.update(cx, |editor, cx| editor.display_text(cx)), + concat!( + // + // consts.rs + // + "\n", // filename + "\n", // padding + // diagnostic group 1 + "\n", // primary message + "\n", // padding + "const a: i32 = 'a';\n", + "\n", // supporting diagnostic + "const b: i32 = c;\n", + // + // main.rs + // + "\n", // filename + "\n", // padding + // diagnostic group 1 + "\n", // primary message + "\n", // padding + " let x = vec![];\n", + " let y = vec![];\n", + "\n", // supporting diagnostic + " a(x);\n", + " b(y);\n", + "\n", // supporting diagnostic + " // comment 1\n", + " // comment 2\n", + " c(y);\n", + "\n", // supporting diagnostic + " d(x);\n", + "\n", // collapsed context + // diagnostic group 2 + "\n", // primary message + "\n", // filename + "fn main() {\n", + " let x = vec![];\n", + "\n", // supporting diagnostic + " let y = vec![];\n", + " a(x);\n", + "\n", // supporting diagnostic + " b(y);\n", + "\n", // context ellipsis + " c(y);\n", + " d(x);\n", + "\n", // supporting diagnostic + "}" + ) + ); + + // Cursor keeps its position. + view.editor.update(cx, |editor, cx| { + assert_eq!( + editor.selections.display_ranges(cx), + [DisplayPoint::new(19, 6)..DisplayPoint::new(19, 6)] + ); + }); + }); + + // Diagnostics are added to the first path + project.update(cx, |project, cx| { + project.disk_based_diagnostics_started(language_server_id, cx); + project + .update_diagnostic_entries( + language_server_id, + PathBuf::from("/test/consts.rs"), + None, + vec![ + DiagnosticEntry { + range: Unclipped(PointUtf16::new(0, 15)) + ..Unclipped(PointUtf16::new(0, 15)), + diagnostic: Diagnostic { + message: "mismatched types\nexpected `usize`, found `char`" + .to_string(), + severity: DiagnosticSeverity::ERROR, + is_primary: true, + is_disk_based: true, + group_id: 0, + ..Default::default() + }, + }, + DiagnosticEntry { + range: Unclipped(PointUtf16::new(1, 15)) + ..Unclipped(PointUtf16::new(1, 15)), + diagnostic: Diagnostic { + message: "unresolved name `c`".to_string(), + severity: DiagnosticSeverity::ERROR, + is_primary: true, + is_disk_based: true, + group_id: 1, + ..Default::default() + }, + }, + ], + cx, + ) + .unwrap(); + project.disk_based_diagnostics_finished(language_server_id, cx); + }); + + view.next_notification(cx).await; + view.update(cx, |view, cx| { + assert_eq!( + editor_blocks(&view.editor, cx), + [ + (0, "path header block".into()), + (2, "diagnostic header".into()), + (7, "collapsed context".into()), + (8, "diagnostic header".into()), + (13, "path header block".into()), + (15, "diagnostic header".into()), + (28, "collapsed context".into()), + (29, "diagnostic header".into()), + (38, "collapsed context".into()), + ] + ); + assert_eq!( + view.editor.update(cx, |editor, cx| editor.display_text(cx)), + concat!( + // + // consts.rs + // + "\n", // filename + "\n", // padding + // diagnostic group 1 + "\n", // primary message + "\n", // padding + "const a: i32 = 'a';\n", + "\n", // supporting diagnostic + "const b: i32 = c;\n", + "\n", // context ellipsis + // diagnostic group 2 + "\n", // primary message + "\n", // padding + "const a: i32 = 'a';\n", + "const b: i32 = c;\n", + "\n", // supporting diagnostic + // + // main.rs + // + "\n", // filename + "\n", // padding + // diagnostic group 1 + "\n", // primary message + "\n", // padding + " let x = vec![];\n", + " let y = vec![];\n", + "\n", // supporting diagnostic + " a(x);\n", + " b(y);\n", + "\n", // supporting diagnostic + " // comment 1\n", + " // comment 2\n", + " c(y);\n", + "\n", // supporting diagnostic + " d(x);\n", + "\n", // context ellipsis + // diagnostic group 2 + "\n", // primary message + "\n", // filename + "fn main() {\n", + " let x = vec![];\n", + "\n", // supporting diagnostic + " let y = vec![];\n", + " a(x);\n", + "\n", // supporting diagnostic + " b(y);\n", + "\n", // context ellipsis + " c(y);\n", + " d(x);\n", + "\n", // supporting diagnostic + "}" + ) + ); + }); + } + + #[gpui::test] + async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/test", + json!({ + "main.js": " + a(); + b(); + c(); + d(); + e(); + ".unindent() + }), + ) + .await; + + let server_id_1 = LanguageServerId(100); + let server_id_2 = LanguageServerId(101); + let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; + let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let workspace = window.root(cx); + + let view = window.build_view(cx, |cx| { + ProjectDiagnosticsEditor::new(project.clone(), workspace.downgrade(), cx) + }); + + // Two language servers start updating diagnostics + project.update(cx, |project, cx| { + project.disk_based_diagnostics_started(server_id_1, cx); + project.disk_based_diagnostics_started(server_id_2, cx); + project + .update_diagnostic_entries( + server_id_1, + PathBuf::from("/test/main.js"), + None, + vec![DiagnosticEntry { + range: Unclipped(PointUtf16::new(0, 0))..Unclipped(PointUtf16::new(0, 1)), + diagnostic: Diagnostic { + message: "error 1".to_string(), + severity: DiagnosticSeverity::WARNING, + is_primary: true, + is_disk_based: true, + group_id: 1, + ..Default::default() + }, + }], + cx, + ) + .unwrap(); + }); + + // The first language server finishes + project.update(cx, |project, cx| { + project.disk_based_diagnostics_finished(server_id_1, cx); + }); + + // Only the first language server's diagnostics are shown. + cx.executor().run_until_parked(); + view.update(cx, |view, cx| { + assert_eq!( + editor_blocks(&view.editor, cx), + [ + (0, "path header block".into()), + (2, "diagnostic header".into()), + ] + ); + assert_eq!( + view.editor.update(cx, |editor, cx| editor.display_text(cx)), + concat!( + "\n", // filename + "\n", // padding + // diagnostic group 1 + "\n", // primary message + "\n", // padding + "a();\n", // + "b();", + ) + ); + }); + + // The second language server finishes + project.update(cx, |project, cx| { + project + .update_diagnostic_entries( + server_id_2, + PathBuf::from("/test/main.js"), + None, + vec![DiagnosticEntry { + range: Unclipped(PointUtf16::new(1, 0))..Unclipped(PointUtf16::new(1, 1)), + diagnostic: Diagnostic { + message: "warning 1".to_string(), + severity: DiagnosticSeverity::ERROR, + is_primary: true, + is_disk_based: true, + group_id: 2, + ..Default::default() + }, + }], + cx, + ) + .unwrap(); + project.disk_based_diagnostics_finished(server_id_2, cx); + }); + + // Both language server's diagnostics are shown. + cx.executor().run_until_parked(); + view.update(cx, |view, cx| { + assert_eq!( + editor_blocks(&view.editor, cx), + [ + (0, "path header block".into()), + (2, "diagnostic header".into()), + (6, "collapsed context".into()), + (7, "diagnostic header".into()), + ] + ); + assert_eq!( + view.editor.update(cx, |editor, cx| editor.display_text(cx)), + concat!( + "\n", // filename + "\n", // padding + // diagnostic group 1 + "\n", // primary message + "\n", // padding + "a();\n", // location + "b();\n", // + "\n", // collapsed context + // diagnostic group 2 + "\n", // primary message + "\n", // padding + "a();\n", // context + "b();\n", // + "c();", // context + ) + ); + }); + + // Both language servers start updating diagnostics, and the first server finishes. + project.update(cx, |project, cx| { + project.disk_based_diagnostics_started(server_id_1, cx); + project.disk_based_diagnostics_started(server_id_2, cx); + project + .update_diagnostic_entries( + server_id_1, + PathBuf::from("/test/main.js"), + None, + vec![DiagnosticEntry { + range: Unclipped(PointUtf16::new(2, 0))..Unclipped(PointUtf16::new(2, 1)), + diagnostic: Diagnostic { + message: "warning 2".to_string(), + severity: DiagnosticSeverity::WARNING, + is_primary: true, + is_disk_based: true, + group_id: 1, + ..Default::default() + }, + }], + cx, + ) + .unwrap(); + project + .update_diagnostic_entries( + server_id_2, + PathBuf::from("/test/main.rs"), + None, + vec![], + cx, + ) + .unwrap(); + project.disk_based_diagnostics_finished(server_id_1, cx); + }); + + // Only the first language server's diagnostics are updated. + cx.executor().run_until_parked(); + view.update(cx, |view, cx| { + assert_eq!( + editor_blocks(&view.editor, cx), + [ + (0, "path header block".into()), + (2, "diagnostic header".into()), + (7, "collapsed context".into()), + (8, "diagnostic header".into()), + ] + ); + assert_eq!( + view.editor.update(cx, |editor, cx| editor.display_text(cx)), + concat!( + "\n", // filename + "\n", // padding + // diagnostic group 1 + "\n", // primary message + "\n", // padding + "a();\n", // location + "b();\n", // + "c();\n", // context + "\n", // collapsed context + // diagnostic group 2 + "\n", // primary message + "\n", // padding + "b();\n", // context + "c();\n", // + "d();", // context + ) + ); + }); + + // The second language server finishes. + project.update(cx, |project, cx| { + project + .update_diagnostic_entries( + server_id_2, + PathBuf::from("/test/main.js"), + None, + vec![DiagnosticEntry { + range: Unclipped(PointUtf16::new(3, 0))..Unclipped(PointUtf16::new(3, 1)), + diagnostic: Diagnostic { + message: "warning 2".to_string(), + severity: DiagnosticSeverity::WARNING, + is_primary: true, + is_disk_based: true, + group_id: 1, + ..Default::default() + }, + }], + cx, + ) + .unwrap(); + project.disk_based_diagnostics_finished(server_id_2, cx); + }); + + // Both language servers' diagnostics are updated. + cx.executor().run_until_parked(); + view.update(cx, |view, cx| { + assert_eq!( + editor_blocks(&view.editor, cx), + [ + (0, "path header block".into()), + (2, "diagnostic header".into()), + (7, "collapsed context".into()), + (8, "diagnostic header".into()), + ] + ); + assert_eq!( + view.editor.update(cx, |editor, cx| editor.display_text(cx)), + concat!( + "\n", // filename + "\n", // padding + // diagnostic group 1 + "\n", // primary message + "\n", // padding + "b();\n", // location + "c();\n", // + "d();\n", // context + "\n", // collapsed context + // diagnostic group 2 + "\n", // primary message + "\n", // padding + "c();\n", // context + "d();\n", // + "e();", // context + ) + ); + }); + } + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + cx.set_global(SettingsStore::test(cx)); + theme::init(cx); + language::init(cx); + client::init_settings(cx); + workspace::init_settings(cx); + Project::init_settings(cx); + crate::init(cx); + }); + } + + fn editor_blocks(editor: &View, cx: &mut WindowContext) -> Vec<(u32, SharedString)> { + editor.update(cx, |editor, cx| { + let snapshot = editor.snapshot(cx); + snapshot + .blocks_in_range(0..snapshot.max_point().row()) + .enumerate() + .filter_map(|(ix, (row, block))| { + let name = match block { + TransformBlock::Custom(block) => block + .render(&mut BlockContext { + view_context: cx, + anchor_x: px(0.), + gutter_padding: px(0.), + gutter_width: px(0.), + line_height: px(0.), + em_width: px(0.), + block_id: ix, + }) + .element_id()? + .try_into() + .expect("All blocks must have string ID"), + + TransformBlock::ExcerptHeader { + starts_new_buffer, .. + } => { + if *starts_new_buffer { + "path header block".into() + } else { + "collapsed context".into() + } + } + }; + + Some((row, name)) + }) + .collect() + }) + } +} diff --git a/crates/diagnostics2/src/items.rs b/crates/diagnostics2/src/items.rs new file mode 100644 index 0000000000..2353267303 --- /dev/null +++ b/crates/diagnostics2/src/items.rs @@ -0,0 +1,251 @@ +use collections::HashSet; +use editor::{Editor, GoToDiagnostic}; +use gpui::{ + div, serde_json, AppContext, CursorStyle, Div, Entity, EventEmitter, MouseButton, Render, + Styled, Subscription, View, ViewContext, WeakView, +}; +use language::Diagnostic; +use lsp::LanguageServerId; +use workspace::{item::ItemHandle, StatusItemView, ToolbarItemEvent, Workspace}; + +use crate::ProjectDiagnosticsEditor; + +// todo!() +// pub fn init(cx: &mut AppContext) { +// cx.add_action(DiagnosticIndicator::go_to_next_diagnostic); +// } + +pub struct DiagnosticIndicator { + summary: project::DiagnosticSummary, + active_editor: Option>, + workspace: WeakView, + current_diagnostic: Option, + in_progress_checks: HashSet, + _observe_active_editor: Option, +} + +impl Render for DiagnosticIndicator { + type Element = Div; + + fn render(&mut self, cx: &mut ViewContext) -> Self::Element { + div().size_full().bg(gpui::red()) + } +} + +impl DiagnosticIndicator { + pub fn new(workspace: &Workspace, cx: &mut ViewContext) -> Self { + let project = workspace.project(); + cx.subscribe(project, |this, project, event, cx| match event { + project::Event::DiskBasedDiagnosticsStarted { language_server_id } => { + this.in_progress_checks.insert(*language_server_id); + cx.notify(); + } + project::Event::DiskBasedDiagnosticsFinished { language_server_id } + | project::Event::LanguageServerRemoved(language_server_id) => { + this.summary = project.read(cx).diagnostic_summary(cx); + this.in_progress_checks.remove(language_server_id); + cx.notify(); + } + project::Event::DiagnosticsUpdated { .. } => { + this.summary = project.read(cx).diagnostic_summary(cx); + cx.notify(); + } + _ => {} + }) + .detach(); + Self { + summary: project.read(cx).diagnostic_summary(cx), + in_progress_checks: project + .read(cx) + .language_servers_running_disk_based_diagnostics() + .collect(), + active_editor: None, + workspace: workspace.weak_handle(), + current_diagnostic: None, + _observe_active_editor: None, + } + } + + fn go_to_next_diagnostic(&mut self, _: &GoToDiagnostic, cx: &mut ViewContext) { + if let Some(editor) = self.active_editor.as_ref().and_then(|e| e.upgrade()) { + editor.update(cx, |editor, cx| { + editor.go_to_diagnostic_impl(editor::Direction::Next, cx); + }) + } + } + + fn update(&mut self, editor: View, cx: &mut ViewContext) { + let editor = editor.read(cx); + let buffer = editor.buffer().read(cx); + let cursor_position = editor.selections.newest::(cx).head(); + let new_diagnostic = buffer + .snapshot(cx) + .diagnostics_in_range::<_, usize>(cursor_position..cursor_position, false) + .filter(|entry| !entry.range.is_empty()) + .min_by_key(|entry| (entry.diagnostic.severity, entry.range.len())) + .map(|entry| entry.diagnostic); + if new_diagnostic != self.current_diagnostic { + self.current_diagnostic = new_diagnostic; + cx.notify(); + } + } +} + +// todo: is this nessesary anymore? +impl EventEmitter for DiagnosticIndicator {} + +// impl View for DiagnosticIndicator { +// fn ui_name() -> &'static str { +// "DiagnosticIndicator" +// } + +// fn render(&mut self, cx: &mut ViewContext) -> AnyElement { +// enum Summary {} +// enum Message {} + +// let tooltip_style = theme::current(cx).tooltip.clone(); +// let in_progress = !self.in_progress_checks.is_empty(); +// let mut element = Flex::row().with_child( +// MouseEventHandler::new::(0, cx, |state, cx| { +// let theme = theme::current(cx); +// let style = theme +// .workspace +// .status_bar +// .diagnostic_summary +// .style_for(state); + +// let mut summary_row = Flex::row(); +// if self.summary.error_count > 0 { +// summary_row.add_child( +// Svg::new("icons/error.svg") +// .with_color(style.icon_color_error) +// .constrained() +// .with_width(style.icon_width) +// .aligned() +// .contained() +// .with_margin_right(style.icon_spacing), +// ); +// summary_row.add_child( +// Label::new(self.summary.error_count.to_string(), style.text.clone()) +// .aligned(), +// ); +// } + +// if self.summary.warning_count > 0 { +// summary_row.add_child( +// Svg::new("icons/warning.svg") +// .with_color(style.icon_color_warning) +// .constrained() +// .with_width(style.icon_width) +// .aligned() +// .contained() +// .with_margin_right(style.icon_spacing) +// .with_margin_left(if self.summary.error_count > 0 { +// style.summary_spacing +// } else { +// 0. +// }), +// ); +// summary_row.add_child( +// Label::new(self.summary.warning_count.to_string(), style.text.clone()) +// .aligned(), +// ); +// } + +// if self.summary.error_count == 0 && self.summary.warning_count == 0 { +// summary_row.add_child( +// Svg::new("icons/check_circle.svg") +// .with_color(style.icon_color_ok) +// .constrained() +// .with_width(style.icon_width) +// .aligned() +// .into_any_named("ok-icon"), +// ); +// } + +// summary_row +// .constrained() +// .with_height(style.height) +// .contained() +// .with_style(if self.summary.error_count > 0 { +// style.container_error +// } else if self.summary.warning_count > 0 { +// style.container_warning +// } else { +// style.container_ok +// }) +// }) +// .with_cursor_style(CursorStyle::PointingHand) +// .on_click(MouseButton::Left, |_, this, cx| { +// if let Some(workspace) = this.workspace.upgrade(cx) { +// workspace.update(cx, |workspace, cx| { +// ProjectDiagnosticsEditor::deploy(workspace, &Default::default(), cx) +// }) +// } +// }) +// .with_tooltip::( +// 0, +// "Project Diagnostics", +// Some(Box::new(crate::Deploy)), +// tooltip_style, +// cx, +// ) +// .aligned() +// .into_any(), +// ); + +// let style = &theme::current(cx).workspace.status_bar; +// let item_spacing = style.item_spacing; + +// if in_progress { +// element.add_child( +// Label::new("Checking…", style.diagnostic_message.default.text.clone()) +// .aligned() +// .contained() +// .with_margin_left(item_spacing), +// ); +// } else if let Some(diagnostic) = &self.current_diagnostic { +// let message_style = style.diagnostic_message.clone(); +// element.add_child( +// MouseEventHandler::new::(1, cx, |state, _| { +// Label::new( +// diagnostic.message.split('\n').next().unwrap().to_string(), +// message_style.style_for(state).text.clone(), +// ) +// .aligned() +// .contained() +// .with_margin_left(item_spacing) +// }) +// .with_cursor_style(CursorStyle::PointingHand) +// .on_click(MouseButton::Left, |_, this, cx| { +// this.go_to_next_diagnostic(&Default::default(), cx) +// }), +// ); +// } + +// element.into_any_named("diagnostic indicator") +// } + +// fn debug_json(&self, _: &gpui::AppContext) -> serde_json::Value { +// serde_json::json!({ "summary": self.summary }) +// } +// } + +impl StatusItemView for DiagnosticIndicator { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + cx: &mut ViewContext, + ) { + if let Some(editor) = active_pane_item.and_then(|item| item.downcast::()) { + self.active_editor = Some(editor.downgrade()); + self._observe_active_editor = Some(cx.observe(&editor, Self::update)); + self.update(editor, cx); + } else { + self.active_editor = None; + self.current_diagnostic = None; + self._observe_active_editor = None; + } + cx.notify(); + } +} diff --git a/crates/diagnostics2/src/project_diagnostics_settings.rs b/crates/diagnostics2/src/project_diagnostics_settings.rs new file mode 100644 index 0000000000..f762d2b1e6 --- /dev/null +++ b/crates/diagnostics2/src/project_diagnostics_settings.rs @@ -0,0 +1,28 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Debug)] +pub struct ProjectDiagnosticsSettings { + pub include_warnings: bool, +} + +#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] +pub struct ProjectDiagnosticsSettingsContent { + include_warnings: Option, +} + +impl settings::Settings for ProjectDiagnosticsSettings { + const KEY: Option<&'static str> = Some("diagnostics"); + type FileContent = ProjectDiagnosticsSettingsContent; + + fn load( + default_value: &Self::FileContent, + user_values: &[&Self::FileContent], + _cx: &mut gpui::AppContext, + ) -> anyhow::Result + where + Self: Sized, + { + Self::load_via_json_merge(default_value, user_values) + } +} diff --git a/crates/diagnostics2/src/toolbar_controls.rs b/crates/diagnostics2/src/toolbar_controls.rs new file mode 100644 index 0000000000..e60ded4016 --- /dev/null +++ b/crates/diagnostics2/src/toolbar_controls.rs @@ -0,0 +1,123 @@ +use crate::{ProjectDiagnosticsEditor, ToggleWarnings}; +use gpui::{ + div, Action, CursorStyle, Div, Entity, EventEmitter, MouseButton, ParentComponent, Render, + View, ViewContext, WeakView, +}; +use ui::{Icon, IconButton, StyledExt}; +use workspace::{item::ItemHandle, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView}; + +pub struct ToolbarControls { + editor: Option>, +} + +impl Render for ToolbarControls { + type Element = Div; + + fn render(&mut self, cx: &mut ViewContext) -> Self::Element { + div() + .h_flex() + .child(IconButton::new("toggle-warnings", Icon::Warning).on_click(|view, cx| todo!())) + } +} + +impl EventEmitter for ToolbarControls {} + +// impl View for ToolbarControls { +// fn ui_name() -> &'static str { +// "ToolbarControls" +// } + +// fn render(&mut self, cx: &mut ViewContext) -> AnyElement { +// let include_warnings = self +// .editor +// .as_ref() +// .and_then(|editor| editor.upgrade(cx)) +// .map(|editor| editor.read(cx).include_warnings) +// .unwrap_or(false); +// let tooltip = if include_warnings { +// "Exclude Warnings".into() +// } else { +// "Include Warnings".into() +// }; +// Flex::row() +// .with_child(render_toggle_button( +// 0, +// "icons/warning.svg", +// include_warnings, +// (tooltip, Some(Box::new(ToggleWarnings))), +// cx, +// move |this, cx| { +// if let Some(editor) = this.editor.and_then(|editor| editor.upgrade(cx)) { +// editor.update(cx, |editor, cx| { +// editor.toggle_warnings(&Default::default(), cx) +// }); +// } +// }, +// )) +// .into_any() +// } +// } + +impl ToolbarItemView for ToolbarControls { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + _: &mut ViewContext, + ) -> ToolbarItemLocation { + if let Some(pane_item) = active_pane_item.as_ref() { + if let Some(editor) = pane_item.downcast::() { + self.editor = Some(editor.downgrade()); + ToolbarItemLocation::PrimaryRight { flex: None } + } else { + ToolbarItemLocation::Hidden + } + } else { + ToolbarItemLocation::Hidden + } + } +} + +impl ToolbarControls { + pub fn new() -> Self { + ToolbarControls { editor: None } + } +} + +// fn render_toggle_button< +// F: 'static + Fn(&mut ToolbarControls, &mut EventContext), +// >( +// index: usize, +// icon: &'static str, +// toggled: bool, +// tooltip: (String, Option>), +// cx: &mut ViewContext, +// on_click: F, +// ) -> AnyElement { +// enum Button {} + +// let theme = theme::current(cx); +// let (tooltip_text, action) = tooltip; + +// MouseEventHandler::new::(index, cx, |mouse_state, _| { +// let style = theme +// .workspace +// .toolbar +// .toggleable_tool +// .in_state(toggled) +// .style_for(mouse_state); +// Svg::new(icon) +// .with_color(style.color) +// .constrained() +// .with_width(style.icon_width) +// .aligned() +// .constrained() +// .with_width(style.button_width) +// .with_height(style.button_width) +// .contained() +// .with_style(style.container) +// }) +// .with_cursor_style(CursorStyle::PointingHand) +// .on_click(MouseButton::Left, move |_, view, cx| on_click(view, cx)) +// .with_tooltip::