From f5ebb210ac9270962dc511e3e7c56b0666ac306d Mon Sep 17 00:00:00 2001 From: Kenneth Date: Sun, 31 May 2026 00:45:25 +0100 Subject: [PATCH] feat: add dummy search bar in diff view --- Cargo.toml | 1 + src/api/issues.rs | 262 ++--- src/component/diff_view.rs | 46 +- src/component/mod.rs | 1 + src/component/text_input.rs | 911 ++++++++++++++++++ src/keyboard.rs | 9 + src/main.rs | 3 + src/screen/dashboard/mod.rs | 2 +- .../dashboard/pull_request_change_view.rs | 42 +- .../dashboard/pull_request_diff_view.rs | 92 +- src/util/file.rs | 166 ++-- 11 files changed, 1273 insertions(+), 262 deletions(-) create mode 100644 src/component/text_input.rs create mode 100644 src/keyboard.rs diff --git a/Cargo.toml b/Cargo.toml index c653636..5e0a612 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ memchr = "2.8.0" thiserror = "2.0.18" tree-sitter-highlight = "0.26.9" tree-sitter-rust = "0.24.2" +unicode-segmentation = "1.13.2" [build-dependencies] serde_json = "1.0.149" diff --git a/src/api/issues.rs b/src/api/issues.rs index be9963f..92f0ddb 100644 --- a/src/api/issues.rs +++ b/src/api/issues.rs @@ -318,8 +318,8 @@ impl query::QueryFn for ListPullRequests { } let query_string = match self.filter { - | Some(filter) => format!("is:pr archived:false sort:updated-desc {}", filter), - | None => "is:pr archived:false sort:updated-desc".into(), + | Some(filter) => format!("is:pr archived:false sort:updated-desc {}", filter), + | None => "is:pr archived:false sort:updated-desc".into(), }; let gql = @@ -341,20 +341,20 @@ impl query::QueryFn for ListPullRequests { .flatten() .filter_map(|edge| { edge.node.and_then(|n| match n { - | PullRequestPaginationQuerySearchEdgesNode::PullRequest(p) => { - Some(PullRequest { - id: p.id.into(), - title: p.title.into(), - state: p.state, - is_draft: p.is_draft, - repo_slug: format!( - "{}/{}", - p.repository.owner.login, p.repository.name - ) - .into(), - }) - } - | _ => None, + | PullRequestPaginationQuerySearchEdgesNode::PullRequest(p) => { + Some(PullRequest { + id: p.id.into(), + title: p.title.into(), + state: p.state, + is_draft: p.is_draft, + repo_slug: format!( + "{}/{}", + p.repository.owner.login, p.repository.name + ) + .into(), + }) + } + | _ => None, }) }) .collect::>() @@ -399,43 +399,43 @@ impl query::QueryFn for FetchPullRequest { "missing 'node' field on PullRequestQuery response".into(), )) .and_then(|n| match n { - | PullRequestQueryNode::PullRequest(p) => { - let created_at = - chrono::DateTime::parse_from_rfc3339(&p.created_at).map_err(|err| { - api::Error::MalformedResponse(format!( - "invalid pull request createdAt {:?}: {err}", - p.created_at - )) - })?; + | PullRequestQueryNode::PullRequest(p) => { + let created_at = + chrono::DateTime::parse_from_rfc3339(&p.created_at).map_err(|err| { + api::Error::MalformedResponse(format!( + "invalid pull request createdAt {:?}: {err}", + p.created_at + )) + })?; - Ok(DetailedPullRequest { - id: Id(p.id.into()), - title: p.title.into(), - state: p.state, - is_draft: p.is_draft, - body: p.body.into(), - author: p.author.map(|it| api::user::Actor { - login: it.login.into(), - avatar_url: it.avatar_url.into(), - }), - base_repo_slug: p - .base_repository - .map(|it| it.name_with_owner.into()) - .unwrap_or_default(), - base_branch_name: p.base_ref_name.into(), - base_ref: p.base_ref_oid.into(), - head_repo_slug: p - .head_repository - .map(|it| it.name_with_owner.into()) - .unwrap_or_default(), - head_branch_name: p.head_ref_name.into(), - head_ref: p.head_ref_oid.into(), - created_at: Some(created_at), - }) - } - | _ => Err(api::Error::MalformedResponse( - "unexpected node type on PullRequestQuery".into(), - )), + Ok(DetailedPullRequest { + id: Id(p.id.into()), + title: p.title.into(), + state: p.state, + is_draft: p.is_draft, + body: p.body.into(), + author: p.author.map(|it| api::user::Actor { + login: it.login.into(), + avatar_url: it.avatar_url.into(), + }), + base_repo_slug: p + .base_repository + .map(|it| it.name_with_owner.into()) + .unwrap_or_default(), + base_branch_name: p.base_ref_name.into(), + base_ref: p.base_ref_oid.into(), + head_repo_slug: p + .head_repository + .map(|it| it.name_with_owner.into()) + .unwrap_or_default(), + head_branch_name: p.head_ref_name.into(), + head_ref: p.head_ref_oid.into(), + created_at: Some(created_at), + }) + } + | _ => Err(api::Error::MalformedResponse( + "unexpected node type on PullRequestQuery".into(), + )), }) } } @@ -494,12 +494,12 @@ impl query::QueryFn for FetchPullRequestFileTree { "missing 'node' field on PullRequestFileTreeQuery response".into(), )) .and_then(|node| match node { - | pull_request_file_tree_query::PullRequestFileTreeQueryNode::PullRequest( - pull_request, - ) => Ok(pull_request), - | _ => Err(api::Error::MalformedResponse( - "unexpected node type on PullRequestFileTreeQuery".into(), - )), + | pull_request_file_tree_query::PullRequestFileTreeQueryNode::PullRequest( + pull_request, + ) => Ok(pull_request), + | _ => Err(api::Error::MalformedResponse( + "unexpected node type on PullRequestFileTreeQuery".into(), + )), })?; Ok(pull_request @@ -513,23 +513,25 @@ impl query::QueryFn for FetchPullRequestFileTree { edge.node.map(|node| ChangedFile { cursor, change_type: match node.change_type { - | pull_request_file_tree_query::PatchStatus::ADDED => ChangeType::Added, - | pull_request_file_tree_query::PatchStatus::MODIFIED => { - ChangeType::Modified - } - | pull_request_file_tree_query::PatchStatus::DELETED => { - ChangeType::Deleted - } - | pull_request_file_tree_query::PatchStatus::RENAMED => { - ChangeType::Renamed - } - | pull_request_file_tree_query::PatchStatus::COPIED => { - ChangeType::Copied - } - | pull_request_file_tree_query::PatchStatus::CHANGED => { - ChangeType::Changed - } - | _ => ChangeType::Changed, + | pull_request_file_tree_query::PatchStatus::ADDED => { + ChangeType::Added + } + | pull_request_file_tree_query::PatchStatus::MODIFIED => { + ChangeType::Modified + } + | pull_request_file_tree_query::PatchStatus::DELETED => { + ChangeType::Deleted + } + | pull_request_file_tree_query::PatchStatus::RENAMED => { + ChangeType::Renamed + } + | pull_request_file_tree_query::PatchStatus::COPIED => { + ChangeType::Copied + } + | pull_request_file_tree_query::PatchStatus::CHANGED => { + ChangeType::Changed + } + | _ => ChangeType::Changed, }, additions: node.additions, deletions: node.deletions, @@ -576,11 +578,11 @@ impl query::QueryFn for FetchPullRequestTimeline { TimelineActor { kind: match on { - | actorFieldsOn::Bot => "Bot", - | actorFieldsOn::EnterpriseUserAccount => "EnterpriseUserAccount", - | actorFieldsOn::Mannequin => "Mannequin", - | actorFieldsOn::Organization => "Organization", - | actorFieldsOn::User => "User", + | actorFieldsOn::Bot => "Bot", + | actorFieldsOn::EnterpriseUserAccount => "EnterpriseUserAccount", + | actorFieldsOn::Mannequin => "Mannequin", + | actorFieldsOn::Organization => "Organization", + | actorFieldsOn::User => "User", } .into(), name: login, @@ -590,62 +592,62 @@ impl query::QueryFn for FetchPullRequestTimeline { fn normalize_assignee(actor: assigneeFields) -> TimelineActor { match actor { - | assigneeFields::Bot(actor) => TimelineActor { - kind: "Bot".into(), - name: actor.login, - avatar_url: Some(actor.avatar_url), - }, - | assigneeFields::Mannequin(actor) => TimelineActor { - kind: "Mannequin".into(), - name: actor.login, - avatar_url: Some(actor.avatar_url), - }, - | assigneeFields::Organization(actor) => TimelineActor { - kind: "Organization".into(), - name: actor.login, - avatar_url: Some(actor.avatar_url), - }, - | assigneeFields::User(actor) => TimelineActor { - kind: "User".into(), - name: actor.login, - avatar_url: Some(actor.avatar_url), - }, + | assigneeFields::Bot(actor) => TimelineActor { + kind: "Bot".into(), + name: actor.login, + avatar_url: Some(actor.avatar_url), + }, + | assigneeFields::Mannequin(actor) => TimelineActor { + kind: "Mannequin".into(), + name: actor.login, + avatar_url: Some(actor.avatar_url), + }, + | assigneeFields::Organization(actor) => TimelineActor { + kind: "Organization".into(), + name: actor.login, + avatar_url: Some(actor.avatar_url), + }, + | assigneeFields::User(actor) => TimelineActor { + kind: "User".into(), + name: actor.login, + avatar_url: Some(actor.avatar_url), + }, } } fn normalize_requested_reviewer(actor: requestedReviewerFields) -> TimelineActor { match actor { - | requestedReviewerFields::Bot(actor) => TimelineActor { - kind: "Bot".into(), - name: actor.login, - avatar_url: Some(actor.avatar_url), - }, - | requestedReviewerFields::Mannequin(actor) => TimelineActor { - kind: "Mannequin".into(), - name: actor.login, - avatar_url: Some(actor.avatar_url), - }, - | requestedReviewerFields::Team(actor) => TimelineActor { - kind: "Team".into(), - name: actor.name, - avatar_url: None, - }, - | requestedReviewerFields::User(actor) => TimelineActor { - kind: "User".into(), - name: actor.login, - avatar_url: Some(actor.avatar_url), - }, + | requestedReviewerFields::Bot(actor) => TimelineActor { + kind: "Bot".into(), + name: actor.login, + avatar_url: Some(actor.avatar_url), + }, + | requestedReviewerFields::Mannequin(actor) => TimelineActor { + kind: "Mannequin".into(), + name: actor.login, + avatar_url: Some(actor.avatar_url), + }, + | requestedReviewerFields::Team(actor) => TimelineActor { + kind: "Team".into(), + name: actor.name, + avatar_url: None, + }, + | requestedReviewerFields::User(actor) => TimelineActor { + kind: "User".into(), + name: actor.login, + avatar_url: Some(actor.avatar_url), + }, } } fn normalize_review_state(state: PullRequestReviewState) -> String { match state { - | PullRequestReviewState::PENDING => "PENDING", - | PullRequestReviewState::COMMENTED => "COMMENTED", - | PullRequestReviewState::APPROVED => "APPROVED", - | PullRequestReviewState::CHANGES_REQUESTED => "CHANGES_REQUESTED", - | PullRequestReviewState::DISMISSED => "DISMISSED", - | _ => "OTHER", + | PullRequestReviewState::PENDING => "PENDING", + | PullRequestReviewState::COMMENTED => "COMMENTED", + | PullRequestReviewState::APPROVED => "APPROVED", + | PullRequestReviewState::CHANGES_REQUESTED => "CHANGES_REQUESTED", + | PullRequestReviewState::DISMISSED => "DISMISSED", + | _ => "OTHER", } .into() } @@ -865,10 +867,10 @@ impl query::QueryFn for FetchPullRequestTimeline { "missing 'node' field on PullRequestTimelineQuery response".into(), )) .and_then(|node| match node { - | PullRequestTimelineQueryNode::PullRequest(pull_request) => Ok(pull_request), - | _ => Err(api::Error::MalformedResponse( - "unexpected node type on PullRequestTimelineQuery".into(), - )), + | PullRequestTimelineQueryNode::PullRequest(pull_request) => Ok(pull_request), + | _ => Err(api::Error::MalformedResponse( + "unexpected node type on PullRequestTimelineQuery".into(), + )), })?; let timeline = pull_request.timeline_items; diff --git a/src/component/diff_view.rs b/src/component/diff_view.rs index 50bcabe..73dbda3 100644 --- a/src/component/diff_view.rs +++ b/src/component/diff_view.rs @@ -117,11 +117,13 @@ impl DiffRow { .map(|it| it.to_shared_string()); let marker = match self.line.op { - | util::diff::Op::Equal => code_view::CodeLineMarker::Unchanged, - // inserting on new side, so placeholder on old side - | util::diff::Op::Insert => code_view::CodeLineMarker::Placeholder, - // old side replaced, so delete - | util::diff::Op::Replace | util::diff::Op::Delete => code_view::CodeLineMarker::Deleted, + | util::diff::Op::Equal => code_view::CodeLineMarker::Unchanged, + // inserting on new side, so placeholder on old side + | util::diff::Op::Insert => code_view::CodeLineMarker::Placeholder, + // old side replaced, so delete + | util::diff::Op::Replace | util::diff::Op::Delete => { + code_view::CodeLineMarker::Deleted + } }; match self.line.old_line.and_then(|line| { @@ -130,14 +132,14 @@ impl DiffRow { .as_ref() .map(|it| it.highlights_at_line(line)) }) { - | Some(highlights) => code_line_with_highlights( - self.line.old_line, - content, - highlights.iter().cloned(), - marker, - ), + | Some(highlights) => code_line_with_highlights( + self.line.old_line, + content, + highlights.iter().cloned(), + marker, + ), - | None => code_line(self.line.old_line, content, marker), + | None => code_line(self.line.old_line, content, marker), } } @@ -151,9 +153,9 @@ impl DiffRow { .map(|it| it.to_shared_string()); let marker = match self.line.op { - | util::diff::Op::Equal => code_view::CodeLineMarker::Unchanged, - | util::diff::Op::Insert | util::diff::Op::Replace => code_view::CodeLineMarker::Added, - | util::diff::Op::Delete => code_view::CodeLineMarker::Deleted, + | util::diff::Op::Equal => code_view::CodeLineMarker::Unchanged, + | util::diff::Op::Insert | util::diff::Op::Replace => code_view::CodeLineMarker::Added, + | util::diff::Op::Delete => code_view::CodeLineMarker::Deleted, }; match self.line.new_line.and_then(|line| { @@ -162,14 +164,14 @@ impl DiffRow { .as_ref() .map(|it| it.highlights_at_line(line)) }) { - | Some(highlights) => code_line_with_highlights( - self.line.new_line, - content, - highlights.iter().cloned(), - marker, - ), + | Some(highlights) => code_line_with_highlights( + self.line.new_line, + content, + highlights.iter().cloned(), + marker, + ), - | None => code_line(self.line.new_line, content, marker), + | None => code_line(self.line.new_line, content, marker), } } } diff --git a/src/component/mod.rs b/src/component/mod.rs index be4bc36..9773bec 100644 --- a/src/component/mod.rs +++ b/src/component/mod.rs @@ -6,3 +6,4 @@ pub(crate) mod font_icon; pub(crate) mod markdown; pub(crate) mod segmented_control; pub(crate) mod text; +pub(crate) mod text_input; diff --git a/src/component/text_input.rs b/src/component/text_input.rs new file mode 100644 index 0000000..00713ac --- /dev/null +++ b/src/component/text_input.rs @@ -0,0 +1,911 @@ +use std::ops::Range; + +use gpui::{ + App, Bounds, Context, CursorStyle, Element, ElementId, ElementInputHandler, Entity, + EntityInputHandler, FocusHandle, Focusable, GlobalElementId, InteractiveElement, IntoElement, + LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, + Pixels, Point, Refineable, ShapedLine, SharedString, Style, Styled, TextRun, UTF16Selection, + UnderlineStyle, Window, div, fill, point, px, relative, size, +}; +use unicode_segmentation::UnicodeSegmentation; + +use crate::app; + +gpui::actions!( + text_input, + [ + Backspace, + BackspaceWord, + Delete, + Left, + WordLeft, + SelectLeft, + SelectWordLeft, + Right, + SelectRight, + WordRight, + SelectWordRight, + LineStart, + SelectToLineStart, + LineEnd, + SelectToLineEnd, + SelectAll, + Home, + End, + Paste, + Cut, + Copy, + Submit, + ] +); + +const KEY_CONTEXT: &str = "TextInput"; + +#[allow(dead_code)] +#[derive(Clone, Debug)] +pub(crate) enum Event { + Changed(SharedString), + Submitted(SharedString), +} + +pub(crate) struct TextInput { + focus_handle: FocusHandle, + content: SharedString, + placeholder: SharedString, + selected_range: Range, + selection_reversed: bool, + marked_range: Option>, + last_layout: Option, + last_bounds: Option>, + is_selecting: bool, +} + +#[allow(dead_code)] +#[derive(gpui::IntoElement)] +pub(crate) struct TextInputView { + input: Entity, + style: gpui::StyleRefinement, +} + +#[allow(dead_code)] +pub(crate) fn text_input(input: Entity) -> TextInputView { + TextInputView { + input, + style: gpui::StyleRefinement::default(), + } +} + +#[allow(dead_code)] +impl TextInput { + pub(crate) fn new(cx: &mut Context) -> Self { + Self::with_placeholder("", cx) + } + + pub(crate) fn with_placeholder( + placeholder: impl Into, + cx: &mut Context, + ) -> Self { + Self { + focus_handle: cx.focus_handle(), + content: "".into(), + placeholder: placeholder.into(), + selected_range: 0..0, + selection_reversed: false, + marked_range: None, + last_layout: None, + last_bounds: None, + is_selecting: false, + } + } + + pub(crate) fn key_bindings() -> [gpui::KeyBinding; 26] { + [ + gpui::KeyBinding::new("backspace", Backspace, Some(KEY_CONTEXT)), + gpui::KeyBinding::new("alt-backspace", BackspaceWord, Some(KEY_CONTEXT)), + gpui::KeyBinding::new("delete", Delete, Some(KEY_CONTEXT)), + // + gpui::KeyBinding::new("left", Left, Some(KEY_CONTEXT)), + gpui::KeyBinding::new("shift-left", SelectLeft, Some(KEY_CONTEXT)), + gpui::KeyBinding::new("alt-left", WordLeft, Some(KEY_CONTEXT)), + gpui::KeyBinding::new("alt-shift-left", SelectWordLeft, Some(KEY_CONTEXT)), + gpui::KeyBinding::new("cmd-left", LineStart, Some(KEY_CONTEXT)), + gpui::KeyBinding::new("shift-cmd-left", SelectToLineStart, Some(KEY_CONTEXT)), + // + gpui::KeyBinding::new("right", Right, Some(KEY_CONTEXT)), + gpui::KeyBinding::new("shift-right", SelectRight, Some(KEY_CONTEXT)), + gpui::KeyBinding::new("alt-right", WordRight, Some(KEY_CONTEXT)), + gpui::KeyBinding::new("alt-shift-right", SelectWordRight, Some(KEY_CONTEXT)), + gpui::KeyBinding::new("cmd-right", LineEnd, Some(KEY_CONTEXT)), + gpui::KeyBinding::new("shift-cmd-right", SelectToLineEnd, Some(KEY_CONTEXT)), + // + gpui::KeyBinding::new("cmd-a", SelectAll, Some(KEY_CONTEXT)), + gpui::KeyBinding::new("ctrl-a", SelectAll, Some(KEY_CONTEXT)), + gpui::KeyBinding::new("cmd-v", Paste, Some(KEY_CONTEXT)), + gpui::KeyBinding::new("ctrl-v", Paste, Some(KEY_CONTEXT)), + gpui::KeyBinding::new("cmd-c", Copy, Some(KEY_CONTEXT)), + gpui::KeyBinding::new("ctrl-c", Copy, Some(KEY_CONTEXT)), + gpui::KeyBinding::new("cmd-x", Cut, Some(KEY_CONTEXT)), + gpui::KeyBinding::new("ctrl-x", Cut, Some(KEY_CONTEXT)), + gpui::KeyBinding::new("home", Home, Some(KEY_CONTEXT)), + gpui::KeyBinding::new("end", End, Some(KEY_CONTEXT)), + gpui::KeyBinding::new("enter", Submit, Some(KEY_CONTEXT)), + ] + } + + pub(crate) fn content(&self) -> &str { + &self.content + } + + pub(crate) fn set_content(&mut self, content: impl Into, cx: &mut Context) { + let content = content.into(); + let changed = self.content != content; + self.content = content; + self.selected_range = self.content.len()..self.content.len(); + self.selection_reversed = false; + self.marked_range = None; + cx.notify(); + + if changed { + cx.emit(Event::Changed(self.content.clone())); + } + } + + pub(crate) fn clear(&mut self, cx: &mut Context) { + self.set_content("", cx); + } + + fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context) { + if self.selected_range.is_empty() { + self.move_to(self.previous_boundary(self.cursor_offset()), cx); + } else { + self.move_to(self.selected_range.start, cx); + } + } + + fn word_left(&mut self, _: &WordLeft, _: &mut Window, cx: &mut Context) { + if self.selected_range.is_empty() { + self.move_to(self.previous_word_boundary(self.cursor_offset()), cx); + } else { + self.move_to(self.selected_range.start, cx); + } + } + + fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context) { + if self.selected_range.is_empty() { + self.move_to(self.next_boundary(self.cursor_offset()), cx); + } else { + self.move_to(self.selected_range.end, cx); + } + } + + fn word_right(&mut self, _: &WordRight, _: &mut Window, cx: &mut Context) { + if self.selected_range.is_empty() { + self.move_to(self.next_word_boundary(self.cursor_offset()), cx); + } else { + self.move_to(self.selected_range.end, cx); + } + } + + fn select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context) { + self.select_to(self.previous_boundary(self.cursor_offset()), cx); + } + + fn select_word_left(&mut self, _: &SelectWordLeft, _: &mut Window, cx: &mut Context) { + self.select_to(self.previous_word_boundary(self.cursor_offset()), cx); + } + + fn select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context) { + self.select_to(self.next_boundary(self.cursor_offset()), cx); + } + + fn select_word_right(&mut self, _: &SelectWordRight, _: &mut Window, cx: &mut Context) { + self.select_to(self.next_word_boundary(self.cursor_offset()), cx); + } + + fn line_start(&mut self, _: &LineStart, _: &mut Window, cx: &mut Context) { + self.move_to(0, cx); + } + + fn select_to_line_start( + &mut self, + _: &SelectToLineStart, + _: &mut Window, + cx: &mut Context, + ) { + self.select_to(0, cx); + } + + fn select_to_line_end(&mut self, _: &SelectToLineEnd, _: &mut Window, cx: &mut Context) { + self.select_to(self.content.len(), cx); + } + + fn line_end(&mut self, _: &LineEnd, _: &mut Window, cx: &mut Context) { + self.move_to(self.content().len(), cx); + } + + fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context) { + self.move_to(0, cx); + self.select_to(self.content.len(), cx); + } + + fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context) { + self.move_to(0, cx); + } + + fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context) { + self.move_to(self.content.len(), cx); + } + + fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context) { + if self.selected_range.is_empty() { + self.select_to(self.previous_boundary(self.cursor_offset()), cx); + } + self.replace_text_in_range(None, "", window, cx); + } + + fn backspace_word(&mut self, _: &BackspaceWord, window: &mut Window, cx: &mut Context) { + if self.selected_range.is_empty() { + self.select_to(self.previous_word_boundary(self.cursor_offset()), cx); + } + self.replace_text_in_range(None, "", window, cx); + } + + fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context) { + if self.selected_range.is_empty() { + self.select_to(self.next_boundary(self.cursor_offset()), cx); + } + self.replace_text_in_range(None, "", window, cx); + } + + fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { + if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) { + self.replace_text_in_range(None, &text.replace('\n', " "), window, cx); + } + } + + fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context) { + if !self.selected_range.is_empty() { + cx.write_to_clipboard(gpui::ClipboardItem::new_string( + self.content[self.selected_range.clone()].to_string(), + )); + } + } + + fn cut(&mut self, _: &Cut, window: &mut Window, cx: &mut Context) { + if !self.selected_range.is_empty() { + cx.write_to_clipboard(gpui::ClipboardItem::new_string( + self.content[self.selected_range.clone()].to_string(), + )); + self.replace_text_in_range(None, "", window, cx); + } + } + + fn submit(&mut self, _: &Submit, _: &mut Window, cx: &mut Context) { + cx.emit(Event::Submitted(self.content.clone())); + } + + fn on_mouse_down( + &mut self, + event: &MouseDownEvent, + _window: &mut Window, + cx: &mut Context, + ) { + self.is_selecting = true; + + if event.modifiers.shift { + self.select_to(self.index_for_mouse_position(event.position), cx); + } else { + self.move_to(self.index_for_mouse_position(event.position), cx); + } + } + + fn on_mouse_up(&mut self, _: &MouseUpEvent, _window: &mut Window, _: &mut Context) { + self.is_selecting = false; + } + + fn on_mouse_move(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context) { + if self.is_selecting { + self.select_to(self.index_for_mouse_position(event.position), cx); + } + } + + fn move_to(&mut self, offset: usize, cx: &mut Context) { + let offset = offset.min(self.content.len()); + self.selected_range = offset..offset; + self.selection_reversed = false; + cx.notify(); + } + + fn cursor_offset(&self) -> usize { + if self.selection_reversed { + self.selected_range.start + } else { + self.selected_range.end + } + } + + fn index_for_mouse_position(&self, position: Point) -> usize { + if self.content.is_empty() { + return 0; + } + + let (Some(bounds), Some(line)) = (self.last_bounds.as_ref(), self.last_layout.as_ref()) + else { + return 0; + }; + + if position.y < bounds.top() { + return 0; + } + if position.y > bounds.bottom() { + return self.content.len(); + } + + line.closest_index_for_x(position.x - bounds.left()) + } + + fn select_to(&mut self, offset: usize, cx: &mut Context) { + let offset = offset.min(self.content.len()); + if self.selection_reversed { + self.selected_range.start = offset; + } else { + self.selected_range.end = offset; + } + if self.selected_range.end < self.selected_range.start { + self.selection_reversed = !self.selection_reversed; + self.selected_range = self.selected_range.end..self.selected_range.start; + } + cx.notify(); + } + + fn range_to_utf16(&self, range: &Range) -> Range { + offset_to_utf16(&self.content, range.start)..offset_to_utf16(&self.content, range.end) + } + + fn range_from_utf16(&self, range_utf16: &Range) -> Range { + range_from_utf16(&self.content, range_utf16) + } + + fn previous_boundary(&self, offset: usize) -> usize { + self.content + .grapheme_indices(true) + .rev() + .find_map(|(idx, _)| (idx < offset).then_some(idx)) + .unwrap_or(0) + } + + fn previous_word_boundary(&self, offset: usize) -> usize { + self.content + .split_word_bound_indices() + .rev() + .find_map(|(idx, word)| (idx < offset && !word.trim().is_empty()).then_some(idx)) + .unwrap_or(0) + } + + fn next_boundary(&self, offset: usize) -> usize { + self.content + .grapheme_indices(true) + .find_map(|(idx, _)| (idx > offset).then_some(idx)) + .unwrap_or(self.content.len()) + } + + fn next_word_boundary(&self, offset: usize) -> usize { + self.content + .split_word_bound_indices() + .find_map(|(idx, word)| { + let word_end = idx + word.len(); + (word_end > offset && !word.trim().is_empty()).then_some(word_end) + }) + .unwrap_or(offset) + } +} + +impl EntityInputHandler for TextInput { + fn text_for_range( + &mut self, + range_utf16: Range, + actual_range: &mut Option>, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + let range = self.range_from_utf16(&range_utf16); + actual_range.replace(self.range_to_utf16(&range)); + Some(self.content[range].to_string()) + } + + fn selected_text_range( + &mut self, + _ignore_disabled_input: bool, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + Some(UTF16Selection { + range: self.range_to_utf16(&self.selected_range), + reversed: self.selection_reversed, + }) + } + + fn marked_text_range( + &self, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + self.marked_range + .as_ref() + .map(|range| self.range_to_utf16(range)) + } + + fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context) { + self.marked_range = None; + } + + fn replace_text_in_range( + &mut self, + range_utf16: Option>, + new_text: &str, + _window: &mut Window, + cx: &mut Context, + ) { + let range = range_utf16 + .as_ref() + .map(|range_utf16| self.range_from_utf16(range_utf16)) + .or(self.marked_range.clone()) + .unwrap_or(self.selected_range.clone()); + + let old_content = self.content.clone(); + self.content = + (self.content[0..range.start].to_owned() + new_text + &self.content[range.end..]) + .into(); + let cursor = range.start + new_text.len(); + self.selected_range = cursor..cursor; + self.selection_reversed = false; + self.marked_range.take(); + cx.notify(); + + if self.content != old_content { + cx.emit(Event::Changed(self.content.clone())); + } + } + + fn replace_and_mark_text_in_range( + &mut self, + range_utf16: Option>, + new_text: &str, + new_selected_range_utf16: Option>, + _window: &mut Window, + cx: &mut Context, + ) { + let range = range_utf16 + .as_ref() + .map(|range_utf16| self.range_from_utf16(range_utf16)) + .or(self.marked_range.clone()) + .unwrap_or(self.selected_range.clone()); + + let old_content = self.content.clone(); + self.content = + (self.content[0..range.start].to_owned() + new_text + &self.content[range.end..]) + .into(); + self.marked_range = + (!new_text.is_empty()).then_some(range.start..range.start + new_text.len()); + self.selected_range = new_selected_range_utf16 + .as_ref() + .map(|range_utf16| range_from_utf16(new_text, range_utf16)) + .map(|new_range| range.start + new_range.start..range.start + new_range.end) + .unwrap_or_else(|| { + let cursor = range.start + new_text.len(); + cursor..cursor + }); + self.selection_reversed = false; + cx.notify(); + + if self.content != old_content { + cx.emit(Event::Changed(self.content.clone())); + } + } + + fn bounds_for_range( + &mut self, + range_utf16: Range, + bounds: Bounds, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + let last_layout = self.last_layout.as_ref()?; + let range = self.range_from_utf16(&range_utf16); + Some(Bounds::from_corners( + point( + bounds.left() + last_layout.x_for_index(range.start), + bounds.top(), + ), + point( + bounds.left() + last_layout.x_for_index(range.end), + bounds.bottom(), + ), + )) + } + + fn character_index_for_point( + &mut self, + point: Point, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + let bounds = self.last_bounds?; + let last_layout = self.last_layout.as_ref()?; + let local_point = bounds.localize(&point)?; + let utf8_index = last_layout.index_for_x(local_point.x)?; + Some(offset_to_utf16(&self.content, utf8_index)) + } +} + +impl Focusable for TextInput { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl gpui::EventEmitter for TextInput {} + +impl Styled for TextInputView { + fn style(&mut self) -> &mut gpui::StyleRefinement { + &mut self.style + } +} + +impl gpui::Render for TextInput { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + TextInputElement { input: cx.entity() } + } +} + +impl gpui::RenderOnce for TextInputView { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = app::current_theme(cx); + let focus_handle = self.input.read(cx).focus_handle.clone(); + + let on_backspace = self.input.clone(); + let on_backspace_word = self.input.clone(); + let on_delete = self.input.clone(); + + let on_left = self.input.clone(); + let on_select_left = self.input.clone(); + let on_word_left = self.input.clone(); + let on_select_word_left = self.input.clone(); + + let on_right = self.input.clone(); + let on_select_right = self.input.clone(); + let on_word_right = self.input.clone(); + let on_select_word_right = self.input.clone(); + + let on_line_start = self.input.clone(); + let on_select_to_line_start = self.input.clone(); + let on_line_end = self.input.clone(); + let on_select_to_line_end = self.input.clone(); + + let on_select_all = self.input.clone(); + let on_home = self.input.clone(); + let on_end = self.input.clone(); + let on_paste = self.input.clone(); + let on_cut = self.input.clone(); + let on_copy = self.input.clone(); + let on_submit = self.input.clone(); + let on_mouse_down = self.input.clone(); + let on_mouse_up = self.input.clone(); + let on_mouse_up_out = self.input.clone(); + let on_mouse_move = self.input.clone(); + + let mut input = div() + .flex() + .items_center() + .w_full() + .px_2() + .py_1() + .text_color(theme.colors.text) + .line_height(px(20.)) + .key_context(KEY_CONTEXT) + .track_focus(&focus_handle) + .cursor(CursorStyle::IBeam) + .on_action(move |action: &Backspace, window, cx| { + _ = on_backspace.update(cx, |this, cx| this.backspace(action, window, cx)); + }) + .on_action(move |action: &BackspaceWord, window, cx| { + _ = on_backspace_word.update(cx, |this, cx| this.backspace_word(action, window, cx)) + }) + .on_action(move |action: &Delete, window, cx| { + _ = on_delete.update(cx, |this, cx| this.delete(action, window, cx)); + }) + .on_action(move |action: &Left, window, cx| { + _ = on_left.update(cx, |this, cx| this.left(action, window, cx)); + }) + .on_action(move |action: &SelectLeft, window, cx| { + _ = on_select_left.update(cx, |this, cx| this.select_left(action, window, cx)); + }) + .on_action(move |action: &WordLeft, window, cx| { + _ = on_word_left.update(cx, |this, cx| this.word_left(action, window, cx)); + }) + .on_action(move |action: &SelectWordLeft, window, cx| { + _ = on_select_word_left + .update(cx, |this, cx| this.select_word_left(action, window, cx)); + }) + .on_action(move |action: &Right, window, cx| { + _ = on_right.update(cx, |this, cx| this.right(action, window, cx)); + }) + .on_action(move |action: &WordRight, window, cx| { + _ = on_word_right.update(cx, |this, cx| this.word_right(action, window, cx)); + }) + .on_action(move |action: &SelectRight, window, cx| { + _ = on_select_right.update(cx, |this, cx| this.select_right(action, window, cx)); + }) + .on_action(move |action: &SelectWordRight, window, cx| { + _ = on_select_word_right + .update(cx, |this, cx| this.select_word_right(action, window, cx)); + }) + .on_action(move |action: &LineStart, window, cx| { + _ = on_line_start.update(cx, |this, cx| this.line_start(action, window, cx)); + }) + .on_action(move |action: &SelectToLineStart, window, cx| { + _ = on_select_to_line_start + .update(cx, |this, cx| this.select_to_line_start(action, window, cx)); + }) + .on_action(move |action: &LineEnd, window, cx| { + _ = on_line_end.update(cx, |this, cx| this.line_end(action, window, cx)); + }) + .on_action(move |action: &SelectToLineEnd, window, cx| { + _ = on_select_to_line_end + .update(cx, |this, cx| this.select_to_line_end(action, window, cx)); + }) + .on_action(move |action: &SelectAll, window, cx| { + _ = on_select_all.update(cx, |this, cx| this.select_all(action, window, cx)); + }) + .on_action(move |action: &Home, window, cx| { + _ = on_home.update(cx, |this, cx| this.home(action, window, cx)); + }) + .on_action(move |action: &End, window, cx| { + _ = on_end.update(cx, |this, cx| this.end(action, window, cx)); + }) + .on_action(move |action: &Paste, window, cx| { + _ = on_paste.update(cx, |this, cx| this.paste(action, window, cx)); + }) + .on_action(move |action: &Cut, window, cx| { + _ = on_cut.update(cx, |this, cx| this.cut(action, window, cx)); + }) + .on_action(move |action: &Copy, window, cx| { + _ = on_copy.update(cx, |this, cx| this.copy(action, window, cx)); + }) + .on_action(move |action: &Submit, window, cx| { + _ = on_submit.update(cx, |this, cx| this.submit(action, window, cx)); + }) + .on_mouse_down(MouseButton::Left, move |event, window, cx| { + _ = on_mouse_down.update(cx, |this, cx| this.on_mouse_down(event, window, cx)); + }) + .on_mouse_up(MouseButton::Left, move |event, window, cx| { + _ = on_mouse_up.update(cx, |this, cx| this.on_mouse_up(event, window, cx)); + }) + .on_mouse_up_out(MouseButton::Left, move |event, window, cx| { + _ = on_mouse_up_out.update(cx, |this, cx| this.on_mouse_up(event, window, cx)); + }) + .on_mouse_move(move |event, window, cx| { + _ = on_mouse_move.update(cx, |this, cx| this.on_mouse_move(event, window, cx)); + }) + .child(self.input.clone()); + + input.style().refine(&self.style); + + input + } +} + +struct TextInputElement { + input: Entity, +} + +struct PrepaintState { + line: Option, + cursor: Option, + selection: Option, +} + +impl IntoElement for TextInputElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for TextInputElement { + type RequestLayoutState = (); + type PrepaintState = PrepaintState; + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&gpui::InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let mut style = Style::default(); + style.size.width = relative(1.).into(); + style.size.height = window.line_height().into(); + (window.request_layout(style, [], cx), ()) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&gpui::InspectorElementId>, + bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + let theme = app::current_theme(cx); + let input = self.input.read(cx); + let content = input.content.clone(); + let selected_range = input.selected_range.clone(); + let cursor = input.cursor_offset(); + let style = window.text_style(); + + let (display_text, text_color) = if content.is_empty() { + (input.placeholder.clone(), theme.colors.text_subtle.into()) + } else { + (content, style.color) + }; + + let run = TextRun { + len: display_text.len(), + font: style.font(), + color: text_color, + background_color: None, + underline: None, + strikethrough: None, + }; + let runs = if let Some(marked_range) = input.marked_range.as_ref() { + vec![ + TextRun { + len: marked_range.start, + ..run.clone() + }, + TextRun { + len: marked_range.end - marked_range.start, + underline: Some(UnderlineStyle { + color: Some(run.color), + thickness: px(1.0), + wavy: false, + }), + ..run.clone() + }, + TextRun { + len: display_text.len() - marked_range.end, + ..run + }, + ] + .into_iter() + .filter(|run| run.len > 0) + .collect() + } else { + vec![run] + }; + + let font_size = style.font_size.to_pixels(window.rem_size()); + let line = window + .text_system() + .shape_line(display_text, font_size, &runs, None); + + let cursor_pos = line.x_for_index(cursor); + let (selection, cursor) = if selected_range.is_empty() { + ( + None, + Some(fill( + Bounds::new( + point(bounds.left() + cursor_pos, bounds.top()), + size(px(1.), bounds.bottom() - bounds.top()), + ), + theme.colors.accent_solid, + )), + ) + } else { + ( + Some(fill( + Bounds::from_corners( + point( + bounds.left() + line.x_for_index(selected_range.start), + bounds.top(), + ), + point( + bounds.left() + line.x_for_index(selected_range.end), + bounds.bottom(), + ), + ), + theme.colors.selection_bg, + )), + None, + ) + }; + + PrepaintState { + line: Some(line), + cursor, + selection, + } + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&gpui::InspectorElementId>, + bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let focus_handle = self.input.read(cx).focus_handle.clone(); + window.handle_input( + &focus_handle, + ElementInputHandler::new(bounds, self.input.clone()), + cx, + ); + + if let Some(selection) = prepaint.selection.take() { + window.paint_quad(selection); + } + + let line = prepaint.line.take().unwrap(); + line.paint(bounds.origin, window.line_height(), window, cx) + .unwrap(); + + if focus_handle.is_focused(window) + && let Some(cursor) = prepaint.cursor.take() + { + window.paint_quad(cursor); + } + + self.input.update(cx, |input, _cx| { + input.last_layout = Some(line); + input.last_bounds = Some(bounds); + }); + } +} + +fn offset_to_utf16(text: &str, offset: usize) -> usize { + let mut utf16_offset = 0; + let mut utf8_count = 0; + + for ch in text.chars() { + if utf8_count >= offset { + break; + } + utf8_count += ch.len_utf8(); + utf16_offset += ch.len_utf16(); + } + + utf16_offset +} + +fn offset_from_utf16(text: &str, offset: usize) -> usize { + let mut utf8_offset = 0; + let mut utf16_count = 0; + + for ch in text.chars() { + if utf16_count >= offset { + break; + } + utf16_count += ch.len_utf16(); + utf8_offset += ch.len_utf8(); + } + + utf8_offset +} + +fn range_from_utf16(text: &str, range_utf16: &Range) -> Range { + offset_from_utf16(text, range_utf16.start)..offset_from_utf16(text, range_utf16.end) +} diff --git a/src/keyboard.rs b/src/keyboard.rs new file mode 100644 index 0000000..1bafabe --- /dev/null +++ b/src/keyboard.rs @@ -0,0 +1,9 @@ +use crate::{ + component::text_input::TextInput, + screen::dashboard::pull_request_diff_view::PullRequestDiffView, +}; + +pub(crate) fn attach_key_binds(cx: &mut gpui::App) { + cx.bind_keys(PullRequestDiffView::key_bindings()); + cx.bind_keys(TextInput::key_bindings()); +} diff --git a/src/main.rs b/src/main.rs index 8d10603..144ef6f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod asset; mod colors; mod component; mod http; +mod keyboard; mod query; mod screen; mod storage; @@ -62,6 +63,8 @@ fn setup_application(cx: &mut gpui::App) { cx.set_global(global); cx.set_global(query_store); + keyboard::attach_key_binds(cx); + if diffops_playground::is_enabled() { _ = diffops_playground::open_window(cx); return; diff --git a/src/screen/dashboard/mod.rs b/src/screen/dashboard/mod.rs index abd440a..fbb0c01 100644 --- a/src/screen/dashboard/mod.rs +++ b/src/screen/dashboard/mod.rs @@ -1,6 +1,6 @@ mod issue_list; mod pull_request_change_view; -mod pull_request_diff_view; +pub(crate) mod pull_request_diff_view; mod pull_request_file_tree; mod pull_request_view; mod screen; diff --git a/src/screen/dashboard/pull_request_change_view.rs b/src/screen/dashboard/pull_request_change_view.rs index 9a9c985..12c93ff 100644 --- a/src/screen/dashboard/pull_request_change_view.rs +++ b/src/screen/dashboard/pull_request_change_view.rs @@ -60,22 +60,28 @@ impl PullRequestChangeView { .detach(); } - fn handle_file_tree_item_click(&mut self, i: usize, cx: &mut gpui::Context) { + fn handle_file_tree_item_click( + &mut self, + i: usize, + window: &mut gpui::Window, + cx: &mut gpui::Context, + ) { let item = &self.file_tree_items[i]; match item.kind { - | FileTreeItemKind::Directory => { - self.file_tree_state - .toggle_directory(&item.full_path, &self.file_tree_items); - cx.notify(); - } - | FileTreeItemKind::File => { - self.selected_file_path = Some(Arc::clone(&item.full_path)); - self.file_tree_state.highlight_item(i); - self.diff_view.update(cx, |diff_view, cx| { - diff_view.show_diff_for_file(&item.full_path, cx); - }); - cx.notify(); - } + | FileTreeItemKind::Directory => { + self.file_tree_state + .toggle_directory(&item.full_path, &self.file_tree_items); + cx.notify(); + } + | FileTreeItemKind::File => { + self.selected_file_path = Some(Arc::clone(&item.full_path)); + self.file_tree_state.highlight_item(i); + self.diff_view.update(cx, |diff_view, cx| { + diff_view.show_diff_for_file(&item.full_path, cx); + }); + cx.focus_view(&self.diff_view, window); + cx.notify(); + } } } } @@ -106,9 +112,11 @@ impl gpui::Render for PullRequestChangeView { file_tree(self.file_tree_state.clone(), move |i, _, cx| { weak.read(cx).file_tree_items[i].clone() }) - .on_item_click(cx.listener(|this, i, _, cx| { - this.handle_file_tree_item_click(*i, cx); - })), + .on_item_click(cx.listener( + |this, i, window, cx| { + this.handle_file_tree_item_click(*i, window, cx); + }, + )), ), ) .child( diff --git a/src/screen/dashboard/pull_request_diff_view.rs b/src/screen/dashboard/pull_request_diff_view.rs index 98955c8..94286ea 100644 --- a/src/screen/dashboard/pull_request_diff_view.rs +++ b/src/screen/dashboard/pull_request_diff_view.rs @@ -1,11 +1,17 @@ use std::sync::Arc; -use gpui::{AppContext, IntoElement, div}; +use gpui::{ + AppContext, FocusHandle, InteractiveElement, IntoElement, ParentElement, Styled, div, + prelude::FluentBuilder, +}; use crate::{ api::{self}, app, - component::diff_view::{DiffViewContent, DiffViewState, diff_view}, + component::{ + diff_view::{DiffViewContent, DiffViewState, diff_view}, + text_input::{TextInput, text_input}, + }, query::{self, QueryStatus, read_query, use_query, watch_query}, util, }; @@ -16,8 +22,16 @@ pub(crate) struct PullRequestDiffView { diff_view_state: DiffViewState, diff_view_content: Option, current_file_path: Option>, + + focus_handle: gpui::FocusHandle, + is_search_input_visible: bool, + search_input: gpui::Entity, } +gpui::actions!([Search, Escape]); + +const KEY_CONTEXT: &'static str = "PullRequestDiffView"; + impl PullRequestDiffView { pub(crate) fn new(pr_id: api::issues::Id, cx: &mut gpui::Context) -> Self { let mut s = Self { @@ -26,11 +40,22 @@ impl PullRequestDiffView { diff_view_state: DiffViewState::new(), diff_view_content: None, current_file_path: None, + + focus_handle: cx.focus_handle(), + is_search_input_visible: false, + search_input: cx.new(|cx| TextInput::with_placeholder("Search", cx)), }; s.on_create(cx); s } + pub(crate) fn key_bindings() -> [gpui::KeyBinding; 2] { + [ + gpui::KeyBinding::new("cmd-f", Search, Some(KEY_CONTEXT)), + gpui::KeyBinding::new("escape", Escape, Some(KEY_CONTEXT)), + ] + } + pub(crate) fn show_diff_for_file( &mut self, file_path: &Arc, @@ -146,6 +171,24 @@ impl PullRequestDiffView { .detach(); } } + + fn open_search_box(&mut self, window: &mut gpui::Window, cx: &mut gpui::Context) { + self.is_search_input_visible = true; + cx.focus_view(&self.search_input, window); + cx.notify(); + } + + fn close_search_box(&mut self, window: &mut gpui::Window, cx: &mut gpui::Context) { + self.is_search_input_visible = false; + self.focus_handle.focus(window); + cx.notify(); + } +} + +impl gpui::Focusable for PullRequestDiffView { + fn focus_handle(&self, _: &gpui::App) -> FocusHandle { + self.focus_handle.clone() + } } impl gpui::Render for PullRequestDiffView { @@ -154,18 +197,49 @@ impl gpui::Render for PullRequestDiffView { _window: &mut gpui::Window, cx: &mut gpui::prelude::Context, ) -> impl gpui::IntoElement { + let theme = app::current_theme(cx); let content_diff = self .content_diff_query .as_ref() .map(|q| read_query(q, cx)) .unwrap_or(QueryStatus::Loading); - match (content_diff, &self.diff_view_content) { - | (QueryStatus::Loaded(_), Some(content)) => { - diff_view(self.diff_view_state.clone(), content.clone()).into_any_element() - } - - | (_, _) => div().into_any_element(), - } + div() + .id(KEY_CONTEXT) + .key_context(KEY_CONTEXT) + .track_focus(&self.focus_handle) + .flex() + .flex_col() + .size_full() + .when(self.is_search_input_visible, |it| { + it.child( + text_input(self.search_input.clone()) + .w_full() + .text_color(theme.colors.text) + .text_xs() + .px_3() + .bg(theme.colors.surface_chrome) + .border_0() + .border_b_1() + .border_color(theme.colors.border_muted), + ) + }) + .when_some( + match (content_diff, &self.diff_view_content) { + | (QueryStatus::Loaded(_), Some(content)) => Some(content.clone()), + | (_, _) => None, + }, + |it, content| { + it.child( + diff_view(self.diff_view_state.clone(), content.clone()).into_any_element(), + ) + }, + ) + .on_action(cx.listener(|this, _: &Search, window, cx| { + this.open_search_box(window, cx); + })) + .on_action(cx.listener(|this, _: &Escape, window, cx| { + this.close_search_box(window, cx); + })) } } diff --git a/src/util/file.rs b/src/util/file.rs index d7667c1..9ef6191 100644 --- a/src/util/file.rs +++ b/src/util/file.rs @@ -43,17 +43,17 @@ pub(crate) fn classify_content(content: &[u8]) -> ContentType { ContentType::Text } else { match memchr(0, &content[..content.len().min(8192)]) { - | None => ContentType::Text, - | Some(_) => ContentType::Binary, + | None => ContentType::Text, + | Some(_) => ContentType::Binary, } } } pub(crate) fn file_type_from_path(path: &str) -> FileType { match Path::new(path).extension().map(|it| it.to_str()).flatten() { - | Some("rs") => FileType::Rust, - | Some("js") | Some("jsx") => FileType::JavaScript, - | _ => FileType::Unknown, + | Some("rs") => FileType::Rust, + | Some("js") | Some("jsx") => FileType::JavaScript, + | _ => FileType::Unknown, } } @@ -71,18 +71,18 @@ pub(crate) fn line_ranges(content: &[u8]) -> Vec> { let c = content[i]; match (c, content.get(i + 1)) { - | (b'\r', Some(b'\n')) => { - // if \r found, check if its \r\n or if its a lone \r - // if \r\n, then treat as one line break - ranges.push(line_start..i + 1); - // because we already counted the \n byte, the next iter into it needs to be skipped - skip_next = true; - line_start = i + 2; - } - | _ => { - ranges.push(line_start..i); - line_start = i + 1; - } + | (b'\r', Some(b'\n')) => { + // if \r found, check if its \r\n or if its a lone \r + // if \r\n, then treat as one line break + ranges.push(line_start..i + 1); + // because we already counted the \n byte, the next iter into it needs to be skipped + skip_next = true; + line_start = i + 2; + } + | _ => { + ranges.push(line_start..i); + line_start = i + 1; + } } } @@ -101,28 +101,28 @@ pub(crate) fn sort_by_path(mut items: Vec, key: impl Fn(&T) -> &str) -> So let b_is_root_file = !b_path.contains('/'); match (a_is_root_file, b_is_root_file) { - | (true, false) => return std::cmp::Ordering::Greater, - | (false, true) => return std::cmp::Ordering::Less, - | _ => {} + | (true, false) => return std::cmp::Ordering::Greater, + | (false, true) => return std::cmp::Ordering::Less, + | _ => {} } let mut a_parts = a_path.split('/').peekable(); let mut b_parts = b_path.split('/').peekable(); loop { match (a_parts.next(), b_parts.next()) { - | (Some(a), Some(b)) => { - if a != b { - match (a_parts.peek().is_some(), b_parts.peek().is_some()) { - | (true, false) => return std::cmp::Ordering::Less, - | (false, true) => return std::cmp::Ordering::Greater, - | _ => {} + | (Some(a), Some(b)) => { + if a != b { + match (a_parts.peek().is_some(), b_parts.peek().is_some()) { + | (true, false) => return std::cmp::Ordering::Less, + | (false, true) => return std::cmp::Ordering::Greater, + | _ => {} + } + return a.cmp(b); } - return a.cmp(b); } - } - | (Some(_), None) => return std::cmp::Ordering::Greater, - | (None, Some(_)) => return std::cmp::Ordering::Less, - | (None, None) => return std::cmp::Ordering::Equal, + | (Some(_), None) => return std::cmp::Ordering::Greater, + | (None, Some(_)) => return std::cmp::Ordering::Less, + | (None, None) => return std::cmp::Ordering::Equal, } } }); @@ -222,67 +222,67 @@ pub(crate) fn build_file_tree( for path in paths.0.iter() { let path = key(path); match path.rsplit_once('/') { - | None => { - flush_leafs(&mut leafs, &stack, &mut items, emitted_depth, base_depth); - stack.clear(); - // top level file - items.push(FileTreeItem { - kind: FileTreeItemKind::File, - full_path: path.into(), - name: path.into(), - level: 0, - }); - } - - | Some((parent, _)) => { - let mut common_depth = 0; - - for (i, seg) in parent.split('/').enumerate() { - let stack_item = stack.get(i); - if stack_item.is_none() { - // segment is unseen, push to stack - stack.push(seg); - common_depth += 1; - } else if Some(&seg) == stack.get(i) { - // segment matches stack, continue comparison - common_depth += 1; - } else { - // segment differs from stack, stop comparison - break; - } + | None => { + flush_leafs(&mut leafs, &stack, &mut items, emitted_depth, base_depth); + stack.clear(); + // top level file + items.push(FileTreeItem { + kind: FileTreeItemKind::File, + full_path: path.into(), + name: path.into(), + level: 0, + }); } - if common_depth == stack.len() { - // current path is in same directory as stack, add to leafs - leafs.push(path); - base_depth = common_depth; - } else { - // e.g. stack = ["a", "b", "c"], path = ["a", "c"] - // common dir path = "a/", stack dir path = "a/b/c", common count = 1 - // push common dir a to items - // also push stack dir a/b/c to items but strip a from name so that it becomes "b/c" with level equal to common_count - // finally push any leaf under a/b/c + | Some((parent, _)) => { + let mut common_depth = 0; - let base_dir_created = - flush_leafs(&mut leafs, &stack, &mut items, emitted_depth, common_depth); + for (i, seg) in parent.split('/').enumerate() { + let stack_item = stack.get(i); + if stack_item.is_none() { + // segment is unseen, push to stack + stack.push(seg); + common_depth += 1; + } else if Some(&seg) == stack.get(i) { + // segment matches stack, continue comparison + common_depth += 1; + } else { + // segment differs from stack, stop comparison + break; + } + } - // pop top of stack minus common dir - stack.truncate(common_depth); - - if base_dir_created { - emitted_depth = common_depth; + if common_depth == stack.len() { + // current path is in same directory as stack, add to leafs + leafs.push(path); + base_depth = common_depth; } else { - emitted_depth = 0; - } + // e.g. stack = ["a", "b", "c"], path = ["a", "c"] + // common dir path = "a/", stack dir path = "a/b/c", common count = 1 + // push common dir a to items + // also push stack dir a/b/c to items but strip a from name so that it becomes "b/c" with level equal to common_count + // finally push any leaf under a/b/c - for seg in parent.split('/').skip(common_depth) { - stack.push(seg); - } + let base_dir_created = + flush_leafs(&mut leafs, &stack, &mut items, emitted_depth, common_depth); - leafs.push(path); + // pop top of stack minus common dir + stack.truncate(common_depth); + + if base_dir_created { + emitted_depth = common_depth; + } else { + emitted_depth = 0; + } + + for seg in parent.split('/').skip(common_depth) { + stack.push(seg); + } + + leafs.push(path); + } } } - } } flush_leafs(&mut leafs, &stack, &mut items, emitted_depth, base_depth);